Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down
62 changes: 29 additions & 33 deletions TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,21 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - URL & File Open

func applicationWillFinishLaunching(_ notification: Notification) {
LaunchTracer.shared.mark(.willFinishLaunchingBegan)
AppSettingsStorage.shared.migrateStartupBehaviorToReopenLastIfNeeded()
AppSettingsStorage.shared.migrateJsonFieldHeightKeyIfNeeded()
AIProviderRegistration.registerAll()

/// 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]) {
Expand All @@ -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() }
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/Compare/KeyOrdering.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
126 changes: 126 additions & 0 deletions TablePro/Core/Diagnostics/LaunchTracer.swift
Original file line number Diff line number Diff line change
@@ -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<kinfo_proc>.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
)
}
}
15 changes: 15 additions & 0 deletions TablePro/Core/Plugins/PluginManager+Lifecycle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions TablePro/Core/Plugins/PluginManager+Validation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,62 @@ 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 }
guard PluginDeveloperTrustStore.shared.isTrusted(identity) else {
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
}
}
}
Loading
Loading