diff --git a/TFI/App/MobileAppDependencies.swift b/TFI/App/MobileAppDependencies.swift index 8858725b..dcc5e2a3 100644 --- a/TFI/App/MobileAppDependencies.swift +++ b/TFI/App/MobileAppDependencies.swift @@ -22,6 +22,8 @@ struct MobileAppDependencies { var liveActivities: MobileLiveActivityController? = nil var activateWorkspaceSession: @MainActor (String, UUID, @escaping @MainActor () -> Void) -> Void = MobileWorkspaceSceneRouting.activate + var closeWorkspaceSession: @MainActor (String, @escaping @MainActor (any Error) -> Void) -> Void = + MobileWorkspaceSceneRouting.close var makeForwardings: (HostLibraryModel) -> PortForwardingController = { PortForwardingController(hosts: $0) } static func live() -> MobileAppDependencies { diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 6f642b0d..f4ea593c 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -159,6 +159,7 @@ final class MobileAppModel { private var recoveredWorkspaceIDs: [String: Set] = [:] private var discardedSessionIDs: Set = [] var activateWorkspaceSession: @MainActor (String, UUID, @escaping @MainActor () -> Void) -> Void + var closeWorkspaceSession: @MainActor (String, @escaping @MainActor (any Error) -> Void) -> Void let outbox: OutboxRunner private var sceneSessionIDs: [UUID: String] = [:] @@ -193,6 +194,7 @@ final class MobileAppModel { try await connectionCoordinator.installAuthorizedKeys(keys, using: configuration) } activateWorkspaceSession = dependencies.activateWorkspaceSession + closeWorkspaceSession = dependencies.closeWorkspaceSession self.background = background self.workspaceConnectionServices = workspaceConnectionServices workspaceStore = diff --git a/TFI/App/MobileCommands.swift b/TFI/App/MobileCommands.swift index fa194902..8e034ede 100644 --- a/TFI/App/MobileCommands.swift +++ b/TFI/App/MobileCommands.swift @@ -84,11 +84,15 @@ struct MobileCommands: Commands { } CommandGroup(replacing: .saveItem) { - Button(AppStrings.Workspace.closeWorkspace.swiftUIKey, systemImage: "xmark") { - scene?.closeActiveWorkspace() + Button( + scene?.canCloseWorkspace == true + ? AppStrings.Workspace.closeWorkspace.swiftUIKey : AppStrings.Workspace.closeWindow.swiftUIKey, + systemImage: "xmark" + ) { + scene?.closeSelectionOrWindow() } .keyboardShortcut(MobileShortcut.closeWorkspace) - .disabled(scene?.canCloseWorkspace != true) + .disabled(scene == nil) } CommandMenu(AppStrings.Navigation.workspaces.swiftUIKey) { diff --git a/TFI/App/MobileSceneModel.swift b/TFI/App/MobileSceneModel.swift index cf193e12..53f3e7e3 100644 --- a/TFI/App/MobileSceneModel.swift +++ b/TFI/App/MobileSceneModel.swift @@ -315,6 +315,22 @@ final class MobileSceneModel { closeWorkspace(activeWorkspace.id) } + func closeSelectionOrWindow() { + guard !workspaceSession.isDiscarded else { return } + if activeWorkspace != nil { + closeActiveWorkspace() + } else { + // UIKit's discard callback owns cleanup; a rejected request must preserve this session. + let logger = app.logging.makeLogger(label: "termind.window") + app.closeWorkspaceSession(workspaceSession.id) { error in + let error = error as NSError + logger.warning( + "Window close request failed", + metadata: ["domain": .string(error.domain), "code": .stringConvertible(error.code)]) + } + } + } + func closeOtherWorkspaces(than id: MobileWorkspace.ID) { guard workspaceSession.orderedWorkspaceItems.contains(where: { $0.id == id }) else { return } workspaceSession.closeOtherWorkspaces(than: id) diff --git a/TFI/Platform/MobileWorkspaceSceneRouting.swift b/TFI/Platform/MobileWorkspaceSceneRouting.swift index b4b5a23b..600fc2ee 100644 --- a/TFI/Platform/MobileWorkspaceSceneRouting.swift +++ b/TFI/Platform/MobileWorkspaceSceneRouting.swift @@ -4,6 +4,10 @@ import UIKit /// Links keep their existing workspace UUID. Window ownership is resolved locally by the app. @MainActor enum MobileWorkspaceSceneRouting { + nonisolated enum CloseError: Error { + case sessionNotFound + } + static let activityType = "com.akinokaede.termind.open-workspace" private static let workspaceIDKey = "workspaceID" @@ -38,4 +42,15 @@ enum MobileWorkspaceSceneRouting { MainActor.assumeIsolated { unavailable() } } } + + static func close(sessionID: String, failed: @escaping @MainActor (any Error) -> Void) { + guard let session = UIApplication.shared.openSessions.first(where: { $0.persistentIdentifier == sessionID }) + else { + failed(CloseError.sessionNotFound) + return + } + UIApplication.shared.requestSceneSessionDestruction(session, options: nil) { error in + MainActor.assumeIsolated { failed(error) } + } + } } diff --git a/TFITests/MobileShortcutTests.swift b/TFITests/MobileShortcutTests.swift index b11e503f..b3edb20d 100644 --- a/TFITests/MobileShortcutTests.swift +++ b/TFITests/MobileShortcutTests.swift @@ -225,6 +225,54 @@ struct MobileShortcutTests { #expect(scene.workspaceID == third) } + @Test func closeCommandClosesTheLastWorkspaceBeforeRequestingWindowClosure() throws { + var requestedSessions: [String] = [] + var dependencies = MobileAppDependencies.testing() + dependencies.closeWorkspaceSession = { id, _ in requestedSessions.append(id) } + let app = try MobileAppModel(dependencies: dependencies) + let host = Host(name: "Test", host: "example.invalid") + app.library.upsert(host) + let scene = MobileSceneModel(app: app, sessionID: "owner") + let other = MobileSceneModel(app: app, sessionID: "other") + defer { app.discardWorkspaceSessions(["owner", "other"]) } + let first = try #require(scene.workspaceSession.open(hostID: host.id, kind: .ssh)) + let second = try #require(other.workspaceSession.open(hostID: host.id, kind: .sftp)) + scene.selectWorkspace(first) + scene.activatePendingWorkspace() + other.selectWorkspace(second) + other.activatePendingWorkspace() + + scene.closeSelectionOrWindow() + + #expect(scene.workspaceSession.orderedWorkspaceItems.isEmpty) + #expect(scene.workspaceID == nil) + #expect(requestedSessions.isEmpty) + #expect(other.activeWorkspace?.id == second) + + scene.closeSelectionOrWindow() + + #expect(requestedSessions == ["owner"]) + #expect(other.activeWorkspace?.id == second) + } + + @Test(arguments: MobileDestination.allCases) + func closeCommandWithoutSelectionRequestsOnlyItsOwnWindow(destination: MobileDestination) throws { + let app = try MobileAppModel(inMemory: true) + let scene = MobileSceneModel(app: app, sessionID: "owner") + let other = MobileSceneModel(app: app, sessionID: "other") + defer { app.discardWorkspaceSessions(["owner", "other"]) } + var requestedSessions: [String] = [] + app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) } + scene.selectedDestination = destination + + scene.closeSelectionOrWindow() + + #expect(requestedSessions == [scene.workspaceSession.id]) + #expect(!other.workspaceSession.isDiscarded) + #expect(app.error == nil) + #expect(!scene.isWorkspaceUnavailable) + } + @Test func closeCommandOnlyClosesAWorkspaceVisibleInThisScene() throws { let app = try MobileAppModel(inMemory: true) let host = Host(name: "Test", host: "example.invalid") diff --git a/TFITests/MobileWorkspaceSessionTests.swift b/TFITests/MobileWorkspaceSessionTests.swift index 4569316a..9106b455 100644 --- a/TFITests/MobileWorkspaceSessionTests.swift +++ b/TFITests/MobileWorkspaceSessionTests.swift @@ -638,6 +638,80 @@ struct MobileWorkspaceSessionTests { #expect(first.isWorkspaceUnavailable) } + @Test func windowCloseWaitsForNativeDiscardBeforeRemovingItsWorkspaces() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let app = try fixture.makeApp() + let first = MobileSceneModel(app: app, sessionID: "a") + let second = MobileSceneModel(app: app, sessionID: "b") + first.open(hostID: fixture.host.id, kind: .ssh) + second.open(hostID: fixture.host.id, kind: .sftp) + let firstID = try #require(first.workspaceSession.workspaces.first?.id) + let secondID = try #require(second.workspaceSession.workspaces.first?.id) + var requestedSessions: [String] = [] + app.closeWorkspaceSession = { id, _ in requestedSessions.append(id) } + first.openSettings() + + first.closeSelectionOrWindow() + + #expect(requestedSessions == ["a"]) + #expect(first.workspaceSession.workspaces.map(\.id) == [firstID]) + #expect(try fixture.store.load().sessions["a"]?.map(\.id) == [firstID]) + let delegate = MobileAppDelegate() + delegate.app = app + delegate.discardSessions(["a"]) + + #expect(first.workspaceSession.isDiscarded) + #expect(first.workspaceSession.orderedWorkspaceItems.isEmpty) + #expect(try fixture.store.load().sessions["a"] == nil) + #expect(second.workspaceSession.workspaces.map(\.id) == [secondID]) + #expect(try fixture.store.load().sessions["b"]?.map(\.id) == [secondID]) + #expect(app.error == nil) + first.closeSelectionOrWindow() + #expect(requestedSessions == ["a"]) + } + + @Test func rejectedWindowCloseKeepsWorkspacesAndSavedRecordsWithoutAnAlert() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let app = try fixture.makeApp() + let scene = MobileSceneModel(app: app, sessionID: "a") + scene.open(hostID: fixture.host.id, kind: .ssh) + let workspace = try #require(scene.workspaceSession.workspaces.first) + var reportFailure: (@MainActor (any Error) -> Void)? + app.closeWorkspaceSession = { _, failed in reportFailure = failed } + scene.openSettings() + + scene.closeSelectionOrWindow() + let failed = try #require(reportFailure) + failed(NSError(domain: "WindowCloseTest", code: 1)) + + #expect(scene.workspaceSession.workspaces.first === workspace) + #expect(!scene.workspaceSession.isDiscarded) + #expect(try fixture.store.load().sessions["a"]?.map(\.id) == [workspace.id]) + #expect(scene.selectedDestination == .settings) + #expect(!scene.isWorkspaceUnavailable) + #expect(app.error == nil) + } + + @Test func closingMissingNativeSessionReportsFailureWithoutDiscardingSavedWorkspaces() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let app = try fixture.makeApp() + let id = "missing-\(UUID())" + let scene = MobileSceneModel(app: app, sessionID: id) + scene.open(hostID: fixture.host.id, kind: .ssh) + let workspaceID = try #require(scene.workspaceSession.workspaces.first?.id) + var failure: (any Error)? + + MobileWorkspaceSceneRouting.close(sessionID: id) { failure = $0 } + + #expect(failure as? MobileWorkspaceSceneRouting.CloseError == .sessionNotFound) + #expect(scene.workspaceSession.workspaces.map(\.id) == [workspaceID]) + #expect(try fixture.store.load().sessions[id]?.map(\.id) == [workspaceID]) + #expect(app.error == nil) + } + @Test func missingNativeWindowReportsUnavailableWithoutCreatingAConnection() throws { let fixture = try Fixture() defer { fixture.cleanUp() }