Compare commits

...

6 Commits

Author SHA1 Message Date
jay 267d8a0f23 Merge T-2.6 + T-2.8 + T-2.11 into main 2026-05-16 11:54:22 +02:00
jay 7be9e64a95 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)
2026-05-16 11:54:12 +02:00
jay fb56c11a29 fix: default font size 13pt → 11pt for ~53 cols on iPhone 12 mini 2026-05-16 04:04:48 +02:00
jay d085444adc fix: clear terminal on connect, increase SIGWINCH settle time to 600ms 2026-05-16 04:00:02 +02:00
jay 994b450fe4 fix: fresh connect uses resize+snapshot instead of full history replay; wire onInput 2026-05-16 03:46:24 +02:00
jay 044a4920bb fix: terminal rendering — resize sync, TERM via sidecar, remove double-dot status 2026-05-16 03:30:31 +02:00
15 changed files with 622 additions and 40 deletions

View File

@ -7,6 +7,8 @@ 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
@ -22,4 +24,18 @@ 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
}
} }

View File

@ -4,16 +4,31 @@ 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 {
Group { ZStack {
if let credential = appState.credential { Group {
MainTerminalView(credential: credential) if let credential = appState.credential {
} else { MainTerminalView(credential: credential)
PairingFlowView { credential in } else {
appState.didPair(credential: credential) 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() }
} }
} }

View File

@ -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
}
}
}

View File

@ -71,7 +71,8 @@ struct PairingService: Sendable {
} }
let pairingToken = try queryValue("pair") 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") let name = try queryValue("name")
return (host: host, port: port, pairingToken: pairingToken, return (host: host, port: port, pairingToken: pairingToken,

View File

@ -93,11 +93,14 @@ 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 case type, lastSeq, name, data, cols, rows
} }
public func encode(to encoder: any Encoder) throws { public func encode(to encoder: any Encoder) throws {
@ -123,6 +126,11 @@ 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)
} }
} }
} }

View File

@ -35,6 +35,10 @@ 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
@ -97,23 +101,40 @@ public final class SessionConnection: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
// JSON frames `stateEvents` subject. // JSON frames `stateEvents` + snapshot converter.
ws.incomingJSON ws.incomingJSON
.sink { [weak self] frame in .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) .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 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 }
Task { @MainActor [self, ws, lastSeq] in if let seq = lastSeq {
try? await ws.send(.resume(lastSeq: lastSeq)) Task { @MainActor [self, ws, seq] in
_ = self // silence unused-capture warning try? await ws.send(.resume(lastSeq: seq))
_ = self
}
} }
// fresh connect: caller drives resize snapshotRequest
} }
.store(in: &cancellables) .store(in: &cancellables)

View File

@ -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."
}
}
}

View File

@ -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
}
}
}

View File

@ -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) }
}
}

View File

@ -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)
}
}
}
}

View File

@ -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() }
}
}
}
}
}

View File

@ -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)
}
}
}

View File

@ -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 = 13 private let kDefaultSize: CGFloat = 11
@MainActor @MainActor
public final class FontStore: ObservableObject { public final class FontStore: ObservableObject {
@ -30,8 +30,11 @@ 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
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. // Restore selected font id.
let all: [TerminalFont] = [.sfMono, .menlo, .jetBrainsMono] let all: [TerminalFont] = [.sfMono, .menlo, .jetBrainsMono]

View File

@ -12,31 +12,26 @@ 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
HStack { StatusBar(
Circle() sessionName: sessionName,
.fill(connection != nil ? Color.green : Color.orange) connectionStatus: statusText,
.frame(width: 8, height: 8) piState: $currentPiState,
Text(statusText) onSwitcher: { showSwitcher = true },
.font(.caption.monospaced()) onSettings: { showSettings = true }, // T-2.11
.foregroundStyle(.secondary) onUnpair: { appState.unpair() }
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)
@ -54,6 +49,16 @@ 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
@ -65,7 +70,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 stream terminal // Wire live ANSI stream terminal
conn.stream conn.stream
.receive(on: DispatchQueue.main) .receive(on: DispatchQueue.main)
.sink { [terminalVC] data in .sink { [terminalVC] data in
@ -73,6 +78,14 @@ 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)
@ -85,10 +98,69 @@ 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)"
} }

View File

@ -31,6 +31,10 @@ 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() {
@ -139,8 +143,11 @@ 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) {
// No-op in T-2.3; the stream layer (T-2.5) will negotiate PTY size // Propagate to whoever manages the sidecar connection so they can
// with the sidecar when it subscribes to this controller. // call `tmux resize-window` and keep line-wrapping in sync.
MainActor.assumeIsolated {
onResize?(newCols, newRows)
}
} }
// MARK: Required title change // MARK: Required title change