Files
Termind/TFI/App/MobileWorkspaceSessionModel.swift
T

365 lines
15 KiB
Swift

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 }
}
/// Connection intent and live workspaces belonging to one persistent UIKit scene session.
@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]
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: savedWorkspaceDescriptors, 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() {
closeAll()
isDiscarded = true
}
private func saveWorkspaces() {
app.saveWorkspaceDescriptors(savedWorkspaceDescriptors, for: id)
}
var recentWorkspaces: [MobileWorkspaceDescriptor] {
let opened = Set(workspaces.map(\.id))
return savedWorkspaceDescriptors.filter { !opened.contains($0.id) }
}
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 }
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,
terminalHighlightSettings: app.terminalHighlightSettings,
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 independent SSH workspace per target. 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.map { makeWorkspace(host: $0, kind: .ssh, 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
}
saveWorkspaces()
return true
}
func close(_ id: UUID) {
manuallyUnreadIDs.remove(id)
let wasSaved = savedWorkspaceDescriptors.contains(where: { $0.id == id })
guard wasSaved || workspaces.contains(where: { $0.id == id }) else { return }
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 }
if wasSaved { saveWorkspaces() }
}
var closableWorkspaceCount: Int {
Set(savedWorkspaceDescriptors.map(\.id)).union(workspaces.map(\.id)).count
}
func closeOtherWorkspaces(than id: MobileWorkspace.ID) {
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 }
manuallyUnreadIDs = manuallyUnreadIDs.intersection([id])
workspaces.removeAll { $0.id != id }
savedWorkspaceDescriptors.removeAll { $0.id != id }
workspaceDisplayOrder.removeAll { $0 != id }
saveWorkspaces()
for workspace in closing {
app.workspaceDidClose(workspace.id)
workspace.didChangeState = nil
workspace.didRequestClose = nil
workspace.close()
}
}
func closeAll() {
manuallyUnreadIDs.removeAll()
guard !savedWorkspaceDescriptors.isEmpty || !workspaces.isEmpty else { return }
let closing = workspaces
workspaces.removeAll()
savedWorkspaceDescriptors.removeAll()
workspaceDisplayOrder.removeAll()
saveWorkspaces()
for workspace in closing {
app.workspaceDidClose(workspace.id)
workspace.didChangeState = nil
workspace.didRequestClose = nil
workspace.close()
}
}
/// 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 app.library.host(descriptor.hostID) != nil else {
close(id)
return nil
}
let workspace = MobileWorkspace(
descriptor: descriptor, hosts: app.library, transfers: app.transfers, sftpEdits: app.sftpEdits,
terminalHighlightSettings: app.terminalHighlightSettings,
terminalSettings: app.terminalSettings, connectionServices: app.workspaceConnectionServices)
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)):
initialCommand = command
recordsInitialCommandInHistory = true
initialSFTPPath = nil
case (.ssh, .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), (.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,
terminalHighlightSettings: app.terminalHighlightSettings,
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 {
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) })
saveWorkspaces()
}
app.workspacesDidOpen(added)
for workspace in added { workspace.connect() }
}
}