// SPDX-FileCopyrightText: 2026 Kaede Akino // SPDX-License-Identifier: GPL-3.0-only import Foundation import Observation import TermindApplication import TermindDomain import TermindRemoteInfrastructure import struct TermindDomain.Host private enum MobileWorkspaceStartup { case none case command(String) case directory(String) } @MainActor struct MobileWorkspaceItem: Identifiable { let descriptor: MobileWorkspaceDescriptor let workspace: MobileWorkspace? let host: Host? let loginAddress: String var id: MobileWorkspaceDescriptor.ID { descriptor.id } } /// Saved connection intent and live workspaces belonging to one native scene. @MainActor @Observable final class MobileWorkspaceSessionModel { let id: String private(set) var workspaces: [MobileWorkspace] = [] private var manuallyUnreadIDs = Set() var unreadWorkspaceIDs: Set { Set(orderedWorkspaceItems.filter { unreadCount(for: $0.id) > 0 }.map(\.id)) } var hasUnreadCompletion: Bool { unreadCompletionCount > 0 } var unreadCompletionCount: Int { orderedWorkspaceItems.reduce(0) { $0 + unreadCount(for: $1.id) } } func unreadCount(for id: UUID) -> Int { max( manuallyUnreadIDs.contains(id) ? 1 : 0, workspaces.first { $0.id == id }?.commandMonitor.unreadCompletionCount ?? 0) } func markRead(_ id: UUID) { manuallyUnreadIDs.remove(id) workspaces.first { $0.id == id }?.commandMonitor.markRead() } func markUnread(_ id: UUID) { guard orderedWorkspaceItems.contains(where: { $0.id == id }) else { return } manuallyUnreadIDs.insert(id) } 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 } var recentlyClosedRecords: [RecentlyClosedRecord] { app.closedWorkspaceRecords } private(set) var isDiscarded = false @ObservationIgnored private unowned let app: MobileAppModel init(id: String, app: MobileAppModel, descriptors: [MobileWorkspaceDescriptor]) { self.id = id self.app = app savedWorkspaceDescriptors = descriptors workspaceDisplayOrder = descriptors.map(\.id) } struct Recovery { var workspaces: [MobileWorkspace] var descriptors: [MobileWorkspaceDescriptor] var order: [MobileWorkspace.ID] var manuallyUnreadIDs: Set = [] } var recovery: Recovery { Recovery( workspaces: workspaces, descriptors: restorableDescriptors, order: workspaceDisplayOrder, manuallyUnreadIDs: manuallyUnreadIDs) } func releaseForRecovery() { manuallyUnreadIDs.removeAll() workspaces.removeAll() savedWorkspaceDescriptors.removeAll() workspaceDisplayOrder.removeAll() } func acceptRecovery(_ recovery: Recovery) { manuallyUnreadIDs.formUnion(recovery.manuallyUnreadIDs) workspaces.append(contentsOf: recovery.workspaces) savedWorkspaceDescriptors.append(contentsOf: recovery.descriptors) workspaceDisplayOrder.append(contentsOf: recovery.order) for workspace in recovery.workspaces { workspace.didRequestClose = { [weak self] in self?.close($0) } } } func discard() { guard !isDiscarded else { return } let states = orderedWorkspaceItems.map { $0.workspace?.reopenState ?? .init(descriptor: $0.descriptor) } app.rememberClosedWorkspaces(states, isWindow: true) closeAll(rememberForReopen: false, authorized: true) isDiscarded = true } var recentWorkspaces: [MobileWorkspaceDescriptor] { let opened = Set(workspaces.map(\.id)) return savedWorkspaceDescriptors.filter { !opened.contains($0.id) } } var restorableDescriptors: [MobileWorkspaceDescriptor] { savedWorkspaceDescriptors.map { saved in workspaces.first { $0.id == saved.id }?.restorationDescriptor ?? saved } } var orderedWorkspaceItems: [MobileWorkspaceItem] { workspaceDisplayOrder.compactMap { id in let workspace = workspaces.first { $0.id == id } guard let descriptor = workspace?.descriptor ?? savedWorkspaceDescriptors.first(where: { $0.id == id }) else { return nil } let host = app.library.host(descriptor.hostID) ?? workspace?.presentationHost let loginAddress = workspace?.temporaryLoginAddress ?? host.map { app.library.loginAddress( for: $0, protocol: descriptor.kind == .telnet ? .telnet : .ssh) } ?? descriptor.title return MobileWorkspaceItem( descriptor: descriptor, workspace: workspace, host: host, loginAddress: loginAddress) } } func open( hostID: UUID, kind: ConnectionHistoryEntry.Kind, initialPath: String? = nil ) -> UUID? { guard !isDiscarded, let host = app.library.host(hostID) else { return nil } do { try app.library.requireHostAccess(host, protocol: kind.terminalProtocol ?? .ssh) } catch { if case HostAccessError.verifyingEntitlement = error { Task { [weak self] in guard let self else { return } do { try await app.library.resolveHostAccess(host, protocol: kind.terminalProtocol ?? .ssh) guard !isDiscarded else { return } _ = open(hostID: hostID, kind: kind, initialPath: initialPath) } catch { if case HostAccessError.lockedHost = error { app.pro.presentPaywall() } else if !(error is CancellationError) { app.error = HostAccessError.presentationDescription(of: error) } } } } else if case HostAccessError.lockedHost = error { app.pro.presentPaywall() } else { app.error = HostAccessError.presentationDescription(of: error) } return nil } let startup: MobileWorkspaceStartup if let initialPath, !initialPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { startup = .directory(RemotePath.normalized(initialPath)) } else { startup = .none } let workspace = makeWorkspace(host: host, kind: kind, startup: startup) addWorkspaces([workspace]) return workspace.id } @discardableResult func batchStartup( hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol ) -> [MobileWorkspace.ID] { let wanted = Set(hostIDs) return app.library.hosts(matching: "", for: connectionProtocol.useCase) .filter { wanted.contains($0.id) } .compactMap { host in guard let kind = connectionProtocol.kind(for: host) else { return nil } return open(hostID: host.id, kind: kind) } } func openExternalConnection(_ request: RemoteConnectionURL) -> MobileWorkspace.ID? { guard !isDiscarded else { return nil } let host: Host let kind: ConnectionHistoryEntry.Kind switch request.kind { case .ssh: host = Host(name: request.host, host: request.host, port: request.port) kind = .ssh case .sftp: host = Host(name: request.host, host: request.host, port: request.port) kind = .sftp case .telnet: host = Host( name: request.host, host: request.host, sshEnabled: false, telnetEnabled: true, telnetPort: request.port) kind = .telnet } let route = SSHConnectionRoute( hosts: [host], usernames: request.username.map { [host.id: $0] } ?? [:]) let workspace = MobileWorkspace( descriptor: MobileWorkspaceDescriptor( hostID: host.id, title: host.name, kind: kind), hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices, transientHost: host, connectionRoute: request.kind == .telnet ? nil : route, usernameHint: request.username, connectionOrigin: .externalLink( hostKeyConstraints: request.hostKeyFingerprint.map { [host.id: $0] } ?? [:]), advisoryTelnetPassword: request.advisoryPassword.map(SSHSecret.init), externalSFTPResourcePath: request.kind == .sftp ? request.path : nil, externalSFTPMustBeDirectory: request.sftpType == .directory) addWorkspaces([workspace], persists: false) return workspace.id } /// Opens one workspace per target using its default terminal transport. The command is intentionally /// carried only by the live workspace and never enters its restoration descriptor. func execute(_ snippet: Snippet, on hostIDs: [Host.ID]) -> [MobileWorkspace.ID] { guard !isDiscarded else { return [] } let wanted = Set(hostIDs) let targets = app.library.hosts(matching: "", for: .snippetExecution) .filter { wanted.contains($0.id) } let opened = targets.compactMap { host -> MobileWorkspace? in guard let kind = ConnectionHistoryEntry.Kind.preferredTerminal(for: host) else { return nil } return makeWorkspace(host: host, kind: kind, startup: .command(snippet.body)) } addWorkspaces(opened) return opened.map(\.id) } @discardableResult func renameWorkspace(_ id: MobileWorkspace.ID, to title: String) -> Bool { let title = title.trimmingCharacters(in: .whitespacesAndNewlines) guard !title.isEmpty else { return false } guard !isDiscarded else { return false } let workspace = workspaces.first { $0.id == id } let savedIndex = savedWorkspaceDescriptors.firstIndex { $0.id == id } guard workspace != nil || savedIndex != nil else { return false } if let workspace { guard workspace.rename(to: title) else { return false } } else { guard let savedIndex, savedWorkspaceDescriptors[savedIndex].title != title else { return false } savedWorkspaceDescriptors[savedIndex].title = title } if let savedIndex, let workspace { savedWorkspaceDescriptors[savedIndex] = workspace.descriptor } app.persistWorkspaceDescriptors() return true } func close(_ id: UUID, rememberForReopen: Bool = true, authorized: Bool = false) { manuallyUnreadIDs.remove(id) let wasSaved = savedWorkspaceDescriptors.contains(where: { $0.id == id }) guard wasSaved || workspaces.contains(where: { $0.id == id }) else { return } let ending = authorized ? [] : app.closePersistentSessions(orderedWorkspaceItems.filter { $0.id == id }) if rememberForReopen { rememberClosedWorkspaces([id], endingRemoteSessions: ending) } app.workspaceDidClose(id) if let workspace = workspaces.first(where: { $0.id == id }) { workspace.didChangeState = nil workspace.didRequestClose = nil workspace.close() } workspaces.removeAll { $0.id == id } savedWorkspaceDescriptors.removeAll { $0.id == id } workspaceDisplayOrder.removeAll { $0 == id } app.persistWorkspaceDescriptors() } var closableWorkspaceCount: Int { Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)).count } func closeOtherWorkspaces(than id: MobileWorkspace.ID, authorized: Bool = false) { let knownIDs = Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)) guard knownIDs.contains(id), knownIDs.count > 1 else { return } let closing = workspaces.filter { $0.id != id } let ending = authorized ? [] : app.closePersistentSessions(orderedWorkspaceItems.filter { $0.id != id }) rememberClosedWorkspaces(knownIDs.subtracting([id]), endingRemoteSessions: ending) for closedID in knownIDs where closedID != id { app.workspaceDidClose(closedID) } manuallyUnreadIDs = manuallyUnreadIDs.intersection([id]) workspaces.removeAll { $0.id != id } savedWorkspaceDescriptors.removeAll { $0.id != id } workspaceDisplayOrder.removeAll { $0 != id } for workspace in closing { workspace.didChangeState = nil workspace.didRequestClose = nil workspace.close() } app.persistWorkspaceDescriptors() } func closeAll(rememberForReopen: Bool = true, authorized: Bool = false) { manuallyUnreadIDs.removeAll() guard !savedWorkspaceDescriptors.isEmpty || !workspaces.isEmpty else { return } let closing = workspaces let ending = authorized ? [] : app.closePersistentSessions(orderedWorkspaceItems) if rememberForReopen { rememberClosedWorkspaces(Set(workspaceDisplayOrder), endingRemoteSessions: ending) } for id in Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)) { app.workspaceDidClose(id) } workspaces.removeAll() savedWorkspaceDescriptors.removeAll() workspaceDisplayOrder.removeAll() for workspace in closing { workspace.didChangeState = nil workspace.didRequestClose = nil workspace.close() } app.persistWorkspaceDescriptors() } private func rememberClosedWorkspaces(_ ids: Set, endingRemoteSessions: Set = []) { for item in orderedWorkspaceItems where ids.contains(item.id) { var state = item.workspace?.reopenState ?? .init(descriptor: item.descriptor) // Reopen must not attach to a session whose explicit termination is already in flight. if endingRemoteSessions.contains(item.id) { state.descriptor.remoteSession = nil } app.rememberClosedWorkspaces([state]) } } func reopenLastClosed() -> MobileWorkspace.ID? { guard !isDiscarded, let record = app.closedWorkspaceRecords.first else { return nil } if record.isWindow { app.requestClosedWindow(record.id) return nil } return reopenClosedWorkspace(record.id) } func reopenClosedWorkspace(_ id: MobileWorkspace.ID) -> MobileWorkspace.ID? { guard !isDiscarded else { return nil } return app.restoreClosedRecord(id, into: self) } func reopenClosedItem(_ itemID: UUID, in recordID: UUID) -> UUID? { guard !isDiscarded else { return nil } return app.restoreClosedRecord(recordID, into: self, itemID: itemID) } func requestClosedWindow(_ id: UUID) { app.requestClosedWindow(id) } func restoreClosedStates(_ states: [MobileWorkspace.ReopenState]) { for state in states { let workspace = MobileWorkspace( descriptor: state.descriptor, hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices, transientHost: state.transientHost, connectionRoute: state.connectionRoute, usernameHint: state.usernameHint, connectionOrigin: state.connectionOrigin, externalSFTPResourcePath: state.externalSFTPResourcePath, externalSFTPMustBeDirectory: state.externalSFTPMustBeDirectory) workspace.restoreRemoteSession(state.descriptor.remoteSession) addWorkspaces([workspace], persists: state.connectionOrigin.allowsPersistence) } } /// Publish before connecting so repeated selection in this window shares one initial attempt. @discardableResult func openWorkspace(_ id: UUID) -> MobileWorkspace? { 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 let host = app.library.host(descriptor.hostID), MobileConnectionHistoryActionPolicy.isAvailable(descriptor.kind, for: host) else { return nil } let workspace = MobileWorkspace( descriptor: descriptor, hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices) workspace.requiresTransportRecovery = descriptor.kind.terminalProtocol?.isResumable == true if descriptor.kind.isSSHTerminal { workspace.restoreRemoteSession(descriptor.remoteSession) } addWorkspaces([workspace]) return workspace } private func makeWorkspace( host: Host, kind: ConnectionHistoryEntry.Kind, startup: MobileWorkspaceStartup = .none ) -> MobileWorkspace { let initialCommand: String? let recordsInitialCommandInHistory: Bool let initialSFTPPath: String? switch (kind, startup) { case (.ssh, .command(let command)), (.mosh, .command(let command)), (.eternalTerminal, .command(let command)): initialCommand = command recordsInitialCommandInHistory = true initialSFTPPath = nil case (.ssh, .directory(let path)), (.mosh, .directory(let path)), (.eternalTerminal, .directory(let path)): initialCommand = RemoteShellCommand.changeDirectory(to: path) recordsInitialCommandInHistory = false initialSFTPPath = nil case (.sftp, .directory(let path)): initialCommand = nil recordsInitialCommandInHistory = true initialSFTPPath = path case (.ssh, .none), (.mosh, .none), (.eternalTerminal, .none), (.sftp, .none), (.sftp, .command), (.telnet, .none), (.telnet, .command), (.telnet, .directory): initialCommand = nil recordsInitialCommandInHistory = true initialSFTPPath = nil } let title = WorkspaceNameAllocator.nextName( prefix: host.name, occupiedNames: savedWorkspaceDescriptors.map(\.title), automaticallyRenames: app.workspaceSettings.preferences.automaticallyRenamesWorkspaces) return MobileWorkspace( descriptor: MobileWorkspaceDescriptor( hostID: host.id, title: title, kind: kind), hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits, terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices, initialCommand: initialCommand, recordsInitialCommandInHistory: recordsInitialCommandInHistory, initialSFTPPath: initialSFTPPath) } private func addWorkspaces(_ added: [MobileWorkspace], persists: Bool = true) { guard !added.isEmpty else { return } var orderedIDs = Set(workspaceDisplayOrder) for id in added.map(\.id) where orderedIDs.insert(id).inserted { workspaceDisplayOrder.append(id) } for workspace in added { workspace.terminalRecoveryStore = app.terminalRecoveryStore app.workspaceWillOpen(workspace, close: { [weak self] in self?.close($0) }) } workspaces.append(contentsOf: added) if persists { let savedIDs = Set(savedWorkspaceDescriptors.map(\.id)) savedWorkspaceDescriptors.append(contentsOf: added.map(\.descriptor).filter { !savedIDs.contains($0.id) }) } app.workspacesDidOpen() for workspace in added { workspace.connect() } app.persistWorkspaceDescriptors() } }