diff --git a/CHANGELOG.md b/CHANGELOG.md index 117d3d098..2cdcb54a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Select > All and Select > None in the structure results pane. - Whole-schema index and table metadata reads on the driver protocol. - Script that checks the SQLite whole-schema reads against the per-table ones. +- Middle-click on a tab to close it. (#2595) +- Launch trace in Instruments' Points of Interest, and `TABLEPRO_LAUNCH_TRACE=1` for the same table on standard error. ### Changed @@ -24,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compare & Sync reopens on the source, target, mode and options it last held. - Table collation on MySQL, previously never read. - PluginKit ABI 20. Every registry plugin needs rebuilding before or with this release. +- Cold launch to a usable window, 470ms down to 260ms. +- Plugin signature checks run after the first window rather than on the launch thread, at 13ms each. +- One gate in front of every path that loads a plugin's executable, enabling one included. ### Fixed @@ -34,10 +39,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Compare & Sync toolbar naming the old pair after a saved comparison was loaded from Options. - A failed whole-schema trigger read counting as a schema with no triggers. - Compare & Sync publishing one pair's results after the pickers moved to another. -- Middle-click on a tab to close it. (#2595) - -### Fixed - - Clicks and hovers in the scrolled tab strip's edge padding landing on a tab clipped off the edge. ## [0.70.0] - 2026-09-01 diff --git a/TablePro/AppDelegate.swift b/TablePro/AppDelegate.swift index b3eefca19..45d08fda6 100644 --- a/TablePro/AppDelegate.swift +++ b/TablePro/AppDelegate.swift @@ -19,6 +19,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - URL & File Open func applicationWillFinishLaunching(_ notification: Notification) { + LaunchTracer.shared.mark(.willFinishLaunchingBegan) AppSettingsStorage.shared.migrateStartupBehaviorToReopenLastIfNeeded() AppSettingsStorage.shared.migrateJsonFieldHeightKeyIfNeeded() AIProviderRegistration.registerAll() @@ -26,21 +27,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { /// Installed before any window exists, so the bar is correct from the first frame. /// Nothing else owns it now that the app no longer runs a SwiftUI `App`. MainMenuBuilder.install(keyboard: AppSettingsManager.shared.keyboard) + LaunchTracer.shared.mark(.menuInstalled) _ = InspectorDocumentController() guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else { return } PluginManager.shared.loadPlugins() - /// The registry manifest only feeds the plugin-install UI, and fetching it is a network call - /// at launch. A sandboxed run has no user plugins directory to install into anyway. - if !AppStorageEnvironment.shared.isIsolated { - Task { await RegistryClient.shared.ensureManifest(.ifStale) } - } - - Task { await QueryHistoryManager.shared.performStartupCleanup() } - Task { @MainActor in - let activeIds = Set(ConnectionStorage.shared.loadConnections().map(\.id)) - await SQLFavoriteManager.shared.pruneOrphaned(activeConnectionIds: activeIds) - } + LaunchTracer.shared.mark(.pluginsDiscovered) + LaunchTracer.shared.mark(.willFinishLaunchingEnded) } func application(_ application: NSApplication, open urls: [URL]) { @@ -62,17 +55,22 @@ class AppDelegate: NSObject, NSApplicationDelegate { // MARK: - Lifecycle func applicationDidFinishLaunching(_ notification: Notification) { + LaunchTracer.shared.mark(.didFinishLaunchingBegan) if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { Self.logger.info("Running under XCTest, skipping normal app startup") return } - let appearanceSettings = AppSettingsManager.shared.appearance - ThemeEngine.shared.updateAppearanceAndTheme( - mode: ScreenshotEnvironment.appearanceMode ?? appearanceSettings.appearanceMode, - lightThemeId: appearanceSettings.preferredLightThemeId, - darkThemeId: appearanceSettings.preferredDarkThemeId - ) + /// `AppSettingsManager.init` has already resolved the theme from these same three values. + /// Only a screenshot run overrides the mode, so only a screenshot run resolves it twice. + if let screenshotMode = ScreenshotEnvironment.appearanceMode { + let appearanceSettings = AppSettingsManager.shared.appearance + ThemeEngine.shared.updateAppearanceAndTheme( + mode: screenshotMode, + lightThemeId: appearanceSettings.preferredLightThemeId, + darkThemeId: appearanceSettings.preferredDarkThemeId + ) + } NSWindow.allowsAutomaticWindowTabbing = true WindowOpener.shared.setWelcomePresenter { WelcomeWindowController.present() } @@ -87,36 +85,34 @@ class AppDelegate: NSObject, NSApplicationDelegate { DatabaseManager.shared.startObservingSystemEvents() DatabaseManager.shared.tabStatePersister = SessionTabStatePersister() - Task { await CloudflareTunnelManager.shared.sweepStalePidsIfNeeded() } - Task { await CloudSQLProxyManager.shared.sweepStalePidsIfNeeded() } - - MemoryPressureAdvisor.startMonitoring() + /// A notification the person acted on to launch the app is delivered as soon as + /// `applicationDidFinishLaunching` returns, before any window has a frame. Apple documents + /// the delegate assignment for that reason, and the two services below own the categories + /// `NotificationRouter` looks the action up in, so deferring either drops the action. UNUserNotificationCenter.current().delegate = self PluginNotificationService.shared.setUp() OperationCompletionReporter.shared.setUp() ChatToolBootstrap.register() + /// Prerequisites for a connection, not post-launch work: a `cloudflared` or + /// `cloud-sql-proxy` left behind by a crash still holds its local port, and a restored + /// connection reaches `ensureConnected` while intents are routing. Both hop straight off + /// the main actor, so starting them here costs the first frame nothing. + Task { await CloudflareTunnelManager.shared.sweepStalePidsIfNeeded() } + Task { await CloudSQLProxyManager.shared.sweepStalePidsIfNeeded() } + NSWorkspace.shared.notificationCenter.addObserver( self, selector: #selector(handleSystemDidWake), name: NSWorkspace.didWakeNotification, object: nil ) - if AppSettingsManager.shared.mcp.enabled, !AppStorageEnvironment.shared.isIsolated { - Task { - await MCPServerManager.shared.start(port: UInt16(clamping: AppSettingsManager.shared.mcp.port)) - } - } - - Task.detached(priority: .background) { - _ = QueryHistoryManager.shared - } - - AppLaunchCoordinator.shared.didFinishLaunching() - NotificationCenter.default.addObserver( self, selector: #selector(windowWillClose(_:)), name: NSWindow.willCloseNotification, object: nil ) + + LaunchTracer.shared.mark(.didFinishLaunchingEnded) + AppLaunchCoordinator.shared.didFinishLaunching() } func applicationDidBecomeActive(_ notification: Notification) { diff --git a/TablePro/Core/Compare/KeyOrdering.swift b/TablePro/Core/Compare/KeyOrdering.swift index 9e566587f..d5cd36182 100644 --- a/TablePro/Core/Compare/KeyOrdering.swift +++ b/TablePro/Core/Compare/KeyOrdering.swift @@ -56,9 +56,9 @@ internal struct KeyOrdering { } internal func compare(_ lhs: [PluginCellValue], _ rhs: [PluginCellValue]) -> ComparisonResult { - for (index, pair) in zip(lhs, rhs).enumerated() { + for index in 0 ..< min(lhs.count, rhs.count) { let order = index < orders.count ? orders[index] : .caseSensitiveText - let result = Self.compare(pair.0, pair.1, using: order) + let result = Self.compare(lhs[index], rhs[index], using: order) guard result == .orderedSame else { return result } } if lhs.count == rhs.count { return .orderedSame } diff --git a/TablePro/Core/Diagnostics/LaunchTracer.swift b/TablePro/Core/Diagnostics/LaunchTracer.swift new file mode 100644 index 000000000..186c7bc98 --- /dev/null +++ b/TablePro/Core/Diagnostics/LaunchTracer.swift @@ -0,0 +1,126 @@ +// +// LaunchTracer.swift +// TablePro +// + +import Darwin +import Foundation +import os + +/// Time tracing for the launch path: process exec, `main`, the two delegate callbacks, intent +/// routing, and the frame the first window actually presents. +/// +/// Stage lines are logged at debug level, so they cost nothing until capture is turned on +/// (`log stream --level debug --predicate 'subsystem == "com.TablePro"'`). Every stage is also an +/// Instruments event under Points of Interest, and the whole launch is one interval there. +/// +/// `TABLEPRO_LAUNCH_TRACE=1` additionally writes the finished table to standard error, which is how +/// a launch is measured from a script without attaching Instruments. +@MainActor +internal final class LaunchTracer { + internal static let shared = LaunchTracer() + + internal enum Stage: String { + case main + case storageResolved + case applicationCreated + case activationRoleApplied + case willFinishLaunchingBegan + case menuInstalled + case pluginsDiscovered + case willFinishLaunchingEnded + case didFinishLaunchingBegan + case didFinishLaunchingEnded + case intentsRouted + case firstWindowOrdered + case firstFramePresented + } + + internal struct Mark: Equatable { + internal let stage: Stage + internal let offset: TimeInterval + } + + nonisolated private static let dumpsToStandardError = + ProcessInfo.processInfo.environment["TABLEPRO_LAUNCH_TRACE"] == "1" + + nonisolated private let logger = Logger(subsystem: "com.TablePro", category: "Launch") + nonisolated private let signposter = OSSignposter(subsystem: "com.TablePro", category: .pointsOfInterest) + + private let processStart: Date + private var marks: [Mark] = [] + private var interval: OSSignpostIntervalState? + private var signpostID: OSSignpostID? + private var hasFinished = false + + internal init(processStart: Date = LaunchTracer.processStartDate()) { + self.processStart = processStart + } + + /// Seconds since the kernel started this process, which is the only number a person waiting for + /// the app can feel. Starting the clock at `main` hides dyld, and `ProcessInfo.systemUptime` + /// read at first touch starts it wherever this type happened to be reached first. + internal var elapsed: TimeInterval { + Date().timeIntervalSince(processStart) + } + + internal var recordedMarks: [Mark] { marks } + + internal func mark(_ stage: Stage) { + guard !hasFinished else { return } + let offset = elapsed + marks.append(Mark(stage: stage, offset: offset)) + + let id = signpostID ?? beginInterval() + signposter.emitEvent("LaunchStage", id: id, "\(stage.rawValue, privacy: .public)") + logger.debug("launch \(stage.rawValue, privacy: .public) at \(Int(offset * 1_000), privacy: .public)ms") + + guard stage == .firstFramePresented else { return } + finish() + } + + internal func report() -> String { + var lines = ["launch trace (ms since exec)"] + var previous: TimeInterval = 0 + for mark in marks { + lines.append(String( + format: " %-26@ %8.1f (+%6.1f)", + mark.stage.rawValue as NSString, + mark.offset * 1_000, + (mark.offset - previous) * 1_000 + )) + previous = mark.offset + } + return lines.joined(separator: "\n") + } + + private func beginInterval() -> OSSignpostID { + let id = signposter.makeSignpostID() + signpostID = id + interval = signposter.beginInterval("AppLaunch", id: id) + return id + } + + private func finish() { + hasFinished = true + let total = Int(elapsed * 1_000) + if let interval { + signposter.endInterval("AppLaunch", interval, "\(total, privacy: .public)ms") + } + logger.info("launch ready in \(total, privacy: .public)ms") + guard Self.dumpsToStandardError else { return } + FileHandle.standardError.write(Data("\n\(report())\n".utf8)) + } + + /// `kinfo_proc.kp_proc.p_starttime` is the only source for when the kernel started this process. + nonisolated internal static func processStartDate() -> Date { + var info = kinfo_proc() + var size = MemoryLayout.stride + var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, ProcessInfo.processInfo.processIdentifier] + guard sysctl(&name, u_int(name.count), &info, &size, nil, 0) == 0 else { return Date() } + let started = info.kp_proc.p_starttime + return Date( + timeIntervalSince1970: TimeInterval(started.tv_sec) + TimeInterval(started.tv_usec) / 1_000_000 + ) + } +} diff --git a/TablePro/Core/Plugins/PluginManager+Lifecycle.swift b/TablePro/Core/Plugins/PluginManager+Lifecycle.swift index b418f46fa..6aaee877e 100644 --- a/TablePro/Core/Plugins/PluginManager+Lifecycle.swift +++ b/TablePro/Core/Plugins/PluginManager+Lifecycle.swift @@ -24,6 +24,21 @@ extension PluginManager { disabledPluginIds = disabled if enabled { + /// `principalClass` loads the bundle's executable, so enabling is a code-loading path + /// and takes the same gate every other one does. A plugin that fails it stays disabled + /// and is withdrawn rather than published as an installed one that cannot be used. + do { + try assertLoadable(plugins[index].bundle, source: plugins[index].source) + } catch { + Self.logger.error( + "Refusing to enable plugin '\(pluginId, privacy: .public)': failed the load gate: \(error.localizedDescription, privacy: .public)" + ) + plugins[index].isEnabled = false + disabled.insert(pluginId) + disabledPluginIds = disabled + withdrawPlugin(at: plugins[index].url, reason: error) + return + } if let principalClass = plugins[index].bundle.principalClass as? any TableProPlugin.Type { let instance = principalClass.init() registerCapabilities(instance, pluginId: pluginId) diff --git a/TablePro/Core/Plugins/PluginManager+Validation.swift b/TablePro/Core/Plugins/PluginManager+Validation.swift index 43db5da34..7c21f1314 100644 --- a/TablePro/Core/Plugins/PluginManager+Validation.swift +++ b/TablePro/Core/Plugins/PluginManager+Validation.swift @@ -24,6 +24,18 @@ extension PluginManager { } } + /// The one check in front of every path that can load a plugin's executable. + /// + /// `Bundle.principalClass` loads that executable, so a caller that only means to *enable* a + /// plugin is a code-loading path too and needs the same gate as `activateLazyBundle`. Discovery + /// and lazy registration publish an entry before its signature has been checked, so nothing may + /// reach the executable on the strength of being published. + func assertLoadable(_ bundle: Bundle, source: PluginSource) throws { + try Self.validateBundleVersions(bundle) + guard source != .builtIn else { return } + try verifyCodeSignature(bundle: bundle) + } + func verifyCodeSignature(bundle: Bundle) throws { let trust = try PluginCodeSignatureVerifier.evaluate(bundle: bundle) guard case .developerID(let identity) = trust else { return } @@ -31,4 +43,43 @@ extension PluginManager { throw PluginError.developerNotTrusted(identity: identity) } } + + /// Checks the signature of every discovered user plugin, off the main actor. + /// + /// This is not the gate. A bundle's code is loaded through `validateAndLoadBundle` when it is + /// eager and `activateLazyBundle` when it is lazy, and both verify immediately before + /// `PluginBundleLoader.load` whatever this pass concluded. What it adds is that the Plugins + /// pane lists a bad bundle before the person tries to use it, which is all the check on the + /// launch thread ever bought, at a measured 13ms per installed plugin and linear in how many + /// there are. + func sweepPluginSignatures() async { + let urls = pendingSignatureChecks + pendingSignatureChecks.removeAll() + guard !urls.isEmpty else { return } + + for url in urls { + guard let failure = await Self.signatureFailure(at: url) else { continue } + Self.logger.error( + "Plugin '\(url.lastPathComponent, privacy: .public)' failed code-sign check: \(failure.localizedDescription, privacy: .public)" + ) + withdrawPlugin(at: url, reason: failure) + } + } + + @concurrent + nonisolated private static func signatureFailure(at url: URL) async -> Error? { + guard let bundle = Bundle(url: url) else { + return PluginError.invalidBundle("Cannot create bundle from \(url.lastPathComponent)") + } + do { + let trust = try PluginCodeSignatureVerifier.evaluate(bundle: bundle) + guard case .developerID(let identity) = trust else { return nil } + guard PluginDeveloperTrustStore.shared.isTrusted(identity) else { + return PluginError.developerNotTrusted(identity: identity) + } + return nil + } catch { + return error + } + } } diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 431223a05..a76f4fe72 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -132,6 +132,10 @@ final class PluginManager { @ObservationIgnored internal var lastNetworkSatisfied = false @ObservationIgnored internal var installsInFlight: Set = [] + /// User-installed bundles discovered but not yet signature-checked. `sweepPluginSignatures()` + /// drains it after the first frame. + @ObservationIgnored internal var pendingSignatureChecks: [URL] = [] + var queryBuildingDriverCache: [String: (any PluginDatabaseDriver)?] = [:] init( @@ -314,24 +318,6 @@ final class PluginManager { } return } - if source == .userInstalled { - do { - try verifyCodeSignature(bundle: bundle) - } catch { - Self.logger.error("Lazy plugin '\(manifest.bundleId)' failed code-sign check: \(error.localizedDescription)") - rejectedPlugins.append(RejectedPlugin( - url: url, - bundleId: manifest.bundleId, - registryId: Self.readRegistryMetadata(for: url)?.pluginId, - name: manifest.bundleId, - reason: error.localizedDescription, - isOutdated: false, - providedDatabaseTypeIds: manifest.providedDatabaseTypeIds - )) - return - } - } - let bundleId = manifest.bundleId let primaryTypeId = manifest.providedDatabaseTypeIds.first let additionalTypeIds = Array(manifest.providedDatabaseTypeIds.dropFirst()) @@ -395,6 +381,65 @@ final class PluginManager { Self.logger.debug("Registered lazy plugin '\(bundleId)': drivers=\(manifest.providedDatabaseTypeIds), exports=\(manifest.providedExportFormatIds), imports=\(manifest.providedImportFormatIds), inspectors=\(manifest.providedInspectorIds)") } + /// Takes back everything `registerLazyManifest` published for this bundle, so a plugin the app + /// would refuse to activate stops being offered as an installed one. + internal func withdrawPlugin(at url: URL, reason: Error) { + let manifest = Bundle(url: url).flatMap { PluginManifest(bundle: $0) } + + plugins.removeAll { $0.url == url } + rebuildLazyRegistrations() + + guard !rejectedPlugins.contains(where: { $0.url == url }) else { return } + let name = manifest?.bundleId ?? url.deletingPathExtension().lastPathComponent + rejectedPlugins.append(RejectedPlugin( + url: url, + bundleId: manifest?.bundleId, + registryId: Self.readRegistryMetadata(for: url)?.pluginId, + name: name, + reason: reason.localizedDescription, + isOutdated: false, + providedDatabaseTypeIds: manifest?.providedDatabaseTypeIds ?? [] + )) + } + + /// Rebuilt from the surviving manifests rather than filtered by URL. + /// + /// Two bundles may declare the same driver, format or inspector key, and the one registered + /// last owns it. Deleting the withdrawn bundle's keys would take the shared key with it and + /// leave the valid plugin listed but unreachable for the rest of the process. + private func rebuildLazyRegistrations() { + lazyDriverURLs = [:] + lazyExportURLs = [:] + lazyImportURLs = [:] + lazyInspectorURLs = [:] + lazyInspectorFileExtensions = [:] + lazyInspectorUTIs = [:] + + for entry in plugins { + guard let bundle = Bundle(url: entry.url), + let manifest = PluginManifest(bundle: bundle), + manifest.supportsLazyLoad else { continue } + for typeId in manifest.providedDatabaseTypeIds { + lazyDriverURLs[typeId] = entry.url + } + for formatId in manifest.providedExportFormatIds { + lazyExportURLs[formatId] = entry.url + } + for formatId in manifest.providedImportFormatIds { + lazyImportURLs[formatId] = entry.url + } + for inspectorId in manifest.providedInspectorIds { + lazyInspectorURLs[inspectorId] = entry.url + } + for ext in manifest.providedInspectorFileExtensions { + lazyInspectorFileExtensions[ext.lowercased()] = entry.url + } + for uti in manifest.providedInspectorUTIs { + lazyInspectorUTIs[uti] = entry.url + } + } + } + func activateDriver(databaseTypeId typeId: String) { guard driverPlugins[typeId] == nil else { return } guard let url = lazyDriverURLs[typeId] else { return } @@ -438,14 +483,12 @@ final class PluginManager { let entry = plugins.first(where: { $0.id == bundleId }) - if entry?.source != .builtIn { - do { - try verifyCodeSignature(bundle: bundle) - } catch { - Self.logger.error("Refusing to activate lazy plugin '\(bundleId)': code-signature re-check failed before load: \(error.localizedDescription)") - recordLazyActivationRejection(url: url, bundleId: bundleId, entry: entry, error: error) - return - } + do { + try assertLoadable(bundle, source: entry?.source ?? .userInstalled) + } catch { + Self.logger.error("Refusing to activate lazy plugin '\(bundleId)': failed the load gate: \(error.localizedDescription)") + recordLazyActivationRejection(url: url, bundleId: bundleId, entry: entry, error: error) + return } do { @@ -500,7 +543,7 @@ final class PluginManager { let bundle: Bundle } - nonisolated private static func validateBundleVersions(_ bundle: Bundle) throws { + nonisolated internal static func validateBundleVersions(_ bundle: Bundle) throws { let infoPlist = bundle.infoDictionary ?? [:] let declaredPluginKit = infoPlist["TableProPluginKitVersion"] as? Int let declaredInspectorKit = infoPlist["TableProInspectorKitVersion"] as? Int @@ -805,8 +848,14 @@ final class PluginManager { try Self.validateBundleVersions(bundle) + /// The signature is not checked here. `SecStaticCodeCheckValidity` hashes the whole bundle, + /// measured at 13ms per user-installed plugin and linear in how many are installed, and + /// discovery loads nothing: it only records the URL. The two gates that decide whether a + /// bundle's code runs both stay where they are, `validateAndLoadBundle` for an eager plugin + /// and `activateLazyBundle` for a lazy one, and `sweepPluginSignatures()` re-checks these + /// off the main actor once the first window is up so the Plugins pane still lists a bad one. if source == .userInstalled { - try verifyCodeSignature(bundle: bundle) + pendingSignatureChecks.append(url) } pendingPluginURLs.append((url: url, source: source)) diff --git a/TablePro/Core/Plugins/PluginModels.swift b/TablePro/Core/Plugins/PluginModels.swift index 88d657094..bdc96519c 100644 --- a/TablePro/Core/Plugins/PluginModels.swift +++ b/TablePro/Core/Plugins/PluginModels.swift @@ -41,9 +41,3 @@ struct RejectedPlugin: Sendable { let isOutdated: Bool let providedDatabaseTypeIds: [String] } - -extension PluginEntry { - var exportPlugin: (any ExportFormatPlugin.Type)? { - bundle.principalClass as? any ExportFormatPlugin.Type - } -} diff --git a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift index 10683619e..9223ca4be 100644 --- a/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/AppLaunchCoordinator.swift @@ -14,28 +14,37 @@ internal final class AppLaunchCoordinator { internal static let shared = AppLaunchCoordinator() nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "AppLaunchCoordinator") - internal static let collectionWindow: Duration = .milliseconds(150) private(set) var phase: LaunchPhase = .launching + @ObservationIgnored private let environment: any LaunchEnvironment private var pendingIntents: [LaunchIntent] = [] - private var deadlineTask: Task? private var hasFinishedLaunching = false + private var isDraining = false + private var hasRoutedAnyIntent = false + private var hasFinishedStartup = false - private init() {} + internal init(environment: any LaunchEnvironment = LiveLaunchEnvironment()) { + self.environment = environment + } // MARK: - App Lifecycle Hooks + /// Intents are collected for exactly one run-loop turn rather than a fixed span of time. + /// + /// Measured on macOS 27 with a probe app registered for a URL scheme and a document type: + /// LaunchServices always delivers the gesture that started the app to `application(_:open:)` + /// before `applicationDidFinishLaunching` returns, coalesces several documents from one gesture + /// into a single call, and delivers a straggler from a second request 2.4 to 7.1ms later, in + /// every run before the first turn of the main queue. A timed window buys nothing over that and + /// costs the person every millisecond of it, because the window they are waiting for is not + /// built until it closes. internal func didFinishLaunching() { hasFinishedLaunching = true deliver(UITestLaunchEnvironment.launchIntents) - let deadline = Date().addingTimeInterval(0.150) - phase = .collectingIntents(deadline: deadline) - deadlineTask = Task { [weak self] in - try? await Task.sleep(for: Self.collectionWindow) - await MainActor.run { - self?.transitionToRouting() - } + phase = .collectingIntents + environment.scheduleNextTurn { [weak self] in + self?.transitionToRouting() } } @@ -83,101 +92,64 @@ internal final class AppLaunchCoordinator { internal func handleReopen(hasVisibleWindows: Bool) -> Bool { AppActivationPolicyController.shared.adoptUserSession() if hasVisibleWindows { return true } - showWelcomeWindow() + environment.presentWelcome() return false } // MARK: - Phase Transitions - private func deliver(_ intents: [LaunchIntent]) { + /// The one way an intent enters the launch pipeline, whether it came from a URL, a handoff, or + /// the UI-test environment. Everything is queued; nothing routes straight from here. + internal func deliver(_ intents: [LaunchIntent]) { guard !intents.isEmpty else { return } - if phase.isAcceptingIntents { - pendingIntents.append(contentsOf: intents) - WindowOpener.shared.closeWelcome() - } else { - Task { [weak self] in - guard let self else { return } - for intent in intents { - await LaunchIntentRouter.shared.route(intent) - } - self.dismissWelcomeIfMainWindowVisible() - } + pendingIntents.append(contentsOf: intents) + guard !phase.isAcceptingIntents else { + environment.closeWelcome() + return } + drain() } private func transitionToRouting() { - guard hasFinishedLaunching else { return } + guard hasFinishedLaunching, phase == .collectingIntents else { return } phase = .routing - let intents = pendingIntents - pendingIntents.removeAll() - - Task { [weak self] in - guard let self else { return } - for intent in intents { - await LaunchIntentRouter.shared.route(intent) - } - self.dismissWelcomeIfMainWindowVisible() - self.runStartupBehaviorIfNeeded(skipping: intents) - self.phase = .ready - self.finalizeWindowsIfNoVisibleMain(intents: intents) - } + drain() } - private func dismissWelcomeIfMainWindowVisible() { - guard NSApp.windows.contains(where: { Self.isMainWindow($0) && $0.isVisible }) else { return } - WindowOpener.shared.closeWelcome() - } + /// One consumer for the whole queue, so an intent that arrives while another is suspended joins + /// the pass in flight instead of racing it. + /// + /// Routing an intent suspends: `TabRouter.openTable` awaits `ensureConnected`. Two independent + /// tasks for the same connection can each look, each find no session, and each open one, which + /// on an embedded engine means two writable instances of one file that never see each other's + /// writes. Draining serially also means the cutoff between "collected at launch" and "arrived + /// later" stops mattering: a straggler is routed in order either way, so nothing rests on when + /// LaunchServices happens to deliver it. + private func drain() { + guard !isDraining else { return } + isDraining = true - /// A launch nobody asked for opens nothing, whatever the startup behaviour says. Reopening the - /// last session, or falling back to the welcome window, would put the person's connections on - /// screen because a client asked a question. - private func runStartupBehaviorIfNeeded(skipping intents: [LaunchIntent]) { - guard AppActivationPolicyController.shared.origin == .user else { return } - guard intents.isEmpty else { return } - - let general = AppSettingsStorage.shared.loadGeneral() - switch general.startupBehavior { - case .showWelcome: - for window in NSApp.windows where Self.isMainWindow(window) { - window.close() - } - case .reopenLast: - reopenLastSession() - } - } - - private func reopenLastSession() { - guard !NSApp.windows.contains(where: { - ConnectionWindowIdentity.isConnectionWindow($0.identifier?.rawValue) - }) else { return } - - let connectionIds = LastOpenConnectionsStorage.shared.load() - guard !connectionIds.isEmpty else { return } - - let knownIds = Set(ConnectionStorage.shared.loadConnections().map(\.id)) - var frontWindow: NSWindow? - for connectionId in connectionIds where knownIds.contains(connectionId) { - WindowManager.shared.openTab( - payload: EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault), - activate: false, - autoConnect: true - ) - if frontWindow == nil { - frontWindow = WindowManager.shared.window(for: connectionId) + Task { [weak self] in + guard let self else { return } + while !self.pendingIntents.isEmpty { + let intent = self.pendingIntents.removeFirst() + self.hasRoutedAnyIntent = true + await self.environment.route(intent) } + self.isDraining = false + self.environment.dismissWelcomeIfMainWindowVisible() + self.finishStartupIfNeeded() } - - guard let frontWindow else { return } - WindowOpener.shared.closeWelcome() - frontWindow.makeKeyAndOrderFront(nil) - AppActivationPolicyController.shared.activate() } - private func finalizeWindowsIfNoVisibleMain(intents: [LaunchIntent]) { - guard AppActivationPolicyController.shared.origin == .user else { return } - guard intents.isEmpty else { return } - guard !NSApp.windows.contains(where: { Self.isMainWindow($0) && $0.isVisible }) else { return } - showWelcomeWindow() + private func finishStartupIfNeeded() { + guard !hasFinishedStartup, phase == .routing else { return } + hasFinishedStartup = true + environment.runStartupBehavior(hadIntents: hasRoutedAnyIntent) + LaunchTracer.shared.mark(.intentsRouted) + phase = .ready + environment.presentWelcomeIfNoMainWindow(hadIntents: hasRoutedAnyIntent) + environment.launchDidComplete() } // MARK: - Window Identification @@ -189,8 +161,4 @@ internal final class AppLaunchCoordinator { internal static func isWelcomeWindow(_ window: NSWindow) -> Bool { ConnectionWindowIdentity.isWelcomeWindow(window.identifier?.rawValue) } - - private func showWelcomeWindow() { - WindowOpener.shared.openWelcome() - } } diff --git a/TablePro/Core/Services/Infrastructure/LaunchEnvironment.swift b/TablePro/Core/Services/Infrastructure/LaunchEnvironment.swift new file mode 100644 index 000000000..6473df450 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/LaunchEnvironment.swift @@ -0,0 +1,192 @@ +// +// LaunchEnvironment.swift +// TablePro +// + +import AppKit +import Foundation + +/// Everything `AppLaunchCoordinator` does to the world, so the coordinator itself is pure +/// orchestration: which intents are collected, when routing runs, and what phase follows. +/// +/// The launch sequence had no test of any kind before this existed, because every path through it +/// opened a real window, read the real settings store, or waited on a real timer. +@MainActor +internal protocol LaunchEnvironment: AnyObject { + /// Runs `body` on the next pass of the main run loop, in common modes so a tracking or modal + /// loop cannot hold it back. + func scheduleNextTurn(_ body: @escaping @MainActor () -> Void) + func route(_ intent: LaunchIntent) async + func closeWelcome() + func dismissWelcomeIfMainWindowVisible() + func runStartupBehavior(hadIntents: Bool) + func presentWelcomeIfNoMainWindow(hadIntents: Bool) + func presentWelcome() + /// Routing has finished. The live environment has usually completed the launch already, off + /// the first window becoming key; this is what covers a launch that shows no window. + func launchDidComplete() +} + +@MainActor +internal final class LiveLaunchEnvironment: LaunchEnvironment { + /// Nothing may start the deferred work later than this, however the frame observation goes. + private static let backstop = Duration.seconds(2) + + private var firstKeyWindowObserver: (any NSObjectProtocol)? + private var backstopTask: Task? + private var hasCompletedLaunch = false + private var hasMarkedFirstWindow = false + + /// The first window becoming key is the signal, not the end of intent routing. + /// + /// `TabRouter.openTable` orders its window and makes it key, and only then awaits + /// `ensureConnected`. Waiting for routing to return would hold every deferred subsystem, the + /// MCP server included, behind a database server that may be slow or unreachable, over a window + /// the person is already looking at. + /// + /// The observer stays armed until a frame actually lands. A launch that opens Welcome and then + /// replaces it with a connection window closes the first candidate before its display link ever + /// fires, and a view that is hidden or off-display is documented not to drive one; latching on + /// the candidate rather than on its frame would leave the memory-pressure monitor, the signature + /// sweep, the history cleanup and the MCP server off for the life of the process. + internal init() { + firstKeyWindowObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.observeFirstFrame(of: NSApp.keyWindow) + } + } + backstopTask = Task { [weak self] in + try? await Task.sleep(for: Self.backstop) + guard !Task.isCancelled else { return } + self?.finishLaunch() + } + } + + + internal func scheduleNextTurn(_ body: @escaping @MainActor () -> Void) { + RunLoop.main.perform(inModes: [.common]) { + MainActor.assumeIsolated(body) + } + } + + internal func route(_ intent: LaunchIntent) async { + await LaunchIntentRouter.shared.route(intent) + } + + internal func closeWelcome() { + WindowOpener.shared.closeWelcome() + } + + internal func dismissWelcomeIfMainWindowVisible() { + guard NSApp.windows.contains(where: { AppLaunchCoordinator.isMainWindow($0) && $0.isVisible }) else { return } + WindowOpener.shared.closeWelcome() + } + + /// A launch nobody asked for opens nothing, whatever the startup behaviour says. Reopening the + /// last session, or falling back to the welcome window, would put the person's connections on + /// screen because a client asked a question. + internal func runStartupBehavior(hadIntents: Bool) { + guard AppActivationPolicyController.shared.origin == .user else { return } + guard !hadIntents else { return } + + let general = AppSettingsStorage.shared.loadGeneral() + switch general.startupBehavior { + case .showWelcome: + for window in NSApp.windows where AppLaunchCoordinator.isMainWindow(window) { + window.close() + } + case .reopenLast: + reopenLastSession() + } + } + + internal func presentWelcomeIfNoMainWindow(hadIntents: Bool) { + guard AppActivationPolicyController.shared.origin == .user else { return } + guard !hadIntents else { return } + guard !NSApp.windows.contains(where: { AppLaunchCoordinator.isMainWindow($0) && $0.isVisible }) else { return } + presentWelcome() + } + + internal func presentWelcome() { + WindowOpener.shared.openWelcome() + } + + /// Routing is done. A launch that put a window on screen has usually finished already, off that + /// window's first frame; this is what covers one that shows no window, which is how a process + /// the MCP bridge started runs. + internal func launchDidComplete() { + let window = NSApp.keyWindow ?? NSApp.windows.first(where: \.isVisible) + guard let window else { + finishLaunch() + return + } + observeFirstFrame(of: window) + } + + private func observeFirstFrame(of window: NSWindow?) { + guard !hasCompletedLaunch else { return } + if !hasMarkedFirstWindow { + hasMarkedFirstWindow = true + LaunchTracer.shared.mark(.firstWindowOrdered) + } + guard let window else { + finishLaunch() + return + } + window.afterNextFrame { [weak self] in + self?.finishLaunch() + } + } + + /// The single completion, whichever of the three routes reaches it first. The observer is + /// removed here rather than in a `deinit`, which cannot reach main-actor state; one of these + /// lives for the process, held by `AppLaunchCoordinator.shared`. + private func finishLaunch() { + guard !hasCompletedLaunch else { return } + hasCompletedLaunch = true + backstopTask?.cancel() + backstopTask = nil + if let firstKeyWindowObserver { + NotificationCenter.default.removeObserver(firstKeyWindowObserver) + self.firstKeyWindowObserver = nil + } + + if !hasMarkedFirstWindow { + hasMarkedFirstWindow = true + LaunchTracer.shared.mark(.firstWindowOrdered) + } + LaunchTracer.shared.mark(.firstFramePresented) + PostLaunchWork.start() + } + + private func reopenLastSession() { + guard !NSApp.windows.contains(where: { + ConnectionWindowIdentity.isConnectionWindow($0.identifier?.rawValue) + }) else { return } + + let connectionIds = LastOpenConnectionsStorage.shared.load() + guard !connectionIds.isEmpty else { return } + + let knownIds = Set(ConnectionStorage.shared.loadConnections().map(\.id)) + var frontWindow: NSWindow? + for connectionId in connectionIds where knownIds.contains(connectionId) { + WindowManager.shared.openTab( + payload: EditorTabPayload(connectionId: connectionId, intent: .restoreOrDefault), + activate: false, + autoConnect: true + ) + if frontWindow == nil { + frontWindow = WindowManager.shared.window(for: connectionId) + } + } + + guard let frontWindow else { return } + WindowOpener.shared.closeWelcome() + frontWindow.makeKeyAndOrderFront(nil) + AppActivationPolicyController.shared.activate() + } +} diff --git a/TablePro/Core/Services/Infrastructure/LaunchPhase.swift b/TablePro/Core/Services/Infrastructure/LaunchPhase.swift index 2bbf8e0c5..a0518913a 100644 --- a/TablePro/Core/Services/Infrastructure/LaunchPhase.swift +++ b/TablePro/Core/Services/Infrastructure/LaunchPhase.swift @@ -7,7 +7,7 @@ import Foundation internal enum LaunchPhase: Equatable, Sendable { case launching - case collectingIntents(deadline: Date) + case collectingIntents case routing case ready diff --git a/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift b/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift new file mode 100644 index 000000000..12ef1db18 --- /dev/null +++ b/TablePro/Core/Services/Infrastructure/PostLaunchWork.swift @@ -0,0 +1,56 @@ +// +// PostLaunchWork.swift +// TablePro +// + +import Foundation +import os + +/// The subsystem work a launch has to do but the first window does not need. +/// +/// Everything here is main-actor work: a registry fetch, a favourites prune that reads the +/// connection store, an MCP server bind, a history cleanup. Started from the delegate it runs while +/// the first window is still being built and takes main-thread time away from it. Started once that +/// window has presented a frame, it costs the person nothing, because by then they are already +/// looking at the app. +/// +/// Nothing whose absence is observable before that frame belongs here, and the bar is not whether +/// it is cheap. A notification handler has to be registered before `applicationDidFinishLaunching` +/// returns or the action that launched the app is dropped, and a stale tunnel process has to be +/// swept before a restored connection tries to bind the port it still holds. Both stay in +/// `AppDelegate`, along with the menu bar, the theme, the window presenters and the +/// `UNUserNotificationCenter` delegate. +@MainActor +internal enum PostLaunchWork { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "Launch") + + private static var hasStarted = false + + /// Idempotent, because the first window presenting a frame and the end of intent routing both + /// reach this, and which arrives first depends on how long a restored connection takes. + internal static func start() { + guard !hasStarted else { return } + hasStarted = true + logger.debug("post-launch work starting") + + MemoryPressureAdvisor.startMonitoring() + + Task { await PluginManager.shared.sweepPluginSignatures() } + Task { await QueryHistoryManager.shared.performStartupCleanup() } + + guard !AppStorageEnvironment.shared.isIsolated else { return } + + /// The registry manifest only feeds the plugin-install UI, and fetching it is a network + /// call. A sandboxed run has no user plugins directory to install into anyway. + Task { await RegistryClient.shared.ensureManifest(.ifStale) } + + Task { @MainActor in + let activeIds = Set(ConnectionStorage.shared.loadConnections().map(\.id)) + await SQLFavoriteManager.shared.pruneOrphaned(activeConnectionIds: activeIds) + } + + let mcp = AppSettingsManager.shared.mcp + guard mcp.enabled else { return } + Task { await MCPServerManager.shared.start(port: UInt16(clamping: mcp.port)) } + } +} diff --git a/TablePro/Extensions/NSWindow+FirstFrame.swift b/TablePro/Extensions/NSWindow+FirstFrame.swift new file mode 100644 index 000000000..5e4cbb734 --- /dev/null +++ b/TablePro/Extensions/NSWindow+FirstFrame.swift @@ -0,0 +1,56 @@ +// +// NSWindow+FirstFrame.swift +// TablePro +// + +import AppKit +import QuartzCore + +internal extension NSWindow { + /// Runs `body` once the display has actually shown a frame of this window. + /// + /// `CATransaction.setCompletionBlock` is not that moment: it tracks a transaction's animations, + /// so for an ordinary non-animated first draw it fires at `commit()` and reports a frame the + /// WindowServer has not presented yet. `NSView.displayLink(target:selector:)` is the documented + /// callback for "the display is about to show the next frame", which is the number a person + /// waiting for the app can feel. + /// + /// A window with no screen never drives a display link, so `body` runs immediately there rather + /// than never. That covers a launch nobody is looking at, which is exactly the case that must + /// not stall. + func afterNextFrame(_ body: @escaping @MainActor () -> Void) { + guard screen != nil, let view = contentView else { + body() + return + } + FirstFrameObserver.observe(view, then: body) + } +} + +@MainActor +private final class FirstFrameObserver: NSObject { + private static var live: Set = [] + + private let body: @MainActor () -> Void + private var link: CADisplayLink? + + private init(body: @escaping @MainActor () -> Void) { + self.body = body + } + + static func observe(_ view: NSView, then body: @escaping @MainActor () -> Void) { + let observer = FirstFrameObserver(body: body) + let link = view.displayLink(target: observer, selector: #selector(fire)) + observer.link = link + live.insert(observer) + link.add(to: .main, forMode: .common) + } + + @objc + private func fire() { + link?.invalidate() + link = nil + Self.live.remove(self) + body() + } +} diff --git a/TablePro/main.swift b/TablePro/main.swift index 5e1705374..3b060df69 100644 --- a/TablePro/main.swift +++ b/TablePro/main.swift @@ -18,9 +18,15 @@ import AppKit /// The activation policy resolves next, before `NSApplicationMain`. A process the MCP bridge /// started opens no window, and this is the only point early enough to keep LaunchServices from /// registering it as a foreground app and putting it in the Dock first. +MainActor.assumeIsolated { LaunchTracer.shared.mark(.main) } AppStorageEnvironment.bootstrap() +MainActor.assumeIsolated { LaunchTracer.shared.mark(.storageResolved) } let application = NSApplication.shared -MainActor.assumeIsolated { AppActivationPolicyController.shared.applyLaunchRole() } +MainActor.assumeIsolated { + LaunchTracer.shared.mark(.applicationCreated) + AppActivationPolicyController.shared.applyLaunchRole() + LaunchTracer.shared.mark(.activationRoleApplied) +} let delegate = MainActor.assumeIsolated { AppDelegate() } application.delegate = delegate _ = NSApplicationMain(CommandLine.argc, CommandLine.unsafeArgv) diff --git a/TableProTests/Core/Diagnostics/LaunchTracerTests.swift b/TableProTests/Core/Diagnostics/LaunchTracerTests.swift new file mode 100644 index 000000000..3b2dc85ac --- /dev/null +++ b/TableProTests/Core/Diagnostics/LaunchTracerTests.swift @@ -0,0 +1,58 @@ +// +// LaunchTracerTests.swift +// TableProTests +// + +@testable import TablePro +import XCTest + +@MainActor +final class LaunchTracerTests: XCTestCase { + func testMarksAreRecordedInOrderWithMonotonicOffsets() { + let tracer = LaunchTracer(processStart: Date()) + + tracer.mark(.main) + tracer.mark(.applicationCreated) + tracer.mark(.didFinishLaunchingBegan) + + XCTAssertEqual(tracer.recordedMarks.map(\.stage), [.main, .applicationCreated, .didFinishLaunchingBegan]) + XCTAssertEqual(tracer.recordedMarks.map(\.offset), tracer.recordedMarks.map(\.offset).sorted()) + } + + /// Every offset is measured from process exec, so a tracer built with a start in the past + /// reports that gap rather than starting its clock at first touch. + func testOffsetsAreMeasuredFromProcessStart() { + let tracer = LaunchTracer(processStart: Date().addingTimeInterval(-2)) + + tracer.mark(.main) + + let offset = try? XCTUnwrap(tracer.recordedMarks.first?.offset) + XCTAssertGreaterThan(offset ?? 0, 1.9) + } + + func testTheFirstFrameEndsTheTraceSoLaterMarksAreIgnored() { + let tracer = LaunchTracer(processStart: Date()) + + tracer.mark(.main) + tracer.mark(.firstFramePresented) + tracer.mark(.intentsRouted) + + XCTAssertEqual(tracer.recordedMarks.map(\.stage), [.main, .firstFramePresented]) + } + + func testTheReportNamesEveryStageItRecorded() { + let tracer = LaunchTracer(processStart: Date()) + + tracer.mark(.main) + tracer.mark(.menuInstalled) + + let report = tracer.report() + XCTAssertTrue(report.contains("main")) + XCTAssertTrue(report.contains("menuInstalled")) + XCTAssertFalse(report.contains("intentsRouted")) + } + + func testProcessStartIsInThePast() { + XCTAssertLessThan(LaunchTracer.processStartDate(), Date()) + } +} diff --git a/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift b/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift new file mode 100644 index 000000000..1d5db8212 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginSignatureGatePlacementTests.swift @@ -0,0 +1,111 @@ +// +// PluginSignatureGatePlacementTests.swift +// TableProTests +// + +import Foundation +import Testing + +/// Where the plugin signature check runs is a launch-time budget decision and a security decision +/// at once, and only one of the two is visible in a diff. +/// +/// `SecStaticCodeCheckValidity` hashes the whole bundle: measured at 13ms per user-installed plugin +/// on the launch thread, linear in how many are installed. Discovery and lazy registration load no +/// code, so a check there buys nothing but the Plugins pane's rejected list, which +/// `sweepPluginSignatures()` now fills off the main actor after the first frame. The two calls that +/// do load code must keep verifying, immediately before `PluginBundleLoader.load`. +@Suite("Plugin signature gate placement") +struct PluginSignatureGatePlacementTests { + @Test("Discovery and lazy registration do not verify signatures") + func launchPathDoesNotVerifySignatures() throws { + for function in ["discoverPlugin", "registerLazyManifest"] { + let body = try Self.functionBody(named: function) + #expect( + !body.contains("verifyCodeSignature") && !body.contains("PluginCodeSignatureVerifier"), + """ + `\(function)` verifies a signature again. It loads no code, so the check costs the \ + launch 13ms per installed plugin and gates nothing. Leave it to \ + `sweepPluginSignatures()`. + """ + ) + } + } + + @Test("Every path that loads a plugin's executable goes through the gate first") + func loadPathsVerifySignatures() throws { + for function in ["activateLazyBundle", "validateAndLoadBundle", "setEnabled"] { + let body = try Self.functionBody(named: function) + #expect( + body.contains("assertLoadable") || body.contains("PluginCodeSignatureVerifier"), + """ + `\(function)` reaches a plugin's executable without the load gate. Both `PluginBundleLoader.load` and `Bundle.principalClass` load it, and discovery now publishes an entry before its signature has been checked, so an unverified bundle is reachable from here. + """ + ) + } + } + + @Test("Nothing outside the gated paths touches principalClass") + func principalClassIsOnlyReachedFromGatedPaths() throws { + /// `validateDependencies` is allowed because it reads `principalClass` only for a bundle + /// that is already `isLoaded`, so the executable it would load is the one a gated path + /// already loaded. + let gated = ["setEnabled", "registerBundle", "activateLazyBundle", "validateDependencies"] + var offenders: [String] = [] + for url in try Self.pluginSources() { + let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) + for (index, line) in lines.enumerated() where line.contains(".principalClass") { + let owner = Self.enclosingFunction(of: index, in: lines) + guard !gated.contains(where: { owner.contains($0) }) else { continue } + offenders.append("\(url.lastPathComponent):\(index + 1) in \(owner)") + } + } + #expect( + offenders.isEmpty, + """ + `Bundle.principalClass` loads the bundle's executable, so every use of it belongs in a function that has already called `assertLoadable`: \(offenders.sorted()) + """ + ) + } + + private static func enclosingFunction(of line: Int, in lines: [String]) -> String { + for index in stride(from: line, through: 0, by: -1) where lines[index].contains("func ") { + return lines[index].trimmingCharacters(in: .whitespaces) + } + return "" + } + + private static func pluginSources(file: StaticString = #filePath) throws -> [URL] { + let root = try pluginManagerSource(file: file).deletingLastPathComponent() + let contents = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) + return contents.filter { $0.pathExtension == "swift" } + } + + /// Everything from the declaration line to the first line that closes it at the declaration's + /// own indentation, which is enough to tell one function's body from its neighbours'. + private static func functionBody(named name: String) throws -> String { + for url in try pluginSources() { + let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) + guard let start = lines.firstIndex(where: { $0.contains("func \(name)(") }) else { continue } + let indent = lines[start].prefix { $0 == " " }.count + let closing = String(repeating: " ", count: indent) + "}" + guard let end = lines[(start + 1)...].firstIndex(of: closing) else { continue } + return lines[start ... end].joined(separator: "\n") + } + throw PlacementError.functionNotFound(name) + } + + private static func pluginManagerSource(file: StaticString = #filePath) throws -> URL { + var directory = URL(fileURLWithPath: "\(file)").deletingLastPathComponent() + while directory.path != "/" { + let candidate = directory.appendingPathComponent("TablePro/Core/Plugins/PluginManager.swift") + if FileManager.default.fileExists(atPath: candidate.path) { return candidate } + directory = directory.deletingLastPathComponent() + } + throw PlacementError.sourceNotFound + } + + private enum PlacementError: Error { + case functionNotFound(String) + case sourceNotFound + } +} diff --git a/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift b/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift new file mode 100644 index 000000000..1f1131f7a --- /dev/null +++ b/TableProTests/Core/Plugins/PluginSignatureSweepTests.swift @@ -0,0 +1,131 @@ +// +// PluginSignatureSweepTests.swift +// TableProTests +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +@MainActor +final class PluginSignatureSweepTests: XCTestCase { + private var root: URL! + private var manager: PluginManager! + + override func setUp() async throws { + try await super.setUp() + root = FileManager.default.temporaryDirectory + .appendingPathComponent("PluginSignatureSweepTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + manager = PluginManager( + userDefaults: UserDefaults(suiteName: "PluginSignatureSweepTests-\(UUID().uuidString)") ?? .standard, + builtInPluginsURL: nil, + userPluginsDir: root.appendingPathComponent("Plugins", isDirectory: true) + ) + } + + override func tearDown() async throws { + manager = nil + if let root { try? FileManager.default.removeItem(at: root) } + root = nil + try await super.tearDown() + } + + /// An ad-hoc signed bundle is not first-party and carries no Developer ID, which is what the + /// sweep is looking for. A test fixture cannot be signed at all, which fails the same way. + func testSweepRejectsAnUnsignedBundleAndWithdrawsIt() async throws { + let url = try makeBundle(id: "com.example.unsigned", databaseTypeIds: ["ExampleDB"]) + manager.pendingSignatureChecks = [url] + + await manager.sweepPluginSignatures() + + XCTAssertEqual(manager.rejectedPlugins.count, 1) + XCTAssertEqual(manager.rejectedPlugins.first?.url, url) + XCTAssertEqual(manager.rejectedPlugins.first?.bundleId, "com.example.unsigned") + XCTAssertTrue(manager.pendingSignatureChecks.isEmpty) + } + + func testSweepRecordsARejectionOnlyOncePerBundle() async throws { + let url = try makeBundle(id: "com.example.unsigned", databaseTypeIds: ["ExampleDB"]) + + manager.pendingSignatureChecks = [url] + await manager.sweepPluginSignatures() + manager.pendingSignatureChecks = [url] + await manager.sweepPluginSignatures() + + XCTAssertEqual(manager.rejectedPlugins.count, 1) + } + + func testSweepWithNothingPendingRecordsNothing() async { + await manager.sweepPluginSignatures() + + XCTAssertTrue(manager.rejectedPlugins.isEmpty) + } + + /// A bundle the sweep rejects must stop being offered, or the app would list a driver it will + /// refuse to activate. + func testWithdrawingRemovesTheEntryAndItsLazyDriverRegistration() throws { + let url = try makeBundle(id: "com.example.unsigned", databaseTypeIds: ["ExampleDB"]) + manager.plugins = [try entry(id: "com.example.unsigned", url: url)] + + manager.withdrawPlugin(at: url, reason: PluginError.signatureInvalid(detail: "bundle is not signed")) + + XCTAssertTrue(manager.plugins.isEmpty) + XCTAssertNil(manager.lazyDriverURLs["ExampleDB"]) + XCTAssertEqual(manager.rejectedPlugins.count, 1) + } + + /// Two bundles can declare the same driver key, and the one registered last owns it. Deleting + /// the withdrawn bundle's keys by URL would take the shared key with it, leaving the valid + /// plugin listed but impossible to activate. + func testWithdrawingRestoresAKeyAnotherPluginAlsoDeclares() throws { + let goodURL = try makeBundle(id: "com.example.good", databaseTypeIds: ["ExampleDB"]) + let badURL = try makeBundle(id: "com.example.bad", databaseTypeIds: ["ExampleDB"]) + manager.plugins = [ + try entry(id: "com.example.good", url: goodURL), + try entry(id: "com.example.bad", url: badURL) + ] + + manager.withdrawPlugin(at: badURL, reason: PluginError.signatureInvalid(detail: "bundle is not signed")) + + XCTAssertEqual(manager.plugins.map(\.id), ["com.example.good"]) + XCTAssertEqual(manager.lazyDriverURLs["ExampleDB"], goodURL) + } + + private func entry(id: String, url: URL) throws -> PluginEntry { + PluginEntry( + id: id, + bundle: try XCTUnwrap(Bundle(url: url)), + url: url, + source: .userInstalled, + name: id, + version: "1.0", + pluginDescription: "", + capabilities: [.databaseDriver], + isEnabled: true, + databaseTypeId: "ExampleDB", + additionalTypeIds: [], + pluginIconName: "puzzlepiece", + defaultPort: nil, + exportFormatId: nil, + importFormatId: nil, + inspectorId: nil + ) + } + + private func makeBundle(id: String, databaseTypeIds: [String]) throws -> URL { + let url = root.appendingPathComponent("\(id).tableplugin", isDirectory: true) + let contents = url.appendingPathComponent("Contents", isDirectory: true) + try FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + let info: [String: Any] = [ + "CFBundleIdentifier": id, + "CFBundleName": id, + "CFBundleShortVersionString": "1.0", + "TableProPluginKitVersion": PluginManager.currentPluginKitVersion, + "TableProProvidesDatabaseTypeIds": databaseTypeIds + ] + let data = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0) + try data.write(to: contents.appendingPathComponent("Info.plist")) + return url + } +} diff --git a/TableProTests/Core/Services/Infrastructure/AppLaunchCoordinatorTests.swift b/TableProTests/Core/Services/Infrastructure/AppLaunchCoordinatorTests.swift new file mode 100644 index 000000000..c791be79a --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/AppLaunchCoordinatorTests.swift @@ -0,0 +1,254 @@ +// +// AppLaunchCoordinatorTests.swift +// TableProTests +// + +@testable import TablePro +import XCTest + +@MainActor +final class AppLaunchCoordinatorTests: XCTestCase { + private var environment: RecordingLaunchEnvironment! + private var coordinator: AppLaunchCoordinator! + + override func setUp() async throws { + try await super.setUp() + environment = RecordingLaunchEnvironment() + coordinator = AppLaunchCoordinator(environment: environment) + } + + override func tearDown() async throws { + coordinator = nil + environment = nil + try await super.tearDown() + } + + func testNothingIsRoutedBeforeTheFirstTurn() async { + let connectionId = UUID() + coordinator.deliver([.openConnection(connectionId)]) + coordinator.didFinishLaunching() + + XCTAssertEqual(coordinator.phase, .collectingIntents) + XCTAssertTrue(environment.routedConnectionIds.isEmpty) + XCTAssertEqual(environment.pendingTurnCount, 1) + } + + /// The gesture that launched the app reaches `application(_:open:)` before + /// `applicationDidFinishLaunching`, so this is the ordinary case, not an edge one. + func testAnIntentDeliveredBeforeLaunchFinishesIsRoutedOnce() async { + let connectionId = UUID() + coordinator.deliver([.openConnection(connectionId)]) + coordinator.didFinishLaunching() + + await environment.runNextTurnAndWaitForCompletion() + + XCTAssertEqual(environment.routedConnectionIds, [connectionId]) + XCTAssertEqual(coordinator.phase, .ready) + } + + /// A straggler from the same gesture lands within a few milliseconds of + /// `applicationDidFinishLaunching` and before the first turn of the main queue, so it must join + /// the same routing pass rather than opening a second window behind the first. + func testAnIntentDeliveredAfterLaunchFinishesJoinsTheSamePass() async { + let first = UUID() + let second = UUID() + coordinator.deliver([.openConnection(first)]) + coordinator.didFinishLaunching() + coordinator.deliver([.openConnection(second)]) + + await environment.runNextTurnAndWaitForCompletion() + + XCTAssertEqual(environment.routedConnectionIds, [first, second]) + XCTAssertEqual(environment.startupBehaviorRuns, 1) + XCTAssertEqual(environment.completions, 1) + } + + func testEveryIntentInOneDeliveryIsRoutedInOrder() async { + let ids = [UUID(), UUID(), UUID()] + coordinator.deliver(ids.map { .openConnection($0) }) + coordinator.didFinishLaunching() + + await environment.runNextTurnAndWaitForCompletion() + + XCTAssertEqual(environment.routedConnectionIds, ids) + } + + func testALaunchWithNoIntentsStillRunsTheStartupBehaviour() async { + coordinator.didFinishLaunching() + + await environment.runNextTurnAndWaitForCompletion() + + XCTAssertTrue(environment.routedConnectionIds.isEmpty) + XCTAssertEqual(environment.startupBehaviorRuns, 1) + XCTAssertEqual(environment.welcomeFallbackRuns, 1) + XCTAssertEqual(coordinator.phase, .ready) + } + + /// A second scheduled turn would route the same intents twice. Nothing schedules one today, and + /// this is what keeps that true. + func testASecondTurnRoutesNothingFurther() async { + let connectionId = UUID() + coordinator.deliver([.openConnection(connectionId)]) + coordinator.didFinishLaunching() + await environment.runNextTurnAndWaitForCompletion() + + environment.replayLastTurn() + await environment.settle() + + XCTAssertEqual(environment.routedConnectionIds, [connectionId]) + XCTAssertEqual(environment.completions, 1) + } + + func testAnIntentArrivingAfterReadyIsRoutedOnItsOwn() async { + coordinator.didFinishLaunching() + await environment.runNextTurnAndWaitForCompletion() + + let late = UUID() + coordinator.deliver([.openConnection(late)]) + await environment.settle() + + XCTAssertEqual(environment.routedConnectionIds, [late]) + XCTAssertEqual(environment.dismissWelcomeRuns, 2) + } + + /// Routing suspends: `TabRouter.openTable` awaits `ensureConnected`. An intent that arrives + /// during that wait must join the pass in flight, not start a second one, or two tasks can each + /// find no session for the same connection and each open one. + func testAnIntentArrivingDuringARouteJoinsTheSameDrain() async { + let first = UUID() + let second = UUID() + environment.holdsRoutes = true + coordinator.deliver([.openConnection(first)]) + coordinator.didFinishLaunching() + environment.fireNextTurn() + await environment.settle() + + XCTAssertEqual(environment.routedConnectionIds, [first], "The first route should still be suspended") + + coordinator.deliver([.openConnection(second)]) + await environment.settle() + + XCTAssertEqual(environment.routedConnectionIds, [first], "The second intent must wait, not race") + XCTAssertEqual(environment.concurrentRoutes, 1) + + environment.holdsRoutes = false + environment.releaseRoute() + await environment.settle() + + XCTAssertEqual(environment.routedConnectionIds, [first, second]) + XCTAssertEqual(environment.concurrentRoutes, 1, "Only one route may be in flight at a time") + XCTAssertEqual(environment.completions, 1) + } + + func testStartupBehaviourIsToldWhetherAnyIntentWasRouted() async { + coordinator.deliver([.openConnection(UUID())]) + coordinator.didFinishLaunching() + + await environment.runNextTurnAndWaitForCompletion() + + XCTAssertTrue(environment.startupBehaviorSawIntents) + } + + func testReopenWithNoVisibleWindowsPresentsWelcome() { + let handled = coordinator.handleReopen(hasVisibleWindows: false) + + XCTAssertFalse(handled) + XCTAssertEqual(environment.presentWelcomeRuns, 1) + } + + func testReopenWithVisibleWindowsPresentsNothing() { + let handled = coordinator.handleReopen(hasVisibleWindows: true) + + XCTAssertTrue(handled) + XCTAssertEqual(environment.presentWelcomeRuns, 0) + } +} + +@MainActor +private final class RecordingLaunchEnvironment: LaunchEnvironment { + private(set) var routedConnectionIds: [UUID] = [] + private(set) var closeWelcomeRuns = 0 + private(set) var dismissWelcomeRuns = 0 + private(set) var startupBehaviorRuns = 0 + private(set) var welcomeFallbackRuns = 0 + private(set) var presentWelcomeRuns = 0 + private(set) var completions = 0 + private(set) var startupBehaviorSawIntents = false + private(set) var concurrentRoutes = 0 + + /// Set to suspend `route` until `releaseRoute()` is called, which is how a launch that awaits + /// `ensureConnected` behaves. + var holdsRoutes = false + private var routeGate: CheckedContinuation? + private var activeRoutes = 0 + + private var turns: [@MainActor () -> Void] = [] + private var lastTurn: (@MainActor () -> Void)? + + var pendingTurnCount: Int { turns.count } + + func scheduleNextTurn(_ body: @escaping @MainActor () -> Void) { + turns.append(body) + lastTurn = body + } + + func route(_ intent: LaunchIntent) async { + activeRoutes += 1 + concurrentRoutes = max(concurrentRoutes, activeRoutes) + if let connectionId = intent.targetConnectionId { + routedConnectionIds.append(connectionId) + } + if holdsRoutes { + await withCheckedContinuation { (continuation: CheckedContinuation) in + routeGate = continuation + } + } + activeRoutes -= 1 + } + + func releaseRoute() { + let gate = routeGate + routeGate = nil + gate?.resume() + } + + func closeWelcome() { closeWelcomeRuns += 1 } + func dismissWelcomeIfMainWindowVisible() { dismissWelcomeRuns += 1 } + func runStartupBehavior(hadIntents: Bool) { + startupBehaviorRuns += 1 + startupBehaviorSawIntents = hadIntents + } + + func presentWelcomeIfNoMainWindow(hadIntents: Bool) { welcomeFallbackRuns += 1 } + func presentWelcome() { presentWelcomeRuns += 1 } + func launchDidComplete() { completions += 1 } + + func replayLastTurn() { + lastTurn?() + } + + func fireNextTurn() { + guard !turns.isEmpty else { return XCTFail("No turn was scheduled") } + turns.removeFirst()() + } + + /// Fires the turn the coordinator scheduled, then drains the main queue until the routing task + /// it starts has finished. `launchDidComplete()` is the coordinator's own last step, so the + /// count moving is the signal, not a sleep. + func runNextTurnAndWaitForCompletion() async { + guard !turns.isEmpty else { return XCTFail("No turn was scheduled") } + let target = completions + 1 + turns.removeFirst()() + for _ in 0 ..< 100 where completions < target { + await Task.yield() + } + XCTAssertEqual(completions, target, "The routing pass never completed") + } + + /// Lets any already-started task finish without expecting a completion. + func settle() async { + for _ in 0 ..< 100 { + await Task.yield() + } + } +} diff --git a/TableProTests/Core/Services/Infrastructure/LaunchPhaseTests.swift b/TableProTests/Core/Services/Infrastructure/LaunchPhaseTests.swift new file mode 100644 index 000000000..64e25275d --- /dev/null +++ b/TableProTests/Core/Services/Infrastructure/LaunchPhaseTests.swift @@ -0,0 +1,26 @@ +// +// LaunchPhaseTests.swift +// TableProTests +// + +@testable import TablePro +import XCTest + +final class LaunchPhaseTests: XCTestCase { + func testLaunchingAndCollectingAcceptIntents() { + XCTAssertTrue(LaunchPhase.launching.isAcceptingIntents) + XCTAssertTrue(LaunchPhase.collectingIntents.isAcceptingIntents) + } + + func testRoutingAndReadyRefuseIntents() { + XCTAssertFalse(LaunchPhase.routing.isAcceptingIntents) + XCTAssertFalse(LaunchPhase.ready.isAcceptingIntents) + } + + func testOnlyReadyReportsReady() { + XCTAssertFalse(LaunchPhase.launching.isReady) + XCTAssertFalse(LaunchPhase.collectingIntents.isReady) + XCTAssertFalse(LaunchPhase.routing.isReady) + XCTAssertTrue(LaunchPhase.ready.isReady) + } +}