Files
Termind/TFI/App/MobileWorkspaceSessionModel.swift

456 lines
20 KiB
Swift

// 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<UUID>()
var unreadWorkspaceIDs: Set<UUID> {
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<MobileWorkspaceDescriptor>] { 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<UUID> = []
}
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<UUID>, endingRemoteSessions: Set<UUID> = []) {
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()
}
}