Merge T-2.6 + T-2.8 + T-2.11 into main
This commit is contained in:
commit
267d8a0f23
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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/<id>
|
||||
|
||||
/// 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AnyCancellable>()
|
||||
@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
|
||||
|
|
|
|||
Loading…
Reference in New Issue