fix: unavaialbe workspace in live activity
This commit is contained in:
1 parent
61b1092b08
commit
cbda3f0712
9 files changed
+253
-224
No files matched your search
@@ -18345,47 +18345,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Connections Preferred (Pro)" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Verbindungen bevorzugen (Pro)"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Privilégier les connexions (Pro)"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "接続を優先(Pro)"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "연결 우선 (Pro)"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "优先连接(Pro)"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "優先連線(Pro)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Contacting the App Store…" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -31604,43 +31563,43 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Free includes up to 3 monitoring cards. Upgrade to Pro for more." : {
|
||||
"Free includes up to 3 hosts. Upgrade to Pro to unlock this host." : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Die kostenlose Version enthält bis zu 3 Überwachungskarten. Mit Pro können Sie weitere hinzufügen."
|
||||
"value" : "Die kostenlose Version unterstützt bis zu 3 Hosts. Führe ein Upgrade auf Pro durch, um diesen Host freizuschalten."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "La version gratuite inclut jusqu’à 3 cartes de surveillance. Passez à Pro pour en ajouter davantage."
|
||||
"value" : "La version gratuite prend en charge jusqu’à 3 hôtes. Passez à Pro pour déverrouiller cet hôte."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "無料版では監視カードを3枚まで追加できます。さらに追加するにはProにアップグレードしてください。"
|
||||
"value" : "無料版ではホストを3台まで利用できます。Proにアップグレードして、このホストのロックを解除してください。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "무료 버전에는 모니터링 카드가 최대 3개까지 포함됩니다. 더 추가하려면 Pro로 업그레이드하세요."
|
||||
"value" : "무료 버전은 호스트를 최대 3대까지 지원합니다. Pro로 업그레이드하여 이 호스트의 잠금을 해제하세요."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "免费版最多可添加3张监控卡片,升级Pro可添加更多。"
|
||||
"value" : "免费版最多支持3台主机。升级Pro以解锁此主机。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "免費版最多可新增3張監控卡片,升級Pro可新增更多。"
|
||||
"value" : "免費版最多支援3台主機。升級Pro以解鎖此主機。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40160,47 +40119,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Max Connections (Pro)" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Max. Verbindungen (Pro)"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Connexions max. (Pro)"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "最大接続数(Pro)"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "최대 연결 수 (Pro)"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "最大连接数(Pro)"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "連線數上限(Pro)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Max Output" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -65853,47 +65771,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Smart (Pro)" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Intelligent (Pro)"
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Intelligent (Pro)"
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "スマート(Pro)"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "스마트 (Pro)"
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "智能(Pro)"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "智慧(Pro)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Smart uses channels for files up to 64 MB and prefers additional connections for larger files. Transfers with an unknown size use channels." : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
@@ -82359,47 +82236,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Upgrade to Pro to unlock this host." : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Führe ein Upgrade auf Pro durch, um diesen Host freizuschalten."
|
||||
}
|
||||
},
|
||||
"fr" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Passez à Pro pour déverrouiller cet hôte."
|
||||
}
|
||||
},
|
||||
"ja" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Proにアップグレードしてこのホストをロック解除。"
|
||||
}
|
||||
},
|
||||
"ko" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Pro로 업그레이드하여 이 호스트의 잠금을 해제하세요."
|
||||
}
|
||||
},
|
||||
"zh-Hans" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "升级到Pro以解锁此主机。"
|
||||
}
|
||||
},
|
||||
"zh-Hant" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "升級至Pro以解鎖此主機。"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Upload" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
|
||||
@@ -15,10 +15,10 @@ public struct SFTPSettingsFields: View {
|
||||
}
|
||||
public var body: some View {
|
||||
Picker(AppStrings.SFTP.concurrentStrategy, selection: access.strategy(for: model)) {
|
||||
Text(access.isAllowed ? AppStrings.SFTP.smart : AppStrings.Pro.smartStrategy)
|
||||
Text(AppStrings.SFTP.smart)
|
||||
.tag(SFTPConcurrencyStrategy.smart)
|
||||
Text(AppStrings.SFTP.channelsOnly).tag(SFTPConcurrencyStrategy.channelsOnly)
|
||||
Text(access.isAllowed ? AppStrings.SFTP.connectionsPreferred : AppStrings.Pro.connectionsPreferredStrategy)
|
||||
Text(AppStrings.SFTP.connectionsPreferred)
|
||||
.tag(SFTPConcurrencyStrategy.connectionsPreferred)
|
||||
}
|
||||
if access.strategy(for: model).wrappedValue == .smart {
|
||||
|
||||
@@ -2,10 +2,6 @@ import Foundation
|
||||
|
||||
extension AppStrings {
|
||||
public nonisolated enum Pro {
|
||||
public static var smartStrategy: LocalizedStringResource { "Smart (Pro)" }
|
||||
public static var connectionsPreferredStrategy: LocalizedStringResource { "Connections Preferred (Pro)" }
|
||||
public static var maxConnections: LocalizedStringResource { "Max Connections (Pro)" }
|
||||
|
||||
public static var contactingTheAppStoreProgress: LocalizedStringResource {
|
||||
"Contacting the App Store…"
|
||||
}
|
||||
@@ -14,7 +10,9 @@ extension AppStrings {
|
||||
"Termind Pro is not available from the App Store right now."
|
||||
}
|
||||
|
||||
public static var upgradeToUnlockHost: LocalizedStringResource { "Upgrade to Pro to unlock this host." }
|
||||
public static var upgradeToUnlockHost: LocalizedStringResource {
|
||||
"Free includes up to 3 hosts. Upgrade to Pro to unlock this host."
|
||||
}
|
||||
public static var verifyingAccess: LocalizedStringResource { "Checking Pro access…" }
|
||||
public static var accessVerificationFailed: LocalizedStringResource {
|
||||
"Unable to verify Pro access. Please try again."
|
||||
|
||||
@@ -507,7 +507,7 @@ final class MobileAppModel {
|
||||
updateWorkspaceActivity()
|
||||
}
|
||||
|
||||
private func updateWorkspaceActivity() {
|
||||
func updateWorkspaceActivity() {
|
||||
// A failed read is not an authoritative empty list.
|
||||
guard hasLoadedWorkspaceSnapshot else { return }
|
||||
liveActivities.update(workspaceActivitySessions)
|
||||
|
||||
@@ -448,6 +448,7 @@ final class MobileSceneModel {
|
||||
return
|
||||
}
|
||||
if WorkspaceActivityAttributes.accepts(url) {
|
||||
app.updateWorkspaceActivity()
|
||||
isWorkspaceUnavailable = false
|
||||
pendingWorkspaceID = nil
|
||||
selectedDestination = .workspaces
|
||||
@@ -463,6 +464,8 @@ final class MobileSceneModel {
|
||||
}
|
||||
return
|
||||
}
|
||||
// Even an expired link must reconcile system cards against the authoritative local list.
|
||||
app.updateWorkspaceActivity()
|
||||
isWorkspaceUnavailable = false
|
||||
let id = sourceID
|
||||
guard let owner = app.workspaceOwner(id) else {
|
||||
|
||||
@@ -12,20 +12,14 @@ final class MobileLiveActivityController {
|
||||
private let services: MobileLiveActivityServices
|
||||
private var focusedWorkspaceID: UUID?
|
||||
private var pendingStart = false
|
||||
private var pendingState: WorkspaceActivityAttributes.ContentState?
|
||||
|
||||
init(enabled: Bool = true, services: MobileLiveActivityServices = .live) {
|
||||
self.enabled = enabled
|
||||
self.services = services
|
||||
if enabled {
|
||||
let restored = services.activities()
|
||||
activity = restored.first { $0.state == .active || $0.state == .stale }
|
||||
activity = services.activities().first { $0.state == .active || $0.state == .stale }
|
||||
focusedWorkspaceID = activity?.focusedWorkspaceID
|
||||
let retainedID = activity?.id
|
||||
updates = Task {
|
||||
for duplicate in restored where duplicate.id != retainedID {
|
||||
await duplicate.end()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,38 +29,61 @@ final class MobileLiveActivityController {
|
||||
update(sessions)
|
||||
}
|
||||
|
||||
/// All starts, updates and ends share a serial queue, including close-last/open-new races.
|
||||
/// Serialize system calls, but coalesce queued changes so closed workspaces are never recreated.
|
||||
func start(_ sessions: [WorkspaceActivityAttributes.Session]) { schedule(sessions, mayStart: true) }
|
||||
func update(_ sessions: [WorkspaceActivityAttributes.Session]) { schedule(sessions, mayStart: false) }
|
||||
|
||||
private func schedule(_ sessions: [WorkspaceActivityAttributes.Session], mayStart: Bool) {
|
||||
guard enabled else { return }
|
||||
let state = WorkspaceActivityAttributes.ContentState.snapshot(
|
||||
pendingState = WorkspaceActivityAttributes.ContentState.snapshot(
|
||||
sessions, focusedID: focusedWorkspaceID)
|
||||
let previous = updates
|
||||
if sessions.isEmpty {
|
||||
pendingStart = false
|
||||
} else if mayStart {
|
||||
pendingStart = true
|
||||
}
|
||||
guard updates == nil else { return }
|
||||
updates = Task { [weak self] in
|
||||
await previous?.value
|
||||
guard let self else { return }
|
||||
if mayStart { pendingStart = true }
|
||||
guard state.workspaceCount > 0 else {
|
||||
pendingStart = false
|
||||
if let activity { await activity.end() }
|
||||
activity = nil
|
||||
return
|
||||
while let state = pendingState {
|
||||
pendingState = nil
|
||||
await reconcile(state)
|
||||
}
|
||||
if let activity, activity.state == .active || activity.state == .stale {
|
||||
pendingStart = false
|
||||
updates = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func reconcile(_ state: WorkspaceActivityAttributes.ContentState) async {
|
||||
// ActivityKit can expose old cards after startup. The cached handle is not the full inventory.
|
||||
var activities = services.activities()
|
||||
if let activity, !activities.contains(where: { $0.id == activity.id }) { activities.append(activity) }
|
||||
let usable = activities.filter { $0.state == .active || $0.state == .stale }
|
||||
let retained =
|
||||
state.workspaceCount > 0
|
||||
? usable.first(where: { $0.id == activity?.id }) ?? usable.first : nil
|
||||
activity = retained
|
||||
if retained != nil { pendingStart = false }
|
||||
for other in activities where other.id != retained?.id { await other.end() }
|
||||
|
||||
// An open/close may have arrived while dismissal was suspended. Publish only the newest list.
|
||||
guard pendingState == nil, state.workspaceCount > 0 else { return }
|
||||
if let activity {
|
||||
if activity.state == .active || activity.state == .stale {
|
||||
await activity.update(state)
|
||||
return
|
||||
}
|
||||
// An explicit open may arrive before activation. Retain its intent until a foreground update.
|
||||
// Once consumed, ordinary updates cannot resurrect a user-dismissed activity.
|
||||
guard pendingStart, services.canStart() else { return }
|
||||
pendingStart = false
|
||||
do {
|
||||
activity = try services.request(state)
|
||||
error = nil
|
||||
} catch { self.error = error.localizedDescription }
|
||||
await activity.end()
|
||||
self.activity = nil
|
||||
}
|
||||
// An explicit open may arrive before activation. Retain its intent until a foreground update.
|
||||
// Once consumed, ordinary updates cannot resurrect a user-dismissed activity.
|
||||
guard pendingState == nil, pendingStart, services.canStart() else { return }
|
||||
pendingStart = false
|
||||
do {
|
||||
activity = try services.request(state)
|
||||
error = nil
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,57 @@ struct MobileLiveActivityRestorationTests {
|
||||
#expect(await eventually { existing.ended })
|
||||
}
|
||||
|
||||
@Test func refreshAdoptsLateSystemActivityAndDismissesDuplicates() async {
|
||||
let backend = Backend()
|
||||
let controller = MobileLiveActivityController(services: backend.services)
|
||||
let current = session()
|
||||
controller.update([current])
|
||||
for _ in 0..<20 { await Task.yield() }
|
||||
let late = TestActivity(state: .stale)
|
||||
let duplicate = TestActivity()
|
||||
let ended = TestActivity(state: .ended)
|
||||
backend.existing = [late, duplicate, ended]
|
||||
|
||||
controller.update([current])
|
||||
|
||||
#expect(await eventually { late.updates.last?.sessions.map(\.id) == [current.id] })
|
||||
#expect(duplicate.ended && ended.ended)
|
||||
#expect(!late.ended)
|
||||
#expect(backend.requests.isEmpty)
|
||||
}
|
||||
|
||||
@Test func emptySnapshotDismissesEveryLateSystemActivity() async {
|
||||
let backend = Backend()
|
||||
let controller = MobileLiveActivityController(services: backend.services)
|
||||
controller.update([])
|
||||
for _ in 0..<20 { await Task.yield() }
|
||||
let late = TestActivity()
|
||||
let ended = TestActivity(state: .ended)
|
||||
backend.existing = [late, ended]
|
||||
|
||||
controller.update([])
|
||||
|
||||
#expect(await eventually { late.ended && ended.ended })
|
||||
#expect(backend.requests.isEmpty)
|
||||
}
|
||||
|
||||
@Test func explicitOpenDismissesEndedCardBeforeReplacingIt() async {
|
||||
let backend = Backend()
|
||||
let existing = TestActivity()
|
||||
backend.existing = [existing]
|
||||
let controller = MobileLiveActivityController(services: backend.services)
|
||||
controller.update([session()])
|
||||
#expect(await eventually { existing.updates.count == 1 })
|
||||
existing.state = .ended
|
||||
let next = session()
|
||||
|
||||
controller.start([next])
|
||||
|
||||
#expect(await eventually { backend.requests.count == 1 })
|
||||
#expect(existing.ended)
|
||||
#expect(backend.requests.first?.sessions.map(\.id) == [next.id])
|
||||
}
|
||||
|
||||
@Test func closeLastThenOpenWaitsForOldActivityToFinishEnding() async {
|
||||
let backend = Backend()
|
||||
let existing = TestActivity()
|
||||
@@ -71,21 +122,143 @@ struct MobileLiveActivityRestorationTests {
|
||||
backend.existing = [existing]
|
||||
let controller = MobileLiveActivityController(services: backend.services)
|
||||
controller.update([])
|
||||
#expect(await eventually { existing.endContinuation != nil })
|
||||
let next = session()
|
||||
controller.start([next])
|
||||
controller.focus(next.id, in: [next])
|
||||
|
||||
#expect(await eventually { existing.endContinuation != nil })
|
||||
#expect(backend.requests.isEmpty)
|
||||
existing.endContinuation?.resume()
|
||||
existing.endContinuation = nil
|
||||
#expect(await eventually { backend.created.first?.updates.count == 1 })
|
||||
#expect(await eventually { backend.requests.count == 1 })
|
||||
#expect(existing.ended)
|
||||
#expect(backend.requests.count == 1)
|
||||
#expect(backend.created.first?.updates.first?.focusedWorkspaceID == next.id)
|
||||
#expect(backend.requests.first?.focusedWorkspaceID == next.id)
|
||||
#expect(backend.created.first?.ended == false)
|
||||
}
|
||||
|
||||
@Test func rapidOpenCloseWhileEndingOnlyPublishesTheLatestWorkspace() async {
|
||||
let backend = Backend()
|
||||
let existing = TestActivity()
|
||||
existing.suspendsEnd = true
|
||||
backend.existing = [existing]
|
||||
let controller = MobileLiveActivityController(services: backend.services)
|
||||
controller.update([])
|
||||
#expect(await eventually { existing.endContinuation != nil })
|
||||
for _ in 0..<5 {
|
||||
controller.start([session()])
|
||||
controller.update([])
|
||||
}
|
||||
let latest = session()
|
||||
controller.start([latest])
|
||||
existing.endContinuation?.resume()
|
||||
existing.endContinuation = nil
|
||||
|
||||
#expect(await eventually { backend.requests.last?.sessions.map(\.id) == [latest.id] })
|
||||
#expect(backend.requests.count == 1)
|
||||
#expect(backend.created.filter { !$0.ended }.count == 1)
|
||||
}
|
||||
|
||||
@Test func closeDuringDuplicateCleanupDoesNotPublishTheObsoleteOpen() async {
|
||||
let backend = Backend()
|
||||
let existing = TestActivity()
|
||||
let duplicate = TestActivity()
|
||||
duplicate.suspendsEnd = true
|
||||
backend.existing = [existing, duplicate]
|
||||
let controller = MobileLiveActivityController(services: backend.services)
|
||||
controller.start([session()])
|
||||
#expect(await eventually { duplicate.endContinuation != nil })
|
||||
|
||||
controller.update([])
|
||||
duplicate.endContinuation?.resume()
|
||||
duplicate.endContinuation = nil
|
||||
|
||||
#expect(await eventually { existing.ended && duplicate.ended })
|
||||
#expect(existing.updates.isEmpty)
|
||||
#expect(backend.requests.isEmpty)
|
||||
}
|
||||
|
||||
@Test func dismissingRetainedCardDuringCleanupDoesNotReplayItsStartIntent() async {
|
||||
let backend = Backend()
|
||||
let existing = TestActivity()
|
||||
let duplicate = TestActivity()
|
||||
duplicate.suspendsEnd = true
|
||||
backend.existing = [existing, duplicate]
|
||||
let controller = MobileLiveActivityController(services: backend.services)
|
||||
let current = session()
|
||||
controller.start([current])
|
||||
#expect(await eventually { duplicate.endContinuation != nil })
|
||||
|
||||
existing.state = .dismissed
|
||||
controller.update([current])
|
||||
duplicate.endContinuation?.resume()
|
||||
duplicate.endContinuation = nil
|
||||
|
||||
#expect(await eventually { existing.ended && duplicate.ended })
|
||||
#expect(existing.updates.isEmpty)
|
||||
#expect(backend.requests.isEmpty)
|
||||
}
|
||||
|
||||
@Test func staleLinkAfterRepeatedOpenCloseCleansLateCardWithoutRebuilding() async throws {
|
||||
let backend = Backend()
|
||||
var dependencies = MobileAppDependencies.testing()
|
||||
dependencies.liveActivities = MobileLiveActivityController(services: backend.services)
|
||||
let app = try MobileAppModel(dependencies: dependencies)
|
||||
let host = TermindDomain.Host(name: "Server", host: "example.invalid")
|
||||
app.library.upsert(host)
|
||||
let scene = MobileSceneModel(app: app, sessionID: "window")
|
||||
var closedID: UUID?
|
||||
for index in 1...3 {
|
||||
let id = try #require(scene.workspaceSession.open(hostID: host.id, kind: .ssh))
|
||||
closedID = id
|
||||
#expect(await eventually { backend.requests.count == index })
|
||||
scene.closeAllWorkspaces()
|
||||
#expect(await eventually { backend.created.last?.ended == true })
|
||||
}
|
||||
let id = try #require(closedID)
|
||||
let late = TestActivity(focusedWorkspaceID: id)
|
||||
late.contentState = .snapshot(
|
||||
[.init(id: id, title: "Closed", kind: "ssh", connected: false)], focusedID: id)
|
||||
backend.existing.append(late)
|
||||
|
||||
scene.open(WorkspaceActivityAttributes.destinationURL(for: id))
|
||||
|
||||
#expect(scene.isWorkspaceUnavailable)
|
||||
#expect(await eventually { late.ended })
|
||||
#expect(!app.containsWorkspace(id))
|
||||
#expect(app.workspaces.isEmpty)
|
||||
#expect(backend.requests.count == 3)
|
||||
}
|
||||
|
||||
@Test func staleLinkKeepsUnrelatedSavedWorkspaceAndDoesNotReconnectIt() async throws {
|
||||
let name = "ActivityStaleLink.\(UUID())"
|
||||
let defaults = try #require(UserDefaults(suiteName: name))
|
||||
defer { defaults.removePersistentDomain(forName: name) }
|
||||
let descriptor = MobileWorkspaceDescriptor(hostID: UUID(), title: "Unavailable Host", kind: .ssh)
|
||||
try MobileWorkspaceStore(defaults: defaults).save(.init(sessions: ["window": [descriptor]]))
|
||||
let backend = Backend()
|
||||
var dependencies = MobileAppDependencies.testing(defaults: defaults)
|
||||
dependencies.liveActivities = MobileLiveActivityController(services: backend.services)
|
||||
let app = try MobileAppModel(dependencies: dependencies)
|
||||
let scene = MobileSceneModel(app: app, sessionID: "window")
|
||||
for _ in 0..<20 { await Task.yield() }
|
||||
let stale = session()
|
||||
let late = TestActivity(focusedWorkspaceID: stale.id)
|
||||
late.contentState = .snapshot([stale], focusedID: stale.id)
|
||||
backend.existing.append(late)
|
||||
|
||||
scene.open(stale.destinationURL)
|
||||
|
||||
#expect(scene.isWorkspaceUnavailable)
|
||||
#expect(await eventually { late.updates.last?.sessions.map(\.id) == [descriptor.id] })
|
||||
#expect(!late.ended)
|
||||
#expect(scene.workspaceSession.savedWorkspaceDescriptors == [descriptor])
|
||||
#expect(app.workspaces.isEmpty)
|
||||
#expect(backend.requests.isEmpty)
|
||||
scene.closeAllWorkspaces()
|
||||
#expect(await eventually { late.ended })
|
||||
}
|
||||
|
||||
@Test(arguments: [false, true])
|
||||
func coldLaunchPublishesSavedRecordsAndCanCloseWithoutConnecting(openBeforeClose: Bool) async throws {
|
||||
let name = "ActivityRestoration.\(UUID())"
|
||||
@@ -153,16 +326,19 @@ struct MobileLiveActivityRestorationTests {
|
||||
let backend = Backend()
|
||||
let existing = TestActivity()
|
||||
existing.contentState = .snapshot([session()], focusedID: nil)
|
||||
backend.existing = [existing]
|
||||
let duplicate = TestActivity()
|
||||
backend.existing = [existing, duplicate]
|
||||
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()
|
||||
let scene = MobileSceneModel(app: app, sessionID: "window")
|
||||
scene.open(WorkspaceActivityAttributes.destinationURL(for: UUID()))
|
||||
for _ in 0..<20 { await Task.yield() }
|
||||
#expect(app.error != nil)
|
||||
#expect(existing.updates.isEmpty)
|
||||
#expect(!existing.ended)
|
||||
#expect(!existing.ended && !duplicate.ended)
|
||||
#expect(defaults.data(forKey: MobileWorkspaceStore.restorationKey) == damaged)
|
||||
}
|
||||
|
||||
@@ -263,7 +439,7 @@ struct MobileLiveActivityRestorationTests {
|
||||
var created: [TestActivity] = []
|
||||
var services: MobileLiveActivityServices {
|
||||
MobileLiveActivityServices(
|
||||
activities: { self.existing },
|
||||
activities: { (self.existing + self.created).filter { !$0.ended } },
|
||||
canStart: { self.canStart },
|
||||
request: {
|
||||
self.requests.append($0)
|
||||
@@ -296,6 +472,7 @@ struct MobileLiveActivityRestorationTests {
|
||||
|
||||
func end() async {
|
||||
if suspendsEnd {
|
||||
suspendsEnd = false
|
||||
await withCheckedContinuation { endContinuation = $0 }
|
||||
}
|
||||
ended = true
|
||||
|
||||
@@ -330,9 +330,7 @@ struct HostEditorView: View {
|
||||
in: Host.portForwardingMaxConnectionsRange
|
||||
) {
|
||||
HStack {
|
||||
Text(
|
||||
app.proAccess.canUse(.portForwardingMultiConnection)
|
||||
? AppStrings.Vault.maxConnections : AppStrings.Pro.maxConnections)
|
||||
Text(AppStrings.Vault.maxConnections)
|
||||
Spacer()
|
||||
Text(forwardingConnections.wrappedValue, format: .number).monospacedDigit()
|
||||
}
|
||||
|
||||
@@ -2033,7 +2033,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 86;
|
||||
CURRENT_PROJECT_VERSION = 87;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
ICLOUD_CONTAINER_ENVIRONMENT = Development;
|
||||
@@ -2162,7 +2162,7 @@
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 86;
|
||||
CURRENT_PROJECT_VERSION = 87;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TFIWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Termind Workspaces";
|
||||
@@ -2345,7 +2345,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = TFI/TFI.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 86;
|
||||
CURRENT_PROJECT_VERSION = 87;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
ICLOUD_CONTAINER_ENVIRONMENT = Production;
|
||||
@@ -2384,7 +2384,7 @@
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 86;
|
||||
CURRENT_PROJECT_VERSION = 87;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = TFIWidgets/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Termind Workspaces";
|
||||
@@ -2481,7 +2481,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 60;
|
||||
CURRENT_PROJECT_VERSION = 61;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = F6SUNDBBN9;
|
||||
@@ -2551,7 +2551,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 60;
|
||||
CURRENT_PROJECT_VERSION = 61;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = F6SUNDBBN9;
|
||||
@@ -2597,7 +2597,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 97;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
@@ -2635,7 +2635,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 97;
|
||||
CURRENT_PROJECT_VERSION = 98;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
|
||||
Reference in new issue
Block a user