diff --git a/docs/PLAN_IOS_REACHABILITY_v1.0.md b/docs/PLAN_IOS_REACHABILITY_v1.0.md new file mode 100644 index 0000000..cbb1b05 --- /dev/null +++ b/docs/PLAN_IOS_REACHABILITY_v1.0.md @@ -0,0 +1,132 @@ +# PLAN — iOS reachability: "always reach Lisa" (v1.0) + +**Problem (from a real TestFlight device):** Lisa Pocket paired to the Mac shows a +full-screen **"Can't reach Lisa"** dumping a raw `NSError` — +`NSURLErrorDomain Code=-999 "cancelled"`, failing URL +`http://192.168.3.42:5757/api/agents/sessions` — while the phone is on **5G**. + +Three distinct defects stack up here; this plan fixes all three, with a 正反方 +debate on *how far* to go. + +## Root cause (three layers) + +1. **LAN-only pairing.** `lisa pair` stores the Mac's **LAN IP** (`192.168.3.42`). + The Mac-side host pickers (`src/web/pairing.ts` `detectLanHost`, + `PairController.swift`) rank `en*` (Wi-Fi/Ethernet) high and `utun*` (where + **Tailscale** lives) low — so a LAN address is chosen, which is **only + reachable on that same Wi-Fi**. Off Wi-Fi (cellular / other network) it's dead. +2. **Raw error dumped to the user.** `RosterView.swift:16` sets + `self.error = (error as? LocalizedError)?.errorDescription ?? "\(error)"`; a + plain `NSError` isn't a `LocalizedError`, so `"\(error)"` prints the brutal + `Error Domain=NSURLErrorDomain Code=-999 …`. The **onboarding** already has a + friendly, classified message (`VerifyOutcome` → `wifi.exclamationmark`, "Is + this iPhone on the same Wi-Fi?… A Tailscale tailnet name works too."), but the + in-tab errors don't reuse it. +3. **`-999` shown as fatal.** `NSURLErrorCancelled(-999)` is thrown by + `LisaClient.decode` (`session.data(for:)`) when a request is cancelled — by a + network switch, or by SwiftUI cancelling `RosterView`'s `.task(id: app.config)` + on re-render, or by a pull-to-refresh superseding an in-flight load. It's a + **transient/ignorable** signal, not an error worth a full-screen takeover. + There's also **no request timeout** (uses `.shared`, 60 s default), so an + unreachable host hangs then fails ambiguously. And the app has **no + `NWPathMonitor`** — zero awareness of Wi-Fi-vs-cellular or a private-IP host. + +## The thesis (unchanged): local-first, reachable everywhere + +The fix must not betray "your AI on your machine." Per +[PLAN_IDENTITY_v1.0.md](PLAN_IDENTITY_v1.0.md) Decision 2, the private +always-reachable path is **Tailscale** (E2E, no data in any cloud) — not a +LISA-run relay. So: **guide users onto Tailscale, tell the truth on failure, and +never crash on a cancelled request.** + +## The three fixes + +### Fix 1 — Tailscale-first pairing (Mac side) +Detect a **Tailscale address** (the `100.64.0.0/10` CGNAT range Tailscale +assigns) and offer it as the **"reachable anywhere"** host, alongside the LAN IP. +- `src/web/pairing.ts`: add `isTailscaleIPv4()` + `detectTailscaleHost()`. Keep + `detectLanHost` for the same-Wi-Fi case. +- `lisa pair` (`src/cli/pair.ts`): when a Tailscale address is present, print a + prominent recommendation — *"To use Lisa away from home, pair over Tailscale: + `<100.x>` (make sure this iPhone is also on Tailscale), then `lisa pair --host + <100.x>`"* — and still show the LAN QR as the default. **Do not silently switch + the default to Tailscale** (the Mac can't know the *phone* is on Tailscale; + forcing it would break plain same-Wi-Fi users — see debate). +- Mirror the detection in `PairController.swift` (Mac app's native QR window) and + surface a "Reachable anywhere (Tailscale)" toggle. + +### Fix 2 — Honest, actionable reachability errors (iOS side) +Stop dumping `NSError`. Add a shared classifier + view reused by every tab. +- New `Sources/ConnectionError.swift`: `classify(_ error:, config:) -> + ConnectionProblem` mapping `NSURLError` codes → cases: + `cancelled` (‑999, transient — see Fix 3), `cannotReach` (‑1001/‑1003/‑1004/ + ‑1005/‑1009), `unauthorized` (401/403), `serverError(code)`. Each case carries a + friendly `title` + `message` + `actions`. +- **Private-IP awareness**: `ServerConfig.isPrivateLAN` (host in `192.168/16`, + `10/8`, `172.16/12`). When `cannotReach` **and** the host is a private LAN IP, + the message becomes: *"This looks like a home-Wi-Fi address (192.168.…). If + you've left that Wi-Fi, reach your Mac with Tailscale, or switch to LISA + Cloud."* with one-tap **"Use LISA Cloud"** + **"Re-pair"** actions. +- Fix `RosterView.swift:16` (and Home/Chat) to render the classified + `ConnectionProblem`, not `"\(error)"`. Reuse the same view the onboarding uses. + +### Fix 3 — Don't treat a cancelled request as a failure (iOS side) +- In `RosterModel.load` (and the shared classifier), **`NSURLErrorCancelled` + (‑999) ⇒ do not set an error** — a newer load/task is coming, or the network + just changed; let the SSE reconnect + `.task` re-run recover silently. +- Give `LisaClient` a `URLSessionConfiguration` with + `timeoutIntervalForRequest = 10s` so an unreachable host fails **fast + clean** + (`timedOut`), classifiable by Fix 2 instead of hanging or masking as ‑999. + +## 正反方辩论 / Pro–Con debate + +### 正方 — FOR (do all three, now) +- **Reachability is the #1 local-edition churn point.** "It worked at home, now it + says Can't reach Lisa with a scary error" is a trust-killer on a *just-shipped* + TestFlight build. Table stakes. +- **Tailscale is the honest always-on answer** — private, E2E, no cloud; it *is* + the local-first reachability story. We already recommend it in help text; we're + just making the Mac *surface the address* so users don't hand-type a tailnet DNS + name. +- **Fixes 2 & 3 are unambiguous quality bugs.** Dumping `NSError` and crashing the + tab on a cancelled request are defects with no downside to fixing. + +### 反方 — AGAINST (or: don't over-reach) +- **Tailscale is a third-party dependency on *both* devices.** The Mac can detect + its *own* Tailscale IP but **cannot know if the phone is on Tailscale** — so a + Tailscale address could be just as unreachable as the LAN IP, only more + confusing. Defaulting/forcing it would break plain same-Wi-Fi users. +- **A relay would be more seamless** (zero user setup) — but a *blind E2E* relay is + ~rebuilding Tailscale (big infra), and a non-blind one puts user data through a + LISA server, betraying the thesis. Rejected in PLAN_IDENTITY; still rejected. +- **`NWPathMonitor` + live network awareness is scope creep** for v1 — nice for + "you're on cellular now" banners, but not needed to fix the reported bug. +- **iOS churn risk.** The iOS app is under heavy parallel redesign; broad edits to + RosterView/LisaClient risk conflicts. + +### 裁决 / Synthesis +- **Fix 2 + Fix 3: yes, fully** — pure quality fixes, no thesis tension, directly + kill the reported screenshot. Highest priority. +- **Fix 1: yes, but *offer* not *force*.** Detect + prominently recommend + Tailscale on the Mac (CLI now, app window next); keep LAN the default QR. The + phone-side Tailscale requirement is surfaced in copy, not assumed. +- **Defer**: `NWPathMonitor` live banners, a relay, and any auto-switch to Cloud. + +## Phasing + +| Phase | What | Risk | +| --- | --- | --- | +| **R1 (this PR)** | Mac `pairing.ts` Tailscale detect + `lisa pair` offer (+ tests); iOS `ConnectionError` classifier + friendly view; RosterView no-raw-dump + ‑999-ignore; `LisaClient` 10s timeout; `ServerConfig.isPrivateLAN` | low–med | +| **R2** | `PairController.swift` Tailscale toggle in the Mac app's Pair window | low | +| **R3** | `NWPathMonitor`: a live "you're off your Mac's Wi-Fi" banner + auto-suggest Cloud/Tailscale | med | +| **R4** | One-tap "Use LISA Cloud" that carries the existing cloud config (needs the cloud account / C3 for a *seamless* switch) | med | + +## Security / privacy invariants +- Tailscale path is **E2E, no data in any LISA cloud** — preserves local-first. +- The friendly error's "Use LISA Cloud" is an *explicit* user choice, never + automatic — the local↔cloud data-plane boundary stays the user's decision. +- No new telemetry; classification is local, from the `NSError` code only. + +## What R1 ships +Mac Tailscale detection + `lisa pair` recommendation (tested); iOS honest errors + +‑999 fix + request timeout. R2–R4 sequenced above. diff --git a/packaging/ios-companion/Sources/ConnectionError.swift b/packaging/ios-companion/Sources/ConnectionError.swift new file mode 100644 index 0000000..119cb37 --- /dev/null +++ b/packaging/ios-companion/Sources/ConnectionError.swift @@ -0,0 +1,65 @@ +import Foundation + +/// A user-facing classification of a connection failure, so tabs show a friendly, +/// actionable message instead of dumping a raw `NSError` (the reported bug: a +/// full-screen "…Error Domain=NSURLErrorDomain Code=-999 cancelled…"). +/// See docs/PLAN_IOS_REACHABILITY_v1.0.md. +enum ConnectionProblem: Equatable { + /// -999 (NSURLErrorCancelled): the request was superseded (a newer load/task) + /// or the network switched mid-flight. Transient — must NOT be shown. + case cancelled + /// Host didn't answer. `privateLAN` = the host is an RFC-1918 home-Wi-Fi IP, + /// so it only works on that same Wi-Fi (the off-Wi-Fi case → suggest Tailscale/Cloud). + case cannotReach(privateLAN: Bool) + /// 401/403 — the device token was rejected/revoked. + case unauthorized + /// A non-auth HTTP error from the Mac. + case serverError(Int) + + /// Classify an error thrown by `LisaClient` against the current config. + static func classify(_ error: Error, config: ServerConfig) -> ConnectionProblem { + if case LisaError.http(let code) = error { + return (code == 401 || code == 403) ? .unauthorized : .serverError(code) + } + let ns = error as NSError + if ns.domain == NSURLErrorDomain, ns.code == NSURLErrorCancelled { + return .cancelled + } + // Everything else network-ish (cannotConnectToHost / timedOut / + // networkConnectionLost / notConnectedToInternet / cannotFindHost / …) is + // "can't reach"; qualify it by whether the host is a private LAN address. + return .cannotReach(privateLAN: config.isPrivateLAN) + } + + /// SF Symbol for the state (unused for `.cancelled`, which never renders). + var icon: String { + switch self { + case .unauthorized: return "lock.trianglebadge.exclamationmark" + case .serverError: return "exclamationmark.triangle" + default: return "wifi.exclamationmark" + } + } + + /// Title + message to show, or `nil` for `.cancelled` (transient — render nothing). + var display: (title: String, message: String)? { + switch self { + case .cancelled: + return nil + case .cannotReach(let privateLAN): + if privateLAN { + return ("Can't reach Lisa", + "You're paired to a home-Wi-Fi address, which only works on that same Wi-Fi. " + + "If you've left it (e.g. on cellular), reach your Mac over Tailscale, or switch to LISA Cloud in Settings.") + } + return ("Can't reach Lisa", + "Make sure your Mac is awake and running `lisa serve --web --host 0.0.0.0`, " + + "and that this device can reach it from your current network.") + case .unauthorized: + return ("Pairing was rejected", + "This device's token was revoked or expired. Re-pair from Settings — run `lisa pair` on your Mac for a fresh code.") + case .serverError(let code): + return ("Your Mac returned an error", + "Lisa answered with HTTP \(code). Make sure it's up to date, then try again.") + } + } +} diff --git a/packaging/ios-companion/Sources/LisaClient.swift b/packaging/ios-companion/Sources/LisaClient.swift index e570e54..e8f8401 100644 --- a/packaging/ios-companion/Sources/LisaClient.swift +++ b/packaging/ios-companion/Sources/LisaClient.swift @@ -13,6 +13,18 @@ struct ServerConfig: Equatable { let standardPort = (scheme == "https" && port == 443) || (scheme == "http" && port == 80) return URL(string: standardPort ? "\(scheme)://\(host)" : "\(scheme)://\(host):\(port)") } + /// True when `host` is an RFC-1918 private LAN IPv4 (192.168/16, 10/8, + /// 172.16–31/12) — reachable only on that same local network, not off it. A + /// Tailscale `100.64/10` address is deliberately NOT counted (it's reachable + /// across the tailnet). Drives the "you've left your Mac's Wi-Fi" guidance. + var isPrivateLAN: Bool { + let p = host.split(separator: ".").compactMap { Int($0) } + guard p.count == 4, p.allSatisfy({ (0...255).contains($0) }) else { return false } + if p[0] == 192 && p[1] == 168 { return true } + if p[0] == 10 { return true } + if p[0] == 172 && (16...31).contains(p[1]) { return true } + return false + } } enum LisaError: LocalizedError { @@ -77,11 +89,16 @@ final class LisaClient { return comps.url } - func makeRequest(_ path: String, method: String = "GET", json: [String: Any]? = nil) throws -> URLRequest { + /// `timeout` bounds a short REST call so an unreachable host (a paired LAN IP + /// off Wi-Fi) fails fast + clean instead of hanging on the 60s default. Left + /// nil for the long-lived SSE stream (`sse()`), which must not time out on idle. + func makeRequest(_ path: String, method: String = "GET", json: [String: Any]? = nil, + timeout: TimeInterval? = nil) throws -> URLRequest { guard config.isConfigured, let base = config.baseURL, let url = URL(string: path, relativeTo: base) else { throw LisaError.notConfigured } var req = URLRequest(url: url) + if let timeout { req.timeoutInterval = timeout } req.httpMethod = method if let token = config.token, !token.isEmpty { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") @@ -93,8 +110,11 @@ final class LisaClient { return req } + /// Timeout for short REST calls (SSE opts out via nil in makeRequest). + private static let restTimeout: TimeInterval = 10 + private func decode(_ path: String, method: String = "GET", json: [String: Any]? = nil, as: T.Type) async throws -> T { - let req = try makeRequest(path, method: method, json: json) + let req = try makeRequest(path, method: method, json: json, timeout: Self.restTimeout) let (data, resp) = try await session.data(for: req) let code = (resp as? HTTPURLResponse)?.statusCode ?? -1 guard (200..<300).contains(code) else { throw LisaError.http(code) } @@ -107,7 +127,7 @@ final class LisaClient { /// off, 409 ⇒ session live, 403 ⇒ remote adoption disabled). @discardableResult private func fireCode(_ path: String, method: String = "POST", json: [String: Any]? = nil) async throws -> Int { - let req = try makeRequest(path, method: method, json: json) + let req = try makeRequest(path, method: method, json: json, timeout: Self.restTimeout) let (_, resp) = try await session.data(for: req) return (resp as? HTTPURLResponse)?.statusCode ?? -1 } diff --git a/packaging/ios-companion/Sources/RosterView.swift b/packaging/ios-companion/Sources/RosterView.swift index 08c648b..ec77cc6 100644 --- a/packaging/ios-companion/Sources/RosterView.swift +++ b/packaging/ios-companion/Sources/RosterView.swift @@ -4,16 +4,21 @@ import WidgetKit @MainActor final class RosterModel: ObservableObject { @Published var sessions: [AgentSession] = [] - @Published var error: String? + @Published var problem: ConnectionProblem? private var streamTask: Task? func load(_ client: LisaClient) async { do { sessions = sortRows(try await client.sessions()) - error = nil + problem = nil publishSnapshot() } catch { - self.error = (error as? LocalizedError)?.errorDescription ?? "\(error)" + // A cancelled request (-999) is transient — a newer load/task is coming + // or the network just changed. Never surface it; classify the rest into + // a friendly, actionable message (docs/PLAN_IOS_REACHABILITY_v1.0.md). + let p = ConnectionProblem.classify(error, config: client.config) + if case .cancelled = p { return } + problem = p } } @@ -123,9 +128,8 @@ struct RosterView: View { if !app.config.isConfigured { ContentUnavailableView("Not paired", systemImage: "wifi.slash", description: Text("Add your Mac in Settings.")) - } else if let err = model.error, model.sessions.isEmpty { - ContentUnavailableView("Can't reach Lisa", systemImage: "exclamationmark.triangle", - description: Text(err)) + } else if let p = model.problem, let d = p.display, model.sessions.isEmpty { + ContentUnavailableView(d.title, systemImage: p.icon, description: Text(d.message)) } else if model.sessions.isEmpty { ContentUnavailableView("No agents", systemImage: "moon.zzz", description: Text("Nothing running right now.")) diff --git a/packaging/ios-companion/Tests/LisaPocketTests.swift b/packaging/ios-companion/Tests/LisaPocketTests.swift index 419390a..a5ae1d5 100644 --- a/packaging/ios-companion/Tests/LisaPocketTests.swift +++ b/packaging/ios-companion/Tests/LisaPocketTests.swift @@ -15,6 +15,42 @@ final class LisaPocketTests: XCTestCase { resumable: nil, adoptedSessionId: nil) } + // ── ServerConfig.isPrivateLAN — home-Wi-Fi addresses that die off-network ── + func testIsPrivateLAN() { + func cfg(_ h: String) -> ServerConfig { ServerConfig(host: h, port: 5757, token: "t") } + XCTAssertTrue(cfg("192.168.3.42").isPrivateLAN) + XCTAssertTrue(cfg("10.0.0.5").isPrivateLAN) + XCTAssertTrue(cfg("172.16.0.1").isPrivateLAN) + XCTAssertTrue(cfg("172.31.255.254").isPrivateLAN) + XCTAssertFalse(cfg("172.15.0.1").isPrivateLAN) // just below 172.16 + XCTAssertFalse(cfg("172.32.0.1").isPrivateLAN) // just above 172.31 + XCTAssertFalse(cfg("100.101.102.103").isPrivateLAN) // Tailscale — reachable anywhere + XCTAssertFalse(cfg("lisa-cloud.run.app").isPrivateLAN) // hostname + XCTAssertFalse(cfg("8.8.8.8").isPrivateLAN) + } + + // ── ConnectionProblem.classify — friendly + honest, and -999 stays silent ── + func testConnectionProblemClassify() { + let lan = ServerConfig(host: "192.168.3.42", port: 5757, token: "t") + let cloud = ServerConfig(host: "lisa-cloud.run.app", port: 443, token: "t", scheme: "https") + + // -999 "cancelled" → transient, never shown (the reported bug) + let cancelled = NSError(domain: NSURLErrorDomain, code: NSURLErrorCancelled) + XCTAssertEqual(ConnectionProblem.classify(cancelled, config: lan), .cancelled) + XCTAssertNil(ConnectionProblem.classify(cancelled, config: lan).display) + + // cannot connect + a LAN host → the off-Wi-Fi guidance; a cloud host → generic + let cannot = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost) + XCTAssertEqual(ConnectionProblem.classify(cannot, config: lan), .cannotReach(privateLAN: true)) + XCTAssertEqual(ConnectionProblem.classify(cannot, config: cloud), .cannotReach(privateLAN: false)) + XCTAssertNotNil(ConnectionProblem.classify(cannot, config: lan).display) + + // auth vs server error + XCTAssertEqual(ConnectionProblem.classify(LisaError.http(401), config: lan), .unauthorized) + XCTAssertEqual(ConnectionProblem.classify(LisaError.http(403), config: lan), .unauthorized) + XCTAssertEqual(ConnectionProblem.classify(LisaError.http(503), config: lan), .serverError(503)) + } + // ── rosterCounts: each session lands in exactly one bucket ── func testRosterCountsBuckets() { let snap = rosterCounts([ diff --git a/src/cli/pair.ts b/src/cli/pair.ts index c446edc..2cdd19c 100644 --- a/src/cli/pair.ts +++ b/src/cli/pair.ts @@ -13,11 +13,11 @@ // export, so import the module object and call .generate off it. import qrcodeTerminal from "qrcode-terminal"; import { Agent, setGlobalDispatcher } from "undici"; -import { detectLanHost, buildPairUrl } from "../web/pairing.js"; +import { detectLanHost, detectTailscaleHost, buildPairUrl } from "../web/pairing.js"; // Re-exported so existing importers (and pair.test.ts) keep working after the // LAN-detection / URL-building helpers moved to the dep-free shared module. -export { detectLanHost, buildPairUrl } from "../web/pairing.js"; +export { detectLanHost, detectTailscaleHost, buildPairUrl } from "../web/pairing.js"; const DEFAULT_PORT = 5757; @@ -148,5 +148,15 @@ export async function runPairCommand(argv: string[]): Promise { } else { console.log(`Keep this phone on the same Wi-Fi (or tailnet) as your Mac.`); } + // Reachable-anywhere upsell: a LAN IP only works on the same Wi-Fi. If this Mac + // is on Tailscale, offer its tailnet address (the phone must join the tailnet + // too) so Lisa keeps working on cellular / away from home. + const tailscale = detectTailscaleHost(); + if (tailscale && host !== tailscale) { + console.log(`\n💡 To reach Lisa away from home (cellular / other Wi-Fi):`); + console.log(` put this iPhone on your Tailscale tailnet too, then re-pair with:`); + console.log(` lisa pair --host ${tailscale}`); + console.log(` (The address above only works on the same Wi-Fi as your Mac.)`); + } return 0; } diff --git a/src/web/pairing.test.ts b/src/web/pairing.test.ts index 8022aca..1780e6a 100644 --- a/src/web/pairing.test.ts +++ b/src/web/pairing.test.ts @@ -1,7 +1,13 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import type os from "node:os"; -import { detectLanHost, buildPairUrl, interfaceRank } from "./pairing.js"; +import { + detectLanHost, + detectTailscaleHost, + isTailscaleIPv4, + buildPairUrl, + interfaceRank, +} from "./pairing.js"; const v4 = (address: string, internal = false): os.NetworkInterfaceInfo => ({ address, family: "IPv4", internal, netmask: "", mac: "", cidr: null } as os.NetworkInterfaceInfo); @@ -45,6 +51,41 @@ describe("detectLanHost", () => { }); }); +describe("isTailscaleIPv4 — the 100.64.0.0/10 CGNAT range", () => { + test("accepts addresses in 100.64.x – 100.127.x", () => { + assert.equal(isTailscaleIPv4("100.64.0.1"), true); + assert.equal(isTailscaleIPv4("100.101.102.103"), true); + assert.equal(isTailscaleIPv4("100.127.255.254"), true); + }); + test("rejects LAN / public / just-outside-range addresses", () => { + assert.equal(isTailscaleIPv4("192.168.1.42"), false); + assert.equal(isTailscaleIPv4("10.0.0.5"), false); + assert.equal(isTailscaleIPv4("100.63.0.1"), false); // below the range + assert.equal(isTailscaleIPv4("100.128.0.1"), false); // above the range + assert.equal(isTailscaleIPv4("100.1.2.3"), false); // 100.x but not CGNAT + assert.equal(isTailscaleIPv4("8.8.8.8"), false); + assert.equal(isTailscaleIPv4(""), false); + }); +}); + +describe("detectTailscaleHost", () => { + test("finds a tailnet address (utun) that detectLanHost skips", () => { + const ifaces = { + lo0: [v4("127.0.0.1", true)], + en0: [v4("192.168.1.42")], + utun5: [v4("100.101.102.103")], + }; + assert.equal(detectTailscaleHost(ifaces), "100.101.102.103"); + assert.equal(detectLanHost(ifaces), "192.168.1.42"); // unchanged: LAN still wins for LAN + }); + test("undefined when no tailnet address is present", () => { + assert.equal(detectTailscaleHost({ en0: [v4("192.168.1.42")] }), undefined); + }); + test("ignores an internal 100.x (not a real interface)", () => { + assert.equal(detectTailscaleHost({ lo0: [v4("100.64.0.1", true)] }), undefined); + }); +}); + describe("buildPairUrl", () => { test("encodes host/port/token/name into a lisa-pair:// v1 URL with %20 spaces", () => { const url = buildPairUrl("192.168.1.42", 5757, "abc123", "my phone"); diff --git a/src/web/pairing.ts b/src/web/pairing.ts index 6df07d0..b1cc7c4 100644 --- a/src/web/pairing.ts +++ b/src/web/pairing.ts @@ -43,6 +43,34 @@ export function detectLanHost( return best?.ip; } +/** + * True for a Tailscale IPv4 — the `100.64.0.0/10` CGNAT range Tailscale assigns + * (100.64.0.0 – 100.127.255.255). A phone that's ALSO on the tailnet can reach + * this address from anywhere (cellular, other Wi-Fi), unlike a LAN 192.168/10/172 + * address that only works on the same Wi-Fi. Pure. + */ +export function isTailscaleIPv4(ip: string): boolean { + const m = /^(\d{1,3})\.(\d{1,3})\./.exec(ip.trim()); + if (!m) return false; + return Number(m[1]) === 100 && Number(m[2]) >= 64 && Number(m[2]) <= 127; +} + +/** + * The Mac's Tailscale IPv4, if Tailscale is up — a "reachable anywhere" pairing + * host (the phone must also be on the tailnet). Undefined if there's no tailnet + * address. Pure (interfaces injected for tests). + */ +export function detectTailscaleHost( + ifaces: NodeJS.Dict = os.networkInterfaces(), +): string | undefined { + for (const addrs of Object.values(ifaces)) { + for (const a of addrs ?? []) { + if (a.family === "IPv4" && !a.internal && isTailscaleIPv4(a.address)) return a.address; + } + } + return undefined; +} + /** Build the `lisa-pair://` deep-link the phone scans/pastes. Pure. */ export function buildPairUrl(host: string, port: number, token: string, name: string): string { // %20 (not "+") for spaces so the device label round-trips through iOS URLComponents.