Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,13 @@ same lifecycle ownership.
| `--provider auto` (default) | Detect and start every installed provider |
| `--provider tailscale-serve` | Keep the link private to your tailnet |
| `--provider tailscale-funnel` | Create a public Tailscale link |
| `--https-port PORT` | Tailscale Serve port; auto mode picks another supported Funnel port; `--funnel-port` is a compatibility alias |
| `--https-port PORT` | Require an exact Tailscale HTTPS port; when omitted, select an available port; `--funnel-port` is a compatibility alias |

Use either `--expire-after` or `--timeout`, not both. Run `remote-installer share --help` for every option.

With the default `--provider auto`, Remote Installer checks for the Tailscale and cloudflared CLIs, starts every provider it can use in parallel, and prints a warning for each unavailable or unready provider. Tailscale Serve and Funnel use different HTTPS ports automatically so both can run in the same share. If you select one provider explicitly, only that provider is started. The terminal labels every result as `Public internet` or `Tailnet only` so the access boundary is visible next to the URL.
With the default `--provider auto`, Remote Installer checks for the Tailscale and cloudflared CLIs, starts every provider it can use, and prints a warning for each unavailable or unready provider. Tailscale Serve and Funnel use different HTTPS ports automatically so both can run in the same share. Tailscale configuration writes are serialized while Cloudflare starts independently. If you select one provider explicitly, only that provider is started. The terminal labels every result as `Public internet` or `Tailnet only` so the access boundary is visible next to the URL.

Remote Installer does not overwrite an existing Tailscale Serve or Funnel configuration. Auto mode warns and skips Tailscale while another available provider can continue; explicitly selecting the conflicting Tailscale mode returns an error.
Remote Installer does not overwrite existing Tailscale Serve or Funnel routes. When `--https-port` is omitted, concurrent shares reserve different available ports on the node. An explicitly requested occupied port returns an error instead of replacing its route. Stopping a share closes only the foreground sessions owned by that process; it never runs a global `serve reset`.

Tailscale Serve requires the phone to be on the same tailnet (or otherwise allowed by its access policy). Tailscale Funnel creates a public link and does not require Tailscale on the phone. The older `--provider tailscale` spelling is kept as an alias for `tailscale-funnel`; use the explicit provider name in new commands.

Expand Down
59 changes: 39 additions & 20 deletions skills/remote-installer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ name: remote-installer
description: >-
Put an iOS or Android build on a real phone or tablet over the air with the
`remote-installer` CLI — it validates the build, opens a temporary HTTPS
tunnel, and prints an install URL plus a QR code to scan. Use this whenever
tunnel, and prints one or more install URLs plus QR codes to scan. Use this
whenever
someone wants a build onto a physical device without TestFlight or a cable:
"get this on my phone", "send this build to a tester", "share the IPA or APK",
"install this on my iPad", "let QA try this build", "make a link for this
Expand All @@ -16,10 +17,11 @@ description: >-

# Sharing a mobile build over the air

`remote-installer share <build>` validates an iOS or Android build, stands up a
temporary HTTPS tunnel in front of a loopback server, and prints an install
page URL plus a QR code. iOS uses `itms-services://`; Android downloads a signed
standalone APK for the system installer. Stopping the process kills the link.
`remote-installer share <build>` validates an iOS or Android build, stands up
temporary HTTPS tunnels in front of a loopback server, and prints an install
page URL plus a QR code for every provider that becomes ready. iOS uses
`itms-services://`; Android downloads a signed standalone APK for the system
installer. Stopping the process kills the links.

The published CLI is currently macOS only. iOS `.app` handling shells out to
Apple system tools. APK handling uses Android SDK `apkanalyzer` and `apksigner`
Expand Down Expand Up @@ -102,14 +104,16 @@ a source checkout or binary path.
`cloudflared` (`brew install cloudflared`) and Tailscale (`brew install
--cask tailscale`), starts every provider that is available, and warns about
the rest. No Cloudflare account is needed for the Quick Tunnel. Select one
provider explicitly when you need only that route. Auto mode may print several
working links; they are alternate origins for one staged artifact, one download
quota, and one lifecycle rather than separate copies.
provider explicitly only when the user requests it or an access requirement
calls for one route. Otherwise keep the default auto mode. Auto mode may print
several working links; they are alternate origins for one staged artifact, one
download quota, and one lifecycle rather than separate copies.

Remote Installer refuses to replace an existing Tailscale Serve or Funnel
configuration. Auto mode warns and skips Tailscale while another available
provider can continue; an explicitly selected Tailscale provider reports the
conflict and stops. Do not reset the user's existing configuration to force it.
Remote Installer preserves existing Tailscale Serve and Funnel routes. When
`--https-port` is omitted, concurrent shares reserve different available ports
on the node. An explicitly requested occupied port reports the conflict instead
of replacing its route. Do not reset the user's existing configuration to
force it.

For APKs, ensure Android SDK Command-Line Tools and Build Tools are installed.
If automatic discovery fails, pass `--apkanalyzer-bin` and `--apksigner-bin`.
Expand All @@ -132,8 +136,9 @@ remote-installer share /path/to/MyApp.ipa \
```

This also works through `npx --yes @icodesign/remote-installer`. The returned
JSON contains the share ID and ready install URLs. Do not send a URL before the
command reports a ready session.
JSON contains the share ID and a `links` array of ready provider results. Do not
send any URL before the command reports a ready session, and do not collapse
that array to its first entry.

**Set `--timeout` on essentially every run.** It takes plain seconds and shuts
the whole thing down when it elapses — tunnel closed, temporary copy deleted.
Expand Down Expand Up @@ -171,8 +176,8 @@ writing a command a human will read. Passing both is an error, so pick one.
Other flags worth knowing: `--provider tailscale-serve` (private to the
tailnet), `--provider tailscale-funnel` (public through Tailscale), and
`--provider tailscale` (the compatibility alias for Funnel), `--https-port`
(the Serve port in auto mode; `--funnel-port` is its visible compatibility
alias), `--no-qr`,
(require an exact Tailscale port instead of automatic allocation;
`--funnel-port` is its visible compatibility alias), `--no-qr`,
`--cloudflared-bin`, and `--tailscale-bin`.

## Reading the output
Expand Down Expand Up @@ -201,8 +206,22 @@ provider is reported as a warning while the other links remain usable. Read the
For Android, `Requires` contains an API level and `Install link` is the granted
HTTPS `download.apk` URL. Give the user the install page in either case.

Give the user the **Install page** URL. That's the one to open on the phone and
the one to paste into a message.
Return **every ready Install page URL** to the user, not just the first or a
preferred provider. Label each URL with its provider and access scope (`Public
internet` or `Tailnet only`) so the user can choose which route to open. A
provider warning is not a reason to omit the other successful links. If only
one provider becomes ready, return that one and briefly mention that it was the
only available route.

The **Install page** URLs are the ones to open on the phone and paste into a
message. Do not substitute the native `Install link` values. A concise reply
with multiple results can look like:

```text
- Cloudflare Quick Tunnel (Public internet): https://.../install/...
- Tailscale Serve (Tailnet only): https://.../install/...
- Tailscale Funnel (Public internet): https://.../install/...
```

The QR code is terminal art printed below that banner. Don't try to reproduce
it in your reply — say it's in their terminal and to scan it with the phone
Expand All @@ -223,8 +242,8 @@ Download complete: MyApp.ipa (214.6 MB in 38s)
Download interrupted: MyApp.ipa at 62% (133.1 MB / 214.6 MB)
```

If the user asks whether it worked, inspect the managed session rather than
guessing:
If the user asks whether it worked or asks for the links again, inspect the
managed session rather than guessing, and return every ready provider link:

```bash
remote-installer status <share-id>
Expand Down
15 changes: 11 additions & 4 deletions src/background.rs
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,6 @@ fn worker_arguments(args: &ShareArgs, executable: String) -> Vec<String> {
.to_string(),
"--provider".to_owned(),
args.provider.cli_name().to_owned(),
"--https-port".to_owned(),
args.https_port.to_string(),
"--listen".to_owned(),
args.listen.to_string(),
"--no-qr".to_owned(),
Expand All @@ -320,6 +318,9 @@ fn worker_arguments(args: &ShareArgs, executable: String) -> Vec<String> {
.display()
.to_string(),
];
if let Some(port) = args.https_port {
result.extend(["--https-port".to_owned(), port.to_string()]);
}
if let Some(maximum) = args.max_downloads {
result.extend(["--max-downloads".to_owned(), maximum.to_string()]);
}
Expand Down Expand Up @@ -630,7 +631,7 @@ mod tests {
args.background = false;
args.no_qr = true;
args.managed_session = Some(directory.to_owned());
args
*args
}

fn state() -> SessionState {
Expand Down Expand Up @@ -716,6 +717,12 @@ mod tests {
assert!(joined.contains("--max-downloads 1"), "{joined}");
assert!(joined.contains("--provider tailscale-serve"), "{joined}");
assert!(joined.contains("--no-qr"), "{joined}");
assert!(!joined.contains("--https-port"), "{joined}");

let mut explicit = background_args(temporary.path());
explicit.https_port = Some(10001);
let joined = worker_arguments(&explicit, "/native/remote-installer".to_owned()).join(" ");
assert!(joined.contains("--https-port 10001"), "{joined}");
}

#[test]
Expand All @@ -736,7 +743,7 @@ mod tests {
else {
panic!("share command")
};
let error = start(args).await.unwrap_err().to_string();
let error = start(*args).await.unwrap_err().to_string();
assert!(
error.contains("requires --expire-after or --timeout"),
"{error}"
Expand Down
Loading
Loading