From 47f226d7967699b56f61522d4e9887792cf0afaa Mon Sep 17 00:00:00 2001 From: onevcat Date: Tue, 26 May 2026 22:07:08 +0900 Subject: [PATCH] Fix main window surfacing after relaunch --- supacode/App/MainWindowOpener.swift | 75 ++++++++ supacode/App/WindowSurfacing.swift | 200 ++++++++++++++++++++++ supacode/App/supacodeApp.swift | 20 ++- supacode/Commands/WindowCommands.swift | 3 + supacodeTests/MainWindowOpenerTests.swift | 32 ++++ 5 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 supacode/App/MainWindowOpener.swift create mode 100644 supacodeTests/MainWindowOpenerTests.swift diff --git a/supacode/App/MainWindowOpener.swift b/supacode/App/MainWindowOpener.swift new file mode 100644 index 00000000..935f9030 --- /dev/null +++ b/supacode/App/MainWindowOpener.swift @@ -0,0 +1,75 @@ +import SwiftUI + +/// Bridges AppKit-side "bring the main window back" requests to SwiftUI's +/// `openWindow` action. +/// +/// The main window is a singleton `Window(_:id:)` scene. SwiftUI tears its +/// `NSWindow` down when the window closes, and on a macOS restart relaunch the +/// scene is often not recreated at all (the app launches with zero windows). A +/// bare `NSApp.activate(ignoringOtherApps:)` cannot bring back a scene SwiftUI +/// has torn down; only `openWindow(id:)` rebuilds it. Until #297 this only +/// happened implicitly when `applicationShouldHandleReopen` returned `true` +/// (Dock icon click); activation paths that do not trigger reopen, such as +/// Cmd-Tab, Mission Control, CLI `open`, and menu commands, left the app stuck +/// windowless. +/// +/// SwiftUI registers the opener from both the app command tree and the main +/// window content. The command registration is important for loginwindow +/// relaunches that start the process with zero windows: the main window content +/// never appears, so an `onAppear`-only registration cannot recreate it. +/// `NSApplication.surfaceMainWindow()` then calls `openMainWindow()` from any +/// path that finds no existing main window. +@MainActor +final class MainWindowOpener { + static let shared = MainWindowOpener() + + private var opener: (() -> Void)? + var hasRegisteredOpener: Bool { + opener != nil + } + + init() {} + + func register(_ opener: @escaping () -> Void) { + self.opener = opener + } + + /// Requests a new main window. Returns `false` when no opener has been + /// registered yet (e.g. the main window has never appeared this launch), so + /// callers can fall back to AppKit's default reopen handling. + @discardableResult + func openMainWindow() -> Bool { + guard let opener else { return false } + opener() + return true + } +} + +/// Registers SwiftUI's `openWindow` action with `MainWindowOpener.shared` so +/// AppKit paths can recreate the main window. Attach to the main window's +/// content; registration refreshes on every appearance. +private struct MainWindowOpenerRegistrar: ViewModifier { + @Environment(\.openWindow) private var openWindow + + func body(content: Content) -> some View { + content.onAppear { + MainWindowOpener.shared.register(openWindow: openWindow) + } + } +} + +extension View { + func registersMainWindowOpener() -> some View { + modifier(MainWindowOpenerRegistrar()) + } +} + +extension MainWindowOpener { + @discardableResult + func register(openWindow: OpenWindowAction) -> Bool { + register { + openWindow(id: WindowID.main) + } + return hasRegisteredOpener + } +} diff --git a/supacode/App/WindowSurfacing.swift b/supacode/App/WindowSurfacing.swift index efb65645..4092b8ba 100644 --- a/supacode/App/WindowSurfacing.swift +++ b/supacode/App/WindowSurfacing.swift @@ -1,4 +1,6 @@ import AppKit +import Foundation +import Sentry enum WindowID { static let main = "main" @@ -10,9 +12,24 @@ extension NSApplication { @discardableResult func surfaceMainWindow() -> Bool { guard let window = mainWindowCandidate() else { + // SwiftUI tore down (or never created) the singleton main Window scene. + // A bare activate() cannot bring it back; only openWindow(id:) rebuilds + // it. See MainWindowOpener / issue #297. + if MainWindowOpener.shared.openMainWindow() { + WindowLifecycleDiagnostics.log("surfaceMainWindow: no candidate -> openWindow(id:.main) requested") + WindowLifecycleDiagnostics.noteWindowless("surfaceMainWindow(openWindowRequested)") + activate(ignoringOtherApps: true) + return true + } + WindowLifecycleDiagnostics.log("surfaceMainWindow: no candidate and opener unavailable -> activate only") + WindowLifecycleDiagnostics.noteWindowless("surfaceMainWindow(noOpener)") activate(ignoringOtherApps: true) return false } + WindowLifecycleDiagnostics.log( + "surfaceMainWindow: candidate id=\(window.identifier?.rawValue ?? "nil") " + + "miniaturized=\(window.isMiniaturized) visible=\(window.isVisible) -> makeKeyAndOrderFront" + ) if window.isMiniaturized { window.deminiaturize(nil) } @@ -32,3 +49,186 @@ extension NSApplication { return candidates.first } } + +// MARK: - Issue #297 diagnostics + +@MainActor +enum WindowLifecycleDiagnostics { + private static let logger = SupaLogger("WindowLifecycle") + private static let heartbeatInterval: TimeInterval = 1.0 + private static let stallThreshold: TimeInterval = 0.3 + private static let windowlessReminderInterval: TimeInterval = 5.0 + private static let windowlessSentryThreshold: TimeInterval = 10.0 + private static let windowlessStallSentryThreshold: TimeInterval = 5.0 + + private static var windowlessSince: Date? + private static var windowlessContext: String? + private static var windowlessReminderScheduled = false + private static var didReportWindowlessTimeout = false + private static var didReportWindowlessStall = false + private static var maxHeartbeatLagDuringWindowless: TimeInterval = 0 + private static var heartbeatRunning = false + private static var mainThreadStalling = false + + static func log(_ event: String) { + logger.info(event) + } + + static func logWithWindows(_ event: String) { + log("\(event) | \(windowsSummary())") + } + + static func noteWindowless(_ context: String) { + if windowlessSince == nil { + windowlessSince = .now + windowlessContext = context + didReportWindowlessTimeout = false + didReportWindowlessStall = false + maxHeartbeatLagDuringWindowless = 0 + log("windowless ENTERED (\(context))") + } else { + windowlessContext = context + } + scheduleWindowlessReminder() + } + + static func noteWindowlessIfNoMainWindow(_ context: String) { + let hasMain = NSApplication.shared.windows.contains { $0.identifier?.rawValue == WindowID.main } + guard !hasMain else { return } + noteWindowless(context) + } + + static func noteMainWindowAppeared() { + guard let since = windowlessSince else { return } + let seconds = Date.now.timeIntervalSince(since) + log(String(format: "windowless RESOLVED: main window appeared after %.3fs", seconds)) + windowlessSince = nil + windowlessContext = nil + } + + private static func scheduleWindowlessReminder() { + guard !windowlessReminderScheduled else { return } + windowlessReminderScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + windowlessReminderInterval) { + MainActor.assumeIsolated { + windowlessReminderScheduled = false + guard let since = windowlessSince else { return } + let seconds = Date.now.timeIntervalSince(since) + log( + String( + format: "still windowless after %.1fs (context=%@, maxLag=%.3fs)", + seconds, + windowlessContext ?? "unknown", + maxHeartbeatLagDuringWindowless + ) + ) + if seconds >= windowlessSentryThreshold { + reportWindowlessTimeoutIfNeeded(elapsed: seconds) + } + scheduleWindowlessReminder() + } + } + } + + static func startMainThreadHeartbeat() { + guard !heartbeatRunning else { return } + heartbeatRunning = true + log("main-thread heartbeat started (interval=\(heartbeatInterval)s, stallThreshold=\(stallThreshold)s)") + scheduleHeartbeatTick() + } + + private static func scheduleHeartbeatTick() { + let scheduledAt = Date.now + DispatchQueue.main.asyncAfter(deadline: .now() + heartbeatInterval) { + MainActor.assumeIsolated { + let lag = Date.now.timeIntervalSince(scheduledAt) - heartbeatInterval + if lag >= stallThreshold { + mainThreadStalling = true + if windowlessSince != nil { + maxHeartbeatLagDuringWindowless = max(maxHeartbeatLagDuringWindowless, lag) + if lag >= windowlessStallSentryThreshold { + reportWindowlessStallIfNeeded(lag: lag) + } + } + log(String(format: "HEARTBEAT STALL: main thread blocked ~%.3fs", lag)) + } else if mainThreadStalling { + mainThreadStalling = false + log(String(format: "heartbeat recovered (lag back to %.3fs)", lag)) + } + scheduleHeartbeatTick() + } + } + } + + static func applyLaunchStallIfConfigured() { + let seconds = UserDefaults.standard.double(forKey: "ProwlDebugLaunchStallSeconds") + guard seconds > 0 else { return } + log("DEBUG launch stall: blocking main thread for \(seconds)s (ProwlDebugLaunchStallSeconds)") + Thread.sleep(forTimeInterval: seconds) + log("DEBUG launch stall: resumed") + } + + private static func reportWindowlessTimeoutIfNeeded(elapsed: TimeInterval) { + guard !didReportWindowlessTimeout else { return } + guard windowlessContext != "launch" || NSApp.isActive else { return } + didReportWindowlessTimeout = true + captureSentryEvent(kind: "main_window_timeout", elapsed: elapsed, lag: nil) + } + + private static func reportWindowlessStallIfNeeded(lag: TimeInterval) { + guard !didReportWindowlessStall else { return } + didReportWindowlessStall = true + let elapsed = windowlessSince.map { Date.now.timeIntervalSince($0) } ?? 0 + captureSentryEvent(kind: "windowless_main_thread_stall", elapsed: elapsed, lag: lag) + } + + private static func captureSentryEvent(kind: String, elapsed: TimeInterval, lag: TimeInterval?) { + let context = windowlessContext ?? "unknown" + log( + String( + format: "Sentry window lifecycle report kind=%@ elapsed=%.3fs context=%@", + kind, + elapsed, + context + ) + ) + + #if !DEBUG + let event = Event(level: .warning) + event.message = SentryMessage(formatted: "Prowl main window surfacing \(kind)") + event.logger = "WindowLifecycle" + event.fingerprint = ["prowl", "main-window-surfacing", kind] + event.tags = [ + "window_lifecycle_kind": kind, + "app_active": NSApp.isActive ? "true" : "false", + "main_window_opener_registered": MainWindowOpener.shared.hasRegisteredOpener ? "true" : "false", + ] + var extra: [String: Any] = [ + "elapsed_seconds": elapsed, + "windowless_context": context, + "windows": windowsSummary(), + "max_heartbeat_lag_seconds": maxHeartbeatLagDuringWindowless, + "arguments": ProcessInfo.processInfo.arguments.joined(separator: " "), + ] + if let lag { + extra["heartbeat_lag_seconds"] = lag + } + event.extra = extra + SentrySDK.capture(event: event) + #endif + } + + private static func windowsSummary() -> String { + let windows = NSApplication.shared.windows + guard !windows.isEmpty else { return "windows[0]=(none)" } + let parts = windows.map { window -> String in + let id = window.identifier?.rawValue ?? "nil" + let type = String(describing: Swift.type(of: window)) + let visibility = window.isVisible ? "visible" : "hidden" + let miniaturized = window.isMiniaturized ? ",mini" : "" + let key = window.isKeyWindow ? ",key" : "" + return "{id=\(id),\(type),\(visibility)\(miniaturized)\(key)}" + } + return "windows[\(windows.count)]=\(parts.joined(separator: " "))" + } +} diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index 1b99106a..9b4f1189 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -41,6 +41,10 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { var cliSocketServer: CLISocketServer? func applicationDidFinishLaunching(_ notification: Notification) { + WindowLifecycleDiagnostics.startMainThreadHeartbeat() + WindowLifecycleDiagnostics.logWithWindows("applicationDidFinishLaunching") + WindowLifecycleDiagnostics.noteWindowlessIfNoMainWindow("launch") + WindowLifecycleDiagnostics.applyLaunchStallIfConfigured() // Disable press-and-hold accent menu so that key repeat works in the terminal. UserDefaults.standard.register(defaults: [ "ApplePressAndHoldEnabled": false @@ -53,16 +57,24 @@ final class SupacodeAppDelegate: NSObject, NSApplicationDelegate { let hasVisibleMainWindow = app.windows.contains { window in window.isVisible && !(window is NSPanel) } + WindowLifecycleDiagnostics.logWithWindows( + "applicationDidBecomeActive hasVisibleMainWindow=\(hasVisibleMainWindow)" + ) guard !hasVisibleMainWindow else { return } + WindowLifecycleDiagnostics.log("applicationDidBecomeActive -> surfaceMainWindow()") app.surfaceMainWindow() } func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + WindowLifecycleDiagnostics.logWithWindows("applicationShouldHandleReopen hasVisibleWindows=\(flag)") if flag { return true } - return !sender.surfaceMainWindow() + let surfaced = sender.surfaceMainWindow() + WindowLifecycleDiagnostics.log("applicationShouldHandleReopen surfaced=\(surfaced) -> handled=\(!surfaced)") + return !surfaced } func applicationWillTerminate(_ notification: Notification) { + WindowLifecycleDiagnostics.logWithWindows("applicationWillTerminate") defer { cliSocketServer?.stop() } guard appStore?.state.settings.restoreTerminalLayoutOnLaunch == true else { return } guard appStore?.state.suppressLayoutSaveUntilRelaunch != true else { return } @@ -619,9 +631,15 @@ struct SupacodeApp: App { .environment(commandKeyObserver) .environment(\.resolvedKeybindings, store.resolvedKeybindings) } + .registersMainWindowOpener() .onAppear { + WindowLifecycleDiagnostics.logWithWindows("mainWindow content onAppear") + WindowLifecycleDiagnostics.noteMainWindowAppeared() syncGhosttyManagedShortcuts(with: store.resolvedKeybindings) } + .onDisappear { + WindowLifecycleDiagnostics.logWithWindows("mainWindow content onDisappear") + } .onChange(of: store.resolvedKeybindings) { _, newValue in syncGhosttyManagedShortcuts(with: newValue) } diff --git a/supacode/Commands/WindowCommands.swift b/supacode/Commands/WindowCommands.swift index 8cc73dd5..f16f94d7 100644 --- a/supacode/Commands/WindowCommands.swift +++ b/supacode/Commands/WindowCommands.swift @@ -8,6 +8,7 @@ struct WindowCommands: Commands { let resolvedKeybindings: ResolvedKeybindingMap @Bindable var settingsWindowManager: SettingsWindowManager @Dependency(SettingsWindowClient.self) private var settingsWindowClient + @Environment(\.openWindow) private var openWindow @FocusedValue(\.closeTabAction) private var closeTabAction @FocusedValue(\.closeSurfaceAction) private var closeSurfaceAction @FocusedValue(\.selectPreviousTerminalTabAction) private var selectPreviousTerminalTabAction @@ -20,6 +21,7 @@ struct WindowCommands: Commands { @FocusedValue(\.selectTerminalPaneRightAction) private var selectTerminalPaneRightAction var body: some Commands { + let mainWindowOpenerRegistered = MainWindowOpener.shared.register(openWindow: openWindow) let closeSurfaceHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_surface") let closeTabHotkey = ghosttyShortcuts.keyboardShortcut(for: "close_tab") let shelfHasOpenBooks = @@ -136,6 +138,7 @@ struct WindowCommands: Commands { Divider() Button(mainWindowTitle) { + guard mainWindowOpenerRegistered else { return } NSApp.surfaceMainWindow() } .help("Show main window") diff --git a/supacodeTests/MainWindowOpenerTests.swift b/supacodeTests/MainWindowOpenerTests.swift new file mode 100644 index 00000000..531a838d --- /dev/null +++ b/supacodeTests/MainWindowOpenerTests.swift @@ -0,0 +1,32 @@ +import Testing + +@testable import supacode + +@MainActor +struct MainWindowOpenerTests { + @Test func returnsFalseWhenNoOpenerRegistered() { + let opener = MainWindowOpener() + #expect(opener.hasRegisteredOpener == false) + #expect(opener.openMainWindow() == false) + } + + @Test func invokesRegisteredOpenerAndReturnsTrue() { + let opener = MainWindowOpener() + var callCount = 0 + opener.register { callCount += 1 } + #expect(opener.hasRegisteredOpener == true) + #expect(opener.openMainWindow() == true) + #expect(callCount == 1) + } + + @Test func reregisteringReplacesPreviousOpener() { + let opener = MainWindowOpener() + var firstCount = 0 + var secondCount = 0 + opener.register { firstCount += 1 } + opener.register { secondCount += 1 } + _ = opener.openMainWindow() + #expect(firstCount == 0) + #expect(secondCount == 1) + } +} -- 2.51.2