diff --git a/macos/Sources/App/macOS/ghostty-bridging-header.h b/macos/Sources/App/macOS/ghostty-bridging-header.h index fc654ad3f..aa6b1442b 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 "VibrantLayer.h" +#import "ObjCExceptionCatcher.h" diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 6573ac7fc..c5c003459 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -411,14 +411,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) } } @@ -863,7 +863,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) } } @@ -952,9 +952,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 @@ -1386,7 +1386,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() } @@ -1401,7 +1401,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 0fa330f1b..4821e5b46 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -39,6 +39,24 @@ extension NSWindow { guard let firstWindow = tabGroup?.windows.first else { return true } return firstWindow === self } + + /// Wraps `addTabbedWindow` with an Objective-C exception catcher because AppKit can + /// occasionally throw NSExceptions in visual tab picker flows. + @discardableResult + func addTabbedWindowSafely( + _ child: NSWindow, + ordered: NSWindow.OrderingMode + ) -> Bool { + var error: NSError? + let success = GhosttyAddTabbedWindowSafely(self, child, ordered.rawValue, &error) + + if let error { + let reason = error.localizedDescription + Ghostty.logger.error("addTabbedWindow failed: \(reason)") + } + + return success + } } /// Native tabbing private API usage. :( diff --git a/macos/Sources/Helpers/Fullscreen.swift b/macos/Sources/Helpers/Fullscreen.swift index 6773b6f0c..139059190 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 000000000..d435615c5 --- /dev/null +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.h @@ -0,0 +1,9 @@ +#import + +/// Minimal Objective-C exception bridge for AppKit tabbing APIs. +FOUNDATION_EXPORT BOOL GhosttyAddTabbedWindowSafely( + id parent, + id 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 000000000..6def44945 --- /dev/null +++ b/macos/Sources/Helpers/ObjCExceptionCatcher.m @@ -0,0 +1,41 @@ +#import "ObjCExceptionCatcher.h" +#import + +#if TARGET_OS_OSX +#import +#endif + +BOOL GhosttyAddTabbedWindowSafely( + id parent, + id child, + NSInteger ordered, + NSError * _Nullable * _Nullable error +) { +#if TARGET_OS_OSX + @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; + } +#else + if (error != NULL) { + *error = [NSError errorWithDomain:@"Ghostty.ObjCException" + code:2 + userInfo:@{ + NSLocalizedDescriptionKey: @"GhosttyAddTabbedWindowSafely is unavailable on this platform.", + }]; + } + return NO; +#endif +}