diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 2ba1b384..d17f99b6 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -503,55 +503,6 @@ } } }, - "%@ connected · %@ workspaces" : { - "comment" : "A label that shows the number of connected workspaces and the total count of workspaces.", - "extractionState" : "manual", - "isCommentAutoGenerated" : true, - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbunden: %1$@ · Arbeitsbereiche: %2$@" - } - }, - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "%1$@ connected · %2$@ workspaces" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connectés : %1$@ · Espaces de travail : %2$@" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@個接続済み · %2$@個のワークスペース" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@개 연결됨 · 작업 공간 %2$@개" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@个已连接,%2$@个工作区" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@個已連線,%2$@個工作區" - } - } - } - }, "%@ copy" : { "extractionState" : "manual", "localizations" : { @@ -1897,47 +1848,6 @@ } } }, - "%lld connected · %lld workspaces" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Verbunden: %lld · Arbeitsbereiche: %lld" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Connectés : %lld · Espaces de travail : %lld" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$lld個接続済み · %2$lld個のワークスペース" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$lld개 연결됨 · 작업 공간 %2$lld개" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$lld个已连接,%2$lld个工作区" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$lld個已連線,%2$lld個工作區" - } - } - } - }, "%lld directories" : { "extractionState" : "manual", "localizations" : { @@ -48570,47 +48480,6 @@ } } }, - "Open to Check Connections" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Öffnen, um Verbindungen zu prüfen" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ouvrir pour vérifier les connexions" - } - }, - "ja" : { - "stringUnit" : { - "state" : "translated", - "value" : "開いて接続を確認" - } - }, - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "열어서 연결 확인" - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "打开以检查连接" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "開啟以檢查連線" - } - } - } - }, "Open with %@" : { "extractionState" : "manual", "localizations" : { diff --git a/Frameworks/TermindPresentation/Strings/Workspace.swift b/Frameworks/TermindPresentation/Strings/Workspace.swift index 5116ba22..14078ede 100644 --- a/Frameworks/TermindPresentation/Strings/Workspace.swift +++ b/Frameworks/TermindPresentation/Strings/Workspace.swift @@ -2,6 +2,14 @@ import Foundation extension AppStrings { public nonisolated enum Workspace { + public static func count(_ count: Int) -> LocalizedStringResource { + switch count { + case 1: "One workspace" + case 2: "Two workspaces" + default: "\(count) workspaces" + } + } + public static var advisoryTelnetPassword: LocalizedStringResource { "This link includes an advisory Telnet password. It will be used once and will not be saved." } diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index a5c04e31..9400d35a 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -132,7 +132,22 @@ final class MobileAppModel { let diagnosis: DiagnosisController let logging: any ApplicationLogManaging let liveActivities: MobileLiveActivityController - @ObservationIgnored private var workspaceActivitySessions: [WorkspaceActivityAttributes.Session] = [] + 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 @@ -143,11 +158,12 @@ final class MobileAppModel { let recentlyClosedStore = RecentlyClosedStore>( defaults: nil, key: "mobile.recentlyClosed.v2") private var orphanedSessionIDs: Set = [] + private var unboundRestoredSessionIDs: Set = [] + private var retiredRestoredSessionIDs: Set = [] // 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] = [:] private var discardedSessionIDs: Set = [] - private var activityRebuilds: [UUID: UUID] = [:] var closedWorkspaceStates: [UUID: [MobileWorkspace.ReopenState]] = [:] var pendingClosedWindows: Set = [] var createWorkspaceWindow: @MainActor (UUID, @escaping @MainActor () -> Void) -> Void @@ -468,19 +484,33 @@ final class MobileAppModel { 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 + updateWorkspaceActivity() } catch { self.error = error.localizedDescription } observeHostArtwork() } - func persistWorkspaceDescriptors() { + 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(.init(sessions: Dictionary(uniqueKeysWithValues: sessions))) + 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() { @@ -584,7 +614,7 @@ final class MobileAppModel { func reconcileWorkspaceSessions(openSessionIDs: Set, activeSessionID: String?) { orphanedSessionIDs = Set(workspaceSessions.map(\.id)) - .subtracting(openSessionIDs).subtracting(discardedSessionIDs) + .subtracting(openSessionIDs.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) } } @@ -592,13 +622,31 @@ final class MobileAppModel { private func recoverOrphanedWorkspaces(into target: MobileWorkspaceSessionModel) { orphanedSessionIDs.remove(target.id) guard !target.isDiscarded else { return } - for source in workspaceSessions where orphanedSessionIDs.contains(source.id) { - let recovery = source.recovery - recoveredWorkspaceIDs[source.id, default: []].formUnion(recovery.order) + 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) { @@ -627,7 +675,7 @@ final class MobileAppModel { for workspace in workspaces { workspace.resumeAfterBackground() } reconcileSync() outbox.start() - liveActivities.update(workspaceActivitySessions) + updateWorkspaceActivity() metrics.start() } } @@ -687,7 +735,7 @@ final class MobileAppModel { forwardings.stop(plan.forwardingRuleIDs) library.replacePersistedSnapshot(snapshot) forwardings.reconcileConfiguration() - liveActivities.update(workspaceActivitySessions) + updateWorkspaceActivity() } func removeScene(_ id: UUID) { @@ -724,14 +772,17 @@ final class MobileAppModel { assistant.expireBackground() } for workspace in workspaces { workspace.suspendResumableTransport() } - liveActivities.update(workspaceActivitySessions) + 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) @@ -747,24 +798,13 @@ final class MobileAppModel { return nil } - /// Main-actor publication happens before authentication, merging concurrent scene/link deliveries. - func rebuildActivityWorkspace(from url: URL, into session: MobileWorkspaceSessionModel) -> UUID? { - guard let source = WorkspaceActivityAttributes.workspaceID(from: url) else { return nil } - if containsWorkspace(source) { return source } - if let rebuilt = activityRebuilds[source], containsWorkspace(rebuilt) { return rebuilt } - let fallback = WorkspaceActivityAttributes.recovery(from: url) - guard let recovery = liveActivities.recovery(for: source) ?? fallback, - recovery.version == 1, recovery.sourceWorkspaceID == source, - fallback == nil || (fallback?.hostID == recovery.hostID && fallback?.kind == recovery.kind), - let id = session.rebuild(recovery) - else { return nil } - activityRebuilds[source] = id - liveActivities.remove(source) - liveActivities.update(workspaceActivitySessions) - return id - } - func discardWorkspaceSessions(_ ids: Set) { + 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) @@ -787,7 +827,7 @@ final class MobileAppModel { 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 { @@ -901,26 +941,20 @@ final class MobileAppModel { Task { @MainActor [weak self] in guard let self else { return } for workspace in self.workspaces { workspace.refreshHostArtwork() } + self.updateWorkspaceActivity() self.observeHostArtwork() } } } - func workspacesDidOpen(_ added: [MobileWorkspace]) { - workspaceActivitySessions.append(contentsOf: added.map(\.activitySession)) + func workspacesDidOpen() { + guard hasLoadedWorkspaceSnapshot else { return } liveActivities.start(workspaceActivitySessions) } func workspaceDidClose(_ id: UUID) { Task { [terminalRecoveryStore] in try? await terminalRecoveryStore.discard(id) } endConnectionHistory(for: id) - liveActivities.remove(id) - for source in activityRebuilds.keys where activityRebuilds[source] == id { - liveActivities.remove(source) - } - activityRebuilds = activityRebuilds.filter { $0.value != id } - workspaceActivitySessions.removeAll { $0.id == id } - liveActivities.update(workspaceActivitySessions) } private func startConnectionHistory(for workspace: MobileWorkspace) { @@ -939,11 +973,7 @@ final class MobileAppModel { private func observeActivityState(of workspace: MobileWorkspace) { 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) + guard let self, containsWorkspace(session.id) else { return } persistWorkspaceDescriptors() } } diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index bcd9745d..4f0f56ea 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -464,9 +464,8 @@ final class MobileSceneModel { return } isWorkspaceUnavailable = false - let id = - app.containsWorkspace(sourceID) ? sourceID : app.rebuildActivityWorkspace(from: url, into: workspaceSession) - guard let id, let owner = app.workspaceOwner(id) else { + let id = sourceID + guard let owner = app.workspaceOwner(id) else { pendingWorkspaceID = nil selectedDestination = .workspaces workspaceID = nil diff --git a/TFI/App/MobileWorkspaceSessionModel.swift b/TFI/App/MobileWorkspaceSessionModel.swift index f0fea437..e6c2078d 100644 --- a/TFI/App/MobileWorkspaceSessionModel.swift +++ b/TFI/App/MobileWorkspaceSessionModel.swift @@ -22,7 +22,7 @@ struct MobileWorkspaceItem: Identifiable { var id: MobileWorkspaceDescriptor.ID { descriptor.id } } -/// In-process connection intent and live workspaces belonging to one native scene. +/// Saved connection intent and live workspaces belonging to one native scene. @MainActor @Observable final class MobileWorkspaceSessionModel { @@ -50,9 +50,7 @@ final class MobileWorkspaceSessionModel { guard orderedWorkspaceItems.contains(where: { $0.id == id }) else { return } manuallyUnreadIDs.insert(id) } - private(set) var savedWorkspaceDescriptors: [MobileWorkspaceDescriptor] { - didSet { app.persistWorkspaceDescriptors() } - } + private(set) var savedWorkspaceDescriptors: [MobileWorkspaceDescriptor] private var workspaceDisplayOrder: [MobileWorkspace.ID] var canReopen: Bool { !isDiscarded && !app.closedWorkspaceRecords.isEmpty } var reopensWindow: Bool { app.closedWorkspaceRecords.first?.isWindow == true } @@ -76,7 +74,7 @@ final class MobileWorkspaceSessionModel { var recovery: Recovery { Recovery( - workspaces: workspaces, descriptors: savedWorkspaceDescriptors, order: workspaceDisplayOrder, + workspaces: workspaces, descriptors: restorableDescriptors, order: workspaceDisplayOrder, manuallyUnreadIDs: manuallyUnreadIDs) } @@ -111,14 +109,8 @@ final class MobileWorkspaceSessionModel { } var restorableDescriptors: [MobileWorkspaceDescriptor] { - savedWorkspaceDescriptors.compactMap { saved in - let descriptor = workspaces.first { $0.id == saved.id }?.restorationDescriptor ?? saved - // Tailnet peers can be rediscovered after the scene's descriptors load. - guard let host = app.library.host(descriptor.hostID) else { return descriptor } - guard host.isEnabled(descriptor.kind.terminalProtocol ?? .ssh) else { return nil } - if descriptor.kind.terminalProtocol?.isResumable == true { return descriptor } - return descriptor.kind == .ssh - && (host.sessionPersistence.enabled || descriptor.remoteSession != nil) ? descriptor : nil + savedWorkspaceDescriptors.map { saved in + workspaces.first { $0.id == saved.id }?.restorationDescriptor ?? saved } } @@ -185,27 +177,6 @@ final class MobileWorkspaceSessionModel { return workspace.id } - func rebuild(_ recovery: WorkspaceActivityAttributes.Recovery) -> UUID? { - guard !isDiscarded, let host = app.library.host(recovery.hostID), - let kind = ConnectionHistoryEntry.Kind(rawValue: recovery.kind), - MobileConnectionHistoryActionPolicy.isAvailable(kind, for: host) - else { return nil } - if kind.terminalProtocol?.isResumable == true { - // A stale activity cannot recreate an explicitly closed resumable session. - return nil - } - let workspace = makeWorkspace(host: host, kind: kind) - if let data = recovery.remoteSession, - let reference = try? JSONDecoder().decode(RemoteSessionReference.self, from: data), - reference.hostID == host.id, kind == .ssh - { - workspace.restoreRemoteSession(reference) - } - workspace.sessionPersistence.requiresSelection = true - addWorkspaces([workspace]) - return workspace.id - } - @discardableResult func batchStartup( hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol @@ -289,6 +260,7 @@ final class MobileWorkspaceSessionModel { if let savedIndex, let workspace { savedWorkspaceDescriptors[savedIndex] = workspace.descriptor } + app.persistWorkspaceDescriptors() return true } @@ -307,6 +279,7 @@ final class MobileWorkspaceSessionModel { workspaces.removeAll { $0.id == id } savedWorkspaceDescriptors.removeAll { $0.id == id } workspaceDisplayOrder.removeAll { $0 == id } + app.persistWorkspaceDescriptors() } var closableWorkspaceCount: Int { @@ -329,6 +302,7 @@ final class MobileWorkspaceSessionModel { workspace.didRequestClose = nil workspace.close() } + app.persistWorkspaceDescriptors() } func closeAll(rememberForReopen: Bool = true, authorized: Bool = false) { @@ -348,6 +322,7 @@ final class MobileWorkspaceSessionModel { workspace.didRequestClose = nil workspace.close() } + app.persistWorkspaceDescriptors() } private func rememberClosedWorkspaces(_ ids: Set, endingRemoteSessions: Set = []) { @@ -390,7 +365,6 @@ final class MobileWorkspaceSessionModel { externalSFTPResourcePath: state.externalSFTPResourcePath, externalSFTPMustBeDirectory: state.externalSFTPMustBeDirectory) workspace.restoreRemoteSession(state.descriptor.remoteSession) - workspace.sessionPersistence.requiresSelection = state.descriptor.kind == .ssh addWorkspaces([workspace], persists: state.connectionOrigin.allowsPersistence) } } @@ -401,7 +375,9 @@ final class MobileWorkspaceSessionModel { guard !isDiscarded else { return nil } if let workspace = workspaces.first(where: { $0.id == id }) { return workspace } guard let descriptor = savedWorkspaceDescriptors.first(where: { $0.id == id }) else { return nil } - guard app.library.host(descriptor.hostID) != nil else { + guard let host = app.library.host(descriptor.hostID), + MobileConnectionHistoryActionPolicy.isAvailable(descriptor.kind, for: host) + else { return nil } let workspace = MobileWorkspace( @@ -410,7 +386,6 @@ final class MobileWorkspaceSessionModel { workspace.requiresTransportRecovery = descriptor.kind.terminalProtocol?.isResumable == true if descriptor.kind.isSSHTerminal { workspace.restoreRemoteSession(descriptor.remoteSession) - workspace.sessionPersistence.requiresSelection = descriptor.kind == .ssh } addWorkspaces([workspace]) return workspace @@ -471,7 +446,7 @@ final class MobileWorkspaceSessionModel { let savedIDs = Set(savedWorkspaceDescriptors.map(\.id)) savedWorkspaceDescriptors.append(contentsOf: added.map(\.descriptor).filter { !savedIDs.contains($0.id) }) } - app.workspacesDidOpen(added) + app.workspacesDidOpen() for workspace in added { workspace.connect() } app.persistWorkspaceDescriptors() } diff --git a/TFI/Infrastructure/MobileWorkspaceStore.swift b/TFI/Infrastructure/MobileWorkspaceStore.swift index b4db7f03..13937e04 100644 --- a/TFI/Infrastructure/MobileWorkspaceStore.swift +++ b/TFI/Infrastructure/MobileWorkspaceStore.swift @@ -1,6 +1,6 @@ import Foundation -/// Retire local workspace snapshots without migrating them into a new cache. +/// Device-local connection intent and recovery references, never credentials or one-shot commands. @MainActor final class MobileWorkspaceStore { static let key = "mobile.workspaces.v1" diff --git a/TFI/Platform/MobileLiveActivityController.swift b/TFI/Platform/MobileLiveActivityController.swift index a5c1fca6..4909e02f 100644 --- a/TFI/Platform/MobileLiveActivityController.swift +++ b/TFI/Platform/MobileLiveActivityController.swift @@ -12,8 +12,6 @@ final class MobileLiveActivityController { private let services: MobileLiveActivityServices private var focusedWorkspaceID: UUID? private var pendingStart = false - private var retainedSessions: [WorkspaceActivityAttributes.Session] = [] - private var removedWorkspaceIDs: Set = [] init(enabled: Bool = true, services: MobileLiveActivityServices = .live) { self.enabled = enabled @@ -22,12 +20,6 @@ final class MobileLiveActivityController { let restored = services.activities() activity = restored.first { $0.state == .active || $0.state == .stale } focusedWorkspaceID = activity?.focusedWorkspaceID - retainedSessions = (activity?.contentState?.sessions ?? []).compactMap { session in - guard session.recovery?.version == 1 else { return nil } - var disconnected = session - disconnected.connected = false - return disconnected - } let retainedID = activity?.id updates = Task { for duplicate in restored where duplicate.id != retainedID { @@ -37,19 +29,6 @@ final class MobileLiveActivityController { } } - func recovery(for id: UUID) -> WorkspaceActivityAttributes.Recovery? { - guard !removedWorkspaceIDs.contains(id) else { return nil } - guard activity?.state == .active || activity?.state == .stale else { return nil } - return retainedSessions.first(where: { $0.id == id })?.recovery - ?? activity?.contentState?.sessions.first(where: { $0.id == id })?.recovery - } - - func remove(_ id: UUID) { - // ActivityKit updates are asynchronous; never recover from its stale content after an explicit close. - removedWorkspaceIDs.insert(id) - retainedSessions.removeAll { $0.id == id } - } - func focus(_ id: UUID, in sessions: [WorkspaceActivityAttributes.Session]) { guard sessions.contains(where: { $0.id == id }) else { return } focusedWorkspaceID = id @@ -62,10 +41,8 @@ final class MobileLiveActivityController { private func schedule(_ sessions: [WorkspaceActivityAttributes.Session], mayStart: Bool) { guard enabled else { return } - let liveIDs = Set(sessions.map(\.id)) - retainedSessions.removeAll { liveIDs.contains($0.id) } let state = WorkspaceActivityAttributes.ContentState.snapshot( - sessions + retainedSessions, focusedID: focusedWorkspaceID) + sessions, focusedID: focusedWorkspaceID) let previous = updates updates = Task { [weak self] in await previous?.value diff --git a/TFI/Platform/MobileLiveActivityServices.swift b/TFI/Platform/MobileLiveActivityServices.swift index 4a42d990..034bcfc7 100644 --- a/TFI/Platform/MobileLiveActivityServices.swift +++ b/TFI/Platform/MobileLiveActivityServices.swift @@ -7,15 +7,10 @@ protocol MobileWorkspaceActivity { var id: String { get } var state: ActivityState { get } var focusedWorkspaceID: UUID? { get } - var contentState: WorkspaceActivityAttributes.ContentState? { get } func update(_ state: WorkspaceActivityAttributes.ContentState) async func end() async } -extension MobileWorkspaceActivity { - var contentState: WorkspaceActivityAttributes.ContentState? { nil } -} - @MainActor struct MobileLiveActivityServices { var activities: () -> [any MobileWorkspaceActivity] @@ -44,8 +39,6 @@ private struct WorkspaceActivity: MobileWorkspaceActivity { var id: String { activity.id } var state: ActivityState { activity.activityState } var focusedWorkspaceID: UUID? { activity.content.state.focusedWorkspaceID } - var contentState: WorkspaceActivityAttributes.ContentState? { activity.content.state } - func update(_ state: WorkspaceActivityAttributes.ContentState) async { await activity.update(ActivityContent(state: state, staleDate: Date.now.addingTimeInterval(60))) } diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index b3f1b073..951b6749 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -1564,32 +1564,32 @@ struct MobileWorkspaceRestorationTests { #expect(next.closedWorkspaceRecords.isEmpty) } - @Test func activityFallbackRebuildsWithNewIdentityAndDeduplicatesAcrossScenes() throws { + @Test func legacyActivityLinksOpenOnlyExistingWorkspacesAndNeverRebuildClosedOnes() throws { let app = try MobileAppModel(inMemory: true) - var host = TermindDomain.Host(name: "Saved", host: "example.invalid") - host.sessionPersistence = .init(enabled: true, onConnect: .createManaged) + let host = TermindDomain.Host(name: "Saved", host: "example.invalid") app.library.upsert(host) - let source = UUID() - let session = WorkspaceActivityAttributes.Session( - id: source, title: "Old", kind: "ssh", connected: false, - recovery: .init(sourceWorkspaceID: source, hostID: host.id, kind: "ssh")) let first = MobileSceneModel(app: app, sessionID: "first") let second = MobileSceneModel(app: app, sessionID: "second") - first.open(session.destinationURL) - let rebuilt = try #require(app.workspaces.first) - #expect(rebuilt.id != source) - #expect(rebuilt.sessionPersistence.requiresSelection) + let source = try #require(first.workspaceSession.open(hostID: host.id, kind: .ssh)) + let url = try #require(URL(string: "termind://workspace/\(source)?v=1&host=\(host.id)&kind=ssh")) + first.open(url) + first.activatePendingWorkspace() + #expect(first.activeWorkspace?.id == source) + #expect(first.activeWorkspace?.sessionPersistence.requiresSelection == false) var activated: [UUID] = [] app.activateWorkspaceSession = { owner, id, _ in #expect(owner == "first") activated.append(id) } - second.open(session.destinationURL) - first.open(session.destinationURL) + second.open(url) + first.open(url) #expect(app.workspaces.count == 1) - #expect(activated == [rebuilt.id]) - app.testWorkspaceSession.closeAll() + #expect(activated == [source]) first.workspaceSession.closeAll() + first.open(url) + second.open(url) + #expect(first.isWorkspaceUnavailable && second.isWorkspaceUnavailable) + #expect(app.workspaces.isEmpty) } @Test func oldAndDeletedHostLinksCannotGuessAConnection() throws { diff --git a/TFITests/MobileLiveActivityRestorationTests.swift b/TFITests/MobileLiveActivityRestorationTests.swift index ade9de24..085f0cb5 100644 --- a/TFITests/MobileLiveActivityRestorationTests.swift +++ b/TFITests/MobileLiveActivityRestorationTests.swift @@ -46,7 +46,7 @@ struct MobileLiveActivityRestorationTests { #expect(existing.updates.count == 1) } - @Test func removedRecoveryCannotBeReadBackFromStaleSystemContent() { + @Test func authoritativeSnapshotDropsOrphanedSystemEntries() async { let backend = Backend() let id = UUID() let recovery = TermindIOS.WorkspaceActivityAttributes.Recovery( @@ -57,9 +57,11 @@ struct MobileLiveActivityRestorationTests { existing.contentState = .snapshot([card], focusedID: id) backend.existing = [existing] let controller = MobileLiveActivityController(services: backend.services) - #expect(controller.recovery(for: id) == recovery) - controller.remove(id) - #expect(controller.recovery(for: id) == nil) + let current = session() + controller.update([current]) + #expect(await eventually { existing.updates.last?.sessions.map(\.id) == [current.id] }) + controller.update([]) + #expect(await eventually { existing.ended }) } @Test func closeLastThenOpenWaitsForOldActivityToFinishEnding() async { @@ -84,7 +86,11 @@ struct MobileLiveActivityRestorationTests { #expect(backend.created.first?.ended == false) } - @Test func coldLaunchRetainsRecoverableSystemActivityWithoutConnecting() async throws { + @Test(arguments: [false, true]) + func coldLaunchPublishesSavedRecordsAndCanCloseWithoutConnecting(openBeforeClose: Bool) async throws { + let name = "ActivityRestoration.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } let seed = try MobileAppModel(inMemory: true) let host = TermindDomain.Host(name: "Server", host: "example.invalid") seed.library.upsert(host) @@ -97,11 +103,15 @@ struct MobileLiveActivityRestorationTests { recovery: .init( sourceWorkspaceID: oldID, hostID: host.id, kind: "ssh", remoteSession: try JSONEncoder().encode(reference))) + let descriptor = MobileWorkspaceDescriptor( + id: oldID, hostID: host.id, title: card.title, kind: .ssh, remoteSession: reference) + try MobileWorkspaceStore(defaults: defaults).save(.init(sessions: ["old": [descriptor]])) let backend = Backend() let existing = TestActivity(focusedWorkspaceID: oldID) - existing.contentState = .snapshot([card], focusedID: oldID) + let orphan = session() + existing.contentState = .snapshot([card, orphan], focusedID: oldID) backend.existing = [existing] - var dependencies = MobileAppDependencies.testing() + var dependencies = MobileAppDependencies.testing(defaults: defaults) dependencies.container = seed.container dependencies.liveActivities = MobileLiveActivityController(services: backend.services) let app = try MobileAppModel(dependencies: dependencies) @@ -109,23 +119,106 @@ struct MobileLiveActivityRestorationTests { #expect(await eventually { !existing.updates.isEmpty }) #expect(!existing.ended) #expect(existing.updates.last?.connectedCount == 0) + #expect(existing.updates.last?.sessions.map(\.id) == [oldID]) #expect(app.workspaces.isEmpty) #expect(backend.requests.isEmpty) let scene = MobileSceneModel(app: app, sessionID: "new") - scene.open(card.destinationURL) - let rebuilt = try #require(app.workspaces.first) - #expect(rebuilt.id != oldID) - #expect(rebuilt.sessionPersistence.requiresSelection) - #expect(rebuilt.sessionPersistence.attachment == reference) + app.discardWorkspaceSessions(["old"]) + app.reconcileWorkspaceSessions(openSessionIDs: ["new"], activeSessionID: "new") + #expect(scene.workspaceSession.savedWorkspaceDescriptors == [descriptor]) + if openBeforeClose { + scene.open(card.destinationURL) + let restored = try #require(app.workspaces.first) + #expect(restored.id == oldID) + #expect(!restored.sessionPersistence.requiresSelection) + #expect(restored.sessionPersistence.attachment == reference) + #expect(!scene.isWorkspaceUnavailable) + } scene.workspaceSession.closeAll() #expect(await eventually { existing.ended }) + #expect(try MobileWorkspaceStore(defaults: defaults).load().sessions.values.allSatisfy(\.isEmpty)) + scene.open(card.destinationURL) + #expect(scene.isWorkspaceUnavailable) + #expect(app.workspaces.isEmpty) app.sceneChanged(scene.id, phase: .active) #expect(backend.requests.isEmpty) } + @Test func failedSnapshotReadPreservesTheSystemCardAndStoredBytes() async throws { + let name = "ActivityReadFailure.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let damaged = Data("unreadable".utf8) + defaults.set(damaged, forKey: MobileWorkspaceStore.restorationKey) + let backend = Backend() + let existing = TestActivity() + existing.contentState = .snapshot([session()], focusedID: nil) + backend.existing = [existing] + var dependencies = MobileAppDependencies.testing(defaults: defaults) + dependencies.liveActivities = MobileLiveActivityController(services: backend.services) + let app = try MobileAppModel(dependencies: dependencies) + app.sceneChanged(UUID(), phase: .active) + app.persistWorkspaceDescriptors() + for _ in 0..<20 { await Task.yield() } + #expect(app.error != nil) + #expect(existing.updates.isEmpty) + #expect(!existing.ended) + #expect(defaults.data(forKey: MobileWorkspaceStore.restorationKey) == damaged) + } + + @Test func explicitOpenIncludesDormantRecordsButLaunchDoesNotRecreateADismissedCard() async throws { + let name = "DormantActivityRecords.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let seed = try MobileAppModel(inMemory: true) + let host = TermindDomain.Host(name: "Server", host: "example.invalid") + seed.library.upsert(host) + let records = [ + MobileWorkspaceDescriptor(hostID: host.id, title: "Shell", kind: .ssh), + MobileWorkspaceDescriptor(hostID: host.id, title: "Files", kind: .sftp), + ] + try MobileWorkspaceStore(defaults: defaults).save(.init(sessions: ["owner": records])) + let backend = Backend() + backend.existing = [TestActivity(state: .dismissed)] + var dependencies = MobileAppDependencies.testing(defaults: defaults) + dependencies.container = seed.container + dependencies.liveActivities = MobileLiveActivityController(services: backend.services) + let app = try MobileAppModel(dependencies: dependencies) + let scene = MobileSceneModel(app: app, sessionID: "owner") + app.sceneChanged(scene.id, phase: .active) + #expect(await eventually { backend.existing[0].ended }) + #expect(backend.requests.isEmpty) + #expect(app.workspaces.isEmpty) + + scene.open(WorkspaceActivityAttributes.destinationURL(for: records[0].id)) + scene.activatePendingWorkspace() + try #require(await eventually { backend.requests.count == 1 }) + #expect(backend.requests[0].workspaceCount == 2) + #expect(backend.requests[0].connectedCount == 0) + #expect(Set(backend.requests[0].sessions.map(\.id)) == Set(records.map(\.id))) + #expect(scene.workspaceSession.recentWorkspaces == [records[1]]) + let workspace = try #require(scene.activeWorkspace) + #expect(!workspace.sessionPersistence.requiresSelection) + #expect(workspace.sessionPersistence.prompt == nil) + scene.workspaceSession.closeAll() + #expect(await eventually { backend.created[0].ended }) + #expect(scene.workspaceSession.reopenLastClosed() == records[1].id) + try #require(await eventually { backend.requests.count == 2 }) + #expect(backend.requests[1].sessions.map(\.id) == [records[1].id]) + #expect(backend.requests[1].connectedCount == 0) + scene.workspaceSession.closeAll() + } + @Test func coldLaunchWithoutRecoveryEndsUnusableCard() async throws { let backend = Backend() let existing = TestActivity() + let id = UUID() + existing.contentState = .snapshot( + [ + .init( + id: id, title: "Orphan", kind: "ssh", connected: true, + recovery: .init(sourceWorkspaceID: id, hostID: UUID(), kind: "ssh")) + ], focusedID: id) backend.existing = [existing] var dependencies = MobileAppDependencies.testing() dependencies.liveActivities = MobileLiveActivityController(services: backend.services) diff --git a/TFITests/MobileTerminalRestorationTests.swift b/TFITests/MobileTerminalRestorationTests.swift index cd7d9400..635e6a48 100644 --- a/TFITests/MobileTerminalRestorationTests.swift +++ b/TFITests/MobileTerminalRestorationTests.swift @@ -35,7 +35,7 @@ struct MobileTerminalRestorationTests { #expect(app.restoreClosedRecord(record.id, into: destination) == descriptor.id) let reopened = try #require(destination.workspaces.first) #expect(reopened.sessionPersistence.attachment == (window ? reference : nil)) - #expect(reopened.sessionPersistence.requiresSelection) + #expect(!reopened.sessionPersistence.requiresSelection) #expect(reopened.pendingInitialCommand == nil) } @@ -60,7 +60,7 @@ struct MobileTerminalRestorationTests { #expect(app.restoreClosedRecord(record.id, into: destination) == workspace.id) let reopened = try #require(destination.workspaces.first) #expect(reopened.sessionPersistence.attachment == nil) - #expect(reopened.sessionPersistence.requiresSelection) + #expect(!reopened.sessionPersistence.requiresSelection) } @Test(arguments: [HostTerminalProtocol.mosh, .eternalTerminal]) @@ -141,9 +141,7 @@ struct MobileTerminalRestorationTests { let logs = workspace.journey.logs workspace.receiveTransportState(.connected) #expect(workspace.journey.logs == logs) - try await Task.sleep(for: .milliseconds(750)) - #expect(!workspace.journey.isPresented) - #expect(workspace.acceptsTerminalInput) + #expect(await eventually { !workspace.journey.isPresented && workspace.acceptsTerminalInput }) #expect(workspace.terminal === terminal) workspace.receiveTransportState(.failed, failure: "Original resume error") #expect(workspace.journey.phase == .failed("Original resume error")) @@ -265,7 +263,7 @@ struct MobileTerminalRestorationTests { #expect(saved.sessions["two"] == [et]) let workspace = try #require(app.workspaceSession(for: "one").openWorkspace(tmux.id)) #expect(workspace.sessionPersistence.attachment == reference) - #expect(workspace.sessionPersistence.requiresSelection) + #expect(!workspace.sessionPersistence.requiresSelection) workspace.disconnect() } diff --git a/TFITests/MobileWorkspacePersistenceTests.swift b/TFITests/MobileWorkspacePersistenceTests.swift new file mode 100644 index 00000000..6fe7ef51 --- /dev/null +++ b/TFITests/MobileWorkspacePersistenceTests.swift @@ -0,0 +1,187 @@ +import Foundation +import TermindApplication +import TermindDomain +import Testing + +@testable import TermindIOS + +@Suite @MainActor +struct MobileWorkspacePersistenceTests { + @Test(arguments: [false, true]) + func everyProtocolSurvivesColdLaunchWithoutOpeningAConnection(persistenceEnabled: Bool) throws { + let name = "AllWorkspaceProtocols.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let original = try MobileAppModel(dependencies: .testing(defaults: defaults)) + defer { for workspace in original.workspaces { workspace.disconnect() } } + var host = Host(name: "Server", host: "example.invalid", telnetEnabled: true) + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + host.sessionPersistence.enabled = persistenceEnabled + original.library.upsert(host) + let session = original.workspaceSession(for: "window") + let kinds: [ConnectionHistoryEntry.Kind] = [.ssh, .sftp, .telnet, .mosh, .eternalTerminal] + let ids = try kinds.map { kind in + try #require(session.open(hostID: host.id, kind: kind)) + } + #expect(session.renameWorkspace(ids[0], to: "Build")) + let expected = session.restorableDescriptors + #expect(try MobileWorkspaceStore(defaults: defaults).load().sessions["window"] == expected) + + var connections = 0 + var dependencies = MobileAppDependencies.testing(defaults: defaults) + dependencies.container = original.container + dependencies.workspaceConnections.connectSSH = { _, _, _, _, _, _ in connections += 1 } + let restored = try MobileAppModel(dependencies: dependencies) + let saved = restored.workspaceSession(for: "window") + #expect(saved.savedWorkspaceDescriptors == expected) + #expect(saved.orderedWorkspaceItems.map(\.id) == ids) + #expect(saved.orderedWorkspaceItems.map(\.descriptor.kind) == kinds) + #expect(saved.orderedWorkspaceItems.first?.descriptor.title == "Build") + #expect(restored.workspaces.isEmpty) + #expect(connections == 0) + #expect(restored.connectionHistory.entries().count == original.connectionHistory.entries().count) + + saved.closeOtherWorkspaces(than: ids[0]) + #expect(try MobileWorkspaceStore(defaults: defaults).load().sessions["window"]?.map(\.id) == [ids[0]]) + saved.closeAll() + #expect(try MobileWorkspaceStore(defaults: defaults).load().sessions["window"]?.isEmpty == true) + #expect(restored.workspaces.isEmpty) + } + + @Test(arguments: [ConnectionHistoryEntry.Kind.ssh, .sftp, .telnet, .mosh, .eternalTerminal]) + func disabledProtocolKeepsItsRecordUntilExplicitlyClosed(kind: ConnectionHistoryEntry.Kind) throws { + let name = "DisabledWorkspaceProtocol.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let original = try MobileAppModel(dependencies: .testing(defaults: defaults)) + defer { for workspace in original.workspaces { workspace.disconnect() } } + var host = Host(name: "Server", host: "example.invalid", telnetEnabled: true) + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + original.library.upsert(host) + let id = try #require(original.testWorkspaceSession.open(hostID: host.id, kind: kind)) + switch kind { + case .ssh, .sftp: host.sshEnabled = false + case .telnet: host.telnetEnabled = false + case .mosh: host.terminalTransports.moshEnabled = false + case .eternalTerminal: host.terminalTransports.eternalTerminalEnabled = false + } + #expect(original.library.saveHost(host)) + original.persistWorkspaceDescriptors() + + var dependencies = MobileAppDependencies.testing(defaults: defaults) + dependencies.container = original.container + let restored = try MobileAppModel(dependencies: dependencies) + #expect(restored.testWorkspaceSession.savedWorkspaceDescriptors.map(\.id) == [id]) + #expect(restored.testWorkspaceSession.openWorkspace(id) == nil) + restored.persistWorkspaceDescriptors() + #expect(try MobileWorkspaceStore(defaults: defaults).load().sessions["test"]?.map(\.id) == [id]) + restored.testWorkspaceSession.close(id) + #expect(try MobileWorkspaceStore(defaults: defaults).load().sessions["test"]?.isEmpty == true) + } + + @Test func plainSSHAndExternalTemporaryConnectionKeepDifferentPersistencePolicies() throws { + let name = "PlainWorkspaceRestoration.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let original = try MobileAppModel(dependencies: .testing(defaults: defaults)) + defer { for workspace in original.workspaces { workspace.disconnect() } } + let host = Host(name: "Server", host: "example.invalid") + original.library.upsert(host) + let id = try #require(original.testWorkspaceSession.open(hostID: host.id, kind: .ssh)) + let temporary = try RemoteConnectionURLParser.parse( + #require(URL(string: "ssh://temporary.example.invalid"))) + let temporaryID = try #require(original.testWorkspaceSession.openExternalConnection(temporary)) + #expect(original.containsWorkspace(temporaryID)) + + var dependencies = MobileAppDependencies.testing(defaults: defaults) + dependencies.container = original.container + let restored = try MobileAppModel(dependencies: dependencies) + defer { for workspace in restored.workspaces { workspace.disconnect() } } + let scene = MobileSceneModel(app: restored, sessionID: "test") + #expect(scene.workspaceSession.savedWorkspaceDescriptors.map(\.id) == [id]) + scene.open(WorkspaceActivityAttributes.destinationURL(for: id)) + scene.open(WorkspaceActivityAttributes.destinationURL(for: id)) + let workspace = try #require(scene.activeWorkspace) + #expect(workspace.id == id) + #expect(!scene.isWorkspaceUnavailable) + #expect(!workspace.sessionPersistence.requiresSelection) + #expect(workspace.sessionPersistence.prompt == nil) + #expect(restored.workspaces.count == 1) + scene.workspaceSession.close(id) + #expect(scene.workspaceSession.reopenLastClosed() == id) + let reopened = try #require(restored.workspaces.first) + #expect(!reopened.sessionPersistence.requiresSelection) + #expect(reopened.pendingInitialCommand == nil) + } + + @Test func attachmentChangesAreSavedWithoutRequiringTheHostPersistenceSwitch() throws { + let name = "WorkspaceReferenceUpdates.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let app = try MobileAppModel(dependencies: .testing(defaults: defaults)) + defer { for workspace in app.workspaces { workspace.disconnect() } } + let host = Host(name: "Server", host: "example.invalid") + app.library.upsert(host) + let id = try #require(app.testWorkspaceSession.open(hostID: host.id, kind: .ssh)) + let workspace = try #require(app.workspaces.first) + let reference = RemoteSessionReference( + backend: .tmux, session: .init(id: "$1", name: "build", identity: Data("identity".utf8)), + hostID: host.id, endpoint: "example.invalid:22", username: "user", ownershipToken: nil) + workspace.restoreRemoteSession(reference) + let store = MobileWorkspaceStore(defaults: defaults) + #expect(try store.load().sessions["test"]?.first?.remoteSession == reference) + workspace.restoreRemoteSession(nil) + #expect(try store.load().sessions["test"]?.first?.remoteSession == nil) + #expect(try store.load().sessions["test"]?.map(\.id) == [id]) + } + + @Test(arguments: [false, true]) + func undiscoveredHostKeepsAClosableRecordAndCanOpenAfterDiscovery(rediscover: Bool) throws { + let name = "UndiscoveredWorkspaceHost.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let host = Host(name: "Server", host: "example.invalid") + let record = MobileWorkspaceDescriptor(hostID: host.id, title: "Saved shell", kind: .ssh) + let store = MobileWorkspaceStore(defaults: defaults) + try store.save(.init(sessions: ["test": [record]])) + let app = try MobileAppModel(dependencies: .testing(defaults: defaults)) + let session = app.testWorkspaceSession + #expect(session.openWorkspace(record.id) == nil) + #expect(session.orderedWorkspaceItems.map(\.id) == [record.id]) + app.persistWorkspaceDescriptors() + #expect(try store.load().sessions["test"] == [record]) + if rediscover { + app.library.upsert(host) + let opened = try #require(session.openWorkspace(record.id)) + #expect(opened.id == record.id) + #expect(!opened.sessionPersistence.requiresSelection) + } + session.close(record.id) + #expect(try store.load().sessions["test"]?.isEmpty == true) + } + + @Test func coldLaunchDoesNotPersistOrReplayOneShotCommands() throws { + let name = "OneShotWorkspaceRestoration.\(UUID())" + let defaults = try #require(UserDefaults(suiteName: name)) + defer { defaults.removePersistentDomain(forName: name) } + let original = try MobileAppModel(dependencies: .testing(defaults: defaults)) + defer { for workspace in original.workspaces { workspace.disconnect() } } + let host = Host(name: "Server", host: "example.invalid") + original.library.upsert(host) + let command = "echo one-shot-marker" + let id = try #require( + original.testWorkspaceSession.execute(Snippet(title: "One shot", body: command), on: [host.id]).first) + #expect(original.workspaces.first?.pendingInitialCommand == command) + let data = try #require(defaults.data(forKey: MobileWorkspaceStore.restorationKey)) + #expect(!String(decoding: data, as: UTF8.self).contains(command)) + var dependencies = MobileAppDependencies.testing(defaults: defaults) + dependencies.container = original.container + let restored = try MobileAppModel(dependencies: dependencies) + defer { for workspace in restored.workspaces { workspace.disconnect() } } + let opened = try #require(restored.testWorkspaceSession.openWorkspace(id)) + #expect(opened.pendingInitialCommand == nil) + #expect(opened.id == id) + } +} diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index bf0de68b..19de458d 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -777,20 +777,118 @@ struct MobileWorkspaceSessionTests { #expect(app.connectionHistory.entries().isEmpty) } - @Test func coldLaunchDoesNotClaimOldNativeSceneWorkspaces() throws { + @Test( + arguments: [false, true], + [ + ConnectionHistoryEntry.Kind.ssh, .sftp, .telnet, .mosh, .eternalTerminal, + ]) + func coldLaunchKeepsRecordsWhenOldNativeWindowIsDiscarded( + afterHandoff: Bool, kind: ConnectionHistoryEntry.Kind + ) throws { let fixture = try Fixture() defer { fixture.cleanUp() } let original = try fixture.makeApp() - let id = try #require(original.workspaceSession(for: "old").open(hostID: fixture.host.id, kind: .ssh)) + var host = fixture.host + host.telnetEnabled = true + host.terminalTransports.moshEnabled = true + host.terminalTransports.eternalTerminalEnabled = true + original.library.upsert(host) + let id = try #require(original.workspaceSession(for: "old").open(hostID: host.id, kind: kind)) let app = try fixture.makeApp() - app.discardWorkspaceSessions(["old"]) + if !afterHandoff { app.discardWorkspaceSessions(["old"]) } let scene = MobileSceneModel(app: app, sessionID: "new") - scene.open(WorkspaceActivityAttributes.destinationURL(for: id)) - #expect(scene.isWorkspaceUnavailable) + app.reconcileWorkspaceSessions(openSessionIDs: ["new"], activeSessionID: "new") + if afterHandoff { app.discardWorkspaceSessions(["old"]) } + #expect(scene.workspaceSession.savedWorkspaceDescriptors.map(\.id) == [id]) #expect(app.workspaces.isEmpty) + #expect(try MobileWorkspaceStore(defaults: fixture.defaults).load().sessions["new"]?.map(\.id) == [id]) + scene.open(WorkspaceActivityAttributes.destinationURL(for: id)) + scene.activatePendingWorkspace() + #expect(!scene.isWorkspaceUnavailable) + #expect(scene.activeWorkspace?.id == id) + #expect(scene.activeWorkspace?.sessionPersistence.requiresSelection == false) #expect(app.closedWorkspaceRecords.isEmpty) } + @Test func coldLaunchKeepsExistingNativeOwnersUntilTheyBindAndExplicitlyClose() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let first = MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Shell", kind: .ssh) + let second = MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Files", kind: .sftp) + try MobileWorkspaceStore(defaults: fixture.defaults).save(.init(sessions: ["a": [first], "b": [second]])) + let app = try fixture.makeApp() + let active = MobileSceneModel(app: app, sessionID: "a") + app.reconcileWorkspaceSessions(openSessionIDs: ["a", "b"], activeSessionID: "a") + var activated: [String] = [] + app.activateWorkspaceSession = { owner, _, _ in activated.append(owner) } + active.open(WorkspaceActivityAttributes.destinationURL(for: second.id)) + #expect(activated == ["b"]) + #expect(app.workspaceOwner(first.id) == "a") + #expect(app.workspaceOwner(second.id) == "b") + #expect(app.workspaces.isEmpty) + let bound = MobileSceneModel(app: app, sessionID: "b") + app.discardWorkspaceSessions(["b"]) + #expect(bound.workspaceSession.isDiscarded) + #expect(!app.containsWorkspace(second.id)) + #expect(app.containsWorkspace(first.id)) + #expect(try MobileWorkspaceStore(defaults: fixture.defaults).load().sessions["b"] == nil) + #expect(app.closedWorkspaceRecords.first?.items == [second]) + } + + @Test func orphanedColdWindowsTransferAsOneCompleteSnapshotAndIgnoreLateDiscards() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let records = (0..<4).map { + MobileWorkspaceDescriptor(hostID: fixture.host.id, title: "Workspace \($0)", kind: .ssh) + } + let store = MobileWorkspaceStore(defaults: fixture.defaults) + try store.save( + .init(sessions: ["active": [records[0]], "old-a": [records[1]], "old-b": [records[2], records[3]]])) + let app = try fixture.makeApp() + app.discardWorkspaceSessions(["old-a"]) + app.reconcileWorkspaceSessions(openSessionIDs: [], activeSessionID: nil) + #expect(app.workspaces.isEmpty) + #expect(app.workspaceOwner(records[1].id) == "old-a") + let active = MobileSceneModel(app: app, sessionID: "active") + // UIKit can briefly keep a discarded cold session in its openSessions set. + app.reconcileWorkspaceSessions(openSessionIDs: ["active", "old-a"], activeSessionID: "active") + #expect(active.workspaceSession.savedWorkspaceDescriptors == records) + #expect(try store.load().sessions["active"] == records) + #expect(try store.load().sessions.values.flatMap { $0 }.count == records.count) + app.discardWorkspaceSessions(["old-a", "old-b"]) + app.persistWorkspaceDescriptors() + #expect(active.workspaceSession.savedWorkspaceDescriptors == records) + #expect(try store.load().sessions["active"] == records) + #expect(app.closedWorkspaceRecords.isEmpty) + } + + @Test func failedHandoffKeepsTheSourceRuntimeAndOwnership() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let damaged = Data("unreadable".utf8) + fixture.defaults.set(damaged, forKey: MobileWorkspaceStore.restorationKey) + let app = try fixture.makeApp() + let first = MobileSceneModel(app: app, sessionID: "a") + let second = MobileSceneModel(app: app, sessionID: "b") + let third = MobileSceneModel(app: app, sessionID: "c") + first.open(hostID: fixture.host.id, kind: .ssh) + third.open(hostID: fixture.host.id, kind: .sftp) + first.activatePendingWorkspace() + third.activatePendingWorkspace() + let workspace = try #require(first.activeWorkspace) + let other = try #require(third.activeWorkspace) + + app.reconcileWorkspaceSessions(openSessionIDs: ["b"], activeSessionID: "b") + + #expect(first.activeWorkspace === workspace) + #expect(app.workspaceOwner(workspace.id) == "a") + #expect(third.activeWorkspace === other) + #expect(app.workspaceOwner(other.id) == "c") + #expect(second.workspaceSession.orderedWorkspaceItems.isEmpty) + #expect(fixture.defaults.data(forKey: MobileWorkspaceStore.restorationKey) == damaged) + #expect(app.error != nil) + } + @Test func missingNativeSessionHandsOffLiveWorkspacesWithoutReconnecting() throws { let fixture = try Fixture() defer { fixture.cleanUp() } diff --git a/TFITests/MobileWorkspaceStoreTests.swift b/TFITests/MobileWorkspaceStoreTests.swift index 4df083e5..9f842afa 100644 --- a/TFITests/MobileWorkspaceStoreTests.swift +++ b/TFITests/MobileWorkspaceStoreTests.swift @@ -27,9 +27,12 @@ struct MobileWorkspaceStoreTests { id: id, title: "Saved", kind: "ssh", connected: false, recovery: .init(sourceWorkspaceID: id, hostID: UUID(), kind: "ssh", remoteSession: reference)) #expect(!session.destinationURL.absoluteString.contains("private")) + #expect(session.destinationURL.query == nil) #expect(WorkspaceActivityAttributes.recovery(from: session.destinationURL)?.remoteSession == nil) #expect(WorkspaceActivityAttributes.workspaceID(from: session.destinationURL) == id) - let injected = try #require(URL(string: session.destinationURL.absoluteString + "&command=whoami")) + let legacy = "termind://workspace/\(id)?v=1&host=\(UUID())&kind=ssh" + #expect(WorkspaceActivityAttributes.workspaceID(from: try #require(URL(string: legacy))) == id) + let injected = try #require(URL(string: legacy + "&command=whoami")) #expect(WorkspaceActivityAttributes.workspaceID(from: injected) == nil) } diff --git a/TFIWidgets/WorkspaceLiveActivity.swift b/TFIWidgets/WorkspaceLiveActivity.swift index 318a4acc..31f6d0dc 100644 --- a/TFIWidgets/WorkspaceLiveActivity.swift +++ b/TFIWidgets/WorkspaceLiveActivity.swift @@ -14,7 +14,9 @@ struct WorkspaceLiveActivity: Widget { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { appIcon(size: 28) - status(context) + workspaceCount(context.state.workspaceCount) + .font(.caption) + .foregroundStyle(.secondary) .lineLimit(1) .minimumScaleFactor(0.8) Spacer(minLength: 0) @@ -102,20 +104,6 @@ struct WorkspaceLiveActivity: Widget { } private func workspaceCount(_ count: Int) -> Text { - switch count { - case 1: Text("One workspace") - case 2: Text("Two workspaces") - default: Text("\(count) workspaces") - } - } - - private func status(_ context: ActivityViewContext) -> some View { - Group { - if context.isStale { - Text("Open to Check Connections") - } else { - Text("\(context.state.connectedCount) connected · \(context.state.workspaceCount) workspaces") - } - }.font(.caption).foregroundStyle(.secondary) + Text(AppStrings.Workspace.count(count)) } } diff --git a/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift b/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift index 752491e9..b9f0e060 100644 --- a/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift +++ b/WorkspaceActivitySupport/WorkspaceActivityAttributes.swift @@ -22,15 +22,7 @@ nonisolated struct WorkspaceActivityAttributes: ActivityAttributes { var recovery: Recovery? = nil var destinationURL: URL { - guard let recovery else { return WorkspaceActivityAttributes.destinationURL(for: id) } - var components = URLComponents( - url: WorkspaceActivityAttributes.destinationURL(for: id), resolvingAgainstBaseURL: false)! - components.queryItems = [ - URLQueryItem(name: "v", value: "1"), - URLQueryItem(name: "host", value: recovery.hostID.uuidString), - URLQueryItem(name: "kind", value: recovery.kind), - ] - return components.url! + WorkspaceActivityAttributes.destinationURL(for: id) } }