agentleFS
sp-hk2ldnsp-hk2ldn/SKILLS/ios-ota-install/SKILL.md

ios-ota-install

Skill0 starsChanged 5 days agoMITView on GitHub

Install a development build of an iOS app on your own phone over the air, from a Safari link served on your Tailscale tailnet. Use when asked to deploy, redeploy or install a build on the phone and the phone is not reachable by Xcode or devicectl (for example it is away from the Mac's Wi-Fi, or the device shows "unavailable"). Also covers what does and does not survive this route.

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Only devices on the tailnet can open the link, and only devices in the app's provisioning profile can install it.

    line 13
  2. Very specific:

    All routes share https://<mac>.<tailnet>.ts.net:8443 (tailnet only).

    line 70
  3. Distinctive:

    If the Mac restarts, the next publish restarts the server.

    line 78
  4. Distinctive:

    Remove an app's route and stop its server when it is no longer needed:

    line 93
  5. Very specific:

    Delete ~/ota-serve/<slug>/ only when the builds are no longer needed for reinstalling.

    line 98

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

iOS over-the-air install (Safari link on the tailnet)

Xcode and devicectl find phones by local-network discovery, which Tailscale does not carry, so they cannot install over Tailscale (CoreDevice error 1011, device "unavailable"). This route works instead: package the device-signed app as an IPA, serve an itms-services install page through Tailscale Serve on the Mac, and send the user the link. They tap Install in Safari on the phone.

Only devices on the tailnet can open the link, and only devices in the app's provisioning profile can install it. Nothing goes to Apple, TestFlight or the public internet. Never use tailscale funnel for this.

Why not the usual routes

  • Xcode or devicectl over Tailscale: they find phones by local-network discovery (Bonjour), which Tailscale does not carry, so the phone shows as "unavailable".
  • TestFlight: needs an upload, processing and sometimes review. Minutes to hours, and the build leaves your machine.
  • Third-party hosts (Diawi and similar): the IPA goes to someone else's server behind a public link.

This route takes seconds, stays on your own tailnet and needs nothing beyond Tailscale.

Requirements

  • macOS with Xcode command-line tools (xcodebuild, xcrun devicectl, codesign, security).
  • Tailscale running and logged in on both the Mac and the phone, with HTTPS certificates enabled for the tailnet (Tailscale Serve needs them).
  • An app signed with a development (or ad hoc) provisioning profile that lists the phone.
  • Python 3 (standard library only).

Steps

  1. Check where the phone is. xcrun devicectl list devices. If it shows available or connected, install directly instead (faster). If it is unavailable, use this route. tailscale status should list the phone as online.
  2. Build a device-signed app from the working tree, as usual (xcodebuild for generic/platform=iOS, or an equivalent device build). Use the same scheme and configuration you would install directly. The result is .../Build/Products/<Config>-iphoneos/<App>.app.
  3. Get the phone's hardware UDID so the script can check the profile includes it:
    xcrun devicectl list devices --json-output /tmp/devices.json
    # result.devices[].hardwareProperties.udid, matched on deviceProperties.name
    This works even while the phone is unavailable.
  4. Publish:
    python3 scripts/ota_publish.py \
      --app /path/to/App.app --slug <app-slug> --title "<App Name>" \
      --port <port> --udid <UDID>
    It verifies the signature, the profile's expiry and that the UDID is listed; packages the IPA; writes the manifest and install page; starts a localhost-only file server if needed; adds the Tailscale Serve route if needed; then downloads the page, manifest and IPA over the tailnet URL and checks the IPA hash. It prints JSON with install_page.
  5. Send the user the install_page link, and tell them: keep Tailscale connected on the phone, open the link in Safari, tap Install, and install over the existing app (do not delete it unless they want a fresh start; installing over it keeps its data).
  6. Report honestly. A published, read-back link is not an installed app. Installation and launch are confirmed only when the user says so.

Re-running step 4 for a new build replaces index.html and keeps the three newest IPAs.

Ports and paths

All routes share https://<mac>.<tailnet>.ts.net:8443 (tailnet only). Pick an unused local port per app and keep it stable, and record it here so later runs reuse it:

SlugLocal portApp
/my-app-dev8771Example app (com.example.myapp.dev)

Files live in ~/ota-serve/<slug>/ (outside every repo). The server log is server.log there. If the Mac restarts, the next publish restarts the server; the Serve route persists.

What this route cannot do

  • No launch arguments. Flags passed by Xcode or devicectl are absent. Features gated on them are off unless the app has another switch.
  • No StoreKit configuration file. Purchases go to the real App Store sandbox for that bundle, so paywalls only load if that app's products exist in App Store Connect. Test paywalls that rely on a local StoreKit file by running from Xcode instead.
  • Device must be in the profile. A new phone needs registering and the profile regenerating, which is a signing change: ask first.
  • Not for App Store or TestFlight. This is development distribution to your own devices.

Cleanup

Remove an app's route and stop its server when it is no longer needed:

tailscale serve --https=8443 --set-path=/<slug> off
lsof -nP -t -iTCP:<port> -sTCP:LISTEN | xargs kill

Delete ~/ota-serve/<slug>/ only when the builds are no longer needed for reinstalling.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.