Compare commits
5 Commits
3a4a6af942
...
e8b3cc422f
| Author | SHA1 | Date |
|---|---|---|
|
|
e8b3cc422f | |
|
|
fb56c11a29 | |
|
|
d085444adc | |
|
|
994b450fe4 | |
|
|
044a4920bb |
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -93,11 +93,14 @@ public enum ClientToServer: Sendable {
|
|||
case paste(data: String)
|
||||
/// Request a full ANSI snapshot of the current pane.
|
||||
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 {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case type, lastSeq, name, data
|
||||
case type, lastSeq, name, data, cols, rows
|
||||
}
|
||||
|
||||
public func encode(to encoder: any Encoder) throws {
|
||||
|
|
@ -123,6 +126,11 @@ extension ClientToServer: Encodable {
|
|||
|
||||
case .snapshotRequest:
|
||||
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,6 +35,10 @@ public final class SessionConnection: ObservableObject {
|
|||
/// Emits every JSON frame received from the server.
|
||||
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.
|
||||
@Published public private(set) var connectionState: ConnectionState = .disconnected
|
||||
|
||||
|
|
@ -97,23 +101,40 @@ public final class SessionConnection: ObservableObject {
|
|||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// JSON frames → `stateEvents` subject.
|
||||
// JSON frames → `stateEvents` + snapshot converter.
|
||||
ws.incomingJSON
|
||||
.sink { [weak self] frame in
|
||||
self?.stateEvents.send(frame)
|
||||
guard let self else { return }
|
||||
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)
|
||||
|
||||
// Once connected, send the resume frame.
|
||||
// Once connected, send the appropriate opening 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
|
||||
.filter { $0 == .connected }
|
||||
.first()
|
||||
.sink { [weak self, weak ws, lastSeq] _ in
|
||||
guard let self, let ws else { return }
|
||||
Task { @MainActor [self, ws, lastSeq] in
|
||||
try? await ws.send(.resume(lastSeq: lastSeq))
|
||||
_ = self // silence unused-capture warning
|
||||
if let seq = lastSeq {
|
||||
Task { @MainActor [self, ws, seq] in
|
||||
try? await ws.send(.resume(lastSeq: seq))
|
||||
_ = self
|
||||
}
|
||||
}
|
||||
// fresh connect: caller drives resize → snapshotRequest
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
NavigationView {
|
||||
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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,7 +8,7 @@ import Combine
|
|||
|
||||
private let kFontIdKey = "terminal.font"
|
||||
private let kFontSizeKey = "terminal.fontSize"
|
||||
private let kDefaultSize: CGFloat = 13
|
||||
private let kDefaultSize: CGFloat = 11
|
||||
|
||||
@MainActor
|
||||
public final class FontStore: ObservableObject {
|
||||
|
|
@ -30,8 +30,11 @@ public final class FontStore: ObservableObject {
|
|||
|
||||
private init() {
|
||||
// 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
|
||||
size = storedSize.map { max(8, min(32, $0)) } ?? kDefaultSize
|
||||
if storedSize == 13 { UserDefaults.standard.removeObject(forKey: kFontSizeKey) }
|
||||
let effectiveStored = storedSize == 13 ? nil : storedSize
|
||||
size = effectiveStored.map { max(8, min(32, $0)) } ?? kDefaultSize
|
||||
|
||||
// Restore selected font id.
|
||||
let all: [TerminalFont] = [.sfMono, .menlo, .jetBrainsMono]
|
||||
|
|
|
|||
|
|
@ -13,19 +13,21 @@ struct MainTerminalView: View {
|
|||
@State private var connection: SessionConnection? = nil
|
||||
@State private var statusText = "Connecting…"
|
||||
@State private var cancellables = Set<AnyCancellable>()
|
||||
@State private var showSettings = false
|
||||
@EnvironmentObject var appState: AppState
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// ── Status bar ──────────────────────────────────────────
|
||||
HStack {
|
||||
Circle()
|
||||
.fill(connection != nil ? Color.green : Color.orange)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(statusText)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button { showSettings = true } label: {
|
||||
Image(systemName: "gear")
|
||||
}
|
||||
.font(.caption)
|
||||
Button("Unpair") {
|
||||
appState.unpair()
|
||||
}
|
||||
|
|
@ -54,6 +56,9 @@ struct MainTerminalView: View {
|
|||
.background(Color(uiColor: .secondarySystemBackground))
|
||||
}
|
||||
.task { await bootstrap() }
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(credential: credential).environmentObject(appState)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap
|
||||
|
|
@ -65,7 +70,7 @@ struct MainTerminalView: View {
|
|||
statusText = "Connecting to \(sessionId)…"
|
||||
let conn = SessionConnection(id: sessionId, credential: credential)
|
||||
|
||||
// Wire stream → terminal
|
||||
// Wire live ANSI stream → terminal
|
||||
conn.stream
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [terminalVC] data in
|
||||
|
|
@ -73,6 +78,14 @@ struct MainTerminalView: View {
|
|||
}
|
||||
.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
|
||||
conn.$connectionState
|
||||
.receive(on: DispatchQueue.main)
|
||||
|
|
@ -85,10 +98,51 @@ struct MainTerminalView: View {
|
|||
}
|
||||
.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)
|
||||
|
||||
connection = conn
|
||||
await conn.resume(from: conn.scrollback.sizeBytes > 0
|
||||
|
||||
let lastSeq: UInt64? = conn.scrollback.sizeBytes > 0
|
||||
? ResumeCursor().lastSeq(for: sessionId)
|
||||
: nil)
|
||||
: nil
|
||||
await conn.resume(from: lastSeq)
|
||||
} catch {
|
||||
statusText = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ public final class TerminalViewController: UIViewController {
|
|||
/// forwarded to the remote PTY (wired by T-2.5).
|
||||
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
|
||||
|
||||
public override func viewDidLoad() {
|
||||
|
|
@ -139,8 +143,11 @@ extension TerminalViewController: TerminalViewDelegate {
|
|||
// MARK: Required — size change notification
|
||||
|
||||
public nonisolated func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) {
|
||||
// No-op in T-2.3; the stream layer (T-2.5) will negotiate PTY size
|
||||
// with the sidecar when it subscribes to this controller.
|
||||
// Propagate to whoever manages the sidecar connection so they can
|
||||
// call `tmux resize-window` and keep line-wrapping in sync.
|
||||
MainActor.assumeIsolated {
|
||||
onResize?(newCols, newRows)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Required — title change
|
||||
|
|
|
|||
Loading…
Reference in New Issue