Continue iOS port with shared settings and Metrics
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,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.")
|
||||
|
||||
@@ -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 = ""
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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 }
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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())"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
Reference in new issue
Block a user