484 lines
17 KiB
Swift
484 lines
17 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import Observation
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
enum MobileVaultRoute: Hashable {
|
|
case history
|
|
}
|
|
|
|
/// Navigation and presentation state owned by one iOS/iPadOS scene.
|
|
///
|
|
/// App data remains shared through `MobileAppModel`. Focused commands operate on
|
|
/// this window's workspaces; workspace links activate their persistent native owner.
|
|
@MainActor
|
|
@Observable
|
|
final class MobileSceneModel {
|
|
enum WorkspaceDestination: Hashable, Identifiable {
|
|
case workspace(UUID)
|
|
case transfers
|
|
|
|
var id: Self { self }
|
|
}
|
|
|
|
enum WorkspaceSheet: String, Identifiable {
|
|
case connectionPicker, batchStartup
|
|
|
|
var id: Self { self }
|
|
}
|
|
|
|
let id = UUID()
|
|
let agentForwarding = MobileAgentForwardingInteraction()
|
|
let workspaceSession: MobileWorkspaceSessionModel
|
|
let assistant: MobileAssistantSceneModel
|
|
var selectedDestination: MobileDestination? = .vault {
|
|
didSet {
|
|
if selectedDestination != .workspaces { pendingWorkspaceID = nil }
|
|
app.refreshCommandFocus()
|
|
}
|
|
}
|
|
var workspaceDestination: WorkspaceDestination? { didSet { app.refreshCommandFocus() } }
|
|
var workspaceSheet: WorkspaceSheet?
|
|
|
|
var workspaceID: MobileWorkspace.ID? {
|
|
get {
|
|
guard case .workspace(let id) = workspaceDestination else { return nil }
|
|
return id
|
|
}
|
|
set {
|
|
if let newValue {
|
|
workspaceDestination = .workspace(newValue)
|
|
} else if case .workspace = workspaceDestination {
|
|
workspaceDestination = nil
|
|
}
|
|
}
|
|
}
|
|
var isConnectionPickerPresented: Bool { workspaceSheet == .connectionPicker }
|
|
var isBatchStartupPresented: Bool { workspaceSheet == .batchStartup }
|
|
var isWorkspaceUnavailable = false
|
|
var showsTransfers: Bool { workspaceDestination == .transfers }
|
|
var pendingExternalConnection: RemoteConnectionURL?
|
|
var editingHost: Host?
|
|
var renamingWorkspace: MobileWorkspaceDescriptor?
|
|
var assistantLibraryRoute: MobileAssistantLibraryRoute?
|
|
private(set) var vaultNavigationID = UUID()
|
|
var vaultNavigationPath = NavigationPath()
|
|
var confirmsClearConnectionHistory = false
|
|
|
|
var historyMenuSnapshot: ConnectionHistoryMenuSnapshot {
|
|
ConnectionHistoryMenuSnapshot(history: app.connectionHistory, library: app.library)
|
|
}
|
|
var hasConnectionHistory: Bool { !app.connectionHistory.entries().isEmpty }
|
|
|
|
func showConnectionHistory() {
|
|
let historyPath = NavigationPath([MobileVaultRoute.history])
|
|
if vaultNavigationPath != historyPath {
|
|
// Also remove destination-based pushes that are not represented in the value path.
|
|
vaultNavigationID = UUID()
|
|
vaultNavigationPath = historyPath
|
|
}
|
|
selectedDestination = .vault
|
|
}
|
|
|
|
func clearConnectionHistory() {
|
|
app.connectionHistory.clearAll()
|
|
if let error = app.connectionHistory.storageError { app.error = error }
|
|
}
|
|
|
|
func reopenClosedWorkspace(_ id: MobileWorkspace.ID) {
|
|
guard let id = workspaceSession.reopenClosedWorkspace(id) else { return }
|
|
selectWorkspace(id)
|
|
}
|
|
|
|
@ObservationIgnored private let app: MobileAppModel
|
|
@ObservationIgnored private var pendingWorkspaceID: MobileWorkspace.ID?
|
|
@ObservationIgnored private var pendingBatchStartup:
|
|
(hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol)?
|
|
private var recentWorkspaceIDs: [MobileWorkspace.ID] = []
|
|
|
|
init(app: MobileAppModel, sessionID: String) {
|
|
workspaceSession = app.workspaceSession(for: sessionID)
|
|
self.app = app
|
|
assistant = app.assistant.makeSceneModel()
|
|
assistant.paneAccess = app.paneAccess(for: self)
|
|
app.registerPaneScene(self)
|
|
app.agentForwardingRouter.register(self)
|
|
}
|
|
|
|
var activeWorkspace: MobileWorkspace? {
|
|
guard selectedDestination == .workspaces, let workspaceID else { return nil }
|
|
return workspaceSession.workspaces.first { $0.id == workspaceID }
|
|
}
|
|
|
|
var hasWorkspaces: Bool { !workspaceSession.workspaces.isEmpty }
|
|
var canCloseAllWorkspaces: Bool {
|
|
!workspaceSession.savedWorkspaceDescriptors.isEmpty || !workspaceSession.workspaces.isEmpty
|
|
}
|
|
var canCloseMultipleWorkspaces: Bool { workspaceSession.closableWorkspaceCount > 1 }
|
|
var workspaceCount: Int { workspaceSession.workspaces.count }
|
|
var canCloseWorkspace: Bool { activeWorkspace != nil }
|
|
var canUseTerminalCommands: Bool { activeWorkspace?.descriptor.kind.isSSHTerminal == true }
|
|
|
|
func openSettings() {
|
|
selectedDestination = .settings
|
|
}
|
|
|
|
func openTransfers() {
|
|
pendingWorkspaceID = nil
|
|
pendingBatchStartup = nil
|
|
workspaceSheet = nil
|
|
selectedDestination = .workspaces
|
|
workspaceDestination = .transfers
|
|
}
|
|
|
|
func openConnectionPicker() {
|
|
pendingBatchStartup = nil
|
|
pendingWorkspaceID = nil
|
|
selectedDestination = .workspaces
|
|
workspaceDestination = nil
|
|
workspaceSheet = .connectionPicker
|
|
}
|
|
|
|
func openBatchStartup() {
|
|
pendingWorkspaceID = nil
|
|
pendingBatchStartup = nil
|
|
selectedDestination = .workspaces
|
|
workspaceDestination = nil
|
|
workspaceSheet = .batchStartup
|
|
}
|
|
|
|
func prepareBatchStartup(hostIDs: [Host.ID], connectionProtocol: MobileConnectionProtocol) {
|
|
guard isBatchStartupPresented, pendingBatchStartup == nil, !hostIDs.isEmpty else { return }
|
|
pendingBatchStartup = (hostIDs, connectionProtocol)
|
|
}
|
|
|
|
func completeBatchStartup() {
|
|
guard let request = pendingBatchStartup else { return }
|
|
if workspaceSheet == .batchStartup { workspaceSheet = nil }
|
|
pendingBatchStartup = nil
|
|
pendingWorkspaceID = nil
|
|
selectedDestination = .workspaces
|
|
workspaceID = nil
|
|
workspaceSession.batchStartup(hostIDs: request.hostIDs, connectionProtocol: request.connectionProtocol)
|
|
}
|
|
|
|
func isShowingAssistant(_ id: UUID) -> Bool {
|
|
selectedDestination == .assistant && assistantLibraryRoute == nil
|
|
&& assistant.library.selectedConversationID == id
|
|
}
|
|
|
|
var visibleAssistantConversationID: UUID? {
|
|
guard let id = assistant.library.selectedConversationID, isShowingAssistant(id) else { return nil }
|
|
return id
|
|
}
|
|
|
|
func openAssistantNotification(_ id: UUID) {
|
|
assistantLibraryRoute = nil
|
|
selectedDestination = .assistant
|
|
if let conversation = assistant.library.conversation(id: id) {
|
|
assistant.select(conversation)
|
|
} else {
|
|
assistantLibraryRoute = .conversations
|
|
}
|
|
}
|
|
|
|
func openNewConversation() {
|
|
assistant.newConversation()
|
|
assistantLibraryRoute = nil
|
|
selectedDestination = .assistant
|
|
}
|
|
|
|
func showAssistant() {
|
|
selectedDestination = .assistant
|
|
}
|
|
|
|
func openNewHost() {
|
|
selectedDestination = .vault
|
|
editingHost = Host(name: "", host: "")
|
|
}
|
|
|
|
func open(hostID: Host.ID, kind: ConnectionHistoryEntry.Kind) {
|
|
guard let id = workspaceSession.open(hostID: hostID, kind: kind) else { return }
|
|
selectWorkspace(id)
|
|
}
|
|
|
|
@discardableResult
|
|
func perform(_ quickAction: MobileHomeScreenQuickAction) -> Bool {
|
|
switch quickAction {
|
|
case .connect:
|
|
openConnectionPicker()
|
|
return true
|
|
case .history(let hostID):
|
|
guard let host = app.library.host(hostID),
|
|
let kind = MobileConnectionHistory.preferredKind(for: host)
|
|
else {
|
|
selectedDestination = .workspaces
|
|
workspaceID = nil
|
|
isWorkspaceUnavailable = true
|
|
return false
|
|
}
|
|
open(hostID: hostID, kind: kind)
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Reuses this scene's most recently selected terminal workspace for the Host, regardless of transport.
|
|
/// Explicit connection actions continue to call `open(hostID:kind:)` so
|
|
/// users can still create independent SSH and SFTP workspaces.
|
|
func activateHost(_ hostID: Host.ID) {
|
|
guard let host = app.library.host(hostID) else { return }
|
|
guard let workspace = workspaceActivatedByHostRow(for: host) else {
|
|
guard let kind = ConnectionHistoryEntry.Kind.preferredTerminal(for: host) else { return }
|
|
open(hostID: hostID, kind: kind)
|
|
return
|
|
}
|
|
selectWorkspace(workspace.id)
|
|
}
|
|
|
|
/// The connection a Vault Host card will reopen in this window.
|
|
func workspaceActivatedByHostRow(for host: Host) -> MobileWorkspaceItem? {
|
|
let matchingItems = workspaceSession.orderedWorkspaceItems.filter {
|
|
$0.descriptor.hostID == host.id && $0.descriptor.kind.terminalProtocol != nil
|
|
}
|
|
if let id = recentWorkspaceIDs.first(where: { id in matchingItems.contains { $0.id == id } }) {
|
|
return matchingItems.first { $0.id == id }
|
|
}
|
|
return matchingItems.last
|
|
}
|
|
|
|
func openSameDirectory(_ launch: MobileWorkspaceDirectoryLaunch) {
|
|
guard let id = workspaceSession.open(hostID: launch.hostID, kind: launch.kind, initialPath: launch.path) else {
|
|
return
|
|
}
|
|
selectWorkspace(id)
|
|
}
|
|
|
|
func execute(_ snippet: Snippet, on hostIDs: [Host.ID]) {
|
|
guard let first = workspaceSession.execute(snippet, on: hostIDs).first else { return }
|
|
selectWorkspace(first)
|
|
}
|
|
|
|
func selectWorkspace(_ id: MobileWorkspace.ID) {
|
|
guard workspaceSession.openWorkspace(id) != nil else {
|
|
pendingWorkspaceID = nil
|
|
selectedDestination = .workspaces
|
|
workspaceID = nil
|
|
isWorkspaceUnavailable = true
|
|
return
|
|
}
|
|
isWorkspaceUnavailable = false
|
|
if selectedDestination != .workspaces {
|
|
workspaceID = nil
|
|
pendingWorkspaceID = id
|
|
selectedDestination = .workspaces
|
|
return
|
|
}
|
|
presentWorkspace(id)
|
|
}
|
|
|
|
func activatePendingWorkspace() {
|
|
guard selectedDestination == .workspaces, let id = pendingWorkspaceID else { return }
|
|
pendingWorkspaceID = nil
|
|
presentWorkspace(id)
|
|
}
|
|
|
|
private func presentWorkspace(_ id: MobileWorkspace.ID) {
|
|
guard workspaceSession.workspaces.contains(where: { $0.id == id }) else { return }
|
|
pendingWorkspaceID = nil
|
|
selectedDestination = .workspaces
|
|
workspaceID = id
|
|
recentWorkspaceIDs.removeAll { $0 == id }
|
|
recentWorkspaceIDs.insert(id, at: 0)
|
|
app.focusWorkspaceActivity(id)
|
|
}
|
|
|
|
func selectWorkspace(at index: Int) {
|
|
guard workspaceSession.workspaces.indices.contains(index) else { return }
|
|
selectWorkspace(workspaceSession.workspaces[index].id)
|
|
}
|
|
|
|
func selectNextWorkspace() {
|
|
guard !workspaceSession.workspaces.isEmpty else { return }
|
|
guard let activeWorkspace,
|
|
let index = workspaceSession.workspaces.firstIndex(where: { $0.id == activeWorkspace.id })
|
|
else {
|
|
selectWorkspace(at: 0)
|
|
return
|
|
}
|
|
selectWorkspace(at: (index + 1) % workspaceSession.workspaces.count)
|
|
}
|
|
|
|
func selectPreviousWorkspace() {
|
|
guard !workspaceSession.workspaces.isEmpty else { return }
|
|
guard let activeWorkspace,
|
|
let index = workspaceSession.workspaces.firstIndex(where: { $0.id == activeWorkspace.id })
|
|
else {
|
|
selectWorkspace(at: workspaceSession.workspaces.count - 1)
|
|
return
|
|
}
|
|
selectWorkspace(at: (index - 1 + workspaceSession.workspaces.count) % workspaceSession.workspaces.count)
|
|
}
|
|
|
|
func selectLastWorkspace() {
|
|
guard !workspaceSession.workspaces.isEmpty else { return }
|
|
selectWorkspace(at: workspaceSession.workspaces.count - 1)
|
|
}
|
|
|
|
func generateTerminalCommand() {
|
|
guard let workspace = activeWorkspace, workspace.descriptor.kind.isSSHTerminal else { return }
|
|
workspace.generateCommand(using: app.commandGenerator)
|
|
}
|
|
|
|
func presentTerminalFind() {
|
|
guard let workspace = activeWorkspace, workspace.descriptor.kind.isSSHTerminal else { return }
|
|
workspace.find.present()
|
|
}
|
|
|
|
func navigateTerminalFind(_ direction: TerminalFindDirection) {
|
|
guard let workspace = activeWorkspace, workspace.descriptor.kind.isSSHTerminal else { return }
|
|
workspace.find.presentAndNavigate(direction)
|
|
}
|
|
|
|
func beginWorkspaceRename(_ descriptor: MobileWorkspaceDescriptor) {
|
|
guard workspaceSession.orderedWorkspaceItems.contains(where: { $0.id == descriptor.id }) else { return }
|
|
renamingWorkspace = descriptor
|
|
}
|
|
|
|
func closeWorkspace(_ id: MobileWorkspace.ID) {
|
|
workspaceSession.close(id)
|
|
recentWorkspaceIDs.removeAll { $0 == id }
|
|
if pendingWorkspaceID == id { pendingWorkspaceID = nil }
|
|
if workspaceID == id { workspaceID = nil }
|
|
}
|
|
|
|
func closeActiveWorkspace() {
|
|
guard let activeWorkspace else { return }
|
|
closeWorkspace(activeWorkspace.id)
|
|
}
|
|
|
|
func reopenLastClosedWorkspace() {
|
|
guard let id = workspaceSession.reopenLastClosed() else { return }
|
|
selectWorkspace(id)
|
|
}
|
|
|
|
func closeSelectionOrWindow() {
|
|
guard !workspaceSession.isDiscarded else { return }
|
|
if activeWorkspace != nil {
|
|
closeActiveWorkspace()
|
|
} else {
|
|
closeWindow()
|
|
}
|
|
}
|
|
|
|
func closeWindow() {
|
|
guard !workspaceSession.isDiscarded else { return }
|
|
requestWindowClose(sessionID: workspaceSession.id)
|
|
}
|
|
|
|
func closeAllWindows() {
|
|
guard !workspaceSession.isDiscarded else { return }
|
|
let sessionIDs = app.openWorkspaceSessionIDs()
|
|
for id in sessionIDs.sorted() where id != workspaceSession.id {
|
|
requestWindowClose(sessionID: id)
|
|
}
|
|
if sessionIDs.contains(workspaceSession.id) {
|
|
requestWindowClose(sessionID: workspaceSession.id)
|
|
}
|
|
}
|
|
|
|
private func requestWindowClose(sessionID: String) {
|
|
// UIKit's discard callback owns cleanup; a rejected request must preserve this session.
|
|
let logger = app.logging.makeLogger(label: "termind.window")
|
|
app.closeWorkspaceSession(sessionID) { 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)
|
|
recentWorkspaceIDs.removeAll { $0 != id }
|
|
if pendingWorkspaceID != id { pendingWorkspaceID = nil }
|
|
if workspaceID != id { workspaceID = nil }
|
|
}
|
|
|
|
func closeAllWorkspaces() {
|
|
workspaceSession.closeAll()
|
|
recentWorkspaceIDs.removeAll()
|
|
pendingWorkspaceID = nil
|
|
workspaceID = nil
|
|
}
|
|
|
|
func reconcileWorkspaceIDs(_ workspaceIDs: [MobileWorkspace.ID]) {
|
|
let existingIDs = Set(workspaceIDs)
|
|
recentWorkspaceIDs.removeAll { !existingIDs.contains($0) }
|
|
if let pendingWorkspaceID, !workspaceIDs.contains(pendingWorkspaceID) {
|
|
self.pendingWorkspaceID = nil
|
|
}
|
|
guard let workspaceID, !workspaceIDs.contains(workspaceID) else { return }
|
|
self.workspaceID = nil
|
|
}
|
|
|
|
func open(_ url: URL) {
|
|
if let scheme = url.scheme?.lowercased(), ["ssh", "sftp", "telnet"].contains(scheme) {
|
|
do {
|
|
let request = try RemoteConnectionURLParser.parse(url)
|
|
if request.kind == .telnet, request.advisoryPassword != nil {
|
|
pendingExternalConnection = request
|
|
} else {
|
|
if let id = workspaceSession.openExternalConnection(request) { selectWorkspace(id) }
|
|
}
|
|
} catch {
|
|
app.error = error.localizedDescription
|
|
}
|
|
return
|
|
}
|
|
if WorkspaceActivityAttributes.accepts(url) {
|
|
isWorkspaceUnavailable = false
|
|
pendingWorkspaceID = nil
|
|
selectedDestination = .workspaces
|
|
workspaceID = nil
|
|
return
|
|
}
|
|
guard let sourceID = WorkspaceActivityAttributes.workspaceID(from: url) else {
|
|
if url.scheme == "termind", url.host == "workspace" {
|
|
pendingWorkspaceID = nil
|
|
selectedDestination = .workspaces
|
|
workspaceID = nil
|
|
isWorkspaceUnavailable = true
|
|
}
|
|
return
|
|
}
|
|
isWorkspaceUnavailable = false
|
|
let id = sourceID
|
|
guard let owner = app.workspaceOwner(id) else {
|
|
pendingWorkspaceID = nil
|
|
selectedDestination = .workspaces
|
|
workspaceID = nil
|
|
isWorkspaceUnavailable = true
|
|
return
|
|
}
|
|
if owner != workspaceSession.id {
|
|
app.activateWorkspaceSession(owner, id) { [weak self] in self?.isWorkspaceUnavailable = true }
|
|
return
|
|
}
|
|
selectWorkspace(id)
|
|
workspaceSession.workspaces.first(where: { $0.id == id })?.terminal.requestFocus()
|
|
}
|
|
|
|
func confirmExternalConnection() {
|
|
guard let request = pendingExternalConnection else { return }
|
|
pendingExternalConnection = nil
|
|
if let id = workspaceSession.openExternalConnection(request) { selectWorkspace(id) }
|
|
}
|
|
}
|