Refactor TFI for project structure
This commit is contained in:
1 parent
1efcbe9063
commit
5eefc33cb0
353 files changed
+1678
-1491
No files matched your search
+2
-2
@@ -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)
|
||||
+1
-1
@@ -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")
|
||||
+13
-13
@@ -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")
|
||||
|
||||
+64
-13
@@ -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
-1
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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.
@@ -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:
|
||||
|
||||
File renamed without changes.
@@ -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
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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 }
|
||||
}
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
+6
-6
@@ -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,
|
||||
File renamed without changes.
+2
-1
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+1
-1
@@ -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 {
|
||||
File renamed without changes.
File renamed without changes.
+7
-7
@@ -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)
|
||||
}
|
||||
|
||||
File renamed without changes.
File renamed without changes.
+6
-6
@@ -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() },
|
||||
File renamed without changes.
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
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?
|
||||
File renamed without changes.
+3
-3
@@ -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) })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
File renamed without changes.
File renamed without changes.
+2
-2
@@ -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 {
|
||||
File renamed without changes.
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindRemoteInfrastructure
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import TermindInfrastructure
|
||||
import TermindPersistence
|
||||
import TermindRemoteInfrastructure
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import TermindInfrastructure
|
||||
|
||||
typealias ProAccessController = TermindInfrastructure.ProAccessController
|
||||
typealias ProAccessControllerError = TermindInfrastructure.ProAccessControllerError
|
||||
@@ -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,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
Reference in new issue
Block a user