From 3b09b5cbc29ddaa620484e2def947ed9d6c259ff Mon Sep 17 00:00:00 2001 From: Bruno Volpato Date: Thu, 26 Feb 2026 09:30:31 -0500 Subject: [PATCH] [macos] restore addTabbedWindowSafely to fix tab crash Port upstream Ghostty's ObjCExceptionCatcher that wraps addTabbedWindow:ordered: in @try/@catch. AppKit can throw NSExceptions during tab operations on recent macOS (2025/2026), and Swift cannot catch those natively. Made-with: Cursor --- macos/Ghostty.xcodeproj/project.pbxproj | 1 + .../App/macOS/ghostty-bridging-header.h | 1 + .../Terminal/TerminalController.swift | 22 ++++++------- .../Extensions/NSWindow+Extension.swift | 14 ++++++++ macos/Sources/Helpers/Fullscreen.swift | 6 ++-- macos/Sources/Helpers/ObjCExceptionCatcher.h | 13 ++++++++ macos/Sources/Helpers/ObjCExceptionCatcher.m | 32 +++++++++++++++++++ 7 files changed, 75 insertions(+), 14 deletions(-) create mode 100644 macos/Sources/Helpers/ObjCExceptionCatcher.h create mode 100644 macos/Sources/Helpers/ObjCExceptionCatcher.m diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b515dfb54d..ff913720a6 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -208,6 +208,7 @@ Helpers/PermissionRequest.swift, Helpers/Private/CGS.swift, Helpers/Private/Dock.swift, + Helpers/ObjCExceptionCatcher.m, Helpers/TabGroupCloseCoordinator.swift, Helpers/VibrantLayer.m, Helpers/Weak.swift, diff --git a/macos/Sources/App/macOS/ghostty-bridging-header.h b/macos/Sources/App/macOS/ghostty-bridging-header.h index fc654ad3f7..44781cbe97 100644 --- a/macos/Sources/App/macOS/ghostty-bridging-header.h +++ b/macos/Sources/App/macOS/ghostty-bridging-header.h @@ -1,3 +1,4 @@ // C imports here are exposed to Swift. +#import "ObjCExceptionCatcher.h" #import "VibrantLayer.h" diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index fedc669f24..7e9ed87d63 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -744,14 +744,14 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // If we already have a tab group and we want the new tab to open at the end, // then we use the last window in the tab group as the parent. if let last = parent.tabGroup?.windows.last { - last.addTabbedWindow(window, ordered: .above) + last.addTabbedWindowSafely(window, ordered: .above) } else { fallthrough } case "current": fallthrough default: - parent.addTabbedWindow(window, ordered: .above) + parent.addTabbedWindowSafely(window, ordered: .above) } } @@ -890,7 +890,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if #available(macOS 26, *) { if window is TitlebarTabsTahoeTerminalWindow { tabGroup.removeWindow(movingWindow) - targetWindow.addTabbedWindow(movingWindow, ordered: .below) + targetWindow.addTabbedWindowSafely(movingWindow, ordered: .below) relabelTabs() return } @@ -899,7 +899,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr NSAnimationContext.beginGrouping() NSAnimationContext.current.duration = 0 tabGroup.removeWindow(movingWindow) - targetWindow.addTabbedWindow(movingWindow, ordered: .below) + targetWindow.addTabbedWindowSafely(movingWindow, ordered: .below) NSAnimationContext.endGrouping() relabelTabs() @@ -915,7 +915,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if #available(macOS 26, *) { if window is TitlebarTabsTahoeTerminalWindow { tabGroup.removeWindow(movingWindow) - targetWindow.addTabbedWindow(movingWindow, ordered: .above) + targetWindow.addTabbedWindowSafely(movingWindow, ordered: .above) relabelTabs() return } @@ -924,7 +924,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr NSAnimationContext.beginGrouping() NSAnimationContext.current.duration = 0 tabGroup.removeWindow(movingWindow) - targetWindow.addTabbedWindow(movingWindow, ordered: .above) + targetWindow.addTabbedWindowSafely(movingWindow, ordered: .above) NSAnimationContext.endGrouping() relabelTabs() @@ -1261,7 +1261,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr controller.showWindow(nil) if let firstWindow = firstController.window, let newWindow = controller.window { - firstWindow.addTabbedWindow(newWindow, ordered: .above) + firstWindow.addTabbedWindowSafely(newWindow, ordered: .above) } } @@ -1350,9 +1350,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if tabIndex < tabGroup.windows.count { // Find the window that is currently at that index let currentWindow = tabGroup.windows[tabIndex] - currentWindow.addTabbedWindow(window, ordered: .below) + currentWindow.addTabbedWindowSafely(window, ordered: .below) } else { - tabGroup.windows.last?.addTabbedWindow(window, ordered: .above) + tabGroup.windows.last?.addTabbedWindowSafely(window, ordered: .above) } // Make it the key window @@ -2346,7 +2346,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if #available(macOS 26, *) { if window is TitlebarTabsTahoeTerminalWindow { tabGroup.removeWindow(selectedWindow) - targetWindow.addTabbedWindow(selectedWindow, ordered: action.amount < 0 ? .below : .above) + targetWindow.addTabbedWindowSafely(selectedWindow, ordered: action.amount < 0 ? .below : .above) DispatchQueue.main.async { selectedWindow.makeKey() } @@ -2361,7 +2361,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Remove and re-add the window in the correct position tabGroup.removeWindow(selectedWindow) - targetWindow.addTabbedWindow(selectedWindow, ordered: action.amount < 0 ? .below : .above) + targetWindow.addTabbedWindowSafely(selectedWindow, ordered: action.amount < 0 ? .below : .above) // Ensure our window remains selected selectedWindow.makeKey() diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 0fa330f1ba..385c129e5a 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -39,6 +39,20 @@ extension NSWindow { guard let firstWindow = tabGroup?.windows.first else { return true } return firstWindow === self } + + @discardableResult + func addTabbedWindowSafely( + _ child: NSWindow, + ordered: NSWindow.OrderingMode + ) -> Bool { + var error: NSError? + let success = GhosttyAddTabbedWindowSafely(self, child, ordered.rawValue, &error) + if let error { + Ghostty.logger.error("addTabbedWindow failed: \(error.localizedDescription)") + } + + return success + } } /// Native tabbing private API usage. :( diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 0dfc5b0e5e..40454ef134 100644 --- a/macos/Sources/Helpers/Fullscreen.swift +++ b/macos/Sources/Helpers/Fullscreen.swift @@ -296,13 +296,13 @@ class NonNativeFullscreen: FullscreenBase, FullscreenStyle { if tabIndex == 0 { // We were previously the first tab. Add it before ("below") // the first window in the tab group currently. - tabGroup.windows.first!.addTabbedWindow(window, ordered: .below) + tabGroup.windows.first!.addTabbedWindowSafely(window, ordered: .below) } else if tabIndex <= tabGroup.windows.count { // We were somewhere in the middle - tabGroup.windows[tabIndex - 1].addTabbedWindow(window, ordered: .above) + tabGroup.windows[tabIndex - 1].addTabbedWindowSafely(window, ordered: .above) } else { // We were at the end - tabGroup.windows.last!.addTabbedWindow(window, ordered: .below) + tabGroup.windows.last!.addTabbedWindowSafely(window, ordered: .below) } } diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.h b/macos/Sources/Helpers/ObjCExceptionCatcher.h new file mode 100644 index 0000000000..7906b59457 --- /dev/null +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.h @@ -0,0 +1,13 @@ +#import + +/// This file contains wrappers around various ObjC functions so we can catch +/// exceptions, since you can't natively catch ObjC exceptions from Swift +/// (at least at the time of writing this comment). + +/// NSWindow.addTabbedWindow wrapper +FOUNDATION_EXPORT BOOL GhosttyAddTabbedWindowSafely( + id _Nonnull parent, + id _Nonnull child, + NSInteger ordered, + NSError * _Nullable * _Nullable error +); diff --git a/macos/Sources/Helpers/ObjCExceptionCatcher.m b/macos/Sources/Helpers/ObjCExceptionCatcher.m new file mode 100644 index 0000000000..e91fb14a76 --- /dev/null +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.m @@ -0,0 +1,32 @@ +#import "ObjCExceptionCatcher.h" + +#import + +BOOL GhosttyAddTabbedWindowSafely( + id parent, + id child, + NSInteger ordered, + NSError * _Nullable * _Nullable error +) { + // AppKit occasionally throws NSException while adding tabbed windows, + // in particular when creating tabs from the tab overview page since some + // macOS update recently in 2025/2026 (unclear). + // + // We must catch it in Objective-C; letting this cross into Swift is unsafe. + @try { + [((NSWindow *)parent) addTabbedWindow:(NSWindow *)child ordered:(NSWindowOrderingMode)ordered]; + return YES; + } @catch (NSException *exception) { + if (error != NULL) { + NSString *reason = exception.reason ?: @"Unknown Objective-C exception"; + *error = [NSError errorWithDomain:@"Ghostty.ObjCException" + code:1 + userInfo:@{ + NSLocalizedDescriptionKey: reason, + @"exception_name": exception.name, + }]; + } + + return NO; + } +}