fix(workspace): restore unified workspace persistence
This commit is contained in:
1 parent
a04db5f867
commit
8c3db48815
16 files changed
+521
-311
No files matched your search
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user