Add extensible remote session backends, safe ownership and restore handling, and pane-scoped shell integration over the existing SSH connection. Keep iOS workspaces in memory and rebuild expired Activity targets on demand. Integrate session selection into Connection Journey with consistent controls, cancellation behavior, and localized counts.
201 lines
8.6 KiB
Swift
201 lines
8.6 KiB
Swift
import SwiftUI
|
|
import TermindPresentation
|
|
|
|
/// A window retains its feature stacks while the system adapts tabs into a sidebar.
|
|
struct MobileRootView: View {
|
|
let app: MobileAppModel
|
|
private let features: MobileFeatureRegistry
|
|
@Environment(\.scenePhase) private var scenePhase
|
|
@Environment(\.horizontalSizeClass) private var sizeClass
|
|
@State private var scene: MobileSceneModel
|
|
private let pendingQuickAction: MobileHomeScreenQuickAction?
|
|
private let consumePendingQuickAction: () -> MobileHomeScreenQuickAction?
|
|
|
|
init(
|
|
app: MobileAppModel,
|
|
scene: MobileSceneModel,
|
|
features: MobileFeatureRegistry = .standard(),
|
|
pendingQuickAction: MobileHomeScreenQuickAction? = nil,
|
|
consumePendingQuickAction: @escaping () -> MobileHomeScreenQuickAction? = { nil }
|
|
) {
|
|
self.app = app
|
|
self.features = features
|
|
self.pendingQuickAction = pendingQuickAction
|
|
self.consumePendingQuickAction = consumePendingQuickAction
|
|
_scene = State(initialValue: scene)
|
|
}
|
|
|
|
var body: some View {
|
|
content.overlay { RemoteSessionClosePromptView(coordinator: app.persistentClose) }
|
|
}
|
|
|
|
private var content: some View {
|
|
featureTabs
|
|
.background { MobileWorkspaceNumberShortcuts(scene: scene) }
|
|
.modifier(InitialNotificationAuthorization(model: app.notificationSettings, service: app.notifications))
|
|
.onChange(of: scene.visibleAssistantConversationID, initial: true) { app.refreshAssistantReadState() }
|
|
.environment(\.singleLineInputAdapter, { AnyView(MobileSingleLineFieldInput()) })
|
|
.sheet(isPresented: Bindable(app.pro).projectedValue.isPaywallPresented) {
|
|
MobileProView(pro: app.pro)
|
|
}
|
|
.sheet(item: $scene.renamingWorkspace) { descriptor in
|
|
MobileWorkspaceRenameView(descriptor: descriptor) { title in
|
|
scene.workspaceSession.renameWorkspace(descriptor.id, to: title)
|
|
}
|
|
}
|
|
.sheet(
|
|
isPresented: Binding(
|
|
get: { scene.agentForwarding.current != nil },
|
|
set: { if !$0 { scene.agentForwarding.cancelAll() } }
|
|
)
|
|
) {
|
|
if let challenge = scene.agentForwarding.current {
|
|
MobileAgentForwardingSheet(interaction: scene.agentForwarding, request: challenge)
|
|
.id(challenge.id)
|
|
}
|
|
}
|
|
.onDisappear { scene.agentForwarding.isActive = false }
|
|
.onChange(of: scenePhase, initial: true) { _, phase in
|
|
scene.agentForwarding.isActive = phase != .background
|
|
app.sceneChanged(scene.id, phase: phase, workspaceSessionID: scene.workspaceSession.id)
|
|
if phase == .active { app.connectionHistory.refreshMenuDate() }
|
|
MobileHomeScreenQuickActions.refresh(app: app)
|
|
}
|
|
.onChange(of: app.connectionHistory.revision, initial: true) { _, _ in
|
|
MobileHomeScreenQuickActions.refresh(app: app)
|
|
}
|
|
.onChange(of: app.library.hosts) { _, _ in
|
|
MobileHomeScreenQuickActions.refresh(app: app)
|
|
}
|
|
.onChange(of: app.library.identities) { _, _ in
|
|
MobileHomeScreenQuickActions.refresh(app: app)
|
|
}
|
|
.onChange(of: pendingQuickAction, initial: true) { _, action in
|
|
guard action != nil, let action = consumePendingQuickAction() else { return }
|
|
_ = scene.perform(action)
|
|
}
|
|
.onChange(of: app.pro.hasProAccess) { _, _ in app.reconcileSync() }
|
|
.onChange(of: scene.workspaceSession.workspaces.map(\.id)) { _, workspaceIDs in
|
|
scene.reconcileWorkspaceIDs(workspaceIDs)
|
|
}
|
|
.onDisappear { app.removeScene(scene.id) }
|
|
.onOpenURL { url in
|
|
MobileWorkspaceSceneRouting.reconcile(app: app, activeSessionID: scene.workspaceSession.id)
|
|
scene.open(url)
|
|
}
|
|
.focusedSceneValue(\.mobileScene, scene)
|
|
.modifier(MobileHistoryMenuPresentation(app: app, scene: scene))
|
|
.alert(
|
|
AppStrings.Workspace.openTelnetConnectionConfirmation,
|
|
isPresented: Binding(
|
|
get: { scene.pendingExternalConnection != nil },
|
|
set: { if !$0 { scene.pendingExternalConnection = nil } }
|
|
)
|
|
) {
|
|
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) { scene.pendingExternalConnection = nil }
|
|
Button(AppStrings.Common.continue.swiftUIKey) { scene.confirmExternalConnection() }
|
|
} message: {
|
|
Text(AppStrings.Workspace.advisoryTelnetPassword)
|
|
}
|
|
.alert(
|
|
scene.isWorkspaceUnavailable
|
|
? String(localized: AppStrings.Workspace.workspaceUnavailable)
|
|
: String(localized: AppStrings.Common.operationFailed),
|
|
isPresented: Binding(
|
|
get: { scene.isWorkspaceUnavailable || app.error != nil },
|
|
set: {
|
|
if !$0 {
|
|
scene.isWorkspaceUnavailable = false
|
|
app.error = nil
|
|
}
|
|
})
|
|
) {
|
|
Button(AppStrings.Common.ok.swiftUIKey, role: .cancel) {
|
|
scene.isWorkspaceUnavailable = false
|
|
app.error = nil
|
|
}
|
|
} message: {
|
|
Text(
|
|
scene.isWorkspaceUnavailable
|
|
? String(
|
|
localized:
|
|
AppStrings.Workspace.unavailableWorkspaceExplanation
|
|
) : app.error ?? "")
|
|
}
|
|
.environment(\.mobileLayout, MobileLayout(sizeClass: sizeClass))
|
|
}
|
|
|
|
private var featureTabs: some View {
|
|
TabView(selection: selection) {
|
|
ForEach(features.registrations) { feature in
|
|
Tab(value: feature.id) {
|
|
featureNavigation(feature)
|
|
.mobileToolbarEnvironment()
|
|
} label: {
|
|
Label(feature.title, systemImage: feature.symbol)
|
|
}
|
|
.badge(unreadCount(for: feature.id))
|
|
}
|
|
}
|
|
.tabViewStyle(.sidebarAdaptable)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func featureNavigation(_ feature: MobileFeatureRegistration) -> some View {
|
|
if feature.id == .vault {
|
|
NavigationStack(path: $scene.vaultNavigationPath) {
|
|
feature.makeView(app: app, scene: scene)
|
|
.navigationDestination(for: MobileVaultRoute.self) { route in
|
|
switch route {
|
|
case .history:
|
|
MobileConnectionHistoryView(app: app, open: scene.open)
|
|
}
|
|
}
|
|
}
|
|
.id(scene.vaultNavigationID)
|
|
} else {
|
|
NavigationStack {
|
|
feature.makeView(app: app, scene: scene)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var selection: Binding<MobileDestination> {
|
|
Binding(
|
|
get: { scene.selectedDestination ?? .vault },
|
|
set: { scene.selectedDestination = $0 }
|
|
)
|
|
}
|
|
|
|
private func unreadCount(for destination: MobileDestination) -> Int {
|
|
switch destination {
|
|
case .workspaces: scene.workspaceSession.unreadCompletionCount
|
|
case .assistant: app.unreadAssistantNotificationCount
|
|
default: 0
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MobileHistoryMenuPresentation: ViewModifier {
|
|
let app: MobileAppModel
|
|
@Bindable var scene: MobileSceneModel
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.onReceive(NotificationCenter.default.publisher(for: .NSCalendarDayChanged)) { _ in
|
|
app.connectionHistory.refreshMenuDate()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .NSSystemTimeZoneDidChange)) { _ in
|
|
app.connectionHistory.refreshMenuDate()
|
|
}
|
|
.operationConfirmation(
|
|
.init(AppStrings.Connections.clearConnectionHistoryConfirmation),
|
|
isPresented: $scene.confirmsClearConnectionHistory, placement: .centered,
|
|
actions: [
|
|
.init(AppStrings.Common.clearHistory, role: .destructive) { scene.clearConnectionHistory() },
|
|
.init(AppStrings.Common.cancel, role: .cancel) {},
|
|
], message: .init(AppStrings.Workspace.clearHistoryWarning)
|
|
)
|
|
}
|
|
}
|