Files
Termind/TFM/App/TermindApp.swift
T

408 lines
20 KiB
Swift

import AgentKitScrubber
import SwiftData
import SwiftUI
import TermindApplication
import TermindInfrastructure
import TermindPersistence
import TermindRemoteInfrastructure
#if canImport(AppKit)
import AppKit
#endif
@main
struct TermindApp: App {
/// Hosts metadata is stored in SwiftData; secrets stay behind Keychain
/// references held by the value-type projections used throughout the UI.
private let modelContainer: ModelContainer
@State private var app: AppModel
private let launchLanguage: AppLanguage
init() {
ContextAttachmentOpener.cleanPreviousSession()
ScrubberWebClient.setup()
#if DEBUG
let screenshotDemo = ScreenshotDemoConfiguration.active
launchLanguage = screenshotDemo?.language ?? AppLanguage.stored()
#else
launchLanguage = AppLanguage.stored()
#endif
do {
let isRunningTests =
ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
#if DEBUG
let usesInMemoryStore = isRunningTests || screenshotDemo != nil
#else
let usesInMemoryStore = isRunningTests
#endif
let container = try TermindPersistence.makeContainer(inMemory: usesInMemoryStore)
modelContainer = container
#if DEBUG
if let screenshotDemo {
let hosts = HostLibraryModel.demo(language: screenshotDemo.language)
let app = AppModel(
hosts: hosts, ai: .demo(), proAccess: .unrestrictedForTesting(),
onboarding: OnboardingController(automaticPresentationEnabled: false),
serverMetrics: screenshotDemo.makeMetricsModel(hosts: hosts)
)
_app = State(initialValue: app)
return
}
#endif
let secrets = KeychainSecretStore()
LegacyWebSearchSettings.remove { reference in
try secrets.setString(nil, for: reference, storage: .thisDevice)
}
SettingsKey.migrateSyncPreferences()
let settingsPersistence = SettingsSyncPersistence(context: container.mainContext)
try settingsPersistence.prepare()
let metricsDefaults =
isRunningTests ? UserDefaults(suiteName: "termind.metrics.tests.\(UUID().uuidString)")! : .standard
let metricsPersistence = MetricsSyncPersistence(
context: container.mainContext, defaults: metricsDefaults)
try metricsPersistence.prepare()
let aiSecrets = AISecretStore(context: container.mainContext, keychain: secrets, defaults: .standard)
let settingsSync: SettingsSyncCoordinator? =
isRunningTests
? nil
: SettingsSyncCoordinator(persistence: settingsPersistence, secrets: aiSecrets)
let metricsSync: MetricsSyncCoordinator? =
isRunningTests ? nil : MetricsSyncCoordinator(persistence: metricsPersistence)
let hosts = HostLibraryModel(
modelContext: container.mainContext, secrets: secrets,
localDefaults: isRunningTests ? nil : .standard
)
if !isRunningTests { try? hosts.migrateCustomPasswordsToICloud() }
let repository = SwiftDataHostRepository(context: container.mainContext)
let outboxStore = SwiftDataOutboxStore(context: container.mainContext)
let outboxRunner = OutboxRunner(store: outboxStore)
let vaultSync: VaultSyncCoordinator? =
isRunningTests
? nil
: VaultSyncCoordinator(context: container.mainContext)
vaultSync?.beforeSend = { [weak hosts] in try hosts?.migrateCustomPasswordsToICloud() }
let connectionHistoryRepository = SwiftDataConnectionHistoryRepository(
context: container.mainContext)
let deviceDirectoryRepository = SwiftDataDeviceDirectoryRepository(
context: container.mainContext)
let connectionHistoryDeviceID = try VaultSyncJournal(context: container.mainContext).state().installationID
connectionHistoryRepository.didRecordVaultChanges = { [weak vaultSync] in
vaultSync?.noteLocalChanges($0)
}
deviceDirectoryRepository.didRecordVaultChanges = { [weak vaultSync] in
vaultSync?.noteLocalChanges($0)
}
let devices = DeviceDirectoryModel(
repository: deviceDirectoryRepository,
currentDeviceID: connectionHistoryDeviceID,
currentDeviceName: Self.computerName)
let externalToolCalls = ToolAuditModel(
modelContext: container.mainContext,
hostName: { [weak hosts] id in hosts?.host(id)?.name }
)
let proAccess: ProAccessController =
isRunningTests
? .unrestrictedForTesting()
: .live()
let logging: any ApplicationLogManaging =
isRunningTests ? DisabledApplicationLogManager.shared : LaunchFileLogService()
let connectionCoordinator = RemoteConnectionCoordinator(
logger: logging.makeLogger(label: "termind.remote-connection"))
_app = State(
initialValue: AppModel(
hosts: hosts,
ai: AIConfigurationModel(
persistence: SyncedAIConfigurationPersistence(settingsPersistence), secrets: secrets,
permissionDefaults: .standard, aiSecrets: aiSecrets, webSearchPersistence: settingsPersistence
),
skills: SkillLibraryModel(persistence: SyncedSkillLibraryPersistence(settingsPersistence)),
memory: isRunningTests
? MemoryModel()
: MemoryModel(
storage: GRDBMemoryStore(directory: GRDBMemoryStore.defaultDirectory), defaults: .standard),
terminalSettings: TerminalSettingsModel(persistence: settingsPersistence),
sshSettings: SSHSettingsModel(persistence: settingsPersistence),
sftpSettings: SFTPSettingsModel(persistence: settingsPersistence),
workspaceSettings: WorkspaceSettingsModel(persistence: settingsPersistence),
commandHistory: CommandHistoryModel(modelContext: container.mainContext),
sftpHistory: SFTPHistoryModel(
repository: SwiftDataSFTPHistoryRepository(context: container.mainContext)
),
connectionHistory: ConnectionHistoryModel(
repository: connectionHistoryRepository,
sourceDeviceID: connectionHistoryDeviceID
),
devices: devices,
externalToolCalls: externalToolCalls,
dependencies: AppDependencies(
hostRepository: repository,
outboxRunner: outboxRunner,
connectionCoordinator: connectionCoordinator,
agentRunRepository: SwiftDataAgentRunRepository(
context: container.mainContext
),
vaultSync: vaultSync, settingsSync: settingsSync,
metricsSync: metricsSync, metricsPreferences: metricsPersistence,
mcpServerSecrets: secrets,
logging: logging,
settingsDefaults: .standard
),
proAccess: proAccess,
appReview: .live(),
windowSizeStore: MainWindowSizeStore(defaults: isRunningTests ? nil : .standard)
)
)
} catch {
fatalError("Unable to open Termind's local database: \(error.localizedDescription)")
}
}
fileprivate static var computerName: String {
MacDeviceName.current
}
#if canImport(AppKit)
@NSApplicationDelegateAdaptor(AppDelegate.self) private var delegate
#endif
private func makeWindow(requestID: UUID?) -> WindowModel {
#if DEBUG
if requestID == nil, let demo = ScreenshotDemoConfiguration.active {
return demo.makeWindow(hosts: app.hostLibrary)
}
#endif
return WindowModel()
}
/// Workspace windows share app data, while each keeps its own Navigator
/// destinations and dynamic Workspaces.
var body: some Scene {
WindowGroup(id: "main", for: UUID.self) { $requestID in
RootView(window: makeWindow(requestID: requestID), requestID: requestID)
.environment(app)
.environment(\.locale, launchLanguage.locale)
.modelContainer(modelContainer)
#if canImport(AppKit)
.onAppear { delegate.app = app }
#endif
}
#if DEBUG
.restorationBehavior(ScreenshotDemoConfiguration.active == nil ? .automatic : .disabled)
#endif
.defaultSize(app.windowSizeStore.defaultSize)
// All main pages share one content floor. Opening the sidebar lets
// the native split view grow a window that cannot fit both columns.
.windowResizability(.contentMinSize)
.commands { TermindCommands(app: app) }
Window("Authentication Request", id: ExternalAuthWindowPresenter.sceneID) {
ExternalAuthWindow()
.environment(app)
.environment(\.locale, launchLanguage.locale)
.modelContainer(modelContainer)
}
.defaultSize(width: 560, height: 240)
.windowResizability(.contentSize)
// SwiftUI lists every `Window` scene in the Window menu so people can
// open it themselves. This one is only ever raised by
// `ExternalAuthWindowPresenter` when an external client asks for a
// credential, so opening it by hand shows an empty window that closes
// itself a moment later. Take the item away.
.commandsRemoved()
}
}
#if canImport(AppKit)
/// Owns native application lifecycle; window and pane commands live in `TermindCommands`.
final class AppDelegate: NSObject, NSApplicationDelegate {
weak var app: AppModel? {
didSet {
guard let app else { return }
guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return }
app.serverMetrics.start()
metricsActivity.start(app.serverMetrics)
metricsMenu.openMainWindow = { [weak app] in app?.mainWindows.openMetrics() }
metricsMenu.start(app.serverMetrics)
}
}
private let metricsActivity = MetricsActivityObserver()
private let metricsMenu = MetricsMenuBarController()
private var awaitingTermination = false
private var repliedToTermination = false
private var quietLaunchObserver: (any NSObjectProtocol)?
private let appearanceController = AppAppearanceController()
/// The launch Apple Event is readable only while it is being handled.
func applicationWillFinishLaunching(_ notification: Notification) {
appearanceController.start()
MenuImageVisibilityController.shared.start()
LoginLaunch.capture()
}
func applicationDidFinishLaunching(_ notification: Notification) {
appearanceController.start()
// Asked for again in case SwiftUI installed this delegate too late
// for the call above; `capture()` only ever latches a `true`.
LoginLaunch.capture()
openHiddenIfLaunchedAtLogin()
AppShortcutRegistry.shared.register(AppShortcutRegistration("q"))
// Product screenshots must not inherit a developer's last window
// frame from macOS state restoration. Each demo owns its capture
// size so tightly focused scenes do not inherit the larger
// workspace composition.
#if DEBUG
if let screenshotDemo = ScreenshotDemoConfiguration.active {
Task { @MainActor in
for _ in 0..<100 {
if let window = NSApp.windows.first(where: { $0.isVisible && $0.canBecomeKey }) {
window.isRestorable = false
window.contentMinSize = .zero
window.setFrame(
NSRect(origin: window.frame.origin, size: screenshotDemo.windowSize), display: true)
window.center()
window.appearance = NSAppearance(named: .aqua)
window.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
try? await Task.sleep(for: .milliseconds(500))
if screenshotDemo.scene == .hosts {
window.initialFirstResponder = nil
window.makeFirstResponder(nil)
}
let neutralFocus = window.firstResponder == nil || window.firstResponder === window
let titlebarHeight = window.frame.height - window.contentLayoutRect.height
let state =
"TERMIND_DEMO_READY window=\(window.windowNumber) "
+ "active=\(NSApp.isActive) key=\(window.isKeyWindow) neutral=\(neutralFocus) "
+ "titlebar=\(titlebarHeight) scale=\(window.backingScaleFactor)\n"
FileHandle.standardOutput.write(Data(state.utf8))
return
}
try? await Task.sleep(for: .milliseconds(100))
}
FileHandle.standardError.write(Data("Screenshot window did not appear.\n".utf8))
}
}
#endif
}
func applicationDidBecomeActive(_ notification: Notification) {
app?.devices.updateCurrentDeviceName(TermindApp.computerName)
Task { [weak self] in await self?.app?.proAccess.refresh() }
}
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
let menuEnabled =
UserDefaults.standard.object(forKey: DesktopSettingsKey.showMetricsInMenuBar) as? Bool ?? true
return !menuEnabled && app?.serverMetrics.keepsRunning != true
}
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
guard !flag else { return true }
return app?.mainWindows.handleReopen() ?? true
}
func applicationDockMenu(_ sender: NSApplication) -> NSMenu? {
let menu = NSMenu()
menu.autoenablesItems = false
let item = NSMenuItem(
title: String(localized: "New Window"), action: #selector(newWindowFromDock(_:)), keyEquivalent: "")
item.target = self
item.isEnabled = app?.mainWindows.createScene != nil
menu.addItem(item)
return menu
}
@objc private func newWindowFromDock(_ sender: NSMenuItem) {
guard app?.mainWindows.newWindow() != nil else { return }
NSApp.activate(ignoringOtherApps: true)
}
/// Opening at login is for having Termind *available*, not for taking the
/// screen from whatever the user opened their Mac to do. Hidden rather
/// than window-less: Termind quits with its last window, so the window is
/// built as usual and simply never comes forward.
///
/// The hide is armed twice because SwiftUI orders the `WindowGroup`
/// window front after this runs, which unhides the app again. Whichever
/// happens first wins, and the observer is torn down either way so a
/// window the user opens later is never hidden out from under them.
private func openHiddenIfLaunchedAtLogin() {
guard LoginLaunch.wasLaunchedAtLogin, DesktopSettingsKey.opensAtLoginQuietly else { return }
NSApp.hide(nil)
quietLaunchObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
NSApp.hide(nil)
self?.endQuietLaunch()
}
}
// A window that never becomes key — because the first hide already
// took — would otherwise leave the observer armed for the session.
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(3))
self?.endQuietLaunch()
}
}
private func endQuietLaunch() {
guard let observer = quietLaunchObserver else { return }
NotificationCenter.default.removeObserver(observer)
quietLaunchObserver = nil
}
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard let app else { return .terminateNow }
if awaitingTermination { return .terminateLater }
awaitingTermination = true
Task { @MainActor [weak self] in
await app.sftpEdits.recheck()
if app.sftpEdits.model.hasPendingChanges {
let alert = NSAlert()
alert.messageText = String(localized: "Quit with unuploaded changes?")
alert.informativeText = String(
localized:
"Your edited files have not all been uploaded. Pending edits will not be restored next time."
)
alert.addButton(withTitle: String(localized: "Cancel"))
alert.addButton(withTitle: String(localized: "Quit Anyway"))
guard alert.runModal() == .alertSecondButtonReturn else {
self?.awaitingTermination = false
NSApp.reply(toApplicationShouldTerminate: false)
return
}
}
self?.beginTermination(app)
}
return .terminateLater
}
private func beginTermination(_ app: AppModel) {
app.mainWindows.saveSizeForTermination()
let cleanup = app.shutdownForTermination()
Task { @MainActor [weak self] in
await cleanup.value
self?.finishTermination()
}
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(3))
self?.finishTermination()
}
}
private func finishTermination() {
guard !repliedToTermination else { return }
repliedToTermination = true
NSApp.reply(toApplicationShouldTerminate: true)
}
func applicationWillTerminate(_ notification: Notification) {
metricsActivity.stop()
metricsMenu.stop()
appearanceController.stop()
app?.shutdownForTermination()
}
}
#endif