Fix session restore routing and browser history persistence

This commit is contained in:
Lawrence Chen 2026-02-24 14:20:19 -08:00
parent 1c3f8458ee
commit 06cd25ed52
7 changed files with 878 additions and 17 deletions

View file

@ -684,6 +684,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
private var startupSessionSnapshot: AppSessionSnapshot?
private var didPrepareStartupSessionSnapshot = false
private var didAttemptStartupSessionRestore = false
private var isApplyingStartupSessionRestore = false
private var sessionAutosaveTimer: DispatchSourceTimer?
private var didHandleExplicitOpenIntentAtStartup = false
private var isTerminatingApp = false
@ -1037,6 +1038,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
let startupSnapshot = startupSessionSnapshot
let primaryWindowSnapshot = startupSnapshot?.windows.first
if let primaryWindowSnapshot {
isApplyingStartupSessionRestore = true
#if DEBUG
dlog(
"session.restore.start windows=\(startupSnapshot?.windows.count ?? 0) " +
"primaryFrame={\(debugSessionRectDescription(primaryWindowSnapshot.frame))} " +
"primaryDisplay={\(debugSessionDisplayDescription(primaryWindowSnapshot.display))}"
)
#endif
applySessionWindowSnapshot(
primaryWindowSnapshot,
to: primaryContext,
@ -1057,21 +1066,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
}
if let startupSnapshot {
let additionalWindows = startupSnapshot
let additionalWindows = Array(startupSnapshot
.windows
.dropFirst()
.prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1))
.prefix(max(0, SessionPersistencePolicy.maxWindowsPerSnapshot - 1)))
#if DEBUG
for (index, windowSnapshot) in additionalWindows.enumerated() {
dlog(
"session.restore.enqueueAdditional idx=\(index + 1) " +
"frame={\(debugSessionRectDescription(windowSnapshot.frame))} " +
"display={\(debugSessionDisplayDescription(windowSnapshot.display))}"
)
}
#endif
if !additionalWindows.isEmpty {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
for windowSnapshot in additionalWindows {
_ = self.createMainWindow(sessionWindowSnapshot: windowSnapshot)
}
self.completeStartupSessionRestore()
}
} else {
completeStartupSessionRestore()
}
}
}
self.startupSessionSnapshot = nil
private func completeStartupSessionRestore() {
startupSessionSnapshot = nil
isApplyingStartupSessionRestore = false
_ = saveSessionSnapshot(includeScrollback: false)
}
private func applySessionWindowSnapshot(
@ -1079,6 +1104,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
to context: MainWindowContext,
window: NSWindow?
) {
#if DEBUG
dlog(
"session.restore.apply window=\(context.windowId.uuidString.prefix(8)) " +
"liveWin=\(window?.windowNumber ?? -1) " +
"snapshotFrame={\(debugSessionRectDescription(snapshot.frame))} " +
"snapshotDisplay={\(debugSessionDisplayDescription(snapshot.display))}"
)
#endif
context.tabManager.restoreSessionSnapshot(snapshot.tabManager)
context.sidebarState.isVisible = snapshot.sidebar.isVisible
context.sidebarState.persistedWidth = CGFloat(
@ -1088,6 +1121,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
if let restoredFrame = resolvedWindowFrame(from: snapshot), let window {
window.setFrame(restoredFrame, display: true)
#if DEBUG
dlog(
"session.restore.frameApplied window=\(context.windowId.uuidString.prefix(8)) " +
"applied={\(debugNSRectDescription(window.frame))}"
)
#endif
}
}
@ -1150,6 +1189,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
guard !availableDisplays.isEmpty else { return frame }
if let targetDisplay = display(for: displaySnapshot, in: availableDisplays) {
if shouldPreserveExactFrame(
frame: frame,
displaySnapshot: displaySnapshot,
targetDisplay: targetDisplay
) {
return frame
}
return resolvedWindowFrame(
frame: frame,
displaySnapshot: displaySnapshot,
@ -1339,6 +1385,45 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return (dx * dx) + (dy * dy)
}
private nonisolated static func shouldPreserveExactFrame(
frame: CGRect,
displaySnapshot: SessionDisplaySnapshot?,
targetDisplay: SessionDisplayGeometry
) -> Bool {
guard let displaySnapshot else { return false }
guard let snapshotDisplayID = displaySnapshot.displayID,
let targetDisplayID = targetDisplay.displayID,
snapshotDisplayID == targetDisplayID else {
return false
}
let visibleMatches = displaySnapshot.visibleFrame.map {
rectApproximatelyEqual($0.cgRect, targetDisplay.visibleFrame)
} ?? false
let frameMatches = displaySnapshot.frame.map {
rectApproximatelyEqual($0.cgRect, targetDisplay.frame)
} ?? false
guard visibleMatches || frameMatches else { return false }
return frame.width.isFinite
&& frame.height.isFinite
&& frame.origin.x.isFinite
&& frame.origin.y.isFinite
}
private nonisolated static func rectApproximatelyEqual(
_ lhs: CGRect,
_ rhs: CGRect,
tolerance: CGFloat = 1
) -> Bool {
let lhsStd = lhs.standardized
let rhsStd = rhs.standardized
return abs(lhsStd.origin.x - rhsStd.origin.x) <= tolerance
&& abs(lhsStd.origin.y - rhsStd.origin.y) <= tolerance
&& abs(lhsStd.size.width - rhsStd.size.width) <= tolerance
&& abs(lhsStd.size.height - rhsStd.size.height) <= tolerance
}
private func displaySnapshot(for window: NSWindow?) -> SessionDisplaySnapshot? {
guard let window else { return nil }
let screen = window.screen
@ -1421,6 +1506,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
@discardableResult
private func saveSessionSnapshot(includeScrollback: Bool, removeWhenEmpty: Bool = false) -> Bool {
if Self.shouldSkipSessionSaveDuringStartupRestore(
isApplyingStartupSessionRestore: isApplyingStartupSessionRestore,
includeScrollback: includeScrollback
) {
#if DEBUG
dlog("session.save.skipped reason=startup_restore_in_progress includeScrollback=0")
#endif
return false
}
guard let snapshot = buildSessionSnapshot(includeScrollback: includeScrollback) else {
if removeWhenEmpty {
SessionPersistenceStore.removeSnapshot()
@ -1433,6 +1527,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
display: primaryWindow.display
)
}
#if DEBUG
debugLogSessionSaveSnapshot(snapshot, includeScrollback: includeScrollback)
#endif
return SessionPersistenceStore.save(snapshot)
}
@ -1446,6 +1543,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
!isTerminatingApp
}
nonisolated static func shouldSkipSessionSaveDuringStartupRestore(
isApplyingStartupSessionRestore: Bool,
includeScrollback: Bool
) -> Bool {
isApplyingStartupSessionRestore && !includeScrollback
}
private func buildSessionSnapshot(includeScrollback: Bool) -> AppSessionSnapshot? {
let contexts = mainWindowContexts.values.sorted { lhs, rhs in
let lhsWindow = lhs.window ?? windowForMainWindowId(lhs.windowId)
@ -1484,6 +1588,54 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
)
}
#if DEBUG
private func debugLogSessionSaveSnapshot(
_ snapshot: AppSessionSnapshot,
includeScrollback: Bool
) {
dlog(
"session.save includeScrollback=\(includeScrollback ? 1 : 0) " +
"windows=\(snapshot.windows.count)"
)
for (index, windowSnapshot) in snapshot.windows.enumerated() {
let workspaceCount = windowSnapshot.tabManager.workspaces.count
let selectedWorkspace = windowSnapshot.tabManager.selectedWorkspaceIndex.map(String.init) ?? "nil"
dlog(
"session.save.window idx=\(index) " +
"frame={\(debugSessionRectDescription(windowSnapshot.frame))} " +
"display={\(debugSessionDisplayDescription(windowSnapshot.display))} " +
"workspaces=\(workspaceCount) selected=\(selectedWorkspace)"
)
}
}
private func debugSessionRectDescription(_ rect: SessionRectSnapshot?) -> String {
guard let rect else { return "nil" }
return "x=\(debugSessionNumber(rect.x)) y=\(debugSessionNumber(rect.y)) " +
"w=\(debugSessionNumber(rect.width)) h=\(debugSessionNumber(rect.height))"
}
private func debugNSRectDescription(_ rect: NSRect?) -> String {
guard let rect else { return "nil" }
return "x=\(debugSessionNumber(Double(rect.origin.x))) " +
"y=\(debugSessionNumber(Double(rect.origin.y))) " +
"w=\(debugSessionNumber(Double(rect.size.width))) " +
"h=\(debugSessionNumber(Double(rect.size.height)))"
}
private func debugSessionDisplayDescription(_ display: SessionDisplaySnapshot?) -> String {
guard let display else { return "nil" }
let displayIdText = display.displayID.map(String.init) ?? "nil"
return "id=\(displayIdText) " +
"frame={\(debugSessionRectDescription(display.frame))} " +
"visible={\(debugSessionRectDescription(display.visibleFrame))}"
}
private func debugSessionNumber(_ value: Double) -> String {
String(format: "%.1f", value)
}
#endif
/// Register a terminal window with the AppDelegate so menu commands and socket control
/// can target whichever window is currently active.
func registerMainWindow(
@ -2186,9 +2338,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return context
}
// If a keyboard event identifies a specific window but that context
// can't be resolved, do not fall back to another window.
if shortcutEventHasAddressableWindow(event) {
#if DEBUG
logWorkspaceCreationRouting(
phase: "choose",
source: debugSource,
reason: "event_context_required_no_fallback",
event: event,
chosenContext: nil
)
#endif
return nil
}
if let keyWindow = NSApp.keyWindow,
let context = contextForMainTerminalWindow(keyWindow) {
#if DEBUG
#if DEBUG
logWorkspaceCreationRouting(
phase: "choose",
source: debugSource,
@ -2238,10 +2405,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
event: event,
chosenContext: fallback
)
#endif
#endif
return fallback
}
private func shortcutEventHasAddressableWindow(_ event: NSEvent?) -> Bool {
guard let event else { return false }
return event.window != nil || event.windowNumber >= 0
}
private func mainWindowContext(
forShortcutEvent event: NSEvent?,
debugSource: String = "unspecified"
@ -2306,6 +2478,76 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return nil
}
private func preferredMainWindowContextForShortcutRouting(event: NSEvent) -> MainWindowContext? {
if let context = mainWindowContext(forShortcutEvent: event, debugSource: "shortcut.routing") {
return context
}
if shortcutEventHasAddressableWindow(event) {
#if DEBUG
logWorkspaceCreationRouting(
phase: "choose",
source: "shortcut.routing",
reason: "event_context_required_no_fallback",
event: event,
chosenContext: nil
)
#endif
return nil
}
if let keyWindow = NSApp.keyWindow,
let context = contextForMainTerminalWindow(keyWindow) {
return context
}
if let mainWindow = NSApp.mainWindow,
let context = contextForMainTerminalWindow(mainWindow) {
return context
}
if let activeManager = tabManager,
let context = mainWindowContexts.values.first(where: { $0.tabManager === activeManager }) {
return context
}
return mainWindowContexts.values.first
}
@discardableResult
private func synchronizeShortcutRoutingContext(event: NSEvent) -> Bool {
guard let context = preferredMainWindowContextForShortcutRouting(event: event) else {
#if DEBUG
FocusLogStore.shared.append(
"shortcut.route reason=no_context_no_fallback eventWin=\(event.windowNumber) keyCode=\(event.keyCode)"
)
#endif
return false
}
let alreadyActive =
tabManager === context.tabManager
&& sidebarState === context.sidebarState
&& sidebarSelectionState === context.sidebarSelectionState
if alreadyActive { return true }
if let window = context.window ?? windowForMainWindowId(context.windowId) {
setActiveMainWindow(window)
} else {
tabManager = context.tabManager
sidebarState = context.sidebarState
sidebarSelectionState = context.sidebarSelectionState
TerminalController.shared.setActiveTabManager(context.tabManager)
}
#if DEBUG
FocusLogStore.shared.append(
"shortcut.route reason=sync activeTM=\(pointerString(tabManager)) chosen={\(summarizeContextForWorkspaceRouting(context))}"
)
#endif
return true
}
@discardableResult
func createMainWindow(
initialWorkingDirectory: String? = nil,
@ -2346,7 +2588,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
window.titlebarAppearsTransparent = true
window.isMovableByWindowBackground = false
window.isMovable = false
if let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot) {
let restoredFrame = resolvedWindowFrame(from: sessionWindowSnapshot)
if let restoredFrame {
window.setFrame(restoredFrame, display: false)
} else {
window.center()
@ -2384,6 +2627,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
setActiveMainWindow(window)
NSApp.activate(ignoringOtherApps: true)
}
if let restoredFrame {
window.setFrame(restoredFrame, display: true)
#if DEBUG
dlog(
"session.restore.frameApplied window=\(windowId.uuidString.prefix(8)) " +
"applied={\(debugNSRectDescription(window.frame))}"
)
#endif
}
return windowId
}
@ -3500,9 +3752,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
return true
}
// Route all shortcut handling through the window that actually produced
// the event to avoid cross-window actions when app-global pointers are stale.
activateMainWindowContextForShortcutEvent(event)
let hasEventWindowContext = shortcutEventHasAddressableWindow(event)
let didSynchronizeShortcutContext = synchronizeShortcutRoutingContext(event: event)
if hasEventWindowContext && !didSynchronizeShortcutContext {
#if DEBUG
dlog("handleCustomShortcut: unresolved event window context; bypassing app shortcut handling")
#endif
return false
}
// Keep keyboard routing deterministic after split close/reparent transitions:
// before processing shortcuts, converge first responder with the focused terminal panel.

View file

@ -1186,6 +1186,13 @@ final class BrowserPanel: Panel, ObservableObject {
/// Published can go forward state
@Published private(set) var canGoForward: Bool = false
private var nativeCanGoBack: Bool = false
private var nativeCanGoForward: Bool = false
private var usesRestoredSessionHistory: Bool = false
private var restoredBackHistoryStack: [URL] = []
private var restoredForwardHistoryStack: [URL] = []
private var restoredHistoryCurrentURL: URL?
/// Published estimated progress (0.0 - 1.0)
@Published private(set) var estimatedProgress: Double = 0.0
@ -1388,6 +1395,43 @@ final class BrowserPanel: Panel, ObservableObject {
focusFlashToken &+= 1
}
func sessionNavigationHistorySnapshot() -> (
backHistoryURLStrings: [String],
forwardHistoryURLStrings: [String]
) {
if usesRestoredSessionHistory {
let back = restoredBackHistoryStack.compactMap { Self.serializableSessionHistoryURLString($0) }
// `restoredForwardHistoryStack` stores nearest-forward entries at the end.
let forward = restoredForwardHistoryStack.reversed().compactMap { Self.serializableSessionHistoryURLString($0) }
return (back, forward)
}
let back = webView.backForwardList.backList.compactMap {
Self.serializableSessionHistoryURLString($0.url)
}
let forward = webView.backForwardList.forwardList.compactMap {
Self.serializableSessionHistoryURLString($0.url)
}
return (back, forward)
}
func restoreSessionNavigationHistory(
backHistoryURLStrings: [String],
forwardHistoryURLStrings: [String],
currentURLString: String?
) {
let restoredBack = Self.sanitizedSessionHistoryURLs(backHistoryURLStrings)
let restoredForward = Self.sanitizedSessionHistoryURLs(forwardHistoryURLStrings)
guard !restoredBack.isEmpty || !restoredForward.isEmpty else { return }
usesRestoredSessionHistory = true
restoredBackHistoryStack = restoredBack
// Store nearest-forward entries at the end to make stack pop operations trivial.
restoredForwardHistoryStack = Array(restoredForward.reversed())
restoredHistoryCurrentURL = Self.sanitizedSessionHistoryURL(currentURLString)
refreshNavigationAvailability()
}
private func setupObservers() {
// URL changes
let urlObserver = webView.observe(\.url, options: [.new]) { [weak self] webView, _ in
@ -1421,7 +1465,9 @@ final class BrowserPanel: Panel, ObservableObject {
// Can go back
let backObserver = webView.observe(\.canGoBack, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.canGoBack = webView.canGoBack
guard let self else { return }
self.nativeCanGoBack = webView.canGoBack
self.refreshNavigationAvailability()
}
}
webViewObservers.append(backObserver)
@ -1429,7 +1475,9 @@ final class BrowserPanel: Panel, ObservableObject {
// Can go forward
let forwardObserver = webView.observe(\.canGoForward, options: [.new]) { [weak self] webView, _ in
Task { @MainActor in
self?.canGoForward = webView.canGoForward
guard let self else { return }
self.nativeCanGoForward = webView.canGoForward
self.refreshNavigationAvailability()
}
}
webViewObservers.append(forwardObserver)
@ -1692,13 +1740,28 @@ final class BrowserPanel: Panel, ObservableObject {
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
}
private func navigateWithoutInsecureHTTPPrompt(to url: URL, recordTypedNavigation: Bool) {
private func navigateWithoutInsecureHTTPPrompt(
to url: URL,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool = false
) {
let request = URLRequest(url: url)
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
navigateWithoutInsecureHTTPPrompt(
request: request,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
)
}
private func navigateWithoutInsecureHTTPPrompt(request: URLRequest, recordTypedNavigation: Bool) {
private func navigateWithoutInsecureHTTPPrompt(
request: URLRequest,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool = false
) {
guard let url = request.url else { return }
if !preserveRestoredSessionHistory {
abandonRestoredSessionHistoryIfNeeded()
}
// Some installs can end up with a legacy Chrome UA override; keep this pinned.
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
shouldRenderWebView = true
@ -1843,12 +1906,48 @@ extension BrowserPanel {
/// Go back in history
func goBack() {
guard canGoBack else { return }
if usesRestoredSessionHistory {
guard let targetURL = restoredBackHistoryStack.popLast() else {
refreshNavigationAvailability()
return
}
if let current = resolvedCurrentSessionHistoryURL() {
restoredForwardHistoryStack.append(current)
}
restoredHistoryCurrentURL = targetURL
refreshNavigationAvailability()
navigateWithoutInsecureHTTPPrompt(
to: targetURL,
recordTypedNavigation: false,
preserveRestoredSessionHistory: true
)
return
}
webView.goBack()
}
/// Go forward in history
func goForward() {
guard canGoForward else { return }
if usesRestoredSessionHistory {
guard let targetURL = restoredForwardHistoryStack.popLast() else {
refreshNavigationAvailability()
return
}
if let current = resolvedCurrentSessionHistoryURL() {
restoredBackHistoryStack.append(current)
}
restoredHistoryCurrentURL = targetURL
refreshNavigationAvailability()
navigateWithoutInsecureHTTPPrompt(
to: targetURL,
recordTypedNavigation: false,
preserveRestoredSessionHistory: true
)
return
}
webView.goForward()
}
@ -2185,6 +2284,64 @@ extension BrowserPanel {
return nil
}
private func resolvedCurrentSessionHistoryURL() -> URL? {
if let webViewURL = webView.url,
Self.serializableSessionHistoryURLString(webViewURL) != nil {
return webViewURL
}
if let currentURL,
Self.serializableSessionHistoryURLString(currentURL) != nil {
return currentURL
}
return restoredHistoryCurrentURL
}
private func refreshNavigationAvailability() {
let resolvedCanGoBack: Bool
let resolvedCanGoForward: Bool
if usesRestoredSessionHistory {
resolvedCanGoBack = !restoredBackHistoryStack.isEmpty
resolvedCanGoForward = !restoredForwardHistoryStack.isEmpty
} else {
resolvedCanGoBack = nativeCanGoBack
resolvedCanGoForward = nativeCanGoForward
}
if canGoBack != resolvedCanGoBack {
canGoBack = resolvedCanGoBack
}
if canGoForward != resolvedCanGoForward {
canGoForward = resolvedCanGoForward
}
}
private func abandonRestoredSessionHistoryIfNeeded() {
guard usesRestoredSessionHistory else { return }
usesRestoredSessionHistory = false
restoredBackHistoryStack.removeAll(keepingCapacity: false)
restoredForwardHistoryStack.removeAll(keepingCapacity: false)
restoredHistoryCurrentURL = nil
refreshNavigationAvailability()
}
private static func serializableSessionHistoryURLString(_ url: URL?) -> String? {
guard let url else { return nil }
let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty, value != "about:blank" else { return nil }
return value
}
private static func sanitizedSessionHistoryURL(_ raw: String?) -> URL? {
guard let raw else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != "about:blank" else { return nil }
return URL(string: trimmed)
}
private static func sanitizedSessionHistoryURLs(_ values: [String]) -> [URL] {
values.compactMap { sanitizedSessionHistoryURL($0) }
}
}
private extension BrowserPanel {

View file

@ -231,6 +231,8 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable {
var shouldRenderWebView: Bool
var pageZoom: Double
var developerToolsVisible: Bool
var backHistoryURLStrings: [String]?
var forwardHistoryURLStrings: [String]?
}
struct SessionPanelSnapshot: Codable, Sendable {

View file

@ -295,11 +295,14 @@ extension Workspace {
case .browser:
guard let browserPanel = panel as? BrowserPanel else { return nil }
terminalSnapshot = nil
let historySnapshot = browserPanel.sessionNavigationHistorySnapshot()
browserSnapshot = SessionBrowserPanelSnapshot(
urlString: browserPanel.currentURL?.absoluteString,
urlString: browserPanel.preferredURLStringForOmnibar(),
shouldRenderWebView: browserPanel.shouldRenderWebView,
pageZoom: Double(browserPanel.webView.pageZoom),
developerToolsVisible: browserPanel.isDeveloperToolsVisible()
developerToolsVisible: browserPanel.isDeveloperToolsVisible(),
backHistoryURLStrings: historySnapshot.backHistoryURLStrings,
forwardHistoryURLStrings: historySnapshot.forwardHistoryURLStrings
)
}
@ -512,6 +515,12 @@ extension Workspace {
if let browserSnapshot = snapshot.browser,
let browserPanel = browserPanel(for: panelId) {
browserPanel.restoreSessionNavigationHistory(
backHistoryURLStrings: browserSnapshot.backHistoryURLStrings ?? [],
forwardHistoryURLStrings: browserSnapshot.forwardHistoryURLStrings ?? [],
currentURLString: browserSnapshot.urlString
)
let pageZoom = CGFloat(max(0.25, min(5.0, browserSnapshot.pageZoom)))
if pageZoom.isFinite {
browserPanel.webView.pageZoom = pageZoom

View file

@ -201,6 +201,242 @@ final class AppDelegateShortcutRoutingTests: XCTestCase {
XCTAssertEqual(secondManager.tabs.count, secondCount + 1, "Menu-driven add workspace should still route to key window context when object-key lookup misses")
}
func testCmdDigitRoutesToEventWindowWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
_ = firstManager.addTab(select: true)
_ = secondManager.addTab(select: true)
guard let firstSelectedBefore = firstManager.selectedTabId,
let secondSelectedBefore = secondManager.selectedTabId else {
XCTFail("Expected selected tabs in both windows")
return
}
guard let secondFirstTabId = secondManager.tabs.first?.id else {
XCTFail("Expected at least one tab in second window")
return
}
appDelegate.tabManager = firstManager
XCTAssertTrue(appDelegate.tabManager === firstManager)
guard let event = makeKeyDownEvent(
key: "1",
modifiers: [.command],
keyCode: 18, // kVK_ANSI_1
windowNumber: secondWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+1 event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Cmd+1 must not select a tab in stale active window")
XCTAssertNotEqual(secondManager.selectedTabId, secondSelectedBefore, "Cmd+1 should change tab selection in event window")
XCTAssertEqual(secondManager.selectedTabId, secondFirstTabId, "Cmd+1 should select first tab in the event window")
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
}
func testCmdTRoutesToEventWindowWhenActiveManagerIsStale() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId),
let firstWorkspace = firstManager.selectedWorkspace,
let secondWorkspace = secondManager.selectedWorkspace else {
XCTFail("Expected both window contexts to exist")
return
}
let firstSurfaceCount = firstWorkspace.panels.count
let secondSurfaceCount = secondWorkspace.panels.count
appDelegate.tabManager = firstManager
XCTAssertTrue(appDelegate.tabManager === firstManager)
guard let event = makeKeyDownEvent(
key: "t",
modifiers: [.command],
keyCode: 17, // kVK_ANSI_T
windowNumber: secondWindow.windowNumber
) else {
XCTFail("Failed to construct Cmd+T event")
return
}
#if DEBUG
XCTAssertTrue(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
XCTAssertEqual(firstWorkspace.panels.count, firstSurfaceCount, "Cmd+T must not create a surface in stale active window")
XCTAssertEqual(secondWorkspace.panels.count, secondSurfaceCount + 1, "Cmd+T should create a surface in the event window")
XCTAssertTrue(appDelegate.tabManager === secondManager, "Shortcut routing should retarget active manager to event window")
}
func testCmdDigitDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
_ = firstManager.addTab(select: true)
_ = secondManager.addTab(select: true)
guard let firstSelectedBefore = firstManager.selectedTabId,
let secondSelectedBefore = secondManager.selectedTabId else {
XCTFail("Expected selected tabs in both windows")
return
}
secondWindow.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
// Force stale app-level manager to first window while keyboard event
// references no known window.
appDelegate.tabManager = firstManager
guard let event = makeKeyDownEvent(
key: "1",
modifiers: [.command],
keyCode: 18,
windowNumber: Int.max
) else {
XCTFail("Failed to construct Cmd+1 event")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(firstManager.selectedTabId, firstSelectedBefore, "Unresolved event window must not route Cmd+1 into stale manager")
XCTAssertEqual(secondManager.selectedTabId, secondSelectedBefore, "Unresolved event window must not route Cmd+1 into key/main fallback manager")
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
}
func testCmdNDoesNotFallbackToOtherWindowWhenEventWindowContextIsMissing() {
guard let appDelegate = AppDelegate.shared else {
XCTFail("Expected AppDelegate.shared")
return
}
let firstWindowId = appDelegate.createMainWindow()
let secondWindowId = appDelegate.createMainWindow()
defer {
closeWindow(withId: firstWindowId)
closeWindow(withId: secondWindowId)
}
guard let firstManager = appDelegate.tabManagerFor(windowId: firstWindowId),
let secondManager = appDelegate.tabManagerFor(windowId: secondWindowId),
let secondWindow = window(withId: secondWindowId) else {
XCTFail("Expected both window contexts to exist")
return
}
secondWindow.makeKeyAndOrderFront(nil)
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
let firstCount = firstManager.tabs.count
let secondCount = secondManager.tabs.count
appDelegate.tabManager = firstManager
guard let event = makeKeyDownEvent(
key: "n",
modifiers: [.command],
keyCode: 45,
windowNumber: Int.max
) else {
XCTFail("Failed to construct Cmd+N event")
return
}
#if DEBUG
XCTAssertFalse(appDelegate.debugHandleCustomShortcut(event: event))
#else
XCTFail("debugHandleCustomShortcut is only available in DEBUG")
#endif
XCTAssertEqual(firstManager.tabs.count, firstCount, "Unresolved event window must not create workspace in stale manager")
XCTAssertEqual(secondManager.tabs.count, secondCount, "Unresolved event window must not create workspace in fallback window")
XCTAssertTrue(appDelegate.tabManager === firstManager, "Unresolved event window should not retarget active manager")
}
private func makeKeyDownEvent(
key: String,
modifiers: NSEvent.ModifierFlags,
keyCode: UInt16,
windowNumber: Int
) -> NSEvent? {
NSEvent.keyEvent(
with: .keyDown,
location: .zero,
modifierFlags: modifiers,
timestamp: ProcessInfo.processInfo.systemUptime,
windowNumber: windowNumber,
context: nil,
characters: key,
charactersIgnoringModifiers: key,
isARepeat: false,
keyCode: keyCode
)
}
private func window(withId windowId: UUID) -> NSWindow? {
let identifier = "cmux.main.\(windowId.uuidString)"
return NSApp.windows.first(where: { $0.identifier?.rawValue == identifier })

View file

@ -1024,6 +1024,72 @@ final class BrowserJavaScriptDialogDelegateTests: XCTestCase {
}
}
@MainActor
final class BrowserSessionHistoryRestoreTests: XCTestCase {
func testSessionNavigationHistorySnapshotUsesRestoredStacks() {
let panel = BrowserPanel(workspaceId: UUID())
panel.restoreSessionNavigationHistory(
backHistoryURLStrings: [
"https://example.com/a",
"https://example.com/b"
],
forwardHistoryURLStrings: [
"https://example.com/d"
],
currentURLString: "https://example.com/c"
)
XCTAssertTrue(panel.canGoBack)
XCTAssertTrue(panel.canGoForward)
let snapshot = panel.sessionNavigationHistorySnapshot()
XCTAssertEqual(
snapshot.backHistoryURLStrings,
["https://example.com/a", "https://example.com/b"]
)
XCTAssertEqual(
snapshot.forwardHistoryURLStrings,
["https://example.com/d"]
)
}
func testSessionNavigationHistoryBackAndForwardUpdateStacks() {
let panel = BrowserPanel(workspaceId: UUID())
panel.restoreSessionNavigationHistory(
backHistoryURLStrings: [
"https://example.com/a",
"https://example.com/b"
],
forwardHistoryURLStrings: [
"https://example.com/d"
],
currentURLString: "https://example.com/c"
)
panel.goBack()
let afterBack = panel.sessionNavigationHistorySnapshot()
XCTAssertEqual(afterBack.backHistoryURLStrings, ["https://example.com/a"])
XCTAssertEqual(
afterBack.forwardHistoryURLStrings,
["https://example.com/c", "https://example.com/d"]
)
XCTAssertTrue(panel.canGoBack)
XCTAssertTrue(panel.canGoForward)
panel.goForward()
let afterForward = panel.sessionNavigationHistorySnapshot()
XCTAssertEqual(
afterForward.backHistoryURLStrings,
["https://example.com/a", "https://example.com/b"]
)
XCTAssertEqual(afterForward.forwardHistoryURLStrings, ["https://example.com/d"])
XCTAssertTrue(panel.canGoBack)
XCTAssertTrue(panel.canGoForward)
}
}
@MainActor
final class BrowserDeveloperToolsVisibilityPersistenceTests: XCTestCase {
private final class FakeInspector: NSObject {

View file

@ -7,7 +7,7 @@ import XCTest
#endif
final class SessionPersistenceTests: XCTestCase {
func testSaveAndLoadRoundTripWithCustomSnapshotPath() {
func testSaveAndLoadRoundTripWithCustomSnapshotPath() throws {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-session-tests-\(UUID().uuidString)", isDirectory: true)
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
@ -23,6 +23,14 @@ final class SessionPersistenceTests: XCTestCase {
XCTAssertEqual(loaded?.version, SessionSnapshotSchema.currentVersion)
XCTAssertEqual(loaded?.windows.count, 1)
XCTAssertEqual(loaded?.windows.first?.sidebar.selection, .tabs)
let frame = try XCTUnwrap(loaded?.windows.first?.frame)
XCTAssertEqual(frame.x, 10, accuracy: 0.001)
XCTAssertEqual(frame.y, 20, accuracy: 0.001)
XCTAssertEqual(frame.width, 900, accuracy: 0.001)
XCTAssertEqual(frame.height, 700, accuracy: 0.001)
XCTAssertEqual(loaded?.windows.first?.display?.displayID, 42)
let visibleFrame = try XCTUnwrap(loaded?.windows.first?.display?.visibleFrame)
XCTAssertEqual(visibleFrame.y, 25, accuracy: 0.001)
}
func testSaveAndLoadRoundTripPreservesWorkspaceCustomColor() {
@ -129,6 +137,56 @@ final class SessionPersistenceTests: XCTestCase {
)
}
func testSessionRectSnapshotEncodesXYWidthHeightKeys() throws {
let snapshot = SessionRectSnapshot(x: 101.25, y: 202.5, width: 903.75, height: 704.5)
let data = try JSONEncoder().encode(snapshot)
let object = try XCTUnwrap(try JSONSerialization.jsonObject(with: data) as? [String: Double])
XCTAssertEqual(Set(object.keys), Set(["x", "y", "width", "height"]))
XCTAssertEqual(try XCTUnwrap(object["x"]), 101.25, accuracy: 0.001)
XCTAssertEqual(try XCTUnwrap(object["y"]), 202.5, accuracy: 0.001)
XCTAssertEqual(try XCTUnwrap(object["width"]), 903.75, accuracy: 0.001)
XCTAssertEqual(try XCTUnwrap(object["height"]), 704.5, accuracy: 0.001)
}
func testSessionBrowserPanelSnapshotHistoryRoundTrip() throws {
let source = SessionBrowserPanelSnapshot(
urlString: "https://example.com/current",
shouldRenderWebView: true,
pageZoom: 1.2,
developerToolsVisible: true,
backHistoryURLStrings: [
"https://example.com/a",
"https://example.com/b"
],
forwardHistoryURLStrings: [
"https://example.com/d"
]
)
let data = try JSONEncoder().encode(source)
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: data)
XCTAssertEqual(decoded.urlString, source.urlString)
XCTAssertEqual(decoded.backHistoryURLStrings, source.backHistoryURLStrings)
XCTAssertEqual(decoded.forwardHistoryURLStrings, source.forwardHistoryURLStrings)
}
func testSessionBrowserPanelSnapshotHistoryDecodesWhenKeysAreMissing() throws {
let json = """
{
"urlString": "https://example.com/current",
"shouldRenderWebView": true,
"pageZoom": 1.0,
"developerToolsVisible": false
}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode(SessionBrowserPanelSnapshot.self, from: json)
XCTAssertEqual(decoded.urlString, "https://example.com/current")
XCTAssertNil(decoded.backHistoryURLStrings)
XCTAssertNil(decoded.forwardHistoryURLStrings)
}
func testScrollbackReplayEnvironmentWritesReplayFile() {
let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("cmux-scrollback-replay-\(UUID().uuidString)", isDirectory: true)
@ -284,6 +342,27 @@ final class SessionPersistenceTests: XCTestCase {
)
}
func testShouldSkipSessionSaveDuringStartupRestorePolicy() {
XCTAssertTrue(
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
isApplyingStartupSessionRestore: true,
includeScrollback: false
)
)
XCTAssertFalse(
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
isApplyingStartupSessionRestore: true,
includeScrollback: true
)
)
XCTAssertFalse(
AppDelegate.shouldSkipSessionSaveDuringStartupRestore(
isApplyingStartupSessionRestore: false,
includeScrollback: false
)
)
}
func testResolvedWindowFramePrefersSavedDisplayIdentity() {
let savedFrame = SessionRectSnapshot(x: 1_200, y: 100, width: 600, height: 400)
let savedDisplay = SessionDisplaySnapshot(
@ -436,6 +515,61 @@ final class SessionPersistenceTests: XCTestCase {
XCTAssertEqual(restored.height, 700, accuracy: 0.001)
}
func testResolvedWindowFramePreservesExactGeometryWhenDisplayIsUnchanged() {
let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410)
let savedDisplay = SessionDisplaySnapshot(
displayID: 2,
frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440),
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410)
)
let display = AppDelegate.SessionDisplayGeometry(
displayID: 2,
frame: CGRect(x: 0, y: 0, width: 2_560, height: 1_440),
visibleFrame: CGRect(x: 0, y: 0, width: 2_560, height: 1_410)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: savedDisplay,
availableDisplays: [display],
fallbackDisplay: display
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertEqual(restored.minX, 1_303, accuracy: 0.001)
XCTAssertEqual(restored.minY, -90, accuracy: 0.001)
XCTAssertEqual(restored.width, 1_280, accuracy: 0.001)
XCTAssertEqual(restored.height, 1_410, accuracy: 0.001)
}
func testResolvedWindowFrameClampsWhenDisplayGeometryChangesEvenWithSameDisplayID() {
let savedFrame = SessionRectSnapshot(x: 1_303, y: -90, width: 1_280, height: 1_410)
let savedDisplay = SessionDisplaySnapshot(
displayID: 2,
frame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_440),
visibleFrame: SessionRectSnapshot(x: 0, y: 0, width: 2_560, height: 1_410)
)
let resizedDisplay = AppDelegate.SessionDisplayGeometry(
displayID: 2,
frame: CGRect(x: 0, y: 0, width: 1_920, height: 1_080),
visibleFrame: CGRect(x: 0, y: 0, width: 1_920, height: 1_050)
)
let restored = AppDelegate.resolvedWindowFrame(
from: savedFrame,
display: savedDisplay,
availableDisplays: [resizedDisplay],
fallbackDisplay: resizedDisplay
)
XCTAssertNotNil(restored)
guard let restored else { return }
XCTAssertTrue(resizedDisplay.visibleFrame.contains(restored))
XCTAssertNotEqual(restored.minX, 1_303, "Changed display geometry should clamp/remap frame")
XCTAssertNotEqual(restored.minY, -90, "Changed display geometry should clamp/remap frame")
}
func testResolvedSnapshotTerminalScrollbackPrefersCaptured() {
let resolved = Workspace.resolvedSnapshotTerminalScrollback(
capturedScrollback: "captured-value",