Files
Termind/TFI/App/MobileAppModel.swift

991 lines
47 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import DeviceKit
import Foundation
import Observation
import SwiftData
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import UIKit
import struct TermindDomain.Host
@MainActor
@Observable
final class MobileAppModel {
let terminalRecoveryStore: TerminalRecoveryStore
private let workspaceStore: MobileWorkspaceStore
let container: ModelContainer
let hosts: any HostRepository
let library: HostLibraryModel
let tailscale: TailscaleModel
let credentials: CredentialLibraryModel
let hostMACResolver: any HostMACAddressResolving
let keyExportServices: KeyExportServices
let snippets: SnippetLibraryModel
let knownHosts: KnownHostsLibraryModel
let forwardingLibrary: ForwardingLibraryModel
let forwardings: PortForwardingController
let sftpEdits: SFTPEditService
let transfers: SFTPTransferCoordinator
let notifications = SystemNotifications()
let notificationSettings: NotificationSettingsModel
@ObservationIgnored private var sftpNotifications: SFTPNotificationCoordinator?
@ObservationIgnored private lazy var notificationRouter = MobileNotificationRouter(app: self)
@ObservationIgnored private var notificationBadge: NotificationBadgeCoordinator?
@ObservationIgnored private lazy var terminalNotifications = TerminalNotificationCoordinator(
service: notifications,
preferences: { [weak self] in self?.notificationSettings.preferences ?? .init() },
isFocused: { [weak self] in self?.isTerminalNotificationFocused($0) ?? false },
title: { [weak self] id in self?.workspaces.first { $0.id == id }?.descriptor.title },
body: CommandNotificationContent.body,
ringBell: TerminalNotificationPlatform.ringBell)
@ObservationIgnored private lazy var assistantNotifications = AssistantNotificationCoordinator(
service: notifications,
preferences: { [weak self] in self?.notificationSettings.preferences ?? .init() },
isFocused: { [weak self] in self?.isAssistantNotificationFocused($0) ?? false },
content: AssistantNotificationContent.make)
private var workspaceSessions: [MobileWorkspaceSessionModel] = []
private var paneScenes: [UUID: WeakPaneScene] = [:]
var workspaces: [MobileWorkspace] { workspaceSessions.flatMap(\.workspaces) }
private final class WeakPaneScene {
weak var value: MobileSceneModel?
init(_ value: MobileSceneModel) { self.value = value }
}
func registerPaneScene(_ scene: MobileSceneModel) {
paneScenes = paneScenes.filter { $0.value.value != nil }
paneScenes[scene.id] = WeakPaneScene(scene)
}
func paneAccess(for source: MobileSceneModel) -> AgentPaneAccess {
AgentPaneAccess(
list: { [weak self, weak source] in
self?.openTerminalPanes(source: source)
.filter { $0.workspace.connected && $0.workspace.readViewportText() != nil }
.map(\.metadata) ?? []
},
read: { [weak self, weak source] id in
guard let match = self?.openTerminalPanes(source: source).first(where: { $0.metadata.id == id })
else { return .closed }
guard match.workspace.connected else { return .notConnected }
guard let text = match.workspace.readViewportText() else { return .noSurface }
return .text(match.metadata, text)
})
}
private func openTerminalPanes(source: MobileSceneModel?)
-> [(metadata: AgentPaneAccess.Pane, workspace: MobileWorkspace)]
{
workspaceSessions.flatMap { session in
session.workspaces.compactMap { workspace in
guard workspace.descriptor.kind.isSSHTerminal || workspace.descriptor.kind == .telnet else {
return nil
}
let owner = paneScenes.values.compactMap(\.value).first {
$0.workspaceSession === session
}
let isActive = owner?.activeWorkspace?.id == workspace.id
let isCurrent = source?.workspaceSession === session && source?.activeWorkspace?.id == workspace.id
let metadata = AgentPaneAccess.Pane(
id: workspace.id,
title: workspace.descriptor.title,
hostID: workspace.descriptor.hostID,
workspace: workspace.descriptor.title,
window: session.id,
isActive: isActive,
isCurrent: isCurrent
)
return (metadata, workspace)
}
}
}
var error: String?
let terminalSettings: TerminalSettingsModel
let terminalExperienceSettings: TerminalExperienceSettingsModel
let terminalEffects: MobileTerminalEffects
let screenAwake: MobileScreenAwakeController
let agentForwardingRouter = MobileAgentForwardingRouter()
let sshSettings: SSHSettingsModel
let sftpSettings: SFTPSettingsModel
let workspaceSettings: WorkspaceSettingsModel
let connectionHistory: ConnectionHistoryModel
let devices: DeviceDirectoryModel
let vaultSync: VaultSyncCoordinator?
let connectionHistorySync: ConnectionHistorySyncCoordinator?
let deviceSync: DeviceSyncCoordinator?
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 diagnosis: DiagnosisController
let logging: any ApplicationLogManaging
let liveActivities: MobileLiveActivityController
private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] {
workspaceSessions.filter { !$0.isDiscarded }.flatMap { session in
session.orderedWorkspaceItems.map { item in
if let workspace = item.workspace { return workspace.activitySession }
let descriptor = item.descriptor
let artwork = HostIconCatalog.resolvedArtwork(for: item.host)
return WorkspaceActivityAttributes.Session(
id: descriptor.id, title: descriptor.title, kind: descriptor.kind.rawValue,
connected: false, iconAsset: artwork.assetName, iconSystemName: artwork.systemImageName,
recovery: .init(
sourceWorkspaceID: descriptor.id, hostID: descriptor.hostID, kind: descriptor.kind.rawValue,
remoteSession: descriptor.remoteSession.flatMap { try? JSONEncoder().encode($0) }))
}
}
}
private var hasLoadedWorkspaceSnapshot = false
@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
let workspaceConnectionServices: MobileWorkspaceConnectionServices
let persistentSessionCloser: RemoteSessionCloseCoordinator
let recentlyClosedStore = RecentlyClosedStore<RecentlyClosedRecord<MobileWorkspaceDescriptor>>(
defaults: nil, key: "mobile.recentlyClosed.v2")
private var orphanedSessionIDs: Set<String> = []
private var unboundRestoredSessionIDs: Set<String> = []
private var retiredRestoredSessionIDs: Set<String> = []
// Disconnect and discard are separate UIKit callbacks. A later explicit discard must still close
// precisely the workspaces recovered from that window, even if another window already received them.
private var recoveredWorkspaceIDs: [String: Set<UUID>] = [:]
private var discardedSessionIDs: Set<String> = []
var closedWorkspaceStates: [UUID: [MobileWorkspace.ReopenState]] = [:]
var pendingClosedWindows: Set<UUID> = []
var createWorkspaceWindow: @MainActor (UUID, @escaping @MainActor () -> Void) -> Void
var activateWorkspaceSession: @MainActor (String, UUID, @escaping @MainActor () -> Void) -> Void
var closeWorkspaceSession: @MainActor (String, @escaping @MainActor (any Error) -> Void) -> Void
var openWorkspaceSessionIDs: @MainActor () -> Set<String>
let outbox: OutboxRunner
private var sceneSessionIDs: [UUID: String] = [:]
private var scenePhases: [UUID: ScenePhase] = [:]
convenience init(
hosts: (any HostRepository)? = nil, inMemory: Bool = false, defaults: UserDefaults? = nil,
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,
terminalEffects: inMemory || hosts != nil ? MobileTerminalEffects() : .live
))
}
init(dependencies: MobileAppDependencies) throws {
let hosts = dependencies.hosts
let inMemory = dependencies.inMemory
let defaults = dependencies.defaults
terminalEffects = dependencies.terminalEffects
screenAwake = MobileScreenAwakeController(
setIdleTimerDisabled: dependencies.terminalEffects.setIdleTimerDisabled)
let background = dependencies.background
let workspaceConnectionServices = dependencies.workspaceConnections
let connectionCoordinator = dependencies.connectionCoordinator
hostMACResolver = SSHHostMACAddressResolver(coordinator: connectionCoordinator)
keyExportServices = connectionCoordinator.keyExportServices()
createWorkspaceWindow = dependencies.createWorkspaceWindow
activateWorkspaceSession = dependencies.activateWorkspaceSession
closeWorkspaceSession = dependencies.closeWorkspaceSession
openWorkspaceSessionIDs = dependencies.openWorkspaceSessionIDs
self.background = background
self.workspaceConnectionServices = workspaceConnectionServices
MobileWorkspaceStore.retire(in: defaults ?? .standard)
appReview = dependencies.appReview
diagnosis = dependencies.diagnosis
logging = dependencies.logging
let memoryOnly = inMemory || hosts != nil
terminalRecoveryStore = dependencies.terminalRecoveryStore
workspaceStore = MobileWorkspaceStore(defaults: memoryOnly ? defaults : defaults ?? .standard)
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 = SnippetTimestamp.fallback(in: self.defaults)
liveActivities = dependencies.liveActivities ?? MobileLiveActivityController(enabled: !inMemory && hosts == nil)
pro = dependencies.proAccess ?? (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
terminalExperienceSettings = TerminalExperienceSettingsModel(defaults: 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)
notificationSettings = NotificationSettingsModel(persistence: settingsPersistence)
sshSettings = SSHSettingsModel(persistence: settingsPersistence)
sftpSettings = SFTPSettingsModel(persistence: settingsPersistence)
workspaceSettings = WorkspaceSettingsModel(persistence: settingsPersistence)
connectionCoordinator.updateSSHPreferences(sshSettings.preferences)
let historyPersistence = HistorySyncPersistence(context: vaultContext)
try historyPersistence.prepare()
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)
connectionHistorySync =
memoryOnly
? nil : ConnectionHistorySyncCoordinator(persistence: historyPersistence, defaults: settingsDefaults)
deviceSync =
memoryOnly ? nil : DeviceSyncCoordinator(persistence: historyPersistence, defaults: settingsDefaults)
vaultSync?.configureLogging(dependencies.logging)
connectionHistorySync?.configureLogging(dependencies.logging)
deviceSync?.configureLogging(dependencies.logging)
settingsSync?.configureLogging(dependencies.logging)
metricsSync?.configureLogging(dependencies.logging)
connectionHistoryRepository.didRecordHistoryChanges = { [weak connectionHistorySync] in
connectionHistorySync?.noteLocalChanges($0)
}
deviceDirectoryRepository.didRecordHistoryChanges = { [weak deviceSync] in
deviceSync?.noteLocalChanges($0)
}
ai = AIConfigurationModel(
persistence: SyncedAIConfigurationPersistence(settingsPersistence), secrets: secrets,
permissionDefaults: settingsDefaults, aiSecrets: aiSecrets,
webSearchPersistence: settingsPersistence,
mcpSecrets: memoryOnly
? InMemoryMCPAuthorizationStore()
: MCPKeychainCredentialStore(
accessGroup: Bundle.main.object(forInfoDictionaryKey: "TermindKeychainAccessGroup") as? String
?? "F6SUNDBBN9.com.akinokaede.termind"))
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
)
let sessionTerminator = dependencies.persistentSessionTerminator
persistentSessionCloser = RemoteSessionCloseCoordinator { [library] request in
try await sessionTerminator.terminate(request, hosts: library)
}
let tailscaleDevices = devices
let tailscaleAccess = pro
library.hostAccess.entitlement = pro.status
library.requestHostUpgrade = { [pro] in pro.presentPaywall() }
library.resolveInitialHostEntitlement = { [pro, weak library] in
await pro.refresh()
library?.hostAccess.entitlement = pro.status
}
library.wakeOnLAN = WakeOnLANController(
sender: LANWakeOnLANSender())
let wakeLogger = dependencies.logging.makeLogger(label: "termind.wake-on-lan")
library.wakeOnLAN?.reportAutomaticFailure = { _ in wakeLogger.warning("wake.automatic.failed") }
tailscale = TailscaleModel(
library: library, store: SwiftDataTailscaleLocalStore(context: ModelContext(container)),
factory: { profileID, hostname, controlURL, authKey in
guard !memoryOnly else { throw TailscaleConfigurationError.unavailable }
return try await LibTailscaleNodeSession.factory(profileID, hostname, controlURL, authKey)
},
profileStorage: memoryOnly ? .disabled : LibTailscaleNodeSession.profileStorage,
hostname: { tailscaleDevices.currentDeviceName }, hasAccess: { tailscaleAccess.canUse(.tailscale) }
)
let forwardingRouter = agentForwardingRouter
connectionCoordinator.updateAgentForwarding(
VaultSSHAgent.factory(
library: library, interactionContext: { forwardingRouter.context(for: $0) }))
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 sftpPreferences = MultiConnectionPolicy(
allowsMultipleConnections: pro.canUse(.sftpMultiConnection)
).sftpPreferences(sftpSettings.preferences)
let sftpConcurrencyScheduler = SFTPConcurrencyScheduler(preferences: sftpPreferences)
assistant = MobileAssistantModel(
configuration: ai,
repository: MemoryIndexedAgentRepository(
base: SwiftDataAgentRunRepository(context: ModelContext(container)), memory: memory),
memory: memory,
modelSelectionStore: SwiftDataAssistantModelSelectionStore(context: ModelContext(container)),
chatCollaborationStore: SwiftDataAssistantChatCollaborationStore(
context: ModelContext(container)),
hostServices: AgentHostServices.live(
store: library,
coordinator: connectionCoordinator,
sftpConcurrencyScheduler: sftpConcurrencyScheduler),
metrics: metrics.core, skills: skills,
authorize: { true },
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: sftpPreferences.globalLimit,
perHostLimit: sftpPreferences.perHostLimit,
strategy: sftpPreferences.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)
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.didRecordHistoryChanges = { [weak connectionHistorySync] in
connectionHistorySync?.noteLocalChanges($0)
}
repository.didRecordVaultChanges = { [weak vaultSync] in vaultSync?.noteLocalChanges($0) }
}
connectionHistorySync?.didApplyRemote = { [weak self] _ in self?.connectionHistory.reload() }
deviceSync?.didApplyRemote = { [weak self] _ in self?.devices.reload() }
vaultSync?.didApplyRemote = { [weak self] in self?.applyRemoteVault($0, keys: $1) }
sftpSettings.didChange = { [weak self] _ in
self?.reconcileConnectionPreferences()
}
library.didDeleteTailscaleGroups = { [weak self] groups, hostIDs in
guard let self else { return }
self.metrics.core.removeTailscaleGroups(groups)
for id in hostIDs {
self.closeWorkspaces(for: id)
self.sftpEdits.invalidate(hostID: id)
self.transfers.cancelAll(for: id)
self.metrics.core.remove(id)
}
}
tailscale.start()
pro.accessDidChange = { [weak self] in
if let self { self.library.hostAccess.entitlement = self.pro.status }
self?.reconcileSync()
self?.tailscale.reconcile()
self?.metrics.core.reconcile()
self?.reconcileConnectionPreferences()
}
sshSettings.didChange = { preferences in
connectionCoordinator.updateSSHPreferences(preferences)
}
assistant.observeNotifications = { [weak self] in self?.assistantNotifications.observe($0) }
sftpNotifications = SFTPNotificationCoordinator(
service: notifications, initialTransfers: library.sftpTransfers,
preferences: { [weak self] in self?.notificationSettings.preferences ?? .init() },
isFocused: { [weak self] in self?.scenePhases.values.contains { $0 != .background } ?? false },
content: SFTPNotificationContent.make)
library.sftpTransferStateDidChange = { [weak self] in self?.sftpNotifications?.receive($0) }
library.sftpTransfersDidRemove = { [weak self] in self?.sftpNotifications?.forgetFinishedTransfers($0) }
notifications.onOpen = { [weak self] target in self?.notificationRouter.open(target) }
notificationBadge = NotificationBadgeCoordinator(service: notifications) { [weak self] in
guard let self, self.notificationSettings.preferences.isEnabled else { return 0 }
return self.unreadNotificationCount
}
ai.didChange = { [weak assistant] in assistant?.repairUnavailableModels() }
settingsSync?.didApplyRemote = { [weak self] snapshot, _ in
self?.ai.applyRemoteSnapshot(snapshot.configuration, webSearch: snapshot.webSearch)
self?.terminalSettings.applyRemoteSnapshot(snapshot.terminal)
self?.terminalSettings.applyRemoteThemes(snapshot.terminalThemes)
self?.sshSettings.applyRemoteSnapshot(snapshot.ssh)
self?.sftpSettings.applyRemoteSnapshot(snapshot.sftp)
self?.workspaceSettings.applyRemoteSnapshot(snapshot.workspace)
self?.notificationSettings.applyRemoteSnapshot(snapshot.notifications)
self?.skills.applyRemoteSnapshot(snapshot.skills)
}
metricsSync?.didApplyRemote = { [weak self] snapshot, _ in
self?.metrics.core.applyRemoteSnapshot(snapshot)
}
do {
let snapshot = try workspaceStore.load()
workspaceSessions = snapshot.sessions.sorted { $0.key < $1.key }.map {
MobileWorkspaceSessionModel(id: $0.key, app: self, descriptors: $0.value)
}
unboundRestoredSessionIDs = Set(snapshot.sessions.keys)
hasLoadedWorkspaceSnapshot = true
liveActivities.restore(workspaceActivitySessions)
} catch {
self.error = error.localizedDescription
}
observeHostArtwork()
}
private var workspaceSnapshot: MobileWorkspaceStore.Snapshot {
let sessions = workspaceSessions.filter { !$0.isDiscarded }.map { ($0.id, $0.restorableDescriptors) }
return .init(sessions: Dictionary(uniqueKeysWithValues: sessions))
}
func persistWorkspaceDescriptors() {
do {
try workspaceStore.save(workspaceSnapshot)
} catch {
self.error = error.localizedDescription
}
updateWorkspaceActivity()
}
private func updateWorkspaceActivity() {
// A failed read is not an authoritative empty list.
guard hasLoadedWorkspaceSnapshot else { return }
liveActivities.update(workspaceActivitySessions)
}
private func reconcileConnectionPreferences() {
let policy = MultiConnectionPolicy(allowsMultipleConnections: pro.canUse(.sftpMultiConnection))
transfers.updatePreferences(policy.sftpPreferences(sftpSettings.preferences))
}
var newVaultKeyStorage: KeyStorage {
NewVaultCredentialStorage.keyStorage(
defaults: settingsDefaults,
canUseVaultSync: pro.canUse(.vaultSync))
}
var newVaultPasswordStorage: PasswordStorage {
NewVaultCredentialStorage.passwordStorage(
defaults: settingsDefaults,
canUseVaultSync: pro.canUse(.vaultSync))
}
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, .mosh), (.ssh, .eternalTerminal), (.ssh, .sftp), (.telnet, .telnet): true
default: false
}
if matches { workspace.disconnect() }
}
}
private func closeWorkspaces(for hostID: UUID) {
forgetClosedWorkspaces(for: hostID)
for session in workspaceSessions {
for item in session.orderedWorkspaceItems where item.descriptor.hostID == hostID {
session.close(item.id, rememberForReopen: false)
}
}
}
@discardableResult
func deleteHost(_ id: UUID) -> Bool {
guard library.host(id)?.isTailscaleManaged != true else { return false }
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).filter { !$0.isTailscaleManaged }
guard hosts.allSatisfy({ deleteHost($0.id) }) else { return }
}
library.deleteGroupProjection(id)
if let storageError = library.storageError { error = storageError }
}
func duplicateHost(_ host: Host) async {
do {
let saved = try await library.duplicateHost(
host, name: String(localized: AppStrings.Vault.duplicatedHostName(host.name)))
if !saved { error = library.storageError }
} catch is CancellationError {
} catch {
self.error = HostAccessError.presentationDescription(of: error)
}
}
func lastConnectedAt(for hostID: Host.ID) -> Date? {
connectionHistory.mostRecentDate(for: hostID)
}
func snippetModifiedAt(for snippetID: Snippet.ID) -> Date {
SnippetTimestamp.resolve(
modifiedAt: snippets.modifiedAt(for: snippetID), fallback: snippetTimestampFallback)
}
func disconnectWorkspaceScene(_ sessionID: String) {
guard !discardedSessionIDs.contains(sessionID) else { return }
for id in Array(sceneSessionIDs.keys) where sceneSessionIDs[id] == sessionID {
sceneChanged(id, phase: .background)
removeScene(id)
}
// A disconnected UIScene may still have a restorable UISceneSession. Only a native
// openSessions reconciliation can establish that its window has actually disappeared.
}
func reconcileWorkspaceSessions(
openSessionIDs: Set<String>, activeSessionID: String?, supportsMultipleScenes: Bool = true
) {
// On single-window devices UIKit can still report archived sessions from a previous launch.
// Their workspaces belong in the current list, not in an unreachable Live Activity-only owner.
let ownerIDs =
supportsMultipleScenes ? openSessionIDs : Set([activeSessionID].compactMap { $0 })
orphanedSessionIDs = Set(workspaceSessions.map(\.id))
.subtracting(ownerIDs.subtracting(retiredRestoredSessionIDs)).subtracting(discardedSessionIDs)
let target = activeSessionID.flatMap { id in workspaceSessions.first { $0.id == id } }
if let target, openSessionIDs.contains(target.id) { recoverOrphanedWorkspaces(into: target) }
}
private func recoverOrphanedWorkspaces(into target: MobileWorkspaceSessionModel) {
orphanedSessionIDs.remove(target.id)
guard !target.isDiscarded else { return }
let sources = workspaceSessions.filter { orphanedSessionIDs.contains($0.id) }
guard !sources.isEmpty else { return }
let recoveries = sources.map { ($0, $0.recovery) }
var snapshot = workspaceSnapshot
for (source, recovery) in recoveries {
snapshot.sessions[source.id] = []
snapshot.sessions[target.id, default: []].append(contentsOf: recovery.descriptors)
}
// Commit the complete handoff before releasing any source, including dormant records.
do {
try workspaceStore.save(snapshot)
} catch {
self.error = String(localized: AppStrings.Workspace.workspaceChangesCouldNotBeSavedForTheNextLaunch)
return
}
for (source, recovery) in recoveries {
// Only a window used in this process can later explicitly close its transferred work.
if !unboundRestoredSessionIDs.contains(source.id) {
recoveredWorkspaceIDs[source.id, default: []].formUnion(recovery.order)
}
source.releaseForRecovery()
target.acceptRecovery(recovery)
orphanedSessionIDs.remove(source.id)
}
updateWorkspaceActivity()
}
func sceneChanged(_ id: UUID, phase: ScenePhase, workspaceSessionID: String? = nil) {
if let workspaceSessionID { sceneSessionIDs[id] = workspaceSessionID }
let wasBackground = allScenesAreBackgrounded
scenePhases[id] = phase
if phase != .active { screenAwake.update(sceneID: id, wantsAwake: false) }
refreshCommandFocus()
if phase == .active {
Task { [weak self] in
await Task.yield()
guard let self, self.scenePhases[id] == .active else { return }
self.notificationRouter.resume()
}
}
let isBackground = allScenesAreBackgrounded
if isBackground && !wasBackground {
enterBackground()
} else if phase == .active {
devices.updateCurrentDeviceName(Device.current.safeDescription)
sftpEdits.setMonitoring(true)
memory.resume()
Task { await sftpEdits.recheck() }
Task { [weak self] in await self?.pro.refresh() }
background.finish()
for workspace in workspaces { workspace.resumeAfterBackground() }
reconcileSync()
outbox.start()
updateWorkspaceActivity()
metrics.start()
}
}
func reconcileSync() {
let foreground = scenePhases.values.contains(.active)
if foreground && pro.canUse(.vaultSync) {
vaultSync?.start()
connectionHistorySync?.start()
deviceSync?.start()
metricsSync?.start()
settingsSync?.start()
} else {
vaultSync?.pause()
connectionHistorySync?.pause()
deviceSync?.pause()
metricsSync?.pause()
settingsSync?.pause()
}
}
func setSyncEnabled(_ value: Bool, key: String) {
guard !value || pro.require(.vaultSync) else { return }
defaults?.set(value, forKey: key)
if key == SettingsKey.historySyncEnabled { connectionHistorySync?.setEnabled(value) }
if value && key == SettingsKey.iCloudSyncEnabled {
do { try library.migrateCustomPasswordsToICloud() } catch { self.error = error.localizedDescription }
}
reconcileSync()
}
func applyRemoteVault(_ snapshot: VaultSnapshot, keys: Set<VaultRecordKey>) {
let retainedHostIDs = Set(snapshot.hosts.map(\.id))
for key in keys where key.kind == .host && !retainedHostIDs.contains(key.id) {
metrics.core.remove(key.id)
}
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)
connectionHistory.clear(for: change.id)
metrics.remove(change.id)
}
}
forwardings.stop(plan.forwardingRuleIDs)
library.replacePersistedSnapshot(snapshot)
forwardings.reconcileConfiguration()
updateWorkspaceActivity()
}
func removeScene(_ id: UUID) {
screenAwake.update(sceneID: id, wantsAwake: false)
let wasBackground = allScenesAreBackgrounded
scenePhases[id] = nil
sceneSessionIDs[id] = nil
paneScenes[id] = nil
if allScenesAreBackgrounded && !wasBackground { enterBackground() }
reconcileSync()
}
private var allScenesAreBackgrounded: Bool {
!scenePhases.isEmpty && scenePhases.values.allSatisfy { $0 == .background }
}
private func enterBackground() {
sftpEdits.setMonitoring(false)
memory.suspend()
vaultSync?.pause()
connectionHistorySync?.pause()
deviceSync?.pause()
metricsSync?.pause()
settingsSync?.pause()
metrics.pause()
outbox.stop()
assistant.suspendRecording()
background.begin { [weak self] in
guard let self else { return }
persistentSessionCloser.cancelAll()
for workspace in workspaces { workspace.suspendForBackgroundExpiration() }
forwardings.stopAll()
transfers.pauseAll()
assistant.expireBackground()
}
for workspace in workspaces { workspace.suspendResumableTransport() }
updateWorkspaceActivity()
}
func focusWorkspaceActivity(_ id: UUID) {
guard hasLoadedWorkspaceSnapshot else { return }
liveActivities.focus(id, in: workspaceActivitySessions)
}
func workspaceSession(for id: String) -> MobileWorkspaceSessionModel {
unboundRestoredSessionIDs.remove(id)
retiredRestoredSessionIDs.remove(id)
if let session = workspaceSessions.first(where: { $0.id == id }) { return session }
let session = MobileWorkspaceSessionModel(id: id, app: self, descriptors: [])
workspaceSessions.append(session)
if discardedSessionIDs.contains(id) { session.discard() }
return session
}
func workspaceOwner(_ workspaceID: UUID) -> String? {
if let session = workspaceSessions.first(where: { $0.orderedWorkspaceItems.contains { $0.id == workspaceID } })
{
return session.id
}
return nil
}
func discardWorkspaceSessions(_ ids: Set<String>) {
guard !ids.isEmpty else { return }
// UIKit may discard last process's windows before their records find a new native owner.
let restored = ids.intersection(unboundRestoredSessionIDs)
retiredRestoredSessionIDs.formUnion(restored)
orphanedSessionIDs.formUnion(restored)
let ids = ids.subtracting(restored)
guard !ids.isEmpty else { return }
// Native dismissal detaches clients; only explicit workspace closure ends owned remote work.
discardedSessionIDs.formUnion(ids)
orphanedSessionIDs.subtract(ids)
for id in Array(sceneSessionIDs.keys) where ids.contains(sceneSessionIDs[id] ?? "") { removeScene(id) }
// A native discard can arrive after reconciliation moved the disappearing window's workspaces.
for id in ids {
if let recovered = recoveredWorkspaceIDs[id], !recovered.isEmpty {
let states = workspaceSessions.flatMap(\.orderedWorkspaceItems)
.filter { recovered.contains($0.id) }
.map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) }
rememberClosedWorkspaces(states, isWindow: true)
}
}
let recoveredIDs = ids.reduce(into: Set<UUID>()) { $0.formUnion(recoveredWorkspaceIDs[$1] ?? []) }
for session in workspaceSessions {
for workspaceID in recoveredIDs { session.close(workspaceID, rememberForReopen: false, authorized: true) }
}
for id in ids { recoveredWorkspaceIDs[id] = nil }
for session in workspaceSessions where ids.contains(session.id) { session.discard() }
workspaceSessions.removeAll { ids.contains($0.id) }
persistWorkspaceDescriptors()
}
func containsWorkspace(_ id: UUID) -> Bool {
workspaceSessions.contains { session in session.orderedWorkspaceItems.contains { $0.id == id } }
}
func isAssistantNotificationFocused(_ id: UUID) -> Bool {
paneScenes.values.compactMap(\.value).contains { $0.isShowingAssistant(id) }
}
var unreadAssistantNotificationCount: Int {
assistant.store.conversations.filter { !$0.isArchived }.reduce(0) { $0 + $1.unreadNotificationCount }
}
var unreadNotificationCount: Int {
unreadAssistantNotificationCount + workspaceSessions.reduce(0) { $0 + $1.unreadCompletionCount }
}
func refreshAssistantReadState() {
for conversation in assistant.store.conversations where isAssistantNotificationFocused(conversation.id) {
conversation.markNotificationsRead()
}
}
func clearNotificationBadge() {
notificationBadge?.stop()
sftpNotifications?.stop()
assistantNotifications.stop()
terminalNotifications.stop()
notificationRouter.stop()
}
private func isTerminalNotificationFocused(_ id: UUID) -> Bool { isCommandWorkspaceFocused(id) }
func isNotificationSceneActive(_ scene: MobileSceneModel) -> Bool { scenePhases[scene.id] == .active }
var hasActiveNotificationScene: Bool { scenePhases.values.contains(.active) }
private var notificationScenes: [MobileSceneModel] {
paneScenes.values.compactMap(\.value).sorted { $0.id.uuidString < $1.id.uuidString }
}
func transferNotificationTarget() -> MobileSceneModel? {
let available = notificationScenes
return available.first(where: isNotificationSceneActive) ?? available.first
}
func assistantNotificationTarget(_ id: UUID) -> MobileSceneModel? {
let available = notificationScenes
return available.first(where: { isNotificationSceneActive($0) && $0.isShowingAssistant(id) })
?? available.first(where: { $0.isShowingAssistant(id) })
?? available.first(where: {
isNotificationSceneActive($0) && $0.assistant.library.selectedConversationID == id
})
?? available.first(where: { $0.assistant.library.selectedConversationID == id })
?? available.first(where: isNotificationSceneActive) ?? available.first
}
func isCommandWorkspaceFocused(_ id: UUID) -> Bool {
paneScenes.values.compactMap(\.value).contains {
scenePhases[$0.id] == .active && $0.activeWorkspace?.id == id
}
}
func refreshCommandFocus() {
for scene in paneScenes.values.compactMap(\.value) where scenePhases[scene.id] == .active {
if let workspace = scene.activeWorkspace { scene.workspaceSession.markRead(workspace.id) }
}
}
func commandNotificationTarget(in sessionID: String) -> MobileSceneModel? {
let available = notificationScenes.filter { $0.workspaceSession.id == sessionID }
return available.first(where: isNotificationSceneActive) ?? available.first
}
func closePersistentSessions(_ targets: [MobileWorkspaceItem]) -> Set<UUID> {
let requests = targets.compactMap { item -> (UUID, RemoteSessionCloseRequest)? in
if let workspace = item.workspace {
return workspace.takePersistentSessionCloseRequest().map { (item.id, $0) }
}
guard let reference = item.descriptor.remoteSession, reference.isManaged,
let host = item.host, host.id == reference.hostID,
"\(host.host):\(host.sshPort)" == reference.endpoint,
let snapshot = try? library.resumableControlSnapshot(for: host, username: reference.username)
else { return nil }
return (item.id, RemoteSessionCloseRequest(reference: reference, snapshot: snapshot))
}.sorted { $0.1.connection != nil && $1.1.connection == nil }
return Set(requests.compactMap { persistentSessionCloser.enqueue($0.1) ? $0.0 : nil })
}
func workspaceWillOpen(_ workspace: MobileWorkspace, close: @escaping (UUID) -> Void) {
workspace.configureTerminalBell { [weak self, weak workspace] in
guard let self, let workspace, self.hasActiveNotificationScene,
self.containsWorkspace(workspace.id)
else { return }
let preferences = self.terminalExperienceSettings.preferences
if preferences.bellVibration { self.terminalEffects.vibration() }
if preferences.bellSound { self.terminalEffects.sound() }
}
terminalNotifications.observe(workspace.commandMonitor, id: workspace.id)
observeActivityState(of: workspace)
workspace.didRequestClose = close
startConnectionHistory(for: workspace)
}
private func observeHostArtwork() {
withObservationTracking {
_ = library.hosts
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
for workspace in self.workspaces { workspace.refreshHostArtwork() }
self.updateWorkspaceActivity()
self.observeHostArtwork()
}
}
}
func workspacesDidOpen() {
guard hasLoadedWorkspaceSnapshot else { return }
liveActivities.start(workspaceActivitySessions)
}
func workspaceDidClose(_ id: UUID) {
Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(id) }
endConnectionHistory(for: id)
}
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.didChangeState = { [weak self] session in
guard let self, containsWorkspace(session.id) else { return }
persistWorkspaceDescriptors()
}
}
}