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
15 changes: 8 additions & 7 deletions docs/PLAN_IOS_REACHABILITY_v1.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,17 +116,18 @@ 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.
- 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.
## 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).
33 changes: 33 additions & 0 deletions packaging/ios-companion/Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
28 changes: 28 additions & 0 deletions packaging/ios-companion/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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<Void, Never>?
/// 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) {
Expand Down Expand Up @@ -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) ──
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packaging/ios-companion/Sources/ConnectionError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
11 changes: 10 additions & 1 deletion packaging/ios-companion/Sources/RosterView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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."))
Expand Down
61 changes: 59 additions & 2 deletions packaging/mac-client/Sources/Lisa/PairController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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 }
Expand All @@ -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 {
Expand Down Expand Up @@ -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<ifaddrs>?
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 {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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".
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down