From 011fe15bf33424f473334daeda22091bc1e0fa9f Mon Sep 17 00:00:00 2001 From: oratis Date: Wed, 1 Jul 2026 17:15:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(ios):=20reachability=20R2=E2=80=93R4=20?= =?UTF-8?q?=E2=80=94=20Tailscale=20toggle,=20off-Wi-Fi=20banner,=20one-tap?= =?UTF-8?q?=20Cloud?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on R1 (#202): - R2 (Mac app): PairController detects the Tailscale address (100.64/10) and adds a "Same Wi-Fi / Anywhere (Tailscale)" segmented toggle to the Pair window that regenerates the QR + details for the chosen host — GUI parity with `lisa pair`. - R3 (iOS): AppState watches NWPathMonitor (`onCellular`); RootView shows a ReachabilityBanner when paired to a private-LAN host while on cellular ("You've left your Mac's Wi-Fi"). - R4 (iOS): AppState.switchToCloud() flips to the LISA Cloud data plane + lands on Settings; wired to a one-tap "Use LISA Cloud" in the banner and the roster cannot-reach error (ConnectionProblem.offersCloud). Verified: iOS build.sh test → 27 tests, 0 failures; Mac `swift build` clean. Plan/status: docs/PLAN_IOS_REACHABILITY_v1.0.md (R1–R4 all landed). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/PLAN_IOS_REACHABILITY_v1.0.md | 15 ++--- packaging/ios-companion/Sources/App.swift | 33 ++++++++++ .../ios-companion/Sources/AppState.swift | 28 +++++++++ .../Sources/ConnectionError.swift | 4 ++ .../ios-companion/Sources/RosterView.swift | 11 +++- .../Sources/Lisa/PairController.swift | 61 ++++++++++++++++++- 6 files changed, 142 insertions(+), 10 deletions(-) diff --git a/docs/PLAN_IOS_REACHABILITY_v1.0.md b/docs/PLAN_IOS_REACHABILITY_v1.0.md index cbb1b05..e167c6b 100644 --- a/docs/PLAN_IOS_REACHABILITY_v1.0.md +++ b/docs/PLAN_IOS_REACHABILITY_v1.0.md @@ -116,10 +116,10 @@ Stop dumping `NSError`. Add a shared classifier + view reused by every tab. | 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 | +| **R1** ✅ | 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` | done (#202) | +| **R2** ✅ | `PairController.swift` Tailscale detect + a "Same Wi-Fi / Anywhere (Tailscale)" toggle in the Mac Pair window that regenerates the QR | done | +| **R3** ✅ | `NWPathMonitor` in AppState (`onCellular`) → a top `ReachabilityBanner` when paired to a private-LAN host while on cellular | done | +| **R4** ✅ | One-tap **"Use LISA Cloud"** (`AppState.switchToCloud` → mode=cloud + Settings) from the banner and the roster error. A seamless reconnect to a *remembered* cloud config still wants C3. | done | ## Security / privacy invariants - Tailscale path is **E2E, no data in any LISA cloud** — preserves local-first. @@ -127,6 +127,7 @@ Stop dumping `NSError`. Add a shared classifier + view reused by every tab. 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. +## Status +R1–R4 all landed. R5 (future): a *seamless* "Use LISA Cloud" that reconnects to a +remembered/authenticated cloud config without re-entry — needs the cloud account +work (C3). diff --git a/packaging/ios-companion/Sources/App.swift b/packaging/ios-companion/Sources/App.swift index cce5223..e281149 100644 --- a/packaging/ios-companion/Sources/App.swift +++ b/packaging/ios-companion/Sources/App.swift @@ -58,6 +58,13 @@ struct RootView: View { SetupBanner { app.presentOnboarding() } } } + .safeAreaInset(edge: .top) { + // Left the Mac's Wi-Fi (LAN pairing + on cellular) → the Mac is + // unreachable; offer the one-tap escape to LISA Cloud (R3/R4). + if app.lanUnreachableOnCellular && !app.showOnboarding { + ReachabilityBanner { app.switchToCloud() } + } + } .task { await app.refreshWidgetSnapshot() } // keep the widget fresh off-tab (A5) .onChange(of: scenePhase) { _, phase in if phase == .background { app.lockIfEnabled() } // re-arm when leaving foreground @@ -89,6 +96,32 @@ struct ToastView: View { } } +/// Top banner shown when paired to a home-Wi-Fi address but on cellular — the Mac +/// is unreachable. Offers the one-tap escape to LISA Cloud (Tailscale is the other +/// path, but that needs a Mac-side re-pair). See docs/PLAN_IOS_REACHABILITY R3/R4. +struct ReachabilityBanner: View { + let onUseCloud: () -> Void + var body: some View { + HStack(spacing: 10) { + Image(systemName: "wifi.exclamationmark").foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 1) { + Text("You've left your Mac's Wi-Fi") + .font(.subheadline.weight(.semibold)).foregroundStyle(Theme.text) + Text("Reach it over Tailscale, or use LISA Cloud.") + .font(.caption).foregroundStyle(Theme.secondary) + } + Spacer(minLength: 8) + Button("Use Cloud", action: onUseCloud) + .font(.caption.weight(.semibold)) + .buttonStyle(.borderedProminent).tint(Theme.accent) + } + .padding(.horizontal, 16).padding(.vertical, 9) + .background(Theme.card) + .overlay(alignment: .bottom) { Rectangle().fill(Theme.border).frame(height: 1) } + .accessibilityElement(children: .combine) + } +} + /// Opaque branded cover shown whenever the scene isn't active, so the iOS /// app-switcher snapshot can't expose the token field / chat (review A6). struct PrivacyCover: View { diff --git a/packaging/ios-companion/Sources/AppState.swift b/packaging/ios-companion/Sources/AppState.swift index 42edf77..561794e 100644 --- a/packaging/ios-companion/Sources/AppState.swift +++ b/packaging/ios-companion/Sources/AppState.swift @@ -4,6 +4,7 @@ import UIKit import UserNotifications import LocalAuthentication import WidgetKit +import Network /// A roster session a deep-link wants to open (agent + sessionId). struct PendingNav: Equatable { var agent: String; var id: String } @@ -45,6 +46,11 @@ final class AppState: ObservableObject { /// that A1 surfaces non-2xx — lands visibly instead of in a buried status row). @Published var toast: ToastMessage? private var toastClear: Task? + /// True when the active network path is cellular with no Wi-Fi — so a paired + /// LAN IP is definitely unreachable. Drives the "you've left your Mac's Wi-Fi" + /// banner (docs/PLAN_IOS_REACHABILITY_v1.0.md R3). + @Published var onCellular = false + private let pathMonitor = NWPathMonitor() /// Fire a haptic + show a brief toast. Use `ok: false` for failures. func notify(_ text: String, ok: Bool = true) { @@ -93,6 +99,14 @@ final class AppState: ObservableObject { guard let link = note.object as? String, let url = URL(string: link) else { return } Task { @MainActor in self?.handleDeepLink(url) } } + // Watch the network path so we can warn when a LAN pairing goes unreachable + // (left the Mac's Wi-Fi → on cellular). Off the main actor; hops back to set. + pathMonitor.pathUpdateHandler = { [weak self] path in + let cell = path.status == .satisfied + && path.usesInterfaceType(.cellular) && !path.usesInterfaceType(.wifi) + Task { @MainActor in self?.onCellular = cell } + } + pathMonitor.start(queue: DispatchQueue(label: "ai.meetlisa.pathmonitor")) } // ── APNs registration (client half; delivery needs the Mac's APNs key) ── @@ -126,6 +140,20 @@ final class AppState: ObservableObject { UserDefaults.standard.set(m.rawValue, forKey: "lisa.mode") } + /// One-tap escape from an unreachable LAN pairing: flip to the LISA Cloud data + /// plane and land on Settings, where the user signs in / enters the cloud URL. + /// (A seamless reconnect to a *remembered* cloud config is R4/C3.) + func switchToCloud() { + setConnectionMode(.cloud) + selectedTab = 3 // Settings → LISA Cloud + } + + /// Paired to a home-Wi-Fi address but currently on cellular → that Mac is + /// unreachable; surface the R3 banner. + var lanUnreachableOnCellular: Bool { + config.isConfigured && config.isPrivateLAN && onCellular + } + func update(host: String, port: Int, token: String?, scheme: String = "http") { let cfg = ServerConfig(host: host, port: port, token: token, scheme: scheme) config = cfg diff --git a/packaging/ios-companion/Sources/ConnectionError.swift b/packaging/ios-companion/Sources/ConnectionError.swift index 119cb37..9bfaf62 100644 --- a/packaging/ios-companion/Sources/ConnectionError.swift +++ b/packaging/ios-companion/Sources/ConnectionError.swift @@ -31,6 +31,10 @@ enum ConnectionProblem: Equatable { return .cannotReach(privateLAN: config.isPrivateLAN) } + /// True when switching to LISA Cloud is a sensible one-tap escape (the Mac + /// itself is unreachable — R4). Not offered for auth/server errors. + var offersCloud: Bool { if case .cannotReach = self { return true }; return false } + /// SF Symbol for the state (unused for `.cancelled`, which never renders). var icon: String { switch self { diff --git a/packaging/ios-companion/Sources/RosterView.swift b/packaging/ios-companion/Sources/RosterView.swift index ec77cc6..af79fca 100644 --- a/packaging/ios-companion/Sources/RosterView.swift +++ b/packaging/ios-companion/Sources/RosterView.swift @@ -129,7 +129,16 @@ struct RosterView: View { ContentUnavailableView("Not paired", systemImage: "wifi.slash", description: Text("Add your Mac in Settings.")) } else if let p = model.problem, let d = p.display, model.sessions.isEmpty { - ContentUnavailableView(d.title, systemImage: p.icon, description: Text(d.message)) + ContentUnavailableView { + Label(d.title, systemImage: p.icon) + } description: { + Text(d.message) + } actions: { + if p.offersCloud { + Button("Use LISA Cloud") { app.switchToCloud() } + .buttonStyle(.borderedProminent) + } + } } else if model.sessions.isEmpty { ContentUnavailableView("No agents", systemImage: "moon.zzz", description: Text("Nothing running right now.")) diff --git a/packaging/mac-client/Sources/Lisa/PairController.swift b/packaging/mac-client/Sources/Lisa/PairController.swift index 292bf73..88bac32 100644 --- a/packaging/mac-client/Sources/Lisa/PairController.swift +++ b/packaging/mac-client/Sources/Lisa/PairController.swift @@ -28,6 +28,12 @@ final class PairController { private var lastToken = "" private let port = 5757 + // Rebuild state for the "Same Wi-Fi / Anywhere (Tailscale)" toggle (R2). + private var pLan = "", pTail: String?, pToken = "", pPort = 5757 + private weak var qrView: NSImageView? + private weak var netLabel: NSTextField? + private weak var detailsField: NSTextField? + /// Mint a device token and show a scannable QR (or an error alert). func showPairing() { // Already showing a QR? Refocus it instead of minting another device token — @@ -45,7 +51,7 @@ final class PairController { // MARK: - Mint (mirrors pair.ts runPairCommand) - struct Pairing { let url: String; let host: String; let port: Int; let token: String } + struct Pairing { let url: String; let host: String; let tailscaleHost: String?; let port: Int; let token: String } private func mint() async throws -> Pairing { guard let host = Self.detectLanHost() else { throw PairError.noLan } @@ -67,7 +73,7 @@ final class PairController { guard let token = body.token, !token.isEmpty else { throw PairError.noToken } let effPort = body.port ?? port return Pairing(url: Self.buildPairUrl(host: host, port: effPort, token: token, name: "iPhone"), - host: host, port: effPort, token: token) + host: host, tailscaleHost: Self.detectTailscaleHost(), port: effPort, token: token) } enum PairError: LocalizedError { @@ -123,6 +129,29 @@ final class PairController { return 2 // unknown: beats VPN, loses to en* } + /// The Mac's Tailscale IPv4 (the `100.64.0.0/10` range), if the tailnet is up — + /// a "reachable anywhere" pairing host. Mirrors pairing.ts detectTailscaleHost. + static func detectTailscaleHost() -> String? { + var ifaddrPtr: UnsafeMutablePointer? + guard getifaddrs(&ifaddrPtr) == 0, let first = ifaddrPtr else { return nil } + defer { freeifaddrs(ifaddrPtr) } + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = ptr.pointee.ifa_flags + guard (flags & UInt32(IFF_UP)) != 0, (flags & UInt32(IFF_LOOPBACK)) == 0 else { continue } + guard let addr = ptr.pointee.ifa_addr, addr.pointee.sa_family == UInt8(AF_INET) else { continue } + var host = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + guard getnameinfo(addr, socklen_t(addr.pointee.sa_len), &host, socklen_t(host.count), nil, 0, NI_NUMERICHOST) == 0 else { continue } + if Self.isTailscaleIPv4(String(cString: host)) { return String(cString: host) } + } + return nil + } + + /// True for the 100.64.0.0/10 CGNAT range Tailscale assigns. + static func isTailscaleIPv4(_ ip: String) -> Bool { + let p = ip.split(separator: ".").compactMap { Int($0) } + return p.count == 4 && p[0] == 100 && (64...127).contains(p[1]) + } + // MARK: - Pair URL (mirrors pair.ts buildPairUrl) + QR static func buildPairUrl(host: String, port: Int, token: String, name: String) -> String { @@ -157,6 +186,7 @@ final class PairController { window?.close() lastURL = pairing.url lastToken = pairing.token + pLan = pairing.host; pTail = pairing.tailscaleHost; pToken = pairing.token; pPort = pairing.port let pad: CGFloat = 24 let width: CGFloat = 320 @@ -181,6 +211,17 @@ final class PairController { sub.preferredMaxLayoutWidth = width - pad * 2 stack.addArrangedSubview(sub) + // Reachability toggle (R2): "Same Wi-Fi" (the LAN IP) vs "Anywhere + // (Tailscale)" — only shown when this Mac is on a tailnet. Flipping it + // regenerates the QR + details for the chosen host. + if pTail != nil { + let seg = NSSegmentedControl(labels: ["Same Wi-Fi", "Anywhere (Tailscale)"], + trackingMode: .selectOne, target: self, + action: #selector(hostModeChanged(_:))) + seg.selectedSegment = 0 + stack.addArrangedSubview(seg) + } + let qr = NSImageView() qr.image = Self.qrImage(pairing.url, side: qrSide * 2) // 2× pixels → crisp on retina qr.imageScaling = .scaleProportionallyUpOrDown @@ -192,11 +233,13 @@ final class PairController { qr.widthAnchor.constraint(equalToConstant: qrSide).isActive = true qr.heightAnchor.constraint(equalToConstant: qrSide).isActive = true stack.addArrangedSubview(qr) + self.qrView = qr let net = NSTextField(labelWithString: "Same Wi-Fi · \(pairing.host):\(pairing.port)") net.font = .systemFont(ofSize: 11) net.textColor = .tertiaryLabelColor stack.addArrangedSubview(net) + self.netLabel = net // Can't scan? Show the same details as copyable text, so they can be typed // (or pasted) into Lisa Pocket → Settings → Pair → "enter manually". @@ -213,6 +256,7 @@ final class PairController { details.alignment = .left details.preferredMaxLayoutWidth = width - pad * 2 stack.addArrangedSubview(details) + self.detailsField = details let copyRow = NSStackView() copyRow.orientation = .horizontal @@ -262,6 +306,19 @@ final class PairController { NSPasteboard.general.setString(lastToken, forType: .string) } + // R2: flip the QR + details between the LAN address (same Wi-Fi) and the + // Tailscale address (reachable anywhere the phone is also on the tailnet). + @objc private func hostModeChanged(_ seg: NSSegmentedControl) { + render(host: seg.selectedSegment == 1 ? (pTail ?? pLan) : pLan) + } + private func render(host: String) { + let onTailnet = (host == pTail) + lastURL = Self.buildPairUrl(host: host, port: pPort, token: pToken, name: "iPhone") + qrView?.image = Self.qrImage(lastURL, side: 240 * 2) + netLabel?.stringValue = (onTailnet ? "Tailscale (anywhere) · " : "Same Wi-Fi · ") + "\(host):\(pPort)" + detailsField?.stringValue = "Host: \(host)\nPort: \(pPort)\nToken: \(pToken)" + } + @objc private func closeWindow() { window?.close() } private func presentError(_ error: Error) {