diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index dbe8fa55ba74..4786dd605cfd 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -477,6 +477,7 @@ jobs: xcrun stapler staple "$app_path" xcrun stapler validate "$app_path" spctl -a -vv --type execute "$app_path" + ./scripts/smoke-launch-macos-app.sh "$app_path" rm -f "$zip_submit" dmg_tmp_dir="$(mktemp -d)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fdc8c21a9fa4..69cbb46135dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -330,6 +330,7 @@ jobs: xcrun stapler staple "$APP_PATH" xcrun stapler validate "$APP_PATH" spctl -a -vv --type execute "$APP_PATH" + ./scripts/smoke-launch-macos-app.sh "$APP_PATH" rm -f "$ZIP_SUBMIT" # create-dmg generates a styled drag-to-install DMG create-dmg \ diff --git a/Sources/App/StartupBreadcrumbLog.swift b/Sources/App/StartupBreadcrumbLog.swift new file mode 100644 index 000000000000..21d25957cb08 --- /dev/null +++ b/Sources/App/StartupBreadcrumbLog.swift @@ -0,0 +1,101 @@ +import Darwin +import Foundation +import os + +enum StartupBreadcrumbLog { + private static let maxFieldLength = 240 + private nonisolated static let logger = Logger(subsystem: "com.cmuxterm.app", category: "StartupBreadcrumbLog") + private static let reservedFieldKeys: Set = [ + "timestamp", + "event", + "pid", + "bundleIdentifier", + "appVersion", + "build" + ] + + static func append(_ event: String, fields: [String: String] = [:]) { + guard isEnabled else { return } + + var payload: [String: Any] = [ + "timestamp": ISO8601DateFormatter().string(from: Date()), + "event": event, + "pid": ProcessInfo.processInfo.processIdentifier, + "bundleIdentifier": Bundle.main.bundleIdentifier ?? "unknown", + "appVersion": Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown", + "build": Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown" + ] + + for (key, value) in fields { + let payloadKey = reservedFieldKeys.contains(key) ? "custom_\(key)" : key + payload[payloadKey] = sanitized(value) + } + + do { + let url = logURL + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + if !FileManager.default.fileExists(atPath: url.path) { + FileManager.default.createFile(atPath: url.path, contents: nil) + } + let line = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + let handle = try FileHandle(forWritingTo: url) + defer { try? handle.close() } + guard flock(handle.fileDescriptor, LOCK_EX) == 0 else { + let code = POSIXErrorCode(rawValue: errno) ?? .EIO + throw POSIXError(code) + } + defer { flock(handle.fileDescriptor, LOCK_UN) } + // Startup breadcrumbs are synchronous so the last edge survives immediate launch aborts. + try handle.seekToEnd() + try handle.write(contentsOf: line) + try handle.write(contentsOf: Data([0x0A])) + } catch { + logger.fault("cmux startup breadcrumb failed: \(String(describing: error), privacy: .public)") + } + } + + private static var isEnabled: Bool { + let environment = ProcessInfo.processInfo.environment + if environment["CMUX_DISABLE_STARTUP_BREADCRUMBS"] == "1" { + return false + } + if environment["CMUX_STARTUP_BREADCRUMBS"] == "1" { + return true + } + let bundleIdentifier = Bundle.main.bundleIdentifier ?? "" + return bundleIdentifier == "com.cmuxterm.app.nightly" + || bundleIdentifier.hasPrefix("com.cmuxterm.app.nightly.") + || bundleIdentifier == "com.cmuxterm.app.debug" + || bundleIdentifier.hasPrefix("com.cmuxterm.app.debug.") + } + + private static var logURL: URL { + let logsDirectory = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask) + .first? + .appendingPathComponent("Logs/cmux", isDirectory: true) + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("cmux-logs", isDirectory: true) + let sanitizedBundleIdentifier = logFileComponent(Bundle.main.bundleIdentifier ?? "unknown") + return logsDirectory.appendingPathComponent("startup-\(sanitizedBundleIdentifier).log") + } + + private static func logFileComponent(_ value: String) -> String { + let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._-") + return sanitized(value, maxLength: 160).unicodeScalars.map { scalar in + allowed.contains(scalar) ? String(scalar) : "-" + }.joined() + } + + private static func sanitized(_ value: String, maxLength: Int = maxFieldLength) -> String { + let flattened = value + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\r", with: "\\r") + if flattened.count <= maxLength { + return flattened + } + return String(flattened.prefix(maxLength)) + "..." + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f19371fbf113..fc20f54bebac 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1010,14 +1010,23 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let env = ProcessInfo.processInfo.environment let isRunningUnderXCTest = isRunningUnderXCTest(env) let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch + StartupBreadcrumbLog.append( + "appDelegate.didFinish.begin", + fields: [ + "xctest": isRunningUnderXCTest ? "1" : "0", + "telemetry": telemetryEnabled ? "1" : "0" + ] + ) AppIconLaunchState.markDidFinishLaunching() if isRunningUnderXCTest { NSApp.setActivationPolicy(.regular) } else { syncActivationPolicy() } + StartupBreadcrumbLog.append("appDelegate.didFinish.activationPolicy.synced") claimAuthCallbackURLSchemes() + StartupBreadcrumbLog.append("appDelegate.didFinish.authSchemes.claimed") // Install the Feed (workstream) store. Separate from the transport // wiring: the store is a plain singleton here, and the socket @@ -1029,6 +1038,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent persistence: WorkstreamPersistence(fileURL: WorkstreamPersistence.defaultFileURL()) ) ) + StartupBreadcrumbLog.append("appDelegate.didFinish.feedStore.installed") Task { @MainActor in await FeedCoordinator.shared.store?.start() #if DEBUG @@ -1090,6 +1100,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent _ = Locale.current _ = NSLocale.preferredLanguages + StartupBreadcrumbLog.append("appDelegate.didFinish.sentry.begin") SentrySDK.start { options in options.dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416" #if DEBUG @@ -1111,10 +1122,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Avoid recursively capturing failed requests from Sentry's own ingestion endpoint. options.enableCaptureFailedRequests = false } + StartupBreadcrumbLog.append("appDelegate.didFinish.sentry.complete") } if telemetryEnabled && !isRunningUnderXCTest { + StartupBreadcrumbLog.append("appDelegate.didFinish.posthog.begin") PostHogAnalytics.shared.startIfNeeded() + StartupBreadcrumbLog.append("appDelegate.didFinish.posthog.complete") } let forceDuplicateLaunchObserver = env["CMUX_UI_TEST_ENABLE_DUPLICATE_LAUNCH_OBSERVER"] == "1" @@ -1125,9 +1139,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if !isRunningUnderXCTest { DispatchQueue.main.async { [weak self] in guard let self else { return } + StartupBreadcrumbLog.append("appDelegate.singleInstance.async.begin") self.scheduleLaunchServicesBundleRegistration() + StartupBreadcrumbLog.append("appDelegate.singleInstance.launchServices.scheduled") self.enforceSingleInstance() self.observeDuplicateLaunches() + StartupBreadcrumbLog.append("appDelegate.singleInstance.async.complete") } } else if forceDuplicateLaunchObserver { // Some UI regressions specifically exercise launch-observer behavior while still @@ -1162,7 +1179,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent SystemWideHotkeyController.shared.start() NSApp.servicesProvider = self + StartupBreadcrumbLog.append("appDelegate.didFinish.bootstrap.begin") scheduleInitialMainWindowBootstrap(debugSource: "didFinishLaunching") + StartupBreadcrumbLog.append("appDelegate.didFinish.complete") #if DEBUG UpdateTestSupport.applyIfNeeded(to: updateController.viewModel) if env["CMUX_UI_TEST_MODE"] == "1" { @@ -1507,12 +1526,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + StartupBreadcrumbLog.append( + "appDelegate.shouldTerminate.begin", + fields: [ + "taggedDev": SocketControlSettings.isTaggedDevBuild() ? "1" : "0", + "quitWarningConfirmed": isQuitWarningConfirmed ? "1" : "0", + "quitWarningEnabled": QuitWarningSettings.isEnabled() ? "1" : "0" + ] + ) isTerminatingApp = true _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) // Tagged DEV builds are ephemeral, skip quit confirmation entirely. if SocketControlSettings.isTaggedDevBuild() { closeAllWebInspectorsBeforeAppTeardown() + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": "taggedDev"]) return .terminateNow } @@ -1520,6 +1548,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // (handleQuitShortcutWarning), skip the check to avoid a second alert. if isQuitWarningConfirmed { closeAllWebInspectorsBeforeAppTeardown() + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": "confirmed"]) return .terminateNow } @@ -1527,6 +1556,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // the Cmd+Tab app switcher, bypassing handleCustomShortcut. guard QuitWarningSettings.isEnabled() else { closeAllWebInspectorsBeforeAppTeardown() + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.terminateNow", fields: ["reason": "warningDisabled"]) return .terminateNow } @@ -1551,12 +1581,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if shouldQuit { self.isQuitWarningConfirmed = true self.closeAllWebInspectorsBeforeAppTeardown() + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.reply", fields: ["shouldQuit": "1"]) } else { // Reset so that the next quit attempt can show the dialog again. self.isTerminatingApp = false + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.reply", fields: ["shouldQuit": "0"]) } NSApp.reply(toApplicationShouldTerminate: shouldQuit) } + StartupBreadcrumbLog.append("appDelegate.shouldTerminate.later") return .terminateLater } @@ -1566,6 +1599,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } func applicationWillTerminate(_ notification: Notification) { + StartupBreadcrumbLog.append("appDelegate.willTerminate.begin") isTerminatingApp = true closeAllWebInspectorsBeforeAppTeardown() _ = saveSessionSnapshot(includeScrollback: true, removeWhenEmpty: false) @@ -1583,6 +1617,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ghosttyCrashBreadcrumbTask = nil notificationStore?.clearAll() GhosttyCrashBreadcrumb.markCleanExit() + StartupBreadcrumbLog.append("appDelegate.willTerminate.complete") enableSuddenTerminationIfNeeded() } @@ -13783,25 +13818,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent #endif private func enforceSingleInstance() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } + guard let bundleId = Bundle.main.bundleIdentifier else { + StartupBreadcrumbLog.append("singleInstance.enforce.skip", fields: ["reason": "missingBundleId"]) + return + } let currentPid = ProcessInfo.processInfo.processIdentifier + var terminatedPids: [String] = [] for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) { guard app.processIdentifier != currentPid else { continue } + terminatedPids.append(String(app.processIdentifier)) app.terminate() if !app.isTerminated { _ = app.forceTerminate() } } + StartupBreadcrumbLog.append( + "singleInstance.enforce.complete", + fields: [ + "bundleIdentifier": bundleId, + "currentPid": String(currentPid), + "terminatedPids": terminatedPids.joined(separator: ",") + ] + ) } private func observeDuplicateLaunches() { - guard let bundleId = Bundle.main.bundleIdentifier else { return } + guard let bundleId = Bundle.main.bundleIdentifier else { + StartupBreadcrumbLog.append("singleInstance.observe.skip", fields: ["reason": "missingBundleId"]) + return + } let embeddedCLIURL = Bundle.main.bundleURL .appendingPathComponent("Contents/Resources/bin/cmux", isDirectory: false) .standardizedFileURL .resolvingSymlinksInPath() let currentPid = ProcessInfo.processInfo.processIdentifier + StartupBreadcrumbLog.append( + "singleInstance.observe.install", + fields: [ + "bundleIdentifier": bundleId, + "currentPid": String(currentPid) + ] + ) workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver( forName: NSWorkspace.didLaunchApplicationNotification, @@ -13818,6 +13876,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return } + StartupBreadcrumbLog.append( + "singleInstance.observe.terminateDuplicate", + fields: [ + "duplicatePid": String(app.processIdentifier), + "duplicateBundleIdentifier": app.bundleIdentifier ?? "nil" + ] + ) app.terminate() if !app.isTerminated { _ = app.forceTerminate() diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 3103adbda577..669f7203b82b 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -1669,6 +1669,12 @@ class GhosttyApp { static let shared = GhosttyApp() private static let releaseBundleIdentifier = "com.cmuxterm.app" private static let fallbackAppearanceConfig = GhosttyConfig() + // SAFETY: Ghostty C callbacks can run while GhosttyApp.shared is still initializing. + // cmux owns one process-lifetime GhosttyApp, so the registry avoids singleton re-entry + // without adding a teardown path for a ghostty_app_t that is never freed/recreated. + private static let appRegistryLock = NSLock() + private static var appRegistry: [UInt: GhosttyApp] = [:] + private static var initializingRuntimeApp: GhosttyApp? private static let backgroundLogTimestampFormatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -2001,10 +2007,11 @@ class GhosttyApp { runtimeConfig.userdata = Unmanaged.passUnretained(self).toOpaque() runtimeConfig.supports_selection_clipboard = true runtimeConfig.wakeup_cb = { userdata in - GhosttyApp.shared.scheduleTick() + GhosttyApp.runtimeApp(from: userdata)?.scheduleTick() } runtimeConfig.action_cb = { app, target, action in - return GhosttyApp.shared.handleAction(target: target, action: action) + guard let runtimeApp = GhosttyApp.runtimeAppForActionCallback(app) else { return false } + return runtimeApp.handleAction(target: target, action: action) } // Some GhosttyKit builds import this callback as returning `Void` in Swift even // though the C ABI returns `bool`. Store the C-compatible shim explicitly so the @@ -2091,9 +2098,13 @@ class GhosttyApp { } // Create app + Self.setInitializingRuntimeApp(self) + defer { Self.setInitializingRuntimeApp(nil) } + if let created = ghostty_app_new(&runtimeConfig, primaryConfig) { self.app = created self.config = primaryConfig + Self.registerRuntimeApp(self, for: created) } else { #if DEBUG Self.initLog("ghostty_app_new(primary) failed; attempting fallback config") @@ -2151,6 +2162,7 @@ class GhosttyApp { self.app = created self.config = fallbackConfig + Self.registerRuntimeApp(self, for: created) } // Notify observers that a usable config is available (initial load). @@ -3612,7 +3624,7 @@ class GhosttyApp { scope: .app ) DispatchQueue.main.async { - GhosttyApp.shared.applyBackgroundToKeyWindow() + self.applyBackgroundToKeyWindow() } case GHOSTTY_ACTION_COLOR_KIND_FOREGROUND: applyDefaultBackground( @@ -3753,6 +3765,44 @@ class GhosttyApp { return Unmanaged.fromOpaque(userdata).takeUnretainedValue() } + private static func runtimeApp(from userdata: UnsafeMutableRawPointer?) -> GhosttyApp? { + guard let userdata else { return nil } + return Unmanaged.fromOpaque(userdata).takeUnretainedValue() + } + + private static func registerRuntimeApp(_ runtimeApp: GhosttyApp, for app: ghostty_app_t) { + let key = UInt(bitPattern: app) + appRegistryLock.lock() + appRegistry[key] = runtimeApp + appRegistryLock.unlock() + } + + private static func setInitializingRuntimeApp(_ runtimeApp: GhosttyApp?) { + appRegistryLock.lock() + initializingRuntimeApp = runtimeApp + appRegistryLock.unlock() + } + + private static func runtimeApp(for app: ghostty_app_t?) -> GhosttyApp? { + guard let app else { return nil } + let key = UInt(bitPattern: app) + appRegistryLock.lock() + defer { appRegistryLock.unlock() } + return appRegistry[key] + } + + private static func runtimeAppForActionCallback(_ app: ghostty_app_t?) -> GhosttyApp? { + appRegistryLock.lock() + defer { appRegistryLock.unlock() } + if let app { + let key = UInt(bitPattern: app) + if let registered = appRegistry[key] { + return registered + } + } + return initializingRuntimeApp + } + private func handleAction(target: ghostty_target_s, action: ghostty_action_s) -> Bool { if target.tag != GHOSTTY_TARGET_SURFACE { if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG || @@ -3805,7 +3855,7 @@ class GhosttyApp { let soft = action.action.reload_config.soft logThemeAction("reload request target=app soft=\(soft)") performOnMain { - GhosttyApp.shared.reloadConfiguration(soft: soft, source: "action.reload_config.app") + self.reloadConfiguration(soft: soft, source: "action.reload_config.app") } return true } @@ -3824,7 +3874,7 @@ class GhosttyApp { scope: .app ) DispatchQueue.main.async { - GhosttyApp.shared.applyBackgroundToKeyWindow() + self.applyBackgroundToKeyWindow() } return true } @@ -4108,7 +4158,7 @@ class GhosttyApp { "reload request target=surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil") soft=\(soft)" ) return performOnMain { - GhosttyApp.shared.reloadSurfaceConfiguration(target.target.surface, soft: soft, source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")") + self.reloadSurfaceConfiguration(target.target.surface, soft: soft, source: "action.reload_config.surface tab=\(surfaceView.tabId?.uuidString ?? "nil") surface=\(surfaceView.terminalSurface?.id.uuidString ?? "nil")") surfaceView.terminalSurface?.hostedView.refreshHostBackgroundAfterGhosttyConfigReload() surfaceView.terminalSurface?.forceRefresh(reason: "surface.reloadConfig") return true diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e9e0cd6f0d0f..6844f3ac82e3 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -23,21 +23,30 @@ struct cmuxApp: App { } init() { + StartupBreadcrumbLog.append("app.init.begin") UITestLaunchManifest.applyIfPresent() + StartupBreadcrumbLog.append("app.init.uiTestManifest.applied") if SocketControlSettings.shouldBlockUntaggedDebugLaunch() { + StartupBreadcrumbLog.append("app.init.blockUntaggedDebugLaunch") Self.terminateForMissingLaunchTag() } Self.configureGhosttyEnvironment() + StartupBreadcrumbLog.append("app.init.ghosttyEnvironment.configured") _ = KeyboardShortcutSettings.settingsFileStore + StartupBreadcrumbLog.append("app.init.keyboardShortcuts.loaded") // Apply saved language preference before any UI loads LanguageSettings.apply(LanguageSettings.languageAtLaunch) + StartupBreadcrumbLog.append("app.init.language.applied") let startupAppearance = AppearanceSettings.resolvedMode() Self.applyAppearance(startupAppearance, duringLaunch: true) + StartupBreadcrumbLog.append("app.init.appearance.applied", fields: ["mode": startupAppearance.rawValue]) + StartupBreadcrumbLog.append("app.init.tabManager.begin") _tabManager = StateObject(wrappedValue: TabManager()) + StartupBreadcrumbLog.append("app.init.tabManager.complete") // Migrate legacy and old-format socket mode values to the new enum. let defaults = UserDefaults.standard if let stored = defaults.string(forKey: SocketControlSettings.appStorageKey) { @@ -56,13 +65,18 @@ struct cmuxApp: App { let bundleID = Bundle.main.bundleIdentifier if !SocketControlSettings.isDebugLikeBundleIdentifier(bundleID) && !SocketControlSettings.isStagingBundleIdentifier(bundleID) { + StartupBreadcrumbLog.append("app.init.keychainMigration.begin") SocketControlPasswordStore.migrateLegacyKeychainPasswordIfNeeded(defaults: defaults) + StartupBreadcrumbLog.append("app.init.keychainMigration.complete") } migrateSidebarAppearanceDefaultsIfNeeded(defaults: defaults) + StartupBreadcrumbLog.append("app.init.sidebarDefaults.migrated") // UI tests depend on AppDelegate wiring happening even if SwiftUI view appearance // callbacks (e.g. `.onAppear`) are delayed or skipped. + StartupBreadcrumbLog.append("app.init.delegate.configure.begin") appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState) + StartupBreadcrumbLog.append("app.init.delegate.configured") } private static func terminateForMissingLaunchTag() -> Never { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index caa3d5f73f49..cb057a98b5c8 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ D0B10016A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10017A1B2C3D4E5F60001 /* FilePreviewTextEditor.swift */; }; A5001450A5001450A5001450 /* FilePreviewNativeBackground.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001451A5001451A5001451 /* FilePreviewNativeBackground.swift */; }; D0B10018A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10019A1B2C3D4E5F60001 /* FileDropOverlayViewTests.swift */; }; + D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B71010000000000000002 /* StartupBreadcrumbLog.swift */; }; D35110010000000000000001 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */; }; D7AB34300000000000000001 /* SidebarDropPlanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB34300000000000000002 /* SidebarDropPlanner.swift */; }; @@ -686,6 +687,7 @@ 2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = ""; }; 2F0C06000000000000000001 /* MainWindowVisibilityController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MainWindowVisibilityController.swift; sourceTree = ""; }; A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/DebugLogging.swift; sourceTree = ""; }; + D35B71010000000000000002 /* StartupBreadcrumbLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/StartupBreadcrumbLog.swift; sourceTree = ""; }; A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/SessionSnapshotDebugBenchmark.swift; sourceTree = ""; }; A5001013 /* TabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabManager.swift; sourceTree = ""; }; C3677003000000000000002 /* TabManager+CompatibilityTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TabManager+CompatibilityTypes.swift"; sourceTree = ""; }; @@ -1227,6 +1229,7 @@ 2F0C07000000000000000001 /* CmuxMainWindow.swift */, 2F0C06000000000000000001 /* MainWindowVisibilityController.swift */, A500D011A1B2C3D4E5F60718 /* DebugLogging.swift */, + D35B71010000000000000002 /* StartupBreadcrumbLog.swift */, A50016B0A1B2C3D4E5F60718 /* SessionSnapshotDebugBenchmark.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, @@ -1949,6 +1952,7 @@ 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */, 2F0C06000000000000000002 /* MainWindowVisibilityController.swift in Sources */, A500D010A1B2C3D4E5F60718 /* DebugLogging.swift in Sources */, + D35B71010000000000000001 /* StartupBreadcrumbLog.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B9000018A1B2C3D4E5F60719 /* WindowDragHandleView.swift in Sources */, A50012F1 /* Backport.swift in Sources */, diff --git a/scripts/build-command-palette-nucleo-ffi.sh b/scripts/build-command-palette-nucleo-ffi.sh index da386e3d6f46..e40bcaf02e54 100755 --- a/scripts/build-command-palette-nucleo-ffi.sh +++ b/scripts/build-command-palette-nucleo-ffi.sh @@ -103,6 +103,8 @@ else lipo -create -output "${SOURCE_LIB}" "${libs[@]}" fi +/usr/bin/install_name_tool -id "@rpath/${LIB_NAME}" "${SOURCE_LIB}" + if [ -z "${TARGET_BUILD_DIR:-}" ]; then echo "built ${SOURCE_LIB}" exit 0 diff --git a/scripts/sign-cmux-bundle.sh b/scripts/sign-cmux-bundle.sh index a129eaf4d7bb..4372622aad06 100755 --- a/scripts/sign-cmux-bundle.sh +++ b/scripts/sign-cmux-bundle.sh @@ -26,6 +26,7 @@ # notarized macOS 26 Tahoe rejects with errno 163. set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" if [[ $# -lt 3 ]]; then echo "usage: $0 " >&2 @@ -87,6 +88,7 @@ echo "==> signing main bundle" echo "==> verifying" /usr/bin/codesign --verify --deep --strict --verbose=2 "$APP_PATH" +"$SCRIPT_DIR/verify-command-palette-nucleo-ffi-artifact.sh" "$APP_PATH" APP_ID="$(/usr/libexec/PlistBuddy -c "Print :com.apple.application-identifier" \ /dev/stdin <<<"$(plutil -convert xml1 -o - "$APP_ENTITLEMENTS")" 2>/dev/null || true)" diff --git a/scripts/smoke-launch-macos-app.sh b/scripts/smoke-launch-macos-app.sh new file mode 100755 index 000000000000..9f9943a78d1a --- /dev/null +++ b/scripts/smoke-launch-macos-app.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +APP_PATH="$1" +if [[ ! -d "$APP_PATH/Contents" ]]; then + echo "error: app bundle not found at $APP_PATH" >&2 + exit 1 +fi + +INFO_PLIST="$APP_PATH/Contents/Info.plist" +BUNDLE_ID="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$INFO_PLIST")" +EXECUTABLE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$INFO_PLIST")" +EXECUTABLE_PATH="$APP_PATH/Contents/MacOS/$EXECUTABLE_NAME" +STARTUP_TIMEOUT_SECONDS="${CMUX_SMOKE_STARTUP_TIMEOUT_SECONDS:-10}" +STABLE_SECONDS="${CMUX_SMOKE_STABLE_SECONDS:-5}" +OPEN_LOG="$(mktemp -t cmux-smoke-open.XXXXXX)" +APP_PID="" +PREEXISTING_PIDS="$(pgrep -f "$EXECUTABLE_PATH" 2>/dev/null || true)" +DEBUG_LOGS="${CMUX_SMOKE_DEBUG_LOGS:-0}" + +cleanup() { + if [[ -n "$APP_PID" ]] && kill -0 "$APP_PID" 2>/dev/null; then + kill "$APP_PID" 2>/dev/null || true + fi + rm -f "$OPEN_LOG" +} +trap cleanup EXIT + +find_new_app_pid() { + local pid + while IFS= read -r pid; do + [[ -n "$pid" ]] || continue + if ! printf '%s\n' "$PREEXISTING_PIDS" | grep -Fxq "$pid"; then + printf '%s\n' "$pid" + return 0 + fi + done < <(pgrep -f "$EXECUTABLE_PATH" 2>/dev/null || true) + return 1 +} + +dump_open_log() { + if [[ ! -s "$OPEN_LOG" ]]; then + return + fi + if [[ "$DEBUG_LOGS" == "1" ]]; then + cat "$OPEN_LOG" >&2 + else + echo "open launcher output captured (set CMUX_SMOKE_DEBUG_LOGS=1 to print)" >&2 + fi +} + +dump_system_log() { + if [[ "$DEBUG_LOGS" == "1" ]]; then + /usr/bin/log show --last 2m --style compact --predicate "process == '$EXECUTABLE_NAME' OR eventMessage CONTAINS '$BUNDLE_ID'" 2>/dev/null | tail -n 160 >&2 || true + else + echo "system log capture skipped (set CMUX_SMOKE_DEBUG_LOGS=1 to print)" >&2 + fi +} + +echo "==> smoke launching $APP_PATH" +/usr/bin/open -n -g "$APP_PATH" --args -ApplePersistenceIgnoreState YES >"$OPEN_LOG" 2>&1 & +OPEN_PID=$! + +# CI-only LaunchServices smoke: open returns before the app process is visible. +# Use bounded polling to wait for registration, then a bounded liveness window. +deadline=$((SECONDS + STARTUP_TIMEOUT_SECONDS)) +while (( SECONDS < deadline )); do + APP_PID="$(find_new_app_pid || true)" + if [[ -n "$APP_PID" ]]; then + break + fi + if ! kill -0 "$OPEN_PID" 2>/dev/null; then + wait "$OPEN_PID" || true + fi + sleep 0.2 +done + +if [[ -z "$APP_PID" ]]; then + echo "error: app process did not appear for bundle $BUNDLE_ID within ${STARTUP_TIMEOUT_SECONDS}s" >&2 + dump_open_log + exit 1 +fi + +for _ in $(seq 1 "$STABLE_SECONDS"); do + sleep 1 + if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "error: app process $APP_PID exited during ${STABLE_SECONDS}s launch smoke" >&2 + dump_open_log + LOG_NAME="$(printf '%s' "$BUNDLE_ID" | sed -E 's/[^A-Za-z0-9._-]/-/g')" + STARTUP_LOG="$HOME/Library/Logs/cmux/startup-${LOG_NAME}.log" + if [[ -f "$STARTUP_LOG" ]]; then + echo "startup breadcrumbs:" >&2 + tail -n 80 "$STARTUP_LOG" >&2 || true + fi + dump_system_log + exit 1 + fi +done + +echo "==> launch smoke OK: pid $APP_PID stayed alive for ${STABLE_SECONDS}s" diff --git a/scripts/verify-command-palette-nucleo-ffi-artifact.sh b/scripts/verify-command-palette-nucleo-ffi-artifact.sh new file mode 100755 index 000000000000..94af4fc7bcc0 --- /dev/null +++ b/scripts/verify-command-palette-nucleo-ffi-artifact.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi + +TARGET="$1" +LIB_NAME="libcmux_command_palette_nucleo_ffi.dylib" + +if [[ -d "$TARGET/Contents" ]]; then + DYLIB="$TARGET/Contents/Frameworks/$LIB_NAME" +else + DYLIB="$TARGET" +fi + +if [[ ! -f "$DYLIB" ]]; then + echo "error: missing bundled Nucleo FFI library at $DYLIB" >&2 + exit 1 +fi + +install_names=() +while IFS= read -r install_name; do + install_names+=("$install_name") +done < <(/usr/bin/otool -D "$DYLIB" | awk 'NF && $0 !~ /:$/ { print $1 }') + +if [[ "${#install_names[@]}" -eq 0 ]]; then + echo "error: could not read install name from $DYLIB" >&2 + exit 1 +fi + +for install_name in "${install_names[@]}"; do + if [[ "$install_name" != "@rpath/$LIB_NAME" ]]; then + echo "error: $LIB_NAME has invalid install name: $install_name" >&2 + echo "expected: @rpath/$LIB_NAME" >&2 + exit 1 + fi +done + +if /usr/bin/otool -L "$DYLIB" | awk 'NR > 1 && NF { print $1 }' | grep -E '/Users/runner/work|/Native/CommandPaletteNucleoFFI/target|/target/(aarch64|x86_64)-apple-darwin/' >/dev/null; then + echo "error: $LIB_NAME contains CI/source-tree absolute load paths" >&2 + /usr/bin/otool -L "$DYLIB" >&2 + exit 1 +fi + +echo "Nucleo FFI artifact OK: $DYLIB"