Files
Termind/TFI/App/MobileAppModel.swift
T

785 lines
34 KiB
Swift

import DeviceKit
import Foundation
import Observation
import SwiftData
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindRemoteInfrastructure
import UIKit
import struct TermindDomain.Host
private enum MobileWorkspaceStartup {
case none
case command(String)
case directory(String)
}
@MainActor
struct MobileWorkspaceItem: Identifiable {
let descriptor: MobileWorkspaceDescriptor
let workspace: MobileWorkspace?
let host: Host?
let loginAddress: String
var id: MobileWorkspaceDescriptor.ID { descriptor.id }
}
enum MobileSnippetTimestamp {
static let fallbackKey = "mobile.snippets.baselineModifiedAtFallback.v1"
static func fallback(in defaults: UserDefaults?) -> Date {
if let existing = defaults?.object(forKey: fallbackKey) as? Date { return existing }
let date = Date()
defaults?.set(date, forKey: fallbackKey)
return date
}
static func resolve(modifiedAt: Date?, fallback: Date) -> Date {
guard let modifiedAt, modifiedAt > VaultVersion.baselineDate else { return fallback }
return modifiedAt
}
}
@MainActor
@Observable
final class MobileAppModel {
let container: ModelContainer
let hosts: any HostRepository
let library: HostLibraryModel
let credentials: CredentialLibraryModel
let snippets: SnippetLibraryModel
let knownHosts: KnownHostsLibraryModel
let forwardingLibrary: ForwardingLibraryModel
let forwardings: PortForwardingController
let sftpEdits: SFTPEditService
let transfers: SFTPTransferCoordinator
private(set) var workspaces: [MobileWorkspace] = []
var error: String?
let terminalSettings: TerminalSettingsModel
let sshSettings: SSHSettingsModel
let sftpSettings: SFTPSettingsModel
let workspaceSettings: WorkspaceSettingsModel
let commandHistory: CommandHistoryModel
let sftpHistory: SFTPHistoryModel
let connectionHistory: ConnectionHistoryModel
let devices: DeviceDirectoryModel
let vaultSync: VaultSyncCoordinator?
let settingsSync: SettingsSyncCoordinator?
let metricsSync: MetricsSyncCoordinator?
let skills: SkillLibraryModel
let memory: MemoryModel
let ai: AIConfigurationModel
let commandGenerator: CommandGeneratorController
let assistant: MobileAssistantModel
let metrics: MobileMetricsModel
let pro: ProAccessController
let appReview: AppReviewController
let logging: any ApplicationLogManaging
let liveActivities: MobileLiveActivityController
@ObservationIgnored private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] = []
@ObservationIgnored private var activeConnectionHistoryIDs: [MobileWorkspace.ID: ConnectionHistoryEntry.ID] = [:]
private let defaults: UserDefaults?
private let settingsDefaults: UserDefaults
private let snippetTimestampFallback: Date
private let background: any MobileBackgroundTaskManaging
private let workspaceConnectionServices: MobileWorkspaceConnectionServices
private let workspaceStore: MobileWorkspaceStore
private(set) var savedWorkspaceDescriptors: [MobileWorkspaceDescriptor] = []
private var workspaceDisplayOrder: [MobileWorkspaceDescriptor.ID] = []
var recentWorkspaces: [MobileWorkspaceDescriptor] {
let opened = Set(workspaces.map(\.id))
return savedWorkspaceDescriptors.filter { !opened.contains($0.id) }
}
var orderedWorkspaceItems: [MobileWorkspaceItem] {
workspaceDisplayOrder.compactMap { id in
let workspace = workspaces.first { $0.id == id }
guard let descriptor = workspace?.descriptor ?? savedWorkspaceDescriptors.first(where: { $0.id == id })
else { return nil }
let host = library.host(descriptor.hostID) ?? workspace?.presentationHost
let loginAddress =
workspace?.temporaryLoginAddress
?? host.map {
library.loginAddress(
for: $0,
protocol: descriptor.kind == .telnet ? .telnet : .ssh)
}
?? descriptor.title
return MobileWorkspaceItem(
descriptor: descriptor,
workspace: workspace,
host: host,
loginAddress: loginAddress)
}
}
let outbox: OutboxRunner
private var scenePhases: [UUID: ScenePhase] = [:]
convenience init(
hosts: (any HostRepository)? = nil, inMemory: Bool = false, defaults: UserDefaults? = .standard,
background: any MobileBackgroundTaskManaging = MobileBackgroundController(),
workspaceConnectionServices: MobileWorkspaceConnectionServices = .live,
appReview: AppReviewController = AppReviewController()
) throws {
try self.init(
dependencies: MobileAppDependencies(
hosts: hosts,
inMemory: inMemory,
defaults: defaults,
background: background,
workspaceConnections: workspaceConnectionServices,
metrics: inMemory || hosts != nil ? .testing : .live,
appReview: appReview
))
}
init(dependencies: MobileAppDependencies) throws {
let hosts = dependencies.hosts
let inMemory = dependencies.inMemory
let defaults = dependencies.defaults
let background = dependencies.background
let workspaceConnectionServices = dependencies.workspaceConnections
let connectionCoordinator = dependencies.connectionCoordinator
self.background = background
self.workspaceConnectionServices = workspaceConnectionServices
workspaceStore =
dependencies.workspaceStore
?? MobileWorkspaceStore(defaults: inMemory || hosts != nil ? nil : defaults)
appReview = dependencies.appReview
logging = dependencies.logging
let memoryOnly = inMemory || hosts != nil
let storeURL = URL.applicationSupportDirectory.appending(path: "Termind.store")
if !memoryOnly {
try FileManager.default.createDirectory(
at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)
}
container =
try dependencies.container
?? TermindPersistence.makeContainer(inMemory: memoryOnly, url: memoryOnly ? nil : storeURL)
let vaultContext = ModelContext(container)
self.hosts = hosts ?? SwiftDataHostRepository(context: vaultContext)
outbox = OutboxRunner(store: SwiftDataOutboxStore(context: ModelContext(container)))
self.defaults = inMemory || hosts != nil ? nil : defaults
snippetTimestampFallback = MobileSnippetTimestamp.fallback(in: self.defaults)
liveActivities = dependencies.liveActivities ?? MobileLiveActivityController(enabled: !inMemory && hosts == nil)
pro = inMemory || hosts != nil ? .unrestrictedForTesting() : .live()
let persistence = HostPersistence(context: vaultContext)
_ = try persistence.load()
let secrets: any SecretStoring =
inMemory || hosts != nil
? InMemorySecretStore()
: KeychainSecretStore(
accessGroup: Bundle.main.object(forInfoDictionaryKey: "TermindKeychainAccessGroup") as? String
?? "F6SUNDBBN9.com.akinokaede.termind")
let settingsDefaults =
memoryOnly
? defaults ?? UserDefaults(suiteName: "termind.tests.\(UUID().uuidString)")!
: defaults ?? .standard
self.settingsDefaults = settingsDefaults
SettingsKey.migrateSyncPreferences(in: settingsDefaults)
let settingsPersistence = SettingsSyncPersistence(context: ModelContext(container), defaults: settingsDefaults)
try settingsPersistence.prepare()
let metricsPersistence = MetricsSyncPersistence(context: ModelContext(container), defaults: settingsDefaults)
try metricsPersistence.prepare()
let aiSecrets = AISecretStore(
context: settingsPersistence.context, keychain: secrets, defaults: settingsDefaults)
terminalSettings = TerminalSettingsModel(persistence: settingsPersistence)
sshSettings = SSHSettingsModel(persistence: settingsPersistence)
sftpSettings = SFTPSettingsModel(persistence: settingsPersistence)
workspaceSettings = WorkspaceSettingsModel(persistence: settingsPersistence)
connectionCoordinator.updateSSHPreferences(sshSettings.preferences)
commandHistory = CommandHistoryModel(
repository: SwiftDataCommandHistoryRepository(context: ModelContext(container)))
sftpHistory = SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: ModelContext(container)))
let connectionHistoryRepository = SwiftDataConnectionHistoryRepository(context: vaultContext)
let deviceDirectoryRepository = SwiftDataDeviceDirectoryRepository(context: vaultContext)
let currentDeviceID = try VaultSyncJournal(context: vaultContext).state().installationID
devices = DeviceDirectoryModel(
repository: deviceDirectoryRepository,
currentDeviceID: currentDeviceID,
currentDeviceName: Device.current.safeDescription)
connectionHistory = ConnectionHistoryModel(
repository: connectionHistoryRepository,
sourceDeviceID: currentDeviceID)
settingsSync =
memoryOnly
? nil
: SettingsSyncCoordinator(persistence: settingsPersistence, secrets: aiSecrets, defaults: settingsDefaults)
metricsSync =
memoryOnly
? nil
: MetricsSyncCoordinator(persistence: metricsPersistence, defaults: settingsDefaults)
vaultSync = memoryOnly ? nil : VaultSyncCoordinator(context: vaultContext, defaults: settingsDefaults)
connectionHistoryRepository.didRecordVaultChanges = { [weak vaultSync] in
vaultSync?.noteLocalChanges($0)
}
deviceDirectoryRepository.didRecordVaultChanges = { [weak vaultSync] in
vaultSync?.noteLocalChanges($0)
}
ai = AIConfigurationModel(
persistence: SyncedAIConfigurationPersistence(settingsPersistence), secrets: secrets,
permissionDefaults: settingsDefaults, aiSecrets: aiSecrets,
webSearchPersistence: settingsPersistence)
commandGenerator = CommandGeneratorController(ai: ai)
skills = SkillLibraryModel(persistence: SyncedSkillLibraryPersistence(settingsPersistence))
memory = MemoryModel(
storage: GRDBMemoryStore(directory: GRDBMemoryStore.defaultDirectory), defaults: settingsDefaults)
library = HostLibraryModel(
persistence: persistence, secrets: secrets,
localDefaults: memoryOnly ? nil : settingsDefaults
)
if !memoryOnly { try? library.migrateCustomPasswordsToICloud() }
vaultSync?.beforeSend = { [weak library] in try library?.migrateCustomPasswordsToICloud() }
metrics = MobileMetricsModel(
preferences: metricsPersistence, hosts: library, services: dependencies.metrics)
let speechAudio = IOSSpeechAudioInput()
let speech = SpeechTranscriptionCoordinator(
service: AppleSpeechTranscriptionService(audioInput: speechAudio)
)
speechAudio.onInterruption = { [weak speech] in speech?.cancelAll() }
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpSettings.preferences)
assistant = MobileAssistantModel(
configuration: ai,
repository: MemoryIndexedAgentRepository(
base: SwiftDataAgentRunRepository(context: ModelContext(container)), memory: memory),
memory: memory,
hostServices: AgentHostServices.live(
store: library,
coordinator: connectionCoordinator,
sftpConcurrencyScheduler: sftpConcurrencyScheduler),
metrics: metrics.core, skills: skills,
authorize: { [pro] in pro.require(.assistant) },
speech: speech,
attachmentLoader: .live,
logger: logging.makeLogger(label: "termind.agent")
)
credentials = CredentialLibraryModel(library: library)
snippets = SnippetLibraryModel(library: library)
knownHosts = KnownHostsLibraryModel(library: library)
forwardingLibrary = ForwardingLibraryModel(library: library)
forwardings = dependencies.makeForwardings(library)
transfers = SFTPTransferCoordinator(
store: library, clientFactory: { connectionCoordinator.makeSFTPClient() },
globalLimit: sftpSettings.preferences.globalLimit,
perHostLimit: sftpSettings.preferences.perHostLimit,
strategy: sftpSettings.preferences.strategy,
concurrencyScheduler: sftpConcurrencyScheduler)
sftpEdits = SFTPEditService(transfers: transfers, host: { [weak library] in library?.host($0) })
library.willDeleteHost = { [weak sftpEdits, weak forwardings] hostID, _ in
sftpEdits?.invalidate(hostID: hostID)
forwardings?.hostConfigurationChanged(hostID)
}
library.willDeletePortForwardings = { [weak forwardings] ids in forwardings?.stop(ids) }
library.didChangeHostConnection = { [weak forwardings, weak self] hostID in
forwardings?.hostConfigurationChanged(hostID)
self?.disconnectWorkspaces(for: hostID, protocol: .ssh)
}
library.didChangeTelnetConnection = { [weak self] hostID in
self?.disconnectWorkspaces(for: hostID, protocol: .telnet)
}
let visibleHostIDs = Set(try self.hosts.snapshot().value.hosts.map(\.id))
for host in library.hosts where !visibleHostIDs.contains(host.id) { library.projectHostDeletion(host.id) }
outbox.registerHostDeletionHandlers(
repository: self.hosts,
closeWorkspaces: { [weak self] payload in
guard let self else { return }
closeWorkspaces(for: payload.hostID)
commandHistory.clear(for: payload.hostID)
sftpHistory.clear(for: payload.hostID)
connectionHistory.clear(for: payload.hostID)
library.projectHostDeletion(payload.hostID)
},
cancelTransfers: { [weak self] payload in
guard let self else { return }
sftpEdits.invalidate(hostID: payload.hostID)
transfers.cancelAll(for: payload.hostID)
},
stopForwardings: { [weak forwardings] payload in
forwardings?.hostConfigurationChanged(payload.hostID)
forwardings?.remove(payload.forwardingRuleIDs)
}
)
library.didRecordVaultChanges = { [weak vaultSync] in vaultSync?.noteLocalChanges($0) }
if let repository = self.hosts as? SwiftDataHostRepository {
repository.didRecordVaultChanges = { [weak vaultSync] in vaultSync?.noteLocalChanges($0) }
}
vaultSync?.didApplyRemote = { [weak self] in self?.applyRemoteVault($0, keys: $1) }
sftpSettings.didChange = { [weak transfers] preferences in
transfers?.updatePreferences(preferences)
}
sshSettings.didChange = { preferences in
connectionCoordinator.updateSSHPreferences(preferences)
}
settingsSync?.didApplyRemote = { [weak self] snapshot, _ in
self?.ai.applyRemoteSnapshot(snapshot.configuration, webSearch: snapshot.webSearch)
self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal)
self?.sshSettings.applyRemoteSnapshot(snapshot.ssh)
self?.sftpSettings.applyRemoteSnapshot(snapshot.sftp)
self?.workspaceSettings.applyRemoteSnapshot(snapshot.workspace)
self?.skills.applyRemoteSnapshot(snapshot.skills)
}
metricsSync?.didApplyRemote = { [weak self] snapshot, _ in
self?.metrics.core.applyRemoteSnapshot(snapshot)
}
loadRecentWorkspaces()
}
var newVaultKeyStorage: KeyStorage {
NewVaultCredentialStorage.keyStorage(
defaults: settingsDefaults,
canUseVaultSync: pro.canUse(.vaultSync))
}
var newVaultPasswordStorage: PasswordStorage {
NewVaultCredentialStorage.passwordStorage(
defaults: settingsDefaults,
canUseVaultSync: pro.canUse(.vaultSync))
}
func open(
hostID: UUID, kind: ConnectionHistoryEntry.Kind,
initialPath: String? = nil
) -> UUID? {
guard let host = library.host(hostID) else { return nil }
let startup: MobileWorkspaceStartup
if let initialPath,
!initialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
startup = .directory(RemotePath.normalized(initialPath))
} else {
startup = .none
}
let workspace = makeWorkspace(host: host, kind: kind, startup: startup)
addWorkspaces([workspace])
return workspace.id
}
func openExternalConnection(_ request: RemoteConnectionURL) -> MobileWorkspace.ID {
let host: Host
let kind: ConnectionHistoryEntry.Kind
switch request.kind {
case .ssh:
host = Host(name: request.host, host: request.host, port: request.port)
kind = .ssh
case .sftp:
host = Host(name: request.host, host: request.host, port: request.port)
kind = .sftp
case .telnet:
host = Host(
name: request.host, host: request.host,
sshEnabled: false, telnetEnabled: true, telnetPort: request.port)
kind = .telnet
}
let route = SSHConnectionRoute(
hosts: [host], usernames: request.username.map { [host.id: $0] } ?? [:])
let workspace = MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(
hostID: host.id, title: host.name, kind: kind),
hosts: library, transfers: transfers, sftpEdits: sftpEdits,
terminalSettings: terminalSettings, commandHistory: commandHistory,
sftpHistory: sftpHistory, connectionServices: workspaceConnectionServices,
transientHost: host,
connectionRoute: request.kind == .telnet ? nil : route,
usernameHint: request.username,
connectionOrigin: .externalLink(
hostKeyConstraints: request.hostKeyFingerprint.map { [host.id: $0] } ?? [:]),
advisoryTelnetPassword: request.advisoryPassword.map(SSHSecret.init),
externalSFTPResourcePath: request.kind == .sftp ? request.path : nil,
externalSFTPMustBeDirectory: request.sftpType == .directory)
addWorkspaces([workspace], persists: false)
return workspace.id
}
/// Opens one independent SSH workspace per target. The command is intentionally
/// carried only by the live workspace and never enters its restoration descriptor.
func execute(_ snippet: Snippet, on hostIDs: [Host.ID]) -> [MobileWorkspace.ID] {
let wanted = Set(hostIDs)
let targets = library.hosts(matching: "", for: .snippetExecution)
.filter { wanted.contains($0.id) }
let opened = targets.map { makeWorkspace(host: $0, kind: .ssh, startup: .command(snippet.body)) }
addWorkspaces(opened)
return opened.map(\.id)
}
@discardableResult
func renameWorkspace(_ id: MobileWorkspace.ID, to title: String) -> Bool {
let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !title.isEmpty else { return false }
let workspace = workspaces.first { $0.id == id }
let savedIndex = savedWorkspaceDescriptors.firstIndex { $0.id == id }
guard workspace != nil || savedIndex != nil else { return false }
if let workspace {
guard workspace.rename(to: title) else { return false }
} else {
guard let savedIndex, savedWorkspaceDescriptors[savedIndex].title != title else { return false }
savedWorkspaceDescriptors[savedIndex].title = title
}
if let savedIndex, let workspace {
savedWorkspaceDescriptors[savedIndex] = workspace.descriptor
}
saveWorkspaces()
return true
}
func close(_ id: UUID) {
let wasSaved = savedWorkspaceDescriptors.contains(where: { $0.id == id })
guard wasSaved || workspaces.contains(where: { $0.id == id }) else { return }
endConnectionHistory(for: id)
if let workspace = workspaces.first(where: { $0.id == id }) {
workspace.didChangeState = nil
workspace.didRequestClose = nil
workspace.disconnect()
}
workspaces.removeAll { $0.id == id }
savedWorkspaceDescriptors.removeAll { $0.id == id }
workspaceDisplayOrder.removeAll { $0 == id }
if wasSaved { saveWorkspaces() }
workspaceActivitySessions.removeAll { $0.id == id }
liveActivities.update(workspaceActivitySessions)
}
var closableWorkspaceCount: Int {
Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)).count
}
func closeOtherWorkspaces(than id: MobileWorkspace.ID) {
let knownIDs = Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id))
guard knownIDs.contains(id), knownIDs.count > 1 else { return }
let closing = workspaces.filter { $0.id != id }
workspaces.removeAll { $0.id != id }
savedWorkspaceDescriptors.removeAll { $0.id != id }
workspaceDisplayOrder.removeAll { $0 != id }
saveWorkspaces()
workspaceActivitySessions.removeAll { $0.id != id }
for workspace in closing {
endConnectionHistory(for: workspace.id)
workspace.didChangeState = nil
workspace.didRequestClose = nil
workspace.disconnect()
}
liveActivities.update(workspaceActivitySessions)
}
func closeAll() {
guard !savedWorkspaceDescriptors.isEmpty || !workspaces.isEmpty else { return }
let closing = workspaces
workspaces.removeAll()
savedWorkspaceDescriptors.removeAll()
workspaceDisplayOrder.removeAll()
saveWorkspaces()
workspaceActivitySessions.removeAll()
for workspace in closing {
endConnectionHistory(for: workspace.id)
workspace.didChangeState = nil
workspace.didRequestClose = nil
workspace.disconnect()
}
liveActivities.update(workspaceActivitySessions)
}
private func disconnectWorkspaces(
for hostID: Host.ID, protocol connectionProtocol: HostTerminalProtocol
) {
for workspace in workspaces where workspace.descriptor.hostID == hostID {
let matches =
switch (connectionProtocol, workspace.descriptor.kind) {
case (.ssh, .ssh), (.ssh, .sftp), (.telnet, .telnet): true
default: false
}
if matches { workspace.disconnect() }
}
}
private func closeWorkspaces(for hostID: UUID) {
for descriptor in savedWorkspaceDescriptors where descriptor.hostID == hostID { close(descriptor.id) }
}
@discardableResult
func deleteHost(_ id: UUID) -> Bool {
do {
assistant.disconnectHost(id)
closeWorkspaces(for: id)
sftpEdits.invalidate(hostID: id)
transfers.cancelAll(for: id)
forwardings.hostConfigurationChanged(id)
forwardings.remove(library.portForwardings(for: id).map(\.id))
metrics.remove(id)
try hosts.requestHostDeletion(id)
library.projectHostDeletion(id)
outbox.start()
return true
} catch {
self.error = error.localizedDescription
return false
}
}
func deleteHostGroup(_ id: HostGroup.ID, keepHosts: Bool) {
if !keepHosts {
let hosts = library.hosts(inTreeOf: id)
guard hosts.allSatisfy({ deleteHost($0.id) }) else { return }
}
library.deleteGroupProjection(id)
if let storageError = library.storageError { error = storageError }
}
func duplicateHost(_ host: Host) {
var copy = host
if host.identityID.flatMap(library.identity)?.isHostCustom == true { copy.identityID = nil }
if host.telnetIdentityID.flatMap(library.identity)?.isHostCustom == true { copy.telnetIdentityID = nil }
if host.proxy?.identityID.flatMap(library.identity)?.isHostCustom == true {
copy.proxy?.identityID = nil
copy.proxy?.username = ""
}
if host.telnetProxy?.identityID.flatMap(library.identity)?.isHostCustom == true {
copy.telnetProxy?.identityID = nil
copy.telnetProxy?.username = ""
}
copy.id = UUID()
copy.name = String(localized: "\(host.name) copy")
library.upsert(copy)
}
func lastConnectedAt(for hostID: Host.ID) -> Date? {
connectionHistory.mostRecentDate(for: hostID)
}
func snippetModifiedAt(for snippetID: Snippet.ID) -> Date {
MobileSnippetTimestamp.resolve(
modifiedAt: snippets.modifiedAt(for: snippetID), fallback: snippetTimestampFallback)
}
func sceneChanged(_ id: UUID, phase: ScenePhase) {
let wasBackground = !scenePhases.isEmpty && scenePhases.values.allSatisfy { $0 == .background }
scenePhases[id] = phase
let isBackground = scenePhases.values.allSatisfy { $0 == .background }
if isBackground && !wasBackground {
sftpEdits.setMonitoring(false)
memory.suspend()
vaultSync?.pause()
metricsSync?.pause()
settingsSync?.pause()
metrics.pause()
outbox.stop()
assistant.suspendRecording()
background.begin { [weak self] in
guard let self else { return }
for workspace in workspaces { workspace.suspendForBackgroundExpiration() }
forwardings.stopAll()
transfers.pauseAll()
assistant.expireBackground()
}
liveActivities.update(workspaceActivitySessions)
} else if phase == .active {
devices.updateCurrentDeviceName(Device.current.safeDescription)
sftpEdits.setMonitoring(true)
Task { await sftpEdits.recheck() }
Task { [weak self] in await self?.pro.refresh() }
background.finish()
for workspace in workspaces { workspace.resumeAfterBackground() }
reconcileSync()
outbox.start()
liveActivities.update(workspaceActivitySessions)
metrics.start()
}
}
func reconcileSync() {
let foreground = scenePhases.values.contains(.active)
if foreground && pro.canUse(.vaultSync) {
vaultSync?.start()
metricsSync?.start()
settingsSync?.start()
} else {
vaultSync?.pause()
metricsSync?.pause()
settingsSync?.pause()
}
}
func setSyncEnabled(_ value: Bool, key: String) {
guard !value || pro.require(.vaultSync) else { return }
defaults?.set(value, forKey: key)
if value && (key == SettingsKey.iCloudSyncEnabled || key == SettingsKey.vaultSyncEnabled) {
do { try library.migrateCustomPasswordsToICloud() } catch { self.error = error.localizedDescription }
}
reconcileSync()
}
func applyRemoteVault(_ snapshot: VaultSnapshot, keys: Set<VaultRecordKey>) {
let plan = VaultRemoteChangePlan.make(
currentHosts: library.hosts,
snapshot: snapshot,
changedKeys: keys
)
for change in plan.hosts {
if change.routeChanged {
sftpEdits.invalidate(hostID: change.id)
if !change.isRemoved {
for workspace in workspaces.filter({ $0.descriptor.hostID == change.id }) { workspace.disconnect() }
}
transfers.cancelAll(for: change.id)
forwardings.stop(library.portForwardings(for: change.id).map(\.id))
}
if change.isRemoved {
closeWorkspaces(for: change.id)
commandHistory.clear(for: change.id)
sftpHistory.clear(for: change.id)
connectionHistory.clear(for: change.id)
metrics.remove(change.id)
}
}
forwardings.stop(plan.forwardingRuleIDs)
library.replacePersistedSnapshot(snapshot)
if keys.contains(where: { $0.kind == .connectionHistory }) {
connectionHistory.reload()
}
if keys.contains(where: { $0.kind == .device }) { devices.reload() }
forwardings.reconcileConfiguration()
liveActivities.update(workspaceActivitySessions)
}
func removeScene(_ id: UUID) {
scenePhases[id] = nil
reconcileSync()
}
func focusWorkspaceActivity(_ id: UUID) {
liveActivities.focus(id, in: workspaceActivitySessions)
}
/// Publish before connecting so every scene shares one instance and one initial attempt.
@discardableResult
func openWorkspace(_ id: UUID) -> MobileWorkspace? {
if let workspace = workspaces.first(where: { $0.id == id }) { return workspace }
guard let descriptor = savedWorkspaceDescriptors.first(where: { $0.id == id }) else { return nil }
guard library.host(descriptor.hostID) != nil else {
close(id)
return nil
}
let workspace = MobileWorkspace(
descriptor: descriptor, hosts: library, transfers: transfers, sftpEdits: sftpEdits,
terminalSettings: terminalSettings, commandHistory: commandHistory, sftpHistory: sftpHistory,
connectionServices: workspaceConnectionServices)
addWorkspaces([workspace])
return workspace
}
private func loadRecentWorkspaces() {
do {
let descriptors = try workspaceStore.load()
savedWorkspaceDescriptors = descriptors.filter { library.host($0.hostID) != nil }
workspaceDisplayOrder = savedWorkspaceDescriptors.map(\.id)
if savedWorkspaceDescriptors.count != descriptors.count { saveWorkspaces() }
} catch {
self.error = String(localized: "Saved workspaces could not be restored. The saved data has been kept.")
}
// No sessions exist at cold launch. Old URLs still resolve through the saved descriptors.
liveActivities.update([])
}
private func saveWorkspaces() {
do {
try workspaceStore.save(savedWorkspaceDescriptors)
} catch {
self.error = String(localized: "Workspace changes could not be saved for the next launch.")
}
}
private func makeWorkspace(
host: Host, kind: ConnectionHistoryEntry.Kind, startup: MobileWorkspaceStartup = .none
) -> MobileWorkspace {
let initialCommand: String?
let recordsInitialCommandInHistory: Bool
let initialSFTPPath: String?
switch (kind, startup) {
case (.ssh, .command(let command)):
initialCommand = command
recordsInitialCommandInHistory = true
initialSFTPPath = nil
case (.ssh, .directory(let path)):
initialCommand = RemoteShellCommand.changeDirectory(to: path)
recordsInitialCommandInHistory = false
initialSFTPPath = nil
case (.sftp, .directory(let path)):
initialCommand = nil
recordsInitialCommandInHistory = true
initialSFTPPath = path
case (.ssh, .none), (.sftp, .none), (.sftp, .command),
(.telnet, .none), (.telnet, .command), (.telnet, .directory):
initialCommand = nil
recordsInitialCommandInHistory = true
initialSFTPPath = nil
}
let title = WorkspaceNameAllocator.nextName(
prefix: host.name,
occupiedNames: savedWorkspaceDescriptors.map(\.title),
automaticallyRenames: workspaceSettings.preferences.automaticallyRenamesWorkspaces)
return MobileWorkspace(
descriptor: MobileWorkspaceDescriptor(
hostID: host.id, title: title, kind: kind),
hosts: library, transfers: transfers, sftpEdits: sftpEdits, terminalSettings: terminalSettings,
commandHistory: commandHistory, sftpHistory: sftpHistory,
connectionServices: workspaceConnectionServices,
initialCommand: initialCommand,
recordsInitialCommandInHistory: recordsInitialCommandInHistory,
initialSFTPPath: initialSFTPPath)
}
private func addWorkspaces(_ added: [MobileWorkspace], persists: Bool = true) {
guard !added.isEmpty else { return }
var orderedIDs = Set(workspaceDisplayOrder)
for id in added.map(\.id) where orderedIDs.insert(id).inserted {
workspaceDisplayOrder.append(id)
}
for workspace in added {
observeActivityState(of: workspace)
startConnectionHistory(for: workspace)
}
workspaces.append(contentsOf: added)
if persists {
let savedIDs = Set(savedWorkspaceDescriptors.map(\.id))
savedWorkspaceDescriptors.append(contentsOf: added.map(\.descriptor).filter { !savedIDs.contains($0.id) })
saveWorkspaces()
}
workspaceActivitySessions.append(contentsOf: added.map(\.activitySession))
liveActivities.start(workspaceActivitySessions)
for workspace in added { workspace.connect() }
}
private func startConnectionHistory(for workspace: MobileWorkspace) {
guard activeConnectionHistoryIDs[workspace.id] == nil,
library.host(workspace.descriptor.hostID) != nil,
let entry = connectionHistory.record(
kind: workspace.descriptor.kind, for: workspace.descriptor.hostID)
else { return }
activeConnectionHistoryIDs[workspace.id] = entry.id
}
private func endConnectionHistory(for workspaceID: MobileWorkspace.ID) {
guard let entryID = activeConnectionHistoryIDs.removeValue(forKey: workspaceID) else { return }
connectionHistory.end(entryID)
}
private func observeActivityState(of workspace: MobileWorkspace) {
workspace.didRequestClose = { [weak self] id in
self?.close(id)
}
workspace.didChangeState = { [weak self] session in
guard let self,
let index = workspaceActivitySessions.firstIndex(where: { $0.id == session.id })
else { return }
workspaceActivitySessions[index] = session
liveActivities.update(workspaceActivitySessions)
}
}
}