diff --git a/FrameworkTests/SharedMetricsJourneyTests.swift b/FrameworkTests/SharedMetricsJourneyTests.swift new file mode 100644 index 00000000..5d87f647 --- /dev/null +++ b/FrameworkTests/SharedMetricsJourneyTests.swift @@ -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 {} +} diff --git a/FrameworkTests/SharedSettingsPortTests.swift b/FrameworkTests/SharedSettingsPortTests.swift new file mode 100644 index 00000000..df1ee1d2 --- /dev/null +++ b/FrameworkTests/SharedSettingsPortTests.swift @@ -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) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index acc486cd..90352dcc 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" diff --git a/Frameworks/TermindApplication/AgentSkillLibrary.swift b/Frameworks/TermindApplication/AgentSkillLibrary.swift new file mode 100644 index 00000000..18080699 --- /dev/null +++ b/Frameworks/TermindApplication/AgentSkillLibrary.swift @@ -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 --no-pager -l + systemctl show -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 -n 200 --no-pager + journalctl -u --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 ` + - **Permissions**: the unit's `User=` against the paths in its `ExecStart=` + - **Out of memory**: `journalctl -k | grep -i "killed process"` + - **Dependency**: `systemctl list-dependencies --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/.service + visudo -c -f + ``` + + 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. + """ + ) + } +} diff --git a/Frameworks/TermindApplication/AppLanguage.swift b/Frameworks/TermindApplication/AppLanguage.swift new file mode 100644 index 00000000..85efc46b --- /dev/null +++ b/Frameworks/TermindApplication/AppLanguage.swift @@ -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) + } + } +} diff --git a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift new file mode 100644 index 00000000..0ba1164d --- /dev/null +++ b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift @@ -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? + + 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" + } + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift b/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift index 95358ce9..3f6c01ee 100644 --- a/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift @@ -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 diff --git a/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift b/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift new file mode 100644 index 00000000..de2f3ef1 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/CommandHistoryModel.swift @@ -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 + } + +} diff --git a/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift b/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift new file mode 100644 index 00000000..90f2e26c --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/TerminalSettingsModel.swift @@ -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, for keyPath: WritableKeyPath) -> 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) + } + } +} diff --git a/Frameworks/TermindApplication/Metrics/MetricsClock.swift b/Frameworks/TermindApplication/Metrics/MetricsClock.swift new file mode 100644 index 00000000..aef1118f --- /dev/null +++ b/Frameworks/TermindApplication/Metrics/MetricsClock.swift @@ -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 + } + +} diff --git a/Frameworks/TermindApplication/Metrics/MetricsSnapshot.swift b/Frameworks/TermindApplication/Metrics/MetricsSnapshot.swift new file mode 100644 index 00000000..c9e860a9 --- /dev/null +++ b/Frameworks/TermindApplication/Metrics/MetricsSnapshot.swift @@ -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 + } + +} diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift new file mode 100644 index 00000000..8c02e73a --- /dev/null +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -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] = [:] + private var generations: [UUID: UUID] = [:] + private var samplers: [UUID: any MetricsSampling] = [:] + private var submissions: [UUID: [UUID: SSHAuthenticationSubmission]] = [:] + private var maintenance: Task? + 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() + 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?, interfaces: Set?) { + 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 = []) 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 diff --git a/Frameworks/TermindApplication/Repositories/CommandHistoryRepository.swift b/Frameworks/TermindApplication/Repositories/CommandHistoryRepository.swift new file mode 100644 index 00000000..8bd06aae --- /dev/null +++ b/Frameworks/TermindApplication/Repositories/CommandHistoryRepository.swift @@ -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 } + } +} diff --git a/Frameworks/TermindApplication/SharedSettingsKey.swift b/Frameworks/TermindApplication/SharedSettingsKey.swift new file mode 100644 index 00000000..10465fb3 --- /dev/null +++ b/Frameworks/TermindApplication/SharedSettingsKey.swift @@ -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) } + } +} diff --git a/Frameworks/TermindApplication/SkillLibraryPersisting.swift b/Frameworks/TermindApplication/SkillLibraryPersisting.swift new file mode 100644 index 00000000..ec39f8d7 --- /dev/null +++ b/Frameworks/TermindApplication/SkillLibraryPersisting.swift @@ -0,0 +1,8 @@ +import AgentKit +import Foundation + +@MainActor +public protocol SkillLibraryPersisting: AnyObject { + func load() throws -> [AgentSkill] + func save(_ skills: [AgentSkill]) throws +} diff --git a/Frameworks/TermindApplication/TerminalPreferencesPersisting.swift b/Frameworks/TermindApplication/TerminalPreferencesPersisting.swift new file mode 100644 index 00000000..40d0cc49 --- /dev/null +++ b/Frameworks/TermindApplication/TerminalPreferencesPersisting.swift @@ -0,0 +1,8 @@ +import Foundation +import TermindDomain + +@MainActor +public protocol TerminalPreferencesPersisting: AnyObject { + func loadTerminal() throws -> TerminalPreferences + func saveTerminal(_ value: TerminalPreferences) throws +} diff --git a/Frameworks/TermindDomain/Credentials/SSHKeyAlgorithm+Display.swift b/Frameworks/TermindDomain/Credentials/SSHKeyAlgorithm+Display.swift new file mode 100644 index 00000000..f43d589e --- /dev/null +++ b/Frameworks/TermindDomain/Credentials/SSHKeyAlgorithm+Display.swift @@ -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" + } + } +} diff --git a/Frameworks/TermindDomain/Metrics/MetricsReadError.swift b/Frameworks/TermindDomain/Metrics/MetricsReadError.swift new file mode 100644 index 00000000..82a2560a --- /dev/null +++ b/Frameworks/TermindDomain/Metrics/MetricsReadError.swift @@ -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.") + } + } +} diff --git a/Frameworks/TermindDomain/Sessions/TerminalCustomConfiguration.swift b/Frameworks/TermindDomain/Sessions/TerminalCustomConfiguration.swift new file mode 100644 index 00000000..0eaeb4bb --- /dev/null +++ b/Frameworks/TermindDomain/Sessions/TerminalCustomConfiguration.swift @@ -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[.. String? { + entries.last { $0.key == key }?.value + } +} diff --git a/Frameworks/TermindDomain/Sessions/TermindTerminalPalette.swift b/Frameworks/TermindDomain/Sessions/TermindTerminalPalette.swift new file mode 100644 index 00000000..e4d7c5a3 --- /dev/null +++ b/Frameworks/TermindDomain/Sessions/TermindTerminalPalette.swift @@ -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] + +} diff --git a/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift new file mode 100644 index 00000000..cc655e21 --- /dev/null +++ b/Frameworks/TermindInfrastructure/Cloud/SettingsSyncCoordinator.swift @@ -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? + private var operationID = UUID() + private var isStarted = false + private var terminalError: String? + + public private(set) var status: VaultSyncStatus = .checkingAccount + public var didApplyRemote: ((SettingsSyncSnapshot, Set) -> 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) { + 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, + needsUpload: Set + ) + { + 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.") + } + } +} diff --git a/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift b/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift new file mode 100644 index 00000000..b8dbe88a --- /dev/null +++ b/Frameworks/TermindInfrastructure/Cloud/VaultSyncCoordinator.swift @@ -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? + 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) -> 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) { + 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() } +} diff --git a/Frameworks/TermindInfrastructure/MetricsReadError.swift b/Frameworks/TermindInfrastructure/MetricsReadError.swift index 82a2560a..b27f9791 100644 --- a/Frameworks/TermindInfrastructure/MetricsReadError.swift +++ b/Frameworks/TermindInfrastructure/MetricsReadError.swift @@ -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 diff --git a/Frameworks/TermindPersistence/CommandHistoryStore.swift b/Frameworks/TermindPersistence/CommandHistoryStore.swift new file mode 100644 index 00000000..c1702271 --- /dev/null +++ b/Frameworks/TermindPersistence/CommandHistoryStore.swift @@ -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( + 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( + 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( + 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( + 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 } + ) + } + } +} diff --git a/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift b/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift new file mode 100644 index 00000000..1274fafa --- /dev/null +++ b/Frameworks/TermindPersistence/SettingsSyncJournalStore.swift @@ -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 = [] +} + +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() + 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() + 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() + + 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( + 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) 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( + 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( + 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() + + 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()) { + 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()) { + 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( + 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()).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 + } + ) + } +} diff --git a/Frameworks/TermindPersistence/SettingsSyncStore.swift b/Frameworks/TermindPersistence/SettingsSyncStore.swift new file mode 100644 index 00000000..29368143 --- /dev/null +++ b/Frameworks/TermindPersistence/SettingsSyncStore.swift @@ -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) + { + var result = installed + var deleted = Set() + 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, + needsUpload: Set + ) + { + 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()).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()).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()).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 {} diff --git a/Frameworks/TermindPersistence/SharedRepositoryAliases.swift b/Frameworks/TermindPersistence/SharedRepositoryAliases.swift index fe9a1b3a..9f9fbc20 100644 --- a/Frameworks/TermindPersistence/SharedRepositoryAliases.swift +++ b/Frameworks/TermindPersistence/SharedRepositoryAliases.swift @@ -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 diff --git a/Frameworks/TermindPersistence/SkillStore.swift b/Frameworks/TermindPersistence/SkillStore.swift new file mode 100644 index 00000000..0874225b --- /dev/null +++ b/Frameworks/TermindPersistence/SkillStore.swift @@ -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(sortBy: [SortDescriptor(\.sortIndex)]) + ) + var seen: Set = [] + 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 {} diff --git a/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift b/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift new file mode 100644 index 00000000..f8ae9448 --- /dev/null +++ b/Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift @@ -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) + } +} diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift new file mode 100644 index 00000000..18dfd651 --- /dev/null +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -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( + 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 { + 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 { + Binding { + username + } set: { value in + username = value + if value != selectedIdentity?.username { identityID = nil } + } + } + + private var passwordInput: Binding { + Binding { + password + } set: { value in + password = value + if !value.isEmpty { identityID = nil } + } + } + + private var keySelection: Binding { + 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." + ) + } + } +} diff --git a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift new file mode 100644 index 00000000..9514f796 --- /dev/null +++ b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift @@ -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.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) + } + } + } +} diff --git a/Frameworks/TermindPresentation/HostConnectionFields.swift b/Frameworks/TermindPresentation/HostConnectionFields.swift index 5ab3c366..281ce4eb 100644 --- a/Frameworks/TermindPresentation/HostConnectionFields.swift +++ b/Frameworks/TermindPresentation/HostConnectionFields.swift @@ -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) diff --git a/Frameworks/TermindPresentation/MetricsDiskTable.swift b/Frameworks/TermindPresentation/MetricsDiskTable.swift new file mode 100644 index 00000000..1903610d --- /dev/null +++ b/Frameworks/TermindPresentation/MetricsDiskTable.swift @@ -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" } ?? "—" + } +} diff --git a/Frameworks/TermindPresentation/MetricsFormat.swift b/Frameworks/TermindPresentation/MetricsFormat.swift new file mode 100644 index 00000000..68beefb2 --- /dev/null +++ b/Frameworks/TermindPresentation/MetricsFormat.swift @@ -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) } +} diff --git a/Frameworks/TermindPresentation/MetricsGPUSection.swift b/Frameworks/TermindPresentation/MetricsGPUSection.swift new file mode 100644 index 00000000..8f720362 --- /dev/null +++ b/Frameworks/TermindPresentation/MetricsGPUSection.swift @@ -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) + } + } +} diff --git a/Frameworks/TermindPresentation/MetricsInstrumentBody.swift b/Frameworks/TermindPresentation/MetricsInstrumentBody.swift new file mode 100644 index 00000000..49f36630 --- /dev/null +++ b/Frameworks/TermindPresentation/MetricsInstrumentBody.swift @@ -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) + } + } + +} diff --git a/Frameworks/TermindPresentation/MetricsRing.swift b/Frameworks/TermindPresentation/MetricsRing.swift new file mode 100644 index 00000000..fccc9f27 --- /dev/null +++ b/Frameworks/TermindPresentation/MetricsRing.swift @@ -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)) + } +} diff --git a/Frameworks/TermindPresentation/MetricsSummaryView.swift b/Frameworks/TermindPresentation/MetricsSummaryView.swift deleted file mode 100644 index 16caf776..00000000 --- a/Frameworks/TermindPresentation/MetricsSummaryView.swift +++ /dev/null @@ -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) - } - } -} diff --git a/Frameworks/TermindPresentation/MetricsSystemPanels.swift b/Frameworks/TermindPresentation/MetricsSystemPanels.swift new file mode 100644 index 00000000..08907db2 --- /dev/null +++ b/Frameworks/TermindPresentation/MetricsSystemPanels.swift @@ -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(@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)) + } +} diff --git a/Frameworks/TermindPresentation/ModelProviderConnectionFields.swift b/Frameworks/TermindPresentation/ModelProviderConnectionFields.swift new file mode 100644 index 00000000..3142a6bc --- /dev/null +++ b/Frameworks/TermindPresentation/ModelProviderConnectionFields.swift @@ -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, endpoint: Binding) { + _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) + } + } + } +} diff --git a/Frameworks/TermindPresentation/PortForwardingFields.swift b/Frameworks/TermindPresentation/PortForwardingFields.swift index cdb048f5..9a23e252 100644 --- a/Frameworks/TermindPresentation/PortForwardingFields.swift +++ b/Frameworks/TermindPresentation/PortForwardingFields.swift @@ -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) } diff --git a/Frameworks/TermindRemoteInfrastructure/Security/SoftwareKeyFileImporter.swift b/Frameworks/TermindRemoteInfrastructure/Security/SoftwareKeyFileImporter.swift new file mode 100644 index 00000000..6a77b430 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Security/SoftwareKeyFileImporter.swift @@ -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) + } +} diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index a297f5d5..92349f99 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -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. diff --git a/Memory/MOBILE_ROLLOUT.md b/Memory/MOBILE_ROLLOUT.md index f37c6a8f..14f577c1 100644 --- a/Memory/MOBILE_ROLLOUT.md +++ b/Memory/MOBILE_ROLLOUT.md @@ -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. diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 3031c9ef..034a16ec 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -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. diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 5748fb83..604017e4 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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) { + 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" diff --git a/TFI/App/MobileAssistantModel.swift b/TFI/App/MobileAssistantModel.swift index 89e3d99d..58bf4cb2 100644 --- a/TFI/App/MobileAssistantModel.swift +++ b/TFI/App/MobileAssistantModel.swift @@ -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? 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.") diff --git a/TFI/App/MobileKeyEditorModel.swift b/TFI/App/MobileKeyEditorModel.swift index 7f6c4aa7..b9f40051 100644 --- a/TFI/App/MobileKeyEditorModel.swift +++ b/TFI/App/MobileKeyEditorModel.swift @@ -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 = "" } } diff --git a/TFI/App/MobileMetricsModel.swift b/TFI/App/MobileMetricsModel.swift index 4099d2e3..d4951ae0 100644 --- a/TFI/App/MobileMetricsModel.swift +++ b/TFI/App/MobileMetricsModel.swift @@ -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] = [:] + 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") } } } diff --git a/TFI/App/MobileModelDiscovery.swift b/TFI/App/MobileModelDiscovery.swift new file mode 100644 index 00000000..dde2dd92 --- /dev/null +++ b/TFI/App/MobileModelDiscovery.swift @@ -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? + 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 + } +} diff --git a/TFI/App/MobileProviderEditorModel.swift b/TFI/App/MobileProviderEditorModel.swift new file mode 100644 index 00000000..7f5662a3 --- /dev/null +++ b/TFI/App/MobileProviderEditorModel.swift @@ -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 = "" + } +} diff --git a/TFI/App/MobileWorkspace.swift b/TFI/App/MobileWorkspace.swift index a1e569de..fcfe94fe 100644 --- a/TFI/App/MobileWorkspace.swift +++ b/TFI/App/MobileWorkspace.swift @@ -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") } diff --git a/TFI/App/TermindIOSApp.swift b/TFI/App/TermindIOSApp.swift index 120926bd..f87f4e9d 100644 --- a/TFI/App/TermindIOSApp.swift +++ b/TFI/App/TermindIOSApp.swift @@ -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 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( diff --git a/TFI/Features/MobileAISettingsView.swift b/TFI/Features/MobileAISettingsView.swift index 3f8617e8..a3fd5af6 100644 --- a/TFI/Features/MobileAISettingsView.swift +++ b/TFI/Features/MobileAISettingsView.swift @@ -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() + @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() + } + }) + } + } +} diff --git a/TFI/Features/MobileAppSettingsView.swift b/TFI/Features/MobileAppSettingsView.swift new file mode 100644 index 00000000..4116b9f0 --- /dev/null +++ b/TFI/Features/MobileAppSettingsView.swift @@ -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) } + } +} diff --git a/TFI/Features/MobileAssistantView.swift b/TFI/Features/MobileAssistantView.swift index dd30fedc..ded8155f 100644 --- a/TFI/Features/MobileAssistantView.swift +++ b/TFI/Features/MobileAssistantView.swift @@ -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) diff --git a/TFI/Features/MobileCommandHistoryView.swift b/TFI/Features/MobileCommandHistoryView.swift new file mode 100644 index 00000000..ab442596 --- /dev/null +++ b/TFI/Features/MobileCommandHistoryView.swift @@ -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() } + } +} diff --git a/TFI/Features/MobileConnectionJourneyView.swift b/TFI/Features/MobileConnectionJourneyView.swift new file mode 100644 index 00000000..98706aa6 --- /dev/null +++ b/TFI/Features/MobileConnectionJourneyView.swift @@ -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) + } +} diff --git a/TFI/Features/MobileCredentialsView.swift b/TFI/Features/MobileCredentialsView.swift index 1c3a898e..006c06ae 100644 --- a/TFI/Features/MobileCredentialsView.swift +++ b/TFI/Features/MobileCredentialsView.swift @@ -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) } }) } } diff --git a/TFI/Features/MobileEditorSheet.swift b/TFI/Features/MobileEditorSheet.swift index ad781391..0fb48ad0 100644 --- a/TFI/Features/MobileEditorSheet.swift +++ b/TFI/Features/MobileEditorSheet.swift @@ -17,7 +17,7 @@ struct MobileEditorSheet: 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: View { .onDisappear { saveTask?.cancel() } } } + +private struct MobileFormTextFieldStyle: TextFieldStyle { + func _body(configuration: TextField) -> some View { + configuration.multilineTextAlignment(.trailing) + } +} diff --git a/TFI/Features/MobileForwardingEditor.swift b/TFI/Features/MobileForwardingEditor.swift new file mode 100644 index 00000000..3e89c5be --- /dev/null +++ b/TFI/Features/MobileForwardingEditor.swift @@ -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) }) + } +} diff --git a/TFI/Features/MobileForwardingsView.swift b/TFI/Features/MobileForwardingsView.swift deleted file mode 100644 index be08c245..00000000 --- a/TFI/Features/MobileForwardingsView.swift +++ /dev/null @@ -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) - }) - } -} diff --git a/TFI/Features/MobileHostEditor.swift b/TFI/Features/MobileHostEditor.swift index d9764748..021ec40c 100644 --- a/TFI/Features/MobileHostEditor.swift +++ b/TFI/Features/MobileHostEditor.swift @@ -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) + } + } + } } } diff --git a/TFI/Features/MobileKeyEditor.swift b/TFI/Features/MobileKeyEditor.swift index b41608f9..362a20f4 100644 --- a/TFI/Features/MobileKeyEditor.swift +++ b/TFI/Features/MobileKeyEditor.swift @@ -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 } }) ) { diff --git a/TFI/Features/MobileMetricsView.swift b/TFI/Features/MobileMetricsView.swift index ee95ea3a..36203210 100644 --- a/TFI/Features/MobileMetricsView.swift +++ b/TFI/Features/MobileMetricsView.swift @@ -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) } } diff --git a/TFI/Features/MobileRootView.swift b/TFI/Features/MobileRootView.swift index ed8994a0..d4830806 100644 --- a/TFI/Features/MobileRootView.swift +++ b/TFI/Features/MobileRootView.swift @@ -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 } diff --git a/TFI/Features/MobileSSHImportView.swift b/TFI/Features/MobileSSHImportView.swift deleted file mode 100644 index f198ead3..00000000 --- a/TFI/Features/MobileSSHImportView.swift +++ /dev/null @@ -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) -> 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) } -} diff --git a/TFI/Features/MobileSettingsView.swift b/TFI/Features/MobileSettingsView.swift index 811127ce..8471fc7a 100644 --- a/TFI/Features/MobileSettingsView.swift +++ b/TFI/Features/MobileSettingsView.swift @@ -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") } diff --git a/TFI/Features/MobileSnippetsView.swift b/TFI/Features/MobileSnippetsView.swift index d3c76a80..f215747f 100644 --- a/TFI/Features/MobileSnippetsView.swift +++ b/TFI/Features/MobileSnippetsView.swift @@ -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() }) } } diff --git a/TFI/Features/MobileSyncSettingsView.swift b/TFI/Features/MobileSyncSettingsView.swift new file mode 100644 index 00000000..25f71994 --- /dev/null +++ b/TFI/Features/MobileSyncSettingsView.swift @@ -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 { + 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) + } + } + } +} diff --git a/TFI/Features/MobileTerminalKeyboard.swift b/TFI/Features/MobileTerminalKeyboard.swift index 9f6cf05d..bad13de2 100644 --- a/TFI/Features/MobileTerminalKeyboard.swift +++ b/TFI/Features/MobileTerminalKeyboard.swift @@ -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 diff --git a/TFI/Features/MobileTerminalSettingsView.swift b/TFI/Features/MobileTerminalSettingsView.swift new file mode 100644 index 00000000..590fe7df --- /dev/null +++ b/TFI/Features/MobileTerminalSettingsView.swift @@ -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(_ path: WritableKeyPath) -> Binding { + Binding(get: { model.preferences[keyPath: path] }, set: { _ = model.set($0, for: path) }) + } +} diff --git a/TFI/Features/MobileVaultView.swift b/TFI/Features/MobileVaultView.swift index c322008b..f91f6277 100644 --- a/TFI/Features/MobileVaultView.swift +++ b/TFI/Features/MobileVaultView.swift @@ -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 } + + } } } diff --git a/TFI/Features/MobileWorkspaceView.swift b/TFI/Features/MobileWorkspaceView.swift index eb4d657e..0eba9d05 100644 --- a/TFI/Features/MobileWorkspaceView.swift +++ b/TFI/Features/MobileWorkspaceView.swift @@ -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 } + } + } diff --git a/TFI/Info.plist b/TFI/Info.plist index 61c2c962..6bb1c46d 100644 --- a/TFI/Info.plist +++ b/TFI/Info.plist @@ -1,11 +1,29 @@ - -NSFaceIDUsageDescriptionAuthenticate to use your SSH keys. -TermindKeychainAccessGroup$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) -NSSupportsLiveActivities -CFBundleURLTypes -CFBundleURLNamecom.akinokaede.termind.workspace -CFBundleURLSchemestermind - - + + + CFBundleURLTypes + + + CFBundleURLName + com.akinokaede.termind.workspace + CFBundleURLSchemes + + termind + + + + NSFaceIDUsageDescription + Authenticate to use your SSH keys. + NSSupportsLiveActivities + + TermindCloudKitEnvironment + $(ICLOUD_CONTAINER_ENVIRONMENT) + TermindKeychainAccessGroup + $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) + UIBackgroundModes + + remote-notification + + + diff --git a/TFI/Platform/MobileCloudNotifications.swift b/TFI/Platform/MobileCloudNotifications.swift new file mode 100644 index 00000000..4bb64151 --- /dev/null +++ b/TFI/Platform/MobileCloudNotifications.swift @@ -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 + } +} diff --git a/TFI/Platform/MobileRuntimeVersions.swift b/TFI/Platform/MobileRuntimeVersions.swift new file mode 100644 index 00000000..8668db55 --- /dev/null +++ b/TFI/Platform/MobileRuntimeVersions.swift @@ -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?, length: Int) -> String? { + guard let pointer, length >= 0 else { return nil } + return String( + decoding: UnsafeRawBufferPointer(start: pointer, count: length), + as: UTF8.self + ) + } +} diff --git a/TFI/Platform/MobileTerminalAppearance.swift b/TFI/Platform/MobileTerminalAppearance.swift new file mode 100644 index 00000000..977b022a --- /dev/null +++ b/TFI/Platform/MobileTerminalAppearance.swift @@ -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) + }) + } +} diff --git a/TFI/TFI.entitlements b/TFI/TFI.entitlements new file mode 100644 index 00000000..b103eb1b --- /dev/null +++ b/TFI/TFI.entitlements @@ -0,0 +1,22 @@ + + + + + aps-environment + $(APS_ENVIRONMENT) + com.apple.developer.icloud-container-environment + $(ICLOUD_CONTAINER_ENVIRONMENT) + com.apple.developer.icloud-container-identifiers + + iCloud.com.akinokaede.termind + + com.apple.developer.icloud-services + + CloudKit + + keychain-access-groups + + $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) + + + diff --git a/TFI/Termind.entitlements b/TFI/Termind.entitlements deleted file mode 100644 index f4139b14..00000000 --- a/TFI/Termind.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - keychain-access-groups - $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) - - diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 00a5d3e0..7a189990 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -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) } } diff --git a/TFITests/MobileNavigationParityTests.swift b/TFITests/MobileNavigationParityTests.swift new file mode 100644 index 00000000..4224dc24 --- /dev/null +++ b/TFITests/MobileNavigationParityTests.swift @@ -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())" + } +} diff --git a/TFITests/MobileSettingsPortTests.swift b/TFITests/MobileSettingsPortTests.swift new file mode 100644 index 00000000..c74dd59f --- /dev/null +++ b/TFITests/MobileSettingsPortTests.swift @@ -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 + } +} diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 267302ae..d86672e1 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -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) diff --git a/TFM/App/AppLanguage.swift b/TFM/App/AppLanguage.swift index a6abf9a4..9887c1fe 100644 --- a/TFM/App/AppLanguage.swift +++ b/TFM/App/AppLanguage.swift @@ -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 diff --git a/TFM/App/SettingsKey.swift b/TFM/App/SettingsKey.swift index c2081596..a5a594d3 100644 --- a/TFM/App/SettingsKey.swift +++ b/TFM/App/SettingsKey.swift @@ -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 diff --git a/TFM/Application/Connections/ConnectionJourney.swift b/TFM/Application/Connections/ConnectionJourney.swift index 57f11999..99ac1bcd 100644 --- a/TFM/Application/Connections/ConnectionJourney.swift +++ b/TFM/Application/Connections/ConnectionJourney.swift @@ -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? - - 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 diff --git a/TFM/Application/FeatureModels/CommandHistoryModel.swift b/TFM/Application/FeatureModels/CommandHistoryModel.swift index 8e1b7669..8c2dc760 100644 --- a/TFM/Application/FeatureModels/CommandHistoryModel.swift +++ b/TFM/Application/FeatureModels/CommandHistoryModel.swift @@ -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 diff --git a/TFM/Application/FeatureModels/SkillLibraryModel+BuiltIn.swift b/TFM/Application/FeatureModels/SkillLibraryModel+BuiltIn.swift index 91277391..f6a1a2bb 100644 --- a/TFM/Application/FeatureModels/SkillLibraryModel+BuiltIn.swift +++ b/TFM/Application/FeatureModels/SkillLibraryModel+BuiltIn.swift @@ -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 --no-pager -l - systemctl show -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 -n 200 --no-pager - journalctl -u --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 ` - - **Permissions**: the unit's `User=` against the paths in its `ExecStart=` - - **Out of memory**: `journalctl -k | grep -i "killed process"` - - **Dependency**: `systemctl list-dependencies --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/.service - visudo -c -f - ``` - - 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 diff --git a/TFM/Application/FeatureModels/TerminalSettingsModel.swift b/TFM/Application/FeatureModels/TerminalSettingsModel.swift index ef96fcc6..64c94218 100644 --- a/TFM/Application/FeatureModels/TerminalSettingsModel.swift +++ b/TFM/Application/FeatureModels/TerminalSettingsModel.swift @@ -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, for keyPath: WritableKeyPath) -> 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 diff --git a/TFM/Application/Metrics/MetricsServices.swift b/TFM/Application/Metrics/MetricsServices.swift index 4b350ad6..9316b94b 100644 --- a/TFM/Application/Metrics/MetricsServices.swift +++ b/TFM/Application/Metrics/MetricsServices.swift @@ -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 diff --git a/TFM/Application/Metrics/MetricsSnapshot.swift b/TFM/Application/Metrics/MetricsSnapshot.swift index c51f634a..c369fc34 100644 --- a/TFM/Application/Metrics/MetricsSnapshot.swift +++ b/TFM/Application/Metrics/MetricsSnapshot.swift @@ -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 diff --git a/TFM/Application/Metrics/ServerMetricsModel.swift b/TFM/Application/Metrics/ServerMetricsModel.swift index 30955033..e74b2cf1 100644 --- a/TFM/Application/Metrics/ServerMetricsModel.swift +++ b/TFM/Application/Metrics/ServerMetricsModel.swift @@ -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] = [:] - private var generations: [UUID: UUID] = [:] - private var samplers: [UUID: any MetricsSampling] = [:] - private var submissions: [UUID: [UUID: SSHAuthenticationSubmission]] = [:] - private var maintenance: Task? - 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() - 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?, interfaces: Set?) { - 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 = []) 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 diff --git a/TFM/Application/Repositories/CommandHistoryRepository.swift b/TFM/Application/Repositories/CommandHistoryRepository.swift index 1d6582dc..4a061981 100644 --- a/TFM/Application/Repositories/CommandHistoryRepository.swift +++ b/TFM/Application/Repositories/CommandHistoryRepository.swift @@ -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 diff --git a/TFM/Application/Repositories/SkillLibraryPersistence.swift b/TFM/Application/Repositories/SkillLibraryPersistence.swift index 0e1b36f9..a9ccba0f 100644 --- a/TFM/Application/Repositories/SkillLibraryPersistence.swift +++ b/TFM/Application/Repositories/SkillLibraryPersistence.swift @@ -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 diff --git a/TFM/Application/Repositories/TerminalPreferencesPersistence.swift b/TFM/Application/Repositories/TerminalPreferencesPersistence.swift index 2fb947a8..1f71cde5 100644 --- a/TFM/Application/Repositories/TerminalPreferencesPersistence.swift +++ b/TFM/Application/Repositories/TerminalPreferencesPersistence.swift @@ -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 diff --git a/TFM/Design/TerminalTheme.swift b/TFM/Design/TerminalTheme.swift index 45c3721f..21deb2b1 100644 --- a/TFM/Design/TerminalTheme.swift +++ b/TFM/Design/TerminalTheme.swift @@ -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[.. 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 { diff --git a/TFM/DesignSystem/Connection+Presentation.swift b/TFM/DesignSystem/Connection+Presentation.swift index aa4861b6..31551fcf 100644 --- a/TFM/DesignSystem/Connection+Presentation.swift +++ b/TFM/DesignSystem/Connection+Presentation.swift @@ -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 { diff --git a/TFM/Features/Keychain/KeychainPageView.swift b/TFM/Features/Keychain/KeychainPageView.swift index f3ca61ed..f7d53dfa 100644 --- a/TFM/Features/Keychain/KeychainPageView.swift +++ b/TFM/Features/Keychain/KeychainPageView.swift @@ -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 { diff --git a/TFM/Features/Metrics/MetricsDiskTable.swift b/TFM/Features/Metrics/MetricsDiskTable.swift index 3163c99d..771de53c 100644 --- a/TFM/Features/Metrics/MetricsDiskTable.swift +++ b/TFM/Features/Metrics/MetricsDiskTable.swift @@ -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 diff --git a/TFM/Features/Metrics/MetricsGPUSection.swift b/TFM/Features/Metrics/MetricsGPUSection.swift index 95f24816..cc58b78f 100644 --- a/TFM/Features/Metrics/MetricsGPUSection.swift +++ b/TFM/Features/Metrics/MetricsGPUSection.swift @@ -1,118 +1,3 @@ -import SwiftUI -import TermindDomain +import TermindPresentation -struct MetricsGPUSection: View { - let server: ServerMetric - var initiallyExpanded = false - - 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(TermindColor.warning) - } - 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) - } - - 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: TermindColor.success) - .frame(width: width).help(Text("Encoder Utilization")) - meter("GPU Decoder", value: gpu.decoder, color: TermindColor.success) - .frame(width: width).help(Text("Decoder Utilization")) - meter("GPU Fan", value: gpu.fan, color: TermindColor.warning) - .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(TermindColor.windowSurface, in: RoundedRectangle(cornerRadius: Metrics.Radius.large)) - .overlay { - RoundedRectangle(cornerRadius: Metrics.Radius.large).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) - } - } -} +typealias MetricsGPUSection = TermindPresentation.MetricsGPUSection diff --git a/TFM/Features/Metrics/MetricsInstrumentBody.swift b/TFM/Features/Metrics/MetricsInstrumentBody.swift index ac79c654..8f760779 100644 --- a/TFM/Features/Metrics/MetricsInstrumentBody.swift +++ b/TFM/Features/Metrics/MetricsInstrumentBody.swift @@ -1,75 +1,3 @@ -import SwiftUI -import TermindDomain +import TermindPresentation -/// Shared horizontal instruments and stable-height connection state, without a card surface. -struct MetricsInstrumentBody: View { - 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")) - } - - 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 ? TermindColor.success : TermindColor.secondaryForeground) - .lineLimit(1).minimumScaleFactor(0.7) - Label(title, systemImage: symbol).font(.system(size: 9)).foregroundStyle(.secondary).lineLimit(1) - } - } - -} +typealias MetricsInstrumentBody = TermindPresentation.MetricsInstrumentBody diff --git a/TFM/Features/Metrics/MetricsServerDetails.swift b/TFM/Features/Metrics/MetricsServerDetails.swift index 715a128c..d0770f44 100644 --- a/TFM/Features/Metrics/MetricsServerDetails.swift +++ b/TFM/Features/Metrics/MetricsServerDetails.swift @@ -1,237 +1,14 @@ import SwiftUI -import TermindDomain +import TermindApplication +import TermindPresentation -/// Independently scrollable system panels; the process table owns its own viewport. struct MetricsServerDetails: View { let server: ServerMetric let model: ServerMetricsModel - private var point: MetricsPoint? { server.point } - @State private var virtualInterfacesExpanded = false - var body: some View { VStack(spacing: 12) { MetricsConnectionNotice(server: server, model: model) - 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: TermindColor.danger) - legend("User", value: point?.cpuUser, color: TermindColor.success) - } - HStack { - legend("I/O Wait", value: point?.cpuWait, color: .purple) - legend("Steal", value: point?.cpuSteal, color: TermindColor.warning) - .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) - } + MetricsSystemPanels(server: server) } } - - 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: TermindColor.warning) - figure( - "Total Received", value: MetricsFormat.bytes(point?.networkReceivedTotal), - color: TermindColor.success) - } - 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(TermindColor.warning, lineWidth: 4) - Circle().trim(from: fraction, to: 1).stroke(TermindColor.success, 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 - ? TermindColor.danger : (percent ?? 0) >= 75 ? TermindColor.warning : TermindColor.success - 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(@ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 12, content: content) - .padding(12).frame(maxWidth: .infinity, alignment: .leading) - .background(TermindColor.windowSurface, in: RoundedRectangle(cornerRadius: Metrics.Radius.large)) - .overlay { - RoundedRectangle(cornerRadius: Metrics.Radius.large).strokeBorder(.quaternary, lineWidth: 1) - } - } - - private func figure(_ title: LocalizedStringKey, value: String, color: Color = TermindColor.primaryForeground) - -> 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 TermindColor.secondaryForeground.opacity(0.2) } - var boundary = point.cpuSystem ?? 0 - if position < boundary { return TermindColor.danger } - boundary += point.cpuUser ?? 0 - if position < boundary { return TermindColor.success } - boundary += point.cpuWait ?? 0 - if position < boundary { return .purple } - boundary += point.cpuSteal ?? 0 - if position < boundary { return TermindColor.warning } - return TermindColor.secondaryForeground.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)) - } } diff --git a/TFM/Features/Metrics/MetricsView.swift b/TFM/Features/Metrics/MetricsView.swift index 2e853544..cfd63cd9 100644 --- a/TFM/Features/Metrics/MetricsView.swift +++ b/TFM/Features/Metrics/MetricsView.swift @@ -1,5 +1,6 @@ import SwiftUI import TermindDomain +import TermindPresentation struct ServerMetricsView: View { @Environment(AppModel.self) private var app @@ -173,33 +174,7 @@ struct ServerMetricsCard: View { } -struct MetricsRing: View { - let title: LocalizedStringKey - let value: Double? - private var color: Color { - guard let value else { return TermindColor.secondaryForeground } - return value >= 90 ? TermindColor.danger : value >= 75 ? TermindColor.warning : TermindColor.success - } - 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)) - } -} +typealias MetricsRing = TermindPresentation.MetricsRing struct MetricsProcessList: View { let server: ServerMetric @@ -266,24 +241,7 @@ struct MetricsProcessList: View { } } -nonisolated enum MetricsFormat { - static func percent(_ value: Double?) -> String { - guard let value, value.isFinite else { return "—" } - return value.formatted(.number.precision(.fractionLength(1))) + "%" - } - 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] - } - static func rate(_ value: Double?) -> String { value == nil ? "—" : bytes(value) + "/s" } - static func capacity(_ used: Double?, _ total: Double?) -> String { bytes(used) + " / " + bytes(total) } -} +typealias MetricsFormat = TermindPresentation.MetricsFormat struct MetricsDevicePicker: View { let server: ServerMetric diff --git a/TFM/Features/Settings/ModelProviderEditorSheet.swift b/TFM/Features/Settings/ModelProviderEditorSheet.swift index f8372ff0..c125c586 100644 --- a/TFM/Features/Settings/ModelProviderEditorSheet.swift +++ b/TFM/Features/Settings/ModelProviderEditorSheet.swift @@ -1,6 +1,7 @@ import AgentKit import SwiftUI import TermindDomain +import TermindPresentation @Observable final class ModelProviderEditorSession { @@ -63,15 +64,10 @@ struct ModelProviderEditorPage: View { private func settings(_ provider: ModelProvider) -> some View { Form { - Section { - TextField("Name", text: binding(\.name)) - - Picker("API Type", selection: endpointBinding(\.apiType)) { - ForEach(ModelAPIType.allCases) { Text($0.displayName).tag($0) } - } - } - - endpointSection + ModelProviderConnectionFields( + name: binding(\.name), + endpoint: Binding( + get: { endpoint }, set: { app.aiConfiguration.setEndpoint($0, for: modelProviderID) })) credentialSection Section("Models") { @@ -109,56 +105,6 @@ struct ModelProviderEditorPage: View { .frame(maxWidth: .infinity) } - /// One address, and the two facts derived from it. - /// - /// One field rather than a base and a path, because splitting them made - /// "does the base URL include `/v1`" a question the user had to answer — - /// and it is the question every client of this shape gets wrong. Here they - /// paste whatever their gateway's documentation printed, at whatever depth - /// it printed it, and the two captions say what that produced. - /// - /// The captions are the point of the section. `Full URL` is read from the - /// same value the request is built from, so it cannot drift from where - /// requests land; the line under it names the body shape, which is how - /// `Automatic` explains its own answer and how an explicit pick that - /// disagrees with the URL stops being a silent surprise. - @ViewBuilder - private var endpointSection: some View { - let endpoint = endpoint - - Section { - TextField( - "URL", - text: endpointBinding(\.url), - prompt: Text(verbatim: "https://ai.termind.akinokaede.com/v1") - ) - - if endpoint.isConfigured { - LabeledContent("Full URL") { - Text(endpoint.requestURL) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .textSelection(.enabled) - .multilineTextAlignment(.trailing) - .fixedSize(horizontal: false, vertical: true) - } - - Text("Requests use the \(endpoint.format.displayName) format.") - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - if endpoint.requestURL.contains(ModelProvider.modelToken) { - Text("\(ModelProvider.modelToken) is replaced with the model id.") - .font(.caption) - .foregroundStyle(.secondary) - } - } - } header: { - Text("Endpoint") - } - } - @ViewBuilder private var credentialSection: some View { Section { diff --git a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift index 4599efe6..d62085aa 100644 --- a/TFM/Features/Workspaces/WorkspaceNavigatorView.swift +++ b/TFM/Features/Workspaces/WorkspaceNavigatorView.swift @@ -147,7 +147,7 @@ struct WorkspaceNavigatorView: View { private var workspaceSection: some View { Group { - navigatorSectionHeader("Workspace", isExpanded: $workspaceExpanded) + navigatorSectionHeader("Workspaces", isExpanded: $workspaceExpanded) .onGeometryChange(for: CGRect.self) { $0.frame(in: .named(Self.coordinateSpace)) } action: { diff --git a/TFM/Infrastructure/Cloud/SettingsSyncCoordinator.swift b/TFM/Infrastructure/Cloud/SettingsSyncCoordinator.swift index c145d694..9965a0ca 100644 --- a/TFM/Infrastructure/Cloud/SettingsSyncCoordinator.swift +++ b/TFM/Infrastructure/Cloud/SettingsSyncCoordinator.swift @@ -1,605 +1,3 @@ -import CloudKit -import CryptoKit -import Foundation -import SwiftData -import TermindDomain -import TermindPersistence +import TermindInfrastructure -/// Settings use their own zone, account binding, and upload queue. -@MainActor -@Observable -final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sendable { - nonisolated static let containerIdentifier = "iCloud.com.akinokaede.termind" - // Keep the initial AI-sync identifiers so existing replicas remain reachable. - nonisolated static let zoneName = "TermindAIConfigurationV1" - nonisolated static let recordType = "AIConfigurationItem" - nonisolated static let subscriptionID = "termind-ai-configuration-v1-changes" - 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? - private var operationID = UUID() - private var isStarted = false - private var terminalError: String? - - private(set) var status: VaultSyncStatus = .checkingAccount - var didApplyRemote: ((SettingsSyncSnapshot, Set) -> Void)? - - var isEnabled: Bool { - guard - defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool - ?? SettingsKey.defaultICloudSyncEnabled - else { return false } - return defaults.object(forKey: SettingsKey.settingsSyncEnabled) as? Bool - ?? SettingsKey.defaultSettingsSyncEnabled - } - - 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) } - } - - nonisolated static var runtimeEnvironmentIdentifier: String { - Bundle.main.object(forInfoDictionaryKey: "TermindCloudKitEnvironment") as? String - ?? "Unknown" - } - - func start() { - guard isEnabled else { - pause() - return - } - guard !isStarted else { return } - isStarted = true - run { [weak self] in try await self?.prepareAndSynchronize() } - } - - func setEnabled(_ enabled: Bool) { - defaults.set(enabled, forKey: SettingsKey.settingsSyncEnabled) - if enabled { start() } else { pause() } - } - - 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() } } - } - - func retry() { - guard isEnabled else { return } - terminalError = nil - if engine == nil { - isStarted = false - start() - } else { - run { [weak self] in try await self?.synchronizeNow() } - } - } - - 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() } - } - - 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) { - 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, - needsUpload: Set - ) - { - 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 - } - - nonisolated func nextFetchChangesOptions( - _ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine - ) async -> CKSyncEngine.FetchChangesOptions { - .init(scope: .zoneIDs([CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)])) - } - - nonisolated func handleEvent( - _ event: CKSyncEngine.Event, syncEngine: CKSyncEngine - ) async { - await handle(event, engine: syncEngine) - } - - 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: SettingsKey.settingsSyncEnvironment) - != environmentIdentifier - else { return } - - try journal.markAllEntriesNeedsUploadAfterEnvironmentChange() - try context.save() - defaults.set(environmentIdentifier, forKey: SettingsKey.settingsSyncEnvironment) - terminalError = nil - } - - 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 - } - - 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 - } - - 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) - } - - 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.") - } - } -} +typealias SettingsSyncCoordinator = TermindInfrastructure.SettingsSyncCoordinator diff --git a/TFM/Infrastructure/Cloud/SettingsSyncJournal.swift b/TFM/Infrastructure/Cloud/SettingsSyncJournal.swift index ddc89ab6..1ac07510 100644 --- a/TFM/Infrastructure/Cloud/SettingsSyncJournal.swift +++ b/TFM/Infrastructure/Cloud/SettingsSyncJournal.swift @@ -1,315 +1,5 @@ -import Foundation -import SwiftData -import TermindDomain import TermindPersistence -nonisolated struct SettingsRemoteMerge: Sendable { - var accepted: [SettingsEnvelope] = [] - var needsUpload: Set = [] -} - -nonisolated struct SettingsOutgoingEntry: Sendable { - var envelope: SettingsEnvelope - var systemFieldsData: Data? -} - -@MainActor -final class SettingsSyncJournal { - private let context: ModelContext - - init(context: ModelContext) { self.context = context } - - func state() throws -> PersistentAIConfigurationSyncState { - var descriptor = FetchDescriptor() - descriptor.fetchLimit = 1 - if let existing = try context.fetch(descriptor).first { return existing } - let state = PersistentAIConfigurationSyncState() - context.insert(state) - return state - } - - @discardableResult - func bootstrapIfNeeded(_ snapshot: SettingsSyncSnapshot) throws -> SettingsMutationSet { - let state = try state() - guard !state.bootstrapCompleted else { return .empty } - let existing = try entriesByName() - var changed = Set() - 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) - } - - 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() - - 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) - } - - func pendingEnvelopes() throws -> [SettingsEnvelope] { - try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.needsUpload } - ) - ).compactMap(\.envelope) - } - - 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) - } - - func envelope(for key: SettingsRecordKey) throws -> SettingsEnvelope? { - try entry(recordName: key.recordName)?.envelope - } - - 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 - } - - func acceptRemote(_ envelopes: [SettingsEnvelope]) throws { - var entries = try entriesByName() - for envelope in envelopes { - try store(envelope, needsUpload: false, in: &entries) - } - } - - func markNeedsUpload(_ keys: Set) throws { - let entries = try entriesByName() - for key in keys { entries[key.recordName]?.needsUpload = true } - } - - func markUploaded(_ key: SettingsRecordKey, version: VaultVersion) throws { - guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else { - return - } - row.needsUpload = false - } - - func updateSystemFields(_ data: Data?, for key: SettingsRecordKey) throws { - try entry(recordName: key.recordName)?.systemFieldsData = data - } - - func quarantine(recordName: String, payloadData: Data, reason: String) throws { - var descriptor = FetchDescriptor( - 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 - )) - } - } - - func clearQuarantine(recordName: String) throws { - try context.delete( - model: PersistentAIConfigurationSyncQuarantine.self, - where: #Predicate { $0.recordName == recordName } - ) - } - - func firstQuarantineReason() throws -> String? { - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] - ) - descriptor.fetchLimit = 1 - return try context.fetch(descriptor).first?.reason - } - - func recordCurrentSnapshot(_ snapshot: SettingsSyncSnapshot, now: Date = .now) throws - -> SettingsMutationSet - { - let state = try state() - let current = snapshot.recordPayloads - var entries = try entriesByName() - var changed = Set() - - 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) - } - - func markAllLiveRecordsNeedsUploadForNewAccount() throws { - for row in try context.fetch(FetchDescriptor()) { - 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 - } - - func markAllEntriesNeedsUploadAfterZoneReset() throws { - for row in try context.fetch(FetchDescriptor()) { - row.needsUpload = true - row.systemFieldsData = nil - } - let state = try state() - state.engineStateData = nil - state.lastSuccessfulSync = nil - } - - func markAllEntriesNeedsUploadAfterEnvironmentChange() throws { - try markAllEntriesNeedsUploadAfterZoneReset() - } - - private func entry(recordName: String) throws -> PersistentAIConfigurationSyncEntry? { - var descriptor = FetchDescriptor( - 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()).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 - } - ) - } -} +typealias SettingsRemoteMerge = SharedSettingsRemoteMerge +typealias SettingsOutgoingEntry = SharedSettingsOutgoingEntry +typealias SettingsSyncJournal = SharedSettingsSyncJournal diff --git a/TFM/Infrastructure/Cloud/SettingsSyncPersistence.swift b/TFM/Infrastructure/Cloud/SettingsSyncPersistence.swift index dd5d24cc..b6182e4e 100644 --- a/TFM/Infrastructure/Cloud/SettingsSyncPersistence.swift +++ b/TFM/Infrastructure/Cloud/SettingsSyncPersistence.swift @@ -1,270 +1,6 @@ -import AgentKit -import Foundation -import SwiftData -import TermindDomain import TermindPersistence -nonisolated struct SettingsSyncSnapshot: Sendable { - var configuration: AIConfigurationSnapshot - var skills: [AgentSkill] - var webSearch: AIWebSearchPreferences - var terminal = TerminalPreferences() - - 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 -final class SettingsSyncPersistence { - let context: ModelContext - private let defaults: UserDefaults - private let ai: AIPersistence - private let skills: SkillPersistence - let journal: SettingsSyncJournal - var didRecordChanges: ((SettingsMutationSet) -> Void)? - - init(context: ModelContext, defaults: UserDefaults = .standard) { - self.context = context - self.defaults = defaults - ai = AIPersistence(context: context) - skills = SkillPersistence(context: context) - journal = SettingsSyncJournal(context: context) - } - - func prepare() throws { - let seeded = defaults.integer(forKey: SettingsKey.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: SettingsKey.seededSkillGeneration) - let snapshot = try load() - mirror(snapshot.webSearch) - snapshot.terminal.mirror(to: defaults) - didRecordChanges?(mutations) - } - - static func normalizeBuiltIns(_ installed: [AgentSkill], seededGeneration: Int) - -> (skills: [AgentSkill], deleted: Set) - { - var result = installed - var deleted = Set() - 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) - } - - func load() throws -> SettingsSyncSnapshot { - SettingsSyncSnapshot( - configuration: try ai.load(preservingUnresolvedReferences: true), - skills: try skills.load(), webSearch: try preferences().value, terminal: try terminalPreferences().load()) - } - - func saveConfiguration(_ configuration: AIConfigurationSnapshot) throws { - try saveLocal { try ai.replace(configuration) } - } - - func saveSkills(_ values: [AgentSkill]) throws { - try saveLocal { try skills.replace(values) } - } - - 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) - } - - func applyRemote(_ envelopes: [SettingsEnvelope]) throws - -> ( - snapshot: SettingsSyncSnapshot, acceptedKeys: Set, - needsUpload: Set - ) - { - 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()).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()).first { return row } - let row = try PersistentTerminalPreferences(TerminalPreferences(defaults: defaults)) - context.insert(row) - return row - } - - func loadTerminal() throws -> TerminalPreferences { try terminalPreferences().load() } - - 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()).first { return row } - let row = PersistentAIWebSearchPreferences( - AIWebSearchPreferences( - isEnabled: defaults.object(forKey: SettingsKey.webSearchEnabled) as? Bool - ?? SettingsKey.defaultWebSearchEnabled, - prefersBuiltIn: defaults.object(forKey: SettingsKey.webSearchBuiltIn) as? Bool - ?? SettingsKey.defaultWebSearchBuiltIn)) - context.insert(row) - return row - } - - private func mirror(_ value: AIWebSearchPreferences) { - defaults.set(value.isEnabled, forKey: SettingsKey.webSearchEnabled) - defaults.set(value.prefersBuiltIn, forKey: SettingsKey.webSearchBuiltIn) - } -} - -@MainActor -final class SyncedAIConfigurationPersistence: AIConfigurationPersisting { - private let store: SettingsSyncPersistence - init(_ store: SettingsSyncPersistence) { self.store = store } - func load() throws -> AIConfigurationSnapshot { try store.load().configuration } - func save(_ snapshot: AIConfigurationSnapshot) throws { try store.saveConfiguration(snapshot) } -} - -@MainActor -final class SyncedSkillLibraryPersistence: SkillLibraryPersisting { - private let store: SettingsSyncPersistence - init(_ store: SettingsSyncPersistence) { self.store = store } - func load() throws -> [AgentSkill] { try store.load().skills } - func save(_ skills: [AgentSkill]) throws { try store.saveSkills(skills) } -} - -extension SettingsSyncPersistence: AIWebSearchPersisting { - func loadWebSearch() throws -> AIWebSearchPreferences { try load().webSearch } -} - -extension SettingsSyncPersistence: TerminalPreferencesPersisting {} +typealias SettingsSyncSnapshot = SharedSettingsSyncSnapshot +typealias SettingsSyncPersistence = SharedSettingsSyncPersistence +typealias SyncedAIConfigurationPersistence = SharedSyncedAIConfigurationPersistence +typealias SyncedSkillLibraryPersistence = SharedSyncedSkillLibraryPersistence diff --git a/TFM/Infrastructure/Cloud/VaultSyncCoordinator.swift b/TFM/Infrastructure/Cloud/VaultSyncCoordinator.swift index 0710cb13..bf2e8ba3 100644 --- a/TFM/Infrastructure/Cloud/VaultSyncCoordinator.swift +++ b/TFM/Infrastructure/Cloud/VaultSyncCoordinator.swift @@ -1,584 +1,3 @@ -import CloudKit -import CryptoKit -import Foundation -import SwiftData -import TermindDomain -import TermindPersistence +import TermindInfrastructure -@MainActor -@Observable -final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sendable { - nonisolated static let containerIdentifier = "iCloud.com.akinokaede.termind" - nonisolated static let zoneName = "TermindVaultV1" - nonisolated static let recordType = "VaultItem" - nonisolated static let subscriptionID = "termind-vault-v1-changes" - 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? - private var operationID = UUID() - private var isReadyToSend = false - private var isStarted = false - private var terminalError: String? - - private(set) var status: VaultSyncStatus = .checkingAccount - var didApplyRemote: ((VaultSnapshot, Set) -> Void)? - - var isEnabled: Bool { - guard - defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool - ?? SettingsKey.defaultICloudSyncEnabled - else { return false } - return defaults.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool - ?? SettingsKey.defaultVaultSyncEnabled - } - - 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() - } - - nonisolated static var runtimeEnvironmentIdentifier: String { - Bundle.main.object(forInfoDictionaryKey: "TermindCloudKitEnvironment") as? String - ?? "Unknown" - } - - func start() { - guard isEnabled else { - pause() - return - } - guard !isStarted else { return } - isStarted = true - run { [weak self] in try await self?.prepareAndSynchronize() } - } - - func setEnabled(_ enabled: Bool) { - defaults.set(enabled, forKey: SettingsKey.vaultSyncEnabled) - if enabled { start() } else { pause() } - } - - 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() } } - } - - func retry() { - guard isEnabled else { return } - terminalError = nil - if engine == nil { - isStarted = false - start() - } else { - run { [weak self] in try await self?.synchronizeNow() } - } - } - - 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() } - } - - 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) { - 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) - } - } - - nonisolated func nextFetchChangesOptions( - _ context: CKSyncEngine.FetchChangesContext, syncEngine: CKSyncEngine - ) async -> CKSyncEngine.FetchChangesOptions { - .init(scope: .zoneIDs([CKRecordZone.ID(zoneName: Self.zoneName, ownerName: CKCurrentUserDefaultName)])) - } - - // MARK: CKSyncEngineDelegate - - nonisolated func handleEvent( - _ event: CKSyncEngine.Event, syncEngine: CKSyncEngine - ) async { - await handle(event, engine: syncEngine) - } - - 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: SettingsKey.vaultSyncEnvironment) - != environmentIdentifier - else { return } - - try journal.markAllEntriesNeedsUploadAfterEnvironmentChange() - try context.save() - defaults.set(environmentIdentifier, forKey: SettingsKey.vaultSyncEnvironment) - terminalError = nil - } - - // MARK: Record coding - - 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 - } - - 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 - } - - 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) - } - - 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() } -} +typealias VaultSyncCoordinator = TermindInfrastructure.VaultSyncCoordinator diff --git a/TFM/Infrastructure/Database/PersistentTerminalPreferences.swift b/TFM/Infrastructure/Database/PersistentTerminalPreferences.swift deleted file mode 100644 index d572d015..00000000 --- a/TFM/Infrastructure/Database/PersistentTerminalPreferences.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation -import SwiftData -import TermindDomain - -extension TerminalPreferences { - nonisolated init(defaults: UserDefaults) { - self.init() - fontSize = defaults.object(forKey: SettingsKey.terminalFontSize) as? Double ?? fontSize - fontFollowTheme = defaults.object(forKey: SettingsKey.terminalFontFollowTheme) as? Bool ?? fontFollowTheme - darkTheme = defaults.string(forKey: SettingsKey.terminalThemeDark) ?? darkTheme - lightTheme = defaults.string(forKey: SettingsKey.terminalThemeLight) ?? lightTheme - darkCustomEnabled = - defaults.object(forKey: SettingsKey.terminalCustomConfigurationDarkEnabled) as? Bool ?? false - lightCustomEnabled = - defaults.object(forKey: SettingsKey.terminalCustomConfigurationLightEnabled) as? Bool ?? false - darkCustomConfiguration = defaults.string(forKey: SettingsKey.terminalCustomConfigurationDark) ?? "" - lightCustomConfiguration = defaults.string(forKey: SettingsKey.terminalCustomConfigurationLight) ?? "" - shellIntegration = defaults.object(forKey: SettingsKey.shellIntegration) as? Bool ?? shellIntegration - remoteWindowTitleInMenuBar = - defaults.object(forKey: SettingsKey.remoteWindowTitleInMenuBar) as? Bool - ?? remoteWindowTitleInMenuBar - } - - nonisolated func mirror(to defaults: UserDefaults) { - defaults.set(fontSize, forKey: SettingsKey.terminalFontSize) - defaults.set(fontFollowTheme, forKey: SettingsKey.terminalFontFollowTheme) - defaults.set(darkTheme, forKey: SettingsKey.terminalThemeDark) - defaults.set(lightTheme, forKey: SettingsKey.terminalThemeLight) - defaults.set(darkCustomEnabled, forKey: SettingsKey.terminalCustomConfigurationDarkEnabled) - defaults.set(lightCustomEnabled, forKey: SettingsKey.terminalCustomConfigurationLightEnabled) - defaults.set(darkCustomConfiguration, forKey: SettingsKey.terminalCustomConfigurationDark) - defaults.set(lightCustomConfiguration, forKey: SettingsKey.terminalCustomConfigurationLight) - defaults.set(shellIntegration, forKey: SettingsKey.shellIntegration) - defaults.set(remoteWindowTitleInMenuBar, forKey: SettingsKey.remoteWindowTitleInMenuBar) - } -} diff --git a/TFM/Infrastructure/Database/SkillPersistence.swift b/TFM/Infrastructure/Database/SkillPersistence.swift index 03a65d1b..16e157b0 100644 --- a/TFM/Infrastructure/Database/SkillPersistence.swift +++ b/TFM/Infrastructure/Database/SkillPersistence.swift @@ -1,44 +1,3 @@ -import AgentKit -import Foundation -import SwiftData import TermindPersistence -/// 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 -final class SkillPersistence { - private let context: ModelContext - - init(context: ModelContext) { - self.context = context - } - - func load() throws -> [AgentSkill] { - let rows = try context.fetch( - FetchDescriptor(sortBy: [SortDescriptor(\.sortIndex)]) - ) - var seen: Set = [] - return rows.compactMap { row in - guard seen.insert(row.id).inserted else { return nil } - return row.value - } - } - - func save(_ skills: [AgentSkill]) throws { - try context.commit { try replace(skills) } - } - - /// Stages rows in the caller-owned transaction. - 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 {} +typealias SkillPersistence = SharedSkillPersistence diff --git a/TFM/Infrastructure/Database/SwiftDataCommandHistoryRepository.swift b/TFM/Infrastructure/Database/SwiftDataCommandHistoryRepository.swift index 996c9b28..c819792e 100644 --- a/TFM/Infrastructure/Database/SwiftDataCommandHistoryRepository.swift +++ b/TFM/Infrastructure/Database/SwiftDataCommandHistoryRepository.swift @@ -1,67 +1,7 @@ -import Foundation import SwiftData -import TermindDomain import TermindPersistence -import struct TermindDomain.Host - -@MainActor -final class SwiftDataCommandHistoryRepository: CommandHistoryRepository { - private let context: ModelContext - - init(context: ModelContext) { self.context = context } - - func entries(for hostID: Host.ID) throws -> [CommandHistoryEntry] { - try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.hostID == hostID }, - sortBy: [SortDescriptor(\.executedAt, order: .reverse)] - ) - ).map(\.value) - } - - func insert(_ entry: CommandHistoryEntry, limit: Int) throws { - try context.commit { - let hostID = entry.hostID - var descriptor = FetchDescriptor( - 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)) - } - } - - func update(_ entry: CommandHistoryEntry) throws { - let id = entry.id - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - guard let existing = try context.fetch(descriptor).first else { return } - try context.commit { existing.apply(entry) } - } - - func delete(_ id: CommandHistoryEntry.ID) throws { - try context.commit { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - if let entry = try context.fetch(descriptor).first { context.delete(entry) } - } - } - - func clear(for hostID: Host.ID) throws { - try context.commit { - try context.delete( - model: PersistentCommandHistoryEntry.self, - where: #Predicate { $0.hostID == hostID } - ) - } - } -} +typealias SwiftDataCommandHistoryRepository = SharedSwiftDataCommandHistoryRepository extension CommandHistoryModel { convenience init(modelContext: ModelContext) { diff --git a/TFM/Termind.entitlements b/TFM/TFM.entitlements similarity index 100% rename from TFM/Termind.entitlements rename to TFM/TFM.entitlements diff --git a/TFM/UI/Components/ConnectionJourneyView.swift b/TFM/UI/Components/ConnectionJourneyView.swift index 16f45b05..0b5d388c 100644 --- a/TFM/UI/Components/ConnectionJourneyView.swift +++ b/TFM/UI/Components/ConnectionJourneyView.swift @@ -3,6 +3,7 @@ import ListViewKit import SwiftUI import TermindApplication import TermindDomain +import TermindPresentation import struct TermindDomain.Host @@ -164,22 +165,7 @@ struct ConnectionJourneyView: View { VStack(spacing: 0) { header - 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) + ConnectionJourneyRouteView(journey: journey) content .frame(maxWidth: 660) @@ -230,49 +216,6 @@ struct ConnectionJourneyView: View { .padding(.horizontal, 34) } - 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.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 func copyLogs() { #if canImport(AppKit) NSPasteboard.general.clearContents() @@ -586,206 +497,6 @@ private struct JourneyReconnectStatus: View { } } -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: TermindColor.accent - case .failed: TermindColor.danger - } - } -} - -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(Color(nsColor: .windowBackgroundColor)) - .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(TermindColor.selectedForeground) - } - } - .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: "laptopcomputer" - } - 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(TermindColor.accent) - .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(TermindColor.accent).frame(width: 28, height: 28) - Image(systemName: checkpoint == .authentication ? "key.fill" : "checkmark.shield.fill") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(TermindColor.selectedForeground) - } - .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 : Motion.panel, value: checkpoint) - } -} - -private struct JourneyTravelingLine: View { - let reduceMotion: Bool - - var body: some View { - if reduceMotion { - HStack(spacing: 0) { - Rectangle().fill(TermindColor.accent).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: [ - TermindColor.accent, - TermindColor.accent.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(TermindColor.accent.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: [ - TermindColor.accent.opacity(0.15), - TermindColor.accent, - TermindColor.accent.opacity(0.32), - ], - center: .center - ), - style: StrokeStyle(lineWidth: 4, lineCap: .round) - ) - .rotationEffect(.degrees(rotation)) - .frame(width: size, height: size) - } - } - } -} - /// Shared SSH credential form used by both the connection Journey and Agent. /// The containing surface owns placement and queue state; this view owns the /// established Custom/Identity choices, button language, and submission flow. @@ -794,285 +505,16 @@ struct SSHAuthenticationForm: View { let submit: (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void let cancel: () -> Void var usesContainerChrome = true - @Environment(AppModel.self) private var app - @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 } - - init( - prompt: SSHAuthenticationPrompt, - submit: @escaping (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void, - cancel: @escaping () -> Void, - usesContainerChrome: Bool = true - ) { - 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" - } - var body: some View { - form - .connectionFormChrome(isEnabled: usesContainerChrome) - .activeFormKeyboardActions( - isDefaultEnabled: canContinue, - onDefault: { commit(save: !needsPassphrase) }, - onCancel: 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(TermindColor.accent) - } - - if let message = prompt.failureMessage { - Label(message, systemImage: "exclamationmark.circle.fill") - .font(.caption).foregroundStyle(TermindColor.danger) - .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 !app.hostLibrary.identities.isEmpty { Divider() } - ForEach(app.hostLibrary.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 !app.hostLibrary.keys.isEmpty { Divider() } - ForEach(app.hostLibrary.keys) { key in - Button { - keySelection.wrappedValue = key.id - } label: { - selectionMenuLabel( - key.label, selected: keyID == key.id - ) - } - } - } - } - } - } - } - .frame(maxWidth: .infinity) - .textFieldStyle(.roundedBorder) - - 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 + SharedSSHAuthenticationForm( + prompt: prompt, submit: submit, cancel: cancel, usesContainerChrome: false, + library: app.hostLibrary, keyboard: DesktopConnectionFormKeyboard() ) - } - - private var selectedIdentity: Identity? { - identityID.flatMap { app.hostLibrary.identity($0) } - } - - private var identityLabel: String { - selectedIdentity.map { "\($0.name) · \($0.username)" } - ?? String(localized: "Custom") - } - - private var keyLabel: String { - keyID.flatMap { app.hostLibrary.key($0) }?.label ?? String(localized: "None") - } - - private func fullWidthMenu( - 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()) - } - .menuStyle(.button) - .menuIndicator(.hidden) - .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 { - Binding { - identityID - } set: { id in - identityID = id - guard let identity = id.flatMap({ app.hostLibrary.identity($0) }) else { return } - username = identity.username - password = "" - passphrase = "" - keyID = identity.keyID - } - } - - private var usernameInput: Binding { - Binding { - username - } set: { value in - username = value - if value != selectedIdentity?.username { identityID = nil } - } - } - - private var passwordInput: Binding { - Binding { - password - } set: { value in - password = value - if !value.isEmpty { identityID = nil } - } - } - - private var keySelection: Binding { - Binding { - keyID - } set: { value in - keyID = value - if value != selectedIdentity?.keyID { identityID = nil } - passphrase = "" - } + .connectionFormChrome(isEnabled: usesContainerChrome) } } -/// Shared host-key confirmation used by Journey, Agent, and the external MCP -/// authentication window. The containing surface owns queueing and placement. struct SSHHostKeyConfirmationForm: View { let prompt: SSHHostKeyPrompt var displayAddress: String? = nil @@ -1080,86 +522,20 @@ struct SSHHostKeyConfirmationForm: View { let trust: (() -> Void)? let deny: () -> Void var usesContainerChrome = true - - private var observation: SSHHostKeyObservation { prompt.observation } - - private var isAdditionalKey: Bool { - if case .additionalKey = prompt.kind { return true } - return false - } - var body: some View { - form - .connectionFormChrome(isEnabled: usesContainerChrome) - .activeFormKeyboardActions( - isDefaultEnabled: true, - onDefault: { trust?() ?? proceed() }, - onCancel: deny - ) + SharedSSHHostKeyConfirmationForm( + prompt: prompt, displayAddress: displayAddress, proceed: proceed, + trust: trust, deny: deny, usesContainerChrome: false, keyboard: DesktopConnectionFormKeyboard() + ) + .connectionFormChrome(isEnabled: usesContainerChrome) } +} - 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 ? TermindColor.warning : TermindColor.accent - ) - } - 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." - ) - } +private struct DesktopConnectionFormKeyboard: ConnectionFormKeyboardAdapting { + func bridge(canSubmit: Bool, submit: @escaping () -> Void, cancel: @escaping () -> Void) -> AnyView { + AnyView( + Color.clear.activeFormKeyboardActions(isDefaultEnabled: canSubmit, onDefault: submit, onCancel: cancel) + .frame(width: 0, height: 0)) } } diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index df37dca8..aaa6f1fa 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -45,7 +45,8 @@ struct ArchitectureBoundaryTests { #expect(!records.contains(excluded)) } let terminal = try String( - contentsOf: sourceRoot.appending(path: "Infrastructure/Database/PersistentTerminalPreferences.swift"), + contentsOf: sourceRoot.appending( + path: "../Frameworks/TermindPersistence/TerminalPreferences+Defaults.swift"), encoding: .utf8) #expect(!terminal.contains("dictionaryRepresentation")) #expect(!terminal.contains("agentPermissionMode")) @@ -55,7 +56,8 @@ struct ArchitectureBoundaryTests { func metricsViewsCannotOwnTransportsAndSecretsAreResolvedOffMainActor() throws { try assertNoConcreteImplementations(in: "Features/Metrics") let model = try String( - contentsOf: sourceRoot.appending(path: "Application/Metrics/ServerMetricsModel.swift"), encoding: .utf8) + contentsOf: sourceRoot.appending(path: "../Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift"), + encoding: .utf8) #expect(!model.contains("hosts.sshConnectionConfiguration(")) let preparation = try String( contentsOf: sourceRoot.appending( @@ -79,7 +81,10 @@ struct ArchitectureBoundaryTests { ] { #expect(!tool.contains(forbidden)) } - for path in ["Application/Metrics/MetricsSnapshot.swift", "Application/Metrics/ServerMetricsModel.swift"] { + for path in [ + "../Frameworks/TermindApplication/Metrics/MetricsSnapshot.swift", + "../Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift", + ] { let source = try String(contentsOf: sourceRoot.appending(path: path), encoding: .utf8) #expect(!source.contains("import AgentKit")) } @@ -251,6 +256,52 @@ struct ArchitectureBoundaryTests { } } + @Test + func mobileNavigationAndMetricsShareTheDesktopDesign() throws { + let root = try String( + contentsOf: sourceRoot.appending(path: "../TFI/Features/MobileRootView.swift"), encoding: .utf8) + #expect(!root.contains("Welcome")) + #expect(root.contains("NavigationLink(\"Transfers\")")) + let vault = try String( + contentsOf: sourceRoot.appending(path: "../TFI/Features/MobileVaultView.swift"), encoding: .utf8) + #expect(vault.contains("NavigationLink(\"Metrics\")")) + #expect(!vault.contains("NavigationLink(\"Port Forwarding\")")) + #expect(vault.contains("open(host.id, .ssh)")) + #expect(vault.contains(".contextMenu")) + #expect(!vault.contains("Import SSH Files")) + let keys = try String( + contentsOf: sourceRoot.appending(path: "../TFI/Features/MobileCredentialsView.swift"), encoding: .utf8) + #expect(!keys.contains("draft.notes")) + let settings = try String( + contentsOf: sourceRoot.appending(path: "../TFI/Features/MobileSettingsView.swift"), encoding: .utf8) + #expect(settings.contains("NavigationLink(\"App\")")) + #expect(settings.contains("NavigationLink(\"Sync\")")) + #expect(!settings.contains("Live Activities")) + let mobile = try String( + contentsOf: sourceRoot.appending(path: "../TFI/App/MobileMetricsModel.swift"), encoding: .utf8) + #expect(mobile.contains("ServerMetricsModel(")) + #expect(!mobile.contains("while !Task.isCancelled")) + } + + @Test + func mobilePortUsesSharedStoresAndSeparateSigningFiles() throws { + let mobile = try String( + contentsOf: sourceRoot.appending(path: "../TFI/App/MobileAppModel.swift"), encoding: .utf8) + #expect(mobile.contains("SyncedAIConfigurationPersistence(settingsPersistence)")) + #expect(mobile.contains("settingsSync?.didApplyRemote")) + #expect(mobile.contains("vaultSync?.didApplyRemote")) + let project = try String( + contentsOf: sourceRoot.appending(path: "../Termind.xcodeproj/project.pbxproj"), encoding: .utf8) + #expect(project.contains("TFI/TFI.entitlements")) + #expect(project.contains("TFM/TFM.entitlements")) + #expect(!project.contains("Termind.entitlements")) + let terminal = try String( + contentsOf: sourceRoot.appending(path: "../TFI/Features/MobileTerminalSettingsView.swift"), encoding: .utf8) + #expect(!terminal.contains("remoteWindowTitleInMenuBar")) + let cloud = try swiftFiles(in: "../Frameworks/TermindInfrastructure/Cloud") + #expect(cloud.count == 2) + } + @Test func sharedFrameworksCannotImportPlatformAppsOrUI() throws { for module in [ diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index 5b012a6b..6dce7066 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ A671EB6598B90BA03EF86CE7 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; A9A947EA53E15BD04CE319C1 /* TermindPresentation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */; }; AB2210ABB10F707CBFB01C78 /* GhosttyTheme in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100013 /* GhosttyTheme */; }; + ABCDEF010000000000000001 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; }; B08DD88AB7C2444E5AEC19BC /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; B1821FD31E7D009C900B5557 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; B616FF213FC31346E6BB3817 /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; @@ -330,6 +331,13 @@ remoteGlobalIDString = DB005287D48C7756E15B6830; remoteInfo = TermindPersistence; }; + DB8AE7623053CEF500C7EFAE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = DB005287D48C7756E15B6830; + remoteInfo = TermindPersistence; + }; DDD7C2043B64225E19B0D941 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = TRMD00000000000000000090 /* Project object */; @@ -566,6 +574,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + ABCDEF010000000000000001 /* TermindPersistence.framework in Frameworks */, 7DE0BDBF5E7A76602DDD443D /* TermindDomain.framework in Frameworks */, 6DF0C216374DA144B1FC2030 /* TermindApplication.framework in Frameworks */, ); @@ -838,6 +847,7 @@ ); dependencies = ( EA931847718F5BD9BCEBB63A /* PBXTargetDependency */, + ABCDEF010000000000000002 /* PBXTargetDependency */, 81CC5F621EFB712B902CD692 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( @@ -1081,6 +1091,16 @@ LastSwiftUpdateCheck = 2660; LastUpgradeCheck = 2660; TargetAttributes = { + 1EAB6B47E04D61C8CFC54762 = { + SystemCapabilities = { + com.apple.Push = { + enabled = 1; + }; + com.apple.iCloud = { + enabled = 1; + }; + }; + }; F815C7E534B763A0E88A5CBD = { CreatedOnToolsVersion = 26.6; TestTargetID = 1EAB6B47E04D61C8CFC54762; @@ -1467,6 +1487,11 @@ target = DB005287D48C7756E15B6830 /* TermindPersistence */; targetProxy = DB8AB3D23053A07800C7EFAE /* PBXContainerItemProxy */; }; + ABCDEF010000000000000002 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = DB005287D48C7756E15B6830 /* TermindPersistence */; + targetProxy = DB8AE7623053CEF500C7EFAE /* PBXContainerItemProxy */; + }; B5CB96BC5381BEF3FF47AED5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = ADAD84AB61B493DA1D22B54C /* TermindInfrastructure */; @@ -1647,12 +1672,14 @@ 3891D69CE8B2E67E07EFE802 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + APS_ENVIRONMENT = development; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = TFI/Termind.entitlements; + CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = F6SUNDBBN9; GENERATE_INFOPLIST_FILE = YES; + ICLOUD_CONTAINER_ENVIRONMENT = Development; INFOPLIST_FILE = TFI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Termind; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -1900,12 +1927,14 @@ D76D79A8E58FBD72935F61F2 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + APS_ENVIRONMENT = production; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = TFI/Termind.entitlements; + CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = F6SUNDBBN9; GENERATE_INFOPLIST_FILE = YES; + ICLOUD_CONTAINER_ENVIRONMENT = Production; INFOPLIST_FILE = TFI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Termind; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -2075,7 +2104,7 @@ APS_ENVIRONMENT = development; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = TFM/Termind.entitlements; + CODE_SIGN_ENTITLEMENTS = TFM/TFM.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; @@ -2112,7 +2141,7 @@ APS_ENVIRONMENT = production; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = TFM/Termind.entitlements; + CODE_SIGN_ENTITLEMENTS = TFM/TFM.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES;