fix(workspace): restore unified workspace persistence

This commit is contained in:
Kaede Akino committed 2026-10-17 15:19:17 +08:00
1 parent a04db5f867
commit 8c3db48815
16 files changed
+521 -311

No files matched your search

-131
View File
@@ -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" : {
@@ -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."
}
+73 -43
View File
@@ -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<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> = []
private var activityRebuilds: [UUID: UUID] = [:]
var closedWorkspaceStates: [UUID: [MobileWorkspace.ReopenState]] = [:]
var pendingClosedWindows: Set<UUID> = []
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<String>, 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<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)
@@ -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()
}
}
+2 -3
View File
@@ -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
+13 -38
View File
@@ -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<UUID>, endingRemoteSessions: Set<UUID> = []) {
@@ -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()
}
@@ -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"
@@ -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<UUID> = []
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
@@ -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)))
}
+15 -15
View File
@@ -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 {
+105 -12
View File
@@ -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)
@@ -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()
}
@@ -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)
}
}
+103 -5
View File
@@ -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() }
+4 -1
View File
@@ -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)
}
+4 -16
View File
@@ -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<WorkspaceActivityAttributes>) -> 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))
}
}
@@ -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)
}
}