jystervinou/vibe2phone

Run your iOS build on the iPhone in your hand, from anywhere — private OTA installs over Tailscale

0

stars

1

commits

Shell

primary language

Sep 9, 2026

updated

README

vibe2phone

You are vibecoding from your phone, far from the Mac running Xcode — and the one screen you are holding is the one Xcode cannot reach. Wireless debugging is lovely, but it still needs the phone on the same local network as the Mac. Three cities away, on cellular, that is simply not the case: the build you just prompted into existence is the single build you cannot run. You are left reading diffs, guessing at what the app looks like.

vibe2phone closes that loop over Tailscale, the mesh VPN that links your own devices wherever they are. Your Mac builds and signs the app, then serves it over HTTPS on your private tailnet — and your iPhone installs it with one tap, on Wi-Fi or on cellular, from anywhere. No TestFlight, no Diawi, no public host, nothing ever leaving your own network.

The install page: app icon, one Install latest build button, and the build details

cd ~/my-ios-project
vibe2phone
[8/8] End-to-end check
      ✓ manifest served as XML
      ✓ IPA downloadable over HTTPS (24M)

Done. On your iPhone, with Tailscale connected, open:

    https://your-mac.your-tailnet.ts.net/ota/

The address never changes. Each run simply swaps the build sitting behind it: run the command, reopen the page on the phone, tap Install latest build.

One file, nothing to install, nothing to configure. The script works out the scheme, the bundle ID, the signing team and the Tailscale hostname on its own.

The loop

You are on the couch, or on a train. Claude Code — or whatever agent you drive — is running on the Mac back home. The loop goes like this:

  1. From your phone, you ask for a change.
  2. The agent edits the code and runs ./vibe2phone.
  3. On your phone: reload the page, tap Install latest build.
  4. You look at the real app, and ask for the next change.

There is nothing to force and no second command to remember. One run of vibe2phone regenerates the project, archives, signs, exports, bumps the build number and swaps what the URL serves. Running XcodeGen alone is not enough — it only writes the .xcodeproj, it builds nothing.

Let the agent deploy on its own

Put this in your project's CLAUDE.md:

## Deploying to my phone

I test on my iPhone, away from this Mac. After any change I could see on screen,
run `./vibe2phone` so the new build reaches my phone.

That is usually the right amount of automation: the agent deploys when something visible changed, and skips it when it merely read a few files.

If you would rather it be unconditional, a Stop hook fires at the end of every turn. In .claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "cd \"$CLAUDE_PROJECT_DIR\" && ./vibe2phone" }
        ]
      }
    ]
  }
}

Be aware that this rebuilds even when the agent only answered a question, which costs a minute or two each time.

Requirements

1. Xcode

Installed from the App Store, and pointed at once:

sudo xcode-select -s /Applications/Xcode.app
xcodebuild -version          # should print a version

python3 comes with the Xcode Command Line Tools, so there is nothing else to install. XcodeGen is only needed if your project already uses a project.yml (brew install xcodegen).

2. An Apple developer account, with your iPhone registered

Free or paid, but the phone must be registered in the portal — its UDID has to be in the provisioning profile, or iOS refuses the install with a blank Unable to install. The easiest way to register it: plug the iPhone in once and run the app from Xcode; Xcode adds the device for you. vibe2phone checks this for you before publishing anything.

With a free account, apps stop launching after seven days. A paid account signs for a year.

3. Tailscale, with a working tailscale command

This is the one that trips people up. On macOS Tailscale comes in three flavours, and they do not all put the tailscale CLI on your PATH — the script needs it to serve the build.

Check what you have:

tailscale version

If that prints a version, you are done. If it prints command not found, pick one of these:

How you installed itWhere the CLI livesWhat to do
Homebrew/opt/homebrew/bin/tailscalebrew install tailscale && sudo brew services start tailscale
Standalone app (tailscale.com/download/mac)/usr/local/bin/tailscalealready on your PATH
Mac App Store appinside the app bundle onlyadd to ~/.zshrc:
alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale"

vibe2phone looks in all three places on its own, so it will usually work even when your shell cannot find the command — it prints which binary it picked:

[1/8] Preflight
      ✓ Xcode 26.6
      ✓ Tailscale 1.102.3 — /opt/homebrew/bin/tailscale

If none of them exists, it stops and tells you exactly what to install.

Then make sure the Mac is logged in and the iPhone is on the same tailnet:

tailscale up
tailscale status        # the iPhone should be in the list

Install Tailscale on the iPhone from the App Store and sign in with the same account. It can stay connected all the time — that is what lets the install work over cellular, away from home.

4. HTTPS enabled on your tailnet

The install page and the IPA are served over HTTPS with a real Let's Encrypt certificate, which iOS insists on. It is off by default:

Tailscale admin console → DNS → HTTPS Certificates → Enable

Without it, tailscale serve fails and the script says so.

That's it

The project itself can be a .xcworkspace, a .xcodeproj or an XcodeGen project.yml — the script adapts.

Install

For one project, drop the script at its root:

cd ~/my-ios-project
curl -fsSLO https://raw.githubusercontent.com/jystervinou/vibe2phone/main/vibe2phone
chmod +x vibe2phone
./vibe2phone

For every project, install it once:

sudo curl -fsSL https://raw.githubusercontent.com/jystervinou/vibe2phone/main/vibe2phone \
     -o /usr/local/bin/vibe2phone
sudo chmod +x /usr/local/bin/vibe2phone

Then, in any iOS project: vibe2phone.

To check a project is ready without waiting for a build:

cd ~/my-ios-project && vibe2phone --check
[2/2] Detecting the configuration
      ✓ project         MyApp.xcodeproj
      ✓ tailscale host  your-mac.your-tailnet.ts.net
      ✓ scheme          MyApp
      ✓ bundle          com.example.myapp
      ✓ team            ABCDE12345 (Automatic signing)

Every project gets its own local port and its own service, so they never collide. If you serve several at once, give them separate addresses:

OTA_PATH=/my-app vibe2phone

What the command does

  1. Preflight — Xcode, Tailscale, python3, kind of project
  2. XcodeGenxcodegen generate, only when a project.yml is there
  3. Detection — scheme, bundle ID, signing team, Tailscale host
  4. Archivexcodebuild archive in Release
  5. Export — IPA signed for Development (or ad hoc)
  6. Publishmanifest.plist, install page, numbered IPA
  7. Serve — loopback server exposed over HTTPS by tailscale serve
  8. Check — are the manifest and the IPA really reachable?

Everything lands in .vibe2phone/ at the project root, added to your .gitignore on the first run.

The install page

The page holds one blue button. Tap it and it stops being mute: the server knows the moment iOS fetches the IPA, so the page waits for that and tells you the build is on its way to the Home Screen. No more wondering whether the tap did anything — the usual failure mode of over-the-air installs, since Safari itself shows nothing while iOS installs in the background.

What gets detected

Nothing is hardcoded. Every value is worked out at run time, and any of them can be forced through the environment.

The project. A .xcworkspace wins over a .xcodeproj — that is what CocoaPods wants and the only one that knows every dependency. If a project.yml is lying around, XcodeGen runs first.

The scheme. Among shared schemes, anything smelling of tests or extensions (…Tests, …UITests, …Widget, …Watch) goes last, and the one named after the project goes first. The script keeps the first that genuinely builds an iOS application — checked in the resolved build settings, not guessed from the name.

The signing team. Two sources, crossed: non-expired Apple Development certificates in the keychain (their OU field carries the team ID) and the installed provisioning profiles. Profiles with no device — the App Store ones — are dropped, since they can install nothing over the air. The team kept is the one holding a valid certificate and the profile covering the most devices. If the project already pins DEVELOPMENT_TEAM, that wins, and manual signing is left alone: the script then reuses the profile the archive actually embedded.

The Tailscale host. Read from tailscale status --json, never cached — rename the machine and the URL follows on the next deploy.

Your iPhone. After the export, the script opens the IPA's embedded.mobileprovision, pulls the allowed UDIDs and compares them with the iPhones Xcode knows about. If yours is missing it says so — rather than leaving you with iOS's terse Unable to install.

Detected values are cached in .vibe2phone/config. vibe2phone --redetect starts over.

Options

vibe2phone              # the normal case
vibe2phone --check      # diagnose in seconds, no build
vibe2phone --redetect   # forget the cache, detect everything again
vibe2phone --clean      # start from an empty DerivedData
vibe2phone --no-serve   # write the files, leave Tailscale alone
vibe2phone --open       # open the install page on the Mac
vibe2phone --uninstall  # stop serving this project

Overrides:

TEAM_ID=XXXXXXXXXX SCHEME=MyApp vibe2phone
EXPORT_METHOD=ad-hoc vibe2phone      # instead of Development
OTA_PATH=/my-app OTA_PORT=9000 vibe2phone

How the private delivery works

iPhone ──HTTPS, Let's Encrypt cert for *.ts.net──> tailscaled (the Mac)
                                                        │ reverse proxy
                                                        ▼
                                                127.0.0.1:87xx
                                                        │
                                                        ▼
                                            .vibe2phone/public/

Two details make this necessary. tailscale serve pointed straight at a folder returns 403 — the daemon cannot read the files. And python3 -m http.server hands the manifest over as application/octet-stream, which iOS refuses. So the script writes a small server of its own: loopback only, text/xml for the manifest, no caching, and a /status.json the install page polls.

A LaunchAgent restarts it at login, and the tailscale serve configuration survives reboots, so the URL keeps working with nothing to relaunch by hand. Nothing is exposed to the public internet — only devices on your tailnet can reach it.

Who can install it

Two independent locks, and you can check both yourself.

The network. tailscale serve publishes to your tailnet only — never to the public internet, which is what its sibling tailscale funnel does. vibe2phone never calls funnel. From a public resolver your hostname does not even exist:

tailscale serve status                     # says "(tailnet only)"
dig @8.8.8.8 your-mac.your-tailnet.ts.net  # NXDOMAIN

The name only resolves through MagicDNS, inside the tailnet, and the address behind it sits in the CGNAT range — unroutable from the internet. There is no open port for anyone to find.

The signature. Even holding the IPA, a stranger cannot install it. The embedded provisioning profile names the UDIDs allowed, and iOS refuses every other device — the very check vibe2phone runs before it publishes anything.

So the trust boundary is your tailnet. If you share a node with someone, or add a user to the tailnet, they can open the page; they can still only install if their device is one of your registered UDIDs. Tailscale ACLs narrow it further if you need that.

When it goes wrong

"Unable to install". Nine times out of ten the iPhone's UDID is not in the profile. Add the device on developer.apple.com, then vibe2phone --clean.

The page does not open on the iPhone. Is Tailscale connected there (tailscale status should list the phone), and is the Mac awake? It is the one hosting the IPA.

Safari downloads a file instead of installing. itms-services: links only work in Safari on iOS — not Chrome, not an in-app browser.

The tap seems to do nothing. It probably worked. iOS installs in the background and puts the icon on your Home Screen, often on the last page or in the App Library, while Safari shows nothing. The status line under the button now confirms it.

tailscale serve failed. HTTPS is not enabled for the tailnet: admin console → DNS → HTTPS Certificates.

The app expires after seven days. That is the free developer account limit. A paid account signs for a year.

Example

example/ holds a minimal SwiftUI app that displays its own version and build number — enough to confirm at a glance which build is running on the phone.

cd example && ../vibe2phone

License

MIT

Contributors

jystervinou

1 commits

jystervinou/vibe2phone

Run your iOS build on the iPhone in your hand, from anywhere — private OTA installs over Tailscale

0

stars

1

commits

Shell

primary language

Sep 9, 2026

updated

README

vibe2phone

You are vibecoding from your phone, far from the Mac running Xcode — and the one screen you are holding is the one Xcode cannot reach. Wireless debugging is lovely, but it still needs the phone on the same local network as the Mac. Three cities away, on cellular, that is simply not the case: the build you just prompted into existence is the single build you cannot run. You are left reading diffs, guessing at what the app looks like.

vibe2phone closes that loop over Tailscale, the mesh VPN that links your own devices wherever they are. Your Mac builds and signs the app, then serves it over HTTPS on your private tailnet — and your iPhone installs it with one tap, on Wi-Fi or on cellular, from anywhere. No TestFlight, no Diawi, no public host, nothing ever leaving your own network.

The install page: app icon, one Install latest build button, and the build details

cd ~/my-ios-project
vibe2phone
[8/8] End-to-end check
      ✓ manifest served as XML
      ✓ IPA downloadable over HTTPS (24M)

Done. On your iPhone, with Tailscale connected, open:

    https://your-mac.your-tailnet.ts.net/ota/

The address never changes. Each run simply swaps the build sitting behind it: run the command, reopen the page on the phone, tap Install latest build.

One file, nothing to install, nothing to configure. The script works out the scheme, the bundle ID, the signing team and the Tailscale hostname on its own.

The loop

You are on the couch, or on a train. Claude Code — or whatever agent you drive — is running on the Mac back home. The loop goes like this:

  1. From your phone, you ask for a change.
  2. The agent edits the code and runs ./vibe2phone.
  3. On your phone: reload the page, tap Install latest build.
  4. You look at the real app, and ask for the next change.

There is nothing to force and no second command to remember. One run of vibe2phone regenerates the project, archives, signs, exports, bumps the build number and swaps what the URL serves. Running XcodeGen alone is not enough — it only writes the .xcodeproj, it builds nothing.

Let the agent deploy on its own

Put this in your project's CLAUDE.md:

## Deploying to my phone

I test on my iPhone, away from this Mac. After any change I could see on screen,
run `./vibe2phone` so the new build reaches my phone.

That is usually the right amount of automation: the agent deploys when something visible changed, and skips it when it merely read a few files.

If you would rather it be unconditional, a Stop hook fires at the end of every turn. In .claude/settings.json:

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          { "type": "command", "command": "cd \"$CLAUDE_PROJECT_DIR\" && ./vibe2phone" }
        ]
      }
    ]
  }
}

Be aware that this rebuilds even when the agent only answered a question, which costs a minute or two each time.

Requirements

1. Xcode

Installed from the App Store, and pointed at once:

sudo xcode-select -s /Applications/Xcode.app
xcodebuild -version          # should print a version

python3 comes with the Xcode Command Line Tools, so there is nothing else to install. XcodeGen is only needed if your project already uses a project.yml (brew install xcodegen).

2. An Apple developer account, with your iPhone registered

Free or paid, but the phone must be registered in the portal — its UDID has to be in the provisioning profile, or iOS refuses the install with a blank Unable to install. The easiest way to register it: plug the iPhone in once and run the app from Xcode; Xcode adds the device for you. vibe2phone checks this for you before publishing anything.

With a free account, apps stop launching after seven days. A paid account signs for a year.

3. Tailscale, with a working tailscale command

This is the one that trips people up. On macOS Tailscale comes in three flavours, and they do not all put the tailscale CLI on your PATH — the script needs it to serve the build.

Check what you have:

tailscale version

If that prints a version, you are done. If it prints command not found, pick one of these:

How you installed itWhere the CLI livesWhat to do
Homebrew/opt/homebrew/bin/tailscalebrew install tailscale && sudo brew services start tailscale
Standalone app (tailscale.com/download/mac)/usr/local/bin/tailscalealready on your PATH
Mac App Store appinside the app bundle onlyadd to ~/.zshrc:
alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale"

vibe2phone looks in all three places on its own, so it will usually work even when your shell cannot find the command — it prints which binary it picked:

[1/8] Preflight
      ✓ Xcode 26.6
      ✓ Tailscale 1.102.3 — /opt/homebrew/bin/tailscale

If none of them exists, it stops and tells you exactly what to install.

Then make sure the Mac is logged in and the iPhone is on the same tailnet:

tailscale up
tailscale status        # the iPhone should be in the list

Install Tailscale on the iPhone from the App Store and sign in with the same account. It can stay connected all the time — that is what lets the install work over cellular, away from home.

4. HTTPS enabled on your tailnet

The install page and the IPA are served over HTTPS with a real Let's Encrypt certificate, which iOS insists on. It is off by default:

Tailscale admin console → DNS → HTTPS Certificates → Enable

Without it, tailscale serve fails and the script says so.

That's it

The project itself can be a .xcworkspace, a .xcodeproj or an XcodeGen project.yml — the script adapts.

Install

For one project, drop the script at its root:

cd ~/my-ios-project
curl -fsSLO https://raw.githubusercontent.com/jystervinou/vibe2phone/main/vibe2phone
chmod +x vibe2phone
./vibe2phone

For every project, install it once:

sudo curl -fsSL https://raw.githubusercontent.com/jystervinou/vibe2phone/main/vibe2phone \
     -o /usr/local/bin/vibe2phone
sudo chmod +x /usr/local/bin/vibe2phone

Then, in any iOS project: vibe2phone.

To check a project is ready without waiting for a build:

cd ~/my-ios-project && vibe2phone --check
[2/2] Detecting the configuration
      ✓ project         MyApp.xcodeproj
      ✓ tailscale host  your-mac.your-tailnet.ts.net
      ✓ scheme          MyApp
      ✓ bundle          com.example.myapp
      ✓ team            ABCDE12345 (Automatic signing)

Every project gets its own local port and its own service, so they never collide. If you serve several at once, give them separate addresses:

OTA_PATH=/my-app vibe2phone

What the command does

  1. Preflight — Xcode, Tailscale, python3, kind of project
  2. XcodeGenxcodegen generate, only when a project.yml is there
  3. Detection — scheme, bundle ID, signing team, Tailscale host
  4. Archivexcodebuild archive in Release
  5. Export — IPA signed for Development (or ad hoc)
  6. Publishmanifest.plist, install page, numbered IPA
  7. Serve — loopback server exposed over HTTPS by tailscale serve
  8. Check — are the manifest and the IPA really reachable?

Everything lands in .vibe2phone/ at the project root, added to your .gitignore on the first run.

The install page

The page holds one blue button. Tap it and it stops being mute: the server knows the moment iOS fetches the IPA, so the page waits for that and tells you the build is on its way to the Home Screen. No more wondering whether the tap did anything — the usual failure mode of over-the-air installs, since Safari itself shows nothing while iOS installs in the background.

What gets detected

Nothing is hardcoded. Every value is worked out at run time, and any of them can be forced through the environment.

The project. A .xcworkspace wins over a .xcodeproj — that is what CocoaPods wants and the only one that knows every dependency. If a project.yml is lying around, XcodeGen runs first.

The scheme. Among shared schemes, anything smelling of tests or extensions (…Tests, …UITests, …Widget, …Watch) goes last, and the one named after the project goes first. The script keeps the first that genuinely builds an iOS application — checked in the resolved build settings, not guessed from the name.

The signing team. Two sources, crossed: non-expired Apple Development certificates in the keychain (their OU field carries the team ID) and the installed provisioning profiles. Profiles with no device — the App Store ones — are dropped, since they can install nothing over the air. The team kept is the one holding a valid certificate and the profile covering the most devices. If the project already pins DEVELOPMENT_TEAM, that wins, and manual signing is left alone: the script then reuses the profile the archive actually embedded.

The Tailscale host. Read from tailscale status --json, never cached — rename the machine and the URL follows on the next deploy.

Your iPhone. After the export, the script opens the IPA's embedded.mobileprovision, pulls the allowed UDIDs and compares them with the iPhones Xcode knows about. If yours is missing it says so — rather than leaving you with iOS's terse Unable to install.

Detected values are cached in .vibe2phone/config. vibe2phone --redetect starts over.

Options

vibe2phone              # the normal case
vibe2phone --check      # diagnose in seconds, no build
vibe2phone --redetect   # forget the cache, detect everything again
vibe2phone --clean      # start from an empty DerivedData
vibe2phone --no-serve   # write the files, leave Tailscale alone
vibe2phone --open       # open the install page on the Mac
vibe2phone --uninstall  # stop serving this project

Overrides:

TEAM_ID=XXXXXXXXXX SCHEME=MyApp vibe2phone
EXPORT_METHOD=ad-hoc vibe2phone      # instead of Development
OTA_PATH=/my-app OTA_PORT=9000 vibe2phone

How the private delivery works

iPhone ──HTTPS, Let's Encrypt cert for *.ts.net──> tailscaled (the Mac)
                                                        │ reverse proxy
                                                        ▼
                                                127.0.0.1:87xx
                                                        │
                                                        ▼
                                            .vibe2phone/public/

Two details make this necessary. tailscale serve pointed straight at a folder returns 403 — the daemon cannot read the files. And python3 -m http.server hands the manifest over as application/octet-stream, which iOS refuses. So the script writes a small server of its own: loopback only, text/xml for the manifest, no caching, and a /status.json the install page polls.

A LaunchAgent restarts it at login, and the tailscale serve configuration survives reboots, so the URL keeps working with nothing to relaunch by hand. Nothing is exposed to the public internet — only devices on your tailnet can reach it.

Who can install it

Two independent locks, and you can check both yourself.

The network. tailscale serve publishes to your tailnet only — never to the public internet, which is what its sibling tailscale funnel does. vibe2phone never calls funnel. From a public resolver your hostname does not even exist:

tailscale serve status                     # says "(tailnet only)"
dig @8.8.8.8 your-mac.your-tailnet.ts.net  # NXDOMAIN

The name only resolves through MagicDNS, inside the tailnet, and the address behind it sits in the CGNAT range — unroutable from the internet. There is no open port for anyone to find.

The signature. Even holding the IPA, a stranger cannot install it. The embedded provisioning profile names the UDIDs allowed, and iOS refuses every other device — the very check vibe2phone runs before it publishes anything.

So the trust boundary is your tailnet. If you share a node with someone, or add a user to the tailnet, they can open the page; they can still only install if their device is one of your registered UDIDs. Tailscale ACLs narrow it further if you need that.

When it goes wrong

"Unable to install". Nine times out of ten the iPhone's UDID is not in the profile. Add the device on developer.apple.com, then vibe2phone --clean.

The page does not open on the iPhone. Is Tailscale connected there (tailscale status should list the phone), and is the Mac awake? It is the one hosting the IPA.

Safari downloads a file instead of installing. itms-services: links only work in Safari on iOS — not Chrome, not an in-app browser.

The tap seems to do nothing. It probably worked. iOS installs in the background and puts the icon on your Home Screen, often on the last page or in the App Library, while Safari shows nothing. The status line under the button now confirms it.

tailscale serve failed. HTTPS is not enabled for the tailnet: admin console → DNS → HTTPS Certificates.

The app expires after seven days. That is the free developer account limit. A paid account signs for a year.

Example

example/ holds a minimal SwiftUI app that displays its own version and build number — enough to confirm at a glance which build is running on the phone.

cd example && ../vibe2phone

License

MIT

Contributors

jystervinou

1 commits

Languages

Shell

95.7%

Swift

4.3%