Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
44 changes: 28 additions & 16 deletions packaging/ios-companion/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>.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
Expand All @@ -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
Expand Down
20 changes: 16 additions & 4 deletions packaging/ios-companion/Sources/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
}
}
111 changes: 111 additions & 0 deletions packaging/ios-companion/Sources/AppState.swift
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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?) {
Expand All @@ -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
}
}
}
102 changes: 102 additions & 0 deletions packaging/ios-companion/Sources/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,31 @@ import SwiftUI
final class ChatModel: ObservableObject {
@Published var transcript = ""
@Published var sending = false
@Published var mood = ""
private var task: Task<Void, Never>?
private var moodTask: Task<Void, Never>?

/// 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)
Expand Down Expand Up @@ -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)
Expand All @@ -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/<slug>.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
}
}
}
Loading