Continue iOS port with shared settings and Metrics

This commit is contained in:
Kaede Akino committed 2026-09-11 15:12:37 +08:00
1 parent c2958a05b3
commit 1b835a5df4
117 files changed
+7792 -5492

No files matched your search

@@ -0,0 +1,86 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@Suite @MainActor
struct SharedMetricsJourneyTests {
@Test func credentialsContinueTheSameMonitorWithoutSavingAnIdentity() async throws {
let host = TermindDomain.Host(name: "Monitor", host: "example.invalid")
let hosts = HostLibraryModel(hosts: [host])
let sampler = PortMetricsSampler()
let model = ServerMetricsModel(
hosts: hosts, preferences: PortMetricsPreferences(), makeSampler: { sampler },
discover: { host, _, _ in
SSHHostKeyObservation(
hostname: host.host, port: host.port, algorithm: .ed25519, fingerprint: "SHA256:test")
})
defer { model.shutdown() }
#expect(model.add(host))
let server = try #require(model.servers.first)
try await wait { server.authenticationPrompt != nil }
#expect(server.showsConnectionJourney)
#expect(server.journey.purpose == .metrics)
#expect(server.journey.localRequirement == .identity)
let promptID = server.authenticationPrompt?.id
#expect(server.authenticationPrompt?.id == promptID)
model.observeDetails(hostID: host.id, observer: UUID())
model.submitAuthentication(
server, identityID: nil, username: "deploy", password: "test-only", passphrase: nil, keyID: nil, save: false
)
try await wait { server.hostKeyPrompt != nil }
let observation = try #require(server.hostKey)
model.acceptHostKey(server, observation: observation, remember: false)
try await wait { server.point != nil }
#expect(hosts.identities.isEmpty)
#expect(hosts.knownHosts(for: host).isEmpty)
#expect(model.servers.count == 1)
#expect(await sampler.connections == 1)
try await wait { server.processesDate != nil }
#expect(await sampler.processRequests == 1)
model.suspend()
#expect(model.snapshot(for: host.id)?.state == .sleeping)
model.remove(host.id)
#expect(model.servers.isEmpty)
}
@Test func checkpointAndLogsSurviveAuthenticationContinuation() {
let host = TermindDomain.Host(name: "Target", host: "example.invalid")
let journey = ConnectionJourney()
journey.begin(host: host, route: [host], purpose: .sshSession, username: "deploy")
journey.waitForHostKey(at: host.id, fingerprint: "SHA256:test")
journey.resume()
let logs = journey.logs
journey.begin(host: host, route: [host], purpose: .sshSession, username: "deploy")
#expect(journey.completedCheckpoints[1] == .hostKey)
#expect(journey.logs.starts(with: logs))
}
private func wait(_ condition: () -> Bool) async throws {
for _ in 0..<200 {
if condition() { return }
try await Task.sleep(for: .milliseconds(5))
}
#expect(condition())
}
}
@MainActor private final class PortMetricsPreferences: MetricsConfigurationStoring {
func load() -> [MetricsSelection] { [] }
func save(_ selections: [MetricsSelection]) {}
}
private actor PortMetricsSampler: MetricsSampling {
var connections = 0
var processRequests = 0
func connect(_ configuration: SSHConnectionConfiguration) async throws { connections += 1 }
func sample() async throws -> MetricsSample {
.init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100)
}
func gpu() async throws -> NvidiaSnapshot? { nil }
func processes() async throws -> MetricsProcessSample? {
processRequests += 1
return MetricsProcessSample(cpu: [10, 0, 10, 80], coreCount: 1, processes: [])
}
func disconnect() async {}
}
@@ -0,0 +1,110 @@
import AgentKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import Testing
@Suite @MainActor
struct SharedSettingsPortTests {
@Test func failedTerminalSaveKeepsTheAppliedPreferences() {
let persistence = FailingTerminalPreferences()
let model = TerminalSettingsModel(persistence: persistence)
#expect(!model.set(20, for: \.fontSize))
#expect(model.preferences.fontSize == 12)
#expect(model.storageError != nil)
model.applyRemoteSnapshot(TerminalPreferences(fontSize: 18))
#expect(model.preferences.fontSize == 18)
#expect(persistence.saves == 1)
}
@Test func mobileSettingsEditsPreserveSkillsMCPAndUnresolvedRoles() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let domain = "SharedSettingsPortTests.\(UUID())"
let defaults = UserDefaults(suiteName: domain)!
defer { defaults.removePersistentDomain(forName: domain) }
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
let server = MCPServer(name: "Preserved")
let unresolved = AIModelRef(modelProviderID: UUID(), modelID: "not-arrived")
try store.saveConfiguration(.init(providers: [], roles: [.securityReview: unresolved], mcpServers: [server]))
let skills = try store.load().skills
let ai = AIConfigurationModel(persistence: SyncedAIConfigurationPersistence(store))
#expect(
ai.saveProvider(
ModelProvider(name: "Mobile"), endpoint: ProviderEndpoint(url: "https://example.invalid/v1")))
let saved = try store.load()
#expect(saved.skills == skills)
#expect(saved.configuration.mcpServers == [server])
#expect(saved.configuration.roles[.securityReview] == unresolved)
#expect(try !store.journal.pendingEnvelopes().isEmpty)
}
@Test func applyingRemoteTerminalDoesNotEnterLocalWriteCallback() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let domain = "SharedSettingsPortTests.\(UUID())"
let defaults = UserDefaults(suiteName: domain)!
defer { defaults.removePersistentDomain(forName: domain) }
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
try store.prepare()
var callbacks = 0
store.didRecordChanges = { _ in callbacks += 1 }
let preferences = TerminalPreferences(
fontSize: 19, darkCustomEnabled: true, darkCustomConfiguration: "background = 123456")
let payload = SettingsRecordPayload.terminal(preferences)
let envelope = SettingsEnvelope(
key: payload.key, version: .next(after: nil, writerID: UUID(), now: .now), payload: payload)
let result = try store.applyRemote([envelope])
#expect(result.snapshot.terminal == preferences)
#expect(defaults.double(forKey: SharedSettingsKey.terminalFontSize) == 19)
#expect(callbacks == 0)
}
@Test func syncDefaultsPreserveIndependentSelectionsWhenPaused() {
let domain = "SharedSettingsPortTests.\(UUID())"
let defaults = UserDefaults(suiteName: domain)!
defer { defaults.removePersistentDomain(forName: domain) }
SharedSettingsKey.migrateSyncPreferences(in: defaults)
#expect(defaults.bool(forKey: SharedSettingsKey.iCloudSyncEnabled))
#expect(defaults.object(forKey: SharedSettingsKey.settingsSyncEnabled) == nil)
defaults.set(true, forKey: SharedSettingsKey.settingsSyncEnabled)
defaults.set(false, forKey: SharedSettingsKey.iCloudSyncEnabled)
SharedSettingsKey.migrateSyncPreferences(in: defaults)
#expect(!defaults.bool(forKey: SharedSettingsKey.iCloudSyncEnabled))
#expect(defaults.bool(forKey: SharedSettingsKey.settingsSyncEnabled))
}
@Test func rolesTakePrecedenceOverLastSelectedModelAndDeletedModelsFallBack() {
let provider = ModelProvider(name: "Test", models: [AIModel(id: "role"), AIModel(id: "last")])
let role = AIModelRef(modelProviderID: provider.id, modelID: "role")
let last = AIModelRef(modelProviderID: provider.id, modelID: "last")
let ai = AIConfigurationModel(modelProviders: [provider], roles: [.chat: role], lastSelectedModel: last)
#expect(ai.resolve(conversation: nil as AIModelRef?)?.model.id == "role")
ai.removeModels(["role"], from: provider.id)
#expect(ai.resolve(.chat)?.model.id == "last")
}
@Test func historyRetainsTheNewestThousandAndUnknownCompletion() {
let model = CommandHistoryModel()
let host = UUID()
for index in 0...CommandHistoryModel.perHostLimit {
model.record("command \(index)", for: host, at: Date(timeIntervalSince1970: Double(index)))
}
let values = model.entries(for: host)
#expect(values.count == 1_000)
#expect(values.first?.command == "command 1000")
#expect(values.last?.command == "command 1")
#expect(values.allSatisfy { $0.exitStatus == nil && $0.duration == nil })
}
}
@MainActor private final class FailingTerminalPreferences: TerminalPreferencesPersisting {
var saves = 0
func loadTerminal() throws -> TerminalPreferences { .init() }
func saveTerminal(_ value: TerminalPreferences) throws {
saves += 1
throw CocoaError(.fileWriteNoPermission)
}
}
+220
View File
@@ -15670,6 +15670,226 @@
}
}
}
},
"%@ connected · %@ workspaces" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$@个已连接·%2$@个工作区"
}
}
}
},
"%@:%lld\\n%@\\n%@" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "%1$@:%2$lld\\n%3$@\\n%4$@"
}
}
}
},
"Accept Import Warnings" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "接受导入警告"
}
}
}
},
"Add Model" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "添加模型"
}
}
}
},
"Add Selected" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "添加所选模型"
}
}
}
},
"Available Models" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "可用模型"
}
}
}
},
"Clear History?" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "清空历史记录?"
}
}
}
},
"Clear Key" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "清除密钥"
}
}
}
},
"Edit Configuration" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "编辑配置"
}
}
}
},
"Exit Status" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出状态"
}
}
}
},
"Font" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "字体"
}
}
}
},
"Keep Existing Key" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "保留现有密钥"
}
}
}
},
"Known Hosts Only" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "仅限已知主机"
}
}
}
},
"Next Match" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "下一个匹配项"
}
}
}
},
"No Transfers" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "暂无传输"
}
}
}
},
"Previous Match" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "上一个匹配项"
}
}
}
},
"Remove Password" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "移除密码"
}
}
}
},
"Replace Key" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "替换密钥"
}
}
}
},
"Require User Presence" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "要求用户在场"
}
}
}
},
"Sort By" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "排序方式"
}
}
}
}
},
"version" : "1.2"
@@ -0,0 +1,241 @@
import AgentKit
import Foundation
/// The skills Termind ships with.
///
/// They exist so the feature is something the user *edits* rather than
/// something they have to invent. A library that starts empty asks a new user to
/// guess the format, the register, and the useful level of detail before they
/// have seen one work; three real runbooks answer all three, and the fastest way
/// to write a fourth is to open one of these and change it.
///
/// Ordinary skills in every other respect. They are copied into the library on
/// first launch and are the user's from that moment: editable, disableable, and
/// deletable, with nothing putting them back. The generation mark is what makes
/// that stick — seeding installs only what a later release added, so a skill
/// deleted today does not return tomorrow.
///
/// English, in every locale, and deliberately not in the string catalog. A
/// skill's body is model-facing prose that happens to be readable — the same
/// register as `AgentPlanContractInjection.contract` — and these are records the
/// user owns and rewrites from the moment they arrive. Translating them would
/// mean either rewriting someone's edited copy when they change languages, or
/// carrying two versions of six kilobytes of shell commentary that must not
/// drift apart. The user's own skills are written in whatever language they
/// write in; these are a starting point they are free to translate or replace.
///
/// The content is deliberately about *this* app's shape of work. Two are
/// investigations that stay read-only until they have something to say, and one
/// teaches the pull/edit/push loop that keeps a remote file's contents out of
/// the conversation entirely.
public nonisolated enum AgentSkillLibrary {
/// Bumped when a release adds starter skills, never for edits to existing
/// ones — a user's copy is theirs, and rewriting it under them would throw
/// away whatever they changed.
public static let currentGeneration = 1
public static func builtIns(after generation: Int) -> [AgentSkill] {
all.filter { $0.generation > generation }.map(\.skill)
}
private static var all: [(generation: Int, skill: AgentSkill)] {
[
(1, diskFullTriage), (1, failingService), (1, safeConfigEdit),
]
}
private static var diskFullTriage: AgentSkill {
AgentSkill(
id: UUID(uuidString: "9C362C57-BCCF-4B44-A1C4-000000000001")!,
name: "disk-full-triage",
title: "Disk Full Triage",
summary: """
Find what filled a disk and decide what is safe to reclaim. Use when a filesystem \
is at or near 100%, when writes are failing with "No space left on device", or \
when someone asks why a host is out of disk.
""",
body: """
# Disk Full Triage
Work out *what* grew before proposing to delete anything. A full disk is usually one
directory, and the fix depends on which.
## 1. Find the full filesystem
```
df -h
df -i
```
Check both. A filesystem at 100% inodes with free bytes is a different problem —
millions of small files, usually a session or cache directory — and `du` will not
show it as large.
## 2. Find what is using it
Descend from the mount point that is full, one level at a time:
```
du -x -h -d 1 /var 2>/dev/null | sort -h | tail -20
```
`-x` matters: without it you walk into other filesystems and misattribute their
size. Repeat into the largest child until a single directory explains the shortfall.
## 3. Check for deleted-but-open files
If `du` totals are far below what `df` reports, something is holding a deleted file
open — a rotated log a process never reopened:
```
lsof -nP +L1 2>/dev/null | head -40
```
That space returns when the process is restarted, not when the file is removed. Say
which process, and let the user decide about restarting it.
## 4. The usual culprits
- `journalctl --disk-usage`, capped with `journalctl --vacuum-size=…`
- `/var/log` with rotation misconfigured, or `*.log.1` never compressed
- Docker: `docker system df` before `docker system prune`
- Package caches: `/var/cache/apt`, `/var/cache/yum`
- A core dump or a database backup somebody left in place
## 5. Report before removing
Say what filled the disk, how much each candidate would return, and what the risk of
each is. Removing files is the user's decision — propose it and let the approval
prompt be where it happens. Never `rm -rf` a directory you have not listed first.
"""
)
}
private static var failingService: AgentSkill {
AgentSkill(
id: UUID(uuidString: "9C362C57-BCCF-4B44-A1C4-000000000002")!,
name: "failing-service-triage",
title: "Failing Service Triage",
summary: """
Diagnose a systemd unit that will not start or keeps restarting. Use when a service \
is down, flapping, reported as failed or inactive, or when a site or API on a host \
has stopped responding.
""",
body: """
# Failing Service Triage
Read the failure before touching the service. A restart that "fixes" it without a
cause found is the same outage again in an hour.
## 1. What the unit says
```
systemctl status <unit> --no-pager -l
systemctl show <unit> -p Result -p ExecMainStatus -p NRestarts
```
`Result` and the exit status usually name the class of failure on their own:
`exit-code` is the program's own error, `signal` is a crash or an OOM kill, `timeout`
is a unit that never signalled readiness.
## 2. What the logs say
```
journalctl -u <unit> -n 200 --no-pager
journalctl -u <unit> --since "1 hour ago" -p err --no-pager
```
Read from the *first* error of the most recent start, not the last line. The last
line is usually the supervisor giving up; the first is the reason.
## 3. Rule out the ordinary causes
- **Config**: validate before restarting — `nginx -t`, `sshd -t`,
`postgres --check`, `caddy validate`. A syntax error found here costs nothing.
- **Port already bound**: `ss -lntp | grep <port>`
- **Permissions**: the unit's `User=` against the paths in its `ExecStart=`
- **Out of memory**: `journalctl -k | grep -i "killed process"`
- **Dependency**: `systemctl list-dependencies <unit> --failed`
- **Disk**: a full filesystem breaks services in unrelated-looking ways
## 4. Then act
Say what failed and why, and propose the specific change. A restart is worth
proposing only once you can say what it will fix; when you cannot, say that instead —
an unexplained flap is worth more investigation, not a restart loop.
"""
)
}
private static var safeConfigEdit: AgentSkill {
AgentSkill(
id: UUID(uuidString: "9C362C57-BCCF-4B44-A1C4-000000000003")!,
name: "safe-config-edit",
title: "Safe Remote Config Edit",
summary: """
Change a configuration file on a host without risking a broken service or an \
unrecoverable state. Use when editing nginx, sshd, systemd units, database or \
application config on a remote machine.
""",
body: """
# Safe Remote Config Edit
The order matters more than the edit. Every step below exists because skipping it is
how a config change becomes an outage you cannot log in to fix.
## 1. Pull it, do not read it into the chat
```
scratch_pull → stage the file locally
scratch_copy → keep the pristine copy to diff against
scratch_replace → change the lines you mean to change
scratch_push → send it back
```
The contents never enter the conversation, revisions cost a diff instead of another
copy of the file, and the only step the user approves is the one that reaches their
host. Use `scratch_read` on the staged copy when you need to see it, and
`scratch_diff` against the pristine copy to check what you actually changed.
## 2. Keep a way back
Before pushing, copy the original beside itself:
```
cp -a /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak-$(date +%Y%m%d%H%M%S)
```
`-a` preserves owner, group and mode. A backup that root can read and the service
user cannot is not a backup.
## 3. Validate before reloading
```
nginx -t
sshd -t
systemd-analyze verify /etc/systemd/system/<unit>.service
visudo -c -f <file>
```
If the tool has a check mode, it runs before the reload, every time.
## 4. Reload rather than restart, where that exists
`systemctl reload nginx` keeps connections; `restart` drops them. Reload only after
validation passed.
## 5. Never lock yourself out
Editing `sshd_config`, a firewall, or anything else in the path you are connected
over: keep the current session open, validate, reload, and prove a *new* connection
works before closing anything. Say this to the user explicitly — they are the one who
has to keep the window open.
## 6. Say what changed
Report the diff, the backup path, the validation output, and how to roll back. A
change nobody can undo in one command is not finished.
"""
)
}
}
@@ -0,0 +1,73 @@
import Foundation
public nonisolated enum AppLocalization {
public static func string(
_ key: String.LocalizationValue, locale: Locale = .current
) -> String {
String(
localized: key,
bundle: bundle(for: locale),
locale: locale
)
}
private static func bundle(for locale: Locale) -> Bundle {
let localization: String? =
switch locale.language.languageCode?.identifier {
case "zh": "zh-Hans"
case "en": "en"
default: nil
}
guard let localization,
let path = Bundle.main.path(forResource: localization, ofType: "lproj"),
let bundle = Bundle(path: path)
else { return .main }
return bundle
}
}
public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendable {
case system
case english = "en"
case simplifiedChineseChina = "zh-Hans-CN"
public nonisolated var id: String { rawValue }
public var displayName: String {
switch self {
case .system: String(localized: "Follow System")
case .english: "English"
case .simplifiedChineseChina: "简体中文(中国)"
}
}
public nonisolated static func stored(in defaults: UserDefaults = .standard) -> AppLanguage {
guard let rawValue = defaults.string(forKey: SharedSettingsKey.appLanguage) else {
return .system
}
return AppLanguage(rawValue: rawValue) ?? .system
}
/// Keeps the app's explicit choice separate from Apple's per-app language
/// override. Removing `AppleLanguages` is what restores normal Bundle
/// negotiation when the user chooses Follow System.
public nonisolated static func store(
_ language: AppLanguage, in defaults: UserDefaults = .standard
) {
defaults.set(language.rawValue, forKey: SharedSettingsKey.appLanguage)
switch language {
case .system:
defaults.removeObject(forKey: "AppleLanguages")
case .english, .simplifiedChineseChina:
defaults.set([language.rawValue], forKey: "AppleLanguages")
}
}
public var locale: Locale {
switch self {
case .system: .autoupdatingCurrent
case .english: Locale(identifier: "en")
case .simplifiedChineseChina: Locale(identifier: rawValue)
}
}
}
@@ -0,0 +1,339 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
public nonisolated enum ConnectionPurpose: String, Hashable, Sendable {
case sshSession
case sftpSession
case serialSession
case metrics
}
public nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable {
public nonisolated enum Role: Hashable, Sendable {
case local
case jump
case destination
}
public nonisolated enum ID: Hashable, Sendable {
case local
case host(Host.ID)
case serial(String)
}
public var id: ID
public var hostID: Host.ID?
public var name: String
public var address: String
public var role: Role
}
/// UI-facing state for one connection attempt. The transport emits facts; this model
/// turns them into a monotonic route so reconnect probes cannot visually move
/// a completed path backwards.
@Observable
public final class ConnectionJourney {
public init() {}
public enum Mode: Equatable {
case connection
case reconnection
}
public enum Phase: Equatable {
case hidden
case connecting
case waiting
case succeeded
case failed(String)
}
public enum CheckpointKind: Equatable {
case authentication
case hostKey
}
public enum LocalRequirement: Equatable {
case identity
case passphrase
}
public struct Checkpoint: Equatable {
public var endpointIndex: Int
public var kind: CheckpointKind
}
public struct LogEntry: Identifiable, Equatable {
public let id = UUID()
public var date = Date()
public var endpoint: String
public var message: String
}
public private(set) var purpose: ConnectionPurpose = .sshSession
public private(set) var mode: Mode = .connection
public private(set) var endpoints: [ConnectionEndpoint] = []
public private(set) var phase: Phase = .hidden
public private(set) var activeEndpointIndex = 0
public private(set) var completedEndpointIndex = 0
public private(set) var checkpoint: Checkpoint?
public private(set) var completedCheckpoints: [Int: CheckpointKind] = [:]
public private(set) var localRequirement: LocalRequirement?
public private(set) var logs: [LogEntry] = []
public var showsLogs = false
private var completionTask: Task<Void, Never>?
public var isPresented: Bool { phase != .hidden }
public var destination: ConnectionEndpoint? { endpoints.last }
public var waitsForLocalIdentity: Bool { localRequirement == .identity }
public var waitsForLocalPassphrase: Bool { localRequirement == .passphrase }
private static var localDeviceName: String {
#if os(macOS)
String(localized: "This Mac")
#else
String(localized: "This Device")
#endif
}
public func begin(
host: Host, route: [Host], purpose: ConnectionPurpose,
username: String?, mode: Mode = .connection
) {
completionTask?.cancel()
let nextEndpoints =
[
ConnectionEndpoint(
id: .local, hostID: nil,
name: Self.localDeviceName, address: "localhost", role: .local
)
]
+ route.enumerated().map { index, node in
ConnectionEndpoint(
id: .host(node.id), hostID: node.id, name: node.name,
address: index == route.count - 1 && username?.isEmpty == false
? "\(username!)@\(node.displayAddress)" : node.displayAddress,
role: index == route.count - 1 ? .destination : .jump
)
}
let continuesJourney =
phase == .connecting && endpoints == nextEndpoints
&& !completedCheckpoints.isEmpty
let retainedCompletedEndpoint = continuesJourney ? completedEndpointIndex : 0
let retainedCheckpoints = continuesJourney ? completedCheckpoints : [:]
let retainedLogs = continuesJourney ? logs : []
let retainedLogVisibility = continuesJourney && showsLogs
self.purpose = purpose
self.mode = mode
endpoints = nextEndpoints
phase = .connecting
activeEndpointIndex = retainedCompletedEndpoint
completedEndpointIndex = retainedCompletedEndpoint
checkpoint = nil
completedCheckpoints = retainedCheckpoints
localRequirement = nil
logs = retainedLogs
showsLogs = retainedLogVisibility
let action = mode == .reconnection ? "Reconnecting " : "Starting "
log(
endpoint: endpoints.first?.name ?? Self.localDeviceName,
message: action + purpose.logName + " connection to " + host.name
)
if let username, !username.isEmpty {
log(endpoint: host.name, message: "Using login " + username)
}
}
public func beginSerial(name: String, address: String, mode: Mode = .connection) {
completionTask?.cancel()
purpose = .serialSession
self.mode = mode
endpoints = [
ConnectionEndpoint(
id: .local, hostID: nil,
name: Self.localDeviceName, address: "localhost", role: .local
),
ConnectionEndpoint(
id: .serial(address), hostID: nil,
name: name, address: address, role: .destination
),
]
phase = .connecting
activeEndpointIndex = 1
completedEndpointIndex = 0
checkpoint = nil
completedCheckpoints = [:]
localRequirement = nil
logs = []
showsLogs = false
log(
endpoint: endpoints[0].name,
message: (mode == .reconnection ? "Reconnecting " : "Starting ")
+ "serial connection to " + name
)
log(endpoint: name, message: "Opening " + address)
}
public func receive(_ event: SSHRouteEvent) {
let hostID: Host.ID
let message: String
switch event {
case .connecting(let id):
hostID = id
message = String(localized: "Opening SSH transport")
case .authenticating(let id):
hostID = id
message = String(localized: "Authenticating")
case .authenticated(let id):
hostID = id
message = String(localized: "Authentication accepted")
case .connected(let id):
hostID = id
message = String(localized: "Host connected")
case .initializing(let id, let detail):
hostID = id
message = detail
}
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
switch event {
case .connecting, .authenticating, .initializing:
if index > completedEndpointIndex { activeEndpointIndex = index }
case .authenticated:
checkpoint = nil
activeEndpointIndex = max(activeEndpointIndex, index)
case .connected:
checkpoint = nil
completedEndpointIndex = max(completedEndpointIndex, index)
activeEndpointIndex = max(activeEndpointIndex, index)
}
log(endpoint: endpoints[index].name, message: message)
}
public func waitForAuthentication(at hostID: Host.ID, failure: String? = nil) {
wait(at: hostID, kind: .authentication, message: failure ?? String(localized: "Waiting for authentication"))
}
/// Pauses before transport authentication because Termind has no usable local
/// Identity. This is not a server challenge, so the untouched route stays
/// gray and no midpoint checkpoint is introduced.
public func waitForLocalIdentity(at hostID: Host.ID, failure: String? = nil) {
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
activeEndpointIndex = 0
completedEndpointIndex = 0
checkpoint = nil
localRequirement = .identity
phase = .waiting
log(
endpoint: endpoints[index].name,
message: failure ?? String(localized: "Waiting for an Identity")
)
}
public func waitForLocalPassphrase(at hostID: Host.ID, failure: String? = nil) {
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
activeEndpointIndex = 0
completedEndpointIndex = 0
checkpoint = nil
localRequirement = .passphrase
phase = .waiting
log(
endpoint: endpoints[index].name,
message: failure ?? String(localized: "Waiting for a Passphrase")
)
}
public func waitForHostKey(at hostID: Host.ID, fingerprint: String) {
wait(
at: hostID, kind: .hostKey,
message: "Waiting to trust host key " + fingerprint
)
}
public func resume() {
guard phase == .waiting else { return }
phase = .connecting
localRequirement = nil
if let checkpoint {
log(endpoint: endpoints[checkpoint.endpointIndex].name, message: String(localized: "Continuing connection"))
completedCheckpoints[checkpoint.endpointIndex] = checkpoint.kind
}
checkpoint = nil
}
public func succeed() {
guard !endpoints.isEmpty else { return }
checkpoint = nil
localRequirement = nil
completedEndpointIndex = endpoints.count - 1
activeEndpointIndex = endpoints.count - 1
phase = .succeeded
log(endpoint: endpoints.last?.name ?? "", message: String(localized: "Connection ready"))
completionTask?.cancel()
completionTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(600))
guard !Task.isCancelled else { return }
self?.phase = .hidden
}
}
public func fail(_ message: String, at hostID: Host.ID? = nil) {
completionTask?.cancel()
checkpoint = nil
localRequirement = nil
if let hostID,
let index = endpoints.firstIndex(where: { $0.hostID == hostID })
{
activeEndpointIndex = index
}
phase = .failed(message)
let endpoint =
endpoints.indices.contains(activeEndpointIndex)
? endpoints[activeEndpointIndex].name : (endpoints.last?.name ?? "SSH")
log(endpoint: endpoint, message: "Failed: " + message)
}
public func hide() {
completionTask?.cancel()
checkpoint = nil
completedCheckpoints = [:]
localRequirement = nil
phase = .hidden
}
public func log(endpoint: String, message: String) {
logs.append(LogEntry(endpoint: endpoint, message: message))
}
public var plainTextLog: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return logs.map {
"[\(formatter.string(from: $0.date))] \($0.endpoint): \($0.message)"
}.joined(separator: "\n")
}
private func wait(at hostID: Host.ID, kind: CheckpointKind, message: String) {
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
activeEndpointIndex = index
checkpoint = Checkpoint(endpointIndex: index, kind: kind)
localRequirement = nil
phase = .waiting
log(endpoint: endpoints[index].name, message: message)
}
}
extension ConnectionPurpose {
fileprivate var logName: String {
switch self {
case .sshSession: "SSH session"
case .sftpSession: "SFTP session"
case .serialSession: "serial session"
case .metrics: "metrics"
}
}
}
@@ -351,20 +351,27 @@ public final class AIConfigurationModel {
}
}
/// The only place `url` and `apiFormat` are ever written.
///
/// Everything else edits a name, a key or a model list. Keeping the
/// derivation in one function is what lets the caption under the URL field
/// promise where requests land: there is exactly one rule turning a typed
/// address and a picked type into an address, and both the caption and the
/// request read it from the same place.
/// Commits a provider form and its endpoint together, without selecting a model.
@discardableResult
public func saveProvider(_ draft: ModelProvider, endpoint: ProviderEndpoint) -> Bool {
mutatePersistedState {
var value = draft
value.inferenceURL = endpoint.requestURL
value.baseURL = endpoint.baseURL
value.apiFormat = endpoint.format
endpoints[value.id] = endpoint
if let index = modelProviders.firstIndex(where: { $0.id == value.id }) {
modelProviders[index] = value
} else {
modelProviders.append(value)
}
}
}
/// Derives and commits the request URL, base URL and format through the same form transaction.
public func setEndpoint(_ endpoint: ProviderEndpoint, for id: ModelProvider.ID) {
guard var provider = modelProvider(id) else { return }
provider.inferenceURL = endpoint.requestURL
provider.baseURL = endpoint.baseURL
provider.apiFormat = endpoint.format
endpoints[id] = endpoint
upsert(provider)
guard let provider = modelProvider(id) else { return }
saveProvider(provider, endpoint: endpoint)
}
/// Removes a provider along with everything that only made sense while it
@@ -0,0 +1,101 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
/// Incremental persistence boundary for terminal history.
///
/// Preview and focused-test instances use the in-memory projection. The app
/// supplies its SwiftData context so inserts and deletes touch only history
/// rows, independently of HostLibraryModel's compatibility snapshot persistence.
@MainActor
@Observable
public final class CommandHistoryModel {
public static let perHostLimit = 1_000
private let repository: any CommandHistoryRepository
public private(set) var revision: UInt64 = 0
public private(set) var storageError: String?
public init(
entries: [CommandHistoryEntry] = [],
repository: (any CommandHistoryRepository)? = nil
) {
self.repository = repository ?? InMemoryCommandHistoryRepository(entries: entries)
}
public func entries(for hostID: Host.ID, matching query: String = "") -> [CommandHistoryEntry] {
let values: [CommandHistoryEntry]
do {
values = try repository.entries(for: hostID)
storageError = nil
} catch {
storageError = error.localizedDescription
return []
}
guard !query.isEmpty else { return values }
return values.filter { $0.command.localizedCaseInsensitiveContains(query) }
}
@discardableResult
public func record(
_ command: String, for hostID: Host.ID, at date: Date = Date()
) -> CommandHistoryEntry? {
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let entry = CommandHistoryEntry(hostID: hostID, command: command, executedAt: date)
do {
try repository.insert(entry, limit: Self.perHostLimit)
storageError = nil
} catch {
storageError = error.localizedDescription
return nil
}
revision &+= 1
return entry
}
@discardableResult
public func recordCompletion(
for id: CommandHistoryEntry.ID, hostID: Host.ID,
exitStatus: Int32, duration: TimeInterval?
) -> CommandHistoryEntry? {
do {
guard var entry = try repository.entries(for: hostID).first(where: { $0.id == id })
else { return nil }
entry.exitStatus = exitStatus
entry.duration = duration.map { max(0, $0) }
try repository.update(entry)
storageError = nil
revision &+= 1
return entry
} catch {
storageError = error.localizedDescription
return nil
}
}
public func delete(_ id: CommandHistoryEntry.ID) {
do {
try repository.delete(id)
storageError = nil
} catch {
storageError = error.localizedDescription
return
}
revision &+= 1
}
public func clear(for hostID: Host.ID) {
do {
try repository.clear(for: hostID)
storageError = nil
} catch {
storageError = error.localizedDescription
return
}
revision &+= 1
}
}
@@ -0,0 +1,42 @@
import Foundation
import TermindDomain
/// Settings edits persist first, then update every window and the terminal runtime.
@Observable
public final class TerminalSettingsModel {
public private(set) var preferences = TerminalPreferences()
public private(set) var storageError: String?
private let persistence: (any TerminalPreferencesPersisting)?
public var didChangeShellIntegration: ((Bool) -> Void)?
public init(persistence: (any TerminalPreferencesPersisting)? = nil) {
self.persistence = persistence
do { if let persistence { preferences = try persistence.loadTerminal() } } catch {
storageError = error.localizedDescription
}
}
@discardableResult
public func set<Value>(_ value: Value, for keyPath: WritableKeyPath<TerminalPreferences, Value>) -> Bool {
var next = preferences
next[keyPath: keyPath] = value
guard next != preferences else { return true }
do {
try persistence?.saveTerminal(next)
applyRemoteSnapshot(next)
return true
} catch {
storageError = error.localizedDescription
return false
}
}
public func applyRemoteSnapshot(_ value: TerminalPreferences) {
let previous = preferences
preferences = value
storageError = nil
if previous.shellIntegration != value.shellIntegration {
didChangeShellIntegration?(value.shellIntegration)
}
}
}
@@ -0,0 +1,16 @@
import Foundation
public nonisolated struct MetricsClock: Sendable {
public var now: @Sendable () -> Date = { Date() }
public var sleep: @Sendable (TimeInterval) async throws -> Void = {
try await Task.sleep(for: .seconds($0))
}
public init(
now: @escaping @Sendable () -> Date = { Date() },
sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }
) {
self.now = now
self.sleep = sleep
}
}
@@ -0,0 +1,50 @@
import Foundation
import TermindDomain
/// A value-only view of the existing monitor. Reading never admits a host or starts collection.
@MainActor
public protocol MetricsSnapshotReading: Sendable {
func snapshot(for hostID: UUID) -> MetricsSnapshot?
}
public nonisolated enum MetricsMonitorState: String, Sendable {
case connecting, collecting, live, failed, paused, sleeping, stopped
case awaitingAuthentication = "awaiting_authentication"
}
public nonisolated struct MetricsSnapshot: Sendable {
public var capturedAt: Date
public var state: MetricsMonitorState
public var point: MetricsPoint?
public var history: [MetricsPoint] = []
public var gpuState: MetricsGPUState = .probing
public var gpu: NvidiaSnapshot?
public var gpuDate: Date?
public var processes: [MetricsProcess] = []
public var processesDate: Date?
public var processesFailed = false
public var filesystems: [MetricsFilesystem] = []
public var filesystemsDate: Date?
public var filesystemsFailed = false
public init(
capturedAt: Date, state: MetricsMonitorState, point: MetricsPoint? = nil, history: [MetricsPoint] = [],
gpuState: MetricsGPUState = .probing, gpu: NvidiaSnapshot? = nil, gpuDate: Date? = nil,
processes: [MetricsProcess] = [], processesDate: Date? = nil, processesFailed: Bool = false,
filesystems: [MetricsFilesystem] = [], filesystemsDate: Date? = nil, filesystemsFailed: Bool = false
) {
self.capturedAt = capturedAt
self.state = state
self.point = point
self.history = history
self.gpuState = gpuState
self.gpu = gpu
self.gpuDate = gpuDate
self.processes = processes
self.processesDate = processesDate
self.processesFailed = processesFailed
self.filesystems = filesystems
self.filesystemsDate = filesystemsDate
self.filesystemsFailed = filesystemsFailed
}
}
@@ -0,0 +1,593 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
@Observable
public final class ServerMetric: Identifiable {
public nonisolated let id: UUID
public var selection: MetricsSelection
public var name: String
public var status = String(localized: "Connecting…")
public var collectionState = MetricsMonitorState.connecting
public var isConnecting = true
public var point: MetricsPoint?
public var history: [MetricsPoint] = []
public var disks: [String] = []
public var interfaces: [String] = []
public var gpuState = MetricsGPUState.probing
public var gpuSnapshot: NvidiaSnapshot?
public var gpuDate: Date?
public var authenticationHost: Host?
public var hostKey: SSHHostKeyObservation?
public var needsAttention = false
public let journey = ConnectionJourney()
public var authenticationPrompt: SSHAuthenticationPrompt?
public var hostKeyPrompt: SSHHostKeyPrompt?
public var hostKeyHostID: UUID?
public var requiresAuthentication: Bool { hostKey != nil || authenticationPrompt != nil }
public var showsConnectionJourney: Bool { requiresAuthentication || journey.isPresented }
public var processes: [MetricsProcess] = []
public var processesDate: Date?
public var processesFailed = false
public var filesystems: [MetricsFilesystem] = []
public var filesystemsFailed = false
public var filesystemsDate: Date?
public init(selection: MetricsSelection, name: String) {
id = selection.id
self.selection = selection
self.name = name
}
}
/// One application owner, independent of windows, with one serial worker per host.
@Observable
public final class ServerMetricsModel: MetricsSnapshotReading {
public static let freeCardLimit = 3
public private(set) var servers: [ServerMetric] = []
public private(set) var sleeping = false
private var started = false
private var stopped = false
private let hosts: any RemoteSessionHostStore
private let preferences: any MetricsConfigurationStoring
private let makeSampler: @Sendable () -> any MetricsSampling
private let discover:
@Sendable (Host, SSHTransportProxy?, [SSHRouteHopConfiguration]) async throws -> SSHHostKeyObservation
private let clock: MetricsClock
private let cardLimit: () -> Int?
private let limitReached: () -> Void
private var workers: [UUID: Task<Void, Never>] = [:]
private var generations: [UUID: UUID] = [:]
private var samplers: [UUID: any MetricsSampling] = [:]
private var submissions: [UUID: [UUID: SSHAuthenticationSubmission]] = [:]
private var maintenance: Task<Void, Never>?
private var detailObservers: [UUID: UUID] = [:]
private var temporaryKeys: [UUID: [UUID: SSHHostKeyIdentity]] = [:]
private var stamps: [UUID: Data] = [:]
public var keepsRunning: Bool { servers.contains { $0.selection.enabled } }
public init(
hosts: any RemoteSessionHostStore, preferences: any MetricsConfigurationStoring,
makeSampler: @escaping @Sendable () -> any MetricsSampling,
discover:
@escaping @Sendable (Host, SSHTransportProxy?, [SSHRouteHopConfiguration]) async throws ->
SSHHostKeyObservation,
clock: MetricsClock = MetricsClock(),
cardLimit: @escaping () -> Int? = { ServerMetricsModel.freeCardLimit },
limitReached: @escaping () -> Void = {}
) {
self.hosts = hosts
self.preferences = preferences
self.makeSampler = makeSampler
self.discover = discover
self.clock = clock
self.cardLimit = cardLimit
self.limitReached = limitReached
}
public func snapshot(for hostID: UUID) -> MetricsSnapshot? {
guard hosts.host(hostID) != nil, let server = servers.first(where: { $0.id == hostID }) else { return nil }
let state: MetricsMonitorState
if !server.selection.enabled {
state = .paused
} else if sleeping {
state = .sleeping
} else if stopped {
state = .stopped
} else if server.requiresAuthentication {
state = .awaitingAuthentication
} else {
state = server.collectionState
}
return MetricsSnapshot(
capturedAt: clock.now(), state: state, point: server.point, history: server.history,
gpuState: server.gpuState, gpu: server.gpuSnapshot, gpuDate: server.gpuDate,
processes: server.processes, processesDate: server.processesDate,
processesFailed: server.processesFailed, filesystems: server.filesystems,
filesystemsDate: server.filesystemsDate, filesystemsFailed: server.filesystemsFailed
)
}
public var remainingCardSlots: Int? { cardLimit().map { max(0, $0 - servers.count) } }
#if DEBUG
/// Frozen product fixtures never load preferences or start an SSH worker.
public convenience init(screenshotHosts: any RemoteSessionHostStore, rows: [ServerMetric]) {
self.init(
hosts: screenshotHosts, preferences: ScreenshotMetricsPreferences(),
makeSampler: { preconditionFailure("Screenshot metrics cannot connect") },
discover: { _, _, _ in preconditionFailure("Screenshot metrics cannot discover hosts") },
cardLimit: { nil }
)
servers = rows
started = true
stopped = true
}
#endif
@discardableResult
public func requestAddition(count: Int = 1) -> Bool {
guard count > 0 else { return true }
guard let remaining = remainingCardSlots, count > remaining else { return true }
limitReached()
return false
}
public func observeDetails(hostID: UUID, observer: UUID) { detailObservers[observer] = hostID }
public func stopObservingDetails(_ observer: UUID) { detailObservers[observer] = nil }
public func start() {
guard !started else { return }
started = true
var seen = Set<UUID>()
servers = preferences.load().filter { seen.insert($0.id).inserted }.compactMap { selection in
guard let host = hosts.host(selection.id) else { return nil }
return ServerMetric(selection: selection, name: host.name)
}
for server in servers { launch(server) }
maintenance = Task { [weak self, clock] in
while !Task.isCancelled {
do { try await clock.sleep(2) } catch { return }
self?.reconcile()
}
}
}
@discardableResult
public func add(_ host: Host) -> Bool { add([host]) }
/// Admission and insertion are one MainActor operation, shared by all windows.
@discardableResult
public func add(_ hosts: [Host]) -> Bool {
var seen = Set(servers.map(\.id))
let additions = hosts.filter { seen.insert($0.id).inserted }
guard requestAddition(count: additions.count) else { return false }
guard !additions.isEmpty else { return true }
let rows = additions.map { ServerMetric(selection: MetricsSelection(id: $0.id), name: $0.name) }
servers.append(contentsOf: rows)
save()
for server in rows { launch(server) }
return true
}
public func move(_ ids: [UUID], before next: UUID?) {
let wanted = Set(ids)
let moving = servers.filter { wanted.contains($0.id) }
guard !moving.isEmpty, next.map({ !wanted.contains($0) }) ?? true else { return }
var ordered = servers.filter { !wanted.contains($0.id) }
let index = next.flatMap { id in ordered.firstIndex { $0.id == id } } ?? ordered.count
ordered.insert(contentsOf: moving, at: index)
guard ordered.map(\.id) != servers.map(\.id) else { return }
servers = ordered
save()
}
public func remove(_ id: UUID) {
cancel(id)
submissions[id] = nil
temporaryKeys[id] = nil
stamps[id] = nil
detailObservers = detailObservers.filter { $0.value != id }
servers.removeAll { $0.id == id }
save()
}
public func setEnabled(_ enabled: Bool, for server: ServerMetric) {
server.selection.enabled = enabled
cancel(server.id)
server.isConnecting = false
server.authenticationHost = nil
server.hostKey = nil
server.needsAttention = false
if !enabled {
submissions[server.id] = nil
temporaryKeys[server.id] = nil
server.journey.hide()
}
server.authenticationPrompt = nil
server.hostKeyPrompt = nil
save()
launch(server)
}
public func retry(_ server: ServerMetric) {
server.journey.resume()
server.authenticationPrompt = nil
server.hostKeyPrompt = nil
cancel(server.id)
server.isConnecting = false
server.authenticationHost = nil
server.hostKey = nil
server.needsAttention = false
launch(server)
}
public func selectDevices(_ server: ServerMetric, disks: Set<String>?, interfaces: Set<String>?) {
server.selection.disks = disks
server.selection.interfaces = interfaces
server.history = []
server.point = nil
save()
retry(server)
}
public func submitAuthentication(
_ server: ServerMetric, identityID: UUID?, username: String, password: String?,
passphrase: String?, keyID: UUID?, save: Bool
) {
guard var host = server.authenticationHost else { return }
let identity = identityID.flatMap { hosts.identity($0) }
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
if server.authenticationPrompt?.requiresPassphrase == true,
var staged = submissions[server.id]?[host.id]
{
guard let passphrase, !passphrase.isEmpty else { return }
staged.passphrase = passphrase
submissions[server.id]?[host.id] = staged
retry(server)
return
}
let chosenKey = identity?.keyID ?? keyID
if save {
let saved = identity ?? Identity(name: name, username: name, keyID: chosenKey)
do {
try hosts.upsert(saved, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep)
host.identityID = saved.id
hosts.upsert(host)
} catch {
server.status = error.localizedDescription
server.collectionState = .failed
server.journey.fail(error.localizedDescription, at: host.id)
return
}
}
submissions[server.id, default: [:]][host.id] = SSHAuthenticationSubmission(
username: name, password: password, passphrase: passphrase,
keyID: chosenKey, identityID: identity?.id
)
retry(server)
}
public func acceptHostKey(_ server: ServerMetric, observation: SSHHostKeyObservation, remember: Bool) {
guard server.hostKey == observation, let hostID = server.hostKeyHostID else { return }
if remember {
hosts.trust(
KnownHostEntry(
hostname: observation.hostname, port: observation.port,
algorithm: observation.algorithm, fingerprint: observation.fingerprint, addedAt: clock.now()
))
} else {
temporaryKeys[server.id, default: [:]][hostID] = observation.identity
}
retry(server)
}
private func preparation(for host: Host, server: ServerMetric) throws -> SSHConnectionPreparation {
var result = try hosts.prepareSSHConnection(for: host, submissions: submissions[server.id] ?? [:])
for index in result.nodes.indices {
if let key = temporaryKeys[server.id]?[result.nodes[index].host.id] {
result.nodes[index].trustedKeys.insert(key)
}
}
return result
}
private func requestAuthentication(_ server: ServerMetric, hostID: UUID, error: TermindSSHError) {
guard let host = hosts.host(hostID) else { return }
let staged = submissions[server.id]?[host.id]
let identity = staged?.identityID.flatMap { hosts.identity($0) } ?? hosts.identity(for: host)
let keyID = staged?.keyID ?? identity?.keyID
let passphrase = keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true && staged?.passphrase == nil
server.authenticationHost = host
server.authenticationPrompt = SSHAuthenticationPrompt(
host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "",
selectedKeyID: keyID, failureMessage: error.localizedDescription, requiresPassphrase: passphrase,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity
)
server.needsAttention = true
if error.isServerAuthenticationChallenge {
server.journey.waitForAuthentication(at: host.id, failure: error.localizedDescription)
} else if passphrase {
server.journey.waitForLocalPassphrase(at: host.id, failure: error.localizedDescription)
} else {
server.journey.waitForLocalIdentity(at: host.id, failure: error.localizedDescription)
}
}
public func suspend() {
sleeping = true
for server in servers {
cancel(server.id)
server.isConnecting = false
if server.selection.enabled { server.status = String(localized: "Sleeping") }
}
}
public func resume() {
guard sleeping else { return }
sleeping = false
reconcile()
for server in servers { launch(server) }
}
public func shutdown() {
stopped = true
maintenance?.cancel()
maintenance = nil
for server in servers { cancel(server.id) }
submissions = [:]
temporaryKeys = [:]
for server in servers { server.journey.hide() }
}
public func reconcile() {
for server in servers {
guard let host = hosts.host(server.id) else {
remove(server.id)
continue
}
server.name = host.name
server.history.removeAll { $0.date < clock.now().addingTimeInterval(-300) }
let stamp = connectionStamp(host)
if let old = stamps[server.id], old != stamp {
submissions[server.id] = nil
temporaryKeys[server.id] = nil
retry(server)
}
stamps[server.id] = stamp
}
}
private func connectionStamp(_ host: Host) -> Data {
// Values contain key references and public material, never resolved secrets.
let route = hosts.route(for: host)
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
var result = (try? encoder.encode(route)) ?? Data()
for item in route {
if let identity = hosts.identity(for: item) {
result.append((try? encoder.encode(identity)) ?? Data())
if let id = identity.keyID, let key = hosts.key(id) {
result.append((try? encoder.encode(key)) ?? Data())
}
}
}
return result
}
private func save() { preferences.save(servers.map(\.selection)) }
private func cancel(_ id: UUID) {
generations[id] = nil
workers.removeValue(forKey: id)?.cancel()
if let sampler = samplers.removeValue(forKey: id) {
Task { await sampler.disconnect() }
}
}
private func launch(_ server: ServerMetric) {
guard !stopped, !sleeping, server.selection.enabled, !server.needsAttention else {
if !server.selection.enabled { server.status = String(localized: "Paused") }
return
}
guard workers[server.id] == nil else { return }
let token = UUID()
generations[server.id] = token
server.isConnecting = true
server.status = String(localized: "Connecting…")
server.collectionState = .connecting
server.gpuState = .probing
if let host = hosts.host(server.id) { stamps[server.id] = connectionStamp(host) }
workers[server.id] = Task { [weak self] in await self?.run(server, token: token) }
}
private func current(_ server: ServerMetric, _ token: UUID) -> Bool {
!Task.isCancelled && generations[server.id] == token && !stopped && !sleeping
}
private func run(_ server: ServerMetric, token: UUID) async {
var delay = 2.0
while current(server, token) {
let sampler = makeSampler()
samplers[server.id] = sampler
do {
guard let host = hosts.host(server.id) else { return }
server.status = String(localized: "Connecting…")
server.collectionState = .connecting
server.isConnecting = true
server.journey.begin(
host: host, route: hosts.route(for: host), purpose: .metrics,
username: submissions[server.id]?[host.id]?.username ?? hosts.identity(for: host)?.username
)
let configuration = try await preparation(for: host, server: server).resolve()
guard current(server, token) else { return }
try await sampler.connect(configuration)
guard current(server, token) else {
await sampler.disconnect()
return
}
server.isConnecting = false
server.journey.receive(.connected(host.id))
server.status = String(localized: "Connected — collecting metrics…")
server.collectionState = .collecting
var previous: MetricsSample?
var previousProcesses: MetricsProcessSample?
var nextFilesystemRefresh = Date.distantPast
var probeGPU = true
while current(server, token) {
let began = clock.now()
let sample = try await sampler.sample()
guard current(server, token) else { break }
var point = MetricsCalculation.point(
previous: previous, current: sample, selection: server.selection, date: clock.now()
)
previous = sample
server.disks = sample.disks.map { $0.keys.sorted() } ?? server.disks
server.interfaces = sample.interfaces.map { $0.keys.sorted() } ?? server.interfaces
server.status = String(localized: "Live")
server.collectionState = .live
if server.journey.phase == .connecting { server.journey.succeed() }
// Publish base metrics before the optional, bounded GPU command.
server.point = point
delay = 2
if probeGPU {
do {
let gpu = try await sampler.gpu()
guard current(server, token) else { break }
server.gpuSnapshot = gpu
server.gpuDate = clock.now()
let devices = gpu?.devices ?? []
probeGPU = !devices.isEmpty
server.gpuState = devices.isEmpty ? .unavailable : .available
let ids = Set(devices.map(\.id))
for index in server.history.indices {
server.history[index].gpus.removeAll { !ids.contains($0.id) }
}
point.gpus = devices
} catch {
guard current(server, token) else { break }
server.gpuState = .failed
// Unknown GPU values create a gap, not a false zero or a repeated stale sample.
}
}
if detailObservers.values.contains(server.id) {
if clock.now() >= nextFilesystemRefresh {
do {
if let filesystems = try await sampler.filesystems() {
guard current(server, token) else { break }
server.filesystems = filesystems
server.filesystemsDate = clock.now()
server.filesystemsFailed = false
}
} catch {
guard current(server, token) else { break }
server.filesystemsFailed = true
}
nextFilesystemRefresh = clock.now().addingTimeInterval(30)
}
do {
if let processes = try await sampler.processes() {
guard current(server, token) else { break }
server.processes = MetricsProcess.rows(previous: previousProcesses, current: processes)
previousProcesses = processes
server.processesDate = clock.now()
server.processesFailed = false
}
} catch {
guard current(server, token) else { break }
server.processesFailed = true
previousProcesses = nil
}
} else {
previousProcesses = nil
}
server.point = point
server.history.append(point)
server.history.removeAll { $0.date < clock.now().addingTimeInterval(-300) }
try await clock.sleep(min(2, max(0.05, 2 - clock.now().timeIntervalSince(began))))
}
} catch {
guard current(server, token) else {
await sampler.disconnect()
return
}
server.isConnecting = false
server.status = error.localizedDescription
server.collectionState = .failed
if let ssh = error as? TermindSSHError {
let targetID = ssh.routeHostID ?? server.id
if ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication {
requestAuthentication(server, hostID: targetID, error: ssh)
} else if ssh.shouldRequestHostKey || ssh.isHostKeyChanged
|| isTrustRequirement(ssh)
{
await requestTrust(server, hostID: targetID, token: token)
}
}
if case MetricsReadError.unsupported = error { server.needsAttention = true }
if !server.requiresAuthentication { server.journey.fail(error.localizedDescription) }
}
await sampler.disconnect()
guard current(server, token) else { return }
if server.needsAttention {
workers[server.id] = nil
samplers[server.id] = nil
return
}
do { try await clock.sleep(delay) } catch { return }
delay = min(60, delay * 2)
}
}
private func isTrustRequirement(_ error: TermindSSHError) -> Bool {
switch error {
case .untrustedHost, .mismatchedHostKey: true
default: false
}
}
private func requestTrust(_ server: ServerMetric, hostID: UUID, token: UUID, visited: Set<UUID> = []) async {
guard !visited.contains(hostID), visited.count < 16 else {
server.needsAttention = true
return
}
guard let host = hosts.host(hostID) else { return }
do {
let configuration = try await preparation(for: host, server: server).resolve(discoveryOnly: true)
guard current(server, token) else { return }
let observation = try await discover(host, configuration.proxy, configuration.jumps)
guard current(server, token) else { return }
server.hostKey = observation
server.hostKeyHostID = host.id
let trustedCount = hosts.knownHosts(for: host).count
server.hostKeyPrompt = SSHHostKeyPrompt(
observation: observation,
kind: trustedCount == 0 ? .firstUse : .additionalKey(trustedKeyCount: trustedCount)
)
server.status = String(localized: "Server Identity Verification Required")
server.needsAttention = true
server.journey.waitForHostKey(at: host.id, fingerprint: observation.fingerprint)
} catch {
guard current(server, token) else { return }
server.status = error.localizedDescription
server.collectionState = .failed
if let ssh = error as? TermindSSHError, ssh.shouldRequestAuthentication {
requestAuthentication(server, hostID: ssh.routeHostID ?? hostID, error: ssh)
} else if let ssh = error as? TermindSSHError, ssh.shouldRequestHostKey,
let next = ssh.routeHostID
{
await requestTrust(server, hostID: next, token: token, visited: visited.union([hostID]))
}
}
}
}
#if DEBUG
private final class ScreenshotMetricsPreferences: MetricsConfigurationStoring {
func load() -> [MetricsSelection] { [] }
func save(_ selections: [MetricsSelection]) {}
}
#endif
@@ -0,0 +1,43 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
@MainActor
public protocol CommandHistoryRepository: AnyObject {
func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry]
func insert(_ entry: CommandHistoryEntry, limit: Int) throws
func update(_ entry: CommandHistoryEntry) throws
func delete(_ id: CommandHistoryEntry.ID) throws
func clear(for hostID: Host.ID) throws
}
@MainActor
public final class InMemoryCommandHistoryRepository: CommandHistoryRepository {
private var values: [CommandHistoryEntry]
public init(entries: [CommandHistoryEntry] = []) { values = entries }
public func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry] {
values.filter { $0.hostID == hostID }.sorted { $0.executedAt > $1.executedAt }
}
public func insert(_ entry: CommandHistoryEntry, limit: Int) throws {
values.append(entry)
let retained = Set(try entries(for: entry.hostID).prefix(limit).map(\.id))
values.removeAll { $0.hostID == entry.hostID && !retained.contains($0.id) }
}
public func update(_ entry: CommandHistoryEntry) throws {
guard let index = values.firstIndex(where: { $0.id == entry.id }) else { return }
values[index] = entry
}
public func delete(_ id: CommandHistoryEntry.ID) throws {
values.removeAll { $0.id == id }
}
public func clear(for hostID: Host.ID) throws {
values.removeAll { $0.hostID == hostID }
}
}
@@ -0,0 +1,42 @@
import Foundation
public nonisolated enum SharedSettingsKey {
public static let appLanguage = "app.language"
public static let defaultICloudSyncEnabled = true
public static let defaultSettingsSyncEnabled = false
public static let defaultTerminalThemeDark = "Termind Dark"
public static let defaultTerminalThemeLight = "Termind Light"
public static let defaultVaultSyncEnabled = true
public static let defaultWebSearchBuiltIn = true
public static let defaultWebSearchEnabled = true
public static let iCloudSyncEnabled = "icloud.sync.enabled"
public static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar"
public static let seededSkillGeneration = "assistant.skills.seededGeneration"
public static let settingsSyncEnabled = "settings.sync.enabled"
public static let settingsSyncEnvironment = "settings.sync.environment"
public static let shellIntegration = "terminal.shellIntegration"
public static let terminalCustomConfigurationDark = "terminal.customConfiguration.dark"
public static let terminalCustomConfigurationDarkEnabled = "terminal.customConfiguration.dark.enabled"
public static let terminalCustomConfigurationLight = "terminal.customConfiguration.light"
public static let terminalCustomConfigurationLightEnabled = "terminal.customConfiguration.light.enabled"
public static let terminalFontFollowTheme = "terminal.font.followTheme"
public static let terminalFontSize = "terminal.fontSize"
public static let terminalThemeDark = "terminal.theme.dark"
public static let terminalThemeLight = "terminal.theme.light"
public static let vaultSyncEnabled = "vault.sync.enabled"
public static let vaultSyncEnvironment = "vault.sync.environment"
public static let webSearchBuiltIn = "search.builtIn"
public static let webSearchEnabled = "search.enabled"
public static func migrateSyncPreferences(in defaults: UserDefaults = .standard) {
if defaults.object(forKey: settingsSyncEnabled) == nil,
let legacy = defaults.object(forKey: "ai.sync.enabled") as? Bool
{
defaults.set(legacy, forKey: settingsSyncEnabled)
}
guard defaults.object(forKey: iCloudSyncEnabled) == nil else { return }
let vault = defaults.object(forKey: vaultSyncEnabled) as? Bool ?? defaultVaultSyncEnabled
let ai = defaults.object(forKey: settingsSyncEnabled) as? Bool ?? defaultSettingsSyncEnabled
defaults.set(vault || ai, forKey: iCloudSyncEnabled)
if !vault && !ai { defaults.set(true, forKey: vaultSyncEnabled) }
}
}
@@ -0,0 +1,8 @@
import AgentKit
import Foundation
@MainActor
public protocol SkillLibraryPersisting: AnyObject {
func load() throws -> [AgentSkill]
func save(_ skills: [AgentSkill]) throws
}
@@ -0,0 +1,8 @@
import Foundation
import TermindDomain
@MainActor
public protocol TerminalPreferencesPersisting: AnyObject {
func loadTerminal() throws -> TerminalPreferences
func saveTerminal(_ value: TerminalPreferences) throws
}
@@ -0,0 +1,19 @@
extension SSHKeyAlgorithm {
public nonisolated var displayName: String {
switch self {
case .ed25519: "Ed25519"
case .ecdsa256: "ECDSA P-256"
case .ecdsa384: "ECDSA P-384"
case .ecdsa521: "ECDSA P-521"
case .rsa: "RSA"
}
}
public nonisolated var typeName: String {
switch self {
case .ed25519: "Ed25519"
case .ecdsa256, .ecdsa384, .ecdsa521: "ECDSA"
case .rsa: "RSA"
}
}
}
@@ -0,0 +1,11 @@
import Foundation
public nonisolated enum MetricsReadError: LocalizedError {
case unsupported, output
public var errorDescription: String? {
switch self {
case .unsupported: String(localized: "Metrics requires a Linux server with procfs.")
case .output: String(localized: "The metrics response is incomplete or too large.")
}
}
}
@@ -0,0 +1,31 @@
import Foundation
public nonisolated struct TerminalCustomConfiguration: Hashable, Sendable {
public nonisolated struct Entry: Hashable, Sendable {
public var key: String
public var value: String
public init(key: String, value: String) {
self.key = key
self.value = value
}
}
public var entries: [Entry]
public init(_ text: String) {
entries = text.split(separator: "\n", omittingEmptySubsequences: false).compactMap {
rawLine in
let line = rawLine.trimmingCharacters(in: .whitespaces)
guard !line.isEmpty, !line.hasPrefix("#"), let separator = line.firstIndex(of: "=")
else { return nil }
let key = line[..<separator].trimmingCharacters(in: .whitespaces)
guard !key.isEmpty else { return nil }
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces)
return Entry(key: key, value: value)
}
}
public func lastValue(for key: String) -> String? {
entries.last { $0.key == key }?.value
}
}
@@ -0,0 +1,76 @@
import Foundation
public nonisolated struct TermindTerminalPalette: Identifiable, Hashable, Sendable {
public nonisolated struct RGB: Hashable, Sendable {
public var r: Double, g: Double, b: Double
public init(_ hex: UInt32) {
r = Double((hex >> 16) & 0xFF) / 255
g = Double((hex >> 8) & 0xFF) / 255
b = Double(hex & 0xFF) / 255
}
public init?(hex value: String) {
var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.hasPrefix("#") { normalized.removeFirst() }
guard normalized.count == 6, let hex = UInt32(normalized, radix: 16) else {
return nil
}
self.init(hex)
}
public var hex: String {
String(
format: "%02X%02X%02X",
Int((r * 255).rounded()),
Int((g * 255).rounded()),
Int((b * 255).rounded())
)
}
}
public var id: String { name }
public var name: String
public var isDark: Bool
public var background: RGB
public var foreground: RGB
public var cursor: RGB
public var selection: RGB
/// ANSI 0-7 followed by their bright variants 8-15.
public var ansi: [RGB]
public static let termindDark = TermindTerminalPalette(
name: "Termind Dark",
isDark: true,
background: RGB(0x1E1E1E),
foreground: RGB(0xD3D8E0),
cursor: RGB(0x4B8BF5),
selection: RGB(0x2A3550),
ansi: [
RGB(0x2A2F3A), RGB(0xE05561), RGB(0x62C073), RGB(0xD5A24C),
RGB(0x4B8BF5), RGB(0xB07CD8), RGB(0x3FB5B5), RGB(0xABB2BF),
RGB(0x515A6C), RGB(0xFF6E76), RGB(0x7BD98C), RGB(0xEFBD5D),
RGB(0x6BA5FF), RGB(0xC996E8), RGB(0x56CFCF), RGB(0xE6E9EF),
]
)
public static let termindLight = TermindTerminalPalette(
name: "Termind Light",
isDark: false,
background: RGB(0xFFFFFF),
foreground: RGB(0x2B303B),
cursor: RGB(0x2C6BD8),
selection: RGB(0xCFDDF7),
ansi: [
RGB(0x3B4048), RGB(0xC4404B), RGB(0x3F8F4E), RGB(0x9C7415),
RGB(0x2C6BD8), RGB(0x8A4FB8), RGB(0x24888A), RGB(0x6C737F),
RGB(0x545C69), RGB(0xE05561), RGB(0x53A863), RGB(0xBB8D22),
RGB(0x3F82EA), RGB(0xA463D0), RGB(0x2FA5A7), RGB(0x2B303B),
]
)
public static let all: [TermindTerminalPalette] = [.termindDark, .termindLight]
}
@@ -0,0 +1,606 @@
import CloudKit
import CryptoKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
/// Settings use their own zone, account binding, and upload queue.
@MainActor
@Observable
public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sendable {
public nonisolated static let containerIdentifier = "iCloud.com.akinokaede.termind"
// Keep the initial AI-sync identifiers so existing replicas remain reachable.
public nonisolated static let zoneName = "TermindAIConfigurationV1"
public nonisolated static let recordType = "AIConfigurationItem"
public nonisolated static let subscriptionID = "termind-ai-configuration-v1-changes"
public nonisolated static let inlinePayloadLimit = 700_000
private let context: ModelContext
private let persistence: SettingsSyncPersistence
private let secrets: AISecretStore
private var isReadyToSend = false
private let journal: SettingsSyncJournal
private let container: CKContainer
private let defaults: UserDefaults
private let environmentIdentifier: String
private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
private var engine: CKSyncEngine?
private var operation: Task<Void, Never>?
private var operationID = UUID()
private var isStarted = false
private var terminalError: String?
public private(set) var status: VaultSyncStatus = .checkingAccount
public var didApplyRemote: ((SettingsSyncSnapshot, Set<SettingsRecordKey>) -> Void)?
public var isEnabled: Bool {
guard
defaults.object(forKey: SharedSettingsKey.iCloudSyncEnabled) as? Bool
?? SharedSettingsKey.defaultICloudSyncEnabled
else { return false }
return defaults.object(forKey: SharedSettingsKey.settingsSyncEnabled) as? Bool
?? SharedSettingsKey.defaultSettingsSyncEnabled
}
public init(
persistence: SettingsSyncPersistence,
secrets: AISecretStore,
container: CKContainer = CKContainer(identifier: SettingsSyncCoordinator.containerIdentifier),
defaults: UserDefaults = .standard,
environmentIdentifier: String = SettingsSyncCoordinator.runtimeEnvironmentIdentifier
) {
context = persistence.context
self.persistence = persistence
self.secrets = secrets
journal = persistence.journal
self.container = container
self.defaults = defaults
self.environmentIdentifier = environmentIdentifier
super.init()
persistence.didRecordChanges = { [weak self] mutations in self?.noteLocalChanges(mutations) }
}
public nonisolated static var runtimeEnvironmentIdentifier: String {
Bundle.main.object(forInfoDictionaryKey: "TermindCloudKitEnvironment") as? String
?? "Unknown"
}
public func start() {
guard isEnabled else {
pause()
return
}
guard !isStarted else { return }
isStarted = true
run { [weak self] in try await self?.prepareAndSynchronize() }
}
public func setEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: SharedSettingsKey.settingsSyncEnabled)
if enabled { start() } else { pause() }
}
public func pause() {
isReadyToSend = false
secrets.uploadsAuthorized = false
operationID = UUID()
operation?.cancel()
operation = nil
isStarted = false
let engine = self.engine
self.engine = nil
status = .paused
if let engine { Task { await engine.cancelOperations() } }
}
public func retry() {
guard isEnabled else { return }
terminalError = nil
if engine == nil {
isStarted = false
start()
} else {
run { [weak self] in try await self?.synchronizeNow() }
}
}
public func noteLocalChanges(_ mutations: SettingsMutationSet) {
guard !mutations.isEmpty else { return }
addPendingChanges(mutations.keys)
guard isEnabled, engine != nil else { return }
run { [weak self] in try await self?.sendPendingChanges() }
}
public func confirmAccountSwitchMerge() {
guard isEnabled else { return }
do {
let state = try journal.state()
guard let pendingUser = state.pendingUserRecordName else { return }
try journal.markAllLiveRecordsNeedsUploadForNewAccount()
state.boundUserRecordName = pendingUser
state.pendingUserRecordName = nil
try context.save()
engine = nil
isStarted = false
start()
} catch {
context.rollback()
status = .failed(error.localizedDescription)
}
}
private func run(_ body: @escaping @MainActor () async throws -> Void) {
guard operation == nil else { return }
let id = UUID()
operationID = id
operation = Task { [weak self] in
defer { if self?.operationID == id { self?.operation = nil } }
do { try await body() } catch is CancellationError {} catch {
if self?.operationID == id { self?.status = Self.status(for: error) }
}
}
}
private func prepareAndSynchronize() async throws {
let id = operationID
status = .checkingAccount
let accountStatus = try await container.accountStatus()
guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return }
guard accountStatus == .available else {
status = .unavailable(Self.accountStatusDescription(accountStatus))
isStarted = false
return
}
let userRecordID = try await container.userRecordID()
guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return }
let currentUser = userRecordID.recordName
let state = try journal.state()
if let boundUser = state.boundUserRecordName, boundUser != currentUser {
state.pendingUserRecordName = currentUser
try context.save()
status = .accountChangeConfirmationRequired
isStarted = false
return
}
state.boundUserRecordName = currentUser
state.pendingUserRecordName = nil
try prepareForCurrentEnvironment()
let serialization = state.engineStateData.flatMap {
try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: $0)
}
var configuration = CKSyncEngine.Configuration(
database: container.privateCloudDatabase,
stateSerialization: serialization,
delegate: self
)
configuration.automaticallySync = true
configuration.subscriptionID = Self.subscriptionID
let engine = CKSyncEngine(configuration)
self.engine = engine
let pending = try journal.pendingEnvelopes()
addPendingChanges(Set(pending.map(\.key)))
try context.save()
let zone = CKRecordZone(zoneID: zoneID)
_ = try await container.privateCloudDatabase.modifyRecordZones(
saving: [zone], deleting: []
)
try await engine.fetchChanges(.init(scope: .zoneIDs([zoneID])))
guard self.engine === engine, isEnabled, !Task.isCancelled else { return }
secrets.uploadsAuthorized = true
isReadyToSend = true
try await sendPendingChanges()
try updateUpToDateStatus()
}
private func synchronizeNow() async throws {
guard let engine else { return }
status = .syncing(pendingCount: try journal.pendingEnvelopes().count)
try await engine.fetchChanges(.init(scope: .zoneIDs([zoneID])))
guard self.engine === engine, isEnabled, !Task.isCancelled else { return }
secrets.uploadsAuthorized = true
isReadyToSend = true
try await sendPendingChanges()
try updateUpToDateStatus()
}
private func sendPendingChanges() async throws {
guard let engine, isEnabled, isReadyToSend else { return }
try migrateSecrets()
let pending = try journal.pendingEnvelopes()
guard !pending.isEmpty else { return }
addPendingChanges(Set(pending.map(\.key)))
status = .syncing(pendingCount: pending.count)
try await engine.sendChanges()
}
private func addPendingChanges(_ keys: Set<SettingsRecordKey>) {
guard let engine else { return }
engine.state.add(
pendingRecordZoneChanges: keys.map {
.saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID))
})
}
private func updateUpToDateStatus() throws {
if let quarantineReason = try journal.firstQuarantineReason() {
status = .failed(quarantineReason)
return
}
if let terminalError {
status = .failed(terminalError)
return
}
let pendingCount = try journal.pendingEnvelopes().count
let state = try journal.state()
if pendingCount == 0 {
state.lastSuccessfulSync = .now
try context.save()
status = .upToDate(lastSuccessfulSync: state.lastSuccessfulSync)
} else {
status = .syncing(pendingCount: pendingCount)
}
}
private func migrateSecrets() throws {
let config = try persistence.load().configuration
try secrets.migrate(Set(config.providers.map(\.credentialRef) + config.mcpServers.map(\.credentialRef)))
}
private func applyRemote(_ envelopes: [SettingsEnvelope]) throws
-> (
snapshot: SettingsSyncSnapshot, acceptedKeys: Set<SettingsRecordKey>,
needsUpload: Set<SettingsRecordKey>
)
{
let result = try persistence.applyRemote(envelopes)
let references = envelopes.filter { result.acceptedKeys.contains($0.key) }.compactMap { envelope -> String? in
switch envelope.payload {
case .provider(let value): value.credentialRef
case .mcpServer(let value): value.credentialRef
default: nil
}
}
try secrets.acceptRemoteReferences(Set(references))
return result
}
public nonisolated func nextFetchChangesOptions(
_ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine
) async -> CKSyncEngine.FetchChangesOptions {
.init(scope: .zoneIDs([CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)]))
}
public nonisolated func handleEvent(
_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine
) async {
await handle(event, engine: syncEngine)
}
public nonisolated func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
await makeChangeBatch(context, engine: syncEngine)
}
@MainActor
private func makeChangeBatch(
_ context: CKSyncEngine.SendChangesContext, engine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
guard self.engine === engine, isReadyToSend, isEnabled else { return nil }
do { try migrateSecrets() } catch {
status = .failed(error.localizedDescription)
return nil
}
let pending = engine.state.pendingRecordZoneChanges.filter {
context.options.scope.contains($0)
}
return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) {
[weak self] recordID in
guard let self else { return nil }
return await self.recordToSave(recordID)
}
}
@MainActor
private func recordToSave(_ recordID: CKRecord.ID) -> CKRecord? {
guard recordID.zoneID == zoneID, let key = SettingsRecordKey(recordName: recordID.recordName),
let outgoing = try? journal.outgoingEntry(for: key)
else { return nil }
do { return try Self.makeRecord(outgoing, recordID: recordID) } catch {
terminalError = error.localizedDescription
status = .failed(error.localizedDescription)
return nil
}
}
@MainActor
private func handle(_ event: CKSyncEngine.Event, engine: CKSyncEngine) async {
guard self.engine === engine, isEnabled else { return }
do {
switch event {
case .stateUpdate(let update):
let state = try journal.state()
state.engineStateData = try JSONEncoder().encode(update.stateSerialization)
try context.save()
case .accountChange(let change):
try handleAccountChange(change)
case .fetchedRecordZoneChanges(let changes):
try handleFetchedChanges(changes, engine: engine)
case .fetchedDatabaseChanges(let changes):
if changes.deletions.contains(where: { $0.zoneID == zoneID }) {
try handleZoneDeletion(engine: engine)
}
case .sentRecordZoneChanges(let changes):
try handleSentChanges(changes, engine: engine)
case .didFetchChanges, .didSendChanges:
try updateUpToDateStatus()
default:
break
}
} catch {
context.rollback()
status = Self.status(for: error)
}
}
private func handleAccountChange(_ change: CKSyncEngine.Event.AccountChange) throws {
let state = try journal.state()
switch change.changeType {
case .signIn(let currentUser):
if let bound = state.boundUserRecordName, bound != currentUser.recordName {
stopEngineForAccountChange()
state.pendingUserRecordName = currentUser.recordName
status = .accountChangeConfirmationRequired
}
case .signOut:
stopEngineForAccountChange()
status = .unavailable(String(localized: "Sign in to iCloud to sync settings."))
case .switchAccounts(_, let currentUser):
stopEngineForAccountChange()
state.pendingUserRecordName = currentUser.recordName
status = .accountChangeConfirmationRequired
@unknown default:
stopEngineForAccountChange()
status = .unavailable(String(localized: "The iCloud account changed."))
}
try context.save()
}
private func stopEngineForAccountChange() {
isReadyToSend = false
secrets.uploadsAuthorized = false
let activeEngine = engine
engine = nil
isStarted = false
if let activeEngine { Task { await activeEngine.cancelOperations() } }
}
private func handleFetchedChanges(
_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges, engine: CKSyncEngine
) throws {
var recordsByKey: [SettingsRecordKey: CKRecord] = [:]
var envelopes: [SettingsEnvelope] = []
var quarantinedError: String?
for modification in changes.modifications {
let record = modification.record
guard record.recordType == Self.recordType, record.recordID.zoneID == zoneID else {
continue
}
do {
let envelope = try Self.envelope(from: record)
try journal.clearQuarantine(recordName: record.recordID.recordName)
recordsByKey[envelope.key] = record
envelopes.append(envelope)
} catch {
try journal.quarantine(
recordName: record.recordID.recordName,
payloadData: (try? Self.payloadData(from: record)) ?? Data(),
reason: error.localizedDescription
)
quarantinedError = error.localizedDescription
}
}
let result = try applyRemote(envelopes)
for (key, record) in recordsByKey {
try journal.updateSystemFields(Self.systemFields(of: record), for: key)
}
try journal.markNeedsUpload(result.needsUpload)
// Physical deletes are not part of the protocol; recreate the local
let physicallyDeleted = Set(
changes.deletions.compactMap {
$0.recordID.zoneID == zoneID ? SettingsRecordKey(recordName: $0.recordID.recordName) : nil
}
).filter { (try? journal.envelope(for: $0)) != nil }
try journal.markNeedsUpload(physicallyDeleted)
addPendingChanges(result.needsUpload.union(physicallyDeleted))
try context.save()
if !result.acceptedKeys.isEmpty {
didApplyRemote?(result.snapshot, result.acceptedKeys)
}
if let quarantinedError { status = .failed(quarantinedError) }
}
private func handleSentChanges(
_ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine
) throws {
for record in changes.savedRecords where record.recordID.zoneID == zoneID {
guard let envelope = try? Self.envelope(from: record) else { continue }
try journal.markUploaded(envelope.key, version: envelope.version)
try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key)
try? FileManager.default.removeItem(at: Self.assetURL(for: envelope.key))
}
for failure in changes.failedRecordSaves where failure.record.recordID.zoneID == zoneID {
let error = failure.error
if error.code == .serverRecordChanged, let serverRecord = error.serverRecord,
let serverEnvelope = try? Self.envelope(from: serverRecord)
{
let localEnvelope = try journal.envelope(for: serverEnvelope.key)
let result = try applyRemote([serverEnvelope])
try journal.updateSystemFields(
Self.systemFields(of: serverRecord), for: serverEnvelope.key
)
if localEnvelope == serverEnvelope {
try journal.markUploaded(
serverEnvelope.key, version: serverEnvelope.version
)
} else {
try journal.markNeedsUpload(result.needsUpload)
addPendingChanges(result.needsUpload)
}
try context.save()
if !result.acceptedKeys.isEmpty {
didApplyRemote?(result.snapshot, result.acceptedKeys)
}
} else if error.code == .zoneNotFound {
try handleZoneDeletion(engine: engine)
} else if !Self.isRetryable(error) {
terminalError = error.localizedDescription
status = .failed(error.localizedDescription)
}
}
try context.save()
}
private func handleZoneDeletion(engine: CKSyncEngine) throws {
try journal.markAllEntriesNeedsUploadAfterZoneReset()
let zone = CKRecordZone(zoneID: zoneID)
engine.state.add(pendingDatabaseChanges: [.saveZone(zone)])
addPendingChanges(Set((try journal.pendingEnvelopes()).map(\.key)))
try context.save()
}
private func prepareForCurrentEnvironment() throws {
guard
defaults.string(forKey: SharedSettingsKey.settingsSyncEnvironment)
!= environmentIdentifier
else { return }
try journal.markAllEntriesNeedsUploadAfterEnvironmentChange()
try context.save()
defaults.set(environmentIdentifier, forKey: SharedSettingsKey.settingsSyncEnvironment)
terminalError = nil
}
public nonisolated static func makeRecord(
_ outgoing: SettingsOutgoingEntry, recordID: CKRecord.ID
) throws -> CKRecord {
let envelope = outgoing.envelope
let record: CKRecord
if let data = outgoing.systemFieldsData,
let restored = try Self.record(fromSystemFields: data)
{
record = restored
} else {
record = CKRecord(recordType: recordType, recordID: recordID)
}
let payload = try SettingsSyncCodec.encode(envelope)
record["formatVersion"] = NSNumber(value: envelope.formatVersion)
record["kind"] = envelope.key.kind.rawValue as NSString
record["entityID"] = envelope.key.id as NSString
record["modifiedAt"] = envelope.version.modifiedAt as NSDate
record["counter"] = NSNumber(value: Int64(clamping: envelope.version.counter))
record["writerID"] = envelope.version.writerID.uuidString.lowercased() as NSString
record["isDeleted"] = NSNumber(value: envelope.isDeleted)
record["payloadHash"] = SHA256.hash(data: payload).map({ String(format: "%02x", $0) }).joined() as NSString
if payload.count > inlinePayloadLimit {
let url = try assetURL(for: envelope.key)
try payload.write(to: url, options: .atomic)
record["payload"] = nil
record["payloadAsset"] = CKAsset(fileURL: url)
} else {
record["payload"] = payload as NSData
record["payloadAsset"] = nil
}
return record
}
public nonisolated static func envelope(from record: CKRecord) throws -> SettingsEnvelope {
let data = try payloadData(from: record)
let expectedHash = record["payloadHash"] as? String
guard expectedHash == SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() else {
throw SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
let envelope = try SettingsSyncCodec.decode(data)
guard envelope.key.recordName == record.recordID.recordName, envelope.isValid else {
throw SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
return envelope
}
public nonisolated static func payloadData(from record: CKRecord) throws -> Data {
if let inline = record["payload"] as? Data { return inline }
if let asset = record["payloadAsset"] as? CKAsset, let url = asset.fileURL {
return try Data(contentsOf: url)
}
throw SettingsSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
public nonisolated static func systemFields(of record: CKRecord) throws -> Data {
let archiver = NSKeyedArchiver(requiringSecureCoding: true)
record.encodeSystemFields(with: archiver)
archiver.finishEncoding()
return archiver.encodedData
}
private nonisolated static func record(fromSystemFields data: Data) throws -> CKRecord? {
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
unarchiver.requiresSecureCoding = true
defer { unarchiver.finishDecoding() }
return CKRecord(coder: unarchiver)
}
private nonisolated static func assetURL(for key: SettingsRecordKey) throws -> URL {
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appending(path: "Termind/SettingsSyncAssets", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appending(path: key.recordName.replacingOccurrences(of: "/", with: "-"))
}
private nonisolated static func isRetryable(_ error: CKError) -> Bool {
switch error.code {
case .networkFailure, .networkUnavailable, .serviceUnavailable,
.requestRateLimited, .zoneBusy, .resultsTruncated:
true
default: false
}
}
private nonisolated static func status(for error: any Error) -> VaultSyncStatus {
if let cloudError = error as? CKError {
if cloudError.code == .notAuthenticated {
return .unavailable(String(localized: "Sign in to iCloud to sync settings."))
}
if isRetryable(cloudError) { return .syncing(pendingCount: 0) }
}
return .failed(error.localizedDescription)
}
private nonisolated static func accountStatusDescription(
_ status: CKAccountStatus
) -> String {
switch status {
case .noAccount: String(localized: "Sign in to iCloud to sync settings.")
case .restricted: String(localized: "iCloud access is restricted on this Mac.")
case .couldNotDetermine: String(localized: "The iCloud account could not be checked.")
case .temporarilyUnavailable: String(localized: "iCloud is temporarily unavailable.")
case .available: ""
@unknown default: String(localized: "iCloud is unavailable.")
}
}
}
@@ -0,0 +1,585 @@
import CloudKit
import CryptoKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
@MainActor
@Observable
public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sendable {
public nonisolated static let containerIdentifier = "iCloud.com.akinokaede.termind"
public nonisolated static let zoneName = "TermindVaultV1"
public nonisolated static let recordType = "VaultItem"
public nonisolated static let subscriptionID = "termind-vault-v1-changes"
public nonisolated static let inlinePayloadLimit = 700_000
private let context: ModelContext
private let persistence: HostPersistence
private let journal: VaultSyncJournal
private let container: CKContainer
private let defaults: UserDefaults
private let environmentIdentifier: String
private let zoneID = CKRecordZone.ID(zoneName: zoneName, ownerName: CKCurrentUserDefaultName)
private var engine: CKSyncEngine?
private var operation: Task<Void, Never>?
private var operationID = UUID()
private var isReadyToSend = false
private var isStarted = false
private var terminalError: String?
public private(set) var status: VaultSyncStatus = .checkingAccount
public var didApplyRemote: ((VaultSnapshot, Set<VaultRecordKey>) -> Void)?
public var isEnabled: Bool {
guard
defaults.object(forKey: SharedSettingsKey.iCloudSyncEnabled) as? Bool
?? SharedSettingsKey.defaultICloudSyncEnabled
else { return false }
return defaults.object(forKey: SharedSettingsKey.vaultSyncEnabled) as? Bool
?? SharedSettingsKey.defaultVaultSyncEnabled
}
public init(
context: ModelContext,
container: CKContainer = CKContainer(identifier: VaultSyncCoordinator.containerIdentifier),
defaults: UserDefaults = .standard,
environmentIdentifier: String = VaultSyncCoordinator.runtimeEnvironmentIdentifier
) {
self.context = context
persistence = HostPersistence(context: context)
journal = VaultSyncJournal(context: context)
self.container = container
self.defaults = defaults
self.environmentIdentifier = environmentIdentifier
super.init()
}
public nonisolated static var runtimeEnvironmentIdentifier: String {
Bundle.main.object(forInfoDictionaryKey: "TermindCloudKitEnvironment") as? String
?? "Unknown"
}
public func start() {
guard isEnabled else {
pause()
return
}
guard !isStarted else { return }
isStarted = true
run { [weak self] in try await self?.prepareAndSynchronize() }
}
public func setEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: SharedSettingsKey.vaultSyncEnabled)
if enabled { start() } else { pause() }
}
public func pause() {
operationID = UUID()
isReadyToSend = false
operation?.cancel()
operation = nil
isStarted = false
let engine = self.engine
self.engine = nil
status = .paused
if let engine { Task { await engine.cancelOperations() } }
}
public func retry() {
guard isEnabled else { return }
terminalError = nil
if engine == nil {
isStarted = false
start()
} else {
run { [weak self] in try await self?.synchronizeNow() }
}
}
public func noteLocalChanges(_ mutations: VaultMutationSet) {
guard !mutations.isEmpty else { return }
addPendingChanges(mutations.keys)
guard isEnabled, engine != nil else { return }
run { [weak self] in try await self?.sendPendingChanges() }
}
public func confirmAccountSwitchMerge() {
guard isEnabled else { return }
do {
let state = try journal.state()
guard let pendingUser = state.pendingUserRecordName else { return }
try journal.markAllLiveRecordsNeedsUploadForNewAccount()
state.boundUserRecordName = pendingUser
state.pendingUserRecordName = nil
try context.save()
engine = nil
isStarted = false
start()
} catch {
context.rollback()
status = .failed(error.localizedDescription)
}
}
private func run(_ body: @escaping @MainActor () async throws -> Void) {
guard operation == nil else { return }
let id = UUID()
operationID = id
operation = Task { [weak self] in
defer { if self?.operationID == id { self?.operation = nil } }
do { try await body() } catch is CancellationError {} catch {
if self?.operationID == id { self?.status = Self.status(for: error) }
}
}
}
private func prepareAndSynchronize() async throws {
let id = operationID
status = .checkingAccount
let accountStatus = try await container.accountStatus()
guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return }
guard accountStatus == .available else {
status = .unavailable(Self.accountStatusDescription(accountStatus))
isStarted = false
return
}
let userRecordID = try await container.userRecordID()
guard operationID == id, isStarted, isEnabled, !Task.isCancelled else { return }
let currentUser = userRecordID.recordName
let state = try journal.state()
if let boundUser = state.boundUserRecordName, boundUser != currentUser {
state.pendingUserRecordName = currentUser
try context.save()
status = .accountChangeConfirmationRequired
isStarted = false
return
}
state.boundUserRecordName = currentUser
state.pendingUserRecordName = nil
try prepareForCurrentEnvironment()
let serialization = state.engineStateData.flatMap {
try? JSONDecoder().decode(CKSyncEngine.State.Serialization.self, from: $0)
}
var configuration = CKSyncEngine.Configuration(
database: container.privateCloudDatabase,
stateSerialization: serialization,
delegate: self
)
configuration.automaticallySync = true
configuration.subscriptionID = Self.subscriptionID
let engine = CKSyncEngine(configuration)
self.engine = engine
let pending = try journal.pendingEnvelopes()
addPendingChanges(Set(pending.map(\.key)))
try context.save()
// Zone creation is idempotent and deliberately precedes the first fetch;
// no local record is sent until the server side has been read.
let zone = CKRecordZone(zoneID: zoneID)
_ = try await container.privateCloudDatabase.modifyRecordZones(
saving: [zone], deleting: []
)
try await engine.fetchChanges(.init(scope: .zoneIDs([zoneID])))
guard self.engine === engine, isEnabled, !Task.isCancelled else { return }
isReadyToSend = true
try await sendPendingChanges()
try updateUpToDateStatus()
}
private func synchronizeNow() async throws {
guard let engine else { return }
status = .syncing(pendingCount: try journal.pendingEnvelopes().count)
try await engine.fetchChanges(.init(scope: .zoneIDs([zoneID])))
guard self.engine === engine, isEnabled, !Task.isCancelled else { return }
isReadyToSend = true
try await sendPendingChanges()
try updateUpToDateStatus()
}
private func sendPendingChanges() async throws {
guard let engine, isEnabled, isReadyToSend else { return }
let pending = try journal.pendingEnvelopes()
guard !pending.isEmpty else { return }
addPendingChanges(Set(pending.map(\.key)))
status = .syncing(pendingCount: pending.count)
try await engine.sendChanges()
}
private func addPendingChanges(_ keys: Set<VaultRecordKey>) {
guard let engine else { return }
engine.state.add(
pendingRecordZoneChanges: keys.map {
.saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID))
})
}
private func updateUpToDateStatus() throws {
if let quarantineReason = try journal.firstQuarantineReason() {
status = .failed(quarantineReason)
return
}
if let terminalError {
status = .failed(terminalError)
return
}
let pendingCount = try journal.pendingEnvelopes().count
let state = try journal.state()
if pendingCount == 0 {
state.lastSuccessfulSync = .now
try context.save()
status = .upToDate(lastSuccessfulSync: state.lastSuccessfulSync)
} else {
status = .syncing(pendingCount: pendingCount)
}
}
public nonisolated func nextFetchChangesOptions(
_ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine
) async -> CKSyncEngine.FetchChangesOptions {
.init(scope: .zoneIDs([CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)]))
}
// MARK: CKSyncEngineDelegate
public nonisolated func handleEvent(
_ event: CKSyncEngine.Event, syncEngine: CKSyncEngine
) async {
await handle(event, engine: syncEngine)
}
public nonisolated func nextRecordZoneChangeBatch(
_ context: CKSyncEngine.SendChangesContext, syncEngine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
await makeChangeBatch(context, engine: syncEngine)
}
@MainActor
private func makeChangeBatch(
_ context: CKSyncEngine.SendChangesContext, engine: CKSyncEngine
) async -> CKSyncEngine.RecordZoneChangeBatch? {
guard self.engine === engine, isEnabled, isReadyToSend else { return nil }
let pending = engine.state.pendingRecordZoneChanges.filter {
context.options.scope.contains($0)
}
return await CKSyncEngine.RecordZoneChangeBatch(pendingChanges: pending) {
[weak self] recordID in
guard let self else { return nil }
return await self.recordToSave(recordID)
}
}
@MainActor
private func recordToSave(_ recordID: CKRecord.ID) -> CKRecord? {
guard recordID.zoneID == zoneID, let key = VaultRecordKey(recordName: recordID.recordName),
let outgoing = try? journal.outgoingEntry(for: key)
else { return nil }
do { return try Self.makeRecord(outgoing, recordID: recordID) } catch {
terminalError = error.localizedDescription
status = .failed(error.localizedDescription)
return nil
}
}
@MainActor
private func handle(_ event: CKSyncEngine.Event, engine: CKSyncEngine) async {
guard self.engine === engine, isEnabled else { return }
do {
switch event {
case .stateUpdate(let update):
let state = try journal.state()
state.engineStateData = try JSONEncoder().encode(update.stateSerialization)
try context.save()
case .accountChange(let change):
try handleAccountChange(change)
case .fetchedRecordZoneChanges(let changes):
try handleFetchedChanges(changes, engine: engine)
case .fetchedDatabaseChanges(let changes):
if changes.deletions.contains(where: { $0.zoneID == zoneID }) {
try handleZoneDeletion(engine: engine)
}
case .sentRecordZoneChanges(let changes):
try handleSentChanges(changes, engine: engine)
case .didFetchChanges, .didSendChanges:
try updateUpToDateStatus()
default:
break
}
} catch {
context.rollback()
status = Self.status(for: error)
}
}
private func handleAccountChange(_ change: CKSyncEngine.Event.AccountChange) throws {
let state = try journal.state()
switch change.changeType {
case .signIn(let currentUser):
if let bound = state.boundUserRecordName, bound != currentUser.recordName {
stopEngineForAccountChange()
state.pendingUserRecordName = currentUser.recordName
status = .accountChangeConfirmationRequired
}
case .signOut:
stopEngineForAccountChange()
status = .unavailable(String(localized: "Sign in to iCloud to sync the Vault."))
case .switchAccounts(_, let currentUser):
stopEngineForAccountChange()
state.pendingUserRecordName = currentUser.recordName
status = .accountChangeConfirmationRequired
@unknown default:
stopEngineForAccountChange()
status = .unavailable(String(localized: "The iCloud account changed."))
}
try context.save()
}
private func stopEngineForAccountChange() {
isReadyToSend = false
let activeEngine = engine
engine = nil
isStarted = false
if let activeEngine { Task { await activeEngine.cancelOperations() } }
}
private func handleFetchedChanges(
_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges, engine: CKSyncEngine
) throws {
var recordsByKey: [VaultRecordKey: CKRecord] = [:]
var envelopes: [VaultEnvelope] = []
var quarantinedError: String?
for modification in changes.modifications {
let record = modification.record
guard record.recordType == Self.recordType, record.recordID.zoneID == zoneID else {
continue
}
do {
let envelope = try Self.envelope(from: record)
try journal.clearQuarantine(recordName: record.recordID.recordName)
recordsByKey[envelope.key] = record
envelopes.append(envelope)
} catch {
try journal.quarantine(
recordName: record.recordID.recordName,
payloadData: (try? Self.payloadData(from: record)) ?? Data(),
reason: error.localizedDescription
)
quarantinedError = error.localizedDescription
}
}
let result = try persistence.applyRemote(envelopes)
for (key, record) in recordsByKey {
try journal.updateSystemFields(Self.systemFields(of: record), for: key)
}
try journal.markNeedsUpload(result.needsUpload)
// Physical deletes are not part of the protocol; recreate the local
// envelope instead of converting a console accident into data loss.
let physicallyDeleted = Set(
changes.deletions.compactMap {
$0.recordID.zoneID == zoneID ? VaultRecordKey(recordName: $0.recordID.recordName) : nil
}
).filter { (try? journal.envelope(for: $0)) != nil }
try journal.markNeedsUpload(physicallyDeleted)
addPendingChanges(result.needsUpload.union(physicallyDeleted))
try context.save()
if !result.acceptedKeys.isEmpty {
didApplyRemote?(result.snapshot, result.acceptedKeys)
}
if let quarantinedError { status = .failed(quarantinedError) }
}
private func handleSentChanges(
_ changes: CKSyncEngine.Event.SentRecordZoneChanges, engine: CKSyncEngine
) throws {
for record in changes.savedRecords where record.recordID.zoneID == zoneID {
guard let envelope = try? Self.envelope(from: record) else { continue }
try journal.markUploaded(envelope.key, version: envelope.version)
try journal.updateSystemFields(Self.systemFields(of: record), for: envelope.key)
try? FileManager.default.removeItem(at: Self.assetURL(for: envelope.key))
}
for failure in changes.failedRecordSaves where failure.record.recordID.zoneID == zoneID {
let error = failure.error
if error.code == .serverRecordChanged, let serverRecord = error.serverRecord,
let serverEnvelope = try? Self.envelope(from: serverRecord)
{
let localEnvelope = try journal.envelope(for: serverEnvelope.key)
let result = try persistence.applyRemote([serverEnvelope])
try journal.updateSystemFields(
Self.systemFields(of: serverRecord), for: serverEnvelope.key
)
if localEnvelope == serverEnvelope {
// Clearing environment-specific system fields intentionally
// turns an existing identical record into a create attempt.
// The server copy proves it is already uploaded.
try journal.markUploaded(
serverEnvelope.key, version: serverEnvelope.version
)
} else {
try journal.markNeedsUpload(result.needsUpload)
addPendingChanges(result.needsUpload)
}
if !result.acceptedKeys.isEmpty {
didApplyRemote?(result.snapshot, result.acceptedKeys)
}
} else if error.code == .zoneNotFound {
try handleZoneDeletion(engine: engine)
} else if !Self.isRetryable(error) {
terminalError = error.localizedDescription
status = .failed(error.localizedDescription)
}
}
try context.save()
}
private func handleZoneDeletion(engine: CKSyncEngine) throws {
try journal.markAllEntriesNeedsUploadAfterZoneReset()
let zone = CKRecordZone(zoneID: zoneID)
engine.state.add(pendingDatabaseChanges: [.saveZone(zone)])
addPendingChanges(Set((try journal.pendingEnvelopes()).map(\.key)))
try context.save()
}
/// The sandbox is shared when a Debug build is replaced by TestFlight.
/// CKSyncEngine serialization and CKRecord system fields are not: they are
/// scoped to Development or Production. Rebind once per environment while
/// retaining every local envelope and its conflict-resolution version.
private func prepareForCurrentEnvironment() throws {
guard
defaults.string(forKey: SharedSettingsKey.vaultSyncEnvironment)
!= environmentIdentifier
else { return }
try journal.markAllEntriesNeedsUploadAfterEnvironmentChange()
try context.save()
defaults.set(environmentIdentifier, forKey: SharedSettingsKey.vaultSyncEnvironment)
terminalError = nil
}
// MARK: Record coding
public nonisolated static func makeRecord(
_ outgoing: VaultOutgoingEntry, recordID: CKRecord.ID
) throws -> CKRecord {
let envelope = outgoing.envelope
let record: CKRecord
if let data = outgoing.systemFieldsData,
let restored = try Self.record(fromSystemFields: data)
{
record = restored
} else {
record = CKRecord(recordType: recordType, recordID: recordID)
}
let payload = VaultSyncCodec.encode(envelope)
record["formatVersion"] = NSNumber(value: envelope.formatVersion)
record["kind"] = envelope.key.kind.rawValue as NSString
record["entityID"] = envelope.key.id.uuidString.lowercased() as NSString
record["modifiedAt"] = envelope.version.modifiedAt as NSDate
record["counter"] = NSNumber(value: Int64(clamping: envelope.version.counter))
record["writerID"] = envelope.version.writerID.uuidString.lowercased() as NSString
record["isDeleted"] = NSNumber(value: envelope.isDeleted)
record["payloadHash"] = SHA256.hash(data: payload).hexString as NSString
if payload.count > inlinePayloadLimit {
let url = try assetURL(for: envelope.key)
try payload.write(to: url, options: .atomic)
record["payload"] = nil
record["payloadAsset"] = CKAsset(fileURL: url)
} else {
record["payload"] = payload as NSData
record["payloadAsset"] = nil
}
return record
}
public nonisolated static func envelope(from record: CKRecord) throws -> VaultEnvelope {
let data = try payloadData(from: record)
let expectedHash = record["payloadHash"] as? String
guard expectedHash == SHA256.hash(data: data).hexString else {
throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
let envelope = try VaultSyncCodec.decode(data)
guard envelope.key.recordName == record.recordID.recordName, envelope.isValid else {
throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
return envelope
}
public nonisolated static func payloadData(from record: CKRecord) throws -> Data {
if let inline = record["payload"] as? Data { return inline }
if let asset = record["payloadAsset"] as? CKAsset, let url = asset.fileURL {
return try Data(contentsOf: url)
}
throw VaultSyncPersistenceError.invalidEnvelope(record.recordID.recordName)
}
public nonisolated static func systemFields(of record: CKRecord) throws -> Data {
let archiver = NSKeyedArchiver(requiringSecureCoding: true)
record.encodeSystemFields(with: archiver)
archiver.finishEncoding()
return archiver.encodedData
}
private nonisolated static func record(fromSystemFields data: Data) throws -> CKRecord? {
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
unarchiver.requiresSecureCoding = true
defer { unarchiver.finishDecoding() }
return CKRecord(coder: unarchiver)
}
private nonisolated static func assetURL(for key: VaultRecordKey) throws -> URL {
let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
.appending(path: "Termind/VaultSyncAssets", directoryHint: .isDirectory)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appending(path: key.recordName.replacingOccurrences(of: "/", with: "-"))
}
private nonisolated static func isRetryable(_ error: CKError) -> Bool {
switch error.code {
case .networkFailure, .networkUnavailable, .serviceUnavailable,
.requestRateLimited, .zoneBusy, .resultsTruncated:
true
default: false
}
}
private nonisolated static func status(for error: any Error) -> VaultSyncStatus {
if let cloudError = error as? CKError {
if cloudError.code == .notAuthenticated {
return .unavailable(String(localized: "Sign in to iCloud to sync the Vault."))
}
if isRetryable(cloudError) { return .syncing(pendingCount: 0) }
}
return .failed(error.localizedDescription)
}
private nonisolated static func accountStatusDescription(
_ status: CKAccountStatus
) -> String {
switch status {
case .noAccount: String(localized: "Sign in to iCloud to sync the Vault.")
case .restricted: String(localized: "iCloud access is restricted on this Mac.")
case .couldNotDetermine: String(localized: "The iCloud account could not be checked.")
case .temporarilyUnavailable: String(localized: "iCloud is temporarily unavailable.")
case .available: ""
@unknown default: String(localized: "iCloud is unavailable.")
}
}
}
extension SHA256.Digest {
fileprivate nonisolated var hexString: String { map { String(format: "%02x", $0) }.joined() }
}
@@ -1,11 +1,3 @@
import Foundation
import TermindDomain
public nonisolated enum MetricsReadError: LocalizedError {
case unsupported, output
public var errorDescription: String? {
switch self {
case .unsupported: String(localized: "Metrics requires a Linux server with procfs.")
case .output: String(localized: "The metrics response is incomplete or too large.")
}
}
}
public typealias MetricsReadError = TermindDomain.MetricsReadError
@@ -0,0 +1,64 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
@MainActor
public final class SwiftDataCommandHistoryRepository: CommandHistoryRepository {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry] {
try context.fetch(
FetchDescriptor<PersistentCommandHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.executedAt, order: .reverse)]
)
).map(\.value)
}
public func insert(_ entry: CommandHistoryEntry, limit: Int) throws {
try context.commit {
let hostID = entry.hostID
var descriptor = FetchDescriptor<PersistentCommandHistoryEntry>(
predicate: #Predicate { $0.hostID == hostID },
sortBy: [SortDescriptor(\.executedAt, order: .reverse)]
)
descriptor.fetchOffset = max(0, limit - 1)
for old in try context.fetch(descriptor) { context.delete(old) }
context.insert(PersistentCommandHistoryEntry(entry))
}
}
public func update(_ entry: CommandHistoryEntry) throws {
let id = entry.id
var descriptor = FetchDescriptor<PersistentCommandHistoryEntry>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
guard let existing = try context.fetch(descriptor).first else { return }
try context.commit { existing.apply(entry) }
}
public func delete(_ id: CommandHistoryEntry.ID) throws {
try context.commit {
var descriptor = FetchDescriptor<PersistentCommandHistoryEntry>(
predicate: #Predicate { $0.id == id }
)
descriptor.fetchLimit = 1
if let entry = try context.fetch(descriptor).first { context.delete(entry) }
}
}
public func clear(for hostID: Host.ID) throws {
try context.commit {
try context.delete(
model: PersistentCommandHistoryEntry.self,
where: #Predicate { $0.hostID == hostID }
)
}
}
}
@@ -0,0 +1,319 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
public nonisolated struct SettingsRemoteMerge: Sendable {
public var accepted: [SettingsEnvelope] = []
public var needsUpload: Set<SettingsRecordKey> = []
}
public nonisolated struct SettingsOutgoingEntry: Sendable {
public var envelope: SettingsEnvelope
public var systemFieldsData: Data?
public init(envelope: SettingsEnvelope, systemFieldsData: Data? = nil) {
self.envelope = envelope
self.systemFieldsData = systemFieldsData
}
}
@MainActor
public final class SettingsSyncJournal {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func state() throws -> PersistentAIConfigurationSyncState {
var descriptor = FetchDescriptor<PersistentAIConfigurationSyncState>()
descriptor.fetchLimit = 1
if let existing = try context.fetch(descriptor).first { return existing }
let state = PersistentAIConfigurationSyncState()
context.insert(state)
return state
}
@discardableResult
public func bootstrapIfNeeded(_ snapshot: SettingsSyncSnapshot) throws -> SettingsMutationSet {
let state = try state()
guard !state.bootstrapCompleted else { return .empty }
let existing = try entriesByName()
var changed = Set<SettingsRecordKey>()
for payload in snapshot.recordPayloads.values {
let key = payload.key
guard existing[key.recordName] == nil else { continue }
let envelope = SettingsEnvelope(
key: key, version: .baseline(writerID: state.installationID), payload: payload
)
context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: true))
changed.insert(key)
}
state.bootstrapCompleted = true
return SettingsMutationSet(keys: changed)
}
public func recordLocalChanges(
from oldSnapshot: SettingsSyncSnapshot, to newSnapshot: SettingsSyncSnapshot,
now: Date = .now
) throws -> SettingsMutationSet {
let state = try state()
let old = oldSnapshot.recordPayloads
let new = newSnapshot.recordPayloads
var entries = try entriesByName()
var changed = Set<SettingsRecordKey>()
for (key, payload) in new where old[key] != payload {
let row = entries[key.recordName]
let envelope = SettingsEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload
)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
for key in old.keys where new[key] == nil {
let row = entries[key.recordName]
let envelope = SettingsEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
isDeleted: true
)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
return SettingsMutationSet(keys: changed)
}
public func pendingEnvelopes() throws -> [SettingsEnvelope] {
try context.fetch(
FetchDescriptor<PersistentAIConfigurationSyncEntry>(
predicate: #Predicate { $0.needsUpload }
)
).compactMap(\.envelope)
}
public func outgoingEntry(for key: SettingsRecordKey) throws -> SettingsOutgoingEntry? {
guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else {
return nil
}
return SettingsOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData)
}
public func envelope(for key: SettingsRecordKey) throws -> SettingsEnvelope? {
try entry(recordName: key.recordName)?.envelope
}
public func mergeDecision(for remote: [SettingsEnvelope]) throws -> SettingsRemoteMerge {
var result = SettingsRemoteMerge()
let entries = try entriesByName()
// A fetch batch can contain several versions of one record. Compare the
// newest candidate once, independently of delivery order.
var candidates: [SettingsRecordKey: SettingsEnvelope] = [:]
for envelope in remote {
guard envelope.isValid else {
throw SettingsSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
if let previous = candidates[envelope.key] {
if envelope.version < previous.version { continue }
let previousData = try SettingsSyncCodec.encode(previous)
let incomingData = try SettingsSyncCodec.encode(envelope)
if envelope.version == previous.version, !previousData.lexicographicallyPrecedes(incomingData) {
continue
}
}
candidates[envelope.key] = envelope
}
for envelope in candidates.values {
guard envelope.formatVersion <= SettingsEnvelope.currentFormatVersion else {
throw SettingsSyncPersistenceError.unsupportedFormat(envelope.formatVersion)
}
guard envelope.isValid else {
throw SettingsSyncPersistenceError.invalidEnvelope(envelope.key.recordName)
}
guard let local = entries[envelope.key.recordName]?.envelope else {
result.accepted.append(envelope)
continue
}
if local.version.modifiedAt == VaultVersion.baselineDate
&& !local.isDeleted && local != envelope
{
result.accepted.append(envelope)
} else if local.version < envelope.version {
result.accepted.append(envelope)
} else if envelope.version < local.version {
result.needsUpload.insert(local.key)
} else if local != envelope {
let localData = try SettingsSyncCodec.encode(local)
let remoteData = try SettingsSyncCodec.encode(envelope)
if localData.lexicographicallyPrecedes(remoteData) {
result.accepted.append(envelope)
} else {
result.needsUpload.insert(local.key)
}
}
}
return result
}
public func acceptRemote(_ envelopes: [SettingsEnvelope]) throws {
var entries = try entriesByName()
for envelope in envelopes {
try store(envelope, needsUpload: false, in: &entries)
}
}
public func markNeedsUpload(_ keys: Set<SettingsRecordKey>) throws {
let entries = try entriesByName()
for key in keys { entries[key.recordName]?.needsUpload = true }
}
public func markUploaded(_ key: SettingsRecordKey, version: VaultVersion) throws {
guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else {
return
}
row.needsUpload = false
}
public func updateSystemFields(_ data: Data?, for key: SettingsRecordKey) throws {
try entry(recordName: key.recordName)?.systemFieldsData = data
}
public func quarantine(recordName: String, payloadData: Data, reason: String) throws {
var descriptor = FetchDescriptor<PersistentAIConfigurationSyncQuarantine>(
predicate: #Predicate { $0.recordName == recordName }
)
descriptor.fetchLimit = 1
if let row = try context.fetch(descriptor).first {
row.payloadData = payloadData
row.reason = reason
row.receivedAt = .now
} else {
context.insert(
PersistentAIConfigurationSyncQuarantine(
recordName: recordName, payloadData: payloadData, reason: reason
))
}
}
public func clearQuarantine(recordName: String) throws {
try context.delete(
model: PersistentAIConfigurationSyncQuarantine.self,
where: #Predicate { $0.recordName == recordName }
)
}
public func firstQuarantineReason() throws -> String? {
var descriptor = FetchDescriptor<PersistentAIConfigurationSyncQuarantine>(
sortBy: [SortDescriptor(\.receivedAt, order: .reverse)]
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first?.reason
}
public func recordCurrentSnapshot(_ snapshot: SettingsSyncSnapshot, now: Date = .now) throws
-> SettingsMutationSet
{
let state = try state()
let current = snapshot.recordPayloads
var entries = try entriesByName()
var changed = Set<SettingsRecordKey>()
for (key, payload) in current {
let row = entries[key.recordName]
guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else {
continue
}
let envelope = SettingsEnvelope(
key: key,
version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now),
payload: payload
)
try store(envelope, needsUpload: true, in: &entries)
changed.insert(key)
}
for row in entries.values {
guard let envelope = row.envelope, !envelope.isDeleted,
current[envelope.key] == nil
else { continue }
let tombstone = SettingsEnvelope(
key: envelope.key,
version: .next(after: envelope.version, writerID: state.installationID, now: now),
isDeleted: true
)
try row.apply(tombstone, needsUpload: true)
changed.insert(envelope.key)
}
return SettingsMutationSet(keys: changed)
}
public func markAllLiveRecordsNeedsUploadForNewAccount() throws {
for row in try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()) {
guard let envelope = row.envelope else { continue }
if envelope.isDeleted {
context.delete(row)
} else {
row.needsUpload = true
row.systemFieldsData = nil
}
}
let state = try state()
state.engineStateData = nil
state.lastSuccessfulSync = nil
}
public func markAllEntriesNeedsUploadAfterZoneReset() throws {
for row in try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()) {
row.needsUpload = true
row.systemFieldsData = nil
}
let state = try state()
state.engineStateData = nil
state.lastSuccessfulSync = nil
}
public func markAllEntriesNeedsUploadAfterEnvironmentChange() throws {
try markAllEntriesNeedsUploadAfterZoneReset()
}
private func entry(recordName: String) throws -> PersistentAIConfigurationSyncEntry? {
var descriptor = FetchDescriptor<PersistentAIConfigurationSyncEntry>(
predicate: #Predicate { $0.recordName == recordName }
)
descriptor.fetchLimit = 1
return try context.fetch(descriptor).first
}
private func store(
_ envelope: SettingsEnvelope, needsUpload: Bool,
in entries: inout [String: PersistentAIConfigurationSyncEntry]
) throws {
if let row = entries[envelope.key.recordName] {
try row.apply(envelope, needsUpload: needsUpload)
} else {
let row = try PersistentAIConfigurationSyncEntry(
envelope: envelope, needsUpload: needsUpload
)
context.insert(row)
entries[envelope.key.recordName] = row
}
}
private func entriesByName() throws -> [String: PersistentAIConfigurationSyncEntry] {
Dictionary(
try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()).map {
($0.recordName, $0)
},
uniquingKeysWith: { first, duplicate in
if let firstVersion = first.envelope?.version,
let duplicateVersion = duplicate.envelope?.version,
firstVersion < duplicateVersion
{
return duplicate
}
return first
}
)
}
}
@@ -0,0 +1,280 @@
import AgentKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
public nonisolated struct SettingsSyncSnapshot: Sendable {
public var configuration: AIConfigurationSnapshot
public var skills: [AgentSkill]
public var webSearch: AIWebSearchPreferences
public var terminal = TerminalPreferences()
public init(
configuration: AIConfigurationSnapshot, skills: [AgentSkill], webSearch: AIWebSearchPreferences,
terminal: TerminalPreferences = .init()
) {
self.configuration = configuration
self.skills = skills
self.webSearch = webSearch
self.terminal = terminal
}
public var recordPayloads: [SettingsRecordKey: SettingsRecordPayload] {
var payloads: [SettingsRecordPayload] = []
for (index, provider) in configuration.providers.enumerated() {
payloads.append(
.provider(
SyncedModelProvider(
provider,
endpoint: configuration.endpoints[provider.id] ?? ProviderEndpoint(url: provider.inferenceURL),
sortIndex: index)))
}
for (index, server) in configuration.mcpServers.enumerated() {
payloads.append(.mcpServer(SyncedMCPServer(server, sortIndex: index)))
}
for (index, skill) in skills.enumerated() { payloads.append(.skill(skill, sortIndex: index)) }
for role in AIRole.allCases { payloads.append(.role(role, configuration.roles[role])) }
payloads.append(.selection(configuration.lastSelectedModel))
payloads.append(.webSearch(webSearch))
payloads.append(.terminal(terminal))
return Dictionary(uniqueKeysWithValues: payloads.map { ($0.key, $0) })
}
}
/// Configuration and upload intent commit together; remote updates skip the local write path.
@MainActor
public final class SettingsSyncPersistence {
public let context: ModelContext
private let defaults: UserDefaults
private let ai: AIPersistence
private let skills: SkillPersistence
public let journal: SettingsSyncJournal
public var didRecordChanges: ((SettingsMutationSet) -> Void)?
public init(context: ModelContext, defaults: UserDefaults = .standard) {
self.context = context
self.defaults = defaults
ai = AIPersistence(context: context)
skills = SkillPersistence(context: context)
journal = SettingsSyncJournal(context: context)
}
public func prepare() throws {
let seeded = defaults.integer(forKey: SharedSettingsKey.seededSkillGeneration)
var mutations = SettingsMutationSet.empty
try context.commit {
if try !journal.state().bootstrapCompleted {
let installed = try skills.load()
let normalized = Self.normalizeBuiltIns(installed, seededGeneration: seeded)
try skills.replace(normalized.skills)
let snapshot = try load()
try journal.bootstrapIfNeeded(snapshot)
let writer = try journal.state().installationID
for id in normalized.deleted {
let key = SettingsRecordKey(kind: .skill, id: id.uuidString.lowercased())
let envelope = SettingsEnvelope(
key: key, version: .baseline(writerID: writer), isDeleted: true)
context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: true))
}
} else if seeded < AgentSkillLibrary.currentGeneration {
let previous = try load()
var installed = previous.skills
for skill in AgentSkillLibrary.builtIns(after: seeded) {
let key = SettingsRecordKey(kind: .skill, id: skill.id.uuidString.lowercased())
if try journal.envelope(for: key) == nil { installed.append(skill) }
}
try skills.replace(installed)
mutations = try journal.recordLocalChanges(from: previous, to: load())
}
// V14 adds Terminal preferences to stores whose V13 bootstrap is already complete.
let terminal = SettingsRecordPayload.terminal(try terminalPreferences().load())
if try journal.envelope(for: terminal.key) == nil {
let version = VaultVersion.baseline(writerID: try journal.state().installationID)
let envelope = SettingsEnvelope(key: terminal.key, version: version, payload: terminal)
context.insert(try PersistentAIConfigurationSyncEntry(envelope: envelope, needsUpload: true))
mutations.keys.insert(terminal.key)
}
}
defaults.set(AgentSkillLibrary.currentGeneration, forKey: SharedSettingsKey.seededSkillGeneration)
let snapshot = try load()
mirror(snapshot.webSearch)
snapshot.terminal.mirror(to: defaults)
didRecordChanges?(mutations)
}
public static func normalizeBuiltIns(_ installed: [AgentSkill], seededGeneration: Int)
-> (skills: [AgentSkill], deleted: Set<UUID>)
{
var result = installed
var deleted = Set<UUID>()
let newIDs = Set(AgentSkillLibrary.builtIns(after: seededGeneration).map(\.id))
for builtIn in AgentSkillLibrary.builtIns(after: 0) {
let matching = result.indices.filter {
result[$0].id == builtIn.id
|| (result[$0].name == builtIn.name && result[$0].title == builtIn.title
&& result[$0].summary == builtIn.summary && result[$0].body == builtIn.body)
}
if let first = matching.first {
result[first].id = builtIn.id
for index in matching.dropFirst().reversed() { result.remove(at: index) }
} else if newIDs.contains(builtIn.id) {
result.append(builtIn)
} else {
deleted.insert(builtIn.id)
}
}
return (result, deleted)
}
public func load() throws -> SettingsSyncSnapshot {
SettingsSyncSnapshot(
configuration: try ai.load(preservingUnresolvedReferences: true),
skills: try skills.load(), webSearch: try preferences().value, terminal: try terminalPreferences().load())
}
public func saveConfiguration(_ configuration: AIConfigurationSnapshot) throws {
try saveLocal { try ai.replace(configuration) }
}
public func saveSkills(_ values: [AgentSkill]) throws {
try saveLocal { try skills.replace(values) }
}
public func saveWebSearch(_ value: AIWebSearchPreferences) throws {
try saveLocal { try preferences().apply(value) }
mirror(value)
}
private func saveLocal(_ change: () throws -> Void) throws {
var mutations = SettingsMutationSet.empty
try context.commit {
let previous = try load()
try journal.bootstrapIfNeeded(previous)
try change()
mutations = try journal.recordLocalChanges(from: previous, to: load())
}
didRecordChanges?(mutations)
}
public func applyRemote(_ envelopes: [SettingsEnvelope]) throws
-> (
snapshot: SettingsSyncSnapshot, acceptedKeys: Set<SettingsRecordKey>,
needsUpload: Set<SettingsRecordKey>
)
{
var merge = SettingsRemoteMerge()
var snapshot = try load()
try context.commit {
merge = try journal.mergeDecision(for: envelopes)
guard !merge.accepted.isEmpty else {
try journal.markNeedsUpload(merge.needsUpload)
return
}
try journal.acceptRemote(merge.accepted)
// Rebuild from the complete journal, so roles arriving before providers survive.
snapshot = try materialize()
try ai.replace(snapshot.configuration)
try skills.replace(snapshot.skills)
try preferences().apply(snapshot.webSearch)
try terminalPreferences().apply(snapshot.terminal)
try journal.markNeedsUpload(merge.needsUpload)
}
mirror(snapshot.webSearch)
snapshot.terminal.mirror(to: defaults)
return (snapshot, Set(merge.accepted.map(\.key)), merge.needsUpload)
}
private func materialize() throws -> SettingsSyncSnapshot {
let envelopes = try context.fetch(FetchDescriptor<PersistentAIConfigurationSyncEntry>()).compactMap(\.envelope)
var providers: [SyncedModelProvider] = []
var servers: [SyncedMCPServer] = []
var skillRows: [(AgentSkill, Int)] = []
var configuration = AIConfigurationSnapshot(providers: [], roles: [:])
var webSearch = try preferences().value
var terminal = try terminalPreferences().load()
for envelope in envelopes where !envelope.isDeleted {
switch envelope.payload {
case .provider(let value): providers.append(value)
case .mcpServer(let value): servers.append(value)
case .skill(let value, let index): skillRows.append((value, index))
case .role(let role, let ref): configuration.roles[role] = ref
case .selection(let ref): configuration.lastSelectedModel = ref
case .webSearch(let value): webSearch = value
case .terminal(let value): terminal = value
case nil: break
}
}
providers.sort { ($0.sortIndex, $0.id.uuidString) < ($1.sortIndex, $1.id.uuidString) }
servers.sort { ($0.sortIndex, $0.id.uuidString) < ($1.sortIndex, $1.id.uuidString) }
skillRows.sort { ($0.1, $0.0.id.uuidString) < ($1.1, $1.0.id.uuidString) }
configuration.providers = providers.map(\.value)
configuration.endpoints = Dictionary(uniqueKeysWithValues: providers.map { ($0.id, $0.endpoint) })
configuration.mcpServers = servers.map(\.value)
let deletedProviders = Set(envelopes.filter { $0.isDeleted && $0.key.kind == .provider }.map(\.key.id))
func wasRemoved(_ ref: AIModelRef) -> Bool {
if deletedProviders.contains(ref.modelProviderID.uuidString.lowercased()) { return true }
guard let provider = configuration.providers.first(where: { $0.id == ref.modelProviderID }) else {
return false
}
return provider.model(ref.modelID) == nil
}
configuration.roles = configuration.roles.filter { !wasRemoved($0.value) }
if let ref = configuration.lastSelectedModel, wasRemoved(ref) { configuration.lastSelectedModel = nil }
return SettingsSyncSnapshot(
configuration: configuration, skills: skillRows.map(\.0), webSearch: webSearch, terminal: terminal)
}
private func terminalPreferences() throws -> PersistentTerminalPreferences {
if let row = try context.fetch(FetchDescriptor<PersistentTerminalPreferences>()).first { return row }
let row = try PersistentTerminalPreferences(TerminalPreferences(defaults: defaults))
context.insert(row)
return row
}
public func loadTerminal() throws -> TerminalPreferences { try terminalPreferences().load() }
public func saveTerminal(_ value: TerminalPreferences) throws {
try saveLocal { try terminalPreferences().apply(value) }
value.mirror(to: defaults)
}
private func preferences() throws -> PersistentAIWebSearchPreferences {
if let row = try context.fetch(FetchDescriptor<PersistentAIWebSearchPreferences>()).first { return row }
let row = PersistentAIWebSearchPreferences(
AIWebSearchPreferences(
isEnabled: defaults.object(forKey: SharedSettingsKey.webSearchEnabled) as? Bool
?? SharedSettingsKey.defaultWebSearchEnabled,
prefersBuiltIn: defaults.object(forKey: SharedSettingsKey.webSearchBuiltIn) as? Bool
?? SharedSettingsKey.defaultWebSearchBuiltIn))
context.insert(row)
return row
}
private func mirror(_ value: AIWebSearchPreferences) {
defaults.set(value.isEnabled, forKey: SharedSettingsKey.webSearchEnabled)
defaults.set(value.prefersBuiltIn, forKey: SharedSettingsKey.webSearchBuiltIn)
}
}
@MainActor
public final class SyncedAIConfigurationPersistence: AIConfigurationPersisting {
private let store: SettingsSyncPersistence
public init(_ store: SettingsSyncPersistence) { self.store = store }
public func load() throws -> AIConfigurationSnapshot { try store.load().configuration }
public func save(_ snapshot: AIConfigurationSnapshot) throws { try store.saveConfiguration(snapshot) }
}
@MainActor
public final class SyncedSkillLibraryPersistence: SkillLibraryPersisting {
private let store: SettingsSyncPersistence
public init(_ store: SettingsSyncPersistence) { self.store = store }
public func load() throws -> [AgentSkill] { try store.load().skills }
public func save(_ skills: [AgentSkill]) throws { try store.saveSkills(skills) }
}
extension SettingsSyncPersistence: AIWebSearchPersisting {
public func loadWebSearch() throws -> AIWebSearchPreferences { try load().webSearch }
}
extension SettingsSyncPersistence: TerminalPreferencesPersisting {}
@@ -4,3 +4,12 @@ public typealias SharedVaultRemoteMerge = VaultRemoteMerge
public typealias SharedVaultOutgoingEntry = VaultOutgoingEntry
public typealias SharedVaultSyncJournal = VaultSyncJournal
public typealias SharedSwiftDataHostRepository = SwiftDataHostRepository
public typealias SharedSettingsOutgoingEntry = SettingsOutgoingEntry
public typealias SharedSettingsRemoteMerge = SettingsRemoteMerge
public typealias SharedSettingsSyncJournal = SettingsSyncJournal
public typealias SharedSettingsSyncPersistence = SettingsSyncPersistence
public typealias SharedSettingsSyncSnapshot = SettingsSyncSnapshot
public typealias SharedSkillPersistence = SkillPersistence
public typealias SharedSwiftDataCommandHistoryRepository = SwiftDataCommandHistoryRepository
public typealias SharedSyncedAIConfigurationPersistence = SyncedAIConfigurationPersistence
public typealias SharedSyncedSkillLibraryPersistence = SyncedSkillLibraryPersistence
@@ -0,0 +1,44 @@
import AgentKit
import Foundation
import SwiftData
import TermindApplication
/// Local storage for the skill library.
///
/// Delete-and-rewrite, following `AIPersistence.save`: the library is a short,
/// wholly-owned list, and a diff would be more code to get wrong than the write
/// it saves. Its own store rather than a fourth field on the AI snapshot, so
/// editing one skill does not rewrite every provider and model row.
@MainActor
public final class SkillPersistence {
private let context: ModelContext
public init(context: ModelContext) {
self.context = context
}
public func load() throws -> [AgentSkill] {
let rows = try context.fetch(
FetchDescriptor<PersistentAgentSkill>(sortBy: [SortDescriptor(\.sortIndex)])
)
var seen: Set<UUID> = []
return rows.compactMap { row in
guard seen.insert(row.id).inserted else { return nil }
return row.value
}
}
public func save(_ skills: [AgentSkill]) throws {
try context.commit { try replace(skills) }
}
/// Stages rows in the caller-owned transaction.
public func replace(_ skills: [AgentSkill]) throws {
try context.delete(model: PersistentAgentSkill.self)
for (index, skill) in skills.enumerated() {
context.insert(PersistentAgentSkill(skill, sortIndex: index))
}
}
}
extension SkillPersistence: SkillLibraryPersisting {}
@@ -0,0 +1,37 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
extension TerminalPreferences {
public nonisolated init(defaults: UserDefaults) {
self.init()
fontSize = defaults.object(forKey: SharedSettingsKey.terminalFontSize) as? Double ?? fontSize
fontFollowTheme = defaults.object(forKey: SharedSettingsKey.terminalFontFollowTheme) as? Bool ?? fontFollowTheme
darkTheme = defaults.string(forKey: SharedSettingsKey.terminalThemeDark) ?? darkTheme
lightTheme = defaults.string(forKey: SharedSettingsKey.terminalThemeLight) ?? lightTheme
darkCustomEnabled =
defaults.object(forKey: SharedSettingsKey.terminalCustomConfigurationDarkEnabled) as? Bool ?? false
lightCustomEnabled =
defaults.object(forKey: SharedSettingsKey.terminalCustomConfigurationLightEnabled) as? Bool ?? false
darkCustomConfiguration = defaults.string(forKey: SharedSettingsKey.terminalCustomConfigurationDark) ?? ""
lightCustomConfiguration = defaults.string(forKey: SharedSettingsKey.terminalCustomConfigurationLight) ?? ""
shellIntegration = defaults.object(forKey: SharedSettingsKey.shellIntegration) as? Bool ?? shellIntegration
remoteWindowTitleInMenuBar =
defaults.object(forKey: SharedSettingsKey.remoteWindowTitleInMenuBar) as? Bool
?? remoteWindowTitleInMenuBar
}
public nonisolated func mirror(to defaults: UserDefaults) {
defaults.set(fontSize, forKey: SharedSettingsKey.terminalFontSize)
defaults.set(fontFollowTheme, forKey: SharedSettingsKey.terminalFontFollowTheme)
defaults.set(darkTheme, forKey: SharedSettingsKey.terminalThemeDark)
defaults.set(lightTheme, forKey: SharedSettingsKey.terminalThemeLight)
defaults.set(darkCustomEnabled, forKey: SharedSettingsKey.terminalCustomConfigurationDarkEnabled)
defaults.set(lightCustomEnabled, forKey: SharedSettingsKey.terminalCustomConfigurationLightEnabled)
defaults.set(darkCustomConfiguration, forKey: SharedSettingsKey.terminalCustomConfigurationDark)
defaults.set(lightCustomConfiguration, forKey: SharedSettingsKey.terminalCustomConfigurationLight)
defaults.set(shellIntegration, forKey: SharedSettingsKey.shellIntegration)
defaults.set(remoteWindowTitleInMenuBar, forKey: SharedSettingsKey.remoteWindowTitleInMenuBar)
}
}
@@ -0,0 +1,416 @@
import SwiftUI
import TermindApplication
import TermindDomain
@MainActor
public protocol ConnectionFormKeyboardAdapting {
func bridge(canSubmit: Bool, submit: @escaping () -> Void, cancel: @escaping () -> Void) -> AnyView
}
extension View {
@ViewBuilder
fileprivate func sharedConnectionFormChrome(isEnabled: Bool) -> some View {
if isEnabled {
padding(18).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay { RoundedRectangle(cornerRadius: 14).strokeBorder(.quaternary) }
} else {
self
}
}
}
public struct SharedSSHAuthenticationForm: View {
let prompt: SSHAuthenticationPrompt
let submit: (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void
let cancel: () -> Void
var usesContainerChrome = true
let library: HostLibraryModel
let keyboard: (any ConnectionFormKeyboardAdapting)?
@State private var identityID: Identity.ID?
@State private var username: String
@State private var password = ""
@State private var passphrase = ""
@State private var keyID: SSHKeyRef.ID?
@FocusState private var focusedField: Field?
private enum Field { case username, password, passphrase }
public init(
prompt: SSHAuthenticationPrompt,
submit: @escaping (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void,
cancel: @escaping () -> Void,
usesContainerChrome: Bool = true,
library: HostLibraryModel, keyboard: (any ConnectionFormKeyboardAdapting)? = nil
) {
self.library = library
self.keyboard = keyboard
self.prompt = prompt
self.submit = submit
self.cancel = cancel
self.usesContainerChrome = usesContainerChrome
_identityID = State(initialValue: prompt.selectedIdentityID)
_username = State(initialValue: prompt.username)
_keyID = State(initialValue: prompt.selectedKeyID)
}
private var needsPassphrase: Bool {
prompt.requiresPassphrase
}
private var canContinue: Bool {
!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (!needsPassphrase || !passphrase.isEmpty)
}
private var title: LocalizedStringKey {
if needsPassphrase { return "Passphrase required" }
return prompt.mode == .localIdentity
? "Identity required" : "Authentication required"
}
public var body: some View {
form
.sharedConnectionFormChrome(isEnabled: usesContainerChrome)
.background {
if let keyboard {
keyboard.bridge(canSubmit: canContinue, submit: { commit(save: !needsPassphrase) }, cancel: cancel)
}
}
.onAppear {
if identityID != nil, selectedIdentity == nil { identityID = nil }
updateFocusForRequirement()
}
}
private var form: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(title)
.font(.headline)
Text(prompt.host.displayAddress).font(.caption.monospaced()).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "key.fill").font(.title2)
.foregroundStyle(Color.accentColor)
}
if let message = prompt.failureMessage {
Label(message, systemImage: "exclamationmark.circle.fill")
.font(.caption).foregroundStyle(Color.red)
.textSelection(.enabled)
}
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
if needsPassphrase {
GridRow {
Text("Passphrase").foregroundStyle(.secondary)
SecureField("Required for this connection", text: $passphrase)
.focused($focusedField, equals: .passphrase)
}
} else {
GridRow {
Text("Identity").foregroundStyle(.secondary)
fullWidthMenu(title: identityLabel) {
Button {
identitySelection.wrappedValue = nil
} label: {
selectionMenuLabel(
String(localized: "Custom"), selected: identityID == nil
)
}
if !library.identities.isEmpty { Divider() }
ForEach(library.identities) { identity in
Button {
identitySelection.wrappedValue = identity.id
} label: {
selectionMenuLabel(
"\(identity.name) · \(identity.username)",
selected: identityID == identity.id
)
}
}
}
}
if identityID == nil {
GridRow {
Text("Username").foregroundStyle(.secondary)
TextField("Username", text: usernameInput)
.focused($focusedField, equals: .username)
}
GridRow {
Text("Password").foregroundStyle(.secondary)
SecureField("Optional", text: passwordInput)
.focused($focusedField, equals: .password)
}
GridRow {
Text("Key").foregroundStyle(.secondary)
fullWidthMenu(title: keyLabel) {
Button {
keySelection.wrappedValue = nil
} label: {
selectionMenuLabel(
String(localized: "None"), selected: keyID == nil
)
}
if !library.keys.isEmpty { Divider() }
ForEach(library.keys) { key in
Button {
keySelection.wrappedValue = key.id
} label: {
selectionMenuLabel(
key.label, selected: keyID == key.id
)
}
}
}
}
}
}
}
.frame(maxWidth: .infinity)
.textFieldStyle(.roundedBorder)
#if os(iOS)
.multilineTextAlignment(.trailing)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
#endif
HStack {
Button("Cancel", action: cancel).keyboardShortcut(.cancelAction)
Spacer()
if needsPassphrase {
Button("Continue") { commit(save: false) }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(!canContinue)
} else {
Button("Continue") { commit(save: false) }.disabled(!canContinue)
Button("Save and Continue") { commit(save: true) }
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(!canContinue)
}
}
}
}
private func updateFocusForRequirement() {
if needsPassphrase {
focusedField = .passphrase
} else if identityID == nil {
focusedField = username.isEmpty ? .username : .password
} else {
focusedField = nil
}
}
private func commit(save: Bool) {
submit(
identityID, username, password.isEmpty ? nil : password,
passphrase.isEmpty ? nil : passphrase, keyID, save
)
}
private var selectedIdentity: Identity? {
identityID.flatMap { library.identity($0) }
}
private var identityLabel: String {
selectedIdentity.map { "\($0.name) · \($0.username)" }
?? String(localized: "Custom")
}
private var keyLabel: String {
keyID.flatMap { library.key($0) }?.label ?? String(localized: "None")
}
private func fullWidthMenu<Content: View>(
title: String,
@ViewBuilder content: @escaping () -> Content
) -> some View {
GeometryReader { geometry in
Menu(content: content) {
HStack(spacing: 8) {
Text(title)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Image(systemName: "chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 10)
.frame(width: geometry.size.width, height: 28)
.background(Color.primary.opacity(0.08), in: .rect(cornerRadius: 6))
.overlay {
RoundedRectangle(cornerRadius: 6)
.strokeBorder(Color.primary.opacity(0.055))
}
.contentShape(Rectangle())
}
#if os(macOS)
.menuStyle(.button)
.menuIndicator(.hidden)
#endif
.buttonStyle(.plain)
.frame(width: geometry.size.width, height: 28)
.accessibilityLabel(title)
}
.frame(maxWidth: .infinity, minHeight: 28, maxHeight: 28)
}
@ViewBuilder
private func selectionMenuLabel(_ title: String, selected: Bool) -> some View {
if selected {
Label(title, systemImage: "checkmark")
} else {
Text(title)
}
}
private var identitySelection: Binding<Identity.ID?> {
Binding {
identityID
} set: { id in
identityID = id
guard let identity = id.flatMap({ library.identity($0) }) else { return }
username = identity.username
password = ""
passphrase = ""
keyID = identity.keyID
}
}
private var usernameInput: Binding<String> {
Binding {
username
} set: { value in
username = value
if value != selectedIdentity?.username { identityID = nil }
}
}
private var passwordInput: Binding<String> {
Binding {
password
} set: { value in
password = value
if !value.isEmpty { identityID = nil }
}
}
private var keySelection: Binding<SSHKeyRef.ID?> {
Binding {
keyID
} set: { value in
keyID = value
if value != selectedIdentity?.keyID { identityID = nil }
passphrase = ""
}
}
}
public struct SharedSSHHostKeyConfirmationForm: View {
let prompt: SSHHostKeyPrompt
var displayAddress: String? = nil
let proceed: () -> Void
let trust: (() -> Void)?
let deny: () -> Void
var usesContainerChrome = true
let keyboard: (any ConnectionFormKeyboardAdapting)?
public init(
prompt: SSHHostKeyPrompt, displayAddress: String? = nil, proceed: @escaping () -> Void,
trust: (() -> Void)?, deny: @escaping () -> Void, usesContainerChrome: Bool = true,
keyboard: (any ConnectionFormKeyboardAdapting)? = nil
) {
self.prompt = prompt
self.displayAddress = displayAddress
self.proceed = proceed
self.trust = trust
self.deny = deny
self.usesContainerChrome = usesContainerChrome
self.keyboard = keyboard
}
private var observation: SSHHostKeyObservation { prompt.observation }
private var isAdditionalKey: Bool {
if case .additionalKey = prompt.kind { return true }
return false
}
public var body: some View {
form
.sharedConnectionFormChrome(isEnabled: usesContainerChrome)
.background {
if let keyboard { keyboard.bridge(canSubmit: true, submit: { trust?() ?? proceed() }, cancel: deny) }
}
}
private var form: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
VStack(alignment: .leading, spacing: 3) {
Text(
isAdditionalKey
? "Unrecognized additional host key" : "Verify host identity"
)
.font(.headline)
if let displayAddress {
Text(displayAddress)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
Spacer()
Image(
systemName: isAdditionalKey
? "exclamationmark.triangle.fill" : "checkmark.shield.fill"
)
.font(.title2)
.foregroundStyle(
isAdditionalKey ? Color.orange : Color.accentColor
)
}
Text(explanation)
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 4) {
Text(observation.algorithm.displayName).font(.caption).foregroundStyle(.secondary)
Text(observation.fingerprint).font(.body.monospaced()).textSelection(.enabled)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.background.opacity(0.65), in: .rect(cornerRadius: 8))
HStack {
Button("Deny", role: .destructive, action: deny).keyboardShortcut(.cancelAction)
Spacer()
if let trust {
Button("Continue", action: proceed)
.help("Continue once without saving this host key")
Button("Trust and Continue", action: trust)
.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
} else {
Button("Continue", action: proceed)
.buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
}
}
}
}
private var explanation: String {
switch prompt.kind {
case .firstUse:
String(
localized:
"No trusted key is saved for \(observation.hostname). Confirm this fingerprint before continuing."
)
case .additionalKey(let trustedKeyCount):
String(
localized:
"This key is not among the \(trustedKeyCount) trusted keys saved for \(observation.hostname). Verify it carefully before adding it."
)
}
}
}
@@ -0,0 +1,308 @@
import SwiftUI
import TermindApplication
/// The same route, checkpoints and progress animation on desktop and mobile.
public struct ConnectionJourneyRouteView: View {
let journey: ConnectionJourney
@Environment(\.accessibilityReduceMotion) private var reduceMotion
public init(journey: ConnectionJourney) { self.journey = journey }
public var body: some View {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView(.horizontal) {
route(viewportWidth: geometry.size.width)
.padding(.vertical, 30)
}
.scrollIndicators(.hidden)
.onChange(of: journey.activeEndpointIndex) { _, _ in
scrollToActivity(proxy)
}
.onChange(of: journey.checkpoint) { _, _ in
scrollToActivity(proxy)
}
}
}
.frame(height: 152)
}
private func route(viewportWidth: CGFloat) -> some View {
let nodeLaneHeight: CGFloat = 56
let endpointSpacing: CGFloat = 190
let labelWidth: CGFloat = 150
let gapBetweenEndpointFrames = endpointSpacing - labelWidth
let routeWidth = labelWidth + CGFloat(max(0, journey.endpoints.count - 1)) * endpointSpacing
let canvasWidth = max(viewportWidth, routeWidth + 58)
return ZStack(alignment: .topLeading) {
HStack(spacing: 0) {
ForEach(1..<journey.endpoints.count, id: \.self) { index in
JourneySegmentView(
state: segmentState(for: index),
checkpoint: journey.checkpoint?.endpointIndex == index
? journey.checkpoint?.kind : journey.completedCheckpoints[index],
checkpointIsWaiting: journey.checkpoint?.endpointIndex == index,
reduceMotion: reduceMotion
)
.frame(width: endpointSpacing, height: nodeLaneHeight)
.id("segment-\(index)")
}
}
.padding(.leading, labelWidth / 2)
HStack(alignment: .top, spacing: gapBetweenEndpointFrames) {
ForEach(Array(journey.endpoints.enumerated()), id: \.element.id) { index, endpoint in
JourneyEndpointView(
endpoint: endpoint,
purpose: journey.purpose,
state: endpointState(for: index),
showsActivity: activityEndpointIndex == index,
localRequirement: index == 0 ? journey.localRequirement : nil,
reduceMotion: reduceMotion
)
.frame(width: labelWidth, height: 92, alignment: .top)
.id("endpoint-\(index)")
}
}
}
.frame(width: routeWidth, height: 92, alignment: .topLeading)
.frame(width: canvasWidth, height: 92, alignment: .center)
}
private var activityEndpointIndex: Int? {
guard journey.phase == .connecting, journey.checkpoint == nil else { return nil }
if journey.activeEndpointIndex > journey.completedEndpointIndex {
return max(0, journey.activeEndpointIndex - 1)
}
return journey.completedEndpointIndex
}
private func endpointState(for index: Int) -> JourneyVisualState {
if case .failed = journey.phase, index <= journey.activeEndpointIndex { return .failed }
if index <= journey.completedEndpointIndex { return .complete }
return .pending
}
private func segmentState(for targetIndex: Int) -> JourneyVisualState {
if case .failed = journey.phase, targetIndex <= journey.activeEndpointIndex { return .failed }
if targetIndex <= journey.completedEndpointIndex { return .complete }
if targetIndex == journey.activeEndpointIndex { return .active }
return .pending
}
private func scrollToActivity(_ proxy: ScrollViewProxy) {
guard !reduceMotion else { return }
let index = journey.checkpoint?.endpointIndex ?? journey.activeEndpointIndex
withAnimation(.easeInOut(duration: 0.24)) {
proxy.scrollTo(
journey.checkpoint == nil ? "endpoint-\(index)" : "segment-\(index)",
anchor: .center
)
}
}
}
private enum JourneyVisualState {
case pending
case active
case complete
case failed
var color: Color {
switch self {
case .pending: .secondary.opacity(0.55)
case .active, .complete: Color.accentColor
case .failed: Color.red
}
}
}
private struct JourneyEndpointView: View {
let endpoint: ConnectionEndpoint
let purpose: ConnectionPurpose
let state: JourneyVisualState
let showsActivity: Bool
let localRequirement: ConnectionJourney.LocalRequirement?
let reduceMotion: Bool
var body: some View {
ZStack(alignment: .top) {
ZStack {
if showsActivity {
JourneyActivityRing(size: 52, reduceMotion: reduceMotion)
}
Circle()
.fill(.background)
.frame(width: 38, height: 38)
Circle()
.fill(state.color)
.frame(width: 36, height: 36)
.overlay {
Image(systemName: symbol)
.font(.system(size: 15, weight: .bold))
.foregroundStyle(Color.white)
}
}
.frame(width: 56, height: 56)
Text(endpoint.name)
.font(.caption.weight(.medium))
.lineLimit(1)
.frame(maxWidth: .infinity)
.padding(.top, 64)
}
.frame(height: 92, alignment: .top)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(endpoint.name), \(accessibilityState)")
}
private var symbol: String {
switch endpoint.role {
case .local:
switch localRequirement {
case .identity: "person.fill"
case .passphrase: "key.fill"
case nil:
#if os(macOS)
"laptopcomputer"
#else
"ipad.and.iphone"
#endif
}
case .jump: "server.rack"
case .destination:
switch purpose {
case .sshSession: "apple.terminal.fill"
case .sftpSession: "folder.fill"
case .metrics: "waveform.path.ecg"
case .serialSession: "cable.connector"
}
}
}
private var accessibilityState: String {
switch state {
case .pending: String(localized: "not connected")
case .active: String(localized: "connecting")
case .complete: String(localized: "connected")
case .failed: String(localized: "failed")
}
}
}
private struct JourneySegmentView: View {
let state: JourneyVisualState
let checkpoint: ConnectionJourney.CheckpointKind?
let checkpointIsWaiting: Bool
let reduceMotion: Bool
var body: some View {
GeometryReader { geometry in
ZStack {
Capsule().fill(Color.secondary.opacity(0.32)).frame(height: 4)
switch state {
case .complete, .failed:
Capsule().fill(state.color).frame(height: 4)
case .active:
if checkpointIsWaiting {
HStack(spacing: 0) {
Rectangle().fill(Color.accentColor)
.frame(width: geometry.size.width / 2, height: 4)
Color.clear
.frame(width: geometry.size.width / 2, height: 4)
}
} else {
JourneyTravelingLine(reduceMotion: reduceMotion)
}
case .pending:
EmptyView()
}
if let checkpoint {
ZStack {
if checkpointIsWaiting {
JourneyActivityRing(size: 42, reduceMotion: reduceMotion)
}
Circle().fill(Color.accentColor).frame(width: 28, height: 28)
Image(systemName: checkpoint == .authentication ? "key.fill" : "checkmark.shield.fill")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(Color.white)
}
.transition(.scale(scale: 0.65).combined(with: .opacity))
.accessibilityLabel(
checkpoint == .authentication
? Text("Authentication required") : Text("Host key confirmation required"))
}
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
.frame(height: 56)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.24), value: checkpoint)
}
}
private struct JourneyTravelingLine: View {
let reduceMotion: Bool
var body: some View {
if reduceMotion {
HStack(spacing: 0) {
Rectangle().fill(Color.accentColor).frame(width: 34, height: 4)
Spacer(minLength: 0)
}
} else {
TimelineView(.animation) { timeline in
GeometryReader { proxy in
let time = timeline.date.timeIntervalSinceReferenceDate
let progress = 0.16 + 0.64 * ((sin(time * 2.4) + 1) / 2)
Capsule()
.fill(
LinearGradient(
colors: [
Color.accentColor,
Color.accentColor.opacity(0.55),
.clear,
],
startPoint: .leading, endPoint: .trailing
)
)
.frame(width: max(18, proxy.size.width * progress), height: 4)
}
.frame(height: 4)
}
}
}
}
private struct JourneyActivityRing: View {
let size: CGFloat
let reduceMotion: Bool
var body: some View {
if reduceMotion {
Circle().stroke(Color.accentColor.opacity(0.45), lineWidth: 4)
.frame(width: size, height: size)
} else {
TimelineView(.animation) { timeline in
let rotation =
timeline.date.timeIntervalSinceReferenceDate
.truncatingRemainder(dividingBy: 1.25) / 1.25 * 360
Circle()
.trim(from: 0.06, to: 0.78)
.stroke(
AngularGradient(
colors: [
Color.accentColor.opacity(0.15),
Color.accentColor,
Color.accentColor.opacity(0.32),
],
center: .center
),
style: StrokeStyle(lineWidth: 4, lineCap: .round)
)
.rotationEffect(.degrees(rotation))
.frame(width: size, height: size)
}
}
}
}
@@ -12,11 +12,12 @@ public struct HostConnectionFields: View {
public var body: some View {
Section("Connection") {
LabeledContent("Name") {
TextField("Name", text: $host.name, prompt: Text(host.host)).labelsHidden()
TextField("Name", text: $host.name, prompt: Text(host.host)).labelsHidden().multilineTextAlignment(
.trailing)
}
LabeledContent("Host") {
TextField("Host", text: $host.host, prompt: Text("IP address or hostname"))
.labelsHidden()
.labelsHidden().multilineTextAlignment(.trailing)
#if os(iOS)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
@@ -25,7 +26,7 @@ public struct HostConnectionFields: View {
}
LabeledContent("Port") {
TextField("Port", value: $host.port, format: .number.grouping(.never))
.labelsHidden()
.labelsHidden().multilineTextAlignment(.trailing)
#if os(iOS)
.keyboardType(.numberPad)
#endif
@@ -34,7 +35,7 @@ public struct HostConnectionFields: View {
Section("SFTP") {
LabeledContent("Initial Path") {
TextField("Initial Path", text: $host.sftpSessionInitialPath, prompt: Text("~"))
.labelsHidden()
.labelsHidden().multilineTextAlignment(.trailing)
.font(.body.monospaced())
#if os(iOS)
.textInputAutocapitalization(.never)
@@ -0,0 +1,54 @@
import SwiftUI
import TermindApplication
import TermindDomain
/// A fixed label gutter followed by four equal-width, leading-aligned data columns.
public struct MetricsDiskTable: View {
public init(io: MetricsDevicePoint?) { self.io = io }
let io: MetricsDevicePoint?
public var body: some View {
GeometryReader { geometry in
let columnWidth = max(0, (geometry.size.width - 30 - 4 * 8) / 4)
HStack(alignment: .top, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Color.clear.frame(height: 12)
Text("Disk Read Row").frame(height: 14)
Text("Disk Write Row").frame(height: 14)
}
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary).frame(width: 30, alignment: .leading)
column(
"Speed", read: MetricsFormat.rate(io?.read), write: MetricsFormat.rate(io?.write),
width: columnWidth)
column(
"Bytes", read: MetricsFormat.bytes(io?.readTotal), write: MetricsFormat.bytes(io?.writeTotal),
width: columnWidth)
column("IOPS", read: number(io?.readIOPS), write: number(io?.writeIOPS), width: columnWidth)
column("Wait", read: milliseconds(io?.readWait), write: milliseconds(io?.writeWait), width: columnWidth)
}
}
.frame(height: 52)
.frame(maxWidth: .infinity)
}
private func column(_ title: LocalizedStringKey, read: String, write: String, width: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.system(size: 9)).foregroundStyle(.secondary)
.frame(width: width, height: 12, alignment: .leading)
Text(read).frame(width: width, height: 14, alignment: .leading)
Text(write).frame(width: width, height: 14, alignment: .leading)
}
.font(.system(size: 10, weight: .medium, design: .monospaced))
.lineLimit(1).minimumScaleFactor(0.65)
.frame(width: width, alignment: .leading)
}
private func number(_ value: Double?) -> String {
value?.formatted(.number.precision(.fractionLength(1))) ?? "—"
}
private func milliseconds(_ value: Double?) -> String {
value.map { number($0) + " ms" } ?? "—"
}
}
@@ -0,0 +1,20 @@
import Foundation
public nonisolated enum MetricsFormat {
public static func percent(_ value: Double?) -> String {
guard let value, value.isFinite else { return "—" }
return value.formatted(.number.precision(.fractionLength(1))) + "%"
}
public static func bytes(_ value: Double?) -> String {
guard var value, value.isFinite, value >= 0 else { return "—" }
let units = ["B", "KiB", "MiB", "GiB", "TiB"]
var index = 0
while value >= 1024, index < units.count - 1 {
value /= 1024
index += 1
}
return value.formatted(.number.precision(.fractionLength(index == 0 ? 0 : 1))) + " " + units[index]
}
public static func rate(_ value: Double?) -> String { value == nil ? "—" : bytes(value) + "/s" }
public static func capacity(_ used: Double?, _ total: Double?) -> String { bytes(used) + " / " + bytes(total) }
}
@@ -0,0 +1,123 @@
import SwiftUI
import TermindApplication
import TermindDomain
public struct MetricsGPUSection: View {
public init(server: ServerMetric, initiallyExpanded: Bool = false) {
self.server = server
self.initiallyExpanded = initiallyExpanded
}
let server: ServerMetric
var initiallyExpanded = false
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
if server.gpuState == .failed, server.gpuSnapshot?.devices.isEmpty == false {
Text("GPU metrics unavailable; showing the last sample.")
.font(.caption).foregroundStyle(Color.orange)
}
ForEach(server.gpuSnapshot?.devices ?? []) { gpu in
MetricsGPUCard(
gpu: gpu, cudaVersion: server.gpuSnapshot?.cudaVersion, initiallyExpanded: initiallyExpanded)
}
}
}
}
private struct MetricsGPUCard: View {
let gpu: NvidiaGPU
let cudaVersion: String?
@State private var informationExpanded: Bool
init(gpu: NvidiaGPU, cudaVersion: String?, initiallyExpanded: Bool = false) {
self.gpu = gpu
self.cudaVersion = cudaVersion
_informationExpanded = State(initialValue: initiallyExpanded)
}
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
Label(gpu.name, systemImage: "cpu")
.font(.caption.bold()).lineLimit(1).help(gpu.name)
HStack(spacing: 14) {
MetricsRing(title: "GPU", value: gpu.utilization)
.help(Text("GPU Utilization"))
MetricsRing(title: "VRAM", value: gpu.memoryPercent)
.help(Text("VRAM Occupancy"))
VStack(alignment: .trailing, spacing: 6) {
memoryRow("Used", value: gpu.memoryUsed)
memoryRow("VRAM Total", value: gpu.memoryTotal)
memoryRow("Free Memory", value: gpu.memoryFree)
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
GeometryReader { geometry in
let width = max(0, (geometry.size.width - 3 * 12) / 4)
HStack(alignment: .top, spacing: 12) {
meter("GPU Memory Activity", value: gpu.memoryUtilization, color: .purple)
.frame(width: width).help(Text("Memory Utilization"))
meter("GPU Encoder", value: gpu.encoder, color: Color.green)
.frame(width: width).help(Text("Encoder Utilization"))
meter("GPU Decoder", value: gpu.decoder, color: Color.green)
.frame(width: width).help(Text("Decoder Utilization"))
meter("GPU Fan", value: gpu.fan, color: Color.orange)
.frame(width: width).help(Text("Fan Speed"))
}
}
.frame(height: 36)
DisclosureGroup("Device Information", isExpanded: $informationExpanded) {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 3) {
Text("UUID").foregroundStyle(.secondary)
Text(gpu.id).font(.system(size: 9, design: .monospaced))
.fixedSize(horizontal: false, vertical: true)
}
HStack {
Text("VBIOS").foregroundStyle(.secondary)
Spacer()
Text(gpu.vbios ?? "—").monospacedDigit()
}
HStack {
Text("Driver CUDA Version").foregroundStyle(.secondary)
Spacer()
Text(cudaVersion ?? "—").monospacedDigit()
}
}
.font(.caption2).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading).padding(.top, 8)
}
.font(.caption).foregroundStyle(.secondary)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.background, in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12).strokeBorder(.quaternary, lineWidth: 1)
}
}
private func memoryRow(_ title: LocalizedStringKey, value: Double?) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(title).font(.system(size: 9)).foregroundStyle(.secondary)
Text(MetricsFormat.bytes(value)).font(.system(size: 11, weight: .semibold, design: .monospaced))
.frame(minWidth: 72, alignment: .trailing)
}
.lineLimit(1).minimumScaleFactor(0.7)
}
private func meter(_ title: LocalizedStringKey, value: Double?, color: Color) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(1)
Text(MetricsFormat.percent(value)).font(.system(size: 11, weight: .semibold, design: .monospaced))
.lineLimit(1).minimumScaleFactor(0.7)
GeometryReader { geometry in
Capsule().fill(.quaternary)
.overlay(alignment: .leading) {
if let value {
Capsule().fill(color).frame(width: geometry.size.width * min(1, max(0, value / 100)))
}
}
}.frame(height: 3).padding(.top, 3).accessibilityHidden(true)
}
}
}
@@ -0,0 +1,80 @@
import SwiftUI
import TermindApplication
import TermindDomain
/// Shared horizontal instruments and stable-height connection state, without a card surface.
public struct MetricsInstrumentBody: View {
public init(server: ServerMetric, sleeping: Bool) {
self.server = server
self.sleeping = sleeping
}
let server: ServerMetric
let sleeping: Bool
private var showsMetrics: Bool {
!server.isConnecting && server.point != nil && !server.needsAttention
&& server.selection.enabled && !sleeping
&& (server.status.isEmpty || server.status == String(localized: "Live"))
}
public var body: some View {
ZStack {
if showsMetrics {
instruments
} else {
VStack(spacing: 7) {
if server.isConnecting
|| (server.point == nil && !server.needsAttention
&& server.selection.enabled && !sleeping)
{
ProgressView().controlSize(.small)
} else {
Image(systemName: server.needsAttention ? "exclamationmark.triangle" : "pause.circle")
.foregroundStyle(.secondary)
}
Text(connectionLabel)
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center).lineLimit(2)
}
.frame(maxWidth: .infinity)
.help(connectionLabel)
}
}
.frame(maxWidth: .infinity)
.frame(height: 64)
.contentShape(Rectangle())
}
private var connectionLabel: String {
if server.requiresAuthentication { return String(localized: "Server Identity Verification Required") }
return server.status
}
private var instruments: some View {
HStack(alignment: .center, spacing: 12) {
MetricsRing(title: "CPU", value: server.point?.cpu)
MetricsRing(title: "Memory", value: server.point?.memory)
.help(MetricsFormat.capacity(server.point?.memoryUsed, server.point?.memoryTotal))
VStack(alignment: .leading, spacing: 6) {
rate("Network Send", symbol: "arrow.up", value: server.point?.networkSend)
rate("Network Receive", symbol: "arrow.down", value: server.point?.networkReceive)
}.frame(maxWidth: .infinity, alignment: .leading)
VStack(alignment: .leading, spacing: 6) {
rate("Disk Read", symbol: "arrow.up.doc", value: server.point?.diskRead)
rate("Disk Write", symbol: "arrow.down.doc", value: server.point?.diskWrite)
}.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private func rate(_ title: LocalizedStringKey, symbol: String, value: Double?) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(MetricsFormat.rate(value)).font(.system(size: 12, weight: .semibold, design: .monospaced))
.foregroundStyle((value ?? 0) > 0 ? Color.green : Color.secondary)
.lineLimit(1).minimumScaleFactor(0.7)
Label(title, systemImage: symbol).font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(1)
}
}
}
@@ -0,0 +1,33 @@
import SwiftUI
public struct MetricsRing: View {
public init(title: LocalizedStringKey, value: Double?) {
self.title = title
self.value = value
}
let title: LocalizedStringKey
let value: Double?
private var color: Color {
guard let value else { return Color.secondary }
return value >= 90 ? Color.red : value >= 75 ? Color.orange : Color.green
}
public var body: some View {
VStack(spacing: 5) {
ZStack {
Circle().stroke(.quaternary, lineWidth: 4)
if let value {
Circle().trim(from: 0, to: min(1, max(0, value / 100)))
.stroke(color, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.rotationEffect(.degrees(-90))
}
Text(value.map { $0.formatted(.number.precision(.fractionLength(0))) + "%" } ?? "—")
.font(.system(size: 10, weight: .medium, design: .monospaced))
}.frame(width: 40, height: 40)
Text(title).font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.7)
}
.frame(width: 46)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(title))
.accessibilityValue(MetricsFormat.percent(value))
}
}
@@ -1,22 +0,0 @@
import SwiftUI
import TermindDomain
public struct MetricsSummaryView: View {
private let point: MetricsPoint
public init(point: MetricsPoint) { self.point = point }
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
if let cpu = point.cpu {
LabeledContent("CPU", value: cpu.formatted(.number.precision(.fractionLength(1))) + "%")
ProgressView(value: min(1, max(0, cpu / 100)))
}
if let memory = point.memory {
LabeledContent("Memory", value: memory.formatted(.number.precision(.fractionLength(1))) + "%")
ProgressView(value: min(1, max(0, memory / 100)))
}
LabeledContent("Updated", value: point.date.formatted(date: .omitted, time: .standard))
.font(.caption).foregroundStyle(.secondary)
}
}
}
@@ -0,0 +1,237 @@
import SwiftUI
import TermindApplication
import TermindDomain
/// Independently scrollable system panels; the process table owns its own viewport.
public struct MetricsSystemPanels: View {
public init(server: ServerMetric) { self.server = server }
let server: ServerMetric
private var point: MetricsPoint? { server.point }
@State private var virtualInterfacesExpanded = false
public var body: some View {
VStack(spacing: 12) {
panel {
HStack(alignment: .top, spacing: 10) {
Text(MetricsFormat.percent(point?.cpu))
.font(.system(size: 30, weight: .semibold, design: .rounded)).minimumScaleFactor(0.7)
Spacer(minLength: 0)
VStack(alignment: .leading, spacing: 6) {
HStack {
legend("System", value: point?.cpuSystem, color: Color.red)
legend("User", value: point?.cpuUser, color: Color.green)
}
HStack {
legend("I/O Wait", value: point?.cpuWait, color: .purple)
legend("Steal", value: point?.cpuSteal, color: Color.orange)
.help("Steal: time a virtual CPU is ready to run but waiting for the hypervisor.")
}
}
}
HStack(spacing: 2) {
ForEach(0..<48, id: \.self) { index in
RoundedRectangle(cornerRadius: 1).fill(cpuColor(at: Double(index) / 48 * 100))
}
}.frame(height: 12).accessibilityLabel(Text("CPU time breakdown"))
HStack {
figure("Cores", value: point?.coreCount.map(String.init) ?? "—")
figure("Idle", value: MetricsFormat.percent(point?.cpuIdle))
figure("Uptime", value: uptime)
}
LabeledContent(
"Load Average",
value: point?.load.map { $0.formatted(.number.precision(.fractionLength(2))) }.joined(
separator: " / ") ?? "—"
)
.font(.caption2).foregroundStyle(.secondary)
}
panel {
HStack {
figure("Free Memory", value: MetricsFormat.bytes(point?.memoryFree))
figure("Used", value: MetricsFormat.bytes(point?.memoryUsed))
figure("Page Cache", value: MetricsFormat.bytes(point?.memoryCached))
MetricsRing(title: "Memory", value: point?.memory)
}
}
if server.gpuSnapshot?.devices.isEmpty == false {
MetricsGPUSection(server: server)
}
networkPanel
if server.filesystemsFailed {
Text("Filesystem metrics unavailable").font(.caption).foregroundStyle(.secondary)
}
ForEach(server.filesystems) { filesystem in
filesystemPanel(filesystem)
}
}
}
private var hardwareInterfaces: [MetricsDevicePoint] {
(point?.networkDevices ?? []).filter { $0.kind != .virtual }
}
private var virtualInterfaces: [MetricsDevicePoint] { (point?.networkDevices ?? []).filter { $0.kind == .virtual } }
private var networkPanel: some View {
panel {
HStack(spacing: 12) {
VStack(spacing: 8) {
figure("Send Speed", value: MetricsFormat.rate(point?.networkSend))
figure("Receive Speed", value: MetricsFormat.rate(point?.networkReceive))
}
VStack(spacing: 8) {
figure(
"Total Sent", value: MetricsFormat.bytes(point?.networkSentTotal), color: Color.orange)
figure(
"Total Received", value: MetricsFormat.bytes(point?.networkReceivedTotal),
color: Color.green)
}
trafficRing
}
HStack(spacing: 6) {
figure("Retransmissions", value: MetricsFormat.percent(point?.tcpRetransmissionPercent))
figure("Active Opens", value: point?.tcp?.active?.formatted() ?? "—")
figure("Passive Opens", value: point?.tcp?.passive?.formatted() ?? "—")
figure("Failed Attempts", value: point?.tcp?.failures?.formatted() ?? "—")
}
ForEach(hardwareInterfaces) { interface in
Divider()
interfaceRow(interface)
}
if !virtualInterfaces.isEmpty {
DisclosureGroup(isExpanded: $virtualInterfacesExpanded) {
VStack(spacing: 12) {
ForEach(virtualInterfaces) { interface in
Divider()
interfaceRow(interface)
}
}
} label: {
HStack {
Text("Virtual Interfaces")
Spacer()
Text(virtualInterfaces.count.formatted()).monospacedDigit()
}.font(.caption).foregroundStyle(.secondary)
}
}
}
}
private func interfaceRow(_ device: MetricsDevicePoint) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label(device.id, systemImage: "network").font(.caption.bold())
Spacer()
Text(device.addresses.joined(separator: ", ")).font(.system(size: 9)).foregroundStyle(.secondary)
.lineLimit(1).help(device.addresses.joined(separator: "\n"))
}
HStack(spacing: 6) {
figure("Send Speed", value: MetricsFormat.rate(device.write))
figure("Receive Speed", value: MetricsFormat.rate(device.read))
figure("Total Sent", value: MetricsFormat.bytes(device.writeTotal))
figure("Total Received", value: MetricsFormat.bytes(device.readTotal))
}
}
}
private var trafficRing: some View {
let sent = point?.networkSentTotal ?? 0
let received = point?.networkReceivedTotal ?? 0
let fraction = sent + received > 0 ? sent / (sent + received) : 0
return ZStack {
Circle().stroke(.quaternary, lineWidth: 4)
if sent + received > 0 {
Circle().trim(from: 0, to: fraction).stroke(Color.orange, lineWidth: 4)
Circle().trim(from: fraction, to: 1).stroke(Color.green, lineWidth: 4)
}
}
.rotationEffect(.degrees(-90)).frame(width: 34, height: 34)
.accessibilityLabel(Text("Sent / Received Traffic"))
.accessibilityValue(MetricsFormat.capacity(point?.networkSentTotal, point?.networkReceivedTotal))
}
private func filesystemPanel(_ filesystem: MetricsFilesystem) -> some View {
let io = point?.blockDevices[filesystem.blockID]
return panel {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 4) {
Text(filesystem.mountPoint).font(.headline).lineLimit(1).help(filesystem.mountPoint)
Text(filesystem.device).font(.caption).foregroundStyle(.secondary).lineLimit(1).help(
filesystem.device)
}
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 4) {
Text(filesystem.type.uppercased()).font(.system(size: 9)).foregroundStyle(.secondary)
Text(MetricsFormat.capacity(filesystem.used, filesystem.total))
.font(.system(size: 10, weight: .medium, design: .monospaced)).lineLimit(1).minimumScaleFactor(
0.7)
}
capacityBar(filesystem.usedPercent)
}
MetricsDiskTable(io: io)
}
}
private func capacityBar(_ percent: Double?) -> some View {
let color =
(percent ?? 0) >= 90
? Color.red : (percent ?? 0) >= 75 ? Color.orange : Color.green
return RoundedRectangle(cornerRadius: 3).fill(.quaternary)
.overlay(alignment: .bottom) {
if let percent {
RoundedRectangle(cornerRadius: 3).fill(color).frame(height: 36 * min(1, max(0, percent / 100)))
}
}
.frame(width: 8, height: 36)
.accessibilityLabel(Text("Disk Usage"))
.accessibilityValue(MetricsFormat.percent(percent))
}
private func panel<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 12, content: content)
.padding(12).frame(maxWidth: .infinity, alignment: .leading)
.background(.background, in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12).strokeBorder(.quaternary, lineWidth: 1)
}
}
private func figure(_ title: LocalizedStringKey, value: String, color: Color = Color.primary)
-> some View
{
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.75)
Text(value).font(.system(size: 11, weight: .semibold, design: .monospaced))
.foregroundStyle(color).lineLimit(1).minimumScaleFactor(0.65)
}.frame(maxWidth: .infinity, alignment: .leading)
}
private func legend(_ title: LocalizedStringKey, value: Double?, color: Color) -> some View {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 3) {
Rectangle().fill(color).frame(width: 3, height: 6)
Text(title).lineLimit(1)
}
.font(.system(size: 8)).foregroundStyle(.secondary)
Text(MetricsFormat.percent(value)).font(.system(size: 10, weight: .semibold, design: .monospaced))
}.frame(maxWidth: .infinity, alignment: .leading)
}
private func cpuColor(at position: Double) -> Color {
guard let point, point.cpu != nil else { return Color.secondary.opacity(0.2) }
var boundary = point.cpuSystem ?? 0
if position < boundary { return Color.red }
boundary += point.cpuUser ?? 0
if position < boundary { return Color.green }
boundary += point.cpuWait ?? 0
if position < boundary { return .purple }
boundary += point.cpuSteal ?? 0
if position < boundary { return Color.orange }
return Color.secondary.opacity(0.2)
}
private var uptime: String {
guard let seconds = point?.uptime, seconds >= 0, seconds < Double(Int64.max) else { return "—" }
return Duration.seconds(seconds).formatted(
.units(allowed: [.days, .hours, .minutes], width: .abbreviated, maximumUnitCount: 2))
}
}
@@ -0,0 +1,35 @@
import SwiftUI
import TermindDomain
/// The same endpoint derivation is presented in desktop pages and mobile drafts.
public struct ModelProviderConnectionFields: View {
@Binding private var name: String
@Binding private var endpoint: ProviderEndpoint
public init(name: Binding<String>, endpoint: Binding<ProviderEndpoint>) {
_name = name
_endpoint = endpoint
}
public var body: some View {
Section {
LabeledContent("Name") { TextField("Name", text: $name).multilineTextAlignment(.trailing) }
Picker("API Type", selection: $endpoint.apiType) {
ForEach(ModelAPIType.allCases) { Text($0.displayName).tag($0) }
}
}
Section("Endpoint") {
LabeledContent("URL") {
TextField("URL", text: $endpoint.url).autocorrectionDisabled().multilineTextAlignment(.trailing)
}
if endpoint.isConfigured {
LabeledContent("Full URL") {
Text(endpoint.requestURL).font(.caption.monospaced()).textSelection(.enabled)
.multilineTextAlignment(.trailing).fixedSize(horizontal: false, vertical: true)
}
Text("Requests use the \(endpoint.format.displayName) format.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
@@ -7,12 +7,15 @@ public struct PortForwardingFields: View {
public var body: some View {
Section("Port Forwarding") {
TextField("Name", text: $rule.label)
TextField("Name", text: $rule.label).textFieldStyle(.automatic).multilineTextAlignment(.leading)
PortForwardingDirectionPicker(kind: $rule.kind)
Text(directionDescription).font(.caption).foregroundStyle(.secondary)
LabeledContent("Bind Address") { TextField("Bind Address", text: $rule.bindHost).labelsHidden() }
LabeledContent("Bind Address") {
TextField("Bind Address", text: $rule.bindHost).labelsHidden().multilineTextAlignment(.trailing)
}
LabeledContent("Bind Port") {
TextField("Bind Port", value: $rule.bindPort, format: .number.grouping(.never)).labelsHidden()
.multilineTextAlignment(.trailing)
#if os(iOS)
.keyboardType(.numberPad)
#endif
@@ -22,13 +25,13 @@ public struct PortForwardingFields: View {
TextField(
"Target Host",
text: Binding(get: { rule.targetHost ?? "localhost" }, set: { rule.targetHost = $0 })
).labelsHidden()
).labelsHidden().multilineTextAlignment(.trailing)
}
LabeledContent("Target Port") {
TextField(
"Target Port", value: Binding(get: { rule.targetPort ?? 80 }, set: { rule.targetPort = $0 }),
format: .number.grouping(.never)
).labelsHidden()
).labelsHidden().multilineTextAlignment(.trailing)
#if os(iOS)
.keyboardType(.numberPad)
#endif
@@ -59,7 +62,7 @@ public struct PortForwardingDirectionPicker: View {
Text("Remote").tag(PortForwardingRule.Kind.remote).accessibilityLabel("Remote Forward")
Text("Dynamic").tag(PortForwardingRule.Kind.dynamic).accessibilityLabel("Dynamic SOCKS5 Proxy")
}
.labelsHidden()
.labelsHidden().multilineTextAlignment(.trailing)
.pickerStyle(.segmented)
.frame(maxWidth: .infinity)
}
@@ -0,0 +1,65 @@
import Foundation
public nonisolated struct SoftwareKeyFileImportResult: Equatable, Sendable {
public var privateKey: String?
public var certificate: String?
public var inferredName: String?
public init(privateKey: String? = nil, certificate: String? = nil, inferredName: String? = nil) {
self.privateKey = privateKey
self.certificate = certificate
self.inferredName = inferredName
}
}
public nonisolated enum SoftwareKeyFileImporter {
public static func shouldInferName(from currentName: String) -> Bool {
currentName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
public static func read(
urls: [URL], infersName: Bool
) throws -> SoftwareKeyFileImportResult {
guard !urls.isEmpty else { return SoftwareKeyFileImportResult() }
var result = SoftwareKeyFileImportResult()
for url in urls {
let text = try readText(at: url)
if text.contains("PRIVATE KEY") {
result.privateKey = text
if infersName { result.inferredName = url.lastPathComponent }
} else if text.contains("-cert-v01@openssh.com") {
result.certificate = text.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
throw CocoaError(
.fileReadCorruptFile,
userInfo: [
NSLocalizedDescriptionKey: String(
localized: "Choose a private key or OpenSSH user certificate file."
)
]
)
}
}
return result
}
public static func unexpectedFileError() -> CocoaError {
CocoaError(
.fileReadCorruptFile,
userInfo: [
NSLocalizedDescriptionKey: String(
localized: "Choose a private key or OpenSSH user certificate file."
)
]
)
}
private static func readText(at url: URL) throws -> String {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1024 * 1024 else {
throw CocoaError(.fileReadTooLarge)
}
return try String(contentsOf: url, encoding: .utf8)
}
}
+49 -4
View File
@@ -11,7 +11,7 @@ remains a versioned remote SPM dependency developed in the sibling repository.
- `TermindApplication` owns repository ports, Vault/AI feature models, secret interfaces,
SSH configuration/preparation, SSH import preview, the outbox runner, and reconnect policy; it depends on Domain and AgentKit.
- `TermindInfrastructure` owns the memory repository, metrics parsers and StoreKit access.
It depends on Application and Domain.
It depends on Application, Domain and Persistence. CloudKit Vault/Settings coordinators live here and share one implementation across both apps.
- `TermindRemoteInfrastructure` owns SSH/SFTP, key material, Keychain and remote sampling.
It also owns scoped SSH import reads, forwarding and transfer execution.
Its C libraries are linked only through this target. Keeping it outside the portable
@@ -44,7 +44,9 @@ Each app process owns one container shared by its scenes. The desktop keeps the
`Termind` store configuration and its existing local location; mobile uses an
Application Support store with the same schema and an in-memory container for tests. Store failures
never silently recreate a database. Keychain access is shared and explicitly configured
by each app. Mobile CloudKit coordinators are still pending; model sharing does not enable sync.
by each app. Both apps wire the shared CloudKit coordinators into the persisted journals;
TFI/TFI.entitlements and TFM/TFM.entitlements use the same CloudKit container and
bundle-derived Keychain access group. Signing environments remain target-specific.
Shared resources are copied into each app bundle. Localized framework strings
resolve against the host bundle; platform icons and metadata remain app-owned.
@@ -523,6 +525,12 @@ Pro access gates both CloudKit coordinators, whose zones, subscriptions, account
bindings and journals are independent. Settings use `TermindAIConfigurationV1`; the
existing `TermindVaultV1` protocol is unchanged.
Shared `TerminalSettingsModel` and `CommandHistoryModel` live in Application, with
repository protocols there and their concrete SwiftData implementations in Persistence.
Theme palettes and custom-configuration parsing are pure Domain values; AppKit and UIKit
apply them through their native Ghostty adapters. Provider forms share Presentation content
and one atomic provider/endpoint mutation. Saving a provider never selects a conversation model.
`SettingsSyncPersistence` commits configuration and upload intent together in
SwiftData V14. V13 already exists in installed development stores: its
`PersistentAIConfigurationSync*` names and defaults stay frozen. V14 adds only
@@ -554,8 +562,7 @@ adapter is the only additional trusted source of skill-library snapshots.
## Mobile presentation and workspace activities
The iPhone has four top-level destinations in order: Welcome (including Vault and
Workspaces), Metrics, Assistant, and Settings. iPad retains a sidebar. Each mobile Workspace
The iPhone has four top-level destinations in order: Vault, Workspaces, Assistant, and Settings. Metrics has its own row/section inside Vault, separate from Keychain, Snippets and Known Hosts. iPad retains a sidebar ordered Vault, Metrics, Workspaces, Assistant, Settings; neither mobile layout has Welcome. Each mobile Workspace
owns one SSH or SFTP session; composition is never enabled by a purchase. Mobile
Assistant is independent and has no workspace sidebar or session-attached sheet.
@@ -590,3 +597,41 @@ Mobile host deletion records the existing deletion saga and starts the shared ou
runner. Restored pending deletions are hidden before workspace restoration. The runner's
start/stop generation prevents a cancelled background loop from clearing a replacement
foreground loop. New port forwarding rules never start merely because they were saved.
Mobile Settings exposes full terminal themes, font controls, custom text, shell integration,
Vault/Settings sync, providers, model discovery and role assignments. The macOS window-title
preference remains in synchronized records but has no mobile control. Search belongs to each
workspace and uses Ghostty's search actions; history insertion uses the paste path without
appending Enter. Only authenticated integration events produce history rows; interrupted
commands retain unknown completion. App-wide history is bounded to 1,000 rows per host.
Mobile sync runs while a scene is active and Pro access permits it. Backgrounding pauses
both engines, retaining upload journals; foregrounding fetches again. Entitlement changes
reconcile the same gate. Remote host deletion closes its workspaces, cancels transfers and
forwardings, and clears local command history. Modified connections are disconnected rather
than automatically replayed. Mobile AI/Terminal edits preserve all other Settings record kinds,
including Skills and MCP configurations without mobile editors. Active chat requests retain
the provider/model/credential captured at start, and no Agent tools are registered.
Mobile navigation and Metrics reuse (2026-09-11):
- Transfers belongs to Workspaces. Host rows connect to SSH on a single activation and expose SFTP/edit/delete through a long-press context menu. SSH folder import is desktop-only; mobile retains private-key and certificate file import.
- Port forwarding rules belong to the host draft and commit with it. Only unchanged, saved rules can start; runtime start/stop and bulk controls are available inside that host editor.
- Keychain lists Keys before Identities. Identity notes remain preserved data without an editor field. Generation defaults match macOS: iCloud, Ed25519, ECDSA P-256, RSA 3072, AES-256 and 100 rounds; encrypted generation requires a matching confirmation. The shared bounded/scoped key-file reader accepts explicit private-key and certificate files.
- ServerMetricsModel, ServerMetric, MetricsClock and ConnectionJourney live in Application. Their samplers/discovery functions remain injected. Mobile delegates its collector to this shared model, including authentication, temporary trust, process/filesystem sampling and detail observer lifetimes.
- Metrics instruments/system panels, Journey route/checkpoint animations, and SSH decision forms live in Presentation. AppKit's keyboard monitor stays in TFM and is injected through ConnectionFormKeyboardAdapting; Presentation has no native UI imports.
- Metrics details show status and processes together at content widths of at least 700 points; narrower widths show a two-icon segmented switch. Width, rather than orientation or device model, determines the layout.
- Inline labeled mobile inputs align trailing. Standalone Port Forwarding Name and multiline editors align leading.
Mobile Settings mirrors the desktop category hierarchy with App, Terminal, AI, then Sync.
App owns appearance, language selection (effective on relaunch), runtime versions and support
links. AppLanguage and AppLocalization are shared Foundation-only Application values; native
relaunch remains macOS-owned. There is no Live Activities settings section. Opening a workspace
starts the activity, and the first foreground activation restores it for retained workspaces;
later foreground updates do not resurrect an activity dismissed by the user. OS authorization
still controls availability.
The mobile navigation shell uses device idiom: iPhone keeps its four tabs even in landscape,
while iPad uses NavigationSplitView's own width adaptation. Metrics' two-column breakpoint uses
the actual detail content width. An SSH terminal surface remains attached underneath the Journey,
so early output and viewport changes are retained while credentials or trust are presented.
+82 -9
View File
@@ -4,8 +4,7 @@
- Shared Vault and AI configuration models and persistence, SSH/SFTP transports,
Keychain adapters, StoreKit access, metrics sampling, and a SwiftUI presentation target.
- Four iPhone navigation destinations: Welcome/Vault/Workspaces, Metrics, Assistant, Settings.
iPad retains the sidebar. No mobile workspace composition or workspace-attached Assistant.
- Four iPhone navigation destinations: Vault, Workspaces, Assistant, Settings. Metrics is a separate Vault entry; iPad retains Vault, Metrics, Workspaces, Assistant, Settings in its sidebar. No mobile workspace composition or workspace-attached Assistant.
- Mobile host, identity, group, snippet and provider forms use sheets; desktop host
connection fields reuse the same SwiftUI content.
- Independent SSH/SFTP workspace descriptors, reconnect entry points, terminal rendering,
@@ -23,9 +22,7 @@
- Key generation (including supported Secure Enclave devices), encrypted key import,
metadata/certificate editing, and key/identity deletion. File parsing and generation
run off MainActor, and secrets stay in the shared secret store.
- SSH folder/known_hosts import uses the shared reader and preview model. Missing usernames
require an explicit edit on iOS; no local process username is substituted. References
outside granted folders still require explicit file selection.
- SSH folder/known_hosts bulk import remains desktop-only. The mobile import screen and entry were removed at user request; individual private-key and certificate file imports remain supported.
- Port forwarding management uses the shared runtime and transactional rule updates.
- Host deletion uses the shared outbox runner, including interrupted-job recovery on startup.
- iOS Keychain sharing entitlement and localized Face ID usage description are configured.
@@ -35,11 +32,9 @@
The full mobile port is not complete. Do not describe the current implementation as feature parity.
- Port complete shared Assistant tool registration, approval/user-input/secret presentation,
SSH/SFTP agent tools, model discovery/roles, Skills, MCP settings and speech input.
SSH/SFTP agent tools, Skills, MCP/Web Search settings and speech input.
The current mobile runtime intentionally registers no tools.
- Wire mobile CloudKit Vault/Settings coordinators and CloudKit signing capabilities;
current mobile data is local, despite using the shared schema and journals.
- Complete terminal preferences, history/search UI and broader desktop SwiftUI view reuse.
- Continue broader desktop SwiftUI view reuse and complete Assistant settings/tool execution beyond provider/model configuration.
- Test live SSH/SFTP, ActivityKit rendering and taps, external keyboards, background/lock
behavior, CloudKit and StoreKit on signed physical devices. Widget icon routing and
bounded payload behavior have automated coverage; real-device behavior is unverified.
@@ -69,3 +64,81 @@ The full mobile port is not complete. Do not describe the current implementation
- The continuation's full macOS run again executed 1,122 tests and reported 14 issues
in the previously failing deadline/approval, Keychain (-25308), and native popover suites.
The full suite is still not green; the focused feature suites above passed.
## Terminal, sync and AI configuration continuation — 2026-09-11
Implemented shared CloudKit coordinators, Settings transaction/journal, terminal preferences,
command history and provider connection fields. Mobile uses the synced configuration store,
preserving Skills/MCP records and unresolved references. Defaults match desktop: Pro-gated
master/Vault enabled, Settings opt-in. Both categories pause in background and resume with
foreground/entitlement reconciliation. APNs registration and CloudKit signing are configured;
signing files are now `TFI/TFI.entitlements` and `TFM/TFM.entitlements`.
Mobile Terminal has light/dark theme catalogs, font controls, custom Ghostty text, integration,
search and per-host history. The remote-title preference is deliberately absent from its UI.
Search queries/surfaces survive navigation; historical commands paste without an added newline.
Provider forms support empty model lists, endpoint derivation and explicit keep/replace/clear
key intent; failed secret/configuration writes keep drafts and attempt credential rollback.
Model discovery is cancellable, adds selected candidates only, and ignores stale responses.
Roles and chat pickers use shared fallback rules. Runtime tools remain intentionally absent.
Verification in this continuation:
- iOS simulator builds and 25 mobile plus 26 shared tests passed, including 16 new regression tests.
- Full macOS run executed 1,123 tests and reported 9 issues in existing Agent approval,
user-input deadline and cancellation tests; all 26 shared tests passed separately.
- Focused rerun still reproduced two existing AgentApprovalQueueTests failures. A catalog
check also caught newly auto-extracted, previously untranslated mobile strings; these
translations have now been supplied; 68 focused macOS feature/language tests passed afterward.
- iPhone Terminal preferences and iPad English/Chinese, light/dark settings and provider forms inspected. iPad rotation preserved drafts and cancel required discard confirmation.
- Connected-device inventory reports the paired iPhone and iPad unavailable. Real SSH,
external-keyboard hardware, background/lock and signed Mac/iOS CloudKit delivery remain
unverified. Production schema deployment was not attempted. AgentKit and Ghostty pins unchanged.
### Follow-up navigation and desktop parity
Vault replaces Welcome on both mobile layouts. iPhone's Metrics tab is replaced by Workspaces;
Metrics gets its own Vault section above the other three library links. Transfers moves into
Workspaces. iPad places Metrics immediately below Vault in its sidebar. Host rows connect SSH
on tap and use long-press menus. macOS Navigator now labels its workspace group “Workspaces”.
Metrics reuses desktop instruments, system panels and the complete shared monitor/Journey
state, with shared credential and host-key forms. At >=700 points, details display status and
processes side by side; narrow layouts use a two-icon switch. Keychain order/defaults and
certificate import align with macOS. Identity notes have no mobile editor. Port forwarding
is staged inside the host editor, with explicit runtime actions only for saved rules. Mobile
SSH folder import was removed, while individual key/certificate import remains.
Settings now has App as its own page, and Sync below AI. The redundant Live Activities
settings section was removed; activity startup/restoration is automatic for workspaces and
continues to respect OS authorization. Shared app-language settings take effect on relaunch.
Phone tabs remain available in landscape; iPad uses a collapsible sidebar.
Follow-up verification:
- iOS: 29 mobile tests and 28 shared tests passed, including certificate-file validation,
default key generation options, navigation/breakpoint rules and shared Metrics credential/
temporary-trust continuation without persisting secrets or trust.
- macOS: 127 targeted tests across 14 suites passed, covering architecture, language, sync,
terminal history/themes, Journey, Metrics lifecycle/presentation/interaction, snapshots,
key-file import and SSH certificates.
- A full 1,124-test macOS run reported existing Agent timing/native-popover issues plus a
brittle new source-format assertion; that assertion and the shared fake-process fixture
were corrected, and the relevant targeted suites then passed. Final full-run status follows.
Final macOS regression (2026-09-11): all 1,124 app tests were executed. Six issues remain
in the preexisting AgentApprovalQueueTests/AgentUserInputTests timing cases; architecture,
Metrics interaction/presentation, connection forms, localization and the other suites passed.
All 28 shared tests passed. These results do not establish a green full macOS suite.
Logs: `/tmp/termind-mac-port-final.log`, `/tmp/termind-mac-nav-focused.log`.
Final visual review on iPhone confirmed the separate Metrics section in Vault, four bottom
tabs, absence of bulk SSH import, forwarding nested in the host editor, leading-aligned
forwarding Name with trailing address/port fields, and Settings ordered App/Terminal/AI/Sync.
The standalone App page shows appearance, language, versions and support; no Live Activities
settings are exposed. Earlier iPad bilingual/theme/rotation checks remain recorded above.
Final iOS verification: 29 mobile and 28 shared tests passed after the complete navigation,
App/Sync settings, credential parity and persistent Journey-overlay changes. Log:
`/tmp/termind-ios-port-complete.log`. Strict Swift formatting, `git diff --check`, and
`Scripts/check-zh-localization.sh` passed. No dependency pins or cloud schemas were changed.
+14
View File
@@ -1058,3 +1058,17 @@ and chat content. Strict formatting and Chinese localization checks passed.
- Action: moved the value-only permission draft into Domain and extracted a SwiftUI permission grid into Presentation, used by both editors. Removed the desktop-only row table and toggle builder.
- Estimated reduction: approximately 45 desktop view lines, with a single shared permission-control implementation replacing a second mobile implementation.
- Status: implemented; regression verification is recorded in `MOBILE_ROLLOUT.md`.
## 2026-09-11 — Share mobile settings and terminal behavior
- Evidence: `rg` confirms CloudKit coordinators, Settings transaction/journal, terminal settings, and command-history models were still desktop-owned; mobile used an unsynchronized AI store.
- Action: move these implementations to their owning shared frameworks, retaining desktop compatibility aliases and native surface adapters.
- Estimated reduction: avoids duplicating approximately 2,000 lines of synchronization and repository behavior in TFI.
- Status: implemented. Shared/mobile regression tests pass; full macOS verification retains existing Agent timing failures. See MOBILE_ROLLOUT.md for the complete verification record.
## 2026-09-11 — Share Metrics and connection presentation
- Evidence: desktop ServerMetricsModel already owns sampling, detail observers, host-key prompts and authentication while MobileMetricsModel duplicated a basic sampling loop; Journey graph and decision forms were embedded in the AppKit view.
- Action: move the monitor and Journey state to Application, metrics panels/instruments and connection decisions/route to Presentation; retain AppKit keyboard handling via a narrow injected adapter. Mobile metrics now delegates to that same monitor.
- Estimated reduction: removes the separate mobile sampling loop and avoids another approximately 1,200 lines of graph, instruments, panels and authentication form code.
- Status: implemented; shared/mobile regression tests and 127 focused macOS tests pass. The final full macOS run retains six existing Agent approval/input timing issues; see MOBILE_ROLLOUT.md.
+101 -19
View File
@@ -22,6 +22,10 @@ final class MobileAppModel {
let transfers: SFTPTransferCoordinator
private(set) var workspaces: [MobileWorkspace] = []
var error: String?
let terminalSettings: TerminalSettingsModel
let commandHistory: CommandHistoryModel
let vaultSync: VaultSyncCoordinator?
let settingsSync: SettingsSyncCoordinator?
let ai: AIConfigurationModel
let assistant: MobileAssistantModel
let metrics: MobileMetricsModel
@@ -30,6 +34,7 @@ final class MobileAppModel {
private let defaults: UserDefaults?
private let background = MobileBackgroundController()
let outbox: OutboxRunner
private var hasActivated = false
private var scenePhases: [UUID: ScenePhase] = [:]
init(hosts: (any HostRepository)? = nil, inMemory: Bool = false, defaults: UserDefaults? = .standard) throws {
@@ -45,7 +50,6 @@ final class MobileAppModel {
outbox = OutboxRunner(store: SwiftDataOutboxStore(context: ModelContext(container)))
self.defaults = inMemory || hosts != nil ? nil : defaults
liveActivities = MobileLiveActivityController(enabled: !inMemory && hosts == nil)
metrics = MobileMetricsModel(defaults: inMemory || hosts != nil ? nil : defaults)
pro = inMemory || hosts != nil ? .unrestrictedForTesting() : .live()
let persistence = HostPersistence(context: vaultContext)
_ = try persistence.load()
@@ -55,14 +59,27 @@ final class MobileAppModel {
: KeychainSecretStore(
accessGroup: Bundle.main.object(forInfoDictionaryKey: "TermindKeychainAccessGroup") as? String
?? "F6SUNDBBN9.com.akinokaede.termind")
let aiPersistence = AIPersistence(context: ModelContext(container))
_ = try aiPersistence.load()
let settingsDefaults =
memoryOnly ? UserDefaults(suiteName: "termind.tests.\(UUID().uuidString)")! : defaults ?? .standard
SharedSettingsKey.migrateSyncPreferences(in: settingsDefaults)
let settingsPersistence = SettingsSyncPersistence(context: ModelContext(container), defaults: settingsDefaults)
try settingsPersistence.prepare()
let aiSecrets = AISecretStore(context: settingsPersistence.context, keychain: secrets)
terminalSettings = TerminalSettingsModel(persistence: settingsPersistence)
commandHistory = CommandHistoryModel(
repository: SwiftDataCommandHistoryRepository(context: ModelContext(container)))
settingsSync =
memoryOnly
? nil
: SettingsSyncCoordinator(persistence: settingsPersistence, secrets: aiSecrets, defaults: settingsDefaults)
vaultSync = memoryOnly ? nil : VaultSyncCoordinator(context: vaultContext, defaults: settingsDefaults)
ai = AIConfigurationModel(
persistence: aiPersistence, secrets: secrets,
aiSecrets: AISecretStore(context: ModelContext(container), keychain: secrets))
persistence: SyncedAIConfigurationPersistence(settingsPersistence), secrets: secrets,
aiSecrets: aiSecrets, webSearchPersistence: settingsPersistence)
assistant = MobileAssistantModel(
configuration: ai, repository: SwiftDataAgentRunRepository(context: ModelContext(container)))
library = HostLibraryModel(persistence: persistence, secrets: secrets)
metrics = MobileMetricsModel(defaults: memoryOnly ? nil : defaults, hosts: library)
credentials = CredentialLibraryModel(library: library)
snippets = SnippetLibraryModel(library: library)
knownHosts = KnownHostsLibraryModel(library: library)
@@ -76,6 +93,7 @@ final class MobileAppModel {
RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.closeWorkspaces) { [weak self] payload in
guard let self else { return }
for workspace in workspaces.filter({ $0.descriptor.hostID == payload.hostID }) { close(workspace.id) }
commandHistory.clear(for: payload.hostID)
library.projectHostDeletion(payload.hostID)
})
outbox.register(
@@ -90,10 +108,21 @@ final class MobileAppModel {
outbox.register(
RepositoryHostDeletionHandler(handlerID: HostDeletionOutbox.clearReferences, repository: self.hosts))
outbox.register(RepositoryHostDeletionHandler(handlerID: HostDeletionOutbox.finalize, repository: self.hosts))
library.didRecordVaultChanges = { [weak vaultSync] in vaultSync?.noteLocalChanges($0) }
if let repository = self.hosts as? SwiftDataHostRepository {
repository.didRecordVaultChanges = { [weak vaultSync] in vaultSync?.noteLocalChanges($0) }
}
vaultSync?.didApplyRemote = { [weak self] in self?.applyRemoteVault($0, keys: $1) }
settingsSync?.didApplyRemote = { [weak self] snapshot, _ in
self?.ai.applyRemoteSnapshot(snapshot.configuration, webSearch: snapshot.webSearch)
self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal)
}
if let data = self.defaults?.data(forKey: "mobile.workspaces.v1") {
let descriptors = try JSONDecoder().decode([MobileWorkspaceDescriptor].self, from: data)
workspaces = descriptors.filter { library.host($0.hostID) != nil }.map {
MobileWorkspace(descriptor: $0, hosts: library, transfers: transfers)
MobileWorkspace(
descriptor: $0, hosts: library, transfers: transfers, terminalSettings: terminalSettings,
commandHistory: commandHistory)
}
for workspace in workspaces {
workspace.didChangeState = { [weak self] in
@@ -104,14 +133,12 @@ final class MobileAppModel {
}
}
func makeSSHImportModel() -> SSHImportModel {
SSHImportModel(library: library, reader: SSHImportReader(username: ""))
}
func open(hostID: UUID, kind: MobileWorkspaceDescriptor.Kind) -> UUID? {
guard let host = library.host(hostID) else { return nil }
let descriptor = MobileWorkspaceDescriptor(hostID: hostID, title: host.name, kind: kind)
let workspace = MobileWorkspace(descriptor: descriptor, hosts: library, transfers: transfers)
let workspace = MobileWorkspace(
descriptor: descriptor, hosts: library, transfers: transfers, terminalSettings: terminalSettings,
commandHistory: commandHistory)
workspaces.append(workspace)
persistWorkspaces()
workspace.didChangeState = { [weak self] in
@@ -136,6 +163,7 @@ final class MobileAppModel {
for workspace in workspaces.filter({ $0.descriptor.hostID == id }) { close(workspace.id) }
transfers.cancelAll(for: id)
forwardings.stop(library.portForwardings(for: id).map(\.id))
metrics.remove(id)
try hosts.requestHostDeletion(id)
library.projectHostDeletion(id)
outbox.start()
@@ -147,6 +175,8 @@ final class MobileAppModel {
scenePhases[id] = phase
let isBackground = scenePhases.values.allSatisfy { $0 == .background }
if isBackground && !wasBackground {
vaultSync?.pause()
settingsSync?.pause()
metrics.pause()
outbox.stop()
assistant.stop()
@@ -159,13 +189,67 @@ final class MobileAppModel {
liveActivities.update(workspaces)
} else if phase == .active {
background.finish()
reconcileSync()
outbox.start()
liveActivities.update(workspaces)
metrics.start(hosts: library)
if hasActivated {
liveActivities.update(workspaces)
} else {
hasActivated = true
liveActivities.start(workspaces)
}
metrics.start()
}
}
func removeScene(_ id: UUID) { scenePhases[id] = nil }
func reconcileSync() {
let foreground = scenePhases.values.contains(.active)
if foreground && pro.canUse(.vaultSync) {
vaultSync?.start()
settingsSync?.start()
} else {
vaultSync?.pause()
settingsSync?.pause()
}
}
func setSyncEnabled(_ value: Bool, key: String) {
guard !value || pro.require(.vaultSync) else { return }
defaults?.set(value, forKey: key)
reconcileSync()
}
func applyRemoteVault(_ snapshot: VaultSnapshot, keys: Set<VaultRecordKey>) {
let changed = Set(keys.filter { $0.kind == .host }.map(\.id))
for id in changed {
guard let old = library.host(id) else { continue }
let next = snapshot.hosts.first { $0.id == id }
let removed = next == nil
let routeChanged =
next.map {
old.host != $0.host || old.port != $0.port || old.identityID != $0.identityID
|| old.jumpHostID != $0.jumpHostID || old.proxy != $0.proxy
} ?? true
if routeChanged {
for workspace in workspaces.filter({ $0.descriptor.hostID == id }) {
if removed { close(workspace.id) } else { workspace.disconnect() }
}
transfers.cancelAll(for: id)
forwardings.stop(library.portForwardings(for: id).map(\.id))
}
if removed {
commandHistory.clear(for: id)
metrics.remove(id)
}
}
forwardings.stop(keys.filter { $0.kind == .portForwarding }.map(\.id))
library.replacePersistedSnapshot(snapshot)
liveActivities.update(workspaces)
}
func removeScene(_ id: UUID) {
scenePhases[id] = nil
reconcileSync()
}
private func persistWorkspaces() {
do {
@@ -175,12 +259,11 @@ final class MobileAppModel {
}
nonisolated enum MobileDestination: String, CaseIterable, Identifiable {
case welcome, hosts, assistant, workspaces, metrics, settings
case vault, metrics, workspaces, assistant, settings
var id: Self { self }
var title: String {
switch self {
case .welcome: String(localized: "Welcome")
case .hosts: String(localized: "Vault")
case .vault: String(localized: "Vault")
case .assistant: String(localized: "Assistant")
case .workspaces: String(localized: "Workspaces")
case .metrics: String(localized: "Metrics")
@@ -189,8 +272,7 @@ nonisolated enum MobileDestination: String, CaseIterable, Identifiable {
}
var symbol: String {
switch self {
case .welcome: "house"
case .hosts: "server.rack"
case .vault: "server.rack"
case .assistant: "sparkles"
case .workspaces: "terminal"
case .metrics: "gauge.with.dots.needle.33percent"
+11 -1
View File
@@ -11,8 +11,10 @@ final class MobileAssistantModel {
private(set) var messages: [AgentTranscriptMessage] = []
private(set) var isRunning = false
var selectedID = UUID()
var selectedModel: AIModelRef?
var draft = ""
var error: String?
private var conversationModels: [UUID: AIModelRef] = [:]
private var runtime: AgentRuntime?
private var task: Task<Void, Never>?
private let repository: any AgentRunPersisting
@@ -32,18 +34,26 @@ final class MobileAssistantModel {
func select(_ conversation: StoredAgentConversation) {
guard !isRunning else { return }
selectedID = conversation.id
selectedModel = conversationModels[conversation.id]
messages = conversation.modelTranscript ?? conversation.messages
}
func newConversation() {
guard !isRunning else { return }
selectedID = UUID()
selectedModel = nil
messages = []
}
func selectModel(_ value: AIModelRef?) {
selectedModel = value
conversationModels[selectedID] = value
if let value { configuration.select(value) }
}
func send() {
guard !isRunning, !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
guard let resolved = configuration.resolve(conversation: configuration.lastSelectedModel),
guard let resolved = configuration.resolve(conversation: selectedModel),
let secret = configuration.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty
else {
error = String(localized: "Configure a model and its API key in Settings before sending a message.")
+66 -32
View File
@@ -10,16 +10,10 @@ final class MobileKeyEditorModel: Identifiable {
enum Mode { case generate, importKey, edit }
enum Algorithm: String, CaseIterable, Identifiable {
case ed25519 = "Ed25519"
case ecdsa = "ECDSA P-256"
case rsa = "RSA 4096"
case ecdsa = "ECDSA"
case rsa = "RSA"
var id: Self { self }
var generation: SSHKeyMaterial.Generation {
switch self {
case .ed25519: .ed25519
case .ecdsa: .ecdsa(bits: 256)
case .rsa: .rsa(bits: 4096)
}
}
}
let id = UUID()
let mode: Mode
@@ -28,6 +22,12 @@ final class MobileKeyEditorModel: Identifiable {
var label: String
var storage: KeyStorage
var algorithm: Algorithm = .ed25519
var colorTag = TagColor.none
var curveBits = 256
var rsaBits = 3072
var cipher = SSHKeyMaterial.Cipher.aes256
var rounds = 100
var confirmation = ""
var privateKey = ""
var passphrase = ""
var certificate: String
@@ -38,17 +38,42 @@ final class MobileKeyEditorModel: Identifiable {
self.credentials = credentials
original = key
label = key?.label ?? ""
storage = key?.storage ?? .thisDevice
storage = key?.storage ?? .iCloud
certificate = key?.certificate ?? ""
colorTag = key?.colorTag ?? .none
}
var isDirty: Bool {
label != (original?.label ?? "") || storage != (original?.storage ?? .thisDevice)
label != (original?.label ?? "") || storage != (original?.storage ?? .iCloud)
|| certificate != (original?.certificate ?? "") || !privateKey.isEmpty || !passphrase.isEmpty
|| algorithm != .ed25519 || requiresUserPresence
|| algorithm != .ed25519 || requiresUserPresence || colorTag != (original?.colorTag ?? .none)
|| curveBits != 256 || rsaBits != 3072 || cipher != .aes256 || rounds != 100 || !confirmation.isEmpty
}
var canSave: Bool {
!label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && (mode != .importKey || !privateKey.isEmpty)
let nameIsValid = mode == .generate || !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let encryptionIsValid =
mode != .generate || storage == .secureEnclave || passphrase.isEmpty
|| (passphrase == confirmation && rounds > 0)
return nameIsValid && encryptionIsValid && (mode != .importKey || !privateKey.isEmpty)
&& (storage != .secureEnclave || canUseEnclave)
}
var defaultName: String { storage == .secureEnclave ? "Secure Enclave" : algorithm.rawValue }
var resolvedLabel: String {
label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? defaultName : label.trimmingCharacters(in: .whitespacesAndNewlines)
}
var certificateWarning: String? {
guard !certificate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
do { return try SSHUserCertificate.parse(certificate, matching: original?.publicKey).validityWarning } catch {
return error.localizedDescription
}
}
private var generation: SSHKeyMaterial.Generation {
switch algorithm {
case .ed25519: .ed25519
case .ecdsa: .ecdsa(bits: curveBits)
case .rsa: .rsa(bits: rsaBits)
}
}
var publicKey: String? { original?.publicKey }
var fingerprint: String? { original?.fingerprint }
@@ -56,26 +81,30 @@ final class MobileKeyEditorModel: Identifiable {
var canUseEnclave: Bool { SecureEnclaveKey.isAvailable }
func loadFile(_ url: URL) async throws {
let text = try await Task.detached {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1024 * 1024 else {
throw CocoaError(.fileReadTooLarge)
}
return try String(contentsOf: url, encoding: .utf8)
}.value
let infersName = SoftwareKeyFileImporter.shouldInferName(from: label)
let result = try await Task.detached { try SoftwareKeyFileImporter.read(urls: [url], infersName: infersName) }
.value
try Task.checkCancellation()
privateKey = text
if label.isEmpty { label = url.deletingPathExtension().lastPathComponent }
guard let key = result.privateKey else { throw SoftwareKeyFileImporter.unexpectedFileError() }
privateKey = key
if let name = result.inferredName { label = name }
}
func loadCertificateFile(_ url: URL) async throws {
let result = try await Task.detached { try SoftwareKeyFileImporter.read(urls: [url], infersName: false) }.value
try Task.checkCancellation()
guard let certificate = result.certificate else { throw SoftwareKeyFileImporter.unexpectedFileError() }
_ = try SSHUserCertificate.parse(certificate, matching: original?.publicKey)
self.certificate = certificate
}
func save() async throws {
guard canSave else { throw CocoaError(.coderInvalidValue) }
if var key = original {
key.label = label.trimmingCharacters(in: .whitespacesAndNewlines)
key.label = resolvedLabel
key.colorTag = colorTag
key.storage = storage
key.certificate = certificate
key.certificate = certificate.trimmingCharacters(in: .whitespacesAndNewlines)
try credentials.upsert(key)
return
}
@@ -89,30 +118,35 @@ final class MobileKeyEditorModel: Identifiable {
}.value
try Task.checkCancellation()
key = SSHKeyRef(
label: label, algorithm: .ecdsa256, publicKey: material.publicKey,
label: resolvedLabel, algorithm: .ecdsa256, publicKey: material.publicKey,
fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .secureEnclave,
keySize: 256, requiresUserPresence: presence, createdAt: .now)
keySize: 256, requiresUserPresence: presence, createdAt: .now, colorTag: colorTag)
secret = .secureEnclave(material.credential)
} else {
let input = privateKey
let passphrase = passphrase.isEmpty ? nil : passphrase
let generation = algorithm.generation
let generation = generation
let cipher = cipher
let rounds = rounds
let generates = mode == .generate
let material = try await Task.detached(priority: .userInitiated) {
if generates {
return try SSHKeyMaterial.generate(generation, comment: comment, passphrase: passphrase)
return try SSHKeyMaterial.generate(
generation, comment: comment, passphrase: passphrase, cipher: cipher, rounds: rounds)
}
return try SSHKeyMaterial.inspect(privateKey: input, passphrase: passphrase, comment: comment)
}.value
try Task.checkCancellation()
key = SSHKeyRef(
label: label, algorithm: material.algorithm, publicKey: material.publicKey,
label: resolvedLabel, algorithm: material.algorithm, publicKey: material.publicKey,
certificate: certificate, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())",
storage: storage, keySize: material.keySize, hasPassphrase: passphrase != nil, createdAt: .now)
storage: storage, keySize: material.keySize, hasPassphrase: passphrase != nil, createdAt: .now,
colorTag: colorTag)
secret = .software(SSHPrivateKeySecret(privateKey: material.privateKey))
}
try credentials.upsert(key, credential: secret)
privateKey = ""
passphrase = ""
confirmation = ""
}
}
+39 -58
View File
@@ -2,74 +2,55 @@ import Foundation
import Observation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindRemoteInfrastructure
/// The mobile shell delegates collection, authentication and retries to the desktop's shared model.
@MainActor
@Observable
final class MobileMetricsModel {
private(set) var points: [UUID: MetricsPoint] = [:]
private(set) var errors: [UUID: String] = [:]
private(set) var selections: [MetricsSelection]
private let defaults: UserDefaults?
private var tasks: [UUID: Task<Void, Never>] = [:]
let core: ServerMetricsModel
private let hosts: any RemoteSessionHostStore
private let limit = MobileMetricsLimit()
var servers: [ServerMetric] { core.servers }
var selections: [MetricsSelection] { servers.map(\.selection) }
init(defaults: UserDefaults?) {
self.defaults = defaults
selections =
defaults?.data(forKey: "mobile.metrics.v1").flatMap {
try? JSONDecoder().decode([MetricsSelection].self, from: $0)
} ?? []
init(defaults: UserDefaults?, hosts: any RemoteSessionHostStore) {
self.hosts = hosts
let limit = limit
core = ServerMetricsModel(
hosts: hosts, preferences: MobileMetricsPreferences(defaults: defaults),
makeSampler: { SSHMetricsSampler(session: SSHExecSession()) },
discover: { host, proxy, jumps in
try await SSHSessionActor.discoverHostKey(
hostID: host.id, host: host.host, port: host.port, proxy: proxy, jumps: jumps)
}, cardLimit: { limit.maximum })
}
func add(_ id: UUID, pro: Bool) -> Bool {
if selections.contains(where: { $0.id == id }) { return true }
guard pro || selections.count < 3 else { return false }
selections.append(MetricsSelection(id: id))
persist()
return true
limit.maximum = pro ? nil : ServerMetricsModel.freeCardLimit
guard let host = hosts.host(id) else { return false }
return core.add(host)
}
func remove(_ id: UUID) {
tasks.removeValue(forKey: id)?.cancel()
selections.removeAll { $0.id == id }
points[id] = nil
errors[id] = nil
persist()
}
func pause() {
for task in tasks.values { task.cancel() }
tasks.removeAll()
}
func start(hosts: any RemoteSessionHostStore) {
for selection in selections where selection.enabled && tasks[selection.id] == nil {
guard let host = hosts.host(selection.id) else { continue }
tasks[selection.id] = Task { [weak self] in
let sampler = SSHMetricsSampler(session: SSHExecSession())
do {
let config = try await hosts.prepareSSHConnection(for: host, submissions: [:]).resolve()
try await sampler.connect(config)
var previous: MetricsSample?
while !Task.isCancelled {
let sample = try await sampler.sample()
try Task.checkCancellation()
self?.points[host.id] = MetricsCalculation.point(
previous: previous, current: sample, selection: selection, date: .now)
self?.errors[host.id] = nil
previous = sample
try await Task.sleep(for: .seconds(3))
}
} catch {
if !Task.isCancelled { self?.errors[host.id] = error.localizedDescription }
}
await sampler.disconnect()
}
}
}
private func persist() {
if let data = try? JSONEncoder().encode(selections) { defaults?.set(data, forKey: "mobile.metrics.v1") }
func remove(_ id: UUID) { core.remove(id) }
func pause() { core.suspend() }
func start() {
core.start()
core.resume()
}
}
@MainActor private final class MobileMetricsLimit { var maximum: Int? = 3 }
@MainActor private final class MobileMetricsPreferences: MetricsConfigurationStoring {
private let defaults: UserDefaults?
init(defaults: UserDefaults?) { self.defaults = defaults }
func load() -> [MetricsSelection] {
defaults?.data(forKey: "mobile.metrics.v1").flatMap {
try? JSONDecoder().decode([MetricsSelection].self, from: $0)
} ?? []
}
func save(_ values: [MetricsSelection]) {
if let data = try? JSONEncoder().encode(values) { defaults?.set(data, forKey: "mobile.metrics.v1") }
}
}
+55
View File
@@ -0,0 +1,55 @@
import AgentKit
import Foundation
import Observation
import TermindApplication
@MainActor
@Observable
final class MobileModelDiscovery {
private(set) var candidates: [AIModel] = []
private(set) var running = false
var error: String?
private var task: Task<Void, Never>?
private var generation = UUID()
typealias Fetch = @Sendable (ModelProvider, String) async throws -> [AIModel]
private let fetch: Fetch
init(fetch: @escaping Fetch = { try await ModelCatalogClient().models(for: $0, secret: $1) }) {
self.fetch = fetch
}
func start(ai: AIConfigurationModel, provider: ModelProvider) {
cancel()
let token = generation
let secret = ai.secret(for: provider.effectiveCredentialRef) ?? ""
if let error = ai.secretStorageError {
self.error = error
return
}
running = true
error = nil
task = Task { [weak self, fetch] in
defer { if self?.generation == token { self?.running = false } }
do {
let values = try await fetch(provider, secret)
guard let self, generation == token, !Task.isCancelled,
ai.modelProvider(provider.id) == provider
else { return }
candidates = values
running = false
} catch {
guard let self, generation == token, !Task.isCancelled else { return }
self.error = error.localizedDescription
running = false
}
}
}
func cancel() {
generation = UUID()
task?.cancel()
task = nil
candidates = []
running = false
}
}
+65
View File
@@ -0,0 +1,65 @@
import AgentKit
import Foundation
import Observation
import TermindApplication
import TermindDomain
@MainActor
@Observable
final class MobileProviderEditorModel {
enum KeyAction: String, CaseIterable { case preserve, replace, clear }
let original: ModelProvider
let originalEndpoint: ProviderEndpoint
var draft: ModelProvider
var endpoint: ProviderEndpoint
var keyAction = KeyAction.preserve
var secret = ""
private let ai: AIConfigurationModel
private let wasPersisted: Bool
init(ai: AIConfigurationModel, provider: ModelProvider) {
self.ai = ai
original = provider
draft = provider
wasPersisted = ai.modelProvider(provider.id) != nil
originalEndpoint = wasPersisted ? ai.endpoint(for: provider.id) : ProviderEndpoint(url: provider.inferenceURL)
endpoint = originalEndpoint
}
var isDirty: Bool { draft != original || endpoint != originalEndpoint || keyAction != .preserve || !secret.isEmpty }
var canSave: Bool {
let url = URL(string: endpoint.requestURL)
return !draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& url?.scheme == "https" && url?.host != nil && (keyAction != .replace || !secret.isEmpty)
}
func save() throws {
guard canSave else { return }
let current = ai.modelProvider(original.id)
guard !wasPersisted || current != nil else {
throw MobileEditorError.message(String(localized: "Provider Deleted"))
}
var value = current ?? draft
// A live model refresh must not be overwritten by this form's older draft.
value.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
value.isEnabled = draft.isEnabled
let reference = value.credentialRef.isEmpty ? AIConfigurationModel.makeCredentialRef() : value.credentialRef
var previousSecret: String?
if keyAction != .preserve {
previousSecret = ai.secret(for: reference)
if let error = ai.secretStorageError { throw MobileEditorError.message(error) }
ai.setSecret(keyAction == .clear ? nil : secret, for: reference)
if let error = ai.secretStorageError { throw MobileEditorError.message(error) }
value.credentialRef = keyAction == .clear ? "" : reference
}
guard ai.saveProvider(value, endpoint: endpoint) else {
var message = ai.storageError ?? String(localized: "Unable to Save")
if keyAction != .preserve {
ai.setSecret(previousSecret, for: reference)
if let rollback = ai.secretStorageError { message += "\n" + rollback }
}
throw MobileEditorError.message(message)
}
secret = ""
}
}
+194 -49
View File
@@ -18,22 +18,28 @@ final class MobileWorkspace: Identifiable {
private(set) var status = String(localized: "Disconnected") { didSet { didChangeState?() } }
private(set) var connected = false { didSet { didChangeState?() } }
private(set) var busy = false
let journey = ConnectionJourney()
var error: String?
private var pendingHostKeyHostID: UUID?
private(set) var authenticationPrompt: SSHAuthenticationPrompt?
private var authenticationMode = SSHAuthenticationPrompt.Mode.localIdentity
var pendingHostKey: SSHHostKeyObservation?
var authenticationRequired = false
var username = ""
var password = ""
var passphrase = ""
var authenticationHostName: String {
hosts.host(authenticationHostID ?? descriptor.hostID)?.name ?? descriptor.title
}
private var authenticationHostID: UUID?
private var temporaryKeys: [UUID: SSHHostKeyIdentity] = [:]
private var submissions: [UUID: SSHAuthenticationSubmission] = [:]
var path = "/"
private(set) var files: [RemoteFile] = []
let transfers: SFTPTransferCoordinator?
private var transferConfiguration: SSHConnectionConfiguration?
private var directoryRequest = UUID()
let terminalSettings: TerminalSettingsModel
let commandHistory: CommandHistoryModel
var isFindPresented = false
var findQuery = ""
private var activeHistoryID: UUID?
private var shellIntegrationAvailable = false
private let surfaceOwner = MobileTerminalSurface()
let terminal: TerminalViewState
private let backend: InMemoryTerminalSession
@@ -46,8 +52,12 @@ final class MobileWorkspace: Identifiable {
init(
descriptor: MobileWorkspaceDescriptor, hosts: any RemoteSessionHostStore,
transfers: SFTPTransferCoordinator? = nil
transfers: SFTPTransferCoordinator? = nil,
terminalSettings: TerminalSettingsModel = TerminalSettingsModel(),
commandHistory: CommandHistoryModel = CommandHistoryModel()
) {
self.terminalSettings = terminalSettings
self.commandHistory = commandHistory
self.transfers = transfers
self.descriptor = descriptor
self.hosts = hosts
@@ -65,10 +75,127 @@ final class MobileWorkspace: Identifiable {
relay.owner = self
}
private func makeAuthenticationPrompt() -> SSHAuthenticationPrompt? {
guard authenticationRequired, let host = hosts.host(authenticationHostID ?? descriptor.hostID) else {
return nil
}
let staged = submissions[host.id]
let identity = staged?.identityID.flatMap { hosts.identity($0) } ?? hosts.identity(for: host)
let keyID = staged?.keyID ?? identity?.keyID
return SSHAuthenticationPrompt(
host: host, selectedIdentityID: identity?.id, username: username,
selectedKeyID: keyID, failureMessage: error,
requiresPassphrase: keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true
&& staged?.passphrase == nil,
mode: authenticationMode)
}
var hostKeyPrompt: SSHHostKeyPrompt? {
guard let observation = pendingHostKey else { return nil }
let count = pendingHostKeyHostID.flatMap { hosts.host($0) }.map { hosts.knownHosts(for: $0).count } ?? 0
return SSHHostKeyPrompt(
observation: observation, kind: count == 0 ? .firstUse : .additionalKey(trustedKeyCount: count))
}
private func preparation(for host: Host) throws -> SSHConnectionPreparation {
var result = try hosts.prepareSSHConnection(for: host, submissions: submissions)
for index in result.nodes.indices {
if let key = temporaryKeys[result.nodes[index].host.id] { result.nodes[index].trustedKeys.insert(key) }
}
return result
}
func submitAuthentication(
identityID: UUID?, username: String, password: String?, passphrase: String?, keyID: UUID?, save: Bool
) {
guard var host = hosts.host(authenticationHostID ?? descriptor.hostID) else { return }
let identity = identityID.flatMap { hosts.identity($0) }
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
if authenticationPrompt?.requiresPassphrase == true, var staged = submissions[host.id] {
staged.passphrase = passphrase
submissions[host.id] = staged
} else {
let chosenKey = identity?.keyID ?? keyID
if save {
do {
let value = identity ?? Identity(name: name, username: name, keyID: chosenKey)
try hosts.upsert(value, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep)
host.identityID = value.id
hosts.upsert(host)
} catch {
self.error = error.localizedDescription
return
}
}
submissions[host.id] = SSHAuthenticationSubmission(
username: name, password: password, passphrase: passphrase,
keyID: chosenKey, identityID: identity?.id)
}
authenticationRequired = false
authenticationPrompt = nil
journey.resume()
connect()
}
func presentFind() {
let wasOpen = isFindPresented
isFindPresented = true
if !wasOpen && !findQuery.isEmpty { _ = terminal.performBindingAction("search:\(findQuery)") }
}
func updateFindQuery(_ query: String) {
findQuery = query
_ = terminal.performBindingAction("search:\(query)")
}
func navigateFind(previous: Bool) {
presentFind()
guard !findQuery.isEmpty else { return }
_ = terminal.performBindingAction(previous ? "navigate_search:previous" : "navigate_search:next")
}
func dismissFind() {
_ = terminal.performBindingAction("end_search")
isFindPresented = false
terminal.requestFocus()
}
func insertHistory(_ command: String) {
guard connected else { return }
_ = terminal.paste(text: command)
terminal.requestFocus()
}
func receiveShellEvent(_ event: SSHShellIntegrationEvent) {
guard terminalSettings.preferences.shellIntegration else {
activeHistoryID = nil
shellIntegrationAvailable = false
return
}
switch event {
case .available(_, let systemInfo, _):
shellIntegrationAvailable = true
hosts.recordRemoteSystemInfo(systemInfo, for: descriptor.hostID)
case .systemInfo(let systemInfo): hosts.recordRemoteSystemInfo(systemInfo, for: descriptor.hostID)
case .commandStarted(let command):
guard shellIntegrationAvailable else { return }
activeHistoryID = commandHistory.record(command, for: descriptor.hostID)?.id
case .commandFinished(let status, let duration):
guard let id = activeHistoryID else { return }
commandHistory.recordCompletion(for: id, hostID: descriptor.hostID, exitStatus: status, duration: duration)
activeHistoryID = nil
default: break
}
}
func connect() {
guard !busy, !connected else { return }
let token = UUID()
generation = token
activeHistoryID = nil
shellIntegrationAvailable = false
busy = true
error = nil
status = String(localized: "Connecting")
@@ -77,31 +204,39 @@ final class MobileWorkspace: Identifiable {
defer { if generation == token { busy = false } }
do {
guard let host = hosts.host(descriptor.hostID) else { throw CocoaError(.fileNoSuchFile) }
journey.begin(
host: host, route: hosts.route(for: host),
purpose: descriptor.kind == .ssh ? .sshSession : .sftpSession,
username: hosts.identity(for: host)?.username)
// Validate each hop in order so credentials and trust prompts name the actual endpoint.
for endpoint in hosts.route(for: host) {
let preparation = try hosts.prepareSSHConnection(for: endpoint, submissions: submissions)
let preparation = try preparation(for: endpoint)
let discovery = try await preparation.resolve(discoveryOnly: true)
journey.receive(.connecting(endpoint.id))
let observation = try await SSHSessionActor.discoverHostKey(
hostID: endpoint.id, host: endpoint.host, port: endpoint.port,
proxy: discovery.proxy, jumps: discovery.jumps)
guard generation == token, !Task.isCancelled else { return }
let trusted = hosts.knownHosts(for: endpoint)
if !trusted.contains(where: { $0.identity == observation.identity }) {
if !trusted.contains(where: { $0.identity == observation.identity })
&& temporaryKeys[endpoint.id] != observation.identity
{
if trusted.contains(where: { $0.algorithm == observation.algorithm }) {
hosts.markKnownHostMismatched(hostname: endpoint.host, port: endpoint.port)
throw TermindSSHError.hostKeyChanged(
expected: trusted.map(\.fingerprint), received: observation.fingerprint)
}
pendingHostKeyHostID = endpoint.id
pendingHostKey = observation
journey.waitForHostKey(at: endpoint.id, fingerprint: observation.fingerprint)
status = String(localized: "Waiting for approval")
return
}
}
let preparation = try hosts.prepareSSHConnection(for: host, submissions: submissions)
let preparation = try preparation(for: host)
let config = try await preparation.resolve()
guard generation == token, !Task.isCancelled else { return }
password = ""
passphrase = ""
if descriptor.kind == .ssh {
let backend = backend
let session = SSHSessionActor(
@@ -120,13 +255,7 @@ final class MobileWorkspace: Identifiable {
shellIntegration: { [weak self] event in
Task { @MainActor in
guard let self, self.generation == token else { return }
switch event {
case .available(_, let systemInfo, _):
self.hosts.recordRemoteSystemInfo(systemInfo, for: self.descriptor.hostID)
case .systemInfo(let systemInfo):
self.hosts.recordRemoteSystemInfo(systemInfo, for: self.descriptor.hostID)
default: return
}
self.receiveShellEvent(event)
self.didChangeState?()
}
})
@@ -135,8 +264,16 @@ final class MobileWorkspace: Identifiable {
hostID: config.hostID, host: config.host, port: config.port,
username: config.username, credentials: config.credentials,
expectedHostKeys: config.expectedHostKeys,
proxy: config.proxy, jumps: config.jumps, columns: UInt16(clamping: viewport.columns),
rows: UInt16(clamping: viewport.rows))
proxy: config.proxy, jumps: config.jumps,
shellIntegrationEnabled: terminalSettings.preferences.shellIntegration,
columns: UInt16(clamping: viewport.columns),
rows: UInt16(clamping: viewport.rows),
progress: { [weak self] event in
Task { @MainActor in
guard let self, self.generation == token else { return }
self.journey.receive(event)
}
})
} else {
let client = try await RemoteConnectionCoordinator().connectSFTP(using: config)
guard generation == token, !Task.isCancelled else {
@@ -154,9 +291,11 @@ final class MobileWorkspace: Identifiable {
submissions.removeAll()
connected = true
status = String(localized: "Connected")
journey.succeed()
} catch {
guard generation == token else { return }
self.error = error.localizedDescription
journey.fail(error.localizedDescription)
status = String(localized: "Disconnected")
if let sshError = error as? TermindSSHError {
switch sshError {
@@ -165,13 +304,25 @@ final class MobileWorkspace: Identifiable {
default: authenticationRequired = sshError.shouldRequestAuthentication
}
if authenticationRequired {
authenticationMode =
sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity
authenticationHostID = sshError.routeHostID ?? descriptor.hostID
if let endpoint = hosts.host(authenticationHostID ?? descriptor.hostID) {
username =
submissions[endpoint.id]?.username ?? hosts.identity(for: endpoint)?.username ?? ""
}
password = ""
passphrase = ""
authenticationPrompt = makeAuthenticationPrompt()
if authenticationPrompt?.requiresPassphrase == true {
journey.waitForLocalPassphrase(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
} else if sshError.isServerAuthenticationChallenge {
journey.waitForAuthentication(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
} else {
journey.waitForLocalIdentity(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
}
}
}
await ssh?.disconnect()
@@ -182,37 +333,31 @@ final class MobileWorkspace: Identifiable {
}
}
func submitAuthentication() {
let id = authenticationHostID ?? descriptor.hostID
guard let host = hosts.host(id) else { return }
submissions[id] = SSHAuthenticationSubmission(
username: username,
password: password.isEmpty ? nil : password, passphrase: passphrase.isEmpty ? nil : passphrase,
keyID: hosts.identity(for: host)?.keyID)
password = ""
passphrase = ""
authenticationRequired = false
connect()
}
func cancelAuthentication() {
password = ""
passphrase = ""
submissions.removeAll()
authenticationRequired = false
}
func trustHostKey() {
func trustHostKey(remember: Bool = true) {
guard let observation = pendingHostKey else { return }
hosts.trust(
KnownHostEntry(
hostname: observation.hostname, port: observation.port,
algorithm: observation.algorithm, fingerprint: observation.fingerprint, addedAt: .now))
if remember {
hosts.trust(
KnownHostEntry(
hostname: observation.hostname, port: observation.port,
algorithm: observation.algorithm, fingerprint: observation.fingerprint, addedAt: .now))
} else {
temporaryKeys[pendingHostKeyHostID ?? descriptor.hostID] = observation.identity
}
journey.resume()
pendingHostKey = nil
connect()
}
func disconnect() {
journey.hide()
pendingHostKey = nil
authenticationRequired = false
authenticationPrompt = nil
temporaryKeys.removeAll()
submissions.removeAll()
activeHistoryID = nil
shellIntegrationAvailable = false
generation = UUID()
attempt?.cancel()
attempt = nil
@@ -227,10 +372,10 @@ final class MobileWorkspace: Identifiable {
}
busy = false
connected = false
password = ""
passphrase = ""
submissions.removeAll()
authenticationRequired = false
authenticationPrompt = nil
pendingHostKey = nil
status = String(localized: "Disconnected")
}
+4
View File
@@ -1,9 +1,12 @@
import SwiftData
import SwiftUI
import TermindApplication
@main
struct TermindIOSApp: App {
@UIApplicationDelegateAdaptor(MobileCloudNotifications.self) private var notifications
@AppStorage("mobile.appearance") private var appearance = "system"
private let launchLanguage = AppLanguage.stored()
private let startup: Result<MobileAppModel, any Error>
init() {
@@ -18,6 +21,7 @@ struct TermindIOSApp: App {
case .success(let app):
MobileRootView(app: app)
.modelContainer(app.container)
.environment(\.locale, launchLanguage.locale)
.preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil)
case .failure:
ContentUnavailableView(
+168 -30
View File
@@ -1,59 +1,197 @@
import AgentKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
struct MobileAISettingsView: View {
let ai: AIConfigurationModel
@State private var editing: ModelProvider?
@State private var deleting: ModelProvider?
var body: some View {
List {
ForEach(ai.modelProviders) { provider in
Button(provider.name) { editing = provider }
Section("Model Providers") {
ForEach(ai.modelProviders) { provider in
VStack(alignment: .leading, spacing: 8) {
Button(provider.name) { editing = provider }
NavigationLink("Manage Models") { MobileModelSettingsView(ai: ai, providerID: provider.id) }
Toggle(
"Enabled",
isOn: Binding(
get: { ai.modelProvider(provider.id)?.isEnabled ?? false },
set: { ai.setModelProviderEnabled($0, for: provider.id) }))
}
.swipeActions { Button("Delete", role: .destructive) { deleting = provider } }
}
}
Section("Model Roles") {
ForEach(AIRole.allCases) { role in
Picker(
role.displayName,
selection: Binding(get: { ai.assignment(for: role) }, set: { ai.assign($0, to: role) })
) {
Text("Automatic").tag(nil as AIModelRef?)
ForEach(ai.enabledModelProviders) { provider in
ForEach(provider.chatModels) { model in
Text("\(provider.name) · \(model.id)")
.tag(AIModelRef(modelProviderID: provider.id, modelID: model.id) as AIModelRef?)
}
}
}
}
}
if let error = ai.storageError ?? ai.secretStorageError { Text(error).foregroundStyle(.red) }
}
.navigationTitle("AI")
.toolbar { Button("Add", systemImage: "plus") { editing = ModelProvider(name: "") } }
.sheet(item: $editing) { MobileProviderEditor(ai: ai, provider: $0) }
.sheet(item: $editing) { provider in MobileProviderEditor(ai: ai, provider: provider) }
.confirmationDialog(
"Delete Provider", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }),
titleVisibility: .visible
) {
Button("Delete Provider", role: .destructive) {
if let deleting { ai.delete(modelProviderID: deleting.id) }
deleting = nil
}
} message: {
Text("Permanently deletes this provider, its key, and its models.")
}
}
}
private struct MobileProviderEditor: View {
let ai: AIConfigurationModel
let original: ModelProvider
@State private var draft: ModelProvider
@State private var apiKey = ""
@State private var modelID: String
@State private var model: MobileProviderEditorModel
init(ai: AIConfigurationModel, provider: ModelProvider) {
self.ai = ai
original = provider
_draft = State(initialValue: provider)
_modelID = State(initialValue: provider.models.first?.id ?? "")
_model = State(initialValue: MobileProviderEditorModel(ai: ai, provider: provider))
}
var body: some View {
MobileEditorSheet(
title: String(localized: "Model Provider"),
isDirty: draft != original || !apiKey.isEmpty || modelID != (original.models.first?.id ?? ""),
canSave: !draft.name.isEmpty && URL(string: draft.inferenceURL)?.scheme == "https" && !modelID.isEmpty,
save: {
if !draft.models.contains(where: { $0.id == modelID }) { draft.models.append(AIModel(id: modelID)) }
ai.upsert(draft)
if let error = ai.storageError { throw MobileEditorError.message(error) }
if !apiKey.isEmpty { ai.setSecret(apiKey, for: draft.effectiveCredentialRef) }
if let error = ai.storageError { throw MobileEditorError.message(error) }
ai.select(AIModelRef(modelProviderID: draft.id, modelID: modelID))
},
title: String(localized: "Model Provider"), isDirty: model.isDirty, canSave: model.canSave,
save: { try model.save() },
content: {
TextField("Name", text: $draft.name)
TextField("Inference URL", text: $draft.inferenceURL).textInputAutocapitalization(.never)
.autocorrectionDisabled().keyboardType(.URL)
Picker("API Format", selection: $draft.apiFormat) {
ForEach(ModelAPIFormat.allCases) { Text($0.rawValue).tag($0) }
ModelProviderConnectionFields(name: $model.draft.name, endpoint: $model.endpoint)
.textInputAutocapitalization(.never)
Section("API Key") {
Picker("API Key", selection: $model.keyAction) {
Text("Keep Existing Key").tag(MobileProviderEditorModel.KeyAction.preserve)
Text("Replace Key").tag(MobileProviderEditorModel.KeyAction.replace)
Text("Clear Key").tag(MobileProviderEditorModel.KeyAction.clear)
}
if model.keyAction == .replace {
LabeledContent("API Key") { SecureField("API Key", text: $model.secret) }
}
}
SecureField("API Key", text: $apiKey)
TextField("Model ID", text: $modelID).textInputAutocapitalization(.never).autocorrectionDisabled()
})
}
}
struct MobileModelSettingsView: View {
let ai: AIConfigurationModel
let providerID: UUID
@State private var discovery = MobileModelDiscovery()
@State private var selected = Set<String>()
@State private var adding = false
@State private var modelID = ""
private var provider: ModelProvider? { ai.modelProvider(providerID) }
var body: some View {
List {
if let provider {
Section("Models") {
ForEach(provider.models) { model in
VStack(alignment: .leading) {
Text(model.id).font(.body.monospaced())
if model.kind == .chat {
Toggle(
"Show in Assistant",
isOn: Binding(
get: {
ai.modelProvider(providerID)?.model(model.id)?.isShownInAssistant ?? false
},
set: { value in
guard var current = ai.modelProvider(providerID)?.model(model.id) else {
return
}
current.isShownInAssistant = value
ai.updateModel(current, in: providerID)
}))
}
}.swipeActions {
Button("Delete", role: .destructive) { ai.removeModels([model.id], from: providerID) }
}
}
}
Section {
Button("Fetch") {
selected = []
discovery.start(ai: ai, provider: provider)
}
.disabled(!provider.canFetchModels || discovery.running)
Button("Add Model") { adding = true }
if discovery.running { ProgressView() }
}
if !discovery.candidates.isEmpty {
Section("Available Models") {
ForEach(discovery.candidates, id: \.id) { candidate in
Toggle(
candidate.id,
isOn: Binding(
get: { selected.contains(candidate.id) },
set: { value in
if value {
selected.insert(candidate.id)
} else {
selected.remove(candidate.id)
}
})
).disabled(provider.model(candidate.id) != nil)
}
Button("Add Selected") {
for model in discovery.candidates where selected.contains(model.id) {
ai.addModel(model, to: providerID)
}
if ai.storageError == nil {
ai.markModelsFetched(for: providerID)
discovery.cancel()
selected = []
}
}.disabled(selected.isEmpty)
}
}
if let error = discovery.error ?? ai.storageError { Text(error).foregroundStyle(.red) }
} else {
ContentUnavailableView("Provider Deleted", systemImage: "trash")
}
}
.navigationTitle("Models")
.onDisappear { discovery.cancel() }
.onChange(of: provider) { old, new in
if old?.inferenceURL != new?.inferenceURL || old?.credentialRef != new?.credentialRef || new == nil {
discovery.cancel()
}
}
.sheet(isPresented: $adding) {
MobileEditorSheet(
title: String(localized: "Add Model"), isDirty: !modelID.isEmpty,
canSave: !modelID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, compact: true,
save: {
guard let provider else { throw MobileEditorError.message(String(localized: "Provider Deleted")) }
let model = ModelCapabilityResolver.resolve(
model: AIModel(id: modelID.trimmingCharacters(in: .whitespacesAndNewlines)), provider: provider
).model
ai.addModel(model, to: providerID)
if let error = ai.storageError { throw MobileEditorError.message(error) }
modelID = ""
},
content: {
LabeledContent("Model ID") {
TextField("Model ID", text: $modelID).textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
})
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import SwiftUI
import TermindApplication
struct MobileAppSettingsView: View {
@AppStorage("mobile.appearance") private var appearance = "system"
@AppStorage(SharedSettingsKey.appLanguage) private var language = AppLanguage.system.rawValue
@State private var launchLanguage = AppLanguage.stored()
var body: some View {
Form {
Section("Appearance") {
Picker("Theme", selection: $appearance) {
Text("System").tag("system")
Text("Light").tag("light")
Text("Dark").tag("dark")
}
Picker("Language", selection: $language) {
ForEach(AppLanguage.allCases) { Text($0.displayName).tag($0.rawValue) }
}
if language != launchLanguage.rawValue {
Text("The new language will be used after Termind relaunches.").font(.caption).foregroundStyle(
.secondary)
}
}
Section("Version") {
LabeledContent(
"App",
value: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0")
LabeledContent("Ghostty", value: MobileRuntimeVersions.ghostty)
LabeledContent("libssh2", value: MobileRuntimeVersions.libssh2)
LabeledContent("OpenSSL", value: MobileRuntimeVersions.openSSL)
}
Section("Support") {
Link(
"Rate on the App Store",
destination: URL(string: "https://apps.apple.com/app/id6805856690?action=write-review")!)
Link("Support", destination: URL(string: "https://termind.akinokaede.com/support/")!)
Link("Privacy Policy", destination: URL(string: "https://termind.akinokaede.com/privacy/")!)
}
}
.navigationTitle("App")
.onChange(of: language) { _, value in AppLanguage.store(AppLanguage(rawValue: value) ?? .system) }
}
}
+15
View File
@@ -41,6 +41,21 @@ struct MobileAssistantView: View {
}
.navigationTitle("Assistant")
.toolbar {
Menu {
Picker("Model", selection: Binding(get: { model.selectedModel }, set: { model.selectModel($0) })) {
Text("Automatic").tag(nil as AIModelRef?)
ForEach(app.ai.enabledModelProviders) { provider in
ForEach(provider.assistantModels) { item in
Text("\(provider.name) · \(item.id)")
.tag(AIModelRef(modelProviderID: provider.id, modelID: item.id) as AIModelRef?)
}
}
}
} label: {
Label("Model", systemImage: "cube")
}
.disabled(model.isRunning)
Button("Conversations", systemImage: "clock") { history = true }.disabled(model.isRunning)
Button("New Conversation", systemImage: "square.and.pencil") { model.newConversation() }.disabled(
model.isRunning)
@@ -0,0 +1,49 @@
import SwiftUI
import UIKit
struct MobileCommandHistoryView: View {
let workspace: MobileWorkspace
@Environment(\.dismiss) private var dismiss
@State private var query = ""
@State private var clearing = false
var body: some View {
NavigationStack {
List {
let _ = workspace.commandHistory.revision
ForEach(workspace.commandHistory.entries(for: workspace.descriptor.hostID, matching: query)) { entry in
VStack(alignment: .leading, spacing: 6) {
Text(entry.command).font(.system(.body, design: .monospaced)).textSelection(.enabled)
Text(entry.executedAt, format: .dateTime).font(.caption).foregroundStyle(.secondary)
HStack {
if let code = entry.exitStatus { LabeledContent("Exit Status", value: code.formatted()) }
if let duration = entry.duration {
Text(Duration.seconds(duration), format: .time(pattern: .minuteSecond))
}
}.font(.caption)
}
.contextMenu {
Button("Copy", systemImage: "doc.on.doc") { UIPasteboard.general.string = entry.command }
Button("Insert", systemImage: "text.insert") {
dismiss()
workspace.insertHistory(entry.command)
}.disabled(!workspace.connected)
Button("Delete", role: .destructive) { workspace.commandHistory.delete(entry.id) }
}
.swipeActions { Button("Delete", role: .destructive) { workspace.commandHistory.delete(entry.id) } }
}
if let error = workspace.commandHistory.storageError { Text(error).foregroundStyle(.red) }
}
.searchable(text: $query)
.navigationTitle("History")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("Done") { dismiss() } }
ToolbarItem(placement: .primaryAction) { Button("Clear", role: .destructive) { clearing = true } }
}
.confirmationDialog("Clear History?", isPresented: $clearing, titleVisibility: .visible) {
Button("Clear", role: .destructive) { workspace.commandHistory.clear(for: workspace.descriptor.hostID) }
}
}.presentationDetents([.medium, .large])
.onDisappear { workspace.terminal.requestFocus() }
}
}
@@ -0,0 +1,61 @@
import SwiftUI
import TermindApplication
import TermindPresentation
struct MobileConnectionJourneyView: View {
let journey: ConnectionJourney
let library: HostLibraryModel
let authenticationPrompt: SSHAuthenticationPrompt?
let hostKey: SSHHostKeyPrompt?
let submit: (UUID?, String, String?, String?, UUID?, Bool) -> Void
let accept: (Bool) -> Void
let retry: () -> Void
let cancel: () -> Void
var body: some View {
ScrollView {
VStack(spacing: 20) {
HStack(spacing: 12) {
Image(systemName: "lock.shield.fill").font(.title).foregroundStyle(Color.accentColor)
VStack(alignment: .leading) {
Text(journey.destination?.name ?? String(localized: "SSH Connection")).font(.headline)
Text(journey.destination?.address ?? "").font(.caption.monospaced()).foregroundStyle(.secondary)
}
Spacer()
Button(journey.showsLogs ? "Hide Logs" : "Show Logs") { journey.showsLogs.toggle() }
.font(.caption)
}.padding(.horizontal)
ConnectionJourneyRouteView(journey: journey)
Group {
if journey.showsLogs {
Text(journey.plainTextLog).font(.caption.monospaced()).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
} else if let authenticationPrompt {
SharedSSHAuthenticationForm(
prompt: authenticationPrompt, submit: submit, cancel: cancel, library: library
)
.id(authenticationPrompt.id)
} else if let hostKey {
SharedSSHHostKeyConfirmationForm(
prompt: hostKey, displayAddress: hostKey.observation.hostname,
proceed: { accept(false) }, trust: { accept(true) }, deny: cancel)
} else if case .failed(let message) = journey.phase {
VStack(spacing: 16) {
Label(message, systemImage: "exclamationmark.triangle").foregroundStyle(.red)
HStack {
Button("Cancel", action: cancel)
Spacer()
Button("Retry", action: retry).buttonStyle(.borderedProminent)
}
}
} else if journey.phase == .succeeded {
Label("Connection ready", systemImage: "checkmark.circle.fill").foregroundStyle(.green)
} else {
ProgressView("Establishing connection…")
Button("Cancel", action: cancel)
}
}.padding(.horizontal)
}.padding(.vertical, 20)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
+32 -20
View File
@@ -12,6 +12,18 @@ struct MobileCredentialsView: View {
var body: some View {
List {
Section("Keys") {
ForEach(app.credentials.keys) { key in
Button {
keyEditor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
} label: {
VStack(alignment: .leading) {
Text(key.label)
Text(key.fingerprint).font(.caption.monospaced()).foregroundStyle(.secondary)
}
}.swipeActions { Button("Delete", role: .destructive) { deletingKey = key } }
}
}
Section("Identities") {
ForEach(app.credentials.identities) { identity in
Button {
@@ -25,29 +37,18 @@ struct MobileCredentialsView: View {
.swipeActions { Button("Delete", role: .destructive) { deletingIdentity = identity } }
}
}
Section("Keys") {
ForEach(app.credentials.keys) { key in
Button {
keyEditor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
} label: {
VStack(alignment: .leading) {
Text(key.label)
Text(key.fingerprint).font(.caption.monospaced()).foregroundStyle(.secondary)
}
}.swipeActions { Button("Delete", role: .destructive) { deletingKey = key } }
}
}
}
.navigationTitle("Keychain")
.toolbar {
Menu {
Button("New Identity") { editing = Identity(name: "", username: "") }
Button("Generate Key") {
keyEditor = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
}
Button("Import Key") {
keyEditor = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials)
}
Divider()
Button("New Identity") { editing = Identity(name: "", username: "") }
} label: {
Label("Add", systemImage: "plus")
}
@@ -106,21 +107,33 @@ private struct MobileIdentityEditor: View {
MobileEditorSheet(
title: String(localized: "Identity"),
isDirty: draft != original || !password.isEmpty || removePassword,
canSave: !draft.name.isEmpty && !draft.username.isEmpty,
canSave: !draft.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
save: {
draft.username = draft.username.trimmingCharacters(in: .whitespacesAndNewlines)
draft.name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
if draft.name.isEmpty { draft.name = draft.username }
try app.library.upsert(
draft, passwordUpdate: removePassword ? .remove : password.isEmpty ? .keep : .replace(password))
},
content: {
Section("Identity") {
TextField("Name", text: $draft.name)
TextField("Username", text: $draft.username).textInputAutocapitalization(.never)
.autocorrectionDisabled()
LabeledContent("Name") { TextField("Name", text: $draft.name, prompt: Text(draft.username)) }
LabeledContent("Username") {
TextField("Username", text: $draft.username).textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
}
Section {
Picker("Color", selection: $draft.colorTag) {
ForEach(TagColor.allCases) { Text(LocalizedStringKey($0.rawValue.capitalized)).tag($0) }
}
}
Section("Authentication") {
SecureField("Password", text: $password).textContentType(.password)
LabeledContent("Password") {
SecureField("Password", text: $password).textContentType(.password).disabled(removePassword)
}
if original.usesPassword { Toggle("Remove Password", isOn: $removePassword) }
Picker("Storage", selection: $draft.passwordStorage) {
Picker("Store password in", selection: $draft.passwordStorage) {
Text("This Device").tag(PasswordStorage.thisDevice)
Text("iCloud").tag(PasswordStorage.iCloud)
}
@@ -129,7 +142,6 @@ private struct MobileIdentityEditor: View {
ForEach(app.credentials.keys) { Text($0.label).tag(Optional($0.id)) }
}
}
Section("Notes") { TextEditor(text: $draft.notes).frame(minHeight: 100) }
})
}
}
+7 -1
View File
@@ -17,7 +17,7 @@ struct MobileEditorSheet<Content: View>: View {
var body: some View {
NavigationStack {
Form { content().disabled(isSaving) }
Form { content().disabled(isSaving).textFieldStyle(MobileFormTextFieldStyle()) }
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -57,3 +57,9 @@ struct MobileEditorSheet<Content: View>: View {
.onDisappear { saveTask?.cancel() }
}
}
private struct MobileFormTextFieldStyle: TextFieldStyle {
func _body(configuration: TextField<Self._Label>) -> some View {
configuration.multilineTextAlignment(.trailing)
}
}
+22
View File
@@ -0,0 +1,22 @@
import SwiftUI
import TermindDomain
import TermindPresentation
/// Rules belong to the host draft and commit in its one Vault transaction.
struct MobileForwardingEditor: View {
let original: PortForwardingRule
let save: (PortForwardingRule) -> Void
@State private var draft: PortForwardingRule
init(rule: PortForwardingRule, save: @escaping (PortForwardingRule) -> Void) {
original = rule
self.save = save
_draft = State(initialValue: rule)
}
var body: some View {
MobileEditorSheet(
title: String(localized: "Port Forwarding"), isDirty: draft != original,
canSave: draft.isValid, save: { save(draft) }, content: { PortForwardingFields(rule: $draft) })
}
}
-80
View File
@@ -1,80 +0,0 @@
import SwiftUI
import TermindDomain
import TermindPresentation
struct MobileForwardingsView: View {
let app: MobileAppModel
@State private var editing: PortForwardingRule?
@State private var removing: PortForwardingRule?
@State private var error: String?
var body: some View {
List(app.forwardingLibrary.rules) { rule in
VStack(alignment: .leading, spacing: 8) {
Text(rule.label.isEmpty ? rule.bindAddress : rule.label).font(.headline)
Text(app.library.host(rule.hostID)?.name ?? "").font(.caption)
Text(app.forwardings.state(for: rule.id).label).font(.caption).foregroundStyle(.secondary)
HStack {
Button(app.forwardings.isEnabled(rule.id) ? String(localized: "Stop") : String(localized: "Start"))
{
if app.forwardings.isEnabled(rule.id) {
app.forwardings.stop(rule.id)
} else {
app.forwardings.start(rule.id)
}
}
Button("Edit") { editing = rule }
}.buttonStyle(.bordered)
}
.swipeActions { Button("Delete", role: .destructive) { removing = rule } }
}
.navigationTitle("Port Forwarding")
.toolbar {
Button("Add", systemImage: "plus") {
if let host = app.library.hosts.first { editing = PortForwardingRule(hostID: host.id) }
}.disabled(app.library.hosts.isEmpty)
}
.sheet(item: $editing) { MobileForwardingEditor(app: app, rule: $0) }
.confirmationDialog(
"Delete Forwarding Rule?",
isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }),
titleVisibility: .visible
) {
Button("Delete", role: .destructive) {
if let rule = removing {
do { try app.forwardingLibrary.delete(rule.id) } catch { self.error = error.localizedDescription }
}
removing = nil
}
}
.alert("Unable to Save", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) { error = nil }
} message: {
Text(error ?? "")
}
}
}
private struct MobileForwardingEditor: View {
let app: MobileAppModel
let original: PortForwardingRule
@State private var draft: PortForwardingRule
init(app: MobileAppModel, rule: PortForwardingRule) {
self.app = app
original = rule
_draft = State(initialValue: rule)
}
var body: some View {
MobileEditorSheet(
title: String(localized: "Port Forwarding"), isDirty: draft != original,
canSave: draft.isValid, save: { try app.forwardingLibrary.save(draft) },
content: {
Picker("Host", selection: $draft.hostID) {
ForEach(app.library.hosts) { Text($0.name).tag($0.id) }
}.disabled(app.forwardingLibrary.portForwarding(original.id) != nil)
PortForwardingFields(rule: $draft)
})
}
}
+74 -7
View File
@@ -6,24 +6,35 @@ import TermindPresentation
import struct TermindDomain.Host
struct MobileHostEditor: View {
let library: HostLibraryModel
let app: MobileAppModel
private var library: HostLibraryModel { app.library }
let original: Host
@State private var draft: Host
private let originalForwardings: [PortForwardingRule]
@State private var draftForwardings: [PortForwardingRule]
@State private var editingForwarding: PortForwardingRule?
init(library: HostLibraryModel, host: Host) {
self.library = library
init(app: MobileAppModel, host: Host) {
self.app = app
let library = app.library
original = host
_draft = State(initialValue: host)
originalForwardings = library.portForwardings(for: host.id)
_draftForwardings = State(initialValue: library.portForwardings(for: host.id))
}
private var savedRules: [PortForwardingRule] {
draftForwardings.filter { app.forwardingLibrary.portForwarding($0.id) == $0 }
}
var body: some View {
MobileEditorSheet(
title: String(localized: "Host"), isDirty: draft != original,
title: String(localized: "Host"), isDirty: draft != original || draftForwardings != originalForwardings,
canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (1...65535).contains(draft.port),
&& (1...65535).contains(draft.port) && draftForwardings.allSatisfy(\.isValid),
save: {
if draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draft.name = draft.host }
_ = library.upsert(draft, replacingPortForwardings: library.portForwardings(for: draft.id))
_ = library.upsert(draft, replacingPortForwardings: draftForwardings)
if let error = library.storageError { throw MobileEditorError.message(error) }
},
content: {
@@ -40,13 +51,69 @@ struct MobileHostEditor: View {
ForEach(library.hosts.filter { $0.id != draft.id }) { Text($0.name).tag(Optional($0.id)) }
}
}
Section("Port Forwarding") {
ForEach(draftForwardings) { rule in
HStack {
Button {
editingForwarding = rule
} label: {
VStack(alignment: .leading) {
Text(rule.label.isEmpty ? rule.bindAddress : rule.label)
Text(app.forwardings.state(for: rule.id).label).font(.caption).foregroundStyle(
.secondary)
}
}
Spacer()
Button(
app.forwardings.isEnabled(rule.id)
? String(localized: "Stop") : String(localized: "Start")
) {
if app.forwardings.isEnabled(rule.id) {
app.forwardings.stop(rule.id)
} else {
app.forwardings.start(rule.id)
}
}.disabled(app.forwardingLibrary.portForwarding(rule.id) != rule)
}
.contextMenu {
Button("Duplicate") {
var copy = rule
copy.id = UUID()
draftForwardings.append(copy)
}
Button("Delete", role: .destructive) { draftForwardings.removeAll { $0.id == rule.id } }
}
}
Button("Add Port Forward", systemImage: "plus") {
editingForwarding = PortForwardingRule(hostID: draft.id)
}
HStack {
Spacer()
Button("Start All") { app.forwardings.start(savedRules.map(\.id)) }.disabled(savedRules.isEmpty)
Button("Stop All") { app.forwardings.stop(savedRules.map(\.id)) }.disabled(savedRules.isEmpty)
}
Text(
"Forwarding uses a background SSH connection and stops when Termind quits. Save changes before starting a rule."
)
.font(.caption).foregroundStyle(.secondary)
}
Section("Group") {
Picker("Group", selection: $draft.groupID) {
Text("None").tag(UUID?.none)
ForEach(library.groups) { Text($0.name).tag(Optional($0.id)) }
}
}
})
}
)
.sheet(item: $editingForwarding) { rule in
MobileForwardingEditor(rule: rule) { updated in
if let index = draftForwardings.firstIndex(where: { $0.id == updated.id }) {
draftForwardings[index] = updated
} else {
draftForwardings.append(updated)
}
}
}
}
}
+51 -6
View File
@@ -1,10 +1,12 @@
import SwiftUI
import TermindDomain
import TermindRemoteInfrastructure
import UniformTypeIdentifiers
struct MobileKeyEditor: View {
@Bindable var model: MobileKeyEditorModel
@State private var choosingFile = false
@State private var choosingCertificate = false
@State private var fileError: String?
var body: some View {
@@ -13,9 +15,12 @@ struct MobileKeyEditor: View {
save: { try await model.save() },
content: {
Section("Key") {
TextField("Name", text: $model.label)
LabeledContent("Name") { TextField("Name", text: $model.label, prompt: Text(model.defaultName)) }
Picker("Color", selection: $model.colorTag) {
ForEach(TagColor.allCases) { Text(LocalizedStringKey($0.rawValue.capitalized)).tag($0) }
}
if !model.isEnclave {
Picker("Storage", selection: $model.storage) {
Picker("Store in", selection: $model.storage) {
Text("This Device").tag(KeyStorage.thisDevice)
Text("iCloud").tag(KeyStorage.iCloud)
if model.mode == .generate && model.canUseEnclave {
@@ -30,24 +35,57 @@ struct MobileKeyEditor: View {
Picker("Algorithm", selection: $model.algorithm) {
ForEach(MobileKeyEditorModel.Algorithm.allCases) { Text($0.rawValue).tag($0) }
}
SecureField("Passphrase", text: $model.passphrase)
if model.algorithm == .ecdsa {
Picker("Elliptic Curve", selection: $model.curveBits) {
Text("NIST P-256").tag(256)
Text("NIST P-384").tag(384)
Text("NIST P-521").tag(521)
}
} else if model.algorithm == .rsa {
Picker("Key Size", selection: $model.rsaBits) {
Text("2048 bit").tag(2048)
Text("3072 bit").tag(3072)
Text("4096 bit").tag(4096)
}
}
LabeledContent("Passphrase") { SecureField("Optional", text: $model.passphrase) }
LabeledContent("Confirm Passphrase") {
SecureField("Confirm Passphrase", text: $model.confirmation).disabled(
model.passphrase.isEmpty)
}
if !model.passphrase.isEmpty {
Picker("Cipher", selection: $model.cipher) {
ForEach(SSHKeyMaterial.Cipher.allCases) { Text($0.displayName).tag($0) }
}
LabeledContent("Rounds") {
TextField("Rounds", value: $model.rounds, format: .number).keyboardType(.numberPad)
}
if model.passphrase != model.confirmation {
Text("Passphrases do not match.").foregroundStyle(.red)
}
}
}
}
}
if model.mode == .importKey {
Section("Private Key") {
Button("Choose File") { choosingFile = true }
TextEditor(text: $model.privateKey).font(.body.monospaced()).frame(minHeight: 150)
TextEditor(text: $model.privateKey).multilineTextAlignment(.leading).font(.body.monospaced())
.frame(minHeight: 150)
.textInputAutocapitalization(.never).autocorrectionDisabled()
SecureField("Passphrase", text: $model.passphrase)
}
}
if model.storage != .secureEnclave {
if model.mode != .generate && model.storage != .secureEnclave {
Section("SSH Certificate") {
TextEditor(text: $model.certificate).font(.caption.monospaced()).frame(minHeight: 90)
Button("Choose File") { choosingCertificate = true }
TextEditor(text: $model.certificate).multilineTextAlignment(.leading).font(
.caption.monospaced()
).frame(minHeight: 90)
.textInputAutocapitalization(.never).autocorrectionDisabled()
}
}
if let warning = model.certificateWarning { Text(warning).font(.caption).foregroundStyle(.orange) }
if let publicKey = model.publicKey {
Section("Public Key") { Text(publicKey).font(.caption.monospaced()).textSelection(.enabled) }
}
@@ -61,6 +99,13 @@ struct MobileKeyEditor: View {
do { try await model.loadFile(result.get()) } catch { fileError = error.localizedDescription }
}
}
.fileImporter(isPresented: $choosingCertificate, allowedContentTypes: [.data]) { result in
Task {
do { try await model.loadCertificateFile(result.get()) } catch {
fileError = error.localizedDescription
}
}
}
.alert(
"Unable to Read File", isPresented: Binding(get: { fileError != nil }, set: { if !$0 { fileError = nil } })
) {
+178 -16
View File
@@ -1,35 +1,67 @@
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
struct MobileMetricsView: View {
let app: MobileAppModel
@State private var selecting = false
@State private var selectedID: UUID?
@State private var query = ""
var body: some View {
List {
ForEach(app.metrics.selections) { selection in
Section(app.library.host(selection.id)?.name ?? "") {
if let point = app.metrics.points[selection.id] {
MetricsSummaryView(point: point)
} else if let error = app.metrics.errors[selection.id] {
Text(error).foregroundStyle(.secondary)
} else {
ProgressView()
}
Button("Remove", role: .destructive) { app.metrics.remove(selection.id) }
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 310), spacing: 12)], spacing: 12) {
ForEach(app.metrics.servers.filter { query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) })
{ server in
Button {
selectedID = server.id
} label: {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text(server.name).font(.subheadline.bold()).lineLimit(1)
Spacer()
Image(systemName: "chevron.right").font(.caption).foregroundStyle(.secondary)
}
MetricsInstrumentBody(server: server, sleeping: app.metrics.core.sleeping)
}
.padding(16).frame(maxWidth: .infinity)
.background(.background, in: RoundedRectangle(cornerRadius: 12))
.overlay { RoundedRectangle(cornerRadius: 12).strokeBorder(.quaternary) }
.contentShape(.rect)
}.buttonStyle(.plain)
.contextMenu {
Button(
server.selection.enabled ? "Pause" : "Resume",
systemImage: server.selection.enabled ? "pause" : "play"
) {
app.metrics.core.setEnabled(!server.selection.enabled, for: server)
}
Button("Retry", systemImage: "arrow.clockwise") { app.metrics.core.retry(server) }
Button("Remove", role: .destructive) { app.metrics.remove(server.id) }
}
}
}.padding()
if app.metrics.servers.isEmpty {
ContentUnavailableView(
"No monitored servers", systemImage: "waveform.path.ecg",
description: Text("Add a saved Linux server to start monitoring."))
}
}
.navigationTitle("Metrics")
.toolbar { Button("Add", systemImage: "plus") { selecting = true } }
.searchable(text: $query)
.toolbar { Button("Add Server", systemImage: "plus") { selecting = true } }
.navigationDestination(item: $selectedID) { id in
if let server = app.metrics.servers.first(where: { $0.id == id }) {
MobileMetricsDetailView(app: app, server: server)
}
}
.sheet(isPresented: $selecting) {
NavigationStack {
List(app.library.hosts) { host in
Button(host.name) {
selecting = false
if app.metrics.add(host.id, pro: app.pro.hasProAccess) {
app.metrics.start(hosts: app.library)
} else {
if !app.metrics.add(host.id, pro: app.pro.hasProAccess) {
app.pro.presentPaywall(for: .unlimitedMetrics)
}
}
@@ -38,6 +70,136 @@ struct MobileMetricsView: View {
.toolbar { Button("Cancel") { selecting = false } }
}.presentationDetents([.medium, .large])
}
.task { app.metrics.start(hosts: app.library) }
.task { app.metrics.start() }
}
}
private struct MobileMetricsDetailView: View {
let app: MobileAppModel
let server: ServerMetric
@State private var page = MobileMetricsDetailPage.status
@State private var observer = UUID()
@State private var availableWidth: CGFloat = 0
@State private var query = ""
var body: some View {
Group {
if server.showsConnectionJourney {
MobileConnectionJourneyView(
journey: server.journey, library: app.library,
authenticationPrompt: server.authenticationPrompt, hostKey: server.hostKeyPrompt,
submit: { identity, username, password, passphrase, key, save in
app.metrics.core.submitAuthentication(
server, identityID: identity, username: username,
password: password, passphrase: passphrase, keyID: key, save: save)
},
accept: { remember in
if let observation = server.hostKey {
app.metrics.core.acceptHostKey(server, observation: observation, remember: remember)
}
}, retry: { app.metrics.core.retry(server) },
cancel: { app.metrics.core.setEnabled(false, for: server) })
} else {
GeometryReader { geometry in
if MobileMetricsDetailPage.showsBothColumns(width: geometry.size.width) {
HStack(alignment: .top, spacing: 16) {
ScrollView { MetricsSystemPanels(server: server).padding(1) }
.frame(width: min(360, geometry.size.width * 0.4))
MobileMetricsProcessesView(server: server, query: $query)
}.padding(16)
} else if page == .status {
ScrollView { MetricsSystemPanels(server: server).padding() }
} else {
MobileMetricsProcessesView(server: server, query: $query)
}
}
}
}
.onGeometryChange(for: CGFloat.self) {
$0.size.width
} action: {
availableWidth = $0
}
.navigationTitle(server.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .principal) {
if !MobileMetricsDetailPage.showsBothColumns(width: availableWidth) {
Picker("Details", selection: $page) {
Image(systemName: "gauge.with.dots.needle.33percent").accessibilityLabel("Status").tag(
MobileMetricsDetailPage.status)
Image(systemName: "list.bullet.rectangle").accessibilityLabel("Processes").tag(
MobileMetricsDetailPage.processes)
}.pickerStyle(.segmented).frame(width: 104)
}
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button(server.selection.enabled ? "Pause" : "Resume") {
app.metrics.core.setEnabled(!server.selection.enabled, for: server)
}
Button("Retry") { app.metrics.core.retry(server) }
} label: {
Image(systemName: "ellipsis")
}
}
}
.onAppear { observe() }
.onChange(of: server.showsConnectionJourney) { observe() }
.onDisappear { app.metrics.core.stopObservingDetails(observer) }
}
private func observe() {
if server.showsConnectionJourney {
app.metrics.core.stopObservingDetails(observer)
} else {
app.metrics.core.observeDetails(hostID: server.id, observer: observer)
}
}
}
nonisolated enum MobileMetricsDetailPage: CaseIterable {
case status, processes
static func showsBothColumns(width: CGFloat) -> Bool { width >= 700 }
}
private struct MobileMetricsProcessesView: View {
let server: ServerMetric
@Binding var query: String
@State private var sort = ProcessSort.cpu
enum ProcessSort: String, CaseIterable { case cpu, memory, pid }
private var rows: [MetricsProcess] {
let filtered = server.processes.filter {
query.isEmpty || $0.name.localizedCaseInsensitiveContains(query) || String($0.pid).contains(query)
}
return filtered.sorted {
switch sort {
case .cpu: ($0.cpu ?? -1) > ($1.cpu ?? -1)
case .memory: ($0.memory ?? -1) > ($1.memory ?? -1)
case .pid: $0.pid < $1.pid
}
}
}
var body: some View {
List {
Picker("Sort By", selection: $sort) {
Text("CPU").tag(ProcessSort.cpu)
Text("Memory").tag(ProcessSort.memory)
Text("PID").tag(ProcessSort.pid)
}
if server.processesFailed { Text("Process metrics unavailable").foregroundStyle(.secondary) }
ForEach(rows) { process in
HStack {
Text(String(process.pid)).font(.caption.monospaced()).foregroundStyle(.secondary).frame(
width: 48, alignment: .leading)
Text(process.name).lineLimit(1)
Spacer(minLength: 4)
VStack(alignment: .trailing) {
Text(MetricsFormat.percent(process.cpu))
Text(MetricsFormat.bytes(process.memory)).foregroundStyle(.secondary)
}.font(.caption.monospaced())
}
}
}.searchable(text: $query)
}
}
+29 -48
View File
@@ -1,26 +1,24 @@
import SwiftUI
import UIKit
/// iPhone has four destinations. iPad retains the sidebar with individual library routes.
struct MobileRootView: View {
let app: MobileAppModel
@Environment(\.horizontalSizeClass) private var sizeClass
@Environment(\.scenePhase) private var scenePhase
@State private var sceneID = UUID()
@State private var tab: MobileTab = .home
@State private var selection: MobileDestination? = .welcome
@State private var tab: MobileTab = .vault
@State private var selection: MobileDestination? = .vault
@State private var workspaceID: UUID?
@State private var unavailable = false
@State private var showingWorkspaces = false
var body: some View {
Group {
if sizeClass == .compact {
if UIDevice.current.userInterfaceIdiom != .pad {
TabView(selection: $tab) {
NavigationStack { home }
.tabItem { Label("Welcome", systemImage: "house") }.tag(MobileTab.home)
NavigationStack { metrics }
.tabItem { Label("Metrics", systemImage: "gauge.with.dots.needle.33percent") }.tag(
MobileTab.metrics)
NavigationStack { vault }
.tabItem { Label("Vault", systemImage: "server.rack") }.tag(MobileTab.vault)
NavigationStack { workspaceList }
.tabItem { Label("Workspaces", systemImage: "terminal") }.tag(MobileTab.workspaces)
NavigationStack { assistant }
.tabItem { Label("Assistant", systemImage: "sparkles") }.tag(MobileTab.assistant)
NavigationStack { settings }
@@ -38,12 +36,12 @@ struct MobileRootView: View {
} detail: {
NavigationStack {
switch selection {
case .hosts: MobileVaultView(app: app, open: open)
case .vault: vault
case .assistant: assistant
case .settings: settings
case .metrics: metrics
case .workspaces: workspaceList
default: home
default: vault
}
}
}
@@ -53,17 +51,17 @@ struct MobileRootView: View {
MobileProView(pro: app.pro)
}
.onChange(of: scenePhase, initial: true) { _, phase in app.sceneChanged(sceneID, phase: phase) }
.onChange(of: app.pro.hasProAccess) { _, _ in app.reconcileSync() }
.onDisappear { app.removeScene(sceneID) }
.onOpenURL { url in
if WorkspaceActivityAttributes.accepts(url) {
tab = .home
tab = .workspaces
selection = .workspaces
workspaceID = nil
showingWorkspaces = sizeClass == .compact
return
}
guard let id = WorkspaceActivityAttributes.workspaceID(from: url) else { return }
tab = .home
tab = .workspaces
selection = .workspaces
if let workspace = app.workspaces.first(where: { $0.id == id }) {
workspaceID = id
@@ -96,41 +94,23 @@ struct MobileRootView: View {
}
private var home: some View {
List {
Section {
NavigationLink {
MobileVaultView(app: app, open: open)
} label: {
Label("Vault", systemImage: "server.rack")
}
if sizeClass != .compact {
NavigationLink {
metrics
} label: {
Label("Metrics", systemImage: "gauge.with.dots.needle.33percent")
}
}
}
Section("Workspaces") { workspaceRows }
}
.navigationTitle("Welcome")
.navigationDestination(isPresented: $showingWorkspaces) { workspaceList }
.navigationDestination(item: $workspaceID) { id in
if let workspace = app.workspaces.first(where: { $0.id == id }) {
MobileWorkspaceView(workspace: workspace)
}
}
private var vault: some View {
MobileVaultView(app: app, open: open)
}
private var workspaceList: some View {
List { workspaceRows }
.navigationTitle("Workspaces")
.navigationDestination(item: $workspaceID) { id in
if let workspace = app.workspaces.first(where: { $0.id == id }) {
MobileWorkspaceView(workspace: workspace)
}
List {
Section {
NavigationLink("Transfers") { MobileTransfersView(transfers: app.transfers) }
}
Section("Workspaces") { workspaceRows }
}
.navigationTitle("Workspaces")
.navigationDestination(item: $workspaceID) { id in
if let workspace = app.workspaces.first(where: { $0.id == id }) {
MobileWorkspaceView(library: app.library, workspace: workspace)
}
}
}
@ViewBuilder private var workspaceRows: some View {
@@ -169,10 +149,11 @@ struct MobileRootView: View {
private func open(_ hostID: UUID, _ kind: MobileWorkspaceDescriptor.Kind) {
workspaceID = app.open(hostID: hostID, kind: kind)
if sizeClass != .compact { selection = .workspaces }
tab = .workspaces
selection = .workspaces
}
}
nonisolated enum MobileTab: String, CaseIterable {
case home, metrics, assistant, settings
case vault, workspaces, assistant, settings
}
-141
View File
@@ -1,141 +0,0 @@
import SwiftUI
import TermindApplication
import TermindDomain
import UniformTypeIdentifiers
struct MobileSSHImportView: View {
@Bindable var model: SSHImportModel
@Environment(\.dismiss) private var dismiss
@State private var choosingSource = false
@State private var knownHostsOnly = false
@State private var authorizing = false
@State private var unlockID: UUID?
@State private var passphrase = ""
@State private var fileError: String?
var body: some View {
NavigationStack {
List {
Section {
Toggle("Known Hosts Only", isOn: $knownHostsOnly).disabled(!model.items.isEmpty)
Button("Choose SSH Files") { choosingSource = true }
if let path = model.sourcePath { Text(path).font(.caption).foregroundStyle(.secondary) }
Picker("Key Storage", selection: $model.storage) {
Text("This Device").tag(KeyStorage.thisDevice)
Text("iCloud").tag(KeyStorage.iCloud)
}
}
ForEach(SSHImportCategory.allCases) { category in
Section(category.title) {
ForEach($model.items) { $item in
if item.category == category { row($item) }
}
}
}
if let error = model.error { Text(error).foregroundStyle(.red) }
if let error = fileError { Text(error).foregroundStyle(.red) }
}
.disabled(model.isWorking)
.navigationTitle("Import SSH Files")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
Task {
await model.discard()
dismiss()
}
}.disabled(model.isWorking)
}
ToolbarItem(placement: .confirmationAction) {
if model.isWorking {
ProgressView()
} else {
Button("Import") {
Task {
await model.commit()
if model.completed { dismiss() }
}
}
.disabled(!model.canImport)
}
}
}
}
.presentationDetents([.large])
.interactiveDismissDisabled(model.isWorking)
.onDisappear { Task { await model.discard() } }
.fileImporter(isPresented: $choosingSource, allowedContentTypes: knownHostsOnly ? [.data] : [.folder]) {
result in
Task {
do { await model.scan(try result.get(), knownHostsOnly: knownHostsOnly) } catch {
fileError = error.localizedDescription
}
}
}
.fileImporter(isPresented: $authorizing, allowedContentTypes: [.data, .folder]) { result in
Task {
do { await model.authorize(try result.get()) } catch { fileError = error.localizedDescription }
}
}
.alert(
"Passphrase",
isPresented: Binding(
get: { unlockID != nil },
set: {
if !$0 {
unlockID = nil
passphrase = ""
}
})
) {
SecureField("Passphrase", text: $passphrase)
Button("Unlock") {
guard let id = unlockID else { return }
let value = passphrase
passphrase = ""
unlockID = nil
Task { await model.unlock(id, passphrase: value) }
}
Button("Cancel", role: .cancel) {
passphrase = ""
unlockID = nil
}
}
}
@ViewBuilder private func row(_ item: Binding<SSHImportItem>) -> some View {
let value = item.wrappedValue
VStack(alignment: .leading, spacing: 8) {
Toggle(value.name, isOn: item.selected).disabled(!model.selectable(value))
if value.host != nil {
TextField("Name", text: item.name)
TextField(
"Username",
text: Binding(
get: { item.wrappedValue.host?.username ?? "" }, set: { item.wrappedValue.host?.username = $0 })
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
let keys = model.keyOptions(for: value)
if keys.count > 1 {
Picker("Key", selection: item.preferredKeyID) {
Text("Automatic").tag(UUID?.none)
ForEach(keys) { Text($0.label).tag(Optional($0.id)) }
}
}
}
if let problem = model.problem(for: value) { Text(problem).font(.caption).foregroundStyle(.secondary) }
if value.requiresPassphrase { Button("Unlock Key") { unlockID = value.id } }
if value.needsAuthorization != nil { Button("Choose Referenced File") { authorizing = true } }
if !model.warnings(for: value).isEmpty {
ForEach(model.warnings(for: value), id: \.self) { Text($0).font(.caption) }
Toggle("Accept Import Warnings", isOn: item.acceptedWarnings)
}
}
}
}
struct MobileSSHImportScreen: View {
@State private var model: SSHImportModel
init(app: MobileAppModel) { _model = State(initialValue: app.makeSSHImportModel()) }
var body: some View { MobileSSHImportView(model: model) }
}
+5 -20
View File
@@ -2,29 +2,14 @@ import SwiftUI
struct MobileSettingsView: View {
let app: MobileAppModel
@AppStorage("mobile.appearance") private var appearance = "system"
var body: some View {
Form {
Section { Button("Termind Pro") { app.pro.presentPaywall() } }
Section { NavigationLink("AI") { MobileAISettingsView(ai: app.ai) } }
Section("App") {
Picker("Appearance", selection: $appearance) {
Text("System").tag("system")
Text("Light").tag("light")
Text("Dark").tag("dark")
}
LabeledContent(
"Version",
value: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0")
}
Section("Live Activities") {
Button("Show Workspace Live Activity") { app.liveActivities.start(app.workspaces) }
Text(
"One Live Activity summarizes all workspaces. Tap it to open the workspace list. Live Activities do not keep SSH running in the background."
)
.font(.caption).foregroundStyle(.secondary)
if let error = app.liveActivities.error { Text(error).foregroundStyle(.red) }
Section {
NavigationLink("App") { MobileAppSettingsView() }
NavigationLink("Terminal") { MobileTerminalSettingsView(model: app.terminalSettings) }
NavigationLink("AI") { MobileAISettingsView(ai: app.ai) }
NavigationLink("Sync") { MobileSyncSettingsView(app: app) }
}
}.navigationTitle("Settings")
}
+4 -2
View File
@@ -45,8 +45,10 @@ private struct MobileSnippetEditor: View {
},
content: {
TextField("Title", text: $draft.title)
TextEditor(text: $draft.body).font(.body.monospaced()).frame(minHeight: 220)
.textInputAutocapitalization(.never).autocorrectionDisabled()
TextEditor(text: $draft.body).multilineTextAlignment(.leading).font(.body.monospaced()).frame(
minHeight: 220
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
})
}
}
+70
View File
@@ -0,0 +1,70 @@
import SwiftUI
import TermindApplication
import TermindDomain
struct MobileSyncSettingsView: View {
let app: MobileAppModel
@AppStorage(SharedSettingsKey.iCloudSyncEnabled) private var enabled = true
@AppStorage(SharedSettingsKey.vaultSyncEnabled) private var vault = true
@AppStorage(SharedSettingsKey.settingsSyncEnabled) private var settings = false
var body: some View {
Form {
Section {
Toggle(
"iCloud Sync",
isOn: selection(enabled && app.pro.canUse(.vaultSync), SharedSettingsKey.iCloudSyncEnabled))
}
Section {
Toggle("Vault", isOn: selection(vault, SharedSettingsKey.vaultSyncEnabled))
if vault {
status(
app.vaultSync?.status, retry: { app.vaultSync?.retry() },
merge: { app.vaultSync?.confirmAccountSwitchMerge() })
}
Text("Hosts, identities, snippets, known hosts, and port forwards.").font(.caption).foregroundStyle(
.secondary)
}.disabled(!enabled || !app.pro.canUse(.vaultSync))
Section {
Toggle("Settings", isOn: selection(settings, SharedSettingsKey.settingsSyncEnabled))
if settings {
status(
app.settingsSync?.status, retry: { app.settingsSync?.retry() },
merge: { app.settingsSync?.confirmAccountSwitchMerge() })
}
Text("Terminal and AI preferences.").font(.caption).foregroundStyle(.secondary)
}.disabled(!enabled || !app.pro.canUse(.vaultSync))
if settings {
Text("API keys use iCloud Keychain, even when configuration sync is paused.")
.font(.caption).foregroundStyle(.secondary)
}
}.navigationTitle("Sync")
}
private func selection(_ value: Bool, _ key: String) -> Binding<Bool> {
Binding(get: { value }, set: { app.setSyncEnabled($0, key: key) })
}
@ViewBuilder
private func status(_ value: VaultSyncStatus?, retry: @escaping () -> Void, merge: @escaping () -> Void)
-> some View
{
if let value {
switch value {
case .checkingAccount: Label("Checking iCloud…", systemImage: "icloud")
case .paused: Label("Paused", systemImage: "pause.circle")
case .unavailable(let message): Text(message)
case .accountChangeConfirmationRequired:
Text("Waiting for confirmation")
Button("Merge with This iCloud Account", action: merge)
case .syncing(let count): Text("Syncing \(count) changes…")
case .upToDate(let date):
Label("Up to date", systemImage: "checkmark.icloud")
if let date { Text(date, format: .dateTime).font(.caption).foregroundStyle(.secondary) }
case .failed(let message):
Text(message).foregroundStyle(.red)
Button("Retry", action: retry)
}
}
}
}
+7 -2
View File
@@ -33,13 +33,18 @@ struct MobileTerminalKeyboard: View {
}.background(.bar)
.onChange(of: state.isFocused, initial: true) { _, _ in
state.attachedPlatformView?.setStickyModifierChangeHandler {
control = state.attachedPlatformView?.stickyActivation(for: .ctrl) ?? .inactive
alt = state.attachedPlatformView?.stickyActivation(for: .alt) ?? .inactive
refreshModifiers()
}
refreshModifiers()
}
.onDisappear { state.attachedPlatformView?.setStickyModifierChangeHandler(nil) }
}
private func refreshModifiers() {
control = state.attachedPlatformView?.stickyActivation(for: .ctrl) ?? .inactive
alt = state.attachedPlatformView?.stickyActivation(for: .alt) ?? .inactive
}
private func modifier(
_ title: String, _ modifier: TerminalPublicStickyModifier,
activation: TerminalPublicStickyActivation
@@ -0,0 +1,66 @@
import SwiftUI
import TermindApplication
import TermindDomain
struct MobileTerminalSettingsView: View {
let model: TerminalSettingsModel
@State private var editingDark: Bool?
@State private var customText = ""
var body: some View {
Form {
Section("Font") {
Toggle("Follow Theme", isOn: binding(\.fontFollowTheme))
Stepper(value: binding(\.fontSize), in: 8...36, step: 1) {
LabeledContent("Font Size", value: model.preferences.fontSize.formatted())
}.disabled(model.preferences.fontFollowTheme)
}
themeSection(dark: false)
themeSection(dark: true)
Section { Toggle("Shell Integration", isOn: binding(\.shellIntegration)) }
if let error = model.storageError { Text(error).foregroundStyle(.red) }
}
.navigationTitle("Terminal")
.sheet(isPresented: Binding(get: { editingDark != nil }, set: { if !$0 { editingDark = nil } })) {
if let dark = editingDark {
let original =
dark ? model.preferences.darkCustomConfiguration : model.preferences.lightCustomConfiguration
MobileEditorSheet(
title: String(localized: "Custom Configuration"), isDirty: customText != original,
save: {
guard model.set(customText, for: dark ? \.darkCustomConfiguration : \.lightCustomConfiguration)
else {
throw MobileEditorError.message(model.storageError ?? "")
}
},
content: {
TextEditor(text: $customText).multilineTextAlignment(.leading).font(
.system(.body, design: .monospaced)
)
.textInputAutocapitalization(.never).autocorrectionDisabled().frame(minHeight: 280)
.accessibilityLabel("Custom Configuration")
})
}
}
}
private func themeSection(dark: Bool) -> some View {
Section(dark ? String(localized: "Dark") : String(localized: "Light")) {
Picker("Theme", selection: binding(dark ? \.darkTheme : \.lightTheme)) {
ForEach(TermindTerminalThemeCatalog.allThemes.filter { $0.isDark == dark }, id: \.name) {
Text(verbatim: $0.name).tag($0.name)
}
}
Toggle("Custom Configuration", isOn: binding(dark ? \.darkCustomEnabled : \.lightCustomEnabled))
Button("Edit Configuration") {
customText =
dark ? model.preferences.darkCustomConfiguration : model.preferences.lightCustomConfiguration
editingDark = dark
}
}
}
private func binding<Value>(_ path: WritableKeyPath<TerminalPreferences, Value>) -> Binding<Value> {
Binding(get: { model.preferences[keyPath: path] }, set: { _ = model.set($0, for: path) })
}
}
+12 -12
View File
@@ -12,16 +12,14 @@ struct MobileVaultView: View {
@State private var deletingHost: Host?
@State private var groupName = ""
@State private var addingGroup = false
@State private var importingSSH = false
var body: some View {
List {
Section { NavigationLink("Metrics") { MobileMetricsView(app: app) } }
Section {
NavigationLink("Keychain") { MobileCredentialsView(app: app) }
NavigationLink("Snippets") { MobileSnippetsView(app: app) }
NavigationLink("Known Hosts") { MobileKnownHostsView(app: app) }
NavigationLink("Port Forwarding") { MobileForwardingsView(app: app) }
NavigationLink("Transfers") { MobileTransfersView(transfers: app.transfers) }
}
if app.library.hosts.isEmpty {
ContentUnavailableView(
@@ -44,14 +42,12 @@ struct MobileVaultView: View {
Menu {
Button("New Host", systemImage: "server.rack") { editingHost = Host(name: "", host: "") }
Button("New Group", systemImage: "folder.badge.plus") { addingGroup = true }
Button("Import SSH Files", systemImage: "square.and.arrow.down") { importingSSH = true }
} label: {
Label("Add", systemImage: "plus")
}
}
}
.sheet(isPresented: $importingSSH) { MobileSSHImportScreen(app: app) }
.sheet(item: $editingHost) { MobileHostEditor(library: app.library, host: $0) }
.sheet(item: $editingHost) { MobileHostEditor(app: app, host: $0) }
.sheet(isPresented: $addingGroup) {
MobileEditorSheet(
title: String(localized: "New Group"), isDirty: !groupName.isEmpty,
@@ -77,11 +73,8 @@ struct MobileVaultView: View {
}
private func row(_ host: Host) -> some View {
Menu {
Button("SSH", systemImage: "terminal") { open(host.id, .ssh) }
Button("SFTP", systemImage: "folder") { open(host.id, .sftp) }
Button("Edit", systemImage: "pencil") { editingHost = host }
Button("Delete", systemImage: "trash", role: .destructive) { deletingHost = host }
Button {
open(host.id, .ssh)
} label: {
HStack {
HostSystemIcon(asset: LinuxDistributionIconCatalog.entry(for: host.detectedDistribution)?.asset)
@@ -92,6 +85,13 @@ struct MobileVaultView: View {
Spacer()
Image(systemName: "ellipsis").foregroundStyle(.secondary)
}
}
}.buttonStyle(.plain)
.contextMenu {
Button("SSH", systemImage: "terminal") { open(host.id, .ssh) }
Button("SFTP", systemImage: "folder") { open(host.id, .sftp) }
Button("Edit", systemImage: "pencil") { editingHost = host }
Button("Delete", systemImage: "trash", role: .destructive) { deletingHost = host }
}
}
}
+72 -46
View File
@@ -1,12 +1,16 @@
import GhosttyTerminal
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindRemoteInfrastructure
import UniformTypeIdentifiers
struct MobileWorkspaceView: View {
let library: HostLibraryModel
@Bindable var workspace: MobileWorkspace
@State private var showingHistory = false
@FocusState private var findFocused: Bool
@State private var importing = false
@State private var directoryName = ""
@State private var creatingDirectory = false
@@ -18,10 +22,22 @@ struct MobileWorkspaceView: View {
var body: some View {
VStack(spacing: 0) {
if workspace.descriptor.kind == .ssh {
TerminalSurfaceView(context: workspace.terminal)
.id(workspace.id)
.onTapGesture { workspace.terminal.requestFocus() }
MobileTerminalKeyboard(state: workspace.terminal)
ZStack {
VStack(spacing: 0) {
if workspace.isFindPresented { findBar }
TerminalSurfaceView(context: workspace.terminal)
.id(workspace.id)
.onChange(of: workspace.terminalSettings.preferences, initial: true) { _, value in
workspace.terminal.applyPreferences(value)
}
.onTapGesture { workspace.terminal.requestFocus() }
MobileTerminalKeyboard(state: workspace.terminal)
}.opacity(showsJourney ? 0 : 1)
.allowsHitTesting(!showsJourney).accessibilityHidden(showsJourney)
if showsJourney { connectionJourney.background(.background) }
}
} else if showsJourney {
connectionJourney
} else {
List {
Button("Parent Directory", systemImage: "arrow.up") {
@@ -64,6 +80,28 @@ struct MobileWorkspaceView: View {
.navigationTitle(workspace.descriptor.title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if workspace.descriptor.kind == .ssh {
Button("Find", systemImage: "magnifyingglass") {
workspace.presentFind()
findFocused = true
}
.keyboardShortcut("f", modifiers: .command)
Button("History", systemImage: "clock") { showingHistory = true }
Menu {
Button("Next Match") {
workspace.navigateFind(previous: false)
findFocused = true
}
.keyboardShortcut("g", modifiers: .command)
Button("Previous Match") {
workspace.navigateFind(previous: true)
findFocused = true
}
.keyboardShortcut("g", modifiers: [.command, .shift])
} label: {
Image(systemName: "ellipsis")
}
}
if workspace.descriptor.kind == .sftp {
Button("Transfers", systemImage: "arrow.up.arrow.down") { showingTransfers = true }
Menu {
@@ -74,6 +112,7 @@ struct MobileWorkspaceView: View {
}.disabled(!workspace.connected)
}
}
.sheet(isPresented: $showingHistory) { MobileCommandHistoryView(workspace: workspace) }
.sheet(item: $renaming) { MobileRemoteFileEditor(workspace: workspace, file: $0, mode: .rename) }
.sheet(item: $editingPermissions) { MobileRemoteFileEditor(workspace: workspace, file: $0, mode: .permissions) }
.sheet(isPresented: $showingTransfers) {
@@ -110,51 +149,10 @@ struct MobileWorkspaceView: View {
deleting = nil
}
}
.alert(
"Trust Host Key?",
isPresented: Binding(
get: { workspace.pendingHostKey != nil }, set: { if !$0 { workspace.pendingHostKey = nil } })
) {
Button("Trust") { workspace.trustHostKey() }
Button("Cancel", role: .cancel) { workspace.pendingHostKey = nil }
} message: {
Text(
workspace.pendingHostKey.map {
"\($0.hostname):\($0.port)\n\($0.algorithm.rawValue)\n\($0.fingerprint)"
} ?? "")
}
.sheet(isPresented: $workspace.authenticationRequired) {
NavigationStack {
Form {
TextField("Username", text: $workspace.username).textInputAutocapitalization(.never)
.autocorrectionDisabled()
Text(workspace.authenticationHostName).font(.headline)
SecureField("Password", text: $workspace.password)
SecureField("Passphrase", text: $workspace.passphrase)
}
.navigationTitle("Authentication")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
workspace.cancelAuthentication()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Connect") {
workspace.submitAuthentication()
}
}
}
}.presentationDetents([.medium, .large])
.onDisappear {
workspace.password = ""
workspace.passphrase = ""
}
}
.alert(
"Connection Error",
isPresented: Binding(
get: { workspace.error != nil && !workspace.authenticationRequired },
get: { workspace.error != nil && !workspace.authenticationRequired && !workspace.journey.isPresented },
set: { if !$0 { workspace.error = nil } })
) {
Button("OK", role: .cancel) { workspace.error = nil }
@@ -162,4 +160,32 @@ struct MobileWorkspaceView: View {
Text(workspace.error ?? "")
}
}
private var showsJourney: Bool {
workspace.journey.isPresented || workspace.authenticationRequired || workspace.pendingHostKey != nil
}
private var connectionJourney: some View {
MobileConnectionJourneyView(
journey: workspace.journey, library: library,
authenticationPrompt: workspace.authenticationPrompt, hostKey: workspace.hostKeyPrompt,
submit: { identity, username, password, passphrase, key, save in
workspace.submitAuthentication(
identityID: identity, username: username, password: password,
passphrase: passphrase, keyID: key, save: save)
}, accept: { workspace.trustHostKey(remember: $0) }, retry: { workspace.connect() },
cancel: { workspace.disconnect() })
}
private var findBar: some View {
HStack {
TextField("Find", text: Binding(get: { workspace.findQuery }, set: { workspace.updateFindQuery($0) }))
.textInputAutocapitalization(.never).autocorrectionDisabled().focused($findFocused)
.onSubmit { workspace.navigateFind(previous: true) }
Button("Previous Match", systemImage: "chevron.up") { workspace.navigateFind(previous: true) }
Button("Next Match", systemImage: "chevron.down") { workspace.navigateFind(previous: false) }
Button("Close", systemImage: "xmark") { workspace.dismissFind() }.keyboardShortcut(.escape, modifiers: [])
}.labelStyle(.iconOnly).padding(8).background(.bar)
.onAppear { findFocused = true }
}
}
+27 -9
View File
@@ -1,11 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>NSFaceIDUsageDescription</key><string>Authenticate to use your SSH keys.</string>
<key>TermindKeychainAccessGroup</key><string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>NSSupportsLiveActivities</key><true/>
<key>CFBundleURLTypes</key><array><dict>
<key>CFBundleURLName</key><string>com.akinokaede.termind.workspace</string>
<key>CFBundleURLSchemes</key><array><string>termind</string></array>
</dict></array>
</dict></plist>
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>com.akinokaede.termind.workspace</string>
<key>CFBundleURLSchemes</key>
<array>
<string>termind</string>
</array>
</dict>
</array>
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to use your SSH keys.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>TermindCloudKitEnvironment</key>
<string>$(ICLOUD_CONTAINER_ENVIRONMENT)</string>
<key>TermindKeychainAccessGroup</key>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>
@@ -0,0 +1,15 @@
import UIKit
/// CKSyncEngine owns subscription delivery; registering with APNs enables silent cloud notifications.
@MainActor
final class MobileCloudNotifications: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil {
application.registerForRemoteNotifications()
}
return true
}
}
+23
View File
@@ -0,0 +1,23 @@
import GhosttyKit
import TermindInfrastructure
import TermindRemoteInfrastructure
nonisolated enum MobileRuntimeVersions {
static let unknown = "Unknown"
static var ghostty: String {
let info = ghostty_info()
return decode(info.version, length: Int(info.version_len)) ?? unknown
}
static var libssh2: String { SSHRuntimeVersions.libssh2 }
static var openSSL: String { SSHRuntimeVersions.openSSL }
static func decode(_ pointer: UnsafePointer<CChar>?, length: Int) -> String? {
guard let pointer, length >= 0 else { return nil }
return String(
decoding: UnsafeRawBufferPointer(start: pointer, count: length),
as: UTF8.self
)
}
}
+115
View File
@@ -0,0 +1,115 @@
import Foundation
import GhosttyTerminal
import GhosttyTheme
import SwiftUI
import TermindDomain
typealias TerminalTheme = TermindTerminalPalette
typealias GhosttyCustomConfiguration = TerminalCustomConfiguration
nonisolated extension TermindTerminalPalette.RGB {
var color: Color { Color(.sRGB, red: r, green: g, blue: b, opacity: 1) }
}
nonisolated extension TermindTerminalPalette {
/// The same palette expressed in the format consumed by libghostty. The
/// SwiftUI chrome and the terminal grid therefore share one definition
/// instead of merely using similarly named colors.
var ghosttyDefinition: GhosttyThemeDefinition {
GhosttyThemeDefinition(
name: name,
background: background.hex,
foreground: foreground.hex,
cursorColor: cursor.hex,
cursorText: background.hex,
selectionBackground: selection.hex,
selectionForeground: foreground.hex,
palette: Dictionary(uniqueKeysWithValues: ansi.enumerated().map { ($0, $1.hex) })
)
}
}
nonisolated extension TerminalCustomConfiguration {
func applying(to base: GhosttyTerminal.TerminalConfiguration)
-> GhosttyTerminal.TerminalConfiguration
{
GhosttyTerminal.TerminalConfiguration(startingFrom: base) { builder in
for entry in entries {
builder.withCustom(entry.key, entry.value)
}
}
}
}
nonisolated enum TermindTerminalThemeCatalog {
static let allThemes: [GhosttyThemeDefinition] = {
let termindThemes = TerminalTheme.all.map(\.ghosttyDefinition)
let termindNames = Set(termindThemes.map(\.name))
return termindThemes + GhosttyThemeCatalog.allThemes.filter { !termindNames.contains($0.name) }
}()
static func theme(named name: String) -> GhosttyThemeDefinition? {
allThemes.first { $0.name == name }
}
static func darkTheme(named name: String) -> GhosttyThemeDefinition? {
theme(named: name).flatMap { $0.isDark ? $0 : nil }
}
static func lightTheme(named name: String) -> GhosttyThemeDefinition? {
theme(named: name).flatMap { $0.isDark ? nil : $0 }
}
static let defaultDark = TerminalTheme.termindDark.ghosttyDefinition
static let defaultLight = TerminalTheme.termindLight.ghosttyDefinition
static func terminalConfiguration(
themeName: String,
customEnabled: Bool,
customText: String,
isDark: Bool
) -> GhosttyTerminal.TerminalConfiguration {
let fallback = isDark ? defaultDark : defaultLight
let selected = theme(named: themeName).flatMap { $0.isDark == isDark ? $0 : nil } ?? fallback
let base = selected.toTerminalConfiguration()
guard customEnabled else { return base }
return GhosttyCustomConfiguration(customText).applying(to: base)
}
static func background(
themeName: String,
customEnabled: Bool,
customText: String,
isDark: Bool
) -> TerminalTheme.RGB {
let fallback = isDark ? defaultDark : defaultLight
let selected = theme(named: themeName).flatMap { $0.isDark == isDark ? $0 : nil } ?? fallback
if customEnabled,
let value = GhosttyCustomConfiguration(customText).lastValue(for: "background"),
let customBackground = TerminalTheme.RGB(hex: value)
{
return customBackground
}
return TerminalTheme.RGB(hex: selected.background)
?? (isDark ? TerminalTheme.termindDark.background : TerminalTheme.termindLight.background)
}
}
@MainActor
extension TerminalViewState {
func applyPreferences(_ value: TerminalPreferences) {
let light = TermindTerminalThemeCatalog.terminalConfiguration(
themeName: value.lightTheme,
customEnabled: value.lightCustomEnabled, customText: value.lightCustomConfiguration, isDark: false)
let dark = TermindTerminalThemeCatalog.terminalConfiguration(
themeName: value.darkTheme,
customEnabled: value.darkCustomEnabled, customText: value.darkCustomConfiguration, isDark: true)
setTheme(GhosttyTerminal.TerminalTheme(light: light, dark: dark))
setTerminalConfiguration(
GhosttyTerminal.TerminalConfiguration { builder in
if !value.fontFollowTheme { builder.withFontSize(Float(value.fontSize)) }
builder.withWindowPaddingX(8)
builder.withWindowPaddingY(8)
})
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>$(APS_ENVIRONMENT)</string>
<key>com.apple.developer.icloud-container-environment</key>
<string>$(ICLOUD_CONTAINER_ENVIRONMENT)</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.akinokaede.termind</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>keychain-access-groups</key>
<array>
<string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</plist>
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>keychain-access-groups</key>
<array><string>$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER)</string></array>
</dict>
</plist>
+13 -9
View File
@@ -1,5 +1,6 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
@@ -14,7 +15,7 @@ struct MobileAppTests {
let app = try MobileAppModel(inMemory: true)
#expect(try app.hosts.snapshot().value == .empty)
#expect(Set(app.container.schema.entities.map(\.name)) == Set(TermindPersistence.schema.entities.map(\.name)))
#expect(MobileTab.allCases == [.home, .metrics, .assistant, .settings])
#expect(MobileTab.allCases == [.vault, .workspaces, .assistant, .settings])
}
@Test func scenesCanUseTheSameAppRepository() throws {
@@ -56,14 +57,17 @@ struct MobileWorkspaceTests {
}
@Test func freeMetricsLimitCountsAllSelectionsAndDeduplicates() {
let metrics = MobileMetricsModel(defaults: nil)
let id = UUID()
#expect(metrics.add(id, pro: false))
#expect(metrics.add(id, pro: false))
#expect(metrics.add(UUID(), pro: false))
#expect(metrics.add(UUID(), pro: false))
#expect(!metrics.add(UUID(), pro: false))
#expect(metrics.add(UUID(), pro: true))
let library = HostLibraryModel()
let hosts = (0..<5).map { TermindDomain.Host(name: "Host \($0)", host: "example.invalid") }
for host in hosts { library.upsert(host) }
let metrics = MobileMetricsModel(defaults: nil, hosts: library)
metrics.pause()
#expect(metrics.add(hosts[0].id, pro: false))
#expect(metrics.add(hosts[0].id, pro: false))
#expect(metrics.add(hosts[1].id, pro: false))
#expect(metrics.add(hosts[2].id, pro: false))
#expect(!metrics.add(hosts[3].id, pro: false))
#expect(metrics.add(hosts[4].id, pro: true))
#expect(metrics.selections.count == 4)
}
}
@@ -0,0 +1,79 @@
import Foundation
import TermindDomain
import TermindRemoteInfrastructure
import Testing
@testable import TermindIOS
@Suite @MainActor
struct MobileNavigationParityTests {
@Test func vaultReplacesWelcomeAndMetricsRemainsBelowItOnIPad() {
#expect(MobileTab.allCases == [.vault, .workspaces, .assistant, .settings])
#expect(MobileDestination.allCases == [.vault, .metrics, .workspaces, .assistant, .settings])
}
@Test func metricsUsesAvailableWidthRatherThanDeviceOrientation() {
#expect(!MobileMetricsDetailPage.showsBothColumns(width: 430))
#expect(!MobileMetricsDetailPage.showsBothColumns(width: 680))
#expect(MobileMetricsDetailPage.showsBothColumns(width: 700))
#expect(MobileMetricsDetailPage.showsBothColumns(width: 1024))
}
@Test func keyGenerationMatchesDesktopDefaultsAndRequiresMatchingPassphrases() throws {
let app = try MobileAppModel(inMemory: true)
let editor = MobileKeyEditorModel(mode: .generate, credentials: app.credentials)
#expect(editor.storage == .iCloud)
#expect(editor.algorithm == .ed25519)
#expect(editor.curveBits == 256 && editor.rsaBits == 3072)
#expect(editor.cipher == .aes256 && editor.rounds == 100)
#expect(editor.resolvedLabel == "Ed25519" && editor.canSave)
editor.passphrase = "test-only"
#expect(!editor.canSave)
editor.confirmation = "test-only"
#expect(editor.canSave)
editor.rounds = 0
#expect(!editor.canSave)
}
@Test func certificateFileImportValidatesKeyMatchingAndLeavesDraftUntouchedOnFailure() async throws {
let app = try MobileAppModel(inMemory: true)
let first = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
let second = try SSHKeyMaterial.generate(.ed25519, comment: "", passphrase: nil)
let key = SSHKeyRef(label: "First", publicKey: first.publicKey)
let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key)
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let good = try certificate(publicKey: first.publicKey)
let file = directory.appending(path: "key-cert.pub")
try good.write(to: file, atomically: true, encoding: .utf8)
try await editor.loadCertificateFile(file)
#expect(editor.certificate == good)
try certificate(publicKey: second.publicKey).write(to: file, atomically: true, encoding: .utf8)
await #expect(throws: SSHUserCertificateError.keyMismatch) { try await editor.loadCertificateFile(file) }
#expect(editor.certificate == good)
}
private func certificate(publicKey: String) throws -> String {
let encoded = try #require(publicKey.split(separator: " ").dropFirst().first)
let blob = try #require(Data(base64Encoded: String(encoded)))
// Ed25519 authorized keys contain a method string followed by one 32-byte key.
let key = Data(blob.suffix(32))
func u32(_ value: UInt32) -> Data {
var big = value.bigEndian
return withUnsafeBytes(of: &big) { Data($0) }
}
func u64(_ value: UInt64) -> Data {
var big = value.bigEndian
return withUnsafeBytes(of: &big) { Data($0) }
}
func field(_ value: Data) -> Data { u32(UInt32(value.count)) + value }
let method = "ssh-ed25519-cert-v01@openssh.com"
var certificate = field(Data(method.utf8)) + field(Data("nonce".utf8)) + field(key)
certificate += u64(42) + u32(1) + field(Data("test".utf8)) + field(Data())
certificate += u64(0) + u64(UInt64.max)
for _ in 0..<3 { certificate += field(Data()) }
certificate += field(Data("ca".utf8)) + field(Data("signature".utf8))
return "\(method) \(certificate.base64EncodedString())"
}
}
+190
View File
@@ -0,0 +1,190 @@
import AgentKit
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import TermindRemoteInfrastructure
import Testing
@testable import TermindIOS
@Suite @MainActor
struct MobileSettingsPortTests {
@Test func testsNeverStartCloudEngines() throws {
let app = try MobileAppModel(inMemory: true)
app.sceneChanged(UUID(), phase: .active)
#expect(app.vaultSync == nil)
#expect(app.settingsSync == nil)
}
@Test func providerCanBeSavedWithoutModelsAndDoesNotChangeSelection() throws {
let ai = AIConfigurationModel()
let chosen = AIModelRef(modelProviderID: UUID(), modelID: "chosen")
ai.select(chosen)
let editor = MobileProviderEditorModel(ai: ai, provider: ModelProvider(name: "Gateway"))
editor.endpoint.url = "https://example.invalid/v1"
editor.endpoint.apiType = .responses
try editor.save()
let saved = try #require(ai.modelProvider(editor.original.id))
#expect(saved.models.isEmpty)
#expect(saved.inferenceURL == "https://example.invalid/v1/responses")
#expect(ai.lastSelectedModel == chosen)
}
@Test func secretFailureKeepsDraftAndDoesNotPersistProvider() throws {
let secrets = EditorSecrets()
secrets.failWrites = true
let ai = AIConfigurationModel(aiSecrets: secrets)
let editor = MobileProviderEditorModel(ai: ai, provider: ModelProvider(name: "Gateway"))
editor.endpoint.url = "https://example.invalid/v1"
editor.keyAction = .replace
editor.secret = "test-key"
#expect(throws: (any Error).self) { try editor.save() }
#expect(ai.modelProviders.isEmpty)
#expect(editor.secret == "test-key")
#expect(ai.secretStorageError != nil)
}
@Test func metadataFailureRestoresPreviousSecret() throws {
let provider = ModelProvider(
name: "Before", inferenceURL: "https://example.invalid/v1/responses", credentialRef: "ref")
let persistence = EditorPersistence(provider: provider)
let secrets = EditorSecrets()
secrets.values["ref"] = "before"
let ai = AIConfigurationModel(persistence: persistence, aiSecrets: secrets)
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
editor.draft.name = "After"
editor.keyAction = .replace
editor.secret = "after"
persistence.failWrites = true
#expect(throws: (any Error).self) { try editor.save() }
#expect(ai.modelProvider(provider.id)?.name == "Before")
#expect(secrets.values["ref"] == "before")
#expect(editor.isDirty)
}
@Test func editingPreservesRefreshedModelsAndCanClearAKey() throws {
let provider = ModelProvider(
name: "Gateway", inferenceURL: "https://example.invalid/v1/responses", credentialRef: "ref")
let secrets = EditorSecrets()
secrets.values["ref"] = "before"
let ai = AIConfigurationModel(modelProviders: [provider], aiSecrets: secrets)
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
ai.addModel(AIModel(id: "new-model"), to: provider.id)
editor.keyAction = .clear
try editor.save()
#expect(ai.modelProvider(provider.id)?.models.map(\.id) == ["new-model"])
#expect(ai.modelProvider(provider.id)?.credentialRef == "")
#expect(secrets.values["ref"] == nil)
}
@Test func remoteDeletionDoesNotResurrectAnOpenDraft() throws {
let provider = ModelProvider(name: "Gateway", inferenceURL: "https://example.invalid/v1/responses")
let ai = AIConfigurationModel(modelProviders: [provider])
let editor = MobileProviderEditorModel(ai: ai, provider: provider)
ai.delete(modelProviderID: provider.id)
#expect(throws: (any Error).self) { try editor.save() }
#expect(ai.modelProviders.isEmpty)
}
@Test func cancelledDiscoveryIgnoresAnUncooperativeResponse() async {
let latch = DiscoveryLatch()
let provider = ModelProvider(name: "Gateway")
let ai = AIConfigurationModel(modelProviders: [provider])
let discovery = MobileModelDiscovery(fetch: { _, _ in await latch.wait() })
discovery.start(ai: ai, provider: provider)
await latch.awaitStarted()
discovery.cancel()
await latch.finish()
for _ in 0..<10 { await Task.yield() }
#expect(discovery.candidates.isEmpty)
#expect(!discovery.running)
#expect(ai.modelProvider(provider.id)?.models.isEmpty == true)
}
@Test func deletedProviderIgnoresDiscoveryResponse() async {
let latch = DiscoveryLatch()
let provider = ModelProvider(name: "Gateway")
let ai = AIConfigurationModel(modelProviders: [provider])
let discovery = MobileModelDiscovery(fetch: { _, _ in await latch.wait() })
discovery.start(ai: ai, provider: provider)
await latch.awaitStarted()
ai.delete(modelProviderID: provider.id)
await latch.finish()
for _ in 0..<10 { await Task.yield() }
#expect(discovery.candidates.isEmpty)
}
@Test func terminalHistoryRequiresIntegrationAndPairsCompletionOnlyOnce() throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh),
hosts: app.library, terminalSettings: app.terminalSettings, commandHistory: app.commandHistory)
workspace.receiveShellEvent(.commandStarted("not-authenticated"))
#expect(app.commandHistory.entries(for: host.id).isEmpty)
workspace.receiveShellEvent(.available(shell: "bash", systemInfo: nil, homeDirectory: nil))
workspace.receiveShellEvent(.commandStarted("echo hello"))
workspace.receiveShellEvent(.commandFinished(0, duration: 1.25))
workspace.receiveShellEvent(.commandFinished(1, duration: 9))
let entry = try #require(app.commandHistory.entries(for: host.id).first)
#expect(entry.command == "echo hello")
#expect(entry.exitStatus == 0)
#expect(entry.duration == 1.25)
workspace.receiveShellEvent(.commandStarted("interrupted"))
workspace.disconnect()
workspace.receiveShellEvent(.commandFinished(0, duration: 1))
#expect(app.commandHistory.entries(for: host.id).first?.exitStatus == nil)
}
@Test func findQuerySurvivesDismissalAndRemoteDeletionClearsHistory() throws {
let app = try MobileAppModel(inMemory: true)
let host = TermindDomain.Host(name: "Test", host: "example.invalid")
app.library.upsert(host)
let workspace = MobileWorkspace(
descriptor: .init(hostID: host.id, title: host.name, kind: .ssh), hosts: app.library)
workspace.presentFind()
workspace.updateFindQuery("retained")
workspace.dismissFind()
workspace.presentFind()
#expect(workspace.findQuery == "retained")
#expect(workspace.isFindPresented)
app.commandHistory.record("test", for: host.id)
app.applyRemoteVault(.init(), keys: [VaultRecordKey(kind: .host, id: host.id)])
#expect(app.library.host(host.id) == nil)
#expect(app.commandHistory.entries(for: host.id).isEmpty)
}
}
@MainActor private final class EditorSecrets: AISecretStoring {
var values: [String: String] = [:]
var failWrites = false
func string(for reference: String) throws -> String? { values[reference] }
func setString(_ value: String?, for reference: String) throws {
if failWrites { throw CocoaError(.fileWriteNoPermission) }
values[reference] = value
}
}
@MainActor private final class EditorPersistence: AIConfigurationPersisting {
var value: AIConfigurationSnapshot
var failWrites = false
init(provider: ModelProvider) { value = .init(providers: [provider], roles: [:]) }
func load() throws -> AIConfigurationSnapshot { value }
func save(_ snapshot: AIConfigurationSnapshot) throws {
if failWrites { throw CocoaError(.fileWriteNoPermission) }
value = snapshot
}
}
private actor DiscoveryLatch {
private var continuation: CheckedContinuation<[AIModel], Never>?
func wait() async -> [AIModel] { await withCheckedContinuation { continuation = $0 } }
func awaitStarted() async { while continuation == nil { await Task.yield() } }
func finish() {
continuation?.resume(returning: [AIModel(id: "late")])
continuation = nil
}
}
+2
View File
@@ -15,7 +15,9 @@ struct MobileVaultFeatureTests {
#expect(!generator.isDirty)
generator.label = "Generated"
#expect(generator.isDirty)
#expect(generator.storage == .iCloud)
generator.passphrase = "test-only-passphrase"
generator.confirmation = "test-only-passphrase"
try await generator.save()
let key = try #require(app.credentials.keys.first)
#expect(key.hasPassphrase)
+3 -76
View File
@@ -1,85 +1,12 @@
import Foundation
import TermindApplication
#if canImport(AppKit)
import AppKit
#endif
/// Resolves an explicitly requested app language instead of relying on the
/// process-wide bundle negotiation. `String(localized:locale:)` uses `locale`
/// for formatting, but an already-running app (and the test host) can still
/// have a different preferred localization. Loading the matching lproj bundle
/// keeps locale-injectable presentation code deterministic.
nonisolated enum AppLocalization {
static func string(
_ key: String.LocalizationValue, locale: Locale = .current
) -> String {
String(
localized: key,
bundle: bundle(for: locale),
locale: locale
)
}
private static func bundle(for locale: Locale) -> Bundle {
let localization: String? =
switch locale.language.languageCode?.identifier {
case "zh": "zh-Hans"
case "en": "en"
default: nil
}
guard let localization,
let path = Bundle.main.path(forResource: localization, ofType: "lproj"),
let bundle = Bundle(path: path)
else { return .main }
return bundle
}
}
nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendable {
case system
case english = "en"
case simplifiedChineseChina = "zh-Hans-CN"
nonisolated var id: String { rawValue }
var displayName: String {
switch self {
case .system: String(localized: "Follow System")
case .english: "English"
case .simplifiedChineseChina: "简体中文(中国)"
}
}
nonisolated static func stored(in defaults: UserDefaults = .standard) -> AppLanguage {
guard let rawValue = defaults.string(forKey: SettingsKey.appLanguage) else {
return .system
}
return AppLanguage(rawValue: rawValue) ?? .system
}
/// Keeps the app's explicit choice separate from Apple's per-app language
/// override. Removing `AppleLanguages` is what restores normal Bundle
/// negotiation when the user chooses Follow System.
nonisolated static func store(
_ language: AppLanguage, in defaults: UserDefaults = .standard
) {
defaults.set(language.rawValue, forKey: SettingsKey.appLanguage)
switch language {
case .system:
defaults.removeObject(forKey: "AppleLanguages")
case .english, .simplifiedChineseChina:
defaults.set([language.rawValue], forKey: "AppleLanguages")
}
}
var locale: Locale {
switch self {
case .system: .autoupdatingCurrent
case .english: Locale(identifier: "en")
case .simplifiedChineseChina: Locale(identifier: rawValue)
}
}
}
typealias AppLanguage = TermindApplication.AppLanguage
typealias AppLocalization = TermindApplication.AppLocalization
#if canImport(AppKit)
@MainActor
+28 -38
View File
@@ -1,5 +1,6 @@
import AgentKit
import Foundation
import TermindApplication
/// `@AppStorage` keys in one place.
///
@@ -15,32 +16,23 @@ nonisolated enum SettingsKey {
/// App language selection. `system` means normal Bundle negotiation.
static let showMetricsInMenuBar = "metrics.showInMenuBar"
static let appLanguage = "app.language"
static let appLanguage = SharedSettingsKey.appLanguage
/// App-wide appearance. `system` inherits changes from macOS.
static let appAppearance = "app.appearance"
static let iCloudSyncEnabled = "icloud.sync.enabled"
static let defaultICloudSyncEnabled = true
static let settingsSyncEnabled = "settings.sync.enabled"
static let settingsSyncEnvironment = "settings.sync.environment"
static let defaultSettingsSyncEnabled = false
static let vaultSyncEnabled = "vault.sync.enabled"
static let iCloudSyncEnabled = SharedSettingsKey.iCloudSyncEnabled
static let defaultICloudSyncEnabled = SharedSettingsKey.defaultICloudSyncEnabled
static let settingsSyncEnabled = SharedSettingsKey.settingsSyncEnabled
static let settingsSyncEnvironment = SharedSettingsKey.settingsSyncEnvironment
static let defaultSettingsSyncEnabled = SharedSettingsKey.defaultSettingsSyncEnabled
static let vaultSyncEnabled = SharedSettingsKey.vaultSyncEnabled
/// The CloudKit environment owning the persisted sync engine state and
/// record system fields. Debug and TestFlight share the app container, but
/// CloudKit development change tags are invalid in production (and vice
/// versa), so an environment switch must rebind the local journal.
static let vaultSyncEnvironment = "vault.sync.environment"
static let defaultVaultSyncEnabled = true
static let vaultSyncEnvironment = SharedSettingsKey.vaultSyncEnvironment
static let defaultVaultSyncEnabled = SharedSettingsKey.defaultVaultSyncEnabled
static func migrateSyncPreferences(in defaults: UserDefaults = .standard) {
if defaults.object(forKey: settingsSyncEnabled) == nil,
let legacy = defaults.object(forKey: "ai.sync.enabled") as? Bool
{
defaults.set(legacy, forKey: settingsSyncEnabled)
}
guard defaults.object(forKey: iCloudSyncEnabled) == nil else { return }
let vault = defaults.object(forKey: vaultSyncEnabled) as? Bool ?? defaultVaultSyncEnabled
let ai = defaults.object(forKey: settingsSyncEnabled) as? Bool ?? defaultSettingsSyncEnabled
defaults.set(vault || ai, forKey: iCloudSyncEnabled)
if !vault && !ai { defaults.set(true, forKey: vaultSyncEnabled) }
SharedSettingsKey.migrateSyncPreferences(in: defaults)
}
static let mcpServerEnabled = "mcpServer.enabled"
@@ -59,25 +51,23 @@ nonisolated enum SettingsKey {
/// how much of the pane you want covered, not a property of one session.
static let statusBarExpanded = "statusBar.expanded"
/// Terminal palette name, matched against `GhosttyThemeCatalog`.
static let terminalThemeDark = "terminal.theme.dark"
static let terminalThemeLight = "terminal.theme.light"
static let terminalCustomConfigurationDarkEnabled =
"terminal.customConfiguration.dark.enabled"
static let terminalCustomConfigurationLightEnabled =
"terminal.customConfiguration.light.enabled"
static let terminalCustomConfigurationDark = "terminal.customConfiguration.dark"
static let terminalCustomConfigurationLight = "terminal.customConfiguration.light"
static let terminalFontFollowTheme = "terminal.font.followTheme"
static let defaultTerminalThemeDark = "Termind Dark"
static let defaultTerminalThemeLight = "Termind Light"
static let terminalFontSize = "terminal.fontSize"
static let terminalThemeDark = SharedSettingsKey.terminalThemeDark
static let terminalThemeLight = SharedSettingsKey.terminalThemeLight
static let terminalCustomConfigurationDarkEnabled = SharedSettingsKey.terminalCustomConfigurationDarkEnabled
static let terminalCustomConfigurationLightEnabled = SharedSettingsKey.terminalCustomConfigurationLightEnabled
static let terminalCustomConfigurationDark = SharedSettingsKey.terminalCustomConfigurationDark
static let terminalCustomConfigurationLight = SharedSettingsKey.terminalCustomConfigurationLight
static let terminalFontFollowTheme = SharedSettingsKey.terminalFontFollowTheme
static let defaultTerminalThemeDark = SharedSettingsKey.defaultTerminalThemeDark
static let defaultTerminalThemeLight = SharedSettingsKey.defaultTerminalThemeLight
static let terminalFontSize = SharedSettingsKey.terminalFontSize
/// Enable session-scoped hooks that report remote shell and system context.
static let shellIntegration = "terminal.shellIntegration"
static let shellIntegration = SharedSettingsKey.shellIntegration
static var isShellIntegrationEnabled: Bool {
UserDefaults.standard.object(forKey: shellIntegration) as? Bool ?? true
}
/// Mirror the focused SSH terminal's OSC 0/2 title in the macOS menu bar.
static let remoteWindowTitleInMenuBar = "terminal.remoteWindowTitleInMenuBar"
static let remoteWindowTitleInMenuBar = SharedSettingsKey.remoteWindowTitleInMenuBar
static let defaultRemoteWindowTitleInMenuBarEnabled = true
static let onboardingQuickOpenCompleted = "onboarding.quickOpen.completed"
static let onboardingStatusBarPathCompleted = "onboarding.statusBarPath.completed"
@@ -107,7 +97,7 @@ nonisolated enum SettingsKey {
/// How far through `AgentSkillLibrary` the starter skills have been copied
/// into the user's library. A mark rather than a flag, so a later release
/// can add one without resurrecting the ones they deleted.
static let seededSkillGeneration = "assistant.skills.seededGeneration"
static let seededSkillGeneration = SharedSettingsKey.seededSkillGeneration
static let sftpGlobalTransferLimit = "sftp.transfers.globalLimit"
static let sftpPerHostTransferLimit = "sftp.transfers.perHostLimit"
static let defaultSFTPGlobalTransferLimit = 3
@@ -117,8 +107,8 @@ nonisolated enum SettingsKey {
///
/// On by default because ScrubberKit searches from this Mac without an
/// account or API key.
static let webSearchEnabled = "search.enabled"
static let defaultWebSearchEnabled = true
static let webSearchEnabled = SharedSettingsKey.webSearchEnabled
static let defaultWebSearchEnabled = SharedSettingsKey.defaultWebSearchEnabled
static var isWebSearchEnabled: Bool {
UserDefaults.standard.object(forKey: webSearchEnabled) as? Bool ?? defaultWebSearchEnabled
}
@@ -133,8 +123,8 @@ nonisolated enum SettingsKey {
/// search is a *guess*: `ModelProvider.supportsNativeWebSearch(model:)`
/// infers it from the model id, and a gateway can make that inference wrong
/// in either direction. This is the escape hatch for when it is.
static let webSearchBuiltIn = "search.builtIn"
static let defaultWebSearchBuiltIn = true
static let webSearchBuiltIn = SharedSettingsKey.webSearchBuiltIn
static let defaultWebSearchBuiltIn = SharedSettingsKey.defaultWebSearchBuiltIn
static var isBuiltInWebSearchEnabled: Bool {
UserDefaults.standard.object(forKey: webSearchBuiltIn) as? Bool
?? defaultWebSearchBuiltIn
@@ -1,330 +1,5 @@
import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
nonisolated enum ConnectionPurpose: String, Hashable, Sendable {
case sshSession
case sftpSession
case serialSession
case metrics
}
nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable {
nonisolated enum Role: Hashable, Sendable {
case local
case jump
case destination
}
nonisolated enum ID: Hashable, Sendable {
case local
case host(Host.ID)
case serial(String)
}
var id: ID
var hostID: Host.ID?
var name: String
var address: String
var role: Role
}
/// UI-facing state for one connection attempt. The transport emits facts; this model
/// turns them into a monotonic route so reconnect probes cannot visually move
/// a completed path backwards.
@Observable
final class ConnectionJourney {
enum Mode: Equatable {
case connection
case reconnection
}
enum Phase: Equatable {
case hidden
case connecting
case waiting
case succeeded
case failed(String)
}
enum CheckpointKind: Equatable {
case authentication
case hostKey
}
enum LocalRequirement: Equatable {
case identity
case passphrase
}
struct Checkpoint: Equatable {
var endpointIndex: Int
var kind: CheckpointKind
}
struct LogEntry: Identifiable, Equatable {
let id = UUID()
var date = Date()
var endpoint: String
var message: String
}
private(set) var purpose: ConnectionPurpose = .sshSession
private(set) var mode: Mode = .connection
private(set) var endpoints: [ConnectionEndpoint] = []
private(set) var phase: Phase = .hidden
private(set) var activeEndpointIndex = 0
private(set) var completedEndpointIndex = 0
private(set) var checkpoint: Checkpoint?
private(set) var completedCheckpoints: [Int: CheckpointKind] = [:]
private(set) var localRequirement: LocalRequirement?
private(set) var logs: [LogEntry] = []
var showsLogs = false
private var completionTask: Task<Void, Never>?
var isPresented: Bool { phase != .hidden }
var destination: ConnectionEndpoint? { endpoints.last }
var waitsForLocalIdentity: Bool { localRequirement == .identity }
var waitsForLocalPassphrase: Bool { localRequirement == .passphrase }
func begin(
host: Host, route: [Host], purpose: ConnectionPurpose,
username: String?, mode: Mode = .connection
) {
completionTask?.cancel()
let nextEndpoints =
[
ConnectionEndpoint(
id: .local, hostID: nil,
name: String(localized: "This Mac"), address: "localhost", role: .local
)
]
+ route.enumerated().map { index, node in
ConnectionEndpoint(
id: .host(node.id), hostID: node.id, name: node.name,
address: index == route.count - 1 && username?.isEmpty == false
? "\(username!)@\(node.displayAddress)" : node.displayAddress,
role: index == route.count - 1 ? .destination : .jump
)
}
let continuesJourney =
phase == .connecting && endpoints == nextEndpoints
&& !completedCheckpoints.isEmpty
let retainedCompletedEndpoint = continuesJourney ? completedEndpointIndex : 0
let retainedCheckpoints = continuesJourney ? completedCheckpoints : [:]
let retainedLogs = continuesJourney ? logs : []
let retainedLogVisibility = continuesJourney && showsLogs
self.purpose = purpose
self.mode = mode
endpoints = nextEndpoints
phase = .connecting
activeEndpointIndex = retainedCompletedEndpoint
completedEndpointIndex = retainedCompletedEndpoint
checkpoint = nil
completedCheckpoints = retainedCheckpoints
localRequirement = nil
logs = retainedLogs
showsLogs = retainedLogVisibility
let action = mode == .reconnection ? "Reconnecting " : "Starting "
log(
endpoint: endpoints.first?.name ?? String(localized: "This Mac"),
message: action + purpose.logName + " connection to " + host.name
)
if let username, !username.isEmpty {
log(endpoint: host.name, message: "Using login " + username)
}
}
func beginSerial(name: String, address: String, mode: Mode = .connection) {
completionTask?.cancel()
purpose = .serialSession
self.mode = mode
endpoints = [
ConnectionEndpoint(
id: .local, hostID: nil,
name: String(localized: "This Mac"), address: "localhost", role: .local
),
ConnectionEndpoint(
id: .serial(address), hostID: nil,
name: name, address: address, role: .destination
),
]
phase = .connecting
activeEndpointIndex = 1
completedEndpointIndex = 0
checkpoint = nil
completedCheckpoints = [:]
localRequirement = nil
logs = []
showsLogs = false
log(
endpoint: endpoints[0].name,
message: (mode == .reconnection ? "Reconnecting " : "Starting ")
+ "serial connection to " + name
)
log(endpoint: name, message: "Opening " + address)
}
func receive(_ event: SSHRouteEvent) {
let hostID: Host.ID
let message: String
switch event {
case .connecting(let id):
hostID = id
message = String(localized: "Opening SSH transport")
case .authenticating(let id):
hostID = id
message = String(localized: "Authenticating")
case .authenticated(let id):
hostID = id
message = String(localized: "Authentication accepted")
case .connected(let id):
hostID = id
message = String(localized: "Host connected")
case .initializing(let id, let detail):
hostID = id
message = detail
}
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
switch event {
case .connecting, .authenticating, .initializing:
if index > completedEndpointIndex { activeEndpointIndex = index }
case .authenticated:
checkpoint = nil
activeEndpointIndex = max(activeEndpointIndex, index)
case .connected:
checkpoint = nil
completedEndpointIndex = max(completedEndpointIndex, index)
activeEndpointIndex = max(activeEndpointIndex, index)
}
log(endpoint: endpoints[index].name, message: message)
}
func waitForAuthentication(at hostID: Host.ID, failure: String? = nil) {
wait(at: hostID, kind: .authentication, message: failure ?? String(localized: "Waiting for authentication"))
}
/// Pauses before transport authentication because Termind has no usable local
/// Identity. This is not a server challenge, so the untouched route stays
/// gray and no midpoint checkpoint is introduced.
func waitForLocalIdentity(at hostID: Host.ID, failure: String? = nil) {
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
activeEndpointIndex = 0
completedEndpointIndex = 0
checkpoint = nil
localRequirement = .identity
phase = .waiting
log(
endpoint: endpoints[index].name,
message: failure ?? String(localized: "Waiting for an Identity")
)
}
func waitForLocalPassphrase(at hostID: Host.ID, failure: String? = nil) {
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
activeEndpointIndex = 0
completedEndpointIndex = 0
checkpoint = nil
localRequirement = .passphrase
phase = .waiting
log(
endpoint: endpoints[index].name,
message: failure ?? String(localized: "Waiting for a Passphrase")
)
}
func waitForHostKey(at hostID: Host.ID, fingerprint: String) {
wait(
at: hostID, kind: .hostKey,
message: "Waiting to trust host key " + fingerprint
)
}
func resume() {
guard phase == .waiting else { return }
phase = .connecting
localRequirement = nil
if let checkpoint {
log(endpoint: endpoints[checkpoint.endpointIndex].name, message: String(localized: "Continuing connection"))
completedCheckpoints[checkpoint.endpointIndex] = checkpoint.kind
}
checkpoint = nil
}
func succeed() {
guard !endpoints.isEmpty else { return }
checkpoint = nil
localRequirement = nil
completedEndpointIndex = endpoints.count - 1
activeEndpointIndex = endpoints.count - 1
phase = .succeeded
log(endpoint: endpoints.last?.name ?? "", message: String(localized: "Connection ready"))
completionTask?.cancel()
completionTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(600))
guard !Task.isCancelled else { return }
self?.phase = .hidden
}
}
func fail(_ message: String, at hostID: Host.ID? = nil) {
completionTask?.cancel()
checkpoint = nil
localRequirement = nil
if let hostID,
let index = endpoints.firstIndex(where: { $0.hostID == hostID })
{
activeEndpointIndex = index
}
phase = .failed(message)
let endpoint =
endpoints.indices.contains(activeEndpointIndex)
? endpoints[activeEndpointIndex].name : (endpoints.last?.name ?? "SSH")
log(endpoint: endpoint, message: "Failed: " + message)
}
func hide() {
completionTask?.cancel()
checkpoint = nil
completedCheckpoints = [:]
localRequirement = nil
phase = .hidden
}
func log(endpoint: String, message: String) {
logs.append(LogEntry(endpoint: endpoint, message: message))
}
var plainTextLog: String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return logs.map {
"[\(formatter.string(from: $0.date))] \($0.endpoint): \($0.message)"
}.joined(separator: "\n")
}
private func wait(at hostID: Host.ID, kind: CheckpointKind, message: String) {
guard let index = endpoints.firstIndex(where: { $0.hostID == hostID }) else { return }
activeEndpointIndex = index
checkpoint = Checkpoint(endpointIndex: index, kind: kind)
localRequirement = nil
phase = .waiting
log(endpoint: endpoints[index].name, message: message)
}
}
extension ConnectionPurpose {
fileprivate var logName: String {
switch self {
case .sshSession: "SSH session"
case .sftpSession: "SFTP session"
case .serialSession: "serial session"
case .metrics: "metrics"
}
}
}
typealias ConnectionPurpose = TermindApplication.ConnectionPurpose
typealias ConnectionEndpoint = TermindApplication.ConnectionEndpoint
typealias ConnectionJourney = TermindApplication.ConnectionJourney
@@ -1,101 +1,3 @@
import Foundation
import TermindDomain
import TermindApplication
import struct TermindDomain.Host
/// Incremental persistence boundary for terminal history.
///
/// Preview and focused-test instances use the in-memory projection. The app
/// supplies its SwiftData context so inserts and deletes touch only history
/// rows, independently of HostLibraryModel's compatibility snapshot persistence.
@MainActor
@Observable
final class CommandHistoryModel {
static let perHostLimit = 1_000
private let repository: any CommandHistoryRepository
private(set) var revision: UInt64 = 0
private(set) var storageError: String?
init(
entries: [CommandHistoryEntry] = [],
repository: (any CommandHistoryRepository)? = nil
) {
self.repository = repository ?? InMemoryCommandHistoryRepository(entries: entries)
}
func entries(for hostID: Host.ID, matching query: String = "") -> [CommandHistoryEntry] {
let values: [CommandHistoryEntry]
do {
values = try repository.entries(for: hostID)
storageError = nil
} catch {
storageError = error.localizedDescription
return []
}
guard !query.isEmpty else { return values }
return values.filter { $0.command.localizedCaseInsensitiveContains(query) }
}
@discardableResult
func record(
_ command: String, for hostID: Host.ID, at date: Date = Date()
) -> CommandHistoryEntry? {
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let entry = CommandHistoryEntry(hostID: hostID, command: command, executedAt: date)
do {
try repository.insert(entry, limit: Self.perHostLimit)
storageError = nil
} catch {
storageError = error.localizedDescription
return nil
}
revision &+= 1
return entry
}
@discardableResult
func recordCompletion(
for id: CommandHistoryEntry.ID, hostID: Host.ID,
exitStatus: Int32, duration: TimeInterval?
) -> CommandHistoryEntry? {
do {
guard var entry = try repository.entries(for: hostID).first(where: { $0.id == id })
else { return nil }
entry.exitStatus = exitStatus
entry.duration = duration.map { max(0, $0) }
try repository.update(entry)
storageError = nil
revision &+= 1
return entry
} catch {
storageError = error.localizedDescription
return nil
}
}
func delete(_ id: CommandHistoryEntry.ID) {
do {
try repository.delete(id)
storageError = nil
} catch {
storageError = error.localizedDescription
return
}
revision &+= 1
}
func clear(for hostID: Host.ID) {
do {
try repository.clear(for: hostID)
storageError = nil
} catch {
storageError = error.localizedDescription
return
}
revision &+= 1
}
}
typealias CommandHistoryModel = TermindApplication.CommandHistoryModel
@@ -1,241 +1,3 @@
import AgentKit
import Foundation
import TermindApplication
/// The skills Termind ships with.
///
/// They exist so the feature is something the user *edits* rather than
/// something they have to invent. A library that starts empty asks a new user to
/// guess the format, the register, and the useful level of detail before they
/// have seen one work; three real runbooks answer all three, and the fastest way
/// to write a fourth is to open one of these and change it.
///
/// Ordinary skills in every other respect. They are copied into the library on
/// first launch and are the user's from that moment: editable, disableable, and
/// deletable, with nothing putting them back. The generation mark is what makes
/// that stick — seeding installs only what a later release added, so a skill
/// deleted today does not return tomorrow.
///
/// English, in every locale, and deliberately not in the string catalog. A
/// skill's body is model-facing prose that happens to be readable — the same
/// register as `AgentPlanContractInjection.contract` — and these are records the
/// user owns and rewrites from the moment they arrive. Translating them would
/// mean either rewriting someone's edited copy when they change languages, or
/// carrying two versions of six kilobytes of shell commentary that must not
/// drift apart. The user's own skills are written in whatever language they
/// write in; these are a starting point they are free to translate or replace.
///
/// The content is deliberately about *this* app's shape of work. Two are
/// investigations that stay read-only until they have something to say, and one
/// teaches the pull/edit/push loop that keeps a remote file's contents out of
/// the conversation entirely.
nonisolated enum AgentSkillLibrary {
/// Bumped when a release adds starter skills, never for edits to existing
/// ones — a user's copy is theirs, and rewriting it under them would throw
/// away whatever they changed.
static let currentGeneration = 1
static func builtIns(after generation: Int) -> [AgentSkill] {
all.filter { $0.generation > generation }.map(\.skill)
}
private static var all: [(generation: Int, skill: AgentSkill)] {
[
(1, diskFullTriage), (1, failingService), (1, safeConfigEdit),
]
}
private static var diskFullTriage: AgentSkill {
AgentSkill(
id: UUID(uuidString: "9C362C57-BCCF-4B44-A1C4-000000000001")!,
name: "disk-full-triage",
title: "Disk Full Triage",
summary: """
Find what filled a disk and decide what is safe to reclaim. Use when a filesystem \
is at or near 100%, when writes are failing with "No space left on device", or \
when someone asks why a host is out of disk.
""",
body: """
# Disk Full Triage
Work out *what* grew before proposing to delete anything. A full disk is usually one
directory, and the fix depends on which.
## 1. Find the full filesystem
```
df -h
df -i
```
Check both. A filesystem at 100% inodes with free bytes is a different problem —
millions of small files, usually a session or cache directory — and `du` will not
show it as large.
## 2. Find what is using it
Descend from the mount point that is full, one level at a time:
```
du -x -h -d 1 /var 2>/dev/null | sort -h | tail -20
```
`-x` matters: without it you walk into other filesystems and misattribute their
size. Repeat into the largest child until a single directory explains the shortfall.
## 3. Check for deleted-but-open files
If `du` totals are far below what `df` reports, something is holding a deleted file
open — a rotated log a process never reopened:
```
lsof -nP +L1 2>/dev/null | head -40
```
That space returns when the process is restarted, not when the file is removed. Say
which process, and let the user decide about restarting it.
## 4. The usual culprits
- `journalctl --disk-usage`, capped with `journalctl --vacuum-size=…`
- `/var/log` with rotation misconfigured, or `*.log.1` never compressed
- Docker: `docker system df` before `docker system prune`
- Package caches: `/var/cache/apt`, `/var/cache/yum`
- A core dump or a database backup somebody left in place
## 5. Report before removing
Say what filled the disk, how much each candidate would return, and what the risk of
each is. Removing files is the user's decision — propose it and let the approval
prompt be where it happens. Never `rm -rf` a directory you have not listed first.
"""
)
}
private static var failingService: AgentSkill {
AgentSkill(
id: UUID(uuidString: "9C362C57-BCCF-4B44-A1C4-000000000002")!,
name: "failing-service-triage",
title: "Failing Service Triage",
summary: """
Diagnose a systemd unit that will not start or keeps restarting. Use when a service \
is down, flapping, reported as failed or inactive, or when a site or API on a host \
has stopped responding.
""",
body: """
# Failing Service Triage
Read the failure before touching the service. A restart that "fixes" it without a
cause found is the same outage again in an hour.
## 1. What the unit says
```
systemctl status <unit> --no-pager -l
systemctl show <unit> -p Result -p ExecMainStatus -p NRestarts
```
`Result` and the exit status usually name the class of failure on their own:
`exit-code` is the program's own error, `signal` is a crash or an OOM kill, `timeout`
is a unit that never signalled readiness.
## 2. What the logs say
```
journalctl -u <unit> -n 200 --no-pager
journalctl -u <unit> --since "1 hour ago" -p err --no-pager
```
Read from the *first* error of the most recent start, not the last line. The last
line is usually the supervisor giving up; the first is the reason.
## 3. Rule out the ordinary causes
- **Config**: validate before restarting — `nginx -t`, `sshd -t`,
`postgres --check`, `caddy validate`. A syntax error found here costs nothing.
- **Port already bound**: `ss -lntp | grep <port>`
- **Permissions**: the unit's `User=` against the paths in its `ExecStart=`
- **Out of memory**: `journalctl -k | grep -i "killed process"`
- **Dependency**: `systemctl list-dependencies <unit> --failed`
- **Disk**: a full filesystem breaks services in unrelated-looking ways
## 4. Then act
Say what failed and why, and propose the specific change. A restart is worth
proposing only once you can say what it will fix; when you cannot, say that instead —
an unexplained flap is worth more investigation, not a restart loop.
"""
)
}
private static var safeConfigEdit: AgentSkill {
AgentSkill(
id: UUID(uuidString: "9C362C57-BCCF-4B44-A1C4-000000000003")!,
name: "safe-config-edit",
title: "Safe Remote Config Edit",
summary: """
Change a configuration file on a host without risking a broken service or an \
unrecoverable state. Use when editing nginx, sshd, systemd units, database or \
application config on a remote machine.
""",
body: """
# Safe Remote Config Edit
The order matters more than the edit. Every step below exists because skipping it is
how a config change becomes an outage you cannot log in to fix.
## 1. Pull it, do not read it into the chat
```
scratch_pull → stage the file locally
scratch_copy → keep the pristine copy to diff against
scratch_replace → change the lines you mean to change
scratch_push → send it back
```
The contents never enter the conversation, revisions cost a diff instead of another
copy of the file, and the only step the user approves is the one that reaches their
host. Use `scratch_read` on the staged copy when you need to see it, and
`scratch_diff` against the pristine copy to check what you actually changed.
## 2. Keep a way back
Before pushing, copy the original beside itself:
```
cp -a /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak-$(date +%Y%m%d%H%M%S)
```
`-a` preserves owner, group and mode. A backup that root can read and the service
user cannot is not a backup.
## 3. Validate before reloading
```
nginx -t
sshd -t
systemd-analyze verify /etc/systemd/system/<unit>.service
visudo -c -f <file>
```
If the tool has a check mode, it runs before the reload, every time.
## 4. Reload rather than restart, where that exists
`systemctl reload nginx` keeps connections; `restart` drops them. Reload only after
validation passed.
## 5. Never lock yourself out
Editing `sshd_config`, a firewall, or anything else in the path you are connected
over: keep the current session open, validate, reload, and prove a *new* connection
works before closing anything. Say this to the user explicitly — they are the one who
has to keep the window open.
## 6. Say what changed
Report the diff, the backup path, the validation output, and how to roll back. A
change nobody can undo in one command is not finished.
"""
)
}
}
typealias AgentSkillLibrary = TermindApplication.AgentSkillLibrary
@@ -1,42 +1,3 @@
import Foundation
import TermindDomain
import TermindApplication
/// Settings edits persist first, then update every window and the terminal runtime.
@Observable
final class TerminalSettingsModel {
private(set) var preferences = TerminalPreferences()
private(set) var storageError: String?
private let persistence: (any TerminalPreferencesPersisting)?
var didChangeShellIntegration: ((Bool) -> Void)?
init(persistence: (any TerminalPreferencesPersisting)? = nil) {
self.persistence = persistence
do { if let persistence { preferences = try persistence.loadTerminal() } } catch {
storageError = error.localizedDescription
}
}
@discardableResult
func set<Value>(_ value: Value, for keyPath: WritableKeyPath<TerminalPreferences, Value>) -> Bool {
var next = preferences
next[keyPath: keyPath] = value
guard next != preferences else { return true }
do {
try persistence?.saveTerminal(next)
applyRemoteSnapshot(next)
return true
} catch {
storageError = error.localizedDescription
return false
}
}
func applyRemoteSnapshot(_ value: TerminalPreferences) {
let previous = preferences
preferences = value
storageError = nil
if previous.shellIntegration != value.shellIntegration {
didChangeShellIntegration?(value.shellIntegration)
}
}
}
typealias TerminalSettingsModel = TermindApplication.TerminalSettingsModel
@@ -2,12 +2,7 @@ import Foundation
import TermindApplication
import TermindDomain
nonisolated struct MetricsClock: Sendable {
var now: @Sendable () -> Date = { Date() }
var sleep: @Sendable (TimeInterval) async throws -> Void = {
try await Task.sleep(for: .seconds($0))
}
}
typealias MetricsClock = TermindApplication.MetricsClock
typealias MetricsSampling = TermindApplication.MetricsSampling
typealias MetricsConfigurationStoring = TermindApplication.MetricsConfigurationStoring
+4 -28
View File
@@ -1,29 +1,5 @@
import Foundation
import TermindDomain
import TermindApplication
/// A value-only view of the existing monitor. Reading never admits a host or starts collection.
@MainActor
protocol MetricsSnapshotReading: Sendable {
func snapshot(for hostID: UUID) -> MetricsSnapshot?
}
nonisolated enum MetricsMonitorState: String, Sendable {
case connecting, collecting, live, failed, paused, sleeping, stopped
case awaitingAuthentication = "awaiting_authentication"
}
nonisolated struct MetricsSnapshot: Sendable {
var capturedAt: Date
var state: MetricsMonitorState
var point: MetricsPoint?
var history: [MetricsPoint] = []
var gpuState: MetricsGPUState = .probing
var gpu: NvidiaSnapshot?
var gpuDate: Date?
var processes: [MetricsProcess] = []
var processesDate: Date?
var processesFailed = false
var filesystems: [MetricsFilesystem] = []
var filesystemsDate: Date?
var filesystemsFailed = false
}
typealias MetricsSnapshotReading = TermindApplication.MetricsSnapshotReading
typealias MetricsMonitorState = TermindApplication.MetricsMonitorState
typealias MetricsSnapshot = TermindApplication.MetricsSnapshot
@@ -1,594 +1,4 @@
import Foundation
import TermindDomain
import TermindInfrastructure
import TermindApplication
import struct TermindDomain.Host
@Observable
final class ServerMetric: Identifiable {
nonisolated let id: UUID
var selection: MetricsSelection
var name: String
var status = String(localized: "Connecting…")
var collectionState = MetricsMonitorState.connecting
var isConnecting = true
var point: MetricsPoint?
var history: [MetricsPoint] = []
var disks: [String] = []
var interfaces: [String] = []
var gpuState = MetricsGPUState.probing
var gpuSnapshot: NvidiaSnapshot?
var gpuDate: Date?
var authenticationHost: Host?
var hostKey: SSHHostKeyObservation?
var needsAttention = false
let journey = ConnectionJourney()
var authenticationPrompt: SSHAuthenticationPrompt?
var hostKeyPrompt: SSHHostKeyPrompt?
var hostKeyHostID: UUID?
var requiresAuthentication: Bool { hostKey != nil || authenticationPrompt != nil }
var showsConnectionJourney: Bool { requiresAuthentication || journey.isPresented }
var processes: [MetricsProcess] = []
var processesDate: Date?
var processesFailed = false
var filesystems: [MetricsFilesystem] = []
var filesystemsFailed = false
var filesystemsDate: Date?
init(selection: MetricsSelection, name: String) {
id = selection.id
self.selection = selection
self.name = name
}
}
/// One application owner, independent of windows, with one serial worker per host.
@Observable
final class ServerMetricsModel: MetricsSnapshotReading {
static let freeCardLimit = 3
private(set) var servers: [ServerMetric] = []
private(set) var sleeping = false
private var started = false
private var stopped = false
private let hosts: any RemoteSessionHostStore
private let preferences: any MetricsConfigurationStoring
private let makeSampler: @Sendable () -> any MetricsSampling
private let discover:
@Sendable (Host, SSHTransportProxy?, [SSHRouteHopConfiguration]) async throws -> SSHHostKeyObservation
private let clock: MetricsClock
private let cardLimit: () -> Int?
private let limitReached: () -> Void
private var workers: [UUID: Task<Void, Never>] = [:]
private var generations: [UUID: UUID] = [:]
private var samplers: [UUID: any MetricsSampling] = [:]
private var submissions: [UUID: [UUID: SSHAuthenticationSubmission]] = [:]
private var maintenance: Task<Void, Never>?
private var detailObservers: [UUID: UUID] = [:]
private var temporaryKeys: [UUID: [UUID: SSHHostKeyIdentity]] = [:]
private var stamps: [UUID: Data] = [:]
var keepsRunning: Bool { servers.contains { $0.selection.enabled } }
init(
hosts: any RemoteSessionHostStore, preferences: any MetricsConfigurationStoring,
makeSampler: @escaping @Sendable () -> any MetricsSampling,
discover:
@escaping @Sendable (Host, SSHTransportProxy?, [SSHRouteHopConfiguration]) async throws ->
SSHHostKeyObservation,
clock: MetricsClock = MetricsClock(),
cardLimit: @escaping () -> Int? = { ServerMetricsModel.freeCardLimit },
limitReached: @escaping () -> Void = {}
) {
self.hosts = hosts
self.preferences = preferences
self.makeSampler = makeSampler
self.discover = discover
self.clock = clock
self.cardLimit = cardLimit
self.limitReached = limitReached
}
func snapshot(for hostID: UUID) -> MetricsSnapshot? {
guard hosts.host(hostID) != nil, let server = servers.first(where: { $0.id == hostID }) else { return nil }
let state: MetricsMonitorState
if !server.selection.enabled {
state = .paused
} else if sleeping {
state = .sleeping
} else if stopped {
state = .stopped
} else if server.requiresAuthentication {
state = .awaitingAuthentication
} else {
state = server.collectionState
}
return MetricsSnapshot(
capturedAt: clock.now(), state: state, point: server.point, history: server.history,
gpuState: server.gpuState, gpu: server.gpuSnapshot, gpuDate: server.gpuDate,
processes: server.processes, processesDate: server.processesDate,
processesFailed: server.processesFailed, filesystems: server.filesystems,
filesystemsDate: server.filesystemsDate, filesystemsFailed: server.filesystemsFailed
)
}
var remainingCardSlots: Int? { cardLimit().map { max(0, $0 - servers.count) } }
#if DEBUG
/// Frozen product fixtures never load preferences or start an SSH worker.
convenience init(screenshotHosts: any RemoteSessionHostStore, rows: [ServerMetric]) {
self.init(
hosts: screenshotHosts, preferences: ScreenshotMetricsPreferences(),
makeSampler: { preconditionFailure("Screenshot metrics cannot connect") },
discover: { _, _, _ in preconditionFailure("Screenshot metrics cannot discover hosts") },
cardLimit: { nil }
)
servers = rows
started = true
stopped = true
}
#endif
@discardableResult
func requestAddition(count: Int = 1) -> Bool {
guard count > 0 else { return true }
guard let remaining = remainingCardSlots, count > remaining else { return true }
limitReached()
return false
}
func observeDetails(hostID: UUID, observer: UUID) { detailObservers[observer] = hostID }
func stopObservingDetails(_ observer: UUID) { detailObservers[observer] = nil }
func start() {
guard !started else { return }
started = true
var seen = Set<UUID>()
servers = preferences.load().filter { seen.insert($0.id).inserted }.compactMap { selection in
guard let host = hosts.host(selection.id) else { return nil }
return ServerMetric(selection: selection, name: host.name)
}
for server in servers { launch(server) }
maintenance = Task { [weak self, clock] in
while !Task.isCancelled {
do { try await clock.sleep(2) } catch { return }
self?.reconcile()
}
}
}
@discardableResult
func add(_ host: Host) -> Bool { add([host]) }
/// Admission and insertion are one MainActor operation, shared by all windows.
@discardableResult
func add(_ hosts: [Host]) -> Bool {
var seen = Set(servers.map(\.id))
let additions = hosts.filter { seen.insert($0.id).inserted }
guard requestAddition(count: additions.count) else { return false }
guard !additions.isEmpty else { return true }
let rows = additions.map { ServerMetric(selection: MetricsSelection(id: $0.id), name: $0.name) }
servers.append(contentsOf: rows)
save()
for server in rows { launch(server) }
return true
}
func move(_ ids: [UUID], before next: UUID?) {
let wanted = Set(ids)
let moving = servers.filter { wanted.contains($0.id) }
guard !moving.isEmpty, next.map({ !wanted.contains($0) }) ?? true else { return }
var ordered = servers.filter { !wanted.contains($0.id) }
let index = next.flatMap { id in ordered.firstIndex { $0.id == id } } ?? ordered.count
ordered.insert(contentsOf: moving, at: index)
guard ordered.map(\.id) != servers.map(\.id) else { return }
servers = ordered
save()
}
func remove(_ id: UUID) {
cancel(id)
submissions[id] = nil
temporaryKeys[id] = nil
stamps[id] = nil
detailObservers = detailObservers.filter { $0.value != id }
servers.removeAll { $0.id == id }
save()
}
func setEnabled(_ enabled: Bool, for server: ServerMetric) {
server.selection.enabled = enabled
cancel(server.id)
server.isConnecting = false
server.authenticationHost = nil
server.hostKey = nil
server.needsAttention = false
if !enabled {
submissions[server.id] = nil
temporaryKeys[server.id] = nil
server.journey.hide()
}
server.authenticationPrompt = nil
server.hostKeyPrompt = nil
save()
launch(server)
}
func retry(_ server: ServerMetric) {
server.journey.resume()
server.authenticationPrompt = nil
server.hostKeyPrompt = nil
cancel(server.id)
server.isConnecting = false
server.authenticationHost = nil
server.hostKey = nil
server.needsAttention = false
launch(server)
}
func selectDevices(_ server: ServerMetric, disks: Set<String>?, interfaces: Set<String>?) {
server.selection.disks = disks
server.selection.interfaces = interfaces
server.history = []
server.point = nil
save()
retry(server)
}
func submitAuthentication(
_ server: ServerMetric, identityID: UUID?, username: String, password: String?,
passphrase: String?, keyID: UUID?, save: Bool
) {
guard var host = server.authenticationHost else { return }
let identity = identityID.flatMap { hosts.identity($0) }
let name = (identity?.username ?? username).trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty else { return }
if server.authenticationPrompt?.requiresPassphrase == true,
var staged = submissions[server.id]?[host.id]
{
guard let passphrase, !passphrase.isEmpty else { return }
staged.passphrase = passphrase
submissions[server.id]?[host.id] = staged
retry(server)
return
}
let chosenKey = identity?.keyID ?? keyID
if save {
let saved = identity ?? Identity(name: name, username: name, keyID: chosenKey)
do {
try hosts.upsert(saved, passwordUpdate: password.map(IdentityPasswordUpdate.replace) ?? .keep)
host.identityID = saved.id
hosts.upsert(host)
} catch {
server.status = error.localizedDescription
server.collectionState = .failed
server.journey.fail(error.localizedDescription, at: host.id)
return
}
}
submissions[server.id, default: [:]][host.id] = SSHAuthenticationSubmission(
username: name, password: password, passphrase: passphrase,
keyID: chosenKey, identityID: identity?.id
)
retry(server)
}
func acceptHostKey(_ server: ServerMetric, observation: SSHHostKeyObservation, remember: Bool) {
guard server.hostKey == observation, let hostID = server.hostKeyHostID else { return }
if remember {
hosts.trust(
KnownHostEntry(
hostname: observation.hostname, port: observation.port,
algorithm: observation.algorithm, fingerprint: observation.fingerprint, addedAt: clock.now()
))
} else {
temporaryKeys[server.id, default: [:]][hostID] = observation.identity
}
retry(server)
}
private func preparation(for host: Host, server: ServerMetric) throws -> SSHConnectionPreparation {
var result = try hosts.prepareSSHConnection(for: host, submissions: submissions[server.id] ?? [:])
for index in result.nodes.indices {
if let key = temporaryKeys[server.id]?[result.nodes[index].host.id] {
result.nodes[index].trustedKeys.insert(key)
}
}
return result
}
private func requestAuthentication(_ server: ServerMetric, hostID: UUID, error: TermindSSHError) {
guard let host = hosts.host(hostID) else { return }
let staged = submissions[server.id]?[host.id]
let identity = staged?.identityID.flatMap { hosts.identity($0) } ?? hosts.identity(for: host)
let keyID = staged?.keyID ?? identity?.keyID
let passphrase = keyID.flatMap { hosts.key($0) }?.requiresPassphraseEntry == true && staged?.passphrase == nil
server.authenticationHost = host
server.authenticationPrompt = SSHAuthenticationPrompt(
host: host, selectedIdentityID: identity?.id, username: staged?.username ?? identity?.username ?? "",
selectedKeyID: keyID, failureMessage: error.localizedDescription, requiresPassphrase: passphrase,
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity
)
server.needsAttention = true
if error.isServerAuthenticationChallenge {
server.journey.waitForAuthentication(at: host.id, failure: error.localizedDescription)
} else if passphrase {
server.journey.waitForLocalPassphrase(at: host.id, failure: error.localizedDescription)
} else {
server.journey.waitForLocalIdentity(at: host.id, failure: error.localizedDescription)
}
}
func suspend() {
sleeping = true
for server in servers {
cancel(server.id)
server.isConnecting = false
if server.selection.enabled { server.status = String(localized: "Sleeping") }
}
}
func resume() {
guard sleeping else { return }
sleeping = false
reconcile()
for server in servers { launch(server) }
}
func shutdown() {
stopped = true
maintenance?.cancel()
maintenance = nil
for server in servers { cancel(server.id) }
submissions = [:]
temporaryKeys = [:]
for server in servers { server.journey.hide() }
}
func reconcile() {
for server in servers {
guard let host = hosts.host(server.id) else {
remove(server.id)
continue
}
server.name = host.name
server.history.removeAll { $0.date < clock.now().addingTimeInterval(-300) }
let stamp = connectionStamp(host)
if let old = stamps[server.id], old != stamp {
submissions[server.id] = nil
temporaryKeys[server.id] = nil
retry(server)
}
stamps[server.id] = stamp
}
}
private func connectionStamp(_ host: Host) -> Data {
// Values contain key references and public material, never resolved secrets.
let route = hosts.route(for: host)
let encoder = JSONEncoder()
encoder.outputFormatting = .sortedKeys
var result = (try? encoder.encode(route)) ?? Data()
for item in route {
if let identity = hosts.identity(for: item) {
result.append((try? encoder.encode(identity)) ?? Data())
if let id = identity.keyID, let key = hosts.key(id) {
result.append((try? encoder.encode(key)) ?? Data())
}
}
}
return result
}
private func save() { preferences.save(servers.map(\.selection)) }
private func cancel(_ id: UUID) {
generations[id] = nil
workers.removeValue(forKey: id)?.cancel()
if let sampler = samplers.removeValue(forKey: id) {
Task { await sampler.disconnect() }
}
}
private func launch(_ server: ServerMetric) {
guard !stopped, !sleeping, server.selection.enabled, !server.needsAttention else {
if !server.selection.enabled { server.status = String(localized: "Paused") }
return
}
guard workers[server.id] == nil else { return }
let token = UUID()
generations[server.id] = token
server.isConnecting = true
server.status = String(localized: "Connecting…")
server.collectionState = .connecting
server.gpuState = .probing
if let host = hosts.host(server.id) { stamps[server.id] = connectionStamp(host) }
workers[server.id] = Task { [weak self] in await self?.run(server, token: token) }
}
private func current(_ server: ServerMetric, _ token: UUID) -> Bool {
!Task.isCancelled && generations[server.id] == token && !stopped && !sleeping
}
private func run(_ server: ServerMetric, token: UUID) async {
var delay = 2.0
while current(server, token) {
let sampler = makeSampler()
samplers[server.id] = sampler
do {
guard let host = hosts.host(server.id) else { return }
server.status = String(localized: "Connecting…")
server.collectionState = .connecting
server.isConnecting = true
server.journey.begin(
host: host, route: hosts.route(for: host), purpose: .metrics,
username: submissions[server.id]?[host.id]?.username ?? hosts.identity(for: host)?.username
)
let configuration = try await preparation(for: host, server: server).resolve()
guard current(server, token) else { return }
try await sampler.connect(configuration)
guard current(server, token) else {
await sampler.disconnect()
return
}
server.isConnecting = false
server.journey.receive(.connected(host.id))
server.status = String(localized: "Connected — collecting metrics…")
server.collectionState = .collecting
var previous: MetricsSample?
var previousProcesses: MetricsProcessSample?
var nextFilesystemRefresh = Date.distantPast
var probeGPU = true
while current(server, token) {
let began = clock.now()
let sample = try await sampler.sample()
guard current(server, token) else { break }
var point = MetricsCalculation.point(
previous: previous, current: sample, selection: server.selection, date: clock.now()
)
previous = sample
server.disks = sample.disks.map { $0.keys.sorted() } ?? server.disks
server.interfaces = sample.interfaces.map { $0.keys.sorted() } ?? server.interfaces
server.status = String(localized: "Live")
server.collectionState = .live
if server.journey.phase == .connecting { server.journey.succeed() }
// Publish base metrics before the optional, bounded GPU command.
server.point = point
delay = 2
if probeGPU {
do {
let gpu = try await sampler.gpu()
guard current(server, token) else { break }
server.gpuSnapshot = gpu
server.gpuDate = clock.now()
let devices = gpu?.devices ?? []
probeGPU = !devices.isEmpty
server.gpuState = devices.isEmpty ? .unavailable : .available
let ids = Set(devices.map(\.id))
for index in server.history.indices {
server.history[index].gpus.removeAll { !ids.contains($0.id) }
}
point.gpus = devices
} catch {
guard current(server, token) else { break }
server.gpuState = .failed
// Unknown GPU values create a gap, not a false zero or a repeated stale sample.
}
}
if detailObservers.values.contains(server.id) {
if clock.now() >= nextFilesystemRefresh {
do {
if let filesystems = try await sampler.filesystems() {
guard current(server, token) else { break }
server.filesystems = filesystems
server.filesystemsDate = clock.now()
server.filesystemsFailed = false
}
} catch {
guard current(server, token) else { break }
server.filesystemsFailed = true
}
nextFilesystemRefresh = clock.now().addingTimeInterval(30)
}
do {
if let processes = try await sampler.processes() {
guard current(server, token) else { break }
server.processes = MetricsProcess.rows(previous: previousProcesses, current: processes)
previousProcesses = processes
server.processesDate = clock.now()
server.processesFailed = false
}
} catch {
guard current(server, token) else { break }
server.processesFailed = true
previousProcesses = nil
}
} else {
previousProcesses = nil
}
server.point = point
server.history.append(point)
server.history.removeAll { $0.date < clock.now().addingTimeInterval(-300) }
try await clock.sleep(min(2, max(0.05, 2 - clock.now().timeIntervalSince(began))))
}
} catch {
guard current(server, token) else {
await sampler.disconnect()
return
}
server.isConnecting = false
server.status = error.localizedDescription
server.collectionState = .failed
if let ssh = error as? TermindSSHError {
let targetID = ssh.routeHostID ?? server.id
if ssh.isLocalAuthenticationRequirement || ssh.shouldRequestAuthentication {
requestAuthentication(server, hostID: targetID, error: ssh)
} else if ssh.shouldRequestHostKey || ssh.isHostKeyChanged
|| isTrustRequirement(ssh)
{
await requestTrust(server, hostID: targetID, token: token)
}
}
if case MetricsReadError.unsupported = error { server.needsAttention = true }
if !server.requiresAuthentication { server.journey.fail(error.localizedDescription) }
}
await sampler.disconnect()
guard current(server, token) else { return }
if server.needsAttention {
workers[server.id] = nil
samplers[server.id] = nil
return
}
do { try await clock.sleep(delay) } catch { return }
delay = min(60, delay * 2)
}
}
private func isTrustRequirement(_ error: TermindSSHError) -> Bool {
switch error {
case .untrustedHost, .mismatchedHostKey: true
default: false
}
}
private func requestTrust(_ server: ServerMetric, hostID: UUID, token: UUID, visited: Set<UUID> = []) async {
guard !visited.contains(hostID), visited.count < 16 else {
server.needsAttention = true
return
}
guard let host = hosts.host(hostID) else { return }
do {
let configuration = try await preparation(for: host, server: server).resolve(discoveryOnly: true)
guard current(server, token) else { return }
let observation = try await discover(host, configuration.proxy, configuration.jumps)
guard current(server, token) else { return }
server.hostKey = observation
server.hostKeyHostID = host.id
let trustedCount = hosts.knownHosts(for: host).count
server.hostKeyPrompt = SSHHostKeyPrompt(
observation: observation,
kind: trustedCount == 0 ? .firstUse : .additionalKey(trustedKeyCount: trustedCount)
)
server.status = String(localized: "Server Identity Verification Required")
server.needsAttention = true
server.journey.waitForHostKey(at: host.id, fingerprint: observation.fingerprint)
} catch {
guard current(server, token) else { return }
server.status = error.localizedDescription
server.collectionState = .failed
if let ssh = error as? TermindSSHError, ssh.shouldRequestAuthentication {
requestAuthentication(server, hostID: ssh.routeHostID ?? hostID, error: ssh)
} else if let ssh = error as? TermindSSHError, ssh.shouldRequestHostKey,
let next = ssh.routeHostID
{
await requestTrust(server, hostID: next, token: token, visited: visited.union([hostID]))
}
}
}
}
#if DEBUG
private final class ScreenshotMetricsPreferences: MetricsConfigurationStoring {
func load() -> [MetricsSelection] { [] }
func save(_ selections: [MetricsSelection]) {}
}
#endif
typealias ServerMetric = TermindApplication.ServerMetric
typealias ServerMetricsModel = TermindApplication.ServerMetricsModel
@@ -1,43 +1,4 @@
import Foundation
import TermindDomain
import TermindApplication
import struct TermindDomain.Host
@MainActor
protocol CommandHistoryRepository: AnyObject {
func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry]
func insert(_ entry: CommandHistoryEntry, limit: Int) throws
func update(_ entry: CommandHistoryEntry) throws
func delete(_ id: CommandHistoryEntry.ID) throws
func clear(for hostID: Host.ID) throws
}
@MainActor
final class InMemoryCommandHistoryRepository: CommandHistoryRepository {
private var values: [CommandHistoryEntry]
init(entries: [CommandHistoryEntry] = []) { values = entries }
func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry] {
values.filter { $0.hostID == hostID }.sorted { $0.executedAt > $1.executedAt }
}
func insert(_ entry: CommandHistoryEntry, limit: Int) throws {
values.append(entry)
let retained = Set(try entries(for: entry.hostID).prefix(limit).map(\.id))
values.removeAll { $0.hostID == entry.hostID && !retained.contains($0.id) }
}
func update(_ entry: CommandHistoryEntry) throws {
guard let index = values.firstIndex(where: { $0.id == entry.id }) else { return }
values[index] = entry
}
func delete(_ id: CommandHistoryEntry.ID) throws {
values.removeAll { $0.id == id }
}
func clear(for hostID: Host.ID) throws {
values.removeAll { $0.hostID == hostID }
}
}
typealias CommandHistoryRepository = TermindApplication.CommandHistoryRepository
typealias InMemoryCommandHistoryRepository = TermindApplication.InMemoryCommandHistoryRepository
@@ -1,8 +1,3 @@
import AgentKit
import Foundation
import TermindApplication
@MainActor
protocol SkillLibraryPersisting: AnyObject {
func load() throws -> [AgentSkill]
func save(_ skills: [AgentSkill]) throws
}
typealias SkillLibraryPersisting = TermindApplication.SkillLibraryPersisting
@@ -1,8 +1,3 @@
import Foundation
import TermindDomain
import TermindApplication
@MainActor
protocol TerminalPreferencesPersisting: AnyObject {
func loadTerminal() throws -> TerminalPreferences
func saveTerminal(_ value: TerminalPreferences) throws
}
typealias TerminalPreferencesPersisting = TermindApplication.TerminalPreferencesPersisting
+8 -101
View File
@@ -2,86 +2,19 @@ import Foundation
import GhosttyTerminal
import GhosttyTheme
import SwiftUI
/// Fallback colors for the SwiftUI chrome around the terminal.
///
/// These palettes drive both the terminal surface and its SwiftUI chrome, which
/// keeps banners and empty areas visually continuous around the grid.
nonisolated struct TerminalTheme: Identifiable, Hashable, Sendable {
nonisolated struct RGB: Hashable, Sendable {
var r: Double, g: Double, b: Double
import TermindDomain
init(_ hex: UInt32) {
r = Double((hex >> 16) & 0xFF) / 255
g = Double((hex >> 8) & 0xFF) / 255
b = Double(hex & 0xFF) / 255
}
init?(hex value: String) {
var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
if normalized.hasPrefix("#") { normalized.removeFirst() }
guard normalized.count == 6, let hex = UInt32(normalized, radix: 16) else {
return nil
}
self.init(hex)
}
var color: Color { Color(.sRGB, red: r, green: g, blue: b, opacity: 1) }
var hex: String {
String(
format: "%02X%02X%02X",
Int((r * 255).rounded()),
Int((g * 255).rounded()),
Int((b * 255).rounded())
)
}
}
var id: String { name }
var name: String
var isDark: Bool
var background: RGB
var foreground: RGB
var cursor: RGB
var selection: RGB
/// ANSI 0-7 followed by their bright variants 8-15.
var ansi: [RGB]
static let termindDark = TerminalTheme(
name: SettingsKey.defaultTerminalThemeDark,
isDark: true,
background: RGB(0x1E1E1E),
foreground: RGB(0xD3D8E0),
cursor: RGB(0x4B8BF5),
selection: RGB(0x2A3550),
ansi: [
RGB(0x2A2F3A), RGB(0xE05561), RGB(0x62C073), RGB(0xD5A24C),
RGB(0x4B8BF5), RGB(0xB07CD8), RGB(0x3FB5B5), RGB(0xABB2BF),
RGB(0x515A6C), RGB(0xFF6E76), RGB(0x7BD98C), RGB(0xEFBD5D),
RGB(0x6BA5FF), RGB(0xC996E8), RGB(0x56CFCF), RGB(0xE6E9EF),
]
)
static let termindLight = TerminalTheme(
name: SettingsKey.defaultTerminalThemeLight,
isDark: false,
background: RGB(0xFFFFFF),
foreground: RGB(0x2B303B),
cursor: RGB(0x2C6BD8),
selection: RGB(0xCFDDF7),
ansi: [
RGB(0x3B4048), RGB(0xC4404B), RGB(0x3F8F4E), RGB(0x9C7415),
RGB(0x2C6BD8), RGB(0x8A4FB8), RGB(0x24888A), RGB(0x6C737F),
RGB(0x545C69), RGB(0xE05561), RGB(0x53A863), RGB(0xBB8D22),
RGB(0x3F82EA), RGB(0xA463D0), RGB(0x2FA5A7), RGB(0x2B303B),
]
)
static let all: [TerminalTheme] = [.termindDark, .termindLight]
typealias TerminalTheme = TermindTerminalPalette
typealias GhosttyCustomConfiguration = TerminalCustomConfiguration
nonisolated extension TermindTerminalPalette.RGB {
var color: Color { Color(.sRGB, red: r, green: g, blue: b, opacity: 1) }
}
nonisolated extension TermindTerminalPalette {
/// The same palette expressed in the format consumed by libghostty. The
/// SwiftUI chrome and the terminal grid therefore share one definition
/// instead of merely using similarly named colors.
@@ -99,30 +32,7 @@ nonisolated struct TerminalTheme: Identifiable, Hashable, Sendable {
}
}
/// A small, deliberately permissive parser for Ghostty's line-oriented config.
/// Ghostty remains the authority on supported keys and values; Termind only
/// removes comments and malformed lines before forwarding entries in order.
nonisolated struct GhosttyCustomConfiguration: Hashable, Sendable {
nonisolated struct Entry: Hashable, Sendable {
var key: String
var value: String
}
var entries: [Entry]
init(_ text: String) {
entries = text.split(separator: "\n", omittingEmptySubsequences: false).compactMap {
rawLine in
let line = rawLine.trimmingCharacters(in: .whitespaces)
guard !line.isEmpty, !line.hasPrefix("#"), let separator = line.firstIndex(of: "=")
else { return nil }
let key = line[..<separator].trimmingCharacters(in: .whitespaces)
guard !key.isEmpty else { return nil }
let value = line[line.index(after: separator)...].trimmingCharacters(in: .whitespaces)
return Entry(key: key, value: value)
}
}
nonisolated extension TerminalCustomConfiguration {
func applying(to base: GhosttyTerminal.TerminalConfiguration)
-> GhosttyTerminal.TerminalConfiguration
{
@@ -133,9 +43,6 @@ nonisolated struct GhosttyCustomConfiguration: Hashable, Sendable {
}
}
func lastValue(for key: String) -> String? {
entries.last { $0.key == key }?.value
}
}
nonisolated struct TerminalAppearancePreferences: Hashable, Sendable {
@@ -19,26 +19,6 @@ extension KeyStorage {
}
}
extension SSHKeyAlgorithm {
nonisolated var displayName: String {
switch self {
case .ed25519: "Ed25519"
case .ecdsa256: "ECDSA P-256"
case .ecdsa384: "ECDSA P-384"
case .ecdsa521: "ECDSA P-521"
case .rsa: "RSA"
}
}
nonisolated var typeName: String {
switch self {
case .ed25519: "Ed25519"
case .ecdsa256, .ecdsa384, .ecdsa521: "ECDSA"
case .rsa: "RSA"
}
}
}
extension PortForwardingRule.Kind {
nonisolated var displayName: String {
switch self {
+3 -54
View File
@@ -2,6 +2,7 @@ import AppKit
import ListViewKit
import SwiftUI
import TermindDomain
import TermindRemoteInfrastructure
import UniformTypeIdentifiers
import struct TermindDomain.Host
@@ -350,60 +351,8 @@ struct KeychainImportDetail: View {
}
}
nonisolated struct SoftwareKeyFileImportResult: Equatable, Sendable {
var privateKey: String?
var certificate: String?
var inferredName: String?
}
nonisolated enum SoftwareKeyFileImporter {
static func shouldInferName(from currentName: String) -> Bool {
currentName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
static func read(
urls: [URL], infersName: Bool
) throws -> SoftwareKeyFileImportResult {
guard !urls.isEmpty else { return SoftwareKeyFileImportResult() }
var result = SoftwareKeyFileImportResult()
for url in urls {
let text = try readText(at: url)
if text.contains("PRIVATE KEY") {
result.privateKey = text
if infersName { result.inferredName = url.lastPathComponent }
} else if text.contains("-cert-v01@openssh.com") {
result.certificate = text.trimmingCharacters(in: .whitespacesAndNewlines)
} else {
throw CocoaError(
.fileReadCorruptFile,
userInfo: [
NSLocalizedDescriptionKey: String(
localized: "Choose a private key or OpenSSH user certificate file."
)
]
)
}
}
return result
}
static func unexpectedFileError() -> CocoaError {
CocoaError(
.fileReadCorruptFile,
userInfo: [
NSLocalizedDescriptionKey: String(
localized: "Choose a private key or OpenSSH user certificate file."
)
]
)
}
private static func readText(at url: URL) throws -> String {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
return try String(contentsOf: url, encoding: .utf8)
}
}
typealias SoftwareKeyFileImportResult = TermindRemoteInfrastructure.SoftwareKeyFileImportResult
typealias SoftwareKeyFileImporter = TermindRemoteInfrastructure.SoftwareKeyFileImporter
private struct SoftwareKeyEditor: View {
private enum FileChoice {
+2 -51
View File
@@ -1,52 +1,3 @@
import SwiftUI
import TermindDomain
import TermindPresentation
/// A fixed label gutter followed by four equal-width, leading-aligned data columns.
struct MetricsDiskTable: View {
let io: MetricsDevicePoint?
var body: some View {
GeometryReader { geometry in
let columnWidth = max(0, (geometry.size.width - 30 - 4 * 8) / 4)
HStack(alignment: .top, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Color.clear.frame(height: 12)
Text("Disk Read Row").frame(height: 14)
Text("Disk Write Row").frame(height: 14)
}
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary).frame(width: 30, alignment: .leading)
column(
"Speed", read: MetricsFormat.rate(io?.read), write: MetricsFormat.rate(io?.write),
width: columnWidth)
column(
"Bytes", read: MetricsFormat.bytes(io?.readTotal), write: MetricsFormat.bytes(io?.writeTotal),
width: columnWidth)
column("IOPS", read: number(io?.readIOPS), write: number(io?.writeIOPS), width: columnWidth)
column("Wait", read: milliseconds(io?.readWait), write: milliseconds(io?.writeWait), width: columnWidth)
}
}
.frame(height: 52)
.frame(maxWidth: .infinity)
}
private func column(_ title: LocalizedStringKey, read: String, write: String, width: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.system(size: 9)).foregroundStyle(.secondary)
.frame(width: width, height: 12, alignment: .leading)
Text(read).frame(width: width, height: 14, alignment: .leading)
Text(write).frame(width: width, height: 14, alignment: .leading)
}
.font(.system(size: 10, weight: .medium, design: .monospaced))
.lineLimit(1).minimumScaleFactor(0.65)
.frame(width: width, alignment: .leading)
}
private func number(_ value: Double?) -> String {
value?.formatted(.number.precision(.fractionLength(1))) ?? "—"
}
private func milliseconds(_ value: Double?) -> String {
value.map { number($0) + " ms" } ?? "—"
}
}
typealias MetricsDiskTable = TermindPresentation.MetricsDiskTable
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more