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
132 changes: 132 additions & 0 deletions docs/PLAN_IOS_REACHABILITY_v1.0.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions packaging/ios-companion/Sources/ConnectionError.swift
Original file line number Diff line number Diff line change
@@ -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.")
}
}
}
26 changes: 23 additions & 3 deletions packaging/ios-companion/Sources/LisaClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand All @@ -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<T: Decodable>(_ 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) }
Expand All @@ -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
}
Expand Down
16 changes: 10 additions & 6 deletions packaging/ios-companion/Sources/RosterView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Never>?

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
}
}

Expand Down Expand Up @@ -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."))
Expand Down
36 changes: 36 additions & 0 deletions packaging/ios-companion/Tests/LisaPocketTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Loading