diff --git a/packaging/ios-companion/README.md b/packaging/ios-companion/README.md index 3d23294..a6423eb 100644 --- a/packaging/ios-companion/README.md +++ b/packaging/ios-companion/README.md @@ -14,22 +14,33 @@ uses all live in `src/web/server.ts`. **Compile-verified MVP.** The app builds clean for the iOS Simulator (Xcode 26, iOS 17+ target). It covers: -- **Dispatch** — roster from `/api/agents/sessions` + live `/events` SSE; rows keyed - off `controllable` / `resumable`; per-session control: managed **approve/deny** · - send · cancel, PTY send · **output** · cancel, and **adopt (resume)** for idle - claude sessions (handles the 409/403 the server returns). -- **Chat** — streams `POST /chat`. -- **Settings** — pairing (**scan** the Mac's QR code via the camera, or paste a - `lisa-pair://…` / `?token=` string → Keychain), ntfy push registration, and a - read-only view of the remote-control policy. -- **Glance** — a **Live Activity / Dynamic Island** for a pinned agent (Lock Screen + - compact / expanded / minimal Dynamic Island) and a **home-screen Widget** showing - active / stuck agent counts, both in a WidgetKit extension target. The Widget renders - a counts-only snapshot the app shares through an App Group — the auth token stays in - the Keychain and no session content ever reaches the extension. - -**Not yet** (follow-ups): live Live-Activity updates via APNs, so a pinned agent stays -fresh while backgrounded — needs an Apple push key; ntfy push works today. +- **Dispatch** — roster from `/api/agents/sessions` + live `/events` SSE (auto-reconnect + with backoff + a full resync on foreground); rows keyed off `controllable` / + `resumable`; per-session control: managed **approve/deny** · send · cancel, PTY send · + **output** · cancel, and **adopt (resume)** for idle claude sessions (handles the + 409/403 the server returns). The toolbar opens the **dispatch ledger** (Lisa's own + fire-and-forget runs, with a per-entry log tail). +- **Chat** — streams `POST /chat`, with Lisa's live **mood portrait** (the server's own + art at `/assets/lisa/.png`, driven by the mood SSE). +- **Reve** — "while you were away" note + current desire, an agent-activity **recap** + (2h/8h/24h), and dismissable advisor suggestions. +- **Sense** — ambient-signal **consent** (revoke-only from the phone; granting stays a + Mac action) + recent sense events. +- **Settings** — pairing (**scan** the Mac's QR code, or paste a `lisa-pair://…` / + `?token=` string → Keychain), **ntfy + APNs** push registration, read-only + remote-control policy, **paired devices**, an optional **Face ID / passcode** lock, and + read-only **Inspect Lisa** (Soul / Memory / Skills / Tools). +- **Glance** — a **Live Activity / Dynamic Island** for a pinned agent and a + **home-screen / lock-screen Widget** (systemSmall/Medium + accessory families) showing + active / stuck counts, in a WidgetKit extension. The Widget renders a counts-only + snapshot the app shares through an App Group — the token stays in the Keychain and no + session content reaches the extension — and tapping it deep-links into the app. +- **Deep-links** — `lisapocket://` opens the app from a Widget tap, an ntfy push, or an + APNs push tap (the push carries the link to the relevant session). + +**Not yet** (follow-ups): live **Live-Activity** updates via APNs (so a pinned agent +stays fresh while backgrounded). Plain APNs alerts are wired end-to-end (registration + +sender) but **delivery needs an Apple push key**; ntfy works today with no Apple infra. > Like the Live Activity, the home-screen Widget is **compile-verified on the > Simulator**. Its data only flows on a **signed** build: App Group capabilities aren't @@ -41,6 +52,7 @@ fresh while backgrounded — needs an Apple push key; ntfy push works today. ```sh brew install xcodegen # one-time ./build.sh # xcodegen generate + xcodebuild for the simulator +./build.sh test # run the LisaPocketTests logic tests on a simulator ``` The Xcode project is generated from `project.yml` (not committed). Simulator builds diff --git a/packaging/ios-companion/Sources/App.swift b/packaging/ios-companion/Sources/App.swift index 4fe6eb5..d947532 100644 --- a/packaging/ios-companion/Sources/App.swift +++ b/packaging/ios-companion/Sources/App.swift @@ -2,6 +2,7 @@ import SwiftUI @main struct LisaPocketApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var app = AppState() var body: some Scene { @@ -12,14 +13,25 @@ struct LisaPocketApp: App { } struct RootView: View { + @EnvironmentObject var app: AppState + @Environment(\.scenePhase) private var scenePhase var body: some View { - TabView { + TabView(selection: $app.selectedTab) { RosterView() - .tabItem { Label("Dispatch", systemImage: "cpu") } + .tabItem { Label("Dispatch", systemImage: "cpu") }.tag(0) ChatView() - .tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") } + .tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") }.tag(1) + ReveView() + .tabItem { Label("Reve", systemImage: "moon.stars") }.tag(2) + SenseView() + .tabItem { Label("Sense", systemImage: "sensor.tag.radiowaves.forward") }.tag(3) SettingsView() - .tabItem { Label("Settings", systemImage: "gearshape") } + .tabItem { Label("Settings", systemImage: "gearshape") }.tag(4) + } + .onOpenURL { app.handleDeepLink($0) } + .overlay { if app.locked { LockView() } } + .onChange(of: scenePhase) { _, phase in + if phase == .background { app.lockIfEnabled() } // re-arm when leaving foreground } } } diff --git a/packaging/ios-companion/Sources/AppState.swift b/packaging/ios-companion/Sources/AppState.swift index 5bccee3..f25a1c8 100644 --- a/packaging/ios-companion/Sources/AppState.swift +++ b/packaging/ios-companion/Sources/AppState.swift @@ -1,10 +1,32 @@ import Foundation import SwiftUI +import UIKit +import UserNotifications +import LocalAuthentication + +/// A roster session a deep-link wants to open (agent + sessionId). +struct PendingNav: Equatable { var agent: String; var id: String } + +/// Where a `lisapocket://` deep-link points. +enum DeepLinkRoute: Equatable { + case ignore // not our scheme + case roster // lisapocket://roster (or unknown host) + case session(agent: String, id: String) // lisapocket://session?agent=&id= +} @MainActor final class AppState: ObservableObject { @Published var config: ServerConfig @Published private(set) var client: LisaClient + /// Drives the TabView selection (0=Dispatch … 4=Settings) — deep-links set it. + @Published var selectedTab = 0 + /// Set by a `lisapocket://session?…` deep-link; RosterView consumes + clears it. + @Published var pendingSession: PendingNav? + /// Optional Face ID / passcode gate over the app (token grants full control). + @Published var biometricLockEnabled: Bool + @Published var locked: Bool + /// Last APNs registration outcome, shown in Settings. + @Published var pushStatus = "" init() { let d = UserDefaults.standard @@ -13,6 +35,45 @@ final class AppState: ObservableObject { let cfg = ServerConfig(host: host, port: storedPort == 0 ? 5757 : storedPort, token: TokenStore.load()) self.config = cfg self.client = LisaClient(config: cfg) + let lockOn = d.bool(forKey: "lisa.biometricLock") + self.biometricLockEnabled = lockOn + self.locked = lockOn && cfg.token != nil // require unlock at launch when armed + // The AppDelegate posts the APNs device token here once it arrives. + NotificationCenter.default.addObserver(forName: .apnsToken, object: nil, queue: .main) { [weak self] note in + let hex = note.object as? String + Task { @MainActor in await self?.onApnsToken(hex) } + } + // A tapped push routes its lisapocket:// link through the deep-link handler. + NotificationCenter.default.addObserver(forName: .apnsTapLink, object: nil, queue: .main) { [weak self] note in + guard let link = note.object as? String, let url = URL(string: link) else { return } + Task { @MainActor in self?.handleDeepLink(url) } + } + } + + // ── APNs registration (client half; delivery needs the Mac's APNs key) ── + func enablePush() async { + do { + let granted = try await UNUserNotificationCenter.current() + .requestAuthorization(options: [.alert, .sound, .badge]) + guard granted else { pushStatus = "Notifications not allowed in iOS Settings."; return } + UIApplication.shared.registerForRemoteNotifications() + pushStatus = "Registering for push…" + } catch { + pushStatus = error.localizedDescription + } + } + + private func onApnsToken(_ hex: String?) async { + guard let hex, !hex.isEmpty else { + pushStatus = "APNs unavailable here (no token — e.g. the Simulator)." + return + } + do { + try await client.pushRegister(kind: "apns", target: hex, prefs: PushPrefs()) + pushStatus = "Push registered (APNs)." + } catch { + pushStatus = (error as? LocalizedError)?.errorDescription ?? "\(error)" + } } func update(host: String, port: Int, token: String?) { @@ -38,4 +99,54 @@ final class AppState: ObservableObject { update(host: host, port: port, token: token) return true } + + /// Route a `lisapocket://` deep-link (from a push Click or the home Widget). + /// `lisapocket://roster` → Dispatch tab; `lisapocket://session?agent=&id=` → + /// Dispatch tab + ask RosterView to open that session. + func handleDeepLink(_ url: URL) { + switch AppState.parseDeepLink(url) { + case .ignore: return + case .roster: selectedTab = 0 + case .session(let agent, let id): + selectedTab = 0 + pendingSession = PendingNav(agent: agent, id: id) + } + } + + /// Pure parse of a deep-link into a route. `nonisolated` so it's testable off + /// the main actor. Unknown lisapocket hosts fall back to the roster. + nonisolated static func parseDeepLink(_ url: URL) -> DeepLinkRoute { + guard url.scheme == "lisapocket" else { return .ignore } + guard url.host == "session" else { return .roster } + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let agent = items.first { $0.name == "agent" }?.value + let id = items.first { $0.name == "id" }?.value + if let agent, let id, !agent.isEmpty, !id.isEmpty { + return .session(agent: agent, id: id) + } + return .roster + } + + // ── biometric lock ── + func setBiometricLock(_ on: Bool) { + biometricLockEnabled = on + UserDefaults.standard.set(on, forKey: "lisa.biometricLock") + if !on { locked = false } + } + + /// Re-arm the lock when the app leaves the foreground (called on background). + func lockIfEnabled() { + if biometricLockEnabled && config.token != nil { locked = true } + } + + /// Prompt Face ID / Touch ID (falling back to the device passcode). If no auth + /// is available at all, don't trap the user — just unlock. + func unlock() async { + let ctx = LAContext() + var err: NSError? + guard ctx.canEvaluatePolicy(.deviceOwnerAuthentication, error: &err) else { locked = false; return } + if let ok = try? await ctx.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Lisa Pocket"), ok { + locked = false + } + } } diff --git a/packaging/ios-companion/Sources/ChatView.swift b/packaging/ios-companion/Sources/ChatView.swift index b7c0b4c..cf02958 100644 --- a/packaging/ios-companion/Sources/ChatView.swift +++ b/packaging/ios-companion/Sources/ChatView.swift @@ -4,7 +4,31 @@ import SwiftUI final class ChatModel: ObservableObject { @Published var transcript = "" @Published var sending = false + @Published var mood = "" private var task: Task? + private var moodTask: Task? + + /// Seed the mood from a ping, then track the `mood` SSE — reconnecting with + /// backoff so a mid-session drop doesn't leave the portrait stale forever. + func startMood(_ client: LisaClient) { + moodTask?.cancel() + moodTask = Task { @MainActor in + var backoffSec: UInt64 = 1 + while !Task.isCancelled { + if let p = try? await client.islandPing() { mood = p.mood } + do { + for try await msg in client.eventsStream() where msg.type == "mood" { + backoffSec = 1 + if let s = msg.slug { mood = s } + } + } catch { /* dropped → backoff + reconnect below */ } + if Task.isCancelled { break } + try? await Task.sleep(nanoseconds: backoffSec * 1_000_000_000) + backoffSec = min(backoffSec * 2, 30) + } + } + } + func stopMood() { moodTask?.cancel(); moodTask = nil } func send(_ text: String, client: LisaClient) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) @@ -32,6 +56,11 @@ struct ChatView: View { var body: some View { NavigationStack { VStack(spacing: 0) { + if !model.mood.isEmpty { + MoodPortrait(mood: model.mood) + .padding(.horizontal).padding(.vertical, 6) + Divider() + } ScrollView { Text(model.transcript.isEmpty ? "Say hi to Lisa." : model.transcript) .frame(maxWidth: .infinity, alignment: .leading) @@ -54,6 +83,79 @@ struct ChatView: View { .padding() } .navigationTitle("Chat") + .task(id: app.config) { model.startMood(app.client) } + .onDisappear { model.stopMood() } + } + } +} + +/// Lisa's current mood as a real portrait + label. The portrait is the server's +/// own art (`/assets/lisa/.png`, the same set the web island uses), loaded +/// over the existing connection — no bundling, always in sync. Falls back to the +/// mood chip while loading or if the slug has no art. +struct MoodPortrait: View { + @EnvironmentObject var app: AppState + let mood: String + + var body: some View { + let slug = mood.isEmpty ? "neutral" : mood + let safe = slug.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? slug + HStack(spacing: 10) { + Group { + if let url = app.client.assetURL("/assets/lisa/\(safe).png") { + AsyncImage(url: url) { phase in + switch phase { + case .success(let img): img.resizable().scaledToFit() + case .empty: ProgressView() + default: Image(systemName: "person.crop.circle.fill").resizable().scaledToFit().foregroundStyle(.secondary) + } + } + } else { + Image(systemName: "person.crop.circle.fill").resizable().scaledToFit().foregroundStyle(.secondary) + } + } + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 10)) + MoodChip(mood: slug) + Spacer() + } + } +} + +/// Lisa's current mood as a small labeled chip — the caption beside the portrait, +/// and the fallback while/if the portrait can't load. +struct MoodChip: View { + let mood: String + var body: some View { + HStack(spacing: 6) { + Image(systemName: symbol).foregroundStyle(color) + Text(mood.capitalized).font(.subheadline.weight(.medium)) + } + .padding(.horizontal, 10).padding(.vertical, 5) + .background(color.opacity(0.12), in: Capsule()) + } + + private var symbol: String { + switch mood.lowercased() { + case "happy", "content", "joyful", "playful": return "face.smiling" + case "curious", "intrigued": return "sparkle.magnifyingglass" + case "proud": return "star.fill" + case "weary", "tired": return "moon.zzz" + case "frustrated", "annoyed": return "exclamationmark.bubble" + case "affectionate", "warm": return "heart.fill" + case "awe", "wonder": return "sparkles" + default: return "circle.fill" + } + } + private var color: Color { + switch mood.lowercased() { + case "happy", "content", "joyful", "playful": return .green + case "curious", "intrigued", "awe", "wonder": return .blue + case "proud": return .yellow + case "weary", "tired": return .gray + case "frustrated", "annoyed": return .red + case "affectionate", "warm": return .pink + default: return .secondary } } } diff --git a/packaging/ios-companion/Sources/DispatchLedgerView.swift b/packaging/ios-companion/Sources/DispatchLedgerView.swift new file mode 100644 index 0000000..0bbe887 --- /dev/null +++ b/packaging/ios-companion/Sources/DispatchLedgerView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +/// LISA's own fire-and-forget dispatches (the ledger), distinct from the +/// observed-agent roster (docs/IOS_COMPANION_PLAN.md §6.2): pid / task / alive, +/// from /api/dispatch/list, with a captured log tail per entry from +/// /api/dispatch/status. Reached from the Dispatch tab's toolbar. +struct DispatchLedgerView: View { + @EnvironmentObject var app: AppState + @State private var items: [DispatchView] = [] + @State private var error: String? + @State private var loaded = false + + var body: some View { + Group { + if !loaded { + ProgressView() + } else if let error { + ContentUnavailableView("Couldn't load", systemImage: "exclamationmark.triangle", description: Text(error)) + } else if items.isEmpty { + ContentUnavailableView("No dispatches", systemImage: "tray", + description: Text("Lisa hasn't dispatched any agents recently.")) + } else { + List(items) { d in + NavigationLink { DispatchDetailView(entry: d) } label: { + HStack(spacing: 10) { + Circle().fill(d.alive ? .blue : .gray).frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 2) { + Text(d.task).font(.subheadline).lineLimit(1) + Text("\(d.agent) · pid \(d.pid)\(d.alive ? " · alive" : "")") + .font(.caption2).foregroundStyle(.secondary) + } + } + } + } + } + } + .navigationTitle("Dispatches") + .refreshable { await load() } + .task { await load() } + } + + private func load() async { + do { items = try await app.client.dispatchList(); error = nil } + catch { self.error = (error as? LocalizedError)?.errorDescription ?? "\(error)" } + loaded = true + } +} + +struct DispatchDetailView: View { + @EnvironmentObject var app: AppState + let entry: DispatchView + @State private var tail = "" + @State private var status: DispatchStatus? + + var body: some View { + List { + Section("Dispatch") { + LabeledContent("Agent", value: entry.agent) + LabeledContent("Task", value: entry.task) + LabeledContent("pid", value: String(entry.pid)) + LabeledContent("Alive", value: (status?.alive ?? entry.alive) ? "yes" : "no") + LabeledContent("cwd", value: entry.cwd) + } + Section("Log tail") { + if tail.isEmpty { + Text(entry.hasLog ? "…" : "No log captured.").font(.caption).foregroundStyle(.secondary) + } else { + ScrollView(.horizontal) { + Text(tail).font(.system(.caption2, design: .monospaced)) + } + } + } + } + .navigationTitle(entry.agent) + .navigationBarTitleDisplayMode(.inline) + .refreshable { await load() } + .task { await load() } + } + + private func load() async { + // status is gated by the control policy; tolerate a 403/404 (leave tail empty). + if let s = try? await app.client.dispatchStatus(id: entry.id) { + status = s + tail = s.tail ?? "" + } + } +} diff --git a/packaging/ios-companion/Sources/InspectViews.swift b/packaging/ios-companion/Sources/InspectViews.swift new file mode 100644 index 0000000..88ff563 --- /dev/null +++ b/packaging/ios-companion/Sources/InspectViews.swift @@ -0,0 +1,150 @@ +import SwiftUI + +/// Read-only "speed views" of Lisa's interior (docs/IOS_COMPANION_PLAN.md §G6, +/// Appendix B): Soul, Memory, Skills, Tools. All GET-only; reached from Settings. + +/// A tiny loader that runs an async fetch on appear and renders loading / error / +/// content, so each inspection view stays a one-liner over its data. +struct AsyncContent: View { + let load: () async throws -> T + @ViewBuilder let content: (T) -> Content + + @State private var value: T? + @State private var error: String? + + var body: some View { + Group { + if let value { + content(value) + } else if let error { + ContentUnavailableView("Couldn't load", systemImage: "exclamationmark.triangle", description: Text(error)) + } else { + ProgressView() + } + } + .task { + do { value = try await load(); error = nil } + catch { self.error = (error as? LocalizedError)?.errorDescription ?? "\(error)" } + } + } +} + +struct SoulView: View { + @EnvironmentObject var app: AppState + var body: some View { + AsyncContent(load: { try await app.client.soul() }) { resp in + if !resp.born || resp.summary == nil { + ContentUnavailableView("Not born yet", systemImage: "moon.stars", + description: Text("Lisa hasn't run her birth ritual.")) + } else if let s = resp.summary { + List { + if let n = s.name, !n.isEmpty { Section("Name") { Text(n) } } + soulText("Identity", s.identity) + soulText("Purpose", s.purpose) + soulText("Constitution", s.constitution) + if let emo = s.emotions?.values, !emo.isEmpty { + Section("Mood") { + ForEach(emo.sorted(by: { $0.value > $1.value }), id: \.key) { k, v in + HStack { + Text(k).font(.subheadline) + Spacer() + ProgressView(value: max(0, min(1, v))).frame(width: 120) + } + } + } + } + soulItems("Values", s.values) + soulItems("Opinions", s.opinions) + soulItems("Desires", s.desires) + if let t = s.tampered, !t.isEmpty { + Section("⚠ Tampered files") { ForEach(t, id: \.self) { Text($0).font(.caption.monospaced()) } } + } + } + } + } + .navigationTitle("Soul") + } + + @ViewBuilder private func soulText(_ title: String, _ value: String?) -> some View { + if let value, !value.isEmpty { Section(title) { Text(value).font(.callout) } } + } + @ViewBuilder private func soulItems(_ title: String, _ items: [SoulItem]?) -> some View { + if let items, !items.isEmpty { + Section("\(title) (\(items.count))") { + ForEach(Array(items.enumerated()), id: \.offset) { _, it in Text(it.label).font(.callout) } + } + } + } +} + +struct MemoryView: View { + @EnvironmentObject var app: AppState + var body: some View { + AsyncContent(load: { try await app.client.memory() }) { mem in + List { + if !mem.user.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Section("Who you are (user)") { Text(mem.user).font(.system(.callout, design: .monospaced)) } + } + if !mem.memory.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Section("Memory") { Text(mem.memory).font(.system(.callout, design: .monospaced)) } + } + } + } + .navigationTitle("Memory") + } +} + +struct DevicesView: View { + @EnvironmentObject var app: AppState + var body: some View { + AsyncContent(load: { try await app.client.devices() }) { devices in + List { + Section { + if devices.isEmpty { + Text("No paired devices.").font(.caption).foregroundStyle(.secondary) + } else { + ForEach(devices) { d in + VStack(alignment: .leading, spacing: 2) { + Text(d.name).font(.headline) + Text(d.platform + (d.lastSeenAt.map { " · seen \(Self.rel($0))" } ?? "")) + .font(.caption).foregroundStyle(.secondary) + } + } + } + } footer: { + Text("Each device has its own revocable token. Revoke one on the Mac (localhost only).") + } + } + } + .navigationTitle("Paired devices") + } + + /// Relative time from an epoch-ms timestamp. + static func rel(_ ms: Double) -> String { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .abbreviated + return f.localizedString(for: Date(timeIntervalSince1970: ms / 1000), relativeTo: Date()) + } +} + +struct NamedListView: View { + let title: String + let load: () async throws -> [NamedItem] + var body: some View { + AsyncContent(load: load) { items in + if items.isEmpty { + ContentUnavailableView("None", systemImage: "tray", description: Text("Nothing here.")) + } else { + List(items) { item in + VStack(alignment: .leading, spacing: 2) { + Text(item.name).font(.headline) + if let d = item.description, !d.isEmpty { + Text(d).font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + .navigationTitle(title) + } +} diff --git a/packaging/ios-companion/Sources/LisaClient.swift b/packaging/ios-companion/Sources/LisaClient.swift index 49cdcb9..e96e95f 100644 --- a/packaging/ios-companion/Sources/LisaClient.swift +++ b/packaging/ios-companion/Sources/LisaClient.swift @@ -34,6 +34,19 @@ final class LisaClient { self.session = session } + /// URL for a server asset (e.g. a mood portrait at /assets/lisa/.png), + /// carrying the token as a query param so AsyncImage — which can't set an + /// Authorization header — still authenticates against a non-loopback server. + func assetURL(_ path: String) -> URL? { + guard config.isConfigured, let base = config.baseURL, + let abs = URL(string: path, relativeTo: base), + var comps = URLComponents(url: abs, resolvingAgainstBaseURL: true) else { return nil } + if let token = config.token, !token.isEmpty { + comps.queryItems = (comps.queryItems ?? []) + [URLQueryItem(name: "token", value: token)] + } + return comps.url + } + func makeRequest(_ path: String, method: String = "GET", json: [String: Any]? = nil) throws -> URLRequest { guard config.isConfigured, let base = config.baseURL, let url = URL(string: path, relativeTo: base) else { throw LisaError.notConfigured @@ -69,9 +82,37 @@ final class LisaClient { // ── read ── func sessions() async throws -> [AgentSession] { try await decode("/api/agents/sessions", as: SessionsResponse.self).sessions } func dispatchList() async throws -> [DispatchView] { try await decode("/api/dispatch/list", as: DispatchListResponse.self).dispatches } + func dispatchStatus(id: String) async throws -> DispatchStatus { + let enc = id.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? id + return try await decode("/api/dispatch/status?id=\(enc)", as: DispatchStatus.self) + } func islandPing() async throws -> IslandPing { try await decode("/api/island/ping", as: IslandPing.self) } func controlPolicy() async throws -> ControlPolicy { try await decode("/api/control/policy", as: ControlPolicy.self) } + // ── read: inspection ── + func soul() async throws -> SoulResponse { try await decode("/api/soul", as: SoulResponse.self) } + func memory() async throws -> MemoryResponse { try await decode("/api/memory", as: MemoryResponse.self) } + func skills() async throws -> [NamedItem] { try await decode("/api/skills", as: SkillsResponse.self).skills } + func tools() async throws -> [NamedItem] { try await decode("/api/tools", as: ToolsResponse.self).tools } + + // ── read: Reve ── + func recap(sinceMinutes: Int = 120) async throws -> RecapResponse { + try await decode("/api/agents/recap?sinceMinutes=\(sinceMinutes)", as: RecapResponse.self) + } + func advisorLatest() async throws -> AdvisorResponse { try await decode("/api/advisor/latest", as: AdvisorResponse.self) } + func advisorDismiss(id: String, category: String?) async throws { + try await fire("/api/advisor/dismiss", json: ["id": id, "category": category ?? ""]) + } + + // ── Sense: consent (revoke-only from a phone) + events ── + func consent() async throws -> [ConsentRow] { try await decode("/api/consent", as: ConsentResponse.self).grants } + func consentRevoke(signal: String) async throws { try await fire("/api/consent/revoke", json: ["signal": signal]) } + func consentRevokeAll() async throws { try await fire("/api/consent/revoke-all") } + func senseRecent() async throws -> [SenseEvent] { try await decode("/api/sense/recent", as: SenseResponse.self).events } + + // ── read: paired devices (revoke is a Mac-only action) ── + func devices() async throws -> [DeviceInfo] { try await decode("/api/devices", as: DevicesResponse.self).devices } + // ── control: managed agents ── func managedStart(task: String) async throws { try await fire("/api/agents/managed/start", json: ["task": task]) } func managedSend(_ id: String, _ text: String) async throws { try await fire("/api/agents/managed/\(id)/send", json: ["text": text]) } diff --git a/packaging/ios-companion/Sources/LockView.swift b/packaging/ios-companion/Sources/LockView.swift new file mode 100644 index 0000000..80dc01b --- /dev/null +++ b/packaging/ios-companion/Sources/LockView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Full-screen gate shown while `app.locked` (docs/IOS_COMPANION_PLAN.md §5.3 — +/// the token is a full-control credential, so an optional Face ID / passcode lock +/// guards it). Auto-prompts on appear; a manual Unlock retries after a cancel. +struct LockView: View { + @EnvironmentObject var app: AppState + + var body: some View { + ZStack { + Color(.systemBackground).ignoresSafeArea() + VStack(spacing: 16) { + Image(systemName: "lock.fill").font(.largeTitle).foregroundStyle(.secondary) + Text("Lisa Pocket is locked").font(.headline) + Button { Task { await app.unlock() } } label: { + Label("Unlock", systemImage: "faceid") + } + .buttonStyle(.borderedProminent) + } + } + .task { await app.unlock() } + } +} diff --git a/packaging/ios-companion/Sources/Models.swift b/packaging/ios-companion/Sources/Models.swift index c6b26a6..14d992f 100644 --- a/packaging/ios-companion/Sources/Models.swift +++ b/packaging/ios-companion/Sources/Models.swift @@ -11,7 +11,10 @@ struct AgentSession: Codable, Identifiable, Hashable { var cwd: String? var state: String var stateReason: String - /// ISO-8601 string from /api/agents/sessions (the server serializes lastMtime). + /// Normalized to an ISO-8601 string. The REST `/api/agents/sessions` already + /// serializes it as ISO, but the `agent_session_update` SSE sends raw epoch-ms + /// (a number) — so decode tolerates both (a number → ISO), else a present-but- + /// wrong-type value would throw and silently drop every live roster update. var lastMtime: String? var activity: SessionActivity? /// "managed" | "pty" — which control-endpoint family drives it. Absent ⇒ observe-only. @@ -22,6 +25,42 @@ struct AgentSession: Codable, Identifiable, Hashable { var adoptedSessionId: String? var id: String { "\(agent)/\(sessionId)" } + + init(agent: String, sessionId: String, project: String, cwd: String? = nil, + state: String, stateReason: String, lastMtime: String? = nil, + activity: SessionActivity? = nil, controllable: String? = nil, + resumable: Bool? = nil, adoptedSessionId: String? = nil) { + self.agent = agent; self.sessionId = sessionId; self.project = project; self.cwd = cwd + self.state = state; self.stateReason = stateReason; self.lastMtime = lastMtime + self.activity = activity; self.controllable = controllable + self.resumable = resumable; self.adoptedSessionId = adoptedSessionId + } + + enum CodingKeys: String, CodingKey { + case agent, sessionId, project, cwd, state, stateReason, lastMtime + case activity, controllable, resumable, adoptedSessionId + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + agent = try c.decode(String.self, forKey: .agent) + sessionId = try c.decode(String.self, forKey: .sessionId) + project = (try? c.decode(String.self, forKey: .project)) ?? "" + cwd = try? c.decodeIfPresent(String.self, forKey: .cwd) + state = (try? c.decode(String.self, forKey: .state)) ?? "idle" + stateReason = (try? c.decode(String.self, forKey: .stateReason)) ?? "" + if let s = try? c.decodeIfPresent(String.self, forKey: .lastMtime) { + lastMtime = s + } else if let n = try? c.decodeIfPresent(Double.self, forKey: .lastMtime) { + lastMtime = ISO8601DateFormatter().string(from: Date(timeIntervalSince1970: n / 1000)) + } else { + lastMtime = nil + } + activity = try? c.decodeIfPresent(SessionActivity.self, forKey: .activity) + controllable = try? c.decodeIfPresent(String.self, forKey: .controllable) + resumable = try? c.decodeIfPresent(Bool.self, forKey: .resumable) + adoptedSessionId = try? c.decodeIfPresent(String.self, forKey: .adoptedSessionId) + } } struct SessionActivity: Codable, Hashable { @@ -59,6 +98,17 @@ struct DispatchListResponse: Codable { var dispatches: [DispatchView] } +/// /api/dispatch/status?id= — a DispatchView plus a captured log tail. +struct DispatchStatus: Codable { + var ok: Bool + var id: String? + var agent: String? + var task: String? + var startedAt: String? + var alive: Bool? + var tail: String? +} + struct IslandPing: Codable { var online: Bool var mood: String @@ -80,3 +130,102 @@ struct PushPrefs: Codable, Equatable { var idle: Bool = true var advisor: Bool = false } + +// ── read-only inspection (/api/soul, /api/memory, /api/skills, /api/tools) ── + +struct SoulResponse: Codable { + var born: Bool + var summary: SoulSummary? +} + +/// Mirrors src/soul/types.ts SoulSummary, but lenient (optional) — a roster +/// glance should render whatever the server sends, not fail on a missing field. +struct SoulSummary: Codable { + var name: String? + var identity: String? + var purpose: String? + var constitution: String? + var emotions: Emotions? + var values: [SoulItem]? + var opinions: [SoulItem]? + var desires: [SoulItem]? + var tampered: [String]? +} + +struct Emotions: Codable { + var values: [String: Double]? +} + +/// A values/opinions/desires row. Their exact key varies, so accept several and +/// surface the first present (see ValueEntry/OpinionEntry/DesireEntry server-side). +struct SoulItem: Codable, Hashable { + var name: String? + var statement: String? + var what: String? + var text: String? + var summary: String? + var label: String { statement ?? what ?? text ?? summary ?? name ?? "—" } +} + +struct MemoryResponse: Codable { + var user: String + var memory: String +} + +struct NamedItem: Codable, Identifiable, Hashable { + var name: String + var description: String? + var id: String { name } +} +struct SkillsResponse: Codable { var skills: [NamedItem] } +struct ToolsResponse: Codable { var tools: [NamedItem] } + +// ── Reve (/api/agents/recap, /api/advisor/latest) ── + +struct RecapResponse: Codable { + var text: String + var sinceMinutes: Int? +} + +struct AdvisorSuggestion: Codable, Identifiable { + var id: String + var category: String? + var urgency: String? + var text: String +} +struct AdvisorResponse: Codable { + var suggestions: [AdvisorSuggestion] + var at: String? +} + +// ── Sense (/api/consent, /api/sense/recent) ── + +struct ConsentRow: Codable, Identifiable { + var signal: String + var granted: Bool + var grantedAt: String? + var description: String? + var id: String { signal } +} +struct ConsentResponse: Codable { var grants: [ConsentRow] } + +struct SenseEvent: Codable, Identifiable { + var signal: String + var kind: String + var app: String? + var title: String? + var summary: String + var ts: Double + var id: String { "\(signal)/\(kind)/\(ts)" } +} +struct SenseResponse: Codable { var events: [SenseEvent] } + +// ── Devices (/api/devices) — list is token-auth; revoke is Mac-only ── +struct DeviceInfo: Codable, Identifiable { + var id: String + var name: String + var platform: String + var createdAt: Double? + var lastSeenAt: Double? +} +struct DevicesResponse: Codable { var devices: [DeviceInfo] } diff --git a/packaging/ios-companion/Sources/PushManager.swift b/packaging/ios-companion/Sources/PushManager.swift new file mode 100644 index 0000000..8013954 --- /dev/null +++ b/packaging/ios-companion/Sources/PushManager.swift @@ -0,0 +1,46 @@ +import SwiftUI +import UserNotifications + +/// APNs plumbing. SwiftUI has no didRegisterForRemoteNotifications hook, so a tiny +/// UIApplicationDelegate captures the device token (→ /api/push/register) and routes +/// a notification tap's `link` deep-link. Delivery itself needs the Mac's APNs key +/// (see src/web/push.ts) — this is the client half, ready in advance. +extension Notification.Name { + static let apnsToken = Notification.Name("lisa.apnsToken") + static let apnsTapLink = Notification.Name("lisa.apnsTapLink") +} + +final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + UNUserNotificationCenter.current().delegate = self + return true + } + + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let hex = deviceToken.map { String(format: "%02x", $0) }.joined() + NotificationCenter.default.post(name: .apnsToken, object: hex) + } + + func application(_ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error) { + // e.g. the Simulator has no APNs — surface as a nil token (AppState reports it). + NotificationCenter.default.post(name: .apnsToken, object: nil) + } + + // Show banners for pushes that arrive while the app is foregrounded. + func userNotificationCenter(_ center: UNUserNotificationCenter, + willPresent notification: UNNotification) async -> UNNotificationPresentationOptions { + [.banner, .sound] + } + + // Tapping a notification routes its `link` (the lisapocket:// deep-link the + // server set, mirroring ntfy's Click) to the app. + func userNotificationCenter(_ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse) async { + if let link = response.notification.request.content.userInfo["link"] as? String { + NotificationCenter.default.post(name: .apnsTapLink, object: link) + } + } +} diff --git a/packaging/ios-companion/Sources/ReveView.swift b/packaging/ios-companion/Sources/ReveView.swift new file mode 100644 index 0000000..516267c --- /dev/null +++ b/packaging/ios-companion/Sources/ReveView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +/// Reve — Lisa's reflective surface (docs/IOS_COMPANION_PLAN.md Appendix B): a +/// "while you were away" note + current desire, a recap of recent agent activity +/// over a chosen window, and advisor suggestions (dismissable — feeds the +/// server's "learn to shut up" loop). +struct ReveView: View { + @EnvironmentObject var app: AppState + @Environment(\.scenePhase) private var scenePhase + + @State private var ping: IslandPing? + @State private var recap: String = "" + @State private var suggestions: [AdvisorSuggestion] = [] + @State private var window = 120 // minutes + @State private var error: String? + @State private var loading = false + + private let windows: [(String, Int)] = [("2h", 120), ("8h", 480), ("24h", 1440)] + + var body: some View { + NavigationStack { + Group { + if !app.config.isConfigured { + ContentUnavailableView("Not paired", systemImage: "wifi.slash", + description: Text("Add your Mac in Settings.")) + } else { + List { + if let p = ping { + if let note = p.last_idle_message_text, !note.isEmpty { + Section("While you were away") { Text(note).font(.callout) } + } + if let desire = p.current_desire, !desire.isEmpty { + Section("Current desire") { + Label(desire, systemImage: "scope").font(.callout) + } + } + } + + Section { + Picker("Window", selection: $window) { + ForEach(windows, id: \.1) { Text($0.0).tag($0.1) } + } + .pickerStyle(.segmented) + if recap.isEmpty { + Text(loading ? "…" : "No agent activity in this window.") + .font(.caption).foregroundStyle(.secondary) + } else { + Text(recap).font(.system(.callout, design: .monospaced)) + } + } header: { Text("Recap") } + + if !suggestions.isEmpty { + Section("Suggestions") { + ForEach(suggestions) { s in + VStack(alignment: .leading, spacing: 4) { + if let c = s.category { + Text(c.uppercased()).font(.caption2).foregroundStyle(.secondary) + } + Text(s.text).font(.callout) + Button("Dismiss", role: .destructive) { dismiss(s) } + .font(.caption).buttonStyle(.borderless) + } + } + } + } + + if let error { Section { Text(error).font(.caption).foregroundStyle(.secondary) } } + } + } + } + .navigationTitle("Reve") + .refreshable { await load() } + .task(id: ReveLoadKey(window: window, configured: app.config.isConfigured)) { await load() } + .onChange(of: scenePhase) { _, p in if p == .active { Task { await load() } } } + } + } + + private func load() async { + guard app.config.isConfigured else { return } + loading = true + defer { loading = false } + async let pingResult = app.client.islandPing() + async let recapResult = app.client.recap(sinceMinutes: window) + async let advisorResult = app.client.advisorLatest() + ping = try? await pingResult + recap = (try? await recapResult)?.text ?? "" + suggestions = (try? await advisorResult)?.suggestions ?? [] + error = nil + } + + private func dismiss(_ s: AdvisorSuggestion) { + suggestions.removeAll { $0.id == s.id } + Task { try? await app.client.advisorDismiss(id: s.id, category: s.category) } + } +} + +/// Re-run the loader when the window changes or pairing flips on. +private struct ReveLoadKey: Equatable { let window: Int; let configured: Bool } diff --git a/packaging/ios-companion/Sources/RosterView.swift b/packaging/ios-companion/Sources/RosterView.swift index 338f65c..c2f53b9 100644 --- a/packaging/ios-companion/Sources/RosterView.swift +++ b/packaging/ios-companion/Sources/RosterView.swift @@ -20,15 +20,23 @@ final class RosterModel: ObservableObject { func startStream(_ client: LisaClient) { streamTask?.cancel() streamTask = Task { @MainActor in - do { - for try await msg in client.eventsStream() { - if (msg.type == "agent_session_update" || msg.type == "claude_session_update"), - let s = msg.agentSession { - merge(s) + var backoffSec: UInt64 = 1 + while !Task.isCancelled { + do { + for try await msg in client.eventsStream() { + backoffSec = 1 // healthy traffic resets the backoff + if (msg.type == "agent_session_update" || msg.type == "claude_session_update"), + let s = msg.agentSession { + merge(s) + } } + } catch { + // dropped — fall through to a full resync + backed-off reconnect } - } catch { - // stream ended / dropped — the view reloads on next appear + if Task.isCancelled { break } + await load(client) // catch transitions missed during the gap + try? await Task.sleep(nanoseconds: backoffSec * 1_000_000_000) + backoffSec = min(backoffSec * 2, 30) // 1,2,4,…,30s cap } } } @@ -51,19 +59,23 @@ final class RosterModel: ObservableObject { /// Mirror the roster's counts (metadata only — no session content) to the App /// Group so the home-screen Widget can render them, then nudge it to reload. private func publishSnapshot() { - var working = 0, waiting = 0, error = 0 - for s in sessions { - if s.activity?.pendingPermission != nil || s.state == "waiting" { waiting += 1 } - else if s.state == "error" { error += 1 } - else if s.state == "working" { working += 1 } - } - SharedStore.writeSnapshot(AgentSnapshot( - working: working, waiting: waiting, error: error, - total: sessions.count, updatedAt: Date())) + SharedStore.writeSnapshot(rosterCounts(sessions)) WidgetCenter.shared.reloadAllTimelines() } } +/// Bucket roster sessions into the Widget's counts (pending-permission and +/// "waiting" both count as needs-you). Pure — unit-tested. +func rosterCounts(_ sessions: [AgentSession], at now: Date = Date()) -> AgentSnapshot { + var working = 0, waiting = 0, error = 0 + for s in sessions { + if s.activity?.pendingPermission != nil || s.state == "waiting" { waiting += 1 } + else if s.state == "error" { error += 1 } + else if s.state == "working" { working += 1 } + } + return AgentSnapshot(working: working, waiting: waiting, error: error, total: sessions.count, updatedAt: now) +} + /// Permission/error first, then waiting, then working, then the rest. Newest within a bucket. func sortRows(_ rows: [AgentSession]) -> [AgentSession] { func rank(_ s: AgentSession) -> Int { @@ -97,9 +109,11 @@ func stateColor(_ s: AgentSession) -> Color { struct RosterView: View { @EnvironmentObject var app: AppState @StateObject private var model = RosterModel() + @Environment(\.scenePhase) private var scenePhase + @State private var path: [AgentSession] = [] var body: some View { - NavigationStack { + NavigationStack(path: $path) { Group { if !app.config.isConfigured { ContentUnavailableView("Not paired", systemImage: "wifi.slash", @@ -118,14 +132,42 @@ struct RosterView: View { } .navigationTitle("Dispatch") .navigationDestination(for: AgentSession.self) { SessionDetailView(session: $0) } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { DispatchLedgerView() } label: { + Image(systemName: "list.bullet.rectangle") + } + } + } .refreshable { await model.load(app.client) } .task(id: app.config) { await model.load(app.client) + resolvePending() model.startStream(app.client) } + .onChange(of: scenePhase) { _, phase in + // iOS suspends SSE in the background; on return to foreground do a + // full resync and reconnect so the roster is correct + live again. + // Skip while the Face ID lock is up — don't fetch behind the gate. + guard phase == .active, app.config.isConfigured, !app.locked else { return } + Task { await model.load(app.client); model.startStream(app.client) } + } + // Deep-link (push Click / widget): open the requested session once it's + // in the roster — handle either arrival order (link before/after load). + .onChange(of: app.pendingSession) { _, _ in resolvePending() } + .onChange(of: model.sessions) { _, _ in resolvePending() } .onDisappear { model.stopStream() } } } + + /// If a deep-link is pending and its session is in the roster, push it once. + private func resolvePending() { + guard let p = app.pendingSession, + let match = model.sessions.first(where: { $0.agent == p.agent && $0.sessionId == p.id }) + else { return } + path = [match] + app.pendingSession = nil + } } struct RosterRow: View { diff --git a/packaging/ios-companion/Sources/SenseView.swift b/packaging/ios-companion/Sources/SenseView.swift new file mode 100644 index 0000000..35d6b50 --- /dev/null +++ b/packaging/ios-companion/Sources/SenseView.swift @@ -0,0 +1,89 @@ +import SwiftUI + +/// Sense — ambient-signal consent + recent events (docs/IOS_COMPANION_PLAN.md §G6, +/// §7.2). Deliberately *revoke-only* from a phone: tightening consent is always +/// safe, but granting a sensitive signal remotely would widen the surface, which +/// the privacy floor says stays a Mac action. So we show state + let you revoke, +/// and point grants at the Mac. +struct SenseView: View { + @EnvironmentObject var app: AppState + @Environment(\.scenePhase) private var scenePhase + + @State private var grants: [ConsentRow] = [] + @State private var events: [SenseEvent] = [] + @State private var error: String? + + var body: some View { + NavigationStack { + Group { + if !app.config.isConfigured { + ContentUnavailableView("Not paired", systemImage: "wifi.slash", + description: Text("Add your Mac in Settings.")) + } else { + List { + Section { + ForEach(grants) { row in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(row.signal).font(.headline) + if let d = row.description, !d.isEmpty { + Text(d).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + if row.granted { + Button("Revoke", role: .destructive) { revoke(row.signal) } + .font(.caption).buttonStyle(.borderless) + } else { + Text("off").font(.caption).foregroundStyle(.secondary) + } + } + } + if grants.contains(where: { $0.granted }) { + Button("Revoke all", role: .destructive) { revokeAll() } + } + } header: { Text("Consent") } footer: { + Text("Tightening is safe from anywhere. Grant new signals on the Mac (privacy floor).") + } + + Section("Recent events") { + if events.isEmpty { + Text("Nothing captured.").font(.caption).foregroundStyle(.secondary) + } else { + ForEach(events) { e in + VStack(alignment: .leading, spacing: 2) { + Text(e.summary).font(.callout).lineLimit(2) + Text("\(e.signal) · \(e.kind)\(e.app.map { " · \($0)" } ?? "")") + .font(.caption2).foregroundStyle(.secondary) + } + } + } + } + + if let error { Section { Text(error).font(.caption).foregroundStyle(.secondary) } } + } + } + } + .navigationTitle("Sense") + .refreshable { await load() } + .task(id: app.config) { await load() } + .onChange(of: scenePhase) { _, p in if p == .active { Task { await load() } } } + } + } + + private func load() async { + guard app.config.isConfigured else { return } + async let g = app.client.consent() + async let e = app.client.senseRecent() + grants = (try? await g) ?? [] + events = (try? await e) ?? [] + error = grants.isEmpty && events.isEmpty ? "Couldn't reach Lisa." : nil + } + + private func revoke(_ signal: String) { + Task { try? await app.client.consentRevoke(signal: signal); await load() } + } + private func revokeAll() { + Task { try? await app.client.consentRevokeAll(); await load() } + } +} diff --git a/packaging/ios-companion/Sources/SettingsView.swift b/packaging/ios-companion/Sources/SettingsView.swift index 7cc9140..7b59e99 100644 --- a/packaging/ios-companion/Sources/SettingsView.swift +++ b/packaging/ios-companion/Sources/SettingsView.swift @@ -69,6 +69,15 @@ struct SettingsView: View { .disabled(ntfyTopic.isEmpty) } + Section("Push (APNs)") { + Button("Enable push notifications") { Task { await app.enablePush() } } + if !app.pushStatus.isEmpty { + Text(app.pushStatus).font(.caption).foregroundStyle(.secondary) + } + Text("Native Apple Push. Delivery needs an APNs key set on the Mac; ntfy works without one.") + .font(.caption).foregroundStyle(.secondary) + } + Section("Remote-control policy (set on the Mac)") { if let p = policy { LabeledContent("Control own agents", value: p.remoteControl ? "allowed" : "blocked") @@ -79,6 +88,29 @@ struct SettingsView: View { Text("Change these on the Mac (localhost only).").font(.caption).foregroundStyle(.secondary) } + Section { + NavigationLink { DevicesView() } label: { Label("Paired devices", systemImage: "iphone.gen3") } + } + + Section("Security") { + Toggle("Require Face ID / passcode", isOn: Binding( + get: { app.biometricLockEnabled }, + set: { app.setBiometricLock($0) })) + Text("Locks the app behind biometrics — the device token grants full control of your Mac's agents.") + .font(.caption).foregroundStyle(.secondary) + } + + Section("Inspect Lisa") { + NavigationLink { SoulView() } label: { Label("Soul", systemImage: "sparkles") } + NavigationLink { MemoryView() } label: { Label("Memory", systemImage: "brain") } + NavigationLink { NamedListView(title: "Skills", load: { try await app.client.skills() }) } label: { + Label("Skills", systemImage: "wand.and.stars") + } + NavigationLink { NamedListView(title: "Tools", load: { try await app.client.tools() }) } label: { + Label("Tools", systemImage: "hammer") + } + } + if !status.isEmpty { Section { Text(status).font(.caption).foregroundStyle(.secondary) } } diff --git a/packaging/ios-companion/Tests/LisaPocketTests.swift b/packaging/ios-companion/Tests/LisaPocketTests.swift new file mode 100644 index 0000000..3ec5922 --- /dev/null +++ b/packaging/ios-companion/Tests/LisaPocketTests.swift @@ -0,0 +1,91 @@ +import XCTest +@testable import LisaPocket + +/// Logic tests for the pure helpers — no network, no Keychain, no app launch. +final class LisaPocketTests: XCTestCase { + + private func session(_ state: String, id: String = "s", agent: String = "claude-code", + pending: String? = nil, mtime: String? = nil) -> AgentSession { + let activity = pending.map { + SessionActivity(turnCount: nil, lastTools: nil, filesTouched: nil, lastCommandName: nil, + lastError: nil, gitBranch: nil, tokens: nil, pendingPermission: $0) + } + return AgentSession(agent: agent, sessionId: id, project: "p", cwd: nil, state: state, + stateReason: "", lastMtime: mtime, activity: activity, controllable: nil, + resumable: nil, adoptedSessionId: nil) + } + + // ── rosterCounts: each session lands in exactly one bucket ── + func testRosterCountsBuckets() { + let snap = rosterCounts([ + session("working", id: "a"), + session("working", id: "b"), + session("waiting", id: "c"), + session("error", id: "d"), + session("working", id: "e", pending: "Bash"), // pending ⇒ waiting bucket + session("done", id: "f"), + ], at: Date(timeIntervalSince1970: 0)) + XCTAssertEqual(snap.working, 2) + XCTAssertEqual(snap.waiting, 2) // one "waiting" + one pending-permission + XCTAssertEqual(snap.error, 1) + XCTAssertEqual(snap.total, 6) // done counts toward total only + XCTAssertEqual(snap.stuck, 3) // waiting + error + } + + func testRosterCountsEmpty() { + let snap = rosterCounts([], at: Date(timeIntervalSince1970: 0)) + XCTAssertEqual(snap.total, 0) + XCTAssertEqual(snap.stuck, 0) + } + + // ── sortRows: pending-permission first, then error, waiting, working ── + func testSortRowsRanking() { + let sorted = sortRows([ + session("working", id: "w"), + session("done", id: "d"), + session("error", id: "e"), + session("working", id: "p", pending: "Bash"), + session("waiting", id: "wa"), + ]) + XCTAssertEqual(sorted.map(\.sessionId), ["p", "e", "wa", "w", "d"]) + } + + // ── parseDeepLink ── + func testParseDeepLinkSession() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "lisapocket://session?agent=codex&id=s9")!), + .session(agent: "codex", id: "s9")) + } + func testParseDeepLinkRoster() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "lisapocket://roster")!), .roster) + } + func testParseDeepLinkUnknownHostFallsBackToRoster() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "lisapocket://whatever")!), .roster) + } + func testParseDeepLinkSessionMissingParamsFallsBackToRoster() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "lisapocket://session?agent=codex")!), .roster) + } + func testParseDeepLinkIgnoresForeignScheme() { + XCTAssertEqual(AppState.parseDeepLink(URL(string: "https://example.com")!), .ignore) + } + + // ── AgentSession.lastMtime tolerates both shapes (regression for the SSE bug) ── + func testDecodesNumericLastMtimeFromSSE() throws { + // agent_session_update broadcasts raw epoch-ms; must not throw. + let json = #"{"agent":"codex","sessionId":"s1","project":"p","state":"working","stateReason":"","lastMtime":1718800000000,"activity":{"pendingPermission":"Bash"}}"# + let s = try JSONDecoder().decode(AgentSession.self, from: Data(json.utf8)) + XCTAssertEqual(s.agent, "codex") + XCTAssertNotNil(s.lastMtime) // number normalized to a string + XCTAssertFalse(s.lastMtime!.isEmpty) + XCTAssertEqual(s.activity?.pendingPermission, "Bash") + } + func testDecodesIsoLastMtimeFromREST() throws { + let json = #"{"agent":"claude-code","sessionId":"s2","project":"p","state":"done","stateReason":"","lastMtime":"2026-06-19T10:00:00.000Z"}"# + let s = try JSONDecoder().decode(AgentSession.self, from: Data(json.utf8)) + XCTAssertEqual(s.lastMtime, "2026-06-19T10:00:00.000Z") + } + func testDecodesMissingLastMtime() throws { + let json = #"{"agent":"aider","sessionId":"s3","project":"p","state":"idle","stateReason":""}"# + let s = try JSONDecoder().decode(AgentSession.self, from: Data(json.utf8)) + XCTAssertNil(s.lastMtime) + } +} diff --git a/packaging/ios-companion/Widgets/AgentCountWidget.swift b/packaging/ios-companion/Widgets/AgentCountWidget.swift index ac51691..036b8a8 100644 --- a/packaging/ios-companion/Widgets/AgentCountWidget.swift +++ b/packaging/ios-companion/Widgets/AgentCountWidget.swift @@ -32,10 +32,32 @@ struct AgentCountWidgetView: View { @Environment(\.widgetFamily) private var family var body: some View { - if let snap = entry.snapshot, snap.updatedAt > .distantPast { - populated(snap) + switch family { + case .accessoryInline: + Text(inlineText) + case .accessoryRectangular: + rectangular + default: + if let snap = entry.snapshot, snap.updatedAt > .distantPast { populated(snap) } + else { unconfigured } + } + } + + // Lock-screen accessory families: terse, no background (system styles them). + private var inlineText: String { + guard let s = entry.snapshot, s.updatedAt > .distantPast else { return "Lisa — open to pair" } + return "▶ \(s.working) active · ⏸ \(s.stuck) stuck" + } + + @ViewBuilder private var rectangular: some View { + if let s = entry.snapshot, s.updatedAt > .distantPast { + VStack(alignment: .leading, spacing: 2) { + Label("Dispatch", systemImage: "cpu").font(.caption2.bold()) + Text("\(s.working) active · \(s.stuck) stuck").font(.caption) + Text(summary(s)).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } } else { - unconfigured + Text("Open Lisa Pocket").font(.caption) } } @@ -93,9 +115,10 @@ struct AgentCountWidget: Widget { StaticConfiguration(kind: kind, provider: AgentCountProvider()) { entry in AgentCountWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) + .widgetURL(URL(string: "lisapocket://roster")) // tap → open the Dispatch tab } .configurationDisplayName("Agent activity") .description("Active and stuck agents on your Mac.") - .supportedFamilies([.systemSmall, .systemMedium]) + .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline]) } } diff --git a/packaging/ios-companion/build.sh b/packaging/ios-companion/build.sh index c7f9d87..c3a3fe1 100755 --- a/packaging/ios-companion/build.sh +++ b/packaging/ios-companion/build.sh @@ -20,13 +20,17 @@ export DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Develope command -v xcodegen >/dev/null || { echo "✗ need xcodegen — run: brew install xcodegen" >&2; exit 1; } +# Action: `build` (default) or `test` (runs the LisaPocketTests logic tests). +ACTION="build" +if [ "${1:-}" = "test" ]; then ACTION="test"; shift; fi + echo "==> xcodegen generate" xcodegen generate DEST="${1:-platform=iOS Simulator,name=iPhone 17 Pro}" -echo "==> xcodebuild ($DEST)" +echo "==> xcodebuild $ACTION ($DEST)" xcodebuild -project LisaPocket.xcodeproj -scheme LisaPocket \ -sdk iphonesimulator -destination "$DEST" \ - -derivedDataPath .build build CODE_SIGNING_ALLOWED=NO + -derivedDataPath .build "$ACTION" CODE_SIGNING_ALLOWED=NO -echo "✓ Lisa Pocket built for the simulator." +echo "✓ Lisa Pocket $ACTION succeeded (simulator)." diff --git a/packaging/ios-companion/project.yml b/packaging/ios-companion/project.yml index 8db7c3a..8c87295 100644 --- a/packaging/ios-companion/project.yml +++ b/packaging/ios-companion/project.yml @@ -33,6 +33,11 @@ targets: UILaunchScreen: {} NSSupportsLiveActivities: true NSCameraUsageDescription: "Scan the pairing QR code Lisa shows on your Mac." + NSFaceIDUsageDescription: "Unlock Lisa Pocket with Face ID." + CFBundleURLTypes: + - CFBundleURLName: ai.meetlisa.pocket + CFBundleURLSchemes: + - lisapocket NSAppTransportSecurity: NSAllowsLocalNetworking: true entitlements: @@ -40,6 +45,7 @@ targets: properties: com.apple.security.application-groups: - group.ai.meetlisa.pocket + aps-environment: development settings: base: PRODUCT_BUNDLE_IDENTIFIER: ai.meetlisa.pocket @@ -64,3 +70,27 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: ai.meetlisa.pocket.widgets + + # Logic tests for the pure helpers (parsing / bucketing / sorting). Hosted by the + # app so `@testable import LisaPocket` resolves; run with: ./build.sh test (or + # xcodebuild test). XcodeGen sets TEST_HOST from the app dependency. + LisaPocketTests: + type: bundle.unit-test + platform: iOS + sources: + - path: Tests + dependencies: + - target: LisaPocket + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: ai.meetlisa.pocket.tests + GENERATE_INFOPLIST_FILE: YES + +schemes: + LisaPocket: + build: + targets: + LisaPocket: all + test: + targets: + - LisaPocketTests