Refactor TFI for project structure

This commit is contained in:
Kaede Akino committed 2026-09-12 01:21:51 +08:00
1 parent 1efcbe9063
commit 5eefc33cb0
353 files changed
+1678 -1491

No files matched your search

@@ -8,7 +8,7 @@ import TermindPersistence
import Testing
@Suite @MainActor
struct SharedAISettingsTests {
struct AISettingsIntegrationTests {
@Test func rolesResolveOnlyEnabledChatModelsAndFallBackToChat() {
var provider = ModelProvider(name: "Enabled")
provider.models = [AIModel(id: "chat"), AIModel(id: "embedding", kind: .embedding)]
@@ -51,7 +51,7 @@ struct SharedAISettingsTests {
@Test func syncedLibrarySeedsOnceAndPreservesOtherSettings() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let domain = "SharedAISettingsTests.\(UUID())"
let domain = "AISettingsIntegrationTests.\(UUID())"
let defaults = UserDefaults(suiteName: domain)!
defer { defaults.removePersistentDomain(forName: domain) }
let persistence = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
@@ -4,7 +4,7 @@ import TermindDomain
import Testing
@Suite @MainActor
struct SharedMetricsJourneyTests {
struct MetricsJourneyTests {
@Test func offlineRestoredMetricsReconcileOnMainActor() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
var host = TermindDomain.Host(name: "Monitor", host: "example.invalid")
@@ -8,7 +8,7 @@ import TermindPersistence
import Testing
@Suite @MainActor
struct SharedSettingsPortTests {
struct SettingsPortTests {
@Test func failedTerminalSaveKeepsTheAppliedPreferences() {
let persistence = FailingTerminalPreferences()
let model = TerminalSettingsModel(persistence: persistence)
@@ -22,7 +22,7 @@ struct SharedSettingsPortTests {
@Test func mobileSettingsEditsPreserveSkillsMCPAndUnresolvedRoles() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let domain = "SharedSettingsPortTests.\(UUID())"
let domain = "SettingsPortTests.\(UUID())"
let defaults = UserDefaults(suiteName: domain)!
defer { defaults.removePersistentDomain(forName: domain) }
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
@@ -44,7 +44,7 @@ struct SharedSettingsPortTests {
@Test func applyingRemoteTerminalDoesNotEnterLocalWriteCallback() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let domain = "SharedSettingsPortTests.\(UUID())"
let domain = "SettingsPortTests.\(UUID())"
let defaults = UserDefaults(suiteName: domain)!
defer { defaults.removePersistentDomain(forName: domain) }
let store = SettingsSyncPersistence(context: container.mainContext, defaults: defaults)
@@ -58,22 +58,22 @@ struct SharedSettingsPortTests {
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(defaults.double(forKey: SettingsKey.terminalFontSize) == 19)
#expect(callbacks == 0)
}
@Test func syncDefaultsPreserveIndependentSelectionsWhenPaused() {
let domain = "SharedSettingsPortTests.\(UUID())"
let domain = "SettingsPortTests.\(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))
SettingsKey.migrateSyncPreferences(in: defaults)
#expect(defaults.bool(forKey: SettingsKey.iCloudSyncEnabled))
#expect(defaults.object(forKey: SettingsKey.settingsSyncEnabled) == nil)
defaults.set(true, forKey: SettingsKey.settingsSyncEnabled)
defaults.set(false, forKey: SettingsKey.iCloudSyncEnabled)
SettingsKey.migrateSyncPreferences(in: defaults)
#expect(!defaults.bool(forKey: SettingsKey.iCloudSyncEnabled))
#expect(defaults.bool(forKey: SettingsKey.settingsSyncEnabled))
}
@Test func rolesTakePrecedenceOverLastSelectedModelAndDeletedModelsFallBack() {
@@ -0,0 +1,51 @@
import Foundation
import TermindApplication
import Testing
@Suite @MainActor
struct TerminalCommandDraftControllerTests {
@Test
func readsOneDraftAndRejectsConcurrentRequests() async throws {
let controller = TerminalCommandDraftController()
var writes: [Data] = []
let first = Task {
try await controller.read(isAvailable: true) { data in
writes.append(data)
}
}
await Task.yield()
await #expect(throws: TerminalDraftError.self) {
try await controller.read(isAvailable: true) { _ in }
}
controller.receive("list files")
#expect(try await first.value == "list files")
#expect(writes == [TerminalCommandDraftController.requestPayload])
}
@Test
func replacementUsesBracketedPasteWithoutSubmitting() {
let payload = TerminalCommandDraftController().replacementPayload(for: "ls -la")
#expect(payload == Data("\u{1B}[777;2~\u{1B}[200~ls -la\u{1B}[201~".utf8))
#expect(!payload.contains(0x0A))
#expect(!payload.contains(0x0D))
}
@Test
func cancellationResumesThePendingReader() async {
let controller = TerminalCommandDraftController()
let read = Task {
try await controller.read(isAvailable: true) { _ in }
}
await Task.yield()
controller.cancel(CancellationError())
await #expect(throws: CancellationError.self) {
try await read.value
}
#expect(!controller.hasPendingRequest)
}
}
@@ -3082,6 +3082,7 @@
}
},
"Command Generator is already reading the current input." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -14794,6 +14795,7 @@
}
},
"The remote shell did not return its current input." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -15774,6 +15776,7 @@
}
},
"Type something at the shell prompt before generating a command." : {
"extractionState" : "stale",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
@@ -0,0 +1,120 @@
import Foundation
public nonisolated enum CommandGenerationError: LocalizedError, Sendable {
case emptyDraft
public var errorDescription: String? {
String(localized: "Type something at the shell prompt before generating a command.")
}
}
@MainActor
@Observable
public final class CommandGenerationCoordinator {
public typealias ReadContext = @MainActor @Sendable () async throws -> CommandGeneratorContext
public typealias IsCurrent = @MainActor @Sendable () -> Bool
public typealias Generate = @Sendable (CommandGeneratorContext) async throws -> String
public typealias Insert = @MainActor @Sendable (String) async -> Void
public private(set) var state = CommandGeneratorState.idle
public var isRunning: Bool { task != nil }
private var task: Task<Void, Never>?
private var dismissTask: Task<Void, Never>?
private var generation: UInt64 = 0
public init() {}
public func start(
targetCount: Int,
readContext: @escaping ReadContext,
isCurrent: @escaping IsCurrent,
generate: @escaping Generate,
insert: @escaping Insert
) {
cancel()
generation &+= 1
let generation = generation
state = .readingDraft
task = Task { [weak self] in
do {
let context = try await readContext()
try Task.checkCancellation()
guard !context.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw CommandGenerationError.emptyDraft
}
guard isCurrent() else { throw CancellationError() }
self?.state = .generating
let command = try await Self.withTimeout { try await generate(context) }
try Task.checkCancellation()
guard isCurrent() else { throw CancellationError() }
await insert(command)
self?.finish(generation: generation, state: .inserted(targetCount: targetCount), after: .seconds(2))
} catch is CancellationError {
self?.finishCancellation(generation: generation)
} catch {
self?.finish(generation: generation, state: .failed(error.localizedDescription), after: .seconds(3))
}
}
}
public func cancel() {
generation &+= 1
task?.cancel()
task = nil
dismissTask?.cancel()
dismissTask = nil
state = .idle
}
public func fail(_ message: String) {
cancel()
state = .failed(message)
scheduleDismiss(generation: generation, after: .seconds(3))
}
public func replaceStateForPresentation(_ state: CommandGeneratorState) {
cancel()
self.state = state
}
private func finishCancellation(generation: UInt64) {
guard self.generation == generation else { return }
task = nil
state = .idle
}
private func finish(generation: UInt64, state: CommandGeneratorState, after delay: Duration) {
guard self.generation == generation else { return }
task = nil
self.state = state
scheduleDismiss(generation: generation, after: delay)
}
private func scheduleDismiss(generation: UInt64, after delay: Duration) {
dismissTask?.cancel()
dismissTask = Task { [weak self] in
try? await Task.sleep(for: delay)
guard !Task.isCancelled, self?.generation == generation else { return }
self?.state = .idle
self?.dismissTask = nil
}
}
private nonisolated static func withTimeout(
_ operation: @escaping @Sendable () async throws -> String
) async throws -> String {
try await withThrowingTaskGroup(of: String.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: .seconds(30))
throw CommandGeneratorError.timedOut
}
guard let result = try await group.next() else {
throw CommandGeneratorError.invalidResponse
}
group.cancelAll()
return result
}
}
}
@@ -0,0 +1,99 @@
import Foundation
public nonisolated enum TerminalDraftError: LocalizedError, Sendable {
case unavailable
case alreadyPending
case timedOut
public var errorDescription: String? {
switch self {
case .unavailable:
String(
localized:
"Command Generator requires a supported shell prompt with Shell Integration enabled."
)
case .alreadyPending:
String(localized: "Command Generator is already reading the current input.")
case .timedOut:
String(localized: "The remote shell did not return its current input.")
}
}
}
@MainActor
public final class TerminalCommandDraftController {
public typealias Write = @MainActor @Sendable (Data) async -> Void
private struct Request {
var id: UUID
var continuation: CheckedContinuation<String, any Error>
}
public static let supportedShells: Set<String> = ["bash", "zsh", "fish"]
public static let requestPayload = Data("\u{1B}[777;1~".utf8)
private static let clearPayload = Data("\u{1B}[777;2~".utf8)
private static let bracketedPasteStart = Data("\u{1B}[200~".utf8)
private static let bracketedPasteEnd = Data("\u{1B}[201~".utf8)
private var request: Request?
private var timeoutTask: Task<Void, Never>?
public var hasPendingRequest: Bool { request != nil }
public init() {}
public func read(isAvailable: Bool, write: @escaping Write) async throws -> String {
guard isAvailable else { throw TerminalDraftError.unavailable }
guard request == nil else { throw TerminalDraftError.alreadyPending }
let id = UUID()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
request = Request(id: id, continuation: continuation)
Task { await write(Self.requestPayload) }
timeoutTask?.cancel()
timeoutTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
self?.cancel(id: id, error: TerminalDraftError.timedOut)
}
}
} onCancel: {
Task { @MainActor [weak self] in
self?.cancel(id: id, error: CancellationError())
}
}
}
public func receive(_ draft: String) {
guard let request else { return }
finish(request)
request.continuation.resume(returning: draft)
}
public func cancel(_ error: any Error = TerminalDraftError.unavailable) {
guard let request else { return }
cancel(id: request.id, error: error)
}
public func replacementPayload(for command: String) -> Data {
var data = Self.clearPayload
data.append(Self.bracketedPasteStart)
data.append(Data(command.utf8))
data.append(Self.bracketedPasteEnd)
return data
}
private func cancel(id: UUID, error: any Error) {
guard request?.id == id, let request else { return }
finish(request)
request.continuation.resume(throwing: error)
}
private func finish(_ request: Request) {
guard self.request?.id == request.id else { return }
self.request = nil
timeoutTask?.cancel()
timeoutTask = nil
}
}
@@ -42,7 +42,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
}
public nonisolated static func stored(in defaults: UserDefaults = .standard) -> AppLanguage {
guard let rawValue = defaults.string(forKey: SharedSettingsKey.appLanguage) else {
guard let rawValue = defaults.string(forKey: SettingsKey.appLanguage) else {
return .system
}
return AppLanguage(rawValue: rawValue) ?? .system
@@ -54,7 +54,7 @@ public nonisolated enum AppLanguage: String, CaseIterable, Identifiable, Sendabl
public nonisolated static func store(
_ language: AppLanguage, in defaults: UserDefaults = .standard
) {
defaults.set(language.rawValue, forKey: SharedSettingsKey.appLanguage)
defaults.set(language.rawValue, forKey: SettingsKey.appLanguage)
switch language {
case .system:
defaults.removeObject(forKey: "AppleLanguages")
@@ -1,5 +1,4 @@
import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
@@ -8,23 +7,75 @@ import struct TermindDomain.Host
/// Transport-specific models own their I/O, while this object owns the common
/// connection journey and retry lifecycle shown by both Terminal and SFTP.
@Observable
final class RemoteSessionConnectionController {
let journey = ConnectionJourney()
let reconnect = SessionReconnectController()
var pendingHostKey: SSHHostKeyPrompt?
var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:]
public final class RemoteSessionConnectionController {
public let journey = ConnectionJourney()
public let reconnect: SessionReconnectController
public var pendingHostKey: SSHHostKeyPrompt?
public var temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:]
public private(set) var hasConnectedOnce = false
public private(set) var isRecovering = false
private var pendingHostKeyHostID: Host.ID?
var reconnectState: ReconnectState? { reconnect.state }
public var reconnectState: ReconnectState? { reconnect.state }
func disconnect() {
public init(reconnect: SessionReconnectController = SessionReconnectController()) {
self.reconnect = reconnect
}
public func disconnect() {
clearHostKeyPrompt()
temporaryHostKeys.removeAll()
reconnect.stop()
hasConnectedOnce = false
isRecovering = false
journey.hide()
}
func trustHostKey(
@discardableResult
public func markConnected() -> Bool {
let wasRecovering = isRecovering
let initial = !hasConnectedOnce
hasConnectedOnce = true
isRecovering = false
reconnect.markConnected(initial: initial)
return wasRecovering
}
@discardableResult
public func scheduleRecovery(
after error: String, retry: @escaping SessionReconnectController.RetryAction
) -> Bool {
guard hasConnectedOnce else { return false }
let announcesLoss = !isRecovering
isRecovering = true
reconnect.schedule(after: error, retry: retry)
return announcesLoss
}
public func pauseForUserInteraction() {
reconnect.pauseForUserInteraction()
}
@discardableResult
public func stopRecovery() -> ReconnectState? {
let state = reconnect.state
reconnect.stop()
isRecovering = false
return state
}
public func suspendForForegroundResume(reconnecting: Bool) {
reconnect.stop()
isRecovering = reconnecting && hasConnectedOnce
}
public func resetRecovery() {
reconnect.stop()
hasConnectedOnce = false
isRecovering = false
}
public func trustHostKey(
_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore
) {
store.trust(
@@ -38,7 +89,7 @@ final class RemoteSessionConnectionController {
journey.resume()
}
func useTemporaryHostKey(_ observation: SSHHostKeyObservation) -> Bool {
public func useTemporaryHostKey(_ observation: SSHHostKeyObservation) -> Bool {
guard let pendingHostKeyHostID else { return false }
temporaryHostKeys[pendingHostKeyHostID] = observation.identity
clearHostKeyPrompt()
@@ -46,7 +97,7 @@ final class RemoteSessionConnectionController {
return true
}
func waitForHostKey(
public func waitForHostKey(
_ prompt: SSHHostKeyPrompt, hostID: Host.ID, fingerprint: String
) {
pendingHostKey = prompt
@@ -55,7 +106,7 @@ final class RemoteSessionConnectionController {
journey.waitForHostKey(at: hostID, fingerprint: fingerprint)
}
func progressHandler(
public func progressHandler(
attemptID: UUID, isCurrent: @escaping @MainActor @Sendable (UUID) -> Bool
) -> SSHRouteProgressHandler {
{ [weak self] event in
@@ -66,7 +117,7 @@ final class RemoteSessionConnectionController {
}
}
func presentReconnectJourney(
public func presentReconnectJourney(
host: Host?, using store: any RemoteSessionHostStore,
purpose: ConnectionPurpose, username: String?
) {
@@ -48,7 +48,7 @@ public final class SkillLibraryModel {
/// screenshot build get the library they were handed and nothing else.
private func seedBuiltInSkills() {
guard let seedDefaults else { return }
let seeded = seedDefaults.integer(forKey: SharedSettingsKey.seededSkillGeneration)
let seeded = seedDefaults.integer(forKey: SettingsKey.seededSkillGeneration)
guard seeded < AgentSkillLibrary.currentGeneration else { return }
for skill in AgentSkillLibrary.builtIns(after: seeded) {
var installed = skill
@@ -59,7 +59,7 @@ public final class SkillLibraryModel {
// thing that can make this run twice.
guard storageError == nil else { return }
seedDefaults.set(
AgentSkillLibrary.currentGeneration, forKey: SharedSettingsKey.seededSkillGeneration
AgentSkillLibrary.currentGeneration, forKey: SettingsKey.seededSkillGeneration
)
}
@@ -166,7 +166,7 @@ public final class SkillLibraryModel {
// Nothing left for seeding to add, whatever generation this store was
// written by.
seedDefaults?.set(
AgentSkillLibrary.currentGeneration, forKey: SharedSettingsKey.seededSkillGeneration
AgentSkillLibrary.currentGeneration, forKey: SettingsKey.seededSkillGeneration
)
return shipped.count
}
@@ -1,6 +1,6 @@
import Foundation
public nonisolated enum SharedSettingsKey {
public nonisolated enum SettingsKey {
public static let appLanguage = "app.language"
public static let defaultICloudSyncEnabled = true
public static let defaultSettingsSyncEnabled = false
@@ -0,0 +1,11 @@
import Foundation
public nonisolated enum SSHShellIntegrationEvent: Sendable {
case available(shell: String, systemInfo: RemoteSystemInfo?, homeDirectory: String?)
case systemInfo(RemoteSystemInfo)
case homeResolutionUnavailable
case promptReady
case commandStarted(String)
case commandFinished(Int32, duration: TimeInterval?)
case draft(String)
}
@@ -0,0 +1,14 @@
/// Search order follows Ghostty and VS Code's terminal find widget. A search
/// starts at the bottom of the buffer, so "previous" moves toward older output.
public nonisolated enum TerminalFindDirection: String, Sendable {
case previous
case next
}
public nonisolated enum TerminalFindBinding {
public static func search(_ query: String) -> String { "search:\(query)" }
public static func navigate(_ direction: TerminalFindDirection) -> String {
"navigate_search:\(direction.rawValue)"
}
public static let end = "end_search"
}
@@ -182,7 +182,7 @@ nonisolated public enum ContextAttachmentImporter {
let archive = try officeArchive(data)
let shared: [String]
if let xml = try archiveData(archive, path: "xl/sharedStrings.xml") {
shared = try SharedStringsParser().parse(xml)
shared = try SpreadsheetSharedStringsParser().parse(xml)
} else {
shared = []
}
@@ -361,7 +361,7 @@ nonisolated private final class ParagraphTextParser: NSObject, XMLParserDelegate
}
}
nonisolated private final class SharedStringsParser: NSObject, XMLParserDelegate {
nonisolated private final class SpreadsheetSharedStringsParser: NSObject, XMLParserDelegate {
public var strings: [String] = []
public var value = ""
public var text = ""
@@ -37,11 +37,11 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
public var isEnabled: Bool {
guard
defaults.object(forKey: SharedSettingsKey.iCloudSyncEnabled) as? Bool
?? SharedSettingsKey.defaultICloudSyncEnabled
defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool
?? SettingsKey.defaultICloudSyncEnabled
else { return false }
return defaults.object(forKey: SharedSettingsKey.settingsSyncEnabled) as? Bool
?? SharedSettingsKey.defaultSettingsSyncEnabled
return defaults.object(forKey: SettingsKey.settingsSyncEnabled) as? Bool
?? SettingsKey.defaultSettingsSyncEnabled
}
public init(
@@ -78,7 +78,7 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
}
public func setEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: SharedSettingsKey.settingsSyncEnabled)
defaults.set(enabled, forKey: SettingsKey.settingsSyncEnabled)
if enabled { start() } else { pause() }
}
@@ -487,13 +487,13 @@ public final class SettingsSyncCoordinator: NSObject, CKSyncEngineDelegate, @unc
private func prepareForCurrentEnvironment() throws {
guard
defaults.string(forKey: SharedSettingsKey.settingsSyncEnvironment)
defaults.string(forKey: SettingsKey.settingsSyncEnvironment)
!= environmentIdentifier
else { return }
try journal.markAllEntriesNeedsUploadAfterEnvironmentChange()
try context.save()
defaults.set(environmentIdentifier, forKey: SharedSettingsKey.settingsSyncEnvironment)
defaults.set(environmentIdentifier, forKey: SettingsKey.settingsSyncEnvironment)
terminalError = nil
}
@@ -34,11 +34,11 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
public var isEnabled: Bool {
guard
defaults.object(forKey: SharedSettingsKey.iCloudSyncEnabled) as? Bool
?? SharedSettingsKey.defaultICloudSyncEnabled
defaults.object(forKey: SettingsKey.iCloudSyncEnabled) as? Bool
?? SettingsKey.defaultICloudSyncEnabled
else { return false }
return defaults.object(forKey: SharedSettingsKey.vaultSyncEnabled) as? Bool
?? SharedSettingsKey.defaultVaultSyncEnabled
return defaults.object(forKey: SettingsKey.vaultSyncEnabled) as? Bool
?? SettingsKey.defaultVaultSyncEnabled
}
public init(
@@ -72,7 +72,7 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
}
public func setEnabled(_ enabled: Bool) {
defaults.set(enabled, forKey: SharedSettingsKey.vaultSyncEnabled)
defaults.set(enabled, forKey: SettingsKey.vaultSyncEnabled)
if enabled { start() } else { pause() }
}
@@ -460,13 +460,13 @@ public final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchec
/// retaining every local envelope and its conflict-resolution version.
private func prepareForCurrentEnvironment() throws {
guard
defaults.string(forKey: SharedSettingsKey.vaultSyncEnvironment)
defaults.string(forKey: SettingsKey.vaultSyncEnvironment)
!= environmentIdentifier
else { return }
try journal.markAllEntriesNeedsUploadAfterEnvironmentChange()
try context.save()
defaults.set(environmentIdentifier, forKey: SharedSettingsKey.vaultSyncEnvironment)
defaults.set(environmentIdentifier, forKey: SettingsKey.vaultSyncEnvironment)
terminalError = nil
}
@@ -61,7 +61,7 @@ public final class SettingsSyncPersistence {
}
public func prepare() throws {
let seeded = defaults.integer(forKey: SharedSettingsKey.seededSkillGeneration)
let seeded = defaults.integer(forKey: SettingsKey.seededSkillGeneration)
var mutations = SettingsMutationSet.empty
try context.commit {
if try !journal.state().bootstrapCompleted {
@@ -96,7 +96,7 @@ public final class SettingsSyncPersistence {
mutations.keys.insert(terminal.key)
}
}
defaults.set(AgentSkillLibrary.currentGeneration, forKey: SharedSettingsKey.seededSkillGeneration)
defaults.set(AgentSkillLibrary.currentGeneration, forKey: SettingsKey.seededSkillGeneration)
let snapshot = try load()
mirror(snapshot.webSearch)
snapshot.terminal.mirror(to: defaults)
@@ -243,17 +243,17 @@ public final class SettingsSyncPersistence {
if let row = try context.fetch(FetchDescriptor<PersistentAIWebSearchPreferences>()).first { return row }
let row = PersistentAIWebSearchPreferences(
AIWebSearchPreferences(
isEnabled: defaults.object(forKey: SharedSettingsKey.webSearchEnabled) as? Bool
?? SharedSettingsKey.defaultWebSearchEnabled,
prefersBuiltIn: defaults.object(forKey: SharedSettingsKey.webSearchBuiltIn) as? Bool
?? SharedSettingsKey.defaultWebSearchBuiltIn))
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: SharedSettingsKey.webSearchEnabled)
defaults.set(value.prefersBuiltIn, forKey: SharedSettingsKey.webSearchBuiltIn)
defaults.set(value.isEnabled, forKey: SettingsKey.webSearchEnabled)
defaults.set(value.prefersBuiltIn, forKey: SettingsKey.webSearchBuiltIn)
}
}
@@ -1,16 +0,0 @@
public typealias SharedHostPersistence = HostPersistence
public typealias SharedVaultSyncPersistenceError = VaultSyncPersistenceError
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 SharedSwiftDataConnectionHistoryRepository = SwiftDataConnectionHistoryRepository
public typealias SharedSyncedAIConfigurationPersistence = SyncedAIConfigurationPersistence
public typealias SharedSyncedSkillLibraryPersistence = SyncedSkillLibraryPersistence
@@ -114,5 +114,3 @@ public final class AIPersistence {
}
extension AIPersistence: AIConfigurationPersisting {}
public typealias SharedAIPersistence = AIPersistence
@@ -99,5 +99,3 @@ public final class AISecretStore: AISecretStoring {
return row
}
}
public typealias SharedAISecretStore = AISecretStore
@@ -655,5 +655,3 @@ public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
} catch { context.rollback() }
}
}
public typealias SharedAgentRunRepository = SwiftDataAgentRunRepository
@@ -80,5 +80,3 @@ public final class SwiftDataOutboxStore: OutboxStore {
try context.savePendingChanges()
}
}
public typealias SharedOutboxStore = SwiftDataOutboxStore
@@ -6,32 +6,32 @@ 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
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: SharedSettingsKey.terminalCustomConfigurationDarkEnabled) as? Bool ?? false
defaults.object(forKey: SettingsKey.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
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: SharedSettingsKey.remoteWindowTitleInMenuBar) as? Bool
defaults.object(forKey: SettingsKey.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)
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)
}
}
@@ -9,7 +9,7 @@ public protocol ConnectionFormKeyboardAdapting {
extension View {
@ViewBuilder
fileprivate func sharedConnectionFormChrome(isEnabled: Bool) -> some View {
fileprivate func connectionFormChrome(isEnabled: Bool) -> some View {
if isEnabled {
padding(18).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay { RoundedRectangle(cornerRadius: 14).strokeBorder(.quaternary) }
@@ -19,7 +19,7 @@ extension View {
}
}
public struct SharedSSHAuthenticationForm: View {
public struct SSHAuthenticationForm: View {
let prompt: SSHAuthenticationPrompt
let submit: (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void
let cancel: () -> Void
@@ -71,7 +71,7 @@ public struct SharedSSHAuthenticationForm: View {
public var body: some View {
form
.sharedConnectionFormChrome(isEnabled: usesContainerChrome)
.connectionFormChrome(isEnabled: usesContainerChrome)
.background {
if let keyboard {
keyboard.bridge(canSubmit: canContinue, submit: { commit(save: !needsPassphrase) }, cancel: cancel)
@@ -325,7 +325,7 @@ public struct SharedSSHAuthenticationForm: View {
}
}
public struct SharedSSHHostKeyConfirmationForm: View {
public struct SSHHostKeyConfirmationForm: View {
let prompt: SSHHostKeyPrompt
var displayAddress: String? = nil
let proceed: () -> Void
@@ -357,7 +357,7 @@ public struct SharedSSHHostKeyConfirmationForm: View {
public var body: some View {
form
.sharedConnectionFormChrome(isEnabled: usesContainerChrome)
.connectionFormChrome(isEnabled: usesContainerChrome)
.background {
if let keyboard { keyboard.bridge(canSubmit: true, submit: { trust?() ?? proceed() }, cancel: deny) }
}
@@ -1,5 +1,6 @@
import Foundation
import TermindApplication
import TermindDomain
/// Application-owned construction and one-shot operation boundary for all
/// SSH/SFTP transports. Features receive this service instead of constructing
@@ -518,16 +518,6 @@ public nonisolated struct RemoteCommandEventFilter: Sendable {
}
}
public nonisolated enum SSHShellIntegrationEvent: Sendable {
case available(shell: String, systemInfo: RemoteSystemInfo?, homeDirectory: String?)
case systemInfo(RemoteSystemInfo)
case homeResolutionUnavailable
case promptReady
case commandStarted(String)
case commandFinished(Int32, duration: TimeInterval?)
case draft(String)
}
public actor SSHSessionActor {
public typealias OutputHandler = @Sendable (Data) -> Void
public typealias ExitHandler = @Sendable (Result<Int32, TermindSSHError>) -> Void
@@ -115,14 +115,14 @@ public final class SFTPTransferCoordinator {
self.clientFactory = clientFactory
let defaults = UserDefaults.standard
let normalizedGlobal = Self.normalizedLimit(
globalLimit ?? defaults.object(forKey: SharedSettingsKey.sftpGlobalTransferLimit) as? Int
?? SharedSettingsKey.defaultSFTPGlobalTransferLimit
globalLimit ?? defaults.object(forKey: SettingsKey.sftpGlobalTransferLimit) as? Int
?? SettingsKey.defaultSFTPGlobalTransferLimit
)
self.globalLimit = normalizedGlobal
self.perHostLimit = min(
Self.normalizedLimit(
perHostLimit ?? defaults.object(forKey: SharedSettingsKey.sftpPerHostTransferLimit) as? Int
?? SharedSettingsKey.defaultSFTPPerHostTransferLimit
perHostLimit ?? defaults.object(forKey: SettingsKey.sftpPerHostTransferLimit) as? Int
?? SettingsKey.defaultSFTPPerHostTransferLimit
),
normalizedGlobal
)
@@ -136,8 +136,8 @@ public final class SFTPTransferCoordinator {
private static func normalizedLimit(_ value: Int) -> Int {
min(
max(value, SharedSettingsKey.sftpTransferLimitRange.lowerBound),
SharedSettingsKey.sftpTransferLimitRange.upperBound
max(value, SettingsKey.sftpTransferLimitRange.lowerBound),
SettingsKey.sftpTransferLimitRange.upperBound
)
}
+19 -4
View File
@@ -26,7 +26,15 @@ remains a versioned remote SPM dependency developed in the sibling repository.
orchestration. `TFI` owns its native mobile entry point and scene navigation.
Platform code can consume frameworks; frameworks cannot consume either app.
Both app targets use the same top-level layer vocabulary: `App`, `Application`,
`Design`, `DesignSystem`, `Features`, `Infrastructure`, `Platform`, and `UI`.
TFI feature code is grouped by capability inside that structure; production SSH
construction lives in Infrastructure and reusable mobile chrome lives in UI.
The frameworks have explicit public boundaries and do not re-export imports.
App targets import framework owners directly. They do not recreate framework
names with forwarding type aliases, and framework types do not use `Shared` as
a namespace: the module and responsibility-specific name express ownership.
`Host` is explicitly imported from Domain where Foundation's same-named type
would otherwise make lookup ambiguous. Presentation of session-context chips
belongs to the desktop feature extension, not the domain value.
@@ -89,15 +97,22 @@ infrastructure, persistence, or presentation responsibility.
- A workspace owns SSH/SFTP/serial panes plus its session-scoped assistant
conversation. Workspace content is full width and has no left sidebar.
- Session models own live SSH/SFTP/serial resources and terminal/file-browser state.
- Command Generator's tool-free model request service and validated context live in Application and are shared by
both apps. Each platform session model owns prompt/draft capture, stale-generation cancellation, and command
insertion because those operations belong to its live terminal. Generated commands are bracketed-pasted into the
current edit buffer and are never submitted automatically.
- Command Generator's tool-free model request service, draft protocol and stale-generation lifecycle live in
Application and are shared by both apps. Each platform session supplies its live terminal context and insertion
closure. Generated commands are bracketed-pasted into the current edit buffer and are never submitted automatically.
- `TerminalCommandDraftController` owns the shared draft request protocol, timeout,
cancellation and bracketed-paste payload. `CommandGenerationCoordinator` owns
the shared model timeout, stale-snapshot gates and transient presentation state;
platform sessions provide only their live context, validity check and insertion closure.
- A mobile SSH session delivers Shell Integration and transport-exit callbacks through one session-scoped ordered
consumer on MainActor. Reconnect, background expiration, and explicit close terminate that consumer; callbacks
from an older connection generation cannot reorder prompt state or mutate its replacement session. Authenticated
integration installation and command-finished events are prompt-boundary readiness evidence on mobile; the
following prompt marker is idempotent, so losing or duplicating it cannot strand or cancel Command Generator.
- SSH, SFTP, and mobile Workspaces share `RemoteSessionConnectionController` for
first-connect and recovery state, retry timing, Journey ownership, and host-key
decisions. `SSHShellIntegrationEvent` is a Domain session value emitted by the
Remote Infrastructure transport.
- A clean mobile SSH shell exit closes its Workspace through the app-owned lifecycle, while transport failures keep
the Workspace alive for automatic recovery. Removing the selected Workspace also clears the mobile navigation
destination so the UI returns to the Workspace list instead of retaining an ended terminal surface.
+15
View File
@@ -182,3 +182,18 @@ own `__termind_*` helpers. Mobile also treats authenticated installation and com
duplicate prompt markers are idempotent. Seventeen shared bootstrap tests, including a real bash runtime regression,
and all six focused mobile Command Generator tests passed. The root-cause build was installed and launched on the
connected iPad at 22:30. The affected live bash host then completed Command Generator successfully on that iPad.
## Layered mobile source and canonical framework names — 2026-09-12
TFI now uses the same App/Application/Design/DesignSystem/Features/Infrastructure/Platform/UI layer vocabulary as
TFM. Workspace transport construction moved from the feature model into Infrastructure, mobile layout constants
and interface idiom are centralized, and navigation metadata lives in Shell. The shared connection controller now
owns recovery state for desktop SSH, desktop SFTP, and mobile Workspaces.
The desktop compatibility forwarding layer was removed. Both apps import canonical framework types directly;
temporary `Shared` names were restored to responsibility-specific names. macOS and iOS simulator builds,
ArchitectureBoundaryTests, and the complete iOS test action pass.
Terminal draft reads and Command Generator execution now use the same Application controllers on both platforms,
including cancellation, timeouts, stale-context rejection and bracketed-paste insertion. Focused mobile Command
Generator and desktop Broadcast Input/Terminal Find tests pass after the extraction.
+21
View File
@@ -1087,3 +1087,24 @@ and chat content. Strict formatting and Chinese localization checks passed.
## 2026-09-11 — Shared Assistant execution (in progress)
Evidence: MobileAssistantModel registers no tools, while TFM/App/AppModel builds the complete runtime and ConversationModel owns its event and interaction queues. Move the existing model, tool adapters, import and speech services into their owning frameworks, then share runtime composition and conversation commands. Expected reduction: remove the mobile duplicate chat loop and avoid a second implementation of approximately 5,000 lines of Agent state/tool behavior. Verification pending macOS/iOS builds and regression suites.
## 2026-09-12 — Remove app-target framework forwarding aliases
- Evidence: `rg '^typealias ' TFM -g '*.swift'` found 224 module-level aliases in 96 files; 86 files contained no behavior beyond imports and aliases. Persistence also exported a second `Shared*` alias vocabulary solely for that forwarding layer.
- Action: delete the pure forwarders, migrate the six desktop convenience extensions to responsibility-named adapter files, use canonical framework types directly, and add an architecture guard that rejects forwarding aliases and public `Shared` namespaces.
- Estimated reduction: 86 source files and 224 app-target alias declarations, plus 20 Persistence compatibility aliases.
- Status: implemented. TFM and TFI builds pass, ArchitectureBoundaryTests pass, and the complete TFI test action passes.
## 2026-09-12 — Align the mobile target with desktop layers
- Evidence: TFI placed application models beside its entry point and all presentation in one flat Features directory; transport factories were embedded in `MobileWorkspace` and device-idiom checks were repeated in four views.
- Action: organize TFI under the eight desktop layers, move the workspace connection port and production implementation to Application/Infrastructure, extract mobile design tokens and an injectable interface-idiom environment, and move navigation metadata into Shell.
- Estimated reduction: no intentional feature reduction; the change removes one duplicate reconnect state machine and centralizes four device-idiom branches.
- Status: implemented. Both app builds, ArchitectureBoundaryTests, and the complete TFI test action pass.
## 2026-09-12 — Share terminal draft and generation lifecycles
- Evidence: desktop and mobile separately defined the same shell allowlist, draft request/clear escape payloads, two-second draft timeout, thirty-second model timeout, cancellation token and transient success/failure state.
- Action: extract `TerminalCommandDraftController` and `CommandGenerationCoordinator` into Application and make both terminal implementations supply only context, validity and insertion closures.
- Estimated reduction: approximately 190 duplicated lifecycle lines and one duplicate protocol constant set.
- Status: implemented. Mobile Command Generator, desktop Broadcast Input, Terminal Find, and direct draft-controller tests pass.
+36
View File
@@ -0,0 +1,36 @@
import Foundation
import TermindApplication
@MainActor
struct MobileAppDependencies {
var hosts: (any HostRepository)?
var inMemory: Bool
var defaults: UserDefaults?
var background: any MobileBackgroundTaskManaging
var workspaceConnections: MobileWorkspaceConnectionServices
static func live() -> MobileAppDependencies {
MobileAppDependencies(
hosts: nil,
inMemory: false,
defaults: .standard,
background: MobileBackgroundController(),
workspaceConnections: .live
)
}
static func testing(
hosts: (any HostRepository)? = nil,
defaults: UserDefaults? = nil,
background: any MobileBackgroundTaskManaging = MobileBackgroundController(),
workspaceConnections: MobileWorkspaceConnectionServices = .live
) -> MobileAppDependencies {
MobileAppDependencies(
hosts: hosts,
inMemory: true,
defaults: defaults,
background: background,
workspaceConnections: workspaceConnections
)
}
}
+18 -25
View File
@@ -44,11 +44,27 @@ final class MobileAppModel {
private var hasActivated = false
private var scenePhases: [UUID: ScenePhase] = [:]
init(
convenience init(
hosts: (any HostRepository)? = nil, inMemory: Bool = false, defaults: UserDefaults? = .standard,
background: any MobileBackgroundTaskManaging = MobileBackgroundController(),
workspaceConnectionServices: MobileWorkspaceConnectionServices = .live
) throws {
try self.init(
dependencies: MobileAppDependencies(
hosts: hosts,
inMemory: inMemory,
defaults: defaults,
background: background,
workspaceConnections: workspaceConnectionServices
))
}
init(dependencies: MobileAppDependencies) throws {
let hosts = dependencies.hosts
let inMemory = dependencies.inMemory
let defaults = dependencies.defaults
let background = dependencies.background
let workspaceConnectionServices = dependencies.workspaceConnections
self.background = background
self.workspaceConnectionServices = workspaceConnectionServices
let memoryOnly = inMemory || hosts != nil
@@ -74,7 +90,7 @@ final class MobileAppModel {
?? "F6SUNDBBN9.com.akinokaede.termind")
let settingsDefaults =
memoryOnly ? UserDefaults(suiteName: "termind.tests.\(UUID().uuidString)")! : defaults ?? .standard
SharedSettingsKey.migrateSyncPreferences(in: settingsDefaults)
SettingsKey.migrateSyncPreferences(in: settingsDefaults)
let settingsPersistence = SettingsSyncPersistence(context: ModelContext(container), defaults: settingsDefaults)
try settingsPersistence.prepare()
let aiSecrets = AISecretStore(context: settingsPersistence.context, keychain: secrets)
@@ -341,26 +357,3 @@ final class MobileAppModel {
}
}
}
nonisolated enum MobileDestination: String, CaseIterable, Identifiable {
case vault, metrics, workspaces, assistant, settings
var id: Self { self }
var title: String {
switch self {
case .vault: String(localized: "Vault")
case .assistant: String(localized: "Assistant")
case .workspaces: String(localized: "Workspaces")
case .metrics: String(localized: "Metrics")
case .settings: String(localized: "Settings")
}
}
var symbol: String {
switch self {
case .vault: "server.rack"
case .assistant: "sparkles"
case .workspaces: "terminal"
case .metrics: "gauge.with.dots.needle.33percent"
case .settings: "gearshape"
}
}
}
@@ -8,6 +8,7 @@ import struct TermindDomain.Host
struct MobileRootView: View {
let app: MobileAppModel
@Environment(\.scenePhase) private var scenePhase
@Environment(\.mobileInterfaceIdiom) private var interfaceIdiom
@State private var sceneID = UUID()
@State private var tab: MobileTab = .vault
@State private var selection: MobileDestination? = .vault
@@ -17,7 +18,7 @@ struct MobileRootView: View {
var body: some View {
Group {
if UIDevice.current.userInterfaceIdiom != .pad {
if interfaceIdiom != .pad {
TabView(selection: $tab) {
NavigationStack { vault }
.tabItem { Label("Vault", systemImage: "server.rack") }.tag(MobileTab.vault)
@@ -246,7 +247,3 @@ struct MobileRootView: View {
workspaceID = nil
}
}
nonisolated enum MobileTab: String, CaseIterable {
case vault, workspaces, assistant, settings
}
File renamed without changes.
+3 -1
View File
@@ -11,7 +11,8 @@ struct TermindIOSApp: App {
init() {
startup = Result {
try MobileAppModel(inMemory: ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil)
let isTesting = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
return try MobileAppModel(dependencies: isTesting ? .testing() : .live())
}
}
@@ -21,6 +22,7 @@ struct TermindIOSApp: App {
case .success(let app):
MobileRootView(app: app)
.modelContainer(app.container)
.environment(\.mobileInterfaceIdiom, MobileInterfaceIdiom.current)
.environment(\.locale, launchLanguage.locale)
.preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil)
case .failure:
@@ -0,0 +1,28 @@
import Foundation
import TermindApplication
import TermindDomain
import TermindRemoteInfrastructure
import struct TermindDomain.Host
@MainActor
struct MobileWorkspaceConnectionServices {
var discoverHostKey:
(Host.ID, String, Int, SSHTransportProxy?, [SSHRouteHopConfiguration]) async throws -> SSHHostKeyObservation
var makeSSHSession:
(
@escaping SSHSessionActor.OutputHandler, @escaping SSHSessionActor.ExitHandler,
@escaping SSHSessionActor.ShellIntegrationHandler
) -> SSHSessionActor
var connectSSH:
(
SSHSessionActor, SSHConnectionConfiguration, Bool, UInt16, UInt16,
SSHRouteProgressHandler?
) async throws -> Void
var connectSFTP:
(
SSHConnectionConfiguration, SSHRouteProgressHandler?,
(@Sendable (String) -> Void)?
) async throws -> any SFTPClientProtocol
var writeSSH: (SSHSessionActor, Data) async -> Void
}
+16
View File
@@ -0,0 +1,16 @@
import CoreGraphics
/// Mobile layout values shared by managed collection surfaces.
nonisolated enum MobileCardLayout {
static let minimumHeight: CGFloat = 104
static let maximumVisibleTags = 2
static func columns(width: CGFloat, isPad: Bool, accessibilitySize: Bool) -> Int {
guard isPad, !accessibilitySize else { return 1 }
return max(1, Int((max(0, width) + 12) / 332))
}
static func visibleTags(_ tags: [String]) -> ArraySlice<String> {
tags.prefix(maximumVisibleTags)
}
}
@@ -0,0 +1,22 @@
import SwiftUI
import UIKit
nonisolated enum MobileInterfaceIdiom: Sendable {
case phone
case pad
@MainActor static var current: Self {
UIDevice.current.userInterfaceIdiom == .pad ? .pad : .phone
}
}
private struct MobileInterfaceIdiomKey: EnvironmentKey {
static let defaultValue = MobileInterfaceIdiom.phone
}
extension EnvironmentValues {
var mobileInterfaceIdiom: MobileInterfaceIdiom {
get { self[MobileInterfaceIdiomKey.self] }
set { self[MobileInterfaceIdiomKey.self] = newValue }
}
}
@@ -33,9 +33,9 @@ struct MobileAssistantInteraction: View {
ScrollView {
switch interaction {
case .sshAuthentication(let request):
SharedMobileAuthentication(request: request, conversation: conversation, hosts: hosts)
MobileAssistantAuthenticationForm(request: request, conversation: conversation, hosts: hosts)
case .sshHostKey(let request):
SharedMobileHostKey(request: request, conversation: conversation)
MobileAssistantHostKeyForm(request: request, conversation: conversation)
case .questions:
questions
}
@@ -104,12 +104,12 @@ struct MobileAssistantInteraction: View {
}
}
private struct SharedMobileAuthentication: View {
private struct MobileAssistantAuthenticationForm: View {
let request: AgentSSHAuthenticationRequest
let conversation: ConversationModel
let hosts: HostLibraryModel
var body: some View {
SharedSSHAuthenticationForm(
SSHAuthenticationForm(
prompt: request.journeyPrompt,
submit: { identity, username, password, passphrase, key, save in
conversation.respondToSSHAuthentication(
@@ -124,11 +124,11 @@ private struct SharedMobileAuthentication: View {
}
}
private struct SharedMobileHostKey: View {
private struct MobileAssistantHostKeyForm: View {
let request: AgentSSHHostKeyRequest
let conversation: ConversationModel
var body: some View {
SharedSSHHostKeyConfirmationForm(
SSHHostKeyConfirmationForm(
prompt: request.prompt, displayAddress: request.address,
proceed: { conversation.respondToSSHHostKey(.continueOnce) },
trust: request.allowsPersistentTrust ? { conversation.respondToSSHHostKey(.trustAndContinue) } : nil,
@@ -6,11 +6,12 @@ import TermindDomain
struct MobileAssistantView: View {
let app: MobileAppModel
@Bindable var model: MobileAssistantModel
@Environment(\.mobileInterfaceIdiom) private var interfaceIdiom
@State private var history = false
var body: some View {
GeometryReader { geometry in
if UIDevice.current.userInterfaceIdiom == .pad && geometry.size.width >= 760 {
if interfaceIdiom == .pad && geometry.size.width >= 760 {
HStack(spacing: 0) {
MobileAssistantLibrary(model: model, selected: {})
.frame(width: 280)
File renamed without changes.
@@ -3,7 +3,7 @@ import TermindApplication
struct MobileAppSettingsView: View {
@AppStorage("mobile.appearance") private var appearance = "system"
@AppStorage(SharedSettingsKey.appLanguage) private var language = AppLanguage.system.rawValue
@AppStorage(SettingsKey.appLanguage) private var language = AppLanguage.system.rawValue
@State private var launchLanguage = AppLanguage.stored()
var body: some View {
@@ -8,25 +8,25 @@ nonisolated struct MobileSFTPTransferLimits: Equatable, Sendable {
init(global: Int, perHost: Int) {
self.global = min(
max(global, SharedSettingsKey.sftpTransferLimitRange.lowerBound),
SharedSettingsKey.sftpTransferLimitRange.upperBound
max(global, SettingsKey.sftpTransferLimitRange.lowerBound),
SettingsKey.sftpTransferLimitRange.upperBound
)
self.perHost = min(
max(perHost, SharedSettingsKey.sftpTransferLimitRange.lowerBound), self.global)
max(perHost, SettingsKey.sftpTransferLimitRange.lowerBound), self.global)
}
}
struct MobileSFTPSettingsView: View {
let transfers: SFTPTransferCoordinator
@AppStorage(SharedSettingsKey.sftpGlobalTransferLimit) private var globalLimit = SharedSettingsKey
@AppStorage(SettingsKey.sftpGlobalTransferLimit) private var globalLimit = SettingsKey
.defaultSFTPGlobalTransferLimit
@AppStorage(SharedSettingsKey.sftpPerHostTransferLimit) private var perHostLimit = SharedSettingsKey
@AppStorage(SettingsKey.sftpPerHostTransferLimit) private var perHostLimit = SettingsKey
.defaultSFTPPerHostTransferLimit
var body: some View {
Form {
Section("SFTP") {
Stepper(value: $globalLimit, in: SharedSettingsKey.sftpTransferLimitRange) {
Stepper(value: $globalLimit, in: SettingsKey.sftpTransferLimitRange) {
LabeledContent("Concurrent Transfers") {
Text(globalLimit, format: .number).monospacedDigit()
}
@@ -50,7 +50,7 @@ struct MobileSFTPSettingsView: View {
}
private var perHostRange: ClosedRange<Int> {
let minimum = SharedSettingsKey.sftpTransferLimitRange.lowerBound
let minimum = SettingsKey.sftpTransferLimitRange.lowerBound
return minimum...max(globalLimit, minimum)
}
@@ -4,19 +4,19 @@ 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
@AppStorage(SettingsKey.iCloudSyncEnabled) private var enabled = true
@AppStorage(SettingsKey.vaultSyncEnabled) private var vault = true
@AppStorage(SettingsKey.settingsSyncEnabled) private var settings = false
var body: some View {
Form {
Section {
Toggle(
"iCloud Sync",
isOn: selection(enabled && app.pro.canUse(.vaultSync), SharedSettingsKey.iCloudSyncEnabled))
isOn: selection(enabled && app.pro.canUse(.vaultSync), SettingsKey.iCloudSyncEnabled))
}
Section {
Toggle("Vault", isOn: selection(vault, SharedSettingsKey.vaultSyncEnabled))
Toggle("Vault", isOn: selection(vault, SettingsKey.vaultSyncEnabled))
if vault {
status(
app.vaultSync?.status, retry: { app.vaultSync?.retry() },
@@ -26,7 +26,7 @@ struct MobileSyncSettingsView: View {
.secondary)
}.disabled(!enabled || !app.pro.canUse(.vaultSync))
Section {
Toggle("Settings", isOn: selection(settings, SharedSettingsKey.settingsSyncEnabled))
Toggle("Settings", isOn: selection(settings, SettingsKey.settingsSyncEnabled))
if settings {
status(
app.settingsSync?.status, retry: { app.settingsSync?.retry() },
+31
View File
@@ -0,0 +1,31 @@
import Foundation
nonisolated enum MobileDestination: String, CaseIterable, Identifiable {
case vault, metrics, workspaces, assistant, settings
var id: Self { self }
var title: String {
switch self {
case .vault: String(localized: "Vault")
case .assistant: String(localized: "Assistant")
case .workspaces: String(localized: "Workspaces")
case .metrics: String(localized: "Metrics")
case .settings: String(localized: "Settings")
}
}
var symbol: String {
switch self {
case .vault: "server.rack"
case .assistant: "sparkles"
case .workspaces: "terminal"
case .metrics: "gauge.with.dots.needle.33percent"
case .settings: "gearshape"
}
}
}
nonisolated enum MobileTab: String, CaseIterable {
case vault, workspaces, assistant, settings
}
File renamed without changes.
@@ -10,61 +10,6 @@ import TermindRemoteInfrastructure
import struct TermindDomain.Host
@MainActor
struct MobileWorkspaceConnectionServices {
var discoverHostKey:
(Host.ID, String, Int, SSHTransportProxy?, [SSHRouteHopConfiguration]) async throws -> SSHHostKeyObservation
var makeSSHSession:
(
@escaping SSHSessionActor.OutputHandler, @escaping SSHSessionActor.ExitHandler,
@escaping SSHSessionActor.ShellIntegrationHandler
) -> SSHSessionActor
var connectSSH:
(
SSHSessionActor, SSHConnectionConfiguration, Bool, UInt16, UInt16,
SSHRouteProgressHandler?
) async throws -> Void
var connectSFTP:
(
SSHConnectionConfiguration, SSHRouteProgressHandler?,
(@Sendable (String) -> Void)?
) async throws -> any SFTPClientProtocol
var writeSSH: (SSHSessionActor, Data) async -> Void
static var live: MobileWorkspaceConnectionServices {
let coordinator = RemoteConnectionCoordinator()
return MobileWorkspaceConnectionServices(
discoverHostKey: { hostID, host, port, proxy, jumps in
try await SSHSessionActor.discoverHostKey(
hostID: hostID, host: host, port: port, proxy: proxy, jumps: jumps)
},
makeSSHSession: { output, exit, shellIntegration in
coordinator.makeSSHSession(output: output, exit: exit, shellIntegration: shellIntegration)
},
connectSSH: { session, configuration, shellIntegrationEnabled, columns, rows, progress in
try await session.connect(
hostID: configuration.hostID,
host: configuration.host,
port: configuration.port,
username: configuration.username,
credentials: configuration.credentials,
expectedHostKeys: configuration.expectedHostKeys,
proxy: configuration.proxy,
jumps: configuration.jumps,
shellIntegrationEnabled: shellIntegrationEnabled,
columns: columns,
rows: rows,
progress: progress
)
},
connectSFTP: { configuration, progress, connectionFailure in
try await coordinator.connectSFTP(
using: configuration, progress: progress, connectionFailure: connectionFailure)
},
writeSSH: { session, data in await session.write(data) })
}
}
private enum MobileWorkspaceConnectionResumeIntent: Equatable {
case none
case whenOpened
@@ -76,11 +21,6 @@ private nonisolated enum MobileSSHSessionEvent: Sendable {
case shellIntegration(SSHShellIntegrationEvent)
}
private struct MobilePendingDraftRequest {
var id: UUID
var continuation: CheckedContinuation<String, any Error>
}
nonisolated enum MobileUploadNotice: Equatable, Sendable {
case preparing(String)
case queued(Int, String)
@@ -115,8 +55,8 @@ final class MobileWorkspace: Identifiable {
}
}
private(set) var busy = false
var reconnectState: ReconnectState? { reconnect.state }
let journey = ConnectionJourney()
var reconnectState: ReconnectState? { connection.reconnectState }
var journey: ConnectionJourney { connection.journey }
var operationError: String?
private var connectionFailure: String?
private var pendingHostKeyHostID: UUID?
@@ -126,7 +66,10 @@ final class MobileWorkspace: Identifiable {
var authenticationRequired = false
var username = ""
private var authenticationHostID: UUID?
private var temporaryKeys: [UUID: SSHHostKeyIdentity] = [:]
private var temporaryKeys: [UUID: SSHHostKeyIdentity] {
get { connection.temporaryHostKeys }
set { connection.temporaryHostKeys = newValue }
}
private var submissions: [UUID: SSHAuthenticationSubmission] = [:]
var path = "/"
private(set) var files: [RemoteFile] = []
@@ -146,16 +89,13 @@ final class MobileWorkspace: Identifiable {
private var shellIntegrationAvailable = false
private(set) var pendingInitialCommand: String?
private(set) var workingDirectory: String?
private(set) var commandGeneratorState: CommandGeneratorState = .idle
var commandGeneratorState: CommandGeneratorState { commandGeneration.state }
private var integratedShell: String?
private var remoteSystemInfo: RemoteSystemInfo?
private var isShellPromptReady = false
private var commandGeneratorRevision: UInt64 = 0
private var commandGenerationSequence: UInt64 = 0
private var commandGeneratorTask: Task<Void, Never>?
private var commandGeneratorDismissTask: Task<Void, Never>?
private var pendingDraftRequest: MobilePendingDraftRequest?
private var draftRequestTimeoutTask: Task<Void, Never>?
private let commandGeneration = CommandGenerationCoordinator()
private let commandDraft = TerminalCommandDraftController()
private var workingDirectoryObservation: AnyCancellable?
private let surfaceOwner = MobileTerminalSurface()
let terminal: TerminalViewState
@@ -168,7 +108,7 @@ final class MobileWorkspace: Identifiable {
private var generation = UUID()
private let hosts: any RemoteSessionHostStore
private let connectionServices: MobileWorkspaceConnectionServices
private let reconnect: MobileWorkspaceReconnectState
private let connection: RemoteSessionConnectionController
private var viewport = InMemoryTerminalViewport(columns: 80, rows: 24)
var uploadConflictCount: Int { pendingUpload?.batch.conflictCount ?? 0 }
@@ -225,7 +165,7 @@ final class MobileWorkspace: Identifiable {
for: hosts.host(descriptor.hostID)?.detectedDistribution)?.asset)
pendingInitialCommand = initialCommand
self.connectionServices = connectionServices
reconnect = MobileWorkspaceReconnectState(controller: reconnectController)
connection = RemoteSessionConnectionController(reconnect: reconnectController)
connectionResumeIntent = connectsWhenOpened ? .whenOpened : .none
let relay = MobileTerminalRelay()
backend = InMemoryTerminalSession(
@@ -318,22 +258,24 @@ final class MobileWorkspace: Identifiable {
func presentFind() {
let wasOpen = isFindPresented
isFindPresented = true
if !wasOpen && !findQuery.isEmpty { _ = terminal.performBindingAction("search:\(findQuery)") }
if !wasOpen && !findQuery.isEmpty {
_ = terminal.performBindingAction(TerminalFindBinding.search(findQuery))
}
}
func updateFindQuery(_ query: String) {
findQuery = query
_ = terminal.performBindingAction("search:\(query)")
_ = terminal.performBindingAction(TerminalFindBinding.search(query))
}
func navigateFind(previous: Bool) {
func navigateFind(_ direction: TerminalFindDirection) {
presentFind()
guard !findQuery.isEmpty else { return }
_ = terminal.performBindingAction(previous ? "navigate_search:previous" : "navigate_search:next")
_ = terminal.performBindingAction(TerminalFindBinding.navigate(direction))
}
func dismissFind() {
_ = terminal.performBindingAction("end_search")
_ = terminal.performBindingAction(TerminalFindBinding.end)
isFindPresented = false
terminal.requestFocus()
}
@@ -345,7 +287,7 @@ final class MobileWorkspace: Identifiable {
}
func generateCommand(using generator: any CommandGenerating) {
if commandGeneratorTask != nil {
if commandGeneration.isRunning {
cancelCommandGeneration()
return
}
@@ -365,81 +307,38 @@ final class MobileWorkspace: Identifiable {
return
}
commandGeneratorDismissTask?.cancel()
commandGenerationSequence &+= 1
let sequence = commandGenerationSequence
let connectionGeneration = generation
let revision = commandGeneratorRevision
commandGeneratorState = .readingDraft
commandGeneratorTask = Task { [weak self, generator] in
do {
guard let self else { return }
let includesSystemInfo = terminalSettings.preferences.shellIntegration
commandGeneration.start(
targetCount: 1,
readContext: { [weak self] in
guard let self else { throw CancellationError() }
let draft = try await requestCurrentDraft()
try Task.checkCancellation()
guard !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw MobileCommandGenerationError.emptyDraft
}
guard
commandGenerationSnapshotIsCurrent(
sequence: sequence,
connectionGeneration: connectionGeneration,
revision: revision,
shell: integratedShell,
workingDirectory: workingDirectory
)
else { throw CancellationError() }
commandGeneratorState = .generating
let context = CommandGeneratorContext(
return CommandGeneratorContext(
draft: draft,
shell: integratedShell,
cwd: workingDirectory,
remoteSystemInfo: terminalSettings.preferences.shellIntegration
? remoteSystemInfo : nil
remoteSystemInfo: includesSystemInfo ? remoteSystemInfo : nil
)
let command = try await mobileCommandGenerationWithTimeout {
try await generator.generate(context)
}
try Task.checkCancellation()
guard
commandGenerationSnapshotIsCurrent(
sequence: sequence,
connectionGeneration: connectionGeneration,
revision: revision,
shell: integratedShell,
workingDirectory: workingDirectory
)
else { throw CancellationError() }
await replaceCurrentDraft(with: command)
finishCommandGeneration(
sequence: sequence,
state: .inserted(targetCount: 1),
dismissAfter: .seconds(2)
)
} catch is CancellationError {
self?.finishCancelledCommandGeneration(sequence: sequence)
} catch {
self?.finishCommandGeneration(
sequence: sequence,
state: .failed(error.localizedDescription),
dismissAfter: .seconds(3)
)
}
}
},
isCurrent: { [weak self] in
self?.commandGenerationSnapshotIsCurrent(
connectionGeneration: connectionGeneration,
revision: revision,
shell: integratedShell,
workingDirectory: workingDirectory
) == true
},
generate: { try await generator.generate($0) },
insert: { [weak self] command in await self?.replaceCurrentDraft(with: command) }
)
}
func cancelCommandGeneration() {
guard commandGeneratorTask != nil || pendingDraftRequest != nil else { return }
commandGenerationSequence &+= 1
commandGeneratorTask?.cancel()
commandGeneratorTask = nil
commandGeneratorDismissTask?.cancel()
commandGeneratorDismissTask = nil
if let request = pendingDraftRequest {
failDraftRequest(id: request.id, with: CancellationError())
}
commandGeneratorState = .idle
guard commandGeneration.isRunning || commandDraft.hasPendingRequest else { return }
commandGeneration.cancel()
commandDraft.cancel(CancellationError())
}
func receiveWorkingDirectory(_ path: String?) {
@@ -452,7 +351,7 @@ final class MobileWorkspace: Identifiable {
var canGenerateFromCurrentDraft: Bool {
connected && shellIntegrationAvailable && isShellPromptReady
&& Self.supportedGeneratorShells.contains(integratedShell ?? "")
&& TerminalCommandDraftController.supportedShells.contains(integratedShell ?? "")
}
func receiveInteractiveTerminalInput(_ data: Data) async {
@@ -466,70 +365,23 @@ final class MobileWorkspace: Identifiable {
}
private func requestCurrentDraft() async throws -> String {
guard canGenerateFromCurrentDraft else { throw MobileCommandDraftError.unavailable }
guard pendingDraftRequest == nil else { throw MobileCommandDraftError.alreadyPending }
let requestID = UUID()
return try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<String, any Error>) in
pendingDraftRequest = MobilePendingDraftRequest(
id: requestID,
continuation: continuation
)
Task { [weak self] in
await self?.writeSSHDirectly(Self.commandGeneratorDraftRequest)
}
draftRequestTimeoutTask?.cancel()
draftRequestTimeoutTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(2))
guard !Task.isCancelled else { return }
self?.failDraftRequest(
id: requestID,
with: MobileCommandDraftError.timedOut
)
}
}
} onCancel: {
Task { @MainActor [weak self] in
self?.failDraftRequest(id: requestID, with: CancellationError())
}
try await commandDraft.read(isAvailable: canGenerateFromCurrentDraft) { [weak self] data in
await self?.writeSSHDirectly(data)
}
}
private func replaceCurrentDraft(with command: String) async {
var data = Self.commandGeneratorDraftClear
data.append(Self.bracketedPasteStart)
data.append(Data(command.utf8))
data.append(Self.bracketedPasteEnd)
await writeSSHDirectly(data)
await writeSSHDirectly(commandDraft.replacementPayload(for: command))
terminal.requestFocus()
}
private func failDraftRequest(id: UUID, with error: any Error) {
guard pendingDraftRequest?.id == id, let request = pendingDraftRequest else { return }
pendingDraftRequest = nil
draftRequestTimeoutTask?.cancel()
draftRequestTimeoutTask = nil
request.continuation.resume(throwing: error)
}
private func completeDraftRequest(with draft: String) {
guard let request = pendingDraftRequest else { return }
pendingDraftRequest = nil
draftRequestTimeoutTask?.cancel()
draftRequestTimeoutTask = nil
request.continuation.resume(returning: draft)
}
private func commandGenerationSnapshotIsCurrent(
sequence: UInt64,
connectionGeneration: UUID,
revision: UInt64,
shell: String,
workingDirectory: String
) -> Bool {
commandGenerationSequence == sequence
&& generation == connectionGeneration
generation == connectionGeneration
&& commandGeneratorRevision == revision
&& integratedShell == shell
&& self.workingDirectory == workingDirectory
@@ -538,7 +390,7 @@ final class MobileWorkspace: Identifiable {
private func invalidateCommandGeneration() {
commandGeneratorRevision &+= 1
if commandGeneratorTask != nil { cancelCommandGeneration() }
if commandGeneration.isRunning { cancelCommandGeneration() }
}
private func resetCommandGeneratorShellState() {
@@ -548,53 +400,12 @@ final class MobileWorkspace: Identifiable {
isShellPromptReady = false
shellIntegrationAvailable = false
workingDirectory = nil
if let request = pendingDraftRequest {
failDraftRequest(id: request.id, with: MobileCommandDraftError.unavailable)
}
commandDraft.cancel()
commandGeneratorRevision &+= 1
}
private func commandGenerationDidFinish(sequence: UInt64) -> Bool {
guard commandGenerationSequence == sequence else { return false }
commandGeneratorTask = nil
return true
}
private func finishCancelledCommandGeneration(sequence: UInt64) {
guard commandGenerationDidFinish(sequence: sequence) else { return }
commandGeneratorState = .idle
}
private func finishCommandGeneration(
sequence: UInt64,
state: CommandGeneratorState,
dismissAfter: Duration
) {
guard commandGenerationDidFinish(sequence: sequence) else { return }
commandGeneratorState = state
scheduleCommandGenerationDismiss(sequence: sequence, after: dismissAfter)
}
private func showCommandGenerationFailure(_ message: String) {
commandGenerationSequence &+= 1
commandGeneratorTask?.cancel()
commandGeneratorTask = nil
commandGeneratorDismissTask?.cancel()
commandGeneratorState = .failed(message)
scheduleCommandGenerationDismiss(
sequence: commandGenerationSequence,
after: .seconds(3)
)
}
private func scheduleCommandGenerationDismiss(sequence: UInt64, after delay: Duration) {
commandGeneratorDismissTask?.cancel()
commandGeneratorDismissTask = Task { [weak self] in
try? await Task.sleep(for: delay)
guard !Task.isCancelled, self?.commandGenerationSequence == sequence else { return }
self?.commandGeneratorState = .idle
self?.commandGeneratorDismissTask = nil
}
commandGeneration.fail(message)
}
func receiveShellEvent(_ event: SSHShellIntegrationEvent) {
@@ -642,7 +453,7 @@ final class MobileWorkspace: Identifiable {
for: id, hostID: descriptor.hostID, exitStatus: status, duration: duration)
activeHistoryID = nil
case .draft(let draft):
completeDraftRequest(with: draft)
commandDraft.receive(draft)
}
}
@@ -650,7 +461,7 @@ final class MobileWorkspace: Identifiable {
guard !busy, !connected else { return }
cancelPendingUpload()
stopSSHEventConsumer()
let reconnecting = reconnect.isRecovering
let reconnecting = connection.isRecovering
let token = UUID()
generation = token
activeHistoryID = nil
@@ -660,7 +471,7 @@ final class MobileWorkspace: Identifiable {
status = String(localized: reconnecting ? "Reconnecting" : "Connecting")
guard let host = hosts.host(descriptor.hostID) else {
let message = String(localized: "The host is no longer available.")
reconnect.stopRecovery()
connection.stopRecovery()
connectionFailure = message
status = String(localized: "Disconnected")
busy = false
@@ -700,7 +511,7 @@ final class MobileWorkspace: Identifiable {
}
pendingHostKeyHostID = endpoint.id
pendingHostKey = observation
reconnect.pauseForUserInteraction()
connection.pauseForUserInteraction()
journey.waitForHostKey(at: endpoint.id, fingerprint: observation.fingerprint)
status = String(localized: "Waiting for approval")
return
@@ -751,7 +562,7 @@ final class MobileWorkspace: Identifiable {
guard generation == token, !Task.isCancelled else { return }
transferConfiguration = config
submissions.removeAll()
let wasReconnecting = reconnect.markConnected()
let wasReconnecting = connection.markConnected()
connected = true
status = String(localized: "Connected")
journey.succeed()
@@ -777,7 +588,7 @@ final class MobileWorkspace: Identifiable {
}
if authenticationRequired {
requiresUserInteraction = true
reconnect.pauseForUserInteraction()
connection.pauseForUserInteraction()
authenticationMode =
sshError.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity
authenticationHostID = sshError.routeHostID ?? descriptor.hostID
@@ -809,10 +620,10 @@ final class MobileWorkspace: Identifiable {
}
}
if !requiresUserInteraction {
if (reconnect.hasConnectedOnce || reconnecting) && !preventsAutomaticReconnect {
if (connection.hasConnectedOnce || reconnecting) && !preventsAutomaticReconnect {
scheduleReconnect(after: error.localizedDescription)
} else {
reconnect.stopRecovery()
connection.stopRecovery()
journey.fail(error.localizedDescription)
status = String(localized: "Disconnected")
}
@@ -833,11 +644,11 @@ final class MobileWorkspace: Identifiable {
}
func retryReconnectNow() {
reconnect.retryNow()
connection.reconnect.retryNow()
}
func stopReconnecting() {
guard let reconnectState = reconnect.stopReconnecting() else { return }
guard let reconnectState = connection.stopRecovery() else { return }
cancelPendingUpload()
stopSSHEventConsumer()
resetCommandGeneratorShellState()
@@ -872,7 +683,7 @@ final class MobileWorkspace: Identifiable {
cancelPendingUpload()
stopSSHEventConsumer()
resetCommandGeneratorShellState()
let reconnecting = reconnect.hasConnectedOnce
let reconnecting = connection.hasConnectedOnce
connectionResumeIntent = .foreground
generation = UUID()
attempt?.cancel()
@@ -893,7 +704,7 @@ final class MobileWorkspace: Identifiable {
submissions.removeAll()
connectionFailure = nil
operationError = nil
reconnect.suspendForForegroundResume(reconnecting: reconnecting)
connection.suspendForForegroundResume(reconnecting: reconnecting)
status = String(localized: reconnecting ? "Reconnecting" : "Connecting")
presentConnectionJourney(mode: reconnecting ? .reconnection : .connection)
Task {
@@ -922,7 +733,7 @@ final class MobileWorkspace: Identifiable {
switch result {
case .success:
connectionResumeIntent = .none
reconnect.reset()
connection.resetRecovery()
status = String(localized: "Disconnected")
didRequestClose?(id)
case .failure(let failure):
@@ -931,7 +742,7 @@ final class MobileWorkspace: Identifiable {
}
private func handleConnectionFailure(_ message: String) {
guard reconnect.hasConnectedOnce else { return }
guard connection.hasConnectedOnce else { return }
scheduleReconnect(after: message)
}
@@ -939,7 +750,7 @@ final class MobileWorkspace: Identifiable {
cancelPendingUpload()
stopSSHEventConsumer()
resetCommandGeneratorShellState()
let announcesLoss = reconnect.schedule(after: message) { [weak self] in self?.connect() }
let announcesLoss = connection.scheduleRecovery(after: message) { [weak self] in self?.connect() }
generation = UUID()
attempt?.cancel()
attempt = nil
@@ -991,7 +802,7 @@ final class MobileWorkspace: Identifiable {
resetCommandGeneratorShellState()
connectionResumeIntent = .none
journey.hide()
reconnect.reset()
connection.resetRecovery()
connectionFailure = nil
operationError = nil
pendingHostKey = nil
@@ -1094,7 +905,7 @@ final class MobileWorkspace: Identifiable {
}
private var hasActiveConnectionFlow: Bool {
if connected || busy || reconnect.isRecovering || authenticationRequired || pendingHostKey != nil {
if connected || busy || connection.isRecovering || authenticationRequired || pendingHostKey != nil {
return true
}
switch journey.phase {
@@ -1362,14 +1173,8 @@ final class MobileWorkspace: Identifiable {
&& !name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) })
}
private static let supportedGeneratorShells: Set<String> = ["bash", "zsh", "fish"]
private static let commandGeneratorDraftRequest = Data("\u{1B}[777;1~".utf8)
private static let commandGeneratorDraftClear = Data("\u{1B}[777;2~".utf8)
private static let bracketedPasteStart = Data("\u{1B}[200~".utf8)
private static let bracketedPasteEnd = Data("\u{1B}[201~".utf8)
private func handleSFTPConnectionError(_ error: any Error, connection: UUID) -> Bool {
guard generation == connection, reconnect.hasConnectedOnce, let sftpError = error as? SFTPError else {
guard generation == connection, self.connection.hasConnectedOnce, let sftpError = error as? SFTPError else {
return false
}
switch sftpError {
@@ -1392,115 +1197,6 @@ final class MobileWorkspace: Identifiable {
}
}
private nonisolated enum MobileCommandDraftError: LocalizedError {
case unavailable
case alreadyPending
case timedOut
var errorDescription: String? {
switch self {
case .unavailable:
String(
localized:
"Command Generator requires a supported shell prompt with Shell Integration enabled."
)
case .alreadyPending:
String(localized: "Command Generator is already reading the current input.")
case .timedOut:
String(localized: "The remote shell did not return its current input.")
}
}
}
private nonisolated enum MobileCommandGenerationError: LocalizedError {
case emptyDraft
var errorDescription: String? {
String(localized: "Type something at the shell prompt before generating a command.")
}
}
private nonisolated func mobileCommandGenerationWithTimeout(
_ operation: @escaping @Sendable () async throws -> String
) async throws -> String {
try await withThrowingTaskGroup(of: String.self) { group in
group.addTask { try await operation() }
group.addTask {
try await Task.sleep(for: .seconds(30))
throw CommandGeneratorError.timedOut
}
guard let result = try await group.next() else {
throw CommandGeneratorError.invalidResponse
}
group.cancelAll()
return result
}
}
@MainActor
@Observable
final class MobileWorkspaceReconnectState {
private let controller: SessionReconnectController
private(set) var hasConnectedOnce = false
private(set) var isRecovering = false
var state: ReconnectState? { controller.state }
init(controller: SessionReconnectController = SessionReconnectController()) {
self.controller = controller
}
@discardableResult
func markConnected() -> Bool {
let wasRecovering = isRecovering
let initialConnection = !hasConnectedOnce
hasConnectedOnce = true
isRecovering = false
controller.markConnected(initial: initialConnection)
return wasRecovering
}
@discardableResult
func schedule(after message: String, retry: @escaping SessionReconnectController.RetryAction) -> Bool {
guard hasConnectedOnce else { return false }
let announcesLoss = !isRecovering
isRecovering = true
controller.schedule(after: message, retry: retry)
return announcesLoss
}
func pauseForUserInteraction() {
controller.pauseForUserInteraction()
}
func retryNow() {
controller.retryNow()
}
func stopReconnecting() -> ReconnectState? {
guard let state else { return nil }
controller.stop()
isRecovering = false
return state
}
func stopRecovery() {
controller.stop()
isRecovering = false
}
func suspendForForegroundResume(reconnecting: Bool) {
controller.stop()
isRecovering = reconnecting && hasConnectedOnce
}
func reset() {
controller.stop()
hasConnectedOnce = false
isRecovering = false
}
}
@MainActor
private final class MobileTerminalRelay {
weak var owner: MobileWorkspace?
@@ -124,11 +124,11 @@ struct MobileWorkspaceView: View {
.keyboardShortcut("i", modifiers: .command)
Divider()
Button("Next Match", systemImage: "chevron.down") {
workspace.navigateFind(previous: false)
workspace.navigateFind(.next)
}
.keyboardShortcut("g", modifiers: .command)
Button("Previous Match", systemImage: "chevron.up") {
workspace.navigateFind(previous: true)
workspace.navigateFind(.previous)
}
.keyboardShortcut("g", modifiers: [.command, .shift])
Divider()
@@ -459,7 +459,7 @@ private struct MobileTerminalSearchModifier: ViewModifier {
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.onSubmit(of: .search) { workspace.navigateFind(previous: true) }
.onSubmit(of: .search) { workspace.navigateFind(.previous) }
}
private var query: Binding<String> {
@@ -0,0 +1,37 @@
import Foundation
import TermindRemoteInfrastructure
extension MobileWorkspaceConnectionServices {
static var live: MobileWorkspaceConnectionServices {
let coordinator = RemoteConnectionCoordinator()
return MobileWorkspaceConnectionServices(
discoverHostKey: { hostID, host, port, proxy, jumps in
try await SSHSessionActor.discoverHostKey(
hostID: hostID, host: host, port: port, proxy: proxy, jumps: jumps)
},
makeSSHSession: { output, exit, shellIntegration in
coordinator.makeSSHSession(output: output, exit: exit, shellIntegration: shellIntegration)
},
connectSSH: { session, configuration, shellIntegrationEnabled, columns, rows, progress in
try await session.connect(
hostID: configuration.hostID,
host: configuration.host,
port: configuration.port,
username: configuration.username,
credentials: configuration.credentials,
expectedHostKeys: configuration.expectedHostKeys,
proxy: configuration.proxy,
jumps: configuration.jumps,
shellIntegrationEnabled: shellIntegrationEnabled,
columns: columns,
rows: rows,
progress: progress
)
},
connectSFTP: { configuration, progress, connectionFailure in
try await coordinator.connectSFTP(
using: configuration, progress: progress, connectionFailure: connectionFailure)
},
writeSSH: { session, data in await session.write(data) })
}
}
+9 -12
View File
@@ -4,9 +4,6 @@ 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) }
}
@@ -43,7 +40,7 @@ nonisolated extension TerminalCustomConfiguration {
nonisolated enum TermindTerminalThemeCatalog {
static let allThemes: [GhosttyThemeDefinition] = {
let termindThemes = TerminalTheme.all.map(\.ghosttyDefinition)
let termindThemes = TermindTerminalPalette.all.map(\.ghosttyDefinition)
let termindNames = Set(termindThemes.map(\.name))
return termindThemes + GhosttyThemeCatalog.allThemes.filter { !termindNames.contains($0.name) }
}()
@@ -60,8 +57,8 @@ nonisolated enum TermindTerminalThemeCatalog {
theme(named: name).flatMap { $0.isDark ? nil : $0 }
}
static let defaultDark = TerminalTheme.termindDark.ghosttyDefinition
static let defaultLight = TerminalTheme.termindLight.ghosttyDefinition
static let defaultDark = TermindTerminalPalette.termindDark.ghosttyDefinition
static let defaultLight = TermindTerminalPalette.termindLight.ghosttyDefinition
static func terminalConfiguration(
themeName: String,
@@ -73,7 +70,7 @@ nonisolated enum TermindTerminalThemeCatalog {
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)
return TerminalCustomConfiguration(customText).applying(to: base)
}
static func background(
@@ -81,17 +78,17 @@ nonisolated enum TermindTerminalThemeCatalog {
customEnabled: Bool,
customText: String,
isDark: Bool
) -> TerminalTheme.RGB {
) -> TermindTerminalPalette.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)
let value = TerminalCustomConfiguration(customText).lastValue(for: "background"),
let customBackground = TermindTerminalPalette.RGB(hex: value)
{
return customBackground
}
return TerminalTheme.RGB(hex: selected.background)
?? (isDark ? TerminalTheme.termindDark.background : TerminalTheme.termindLight.background)
return TermindTerminalPalette.RGB(hex: selected.background)
?? (isDark ? TermindTerminalPalette.termindDark.background : TermindTerminalPalette.termindLight.background)
}
}
@@ -87,12 +87,12 @@ struct MobileConnectionJourneyView: View {
if journey.showsLogs {
logs
} else if let authenticationPrompt {
SharedSSHAuthenticationForm(
SSHAuthenticationForm(
prompt: authenticationPrompt, submit: submit, cancel: cancel, library: library
)
.id(authenticationPrompt.id)
} else if let hostKey {
SharedSSHHostKeyConfirmationForm(
SSHHostKeyConfirmationForm(
prompt: hostKey, displayAddress: hostKey.observation.hostname,
proceed: { accept(false) }, trust: { accept(true) }, deny: cancel)
} else if case .failed(let message) = journey.phase {
@@ -1,13 +1,13 @@
import SwiftUI
import UIKit
/// iPad forms use the system's centered presentation. Detents are reserved for iPhone trays.
private struct MobileSheetPresentation: ViewModifier {
var compact: Bool
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.mobileInterfaceIdiom) private var interfaceIdiom
@ViewBuilder func body(content: Content) -> some View {
if UIDevice.current.userInterfaceIdiom == .pad {
if interfaceIdiom == .pad {
content.presentationSizing(.form)
.presentationDragIndicator(.hidden)
} else {
@@ -1,23 +1,6 @@
import SwiftUI
import UIKit
/// Width is the content width, after the page's safe-area margins.
nonisolated enum MobileCardLayout {
/// The natural height of a touch card with a 36pt header, one chip row,
/// 12pt between them and 16pt of padding on each edge.
static let minimumHeight: CGFloat = 104
static let maximumVisibleTags = 2
static func columns(width: CGFloat, isPad: Bool, accessibilitySize: Bool) -> Int {
guard isPad, !accessibilitySize else { return 1 }
return max(1, Int((max(0, width) + 12) / 332))
}
static func visibleTags(_ tags: [String]) -> ArraySlice<String> {
tags.prefix(maximumVisibleTags)
}
}
struct MobileCardPage<Content: View>: View {
@ViewBuilder var content: Content
@@ -32,6 +15,7 @@ struct MobileCardPage<Content: View>: View {
struct MobileCardGrid<Content: View>: View {
@Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.mobileInterfaceIdiom) private var interfaceIdiom
@State private var width: CGFloat = 0
@ViewBuilder var content: Content
@@ -40,7 +24,7 @@ struct MobileCardGrid<Content: View>: View {
columns: Array(
repeating: GridItem(.flexible(), spacing: 12, alignment: .top),
count: MobileCardLayout.columns(
width: width, isPad: UIDevice.current.userInterfaceIdiom == .pad,
width: width, isPad: interfaceIdiom == .pad,
accessibilitySize: typeSize.isAccessibilitySize)),
alignment: .leading, spacing: 12
) { content }
+17 -17
View File
@@ -122,7 +122,7 @@ struct MobileTerminalShiftStateTests {
}
@Suite @MainActor
struct MobileWorkspaceReconnectStateTests {
struct MobileWorkspaceConnectionRecoveryTests {
@Test func backgroundExpirationResumesEveryAffectedWorkspaceOnForeground() async throws {
let background = MobileTestBackgroundTaskManager()
let connections = MobileSFTPConnectionBox(
@@ -483,43 +483,43 @@ struct MobileWorkspaceReconnectStateTests {
@Test func onlySuccessfulSessionsEnterAutomaticRecovery() {
let controller = SessionReconnectController(sleep: { _ in try await Task.sleep(for: .seconds(3_600)) })
let state = MobileWorkspaceReconnectState(controller: controller)
defer { state.reset() }
let state = RemoteSessionConnectionController(reconnect: controller)
defer { state.resetRecovery() }
#expect(!state.schedule(after: "initial failure") {})
#expect(state.state == nil)
#expect(!state.scheduleRecovery(after: "initial failure") {})
#expect(state.reconnectState == nil)
#expect(!state.markConnected())
#expect(state.schedule(after: "connection lost") {})
#expect(state.scheduleRecovery(after: "connection lost") {})
#expect(state.isRecovering)
#expect(state.state?.attempt == 1)
#expect(!state.schedule(after: "still offline") {})
#expect(state.state?.attempt == 2)
#expect(state.reconnectState?.attempt == 1)
#expect(!state.scheduleRecovery(after: "still offline") {})
#expect(state.reconnectState?.attempt == 2)
}
@Test func stoppingRecoveryKeepsManualReconnectAvailableAndResetClearsHistory() {
let controller = SessionReconnectController(sleep: { _ in try await Task.sleep(for: .seconds(3_600)) })
let state = MobileWorkspaceReconnectState(controller: controller)
let state = RemoteSessionConnectionController(reconnect: controller)
_ = state.markConnected()
_ = state.schedule(after: "offline") {}
_ = state.scheduleRecovery(after: "offline") {}
#expect(state.stopReconnecting()?.lastError == "offline")
#expect(state.state == nil)
#expect(state.stopRecovery()?.lastError == "offline")
#expect(state.reconnectState == nil)
#expect(state.hasConnectedOnce)
#expect(!state.isRecovering)
state.reset()
state.resetRecovery()
#expect(!state.hasConnectedOnce)
#expect(!state.isRecovering)
}
@Test func aRecoveredConnectionReportsRecoveryAndClearsRetryState() {
let controller = SessionReconnectController(sleep: { _ in try await Task.sleep(for: .seconds(3_600)) })
let state = MobileWorkspaceReconnectState(controller: controller)
let state = RemoteSessionConnectionController(reconnect: controller)
_ = state.markConnected()
_ = state.schedule(after: "offline") {}
_ = state.scheduleRecovery(after: "offline") {}
#expect(state.markConnected())
#expect(state.state == nil)
#expect(state.reconnectState == nil)
#expect(state.hasConnectedOnce)
#expect(!state.isRecovering)
}
+18 -18
View File
@@ -21,7 +21,7 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
let rootSource = try String(
contentsOf: root.appending(path: "TFI/Features/MobileRootView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8)
#expect(!rootSource.contains(".navigationTitle(\"Termind\")"))
}
@@ -30,9 +30,9 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
let rootSource = try String(
contentsOf: root.appending(path: "TFI/Features/MobileRootView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8)
let vaultSource = try String(
contentsOf: root.appending(path: "TFI/Features/MobileVaultView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/Vault/MobileVaultView.swift"), encoding: .utf8)
#expect(rootSource.contains("NavigationStack { vault }"))
#expect(rootSource.contains("case .vault: sidebarVault"))
@@ -46,7 +46,7 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
let source = try String(
contentsOf: root.appending(path: "TFI/Features/MobileAISettingsView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/Settings/MobileAISettingsView.swift"), encoding: .utf8)
let index = try #require(source.components(separatedBy: "enum MobileAISettingsDestination").first)
#expect(index.contains("NavigationLink {"))
#expect(!index.contains("NavigationLink(value:"))
@@ -65,7 +65,7 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
let source = try String(
contentsOf: root.appending(path: "TFI/Features/MobileMetricsView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/Metrics/MobileMetricsView.swift"), encoding: .utf8)
let processes = try #require(source.components(separatedBy: "struct MobileMetricsProcessesView").last)
#expect(processes.contains(".contentMargins(.top, 0, for: .scrollContent)"))
@@ -76,18 +76,18 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
let workspace = try String(
contentsOf: root.appending(path: "TFI/Features/MobileWorkspaceView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceView.swift"), encoding: .utf8)
#expect(!workspace.contains("\"Connection Error\""))
#expect(workspace.contains("get: { workspace.operationError != nil }"))
#expect(workspace.contains("cancel: { closeWorkspace(workspace.id) }"))
let metrics = try String(
contentsOf: root.appending(path: "TFI/Features/MobileMetricsView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/Metrics/MobileMetricsView.swift"), encoding: .utf8)
#expect(metrics.contains("app.metrics.core.setEnabled(false, for: server)"))
#expect(metrics.contains("dismiss()"))
let rootView = try String(
contentsOf: root.appending(path: "TFI/Features/MobileRootView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8)
#expect(rootView.contains("workspace.activateWhenOpened()"))
let appModel = try String(
contentsOf: root.appending(path: "TFI/App/MobileAppModel.swift"), encoding: .utf8)
@@ -100,14 +100,14 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
let workspaceList = try String(
contentsOf: root.appending(path: "TFI/Features/MobileRootView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8)
#expect(workspaceList.contains(".contextMenu {"))
#expect(
workspaceList.contains(
"Button(\"Close\", systemImage: \"xmark\", role: .destructive) { closeWorkspace(workspace.id) }"))
let toolbar = try String(
contentsOf: root.appending(path: "TFI/Features/MobileConnectionPicker.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/HostChooser/MobileConnectionPicker.swift"), encoding: .utf8)
#expect(toolbar.contains("Menu {"))
#expect(toolbar.contains("Button(\"Close All Workspaces\""))
#expect(toolbar.contains("systemImage: \"rectangle.stack.badge.minus\", role: .destructive"))
@@ -120,7 +120,7 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
let workspace = try String(
contentsOf: root.appending(path: "TFI/Features/MobileWorkspaceView.swift"),
contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceView.swift"),
encoding: .utf8
)
@@ -136,7 +136,7 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
let source = try String(
contentsOf: root.appending(path: "TFI/Features/MobileKnownHostsView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/KnownHosts/MobileKnownHostsView.swift"), encoding: .utf8)
#expect(source.contains("MobileActionCard"))
#expect(source.contains("symbol: \"touchid\""))
@@ -155,12 +155,12 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
let metrics = try String(
contentsOf: root.appending(path: "TFI/Features/MobileMetricsView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/Metrics/MobileMetricsView.swift"), encoding: .utf8)
#expect(metrics.contains(".scrollContentBackground(.hidden)"))
#expect(metrics.contains(".background(Color(uiColor: .systemBackground))"))
let cards = try String(
contentsOf: root.appending(path: "TFI/Features/MobileCards.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/UI/Managed/MobileCards.swift"), encoding: .utf8)
let actionCard = try #require(cards.components(separatedBy: "struct MobileActionCard").last)
#expect(actionCard.contains(".mobileCardSurface()"))
#expect(actionCard.contains(".contextMenu {"))
@@ -172,19 +172,19 @@ struct MobileNavigationParityTests {
#expect(!cards.contains(".frame(width: 320"))
let vault = try String(
contentsOf: root.appending(path: "TFI/Features/MobileVaultView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/Vault/MobileVaultView.swift"), encoding: .utf8)
#expect(vault.contains("MobileCardPage {"))
#expect(!vault.contains("List {"))
#expect(vault.contains("app.library.setExpanded(!section.isExpanded, for: id)"))
#expect(vault.contains("app.library.setUngroupedExpanded(!section.isExpanded)"))
let rootView = try String(
contentsOf: root.appending(path: "TFI/Features/MobileRootView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/App/MobileRootView.swift"), encoding: .utf8)
#expect(rootView.contains("ContentUnavailableView(\n \"No Workspaces\""))
#expect(rootView.contains(".buttonStyle(.plain)"))
let pro = try String(
contentsOf: root.appending(path: "TFI/Features/MobileProView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/Commerce/MobileProView.swift"), encoding: .utf8)
#expect(!pro.contains("Arrange multiple panes and broadcast input"))
#expect(!pro.contains(".safeAreaInset(edge: .bottom)"))
#expect(pro.contains("featureList\n purchaseActions"))
@@ -195,7 +195,7 @@ struct MobileNavigationParityTests {
.deletingLastPathComponent()
.deletingLastPathComponent()
let workspace = try String(
contentsOf: root.appending(path: "TFI/Features/MobileWorkspaceView.swift"), encoding: .utf8)
contentsOf: root.appending(path: "TFI/Features/Workspaces/MobileWorkspaceView.swift"), encoding: .utf8)
let segments = workspace.components(
separatedBy: "Button(\"Close\", systemImage: \"xmark\", role: .destructive)")
+6 -6
View File
@@ -12,11 +12,11 @@ import Testing
@Suite @MainActor
struct MobileSettingsPortTests {
@Test func sftpSettingsDefaultsAndNormalizationMatchDesktop() {
#expect(SharedSettingsKey.sftpGlobalTransferLimit == "sftp.transfers.globalLimit")
#expect(SharedSettingsKey.sftpPerHostTransferLimit == "sftp.transfers.perHostLimit")
#expect(SharedSettingsKey.defaultSFTPGlobalTransferLimit == 3)
#expect(SharedSettingsKey.defaultSFTPPerHostTransferLimit == 2)
#expect(SharedSettingsKey.sftpTransferLimitRange == 1...8)
#expect(SettingsKey.sftpGlobalTransferLimit == "sftp.transfers.globalLimit")
#expect(SettingsKey.sftpPerHostTransferLimit == "sftp.transfers.perHostLimit")
#expect(SettingsKey.defaultSFTPGlobalTransferLimit == 3)
#expect(SettingsKey.defaultSFTPPerHostTransferLimit == 2)
#expect(SettingsKey.sftpTransferLimitRange == 1...8)
let defaults = MobileSFTPTransferLimits(global: 3, perHost: 2)
#expect(defaults.global == 3)
@@ -192,7 +192,7 @@ struct MobileSettingsPortTests {
#expect(workspace.isFindPresented)
workspace.dismissFind()
workspace.navigateFind(previous: false)
workspace.navigateFind(.next)
#expect(workspace.findQuery == "retained")
#expect(workspace.isFindPresented)
}
+1 -1
View File
@@ -15,7 +15,7 @@ nonisolated enum AppAppearance: String, CaseIterable, Identifiable, Sendable {
}
static func stored(in defaults: UserDefaults = .standard) -> AppAppearance {
defaults.string(forKey: SettingsKey.appAppearance)
defaults.string(forKey: DesktopSettingsKey.appAppearance)
.flatMap(Self.init(rawValue:)) ?? .system
}
}
+1
View File
@@ -3,6 +3,7 @@ import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindRemoteInfrastructure
import struct TermindDomain.Host
+1
View File
@@ -4,6 +4,7 @@ import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindRemoteInfrastructure
import struct TermindDomain.Host
+12 -12
View File
@@ -118,26 +118,26 @@ final class AppReviewController {
contexts[windowID]?.canRequest == true, isEligible
else { return }
self.pending = nil
var versions = defaults?.stringArray(forKey: SettingsKey.reviewRequestedVersions) ?? []
var versions = defaults?.stringArray(forKey: DesktopSettingsKey.reviewRequestedVersions) ?? []
versions.append(version)
defaults?.set(versions, forKey: SettingsKey.reviewRequestedVersions)
defaults?.set(now(), forKey: SettingsKey.reviewLastAttempt)
defaults?.set(versions, forKey: DesktopSettingsKey.reviewRequestedVersions)
defaults?.set(now(), forKey: DesktopSettingsKey.reviewLastAttempt)
request()
}
func recordManualReview() {
pending = nil
defaults?.set(now(), forKey: SettingsKey.reviewLastAttempt)
defaults?.set(now(), forKey: DesktopSettingsKey.reviewLastAttempt)
}
private var isEligible: Bool {
guard isEnabled, !version.isEmpty, workspaceSeconds >= 300,
let firstUse = defaults?.object(forKey: SettingsKey.reviewFirstUse) as? Date,
let firstUse = defaults?.object(forKey: DesktopSettingsKey.reviewFirstUse) as? Date,
now().timeIntervalSince(firstUse) >= 7 * 86_400,
(defaults?.stringArray(forKey: SettingsKey.reviewActiveDays)?.count ?? 0) >= 3,
!(defaults?.stringArray(forKey: SettingsKey.reviewRequestedVersions) ?? []).contains(version)
(defaults?.stringArray(forKey: DesktopSettingsKey.reviewActiveDays)?.count ?? 0) >= 3,
!(defaults?.stringArray(forKey: DesktopSettingsKey.reviewRequestedVersions) ?? []).contains(version)
else { return false }
if let lastAttempt = defaults?.object(forKey: SettingsKey.reviewLastAttempt) as? Date {
if let lastAttempt = defaults?.object(forKey: DesktopSettingsKey.reviewLastAttempt) as? Date {
return now().timeIntervalSince(lastAttempt) >= 120 * 86_400
}
return true
@@ -154,16 +154,16 @@ final class AppReviewController {
private func recordActiveDay() {
let date = now()
if defaults?.object(forKey: SettingsKey.reviewFirstUse) == nil {
defaults?.set(date, forKey: SettingsKey.reviewFirstUse)
if defaults?.object(forKey: DesktopSettingsKey.reviewFirstUse) == nil {
defaults?.set(date, forKey: DesktopSettingsKey.reviewFirstUse)
}
var days = defaults?.stringArray(forKey: SettingsKey.reviewActiveDays) ?? []
var days = defaults?.stringArray(forKey: DesktopSettingsKey.reviewActiveDays) ?? []
// Three distinct local dates prove eligibility; no growing usage log.
guard days.count < 3 else { return }
let parts = calendar.dateComponents([.era, .year, .month, .day], from: date)
let key = "\(parts.era ?? 0)-\(parts.year ?? 0)-\(parts.month ?? 0)-\(parts.day ?? 0)"
guard !days.contains(key) else { return }
days.append(key)
defaults?.set(days, forKey: SettingsKey.reviewActiveDays)
defaults?.set(days, forKey: DesktopSettingsKey.reviewActiveDays)
}
}
@@ -7,33 +7,16 @@ import TermindApplication
/// String literals scattered across views are how a preference silently splits
/// into two preferences that disagree. Everything user-facing that lives in
/// `UserDefaults` rather than SwiftData is named here.
nonisolated enum SettingsKey {
nonisolated enum DesktopSettingsKey {
// Device-local review invitation history; never included in Settings sync.
static let reviewFirstUse = "app.review.firstUse"
static let reviewActiveDays = "app.review.activeDays"
static let reviewLastAttempt = "app.review.lastAttempt"
static let reviewRequestedVersions = "app.review.requestedVersions"
/// App language selection. `system` means normal Bundle negotiation.
static let showMetricsInMenuBar = "metrics.showInMenuBar"
static let appLanguage = SharedSettingsKey.appLanguage
/// App-wide appearance. `system` inherits changes from macOS.
static let appAppearance = "app.appearance"
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 = SharedSettingsKey.vaultSyncEnvironment
static let defaultVaultSyncEnabled = SharedSettingsKey.defaultVaultSyncEnabled
static func migrateSyncPreferences(in defaults: UserDefaults = .standard) {
SharedSettingsKey.migrateSyncPreferences(in: defaults)
}
static let mcpServerEnabled = "mcpServer.enabled"
static let mcpServerPort = "mcpServer.port"
@@ -50,24 +33,9 @@ nonisolated enum SettingsKey {
/// shrunk to its status dot. App-wide: it is a standing preference about
/// 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 = 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 = SharedSettingsKey.shellIntegration
static var isShellIntegrationEnabled: Bool {
UserDefaults.standard.object(forKey: shellIntegration) as? Bool ?? true
UserDefaults.standard.object(forKey: SettingsKey.shellIntegration) as? Bool ?? true
}
/// Mirror the focused SSH terminal's OSC 0/2 title in the macOS menu bar.
static let remoteWindowTitleInMenuBar = SharedSettingsKey.remoteWindowTitleInMenuBar
static let defaultRemoteWindowTitleInMenuBarEnabled = true
static let onboardingQuickOpenCompleted = "onboarding.quickOpen.completed"
static let onboardingStatusBarPathCompleted = "onboarding.statusBarPath.completed"
@@ -94,24 +62,9 @@ nonisolated enum SettingsKey {
}
/// The last approval posture selected in the Assistant composer.
static let agentPermissionMode = "assistant.permissionMode"
/// 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 = SharedSettingsKey.seededSkillGeneration
static let sftpGlobalTransferLimit = SharedSettingsKey.sftpGlobalTransferLimit
static let sftpPerHostTransferLimit = SharedSettingsKey.sftpPerHostTransferLimit
static let defaultSFTPGlobalTransferLimit = SharedSettingsKey.defaultSFTPGlobalTransferLimit
static let defaultSFTPPerHostTransferLimit = SharedSettingsKey.defaultSFTPPerHostTransferLimit
static let sftpTransferLimitRange = SharedSettingsKey.sftpTransferLimitRange
/// Let the assistant search the web.
///
/// On by default because ScrubberKit searches from this Mac without an
/// account or API key.
static let webSearchEnabled = SharedSettingsKey.webSearchEnabled
static let defaultWebSearchEnabled = SharedSettingsKey.defaultWebSearchEnabled
static var isWebSearchEnabled: Bool {
UserDefaults.standard.object(forKey: webSearchEnabled) as? Bool ?? defaultWebSearchEnabled
UserDefaults.standard.object(forKey: SettingsKey.webSearchEnabled) as? Bool
?? SettingsKey.defaultWebSearchEnabled
}
/// Let a provider run the search on its own servers where it can.
///
@@ -124,10 +77,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 = SharedSettingsKey.webSearchBuiltIn
static let defaultWebSearchBuiltIn = SharedSettingsKey.defaultWebSearchBuiltIn
static var isBuiltInWebSearchEnabled: Bool {
UserDefaults.standard.object(forKey: webSearchBuiltIn) as? Bool
?? defaultWebSearchBuiltIn
UserDefaults.standard.object(forKey: SettingsKey.webSearchBuiltIn) as? Bool
?? SettingsKey.defaultWebSearchBuiltIn
}
}
+11 -9
View File
@@ -1,5 +1,7 @@
import Foundation
import Security
import TermindApplication
import TermindRemoteInfrastructure
@MainActor
@Observable
@@ -64,11 +66,11 @@ final class MCPServerController {
self.canUsePro = canUsePro
self.requestPro = requestPro
isEnabled =
defaults.object(forKey: SettingsKey.mcpServerEnabled) as? Bool
?? SettingsKey.defaultMCPServerEnabled
defaults.object(forKey: DesktopSettingsKey.mcpServerEnabled) as? Bool
?? DesktopSettingsKey.defaultMCPServerEnabled
port =
defaults.object(forKey: SettingsKey.mcpServerPort) as? Int
?? SettingsKey.defaultMCPServerPort
defaults.object(forKey: DesktopSettingsKey.mcpServerPort) as? Int
?? DesktopSettingsKey.defaultMCPServerPort
let initialDisabledTools = Self.readDisabledTools(defaults)
disabledTools = initialDisabledTools
tools = MCPServerTools(
@@ -92,7 +94,7 @@ final class MCPServerController {
func setEnabled(_ enabled: Bool) {
if enabled, !requestPro() { return }
isEnabled = enabled
defaults.set(enabled, forKey: SettingsKey.mcpServerEnabled)
defaults.set(enabled, forKey: DesktopSettingsKey.mcpServerEnabled)
Task { [weak self] in
if enabled { await self?.start() } else { await self?.stop() }
}
@@ -112,7 +114,7 @@ final class MCPServerController {
let clamped = min(max(value, 1_024), 65_535)
guard clamped != port else { return }
port = clamped
defaults.set(clamped, forKey: SettingsKey.mcpServerPort)
defaults.set(clamped, forKey: DesktopSettingsKey.mcpServerPort)
if isEnabled { Task { [weak self] in await self?.restart() } }
}
@@ -120,7 +122,7 @@ final class MCPServerController {
guard requestPro() else { return }
guard MCPServerTools.exportedNames.contains(name) else { return }
if enabled { disabledTools.remove(name) } else { disabledTools.insert(name) }
defaults.set(Array(disabledTools).sorted(), forKey: SettingsKey.mcpServerDisabledTools)
defaults.set(Array(disabledTools).sorted(), forKey: DesktopSettingsKey.mcpServerDisabledTools)
tools.replaceDisabledTools(
disabledTools, hosts: hosts, metrics: metrics, connections: connections,
authentication: authentication, background: background
@@ -163,7 +165,7 @@ final class MCPServerController {
guard isEnabled, canUsePro() else { return }
guard let token = ensureToken(), let validPort = UInt16(exactly: port) else {
isEnabled = false
defaults.set(false, forKey: SettingsKey.mcpServerEnabled)
defaults.set(false, forKey: DesktopSettingsKey.mcpServerEnabled)
return
}
status = .starting
@@ -198,7 +200,7 @@ final class MCPServerController {
}
private static func readDisabledTools(_ defaults: UserDefaults) -> Set<String> {
Set((defaults.array(forKey: SettingsKey.mcpServerDisabledTools) as? [String]) ?? [])
Set((defaults.array(forKey: DesktopSettingsKey.mcpServerDisabledTools) as? [String]) ?? [])
.intersection(MCPServerTools.exportedNames)
}
+3 -3
View File
@@ -20,9 +20,9 @@ nonisolated enum OnboardingStep: String, CaseIterable, Identifiable, Sendable {
var completionKey: String {
switch self {
case .quickOpen: SettingsKey.onboardingQuickOpenCompleted
case .statusBarPath: SettingsKey.onboardingStatusBarPathCompleted
case .commandGenerator: SettingsKey.onboardingCommandGeneratorCompleted
case .quickOpen: DesktopSettingsKey.onboardingQuickOpenCompleted
case .statusBarPath: DesktopSettingsKey.onboardingStatusBarPathCompleted
case .commandGenerator: DesktopSettingsKey.onboardingCommandGeneratorCompleted
}
}
}
-4
View File
@@ -1,4 +0,0 @@
import TermindInfrastructure
typealias ProAccessController = TermindInfrastructure.ProAccessController
typealias ProAccessControllerError = TermindInfrastructure.ProAccessControllerError
+3 -2
View File
@@ -1,5 +1,6 @@
@preconcurrency import AppKit
import SwiftUI
import TermindApplication
/// One window with one native Navigator column and one persistent detail deck.
///
@@ -15,9 +16,9 @@ struct RootView: View {
@State private var window: WindowModel
@State private var layoutWidth = Metrics.Window.defaultWidth
@State private var automaticQuickOpenReady = false
@AppStorage(SettingsKey.statusBarExpanded) private var isStatusBarExpanded = true
@AppStorage(DesktopSettingsKey.statusBarExpanded) private var isStatusBarExpanded = true
@AppStorage(SettingsKey.remoteWindowTitleInMenuBar) private var showsRemoteWindowTitle =
SettingsKey.defaultRemoteWindowTitleInMenuBarEnabled
DesktopSettingsKey.defaultRemoteWindowTitleInMenuBarEnabled
init(window: WindowModel = WindowModel()) {
_window = State(initialValue: window)
+1
View File
@@ -1,3 +1,4 @@
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
Loaded 100 of 353 files, more files were not shown because too many files have changed in this diff. Show more