Compare commits
No commits in common. "267d8a0f2392d9c333c1b87ad7e8cacd13122adb" and "3a4a6af942c98ab77f3ba181c3c2ed7850536ef1" have entirely different histories.
267d8a0f23
...
3a4a6af942
|
|
@ -7,8 +7,6 @@ final class AppState: ObservableObject {
|
||||||
static let shared = AppState()
|
static let shared = AppState()
|
||||||
|
|
||||||
@Published var credential: SidecarCredential? = nil
|
@Published var credential: SidecarCredential? = nil
|
||||||
@Published var isLocked = false
|
|
||||||
@Published var lastForegroundedAt: Date = Date()
|
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Try loading persisted credential on launch
|
// Try loading persisted credential on launch
|
||||||
|
|
@ -24,18 +22,4 @@ final class AppState: ObservableObject {
|
||||||
credential = nil
|
credential = nil
|
||||||
Keychain.shared.delete(key: "piremote.credential")
|
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,10 +4,8 @@ import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
|
||||||
Group {
|
Group {
|
||||||
if let credential = appState.credential {
|
if let credential = appState.credential {
|
||||||
MainTerminalView(credential: credential)
|
MainTerminalView(credential: credential)
|
||||||
|
|
@ -17,18 +15,5 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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() }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
// 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,8 +71,7 @@ struct PairingService: Sendable {
|
||||||
}
|
}
|
||||||
|
|
||||||
let pairingToken = try queryValue("pair")
|
let pairingToken = try queryValue("pair")
|
||||||
// fp may be empty pre-TLS (Phase 1); allowed for dev
|
let fingerprint = try queryValue("fp")
|
||||||
let fingerprint = items.first(where: { $0.name == "fp" })?.value ?? ""
|
|
||||||
let name = try queryValue("name")
|
let name = try queryValue("name")
|
||||||
|
|
||||||
return (host: host, port: port, pairingToken: pairingToken,
|
return (host: host, port: port, pairingToken: pairingToken,
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,11 @@ public enum ClientToServer: Sendable {
|
||||||
case paste(data: String)
|
case paste(data: String)
|
||||||
/// Request a full ANSI snapshot of the current pane.
|
/// Request a full ANSI snapshot of the current pane.
|
||||||
case snapshotRequest
|
case snapshotRequest
|
||||||
/// Notify the sidecar of the client's terminal dimensions so tmux can
|
|
||||||
/// resize the window to match. Send on connect and on every layout change.
|
|
||||||
case resize(cols: Int, rows: Int)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ClientToServer: Encodable {
|
extension ClientToServer: Encodable {
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case type, lastSeq, name, data, cols, rows
|
case type, lastSeq, name, data
|
||||||
}
|
}
|
||||||
|
|
||||||
public func encode(to encoder: any Encoder) throws {
|
public func encode(to encoder: any Encoder) throws {
|
||||||
|
|
@ -126,11 +123,6 @@ extension ClientToServer: Encodable {
|
||||||
|
|
||||||
case .snapshotRequest:
|
case .snapshotRequest:
|
||||||
try c.encode("snapshot-request", forKey: .type)
|
try c.encode("snapshot-request", forKey: .type)
|
||||||
|
|
||||||
case .resize(let cols, let rows):
|
|
||||||
try c.encode("resize", forKey: .type)
|
|
||||||
try c.encode(cols, forKey: .cols)
|
|
||||||
try c.encode(rows, forKey: .rows)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,10 +35,6 @@ public final class SessionConnection: ObservableObject {
|
||||||
/// Emits every JSON frame received from the server.
|
/// Emits every JSON frame received from the server.
|
||||||
public let stateEvents = PassthroughSubject<ServerToClient, Never>()
|
public let stateEvents = PassthroughSubject<ServerToClient, Never>()
|
||||||
|
|
||||||
/// Emits snapshot content ready to feed directly into SwiftTerm:
|
|
||||||
/// ESC[H + ESC[2J (clear+home) followed by the pane's current content.
|
|
||||||
public let snapshots = PassthroughSubject<Data, Never>()
|
|
||||||
|
|
||||||
/// Tracks the WebSocket lifecycle.
|
/// Tracks the WebSocket lifecycle.
|
||||||
@Published public private(set) var connectionState: ConnectionState = .disconnected
|
@Published public private(set) var connectionState: ConnectionState = .disconnected
|
||||||
|
|
||||||
|
|
@ -101,41 +97,24 @@ public final class SessionConnection: ObservableObject {
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// JSON frames → `stateEvents` + snapshot converter.
|
// JSON frames → `stateEvents` subject.
|
||||||
ws.incomingJSON
|
ws.incomingJSON
|
||||||
.sink { [weak self] frame in
|
.sink { [weak self] frame in
|
||||||
guard let self else { return }
|
self?.stateEvents.send(frame)
|
||||||
if case .snapshot(_, let base64) = frame {
|
|
||||||
// Decode base64 → text, prepend clear+home, normalise line endings.
|
|
||||||
if let raw = Data(base64Encoded: base64),
|
|
||||||
let text = String(data: raw, encoding: .utf8) {
|
|
||||||
let header = "\u{1B}[H\u{1B}[2J" // cursor home + clear screen
|
|
||||||
let body = text.replacingOccurrences(of: "\n", with: "\r\n")
|
|
||||||
self.snapshots.send(Data((header + body).utf8))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.stateEvents.send(frame)
|
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Once connected, send the appropriate opening frame.
|
// Once connected, send the resume frame.
|
||||||
//
|
|
||||||
// • lastSeq == nil → fresh attach: live output already flows; caller
|
|
||||||
// will send resize then snapshotRequest. No frame sent here.
|
|
||||||
// • lastSeq != nil → reconnect after gap: replay missed output.
|
|
||||||
ws.connectionState
|
ws.connectionState
|
||||||
.filter { $0 == .connected }
|
.filter { $0 == .connected }
|
||||||
.first()
|
.first()
|
||||||
.sink { [weak self, weak ws, lastSeq] _ in
|
.sink { [weak self, weak ws, lastSeq] _ in
|
||||||
guard let self, let ws else { return }
|
guard let self, let ws else { return }
|
||||||
if let seq = lastSeq {
|
Task { @MainActor [self, ws, lastSeq] in
|
||||||
Task { @MainActor [self, ws, seq] in
|
try? await ws.send(.resume(lastSeq: lastSeq))
|
||||||
try? await ws.send(.resume(lastSeq: seq))
|
_ = self // silence unused-capture warning
|
||||||
_ = self
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// fresh connect: caller drives resize → snapshotRequest
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
ws.connect(url: url)
|
ws.connect(url: url)
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
// 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."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,92 +0,0 @@
|
||||||
// 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) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
// 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() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,7 @@ import Combine
|
||||||
|
|
||||||
private let kFontIdKey = "terminal.font"
|
private let kFontIdKey = "terminal.font"
|
||||||
private let kFontSizeKey = "terminal.fontSize"
|
private let kFontSizeKey = "terminal.fontSize"
|
||||||
private let kDefaultSize: CGFloat = 11
|
private let kDefaultSize: CGFloat = 13
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
public final class FontStore: ObservableObject {
|
public final class FontStore: ObservableObject {
|
||||||
|
|
@ -30,11 +30,8 @@ public final class FontStore: ObservableObject {
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
// Restore point size (clamped to a sane range).
|
// Restore point size (clamped to a sane range).
|
||||||
// If stored value is the old default (13pt), migrate to new default (11pt).
|
|
||||||
let storedSize = UserDefaults.standard.object(forKey: kFontSizeKey) as? CGFloat
|
let storedSize = UserDefaults.standard.object(forKey: kFontSizeKey) as? CGFloat
|
||||||
if storedSize == 13 { UserDefaults.standard.removeObject(forKey: kFontSizeKey) }
|
size = storedSize.map { max(8, min(32, $0)) } ?? kDefaultSize
|
||||||
let effectiveStored = storedSize == 13 ? nil : storedSize
|
|
||||||
size = effectiveStored.map { max(8, min(32, $0)) } ?? kDefaultSize
|
|
||||||
|
|
||||||
// Restore selected font id.
|
// Restore selected font id.
|
||||||
let all: [TerminalFont] = [.sfMono, .menlo, .jetBrainsMono]
|
let all: [TerminalFont] = [.sfMono, .menlo, .jetBrainsMono]
|
||||||
|
|
|
||||||
|
|
@ -12,26 +12,31 @@ struct MainTerminalView: View {
|
||||||
@State private var terminalVC = TerminalViewController()
|
@State private var terminalVC = TerminalViewController()
|
||||||
@State private var connection: SessionConnection? = nil
|
@State private var connection: SessionConnection? = nil
|
||||||
@State private var statusText = "Connecting…"
|
@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>()
|
@State private var cancellables = Set<AnyCancellable>()
|
||||||
@StateObject private var registry = SessionRegistry() // T-2.6
|
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// ── Status bar ──────────────────────────────────────────
|
// ── Status bar ──────────────────────────────────────────
|
||||||
StatusBar(
|
HStack {
|
||||||
sessionName: sessionName,
|
Circle()
|
||||||
connectionStatus: statusText,
|
.fill(connection != nil ? Color.green : Color.orange)
|
||||||
piState: $currentPiState,
|
.frame(width: 8, height: 8)
|
||||||
onSwitcher: { showSwitcher = true },
|
Text(statusText)
|
||||||
onSettings: { showSettings = true }, // T-2.11
|
.font(.caption.monospaced())
|
||||||
onUnpair: { appState.unpair() }
|
.foregroundStyle(.secondary)
|
||||||
)
|
Spacer()
|
||||||
|
Button("Unpair") {
|
||||||
|
appState.unpair()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color(uiColor: .systemBackground))
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
// ── Terminal ────────────────────────────────────────────
|
// ── Terminal ────────────────────────────────────────────
|
||||||
TerminalViewRepresentable(controller: terminalVC)
|
TerminalViewRepresentable(controller: terminalVC)
|
||||||
|
|
@ -49,16 +54,6 @@ struct MainTerminalView: View {
|
||||||
.background(Color(uiColor: .secondarySystemBackground))
|
.background(Color(uiColor: .secondarySystemBackground))
|
||||||
}
|
}
|
||||||
.task { await bootstrap() }
|
.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
|
// MARK: - Bootstrap
|
||||||
|
|
@ -70,7 +65,7 @@ struct MainTerminalView: View {
|
||||||
statusText = "Connecting to \(sessionId)…"
|
statusText = "Connecting to \(sessionId)…"
|
||||||
let conn = SessionConnection(id: sessionId, credential: credential)
|
let conn = SessionConnection(id: sessionId, credential: credential)
|
||||||
|
|
||||||
// Wire live ANSI stream → terminal
|
// Wire stream → terminal
|
||||||
conn.stream
|
conn.stream
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
.sink { [terminalVC] data in
|
.sink { [terminalVC] data in
|
||||||
|
|
@ -78,14 +73,6 @@ struct MainTerminalView: View {
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Wire snapshots → terminal (already contains ESC[H + ESC[2J prefix)
|
|
||||||
conn.snapshots
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [terminalVC] data in
|
|
||||||
terminalVC.feed(data: data)
|
|
||||||
}
|
|
||||||
.store(in: &cancellables)
|
|
||||||
|
|
||||||
// Wire connection state → status text
|
// Wire connection state → status text
|
||||||
conn.$connectionState
|
conn.$connectionState
|
||||||
.receive(on: DispatchQueue.main)
|
.receive(on: DispatchQueue.main)
|
||||||
|
|
@ -98,69 +85,10 @@ struct MainTerminalView: View {
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
// Wire resize callback — fires on layout + font changes.
|
|
||||||
terminalVC.onResize = { [weak conn] cols, rows in
|
|
||||||
guard let conn else { return }
|
|
||||||
Task { try? await conn.send(.resize(cols: cols, rows: rows)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire keyboard input from SwiftTerm → sidecar.
|
|
||||||
terminalVC.onInput = { [weak conn] data in
|
|
||||||
guard let conn,
|
|
||||||
let text = String(data: data, encoding: .utf8) else { return }
|
|
||||||
Task { try? await conn.send(.keys(data: text)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
// On first connection:
|
|
||||||
// 1. Clear SwiftTerm immediately (removes stale content)
|
|
||||||
// 2. Send resize so tmux + shell know the real dimensions
|
|
||||||
// 3. Wait for SIGWINCH to propagate and shell to redraw
|
|
||||||
// 4. Snapshot the now-stable screen state
|
|
||||||
conn.$connectionState
|
|
||||||
.filter { $0 == .connected }
|
|
||||||
.first()
|
|
||||||
.receive(on: DispatchQueue.main)
|
|
||||||
.sink { [weak conn, terminalVC] _ in
|
|
||||||
// Clear immediately — don't show stale/mismatched content.
|
|
||||||
terminalVC.feed(data: Data("\u{1B}[H\u{1B}[2J".utf8))
|
|
||||||
|
|
||||||
Task { @MainActor in
|
|
||||||
let (cols, rows) = terminalVC.terminalSize
|
|
||||||
if cols > 0 && rows > 0 {
|
|
||||||
try? await conn?.send(.resize(cols: cols, rows: rows))
|
|
||||||
}
|
|
||||||
// 600 ms: SIGWINCH → fish/bash redraws → tmux pane settles.
|
|
||||||
// SSH sessions need extra round-trip time.
|
|
||||||
try? await Task.sleep(nanoseconds: 600_000_000)
|
|
||||||
try? await conn?.send(.snapshotRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.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
|
connection = conn
|
||||||
|
await conn.resume(from: conn.scrollback.sizeBytes > 0
|
||||||
let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0
|
|
||||||
? ResumeCursor().lastSeq(for: sessionId)
|
? ResumeCursor().lastSeq(for: sessionId)
|
||||||
: nil
|
: nil)
|
||||||
await conn.resume(from: lastSeq)
|
|
||||||
} catch {
|
} catch {
|
||||||
statusText = "Error: \(error.localizedDescription)"
|
statusText = "Error: \(error.localizedDescription)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,6 @@ public final class TerminalViewController: UIViewController {
|
||||||
/// forwarded to the remote PTY (wired by T-2.5).
|
/// forwarded to the remote PTY (wired by T-2.5).
|
||||||
public var onInput: ((Data) -> Void)?
|
public var onInput: ((Data) -> Void)?
|
||||||
|
|
||||||
/// Called when SwiftTerm recalculates its column/row count (layout,
|
|
||||||
/// font change, rotation). Forward this to the sidecar so tmux resizes.
|
|
||||||
public var onResize: ((Int, Int) -> Void)?
|
|
||||||
|
|
||||||
// MARK: View lifecycle
|
// MARK: View lifecycle
|
||||||
|
|
||||||
public override func viewDidLoad() {
|
public override func viewDidLoad() {
|
||||||
|
|
@ -143,11 +139,8 @@ extension TerminalViewController: TerminalViewDelegate {
|
||||||
// MARK: Required — size change notification
|
// MARK: Required — size change notification
|
||||||
|
|
||||||
public nonisolated func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
public nonisolated func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
||||||
// Propagate to whoever manages the sidecar connection so they can
|
// No-op in T-2.3; the stream layer (T-2.5) will negotiate PTY size
|
||||||
// call `tmux resize-window` and keep line-wrapping in sync.
|
// with the sidecar when it subscribes to this controller.
|
||||||
MainActor.assumeIsolated {
|
|
||||||
onResize?(newCols, newRows)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Required — title change
|
// MARK: Required — title change
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue