From 7be9e64a95ebfa83c93cf5dd8b6591ad8bc25f55 Mon Sep 17 00:00:00 2001 From: jay Date: Sat, 16 May 2026 11:54:12 +0200 Subject: [PATCH] feat: merge T-2.6 SessionSwitcher + T-2.8 StatusBar + T-2.11 Face-ID - SessionRegistry + SessionSwitcher + SessionRow (T-2.6) - StatusBar component with pi-state indicator (T-2.8) - FaceIDGate + SettingsView + LockView (T-2.11) - Reconciled MainTerminalView: StatusBar wired with onSwitcher/onSettings/onUnpair, two .task modifiers, two .sheet modifiers (SessionSwitcher + SettingsView) - AppState: appDidBackground/appWillForeground + isLocked (T-2.11) - ContentView: scenePhase tracking + LockView overlay (T-2.11) - Pairing.swift: fp param is optional pre-TLS (dev convenience) --- Sources/App/AppState.swift | 16 +++ Sources/App/ContentView.swift | 27 ++++- Sources/Core/Auth/FaceIDGate.swift | 24 ++++ Sources/Core/Auth/Pairing.swift | 3 +- Sources/Core/Sessions/SessionRegistry.swift | 120 ++++++++++++++++++++ Sources/UI/Sessions/SessionRow.swift | 40 +++++++ Sources/UI/Sessions/SessionSwitcher.swift | 92 +++++++++++++++ Sources/UI/Settings/LockView.swift | 16 +++ Sources/UI/Settings/SettingsView.swift | 48 ++++++++ Sources/UI/Status/StatusBar.swift | 99 ++++++++++++++++ Sources/UI/Terminal/MainTerminalView.swift | 60 +++++++--- 11 files changed, 521 insertions(+), 24 deletions(-) create mode 100644 Sources/Core/Auth/FaceIDGate.swift create mode 100644 Sources/Core/Sessions/SessionRegistry.swift create mode 100644 Sources/UI/Sessions/SessionRow.swift create mode 100644 Sources/UI/Sessions/SessionSwitcher.swift create mode 100644 Sources/UI/Settings/LockView.swift create mode 100644 Sources/UI/Settings/SettingsView.swift create mode 100644 Sources/UI/Status/StatusBar.swift diff --git a/Sources/App/AppState.swift b/Sources/App/AppState.swift index 9caf945..c27eacb 100644 --- a/Sources/App/AppState.swift +++ b/Sources/App/AppState.swift @@ -7,6 +7,8 @@ final class AppState: ObservableObject { static let shared = AppState() @Published var credential: SidecarCredential? = nil + @Published var isLocked = false + @Published var lastForegroundedAt: Date = Date() private init() { // Try loading persisted credential on launch @@ -22,4 +24,18 @@ final class AppState: ObservableObject { credential = nil Keychain.shared.delete(key: "piremote.credential") } + + // MARK: - Face ID gate + + func appDidBackground() { + lastForegroundedAt = Date() + } + + func appWillForeground() async { + let elapsed = Date().timeIntervalSince(lastForegroundedAt) + guard elapsed > 60 else { return } // within 60s → no re-auth + isLocked = true + let ok = await FaceIDGate.authenticate() + isLocked = !ok + } } diff --git a/Sources/App/ContentView.swift b/Sources/App/ContentView.swift index de57dfa..86c4cd5 100644 --- a/Sources/App/ContentView.swift +++ b/Sources/App/ContentView.swift @@ -4,16 +4,31 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var appState: AppState + @Environment(\.scenePhase) private var scenePhase var body: some View { - Group { - if let credential = appState.credential { - MainTerminalView(credential: credential) - } else { - PairingFlowView { credential in - appState.didPair(credential: credential) + ZStack { + Group { + if let credential = appState.credential { + MainTerminalView(credential: credential) + } else { + PairingFlowView { credential in + appState.didPair(credential: credential) + } } } + .onChange(of: scenePhase) { _, new in + if new == .background { + appState.appDidBackground() + } else if new == .active { + Task { await appState.appWillForeground() } + } + } + + if appState.isLocked { + LockView() + } } + .task { await appState.appWillForeground() } } } diff --git a/Sources/Core/Auth/FaceIDGate.swift b/Sources/Core/Auth/FaceIDGate.swift new file mode 100644 index 0000000..e96424e --- /dev/null +++ b/Sources/Core/Auth/FaceIDGate.swift @@ -0,0 +1,24 @@ +// Sources/Core/Auth/FaceIDGate.swift +// T-2.11: Biometric gate — evaluates Face ID if enabled in settings. + +import Foundation +import LocalAuthentication + +/// Evaluates biometrics if Face ID is enabled in settings. +/// Returns true if auth succeeded or Face ID is disabled. +struct FaceIDGate: Sendable { + @MainActor + static func authenticate(reason: String = "Unlock pi remote") async -> Bool { + guard UserDefaults.standard.bool(forKey: "faceid.enabled") else { return true } + let context = LAContext() + var error: NSError? + guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { + return true // no biometrics available → allow + } + do { + return try await context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) + } catch { + return false + } + } +} diff --git a/Sources/Core/Auth/Pairing.swift b/Sources/Core/Auth/Pairing.swift index 3684199..ebd9ab6 100644 --- a/Sources/Core/Auth/Pairing.swift +++ b/Sources/Core/Auth/Pairing.swift @@ -71,7 +71,8 @@ struct PairingService: Sendable { } let pairingToken = try queryValue("pair") - let fingerprint = try queryValue("fp") + // fp may be empty pre-TLS (Phase 1); allowed for dev + let fingerprint = items.first(where: { $0.name == "fp" })?.value ?? "" let name = try queryValue("name") return (host: host, port: port, pairingToken: pairingToken, diff --git a/Sources/Core/Sessions/SessionRegistry.swift b/Sources/Core/Sessions/SessionRegistry.swift new file mode 100644 index 0000000..dba9c1b --- /dev/null +++ b/Sources/Core/Sessions/SessionRegistry.swift @@ -0,0 +1,120 @@ +// Sources/Core/Sessions/SessionRegistry.swift +// T-2.6: Session listing, creation, and deletion via the sidecar REST API. + +import Foundation + +// MARK: - SessionInfo + +public struct SessionInfo: Identifiable, Hashable, Sendable { + public let id: String + public let name: String + public let state: String // "running", "idle", etc. +} + +// MARK: - SessionRegistry + +@MainActor +public final class SessionRegistry: ObservableObject { + @Published public var sessions: [SessionInfo] = [] + @Published public var isLoading = false + @Published public var error: String? = nil + + public init() {} + + // MARK: - Private helpers + + private func baseURL(credential: SidecarCredential) -> URL { + URL(string: "http://\(credential.host):\(credential.port)")! + } + + private func authorizedRequest(url: URL, credential: SidecarCredential) -> URLRequest { + var req = URLRequest(url: url) + req.setValue("Bearer \(credential.bearerToken)", forHTTPHeaderField: "Authorization") + return req + } + + // MARK: - GET /sessions + + /// Fetches the current session list from the sidecar and updates `sessions`. + public func refresh(credential: SidecarCredential) async { + isLoading = true + error = nil + defer { isLoading = false } + + let url = baseURL(credential: credential).appendingPathComponent("sessions") + let req = authorizedRequest(url: url, credential: credential) + + do { + let (data, _) = try await URLSession.shared.data(for: req) + let items = try JSONDecoder().decode([SessionItem].self, from: data) + sessions = items.map { SessionInfo(id: $0.id, name: $0.name, state: $0.state) } + } catch { + self.error = error.localizedDescription + } + } + + // MARK: - POST /sessions + + /// Creates a new session with the given name and returns the created `SessionInfo`. + @discardableResult + public func spawnSession(name: String, credential: SidecarCredential) async throws -> SessionInfo { + let url = baseURL(credential: credential).appendingPathComponent("sessions") + var req = authorizedRequest(url: url, credential: credential) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONEncoder().encode(["name": name]) + + let (data, response) = try await URLSession.shared.data(for: req) + + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw SessionRegistryError.unexpectedStatus + } + + let item = try JSONDecoder().decode(SessionItem.self, from: data) + let info = SessionInfo(id: item.id, name: item.name, state: item.state) + + // Append optimistically; a follow-up refresh will normalise order. + sessions.append(info) + return info + } + + // MARK: - DELETE /sessions/ + + /// Deletes the session with the given id. + public func deleteSession(id: String, credential: SidecarCredential) async throws { + let url = baseURL(credential: credential) + .appendingPathComponent("sessions") + .appendingPathComponent(id) + var req = authorizedRequest(url: url, credential: credential) + req.httpMethod = "DELETE" + + let (_, response) = try await URLSession.shared.data(for: req) + + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw SessionRegistryError.unexpectedStatus + } + + sessions.removeAll { $0.id == id } + } +} + +// MARK: - Private Codable helper + +private struct SessionItem: Decodable { + let id: String + let name: String + let state: String +} + +// MARK: - Errors + +public enum SessionRegistryError: LocalizedError { + case unexpectedStatus + + public var errorDescription: String? { + switch self { + case .unexpectedStatus: + return "Unexpected HTTP status from sidecar." + } + } +} diff --git a/Sources/UI/Sessions/SessionRow.swift b/Sources/UI/Sessions/SessionRow.swift new file mode 100644 index 0000000..af74613 --- /dev/null +++ b/Sources/UI/Sessions/SessionRow.swift @@ -0,0 +1,40 @@ +// Sources/UI/Sessions/SessionRow.swift +// T-2.6: Row view for a single session in the SessionSwitcher list. + +import SwiftUI + +struct SessionRow: View { + let session: SessionInfo + + var body: some View { + HStack { + Text(session.name) + .font(.body) + Spacer() + stateBadge + } + } + + @ViewBuilder + private var stateBadge: some View { + HStack(spacing: 4) { + Circle() + .fill(badgeColor) + .frame(width: 8, height: 8) + Text(session.state) + .font(.caption) + .foregroundStyle(badgeColor) + } + } + + private var badgeColor: Color { + switch session.state.lowercased() { + case "running", "active": + return .green + case "idle": + return .yellow + default: + return .secondary + } + } +} diff --git a/Sources/UI/Sessions/SessionSwitcher.swift b/Sources/UI/Sessions/SessionSwitcher.swift new file mode 100644 index 0000000..f71d5db --- /dev/null +++ b/Sources/UI/Sessions/SessionSwitcher.swift @@ -0,0 +1,92 @@ +// Sources/UI/Sessions/SessionSwitcher.swift +// T-2.6: Sheet UI for listing, selecting, and creating sessions. + +import SwiftUI + +struct SessionSwitcher: View { + @ObservedObject var registry: SessionRegistry + let credential: SidecarCredential + let onSelect: (SessionInfo) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var showNewSessionAlert = false + @State private var newSessionName = "" + @State private var spawnError: String? = nil + + var body: some View { + NavigationStack { + Group { + if registry.isLoading { + ProgressView("Loading sessions…") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List(registry.sessions) { session in + Button { + onSelect(session) + dismiss() + } label: { + SessionRow(session: session) + } + .foregroundStyle(.primary) + } + .overlay { + if registry.sessions.isEmpty { + ContentUnavailableView( + "No Sessions", + systemImage: "terminal", + description: Text("Tap + to create a session.") + ) + } + } + } + } + .navigationTitle("Sessions") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Done") { dismiss() } + } + ToolbarItem(placement: .primaryAction) { + Button { + newSessionName = "" + spawnError = nil + showNewSessionAlert = true + } label: { + Image(systemName: "plus") + } + } + } + .alert("New Session", isPresented: $showNewSessionAlert) { + TextField("Session name", text: $newSessionName) + .autocorrectionDisabled() + Button("Create") { + let name = newSessionName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return } + Task { + do { + try await registry.spawnSession(name: name, credential: credential) + await registry.refresh(credential: credential) + } catch { + spawnError = error.localizedDescription + } + } + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Enter a name for the new session.") + } + .safeAreaInset(edge: .bottom) { + if let err = registry.error ?? spawnError { + Text(err) + .font(.caption) + .foregroundStyle(.red) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: .secondarySystemBackground)) + } + } + } + .task { await registry.refresh(credential: credential) } + } +} diff --git a/Sources/UI/Settings/LockView.swift b/Sources/UI/Settings/LockView.swift new file mode 100644 index 0000000..80da0af --- /dev/null +++ b/Sources/UI/Settings/LockView.swift @@ -0,0 +1,16 @@ +// Sources/UI/Settings/LockView.swift +// T-2.11: Full-screen lock overlay shown when isLocked == true. + +import SwiftUI + +struct LockView: View { + var body: some View { + ZStack { + Rectangle().fill(.ultraThinMaterial).ignoresSafeArea() + VStack(spacing: 16) { + Image(systemName: "lock.fill").font(.system(size: 48)) + Text("Locked").font(.title2) + } + } + } +} diff --git a/Sources/UI/Settings/SettingsView.swift b/Sources/UI/Settings/SettingsView.swift new file mode 100644 index 0000000..b31675e --- /dev/null +++ b/Sources/UI/Settings/SettingsView.swift @@ -0,0 +1,48 @@ +// Sources/UI/Settings/SettingsView.swift +// T-2.11: Settings sheet — Face ID toggle + credential info + unpair. + +import LocalAuthentication +import SwiftUI + +@MainActor +struct SettingsView: View { + let credential: SidecarCredential + @EnvironmentObject var appState: AppState + @Environment(\.dismiss) var dismiss + + @AppStorage("faceid.enabled") private var faceIDEnabled = false + + var body: some View { + NavigationStack { + Form { + Section("Security") { + Toggle("Require Face ID", isOn: $faceIDEnabled) + if faceIDEnabled { + Text("Face ID is required on launch and after 60 seconds in background.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section("Sidecar") { + LabeledContent("Name", value: credential.sidecarName) + LabeledContent("Host", value: "\(credential.host):\(credential.port)") + LabeledContent("Paired", value: credential.pairedAt.formatted(date: .abbreviated, time: .shortened)) + } + + Section("Danger") { + Button("Unpair", role: .destructive) { + appState.unpair() + dismiss() + } + } + } + .navigationTitle("Settings") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } +} diff --git a/Sources/UI/Status/StatusBar.swift b/Sources/UI/Status/StatusBar.swift new file mode 100644 index 0000000..dce4931 --- /dev/null +++ b/Sources/UI/Status/StatusBar.swift @@ -0,0 +1,99 @@ +// StatusBar.swift +// T-2.8 — Session status bar with pi-state indicator and action buttons. + +import Combine +import SwiftUI + +@MainActor +struct StatusBar: View { + let sessionName: String + let connectionStatus: String // "Connecting…", "Connected", "Disconnected" + @Binding var piState: PiState? + var onSwitcher: (() -> Void)? = nil + var onUnpair: (() -> Void)? = nil + var onSettings: (() -> Void)? = nil + + var body: some View { + VStack(spacing: 0) { + HStack { + // ── Left: session name ────────────────────────────── + Text(sessionName.isEmpty ? " " : sessionName) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + // ── Center: pi state / connection status ──────────── + stateIndicator + + Spacer() + + // ── Right: icon buttons ───────────────────────────── + HStack(spacing: 14) { + if onSwitcher != nil { + Button { + onSwitcher?() + } label: { + Image(systemName: "list.bullet") + .font(.caption) + } + } + + if onSettings != nil { + Button { + onSettings?() + } label: { + Image(systemName: "gear") + .font(.caption) + } + } + + if onUnpair != nil { + Button { + onUnpair?() + } label: { + Image(systemName: "x.circle") + .font(.caption) + .foregroundStyle(.red) + } + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(uiColor: .systemBackground)) + + Divider() + } + } + + // MARK: - State indicator + + @ViewBuilder + private var stateIndicator: some View { + if let state = piState { + switch state { + case .thinking: + Text("● thinking") + .font(.caption.monospaced()) + .foregroundStyle(.orange) + case .tool: + Text("▶ tool") + .font(.caption.monospaced()) + .foregroundStyle(.blue) + case .awaitingInput: + Text("⏸ awaiting") + .font(.caption.monospaced()) + .foregroundStyle(.yellow) + case .idle: + EmptyView() + } + } else { + Text(connectionStatus) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } +} diff --git a/Sources/UI/Terminal/MainTerminalView.swift b/Sources/UI/Terminal/MainTerminalView.swift index 235418c..fe3e05c 100644 --- a/Sources/UI/Terminal/MainTerminalView.swift +++ b/Sources/UI/Terminal/MainTerminalView.swift @@ -12,28 +12,26 @@ struct MainTerminalView: View { @State private var terminalVC = TerminalViewController() @State private var connection: SessionConnection? = nil @State private var statusText = "Connecting…" + @State private var currentPiState: PiState? = nil + @State private var sessionName = "" + @State private var showSwitcher = false + @State private var showSettings = false // T-2.11 + @State private var activeSessionId: String? = nil // T-2.6 @State private var cancellables = Set() + @StateObject private var registry = SessionRegistry() // T-2.6 @EnvironmentObject var appState: AppState var body: some View { VStack(spacing: 0) { // ── Status bar ────────────────────────────────────────── - HStack { - Text(statusText) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - Spacer() - Button("Unpair") { - appState.unpair() - } - .font(.caption) - .foregroundStyle(.red) - } - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(Color(uiColor: .systemBackground)) - - Divider() + StatusBar( + sessionName: sessionName, + connectionStatus: statusText, + piState: $currentPiState, + onSwitcher: { showSwitcher = true }, + onSettings: { showSettings = true }, // T-2.11 + onUnpair: { appState.unpair() } + ) // ── Terminal ──────────────────────────────────────────── TerminalViewRepresentable(controller: terminalVC) @@ -51,6 +49,16 @@ struct MainTerminalView: View { .background(Color(uiColor: .secondarySystemBackground)) } .task { await bootstrap() } + .task { await registry.refresh(credential: credential) } // T-2.6 + .sheet(isPresented: $showSwitcher) { // T-2.6 + SessionSwitcher(registry: registry, credential: credential) { session in + activeSessionId = session.id + } + } + .sheet(isPresented: $showSettings) { // T-2.11 + SettingsView(credential: credential) + .environmentObject(appState) + } } // MARK: - Bootstrap @@ -113,7 +121,7 @@ struct MainTerminalView: View { .first() .receive(on: DispatchQueue.main) .sink { [weak conn, terminalVC] _ in - // Clear immediately — don’t show stale/mismatched content. + // Clear immediately — don't show stale/mismatched content. terminalVC.feed(data: Data("\u{1B}[H\u{1B}[2J".utf8)) Task { @MainActor in @@ -129,6 +137,24 @@ struct MainTerminalView: View { } .store(in: &cancellables) + // Wire stateEvents → currentPiState + conn.stateEvents + .compactMap { event -> PiState? in + if case .state(let s, _, _) = event { return s } else { return nil } + } + .receive(on: DispatchQueue.main) + .sink { state in currentPiState = state } + .store(in: &cancellables) + + // Wire stateEvents → sessionName + conn.stateEvents + .compactMap { event -> String? in + if case .sessionMeta(let name, _, _) = event { return name } else { return nil } + } + .receive(on: DispatchQueue.main) + .sink { name in sessionName = name } + .store(in: &cancellables) + connection = conn let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0