Set Sentry environment to "development" for DEBUG builds and "production" for Release builds. Also disable debug logging in production to reduce noise and potential performance impact.
458 lines
18 KiB
Swift
458 lines
18 KiB
Swift
import AppKit
|
|
import CoreServices
|
|
import UserNotifications
|
|
import Sentry
|
|
|
|
final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, NSMenuItemValidation {
|
|
static var shared: AppDelegate?
|
|
|
|
weak var tabManager: TabManager?
|
|
weak var notificationStore: TerminalNotificationStore?
|
|
weak var sidebarState: SidebarState?
|
|
private var workspaceObserver: NSObjectProtocol?
|
|
private var shortcutMonitor: Any?
|
|
private let updateController = UpdateController()
|
|
private lazy var titlebarAccessoryController = UpdateTitlebarAccessoryController(viewModel: updateViewModel)
|
|
private let windowDecorationsController = WindowDecorationsController()
|
|
#if DEBUG
|
|
private var didSetupJumpUnreadUITest = false
|
|
private var jumpUnreadFocusExpectation: (tabId: UUID, surfaceId: UUID)?
|
|
#endif
|
|
|
|
var updateViewModel: UpdateViewModel {
|
|
updateController.viewModel
|
|
}
|
|
|
|
override init() {
|
|
super.init()
|
|
Self.shared = self
|
|
}
|
|
|
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
|
SentrySDK.start { options in
|
|
options.dsn = "https://ecba1ec90ecaee02a102fba931b6d2b3@o4507547940749312.ingest.us.sentry.io/4510796264636416"
|
|
#if DEBUG
|
|
options.environment = "development"
|
|
options.debug = true
|
|
#else
|
|
options.environment = "production"
|
|
options.debug = false
|
|
#endif
|
|
options.sendDefaultPii = true
|
|
}
|
|
|
|
registerLaunchServicesBundle()
|
|
enforceSingleInstance()
|
|
NSWindow.allowsAutomaticWindowTabbing = false
|
|
disableNativeTabbingShortcut()
|
|
ensureApplicationIcon()
|
|
observeDuplicateLaunches()
|
|
configureUserNotifications()
|
|
updateController.startUpdater()
|
|
titlebarAccessoryController.start()
|
|
windowDecorationsController.start()
|
|
installShortcutMonitor()
|
|
#if DEBUG
|
|
UpdateTestSupport.applyIfNeeded(to: updateController.viewModel)
|
|
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_TRIGGER_UPDATE_CHECK"] == "1" {
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
|
|
guard let self else { return }
|
|
if UpdateTestSupport.performMockFeedCheckIfNeeded(on: self.updateController.viewModel) {
|
|
return
|
|
}
|
|
self.updateController.checkForUpdatesWhenReady()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
func applicationDidBecomeActive(_ notification: Notification) {
|
|
guard let tabManager, let notificationStore else { return }
|
|
guard let tabId = tabManager.selectedTabId else { return }
|
|
let surfaceId = tabManager.focusedSurfaceId(for: tabId)
|
|
guard notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surfaceId) else { return }
|
|
|
|
if let surfaceId,
|
|
let tab = tabManager.tabs.first(where: { $0.id == tabId }) {
|
|
tab.triggerNotificationFocusFlash(surfaceId: surfaceId, requiresSplit: false, shouldFocus: false)
|
|
}
|
|
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
|
|
func applicationWillTerminate(_ notification: Notification) {
|
|
notificationStore?.clearAll()
|
|
}
|
|
|
|
func configure(tabManager: TabManager, notificationStore: TerminalNotificationStore, sidebarState: SidebarState) {
|
|
self.tabManager = tabManager
|
|
self.notificationStore = notificationStore
|
|
self.sidebarState = sidebarState
|
|
#if DEBUG
|
|
setupJumpUnreadUITestIfNeeded()
|
|
#endif
|
|
}
|
|
|
|
@objc func checkForUpdates(_ sender: Any?) {
|
|
updateViewModel.overrideState = nil
|
|
updateController.checkForUpdates()
|
|
}
|
|
|
|
#if DEBUG
|
|
@objc func showUpdatePill(_ sender: Any?) {
|
|
updateViewModel.overrideState = .notFound(.init(acknowledgement: {}))
|
|
}
|
|
|
|
@objc func showUpdatePillLoading(_ sender: Any?) {
|
|
updateViewModel.overrideState = .checking(.init(cancel: {}))
|
|
}
|
|
|
|
@objc func hideUpdatePill(_ sender: Any?) {
|
|
updateViewModel.overrideState = .idle
|
|
}
|
|
|
|
@objc func clearUpdatePillOverride(_ sender: Any?) {
|
|
updateViewModel.overrideState = nil
|
|
}
|
|
|
|
@objc func copyUpdateLogs(_ sender: Any?) {
|
|
let logText = UpdateLogStore.shared.snapshot()
|
|
let payload: String
|
|
if logText.isEmpty {
|
|
payload = "No update logs captured.\nLog file: \(UpdateLogStore.shared.logPath())"
|
|
} else {
|
|
payload = logText + "\nLog file: \(UpdateLogStore.shared.logPath())"
|
|
}
|
|
let pasteboard = NSPasteboard.general
|
|
pasteboard.clearContents()
|
|
pasteboard.setString(payload, forType: .string)
|
|
}
|
|
#endif
|
|
|
|
#if DEBUG
|
|
@objc func openDebugScrollbackTab(_ sender: Any?) {
|
|
guard let tabManager else { return }
|
|
let tab = tabManager.addTab()
|
|
let config = GhosttyConfig.load()
|
|
let lineCount = min(max(config.scrollbackLimit * 2, 2000), 60000)
|
|
let command = "for i in {1..\(lineCount)}; do printf \"scrollback %06d\\n\" $i; done\n"
|
|
sendTextWhenReady(command, to: tab)
|
|
}
|
|
|
|
private func sendTextWhenReady(_ text: String, to tab: Tab, attempt: Int = 0) {
|
|
let maxAttempts = 60
|
|
if let surface = tab.focusedSurface, surface.surface != nil {
|
|
surface.sendText(text)
|
|
return
|
|
}
|
|
guard attempt < maxAttempts else {
|
|
NSLog("Debug scrollback: surface not ready after \(maxAttempts) attempts")
|
|
return
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
|
|
self?.sendTextWhenReady(text, to: tab, attempt: attempt + 1)
|
|
}
|
|
}
|
|
|
|
@objc func triggerSentryTestCrash(_ sender: Any?) {
|
|
SentrySDK.crash()
|
|
}
|
|
#endif
|
|
|
|
#if DEBUG
|
|
private func setupJumpUnreadUITestIfNeeded() {
|
|
guard !didSetupJumpUnreadUITest else { return }
|
|
didSetupJumpUnreadUITest = true
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard env["CMUX_UI_TEST_JUMP_UNREAD_SETUP"] == "1" else { return }
|
|
guard let tabManager, let notificationStore else { return }
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
guard let self else { return }
|
|
let initialIndex = tabManager.tabs.firstIndex(where: { $0.id == tabManager.selectedTabId }) ?? 0
|
|
let tab = tabManager.addTab()
|
|
guard let initialSurfaceId = tab.focusedSurfaceId else { return }
|
|
|
|
_ = tabManager.newSplit(tabId: tab.id, surfaceId: initialSurfaceId, direction: .right)
|
|
guard let targetSurfaceId = tab.focusedSurfaceId else { return }
|
|
let otherSurfaceId = tab.splitTree.root?.leaves().first(where: { $0.id != targetSurfaceId })?.id
|
|
if let otherSurfaceId {
|
|
tab.focusedSurfaceId = otherSurfaceId
|
|
}
|
|
|
|
notificationStore.addNotification(
|
|
tabId: tab.id,
|
|
surfaceId: targetSurfaceId,
|
|
title: "JumpToUnread",
|
|
subtitle: "",
|
|
body: ""
|
|
)
|
|
|
|
self.writeJumpUnreadTestData([
|
|
"expectedTabId": tab.id.uuidString,
|
|
"expectedSurfaceId": targetSurfaceId.uuidString
|
|
])
|
|
|
|
tabManager.selectTab(at: initialIndex)
|
|
}
|
|
}
|
|
|
|
func recordJumpToUnreadFocus(tabId: UUID, surfaceId: UUID) {
|
|
writeJumpUnreadTestData([
|
|
"focusedTabId": tabId.uuidString,
|
|
"focusedSurfaceId": surfaceId.uuidString
|
|
])
|
|
}
|
|
|
|
func armJumpUnreadFocusRecord(tabId: UUID, surfaceId: UUID) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard let path = env["CMUX_UI_TEST_JUMP_UNREAD_PATH"], !path.isEmpty else { return }
|
|
jumpUnreadFocusExpectation = (tabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
|
|
func recordJumpUnreadFocusIfExpected(tabId: UUID, surfaceId: UUID) {
|
|
guard let expectation = jumpUnreadFocusExpectation else { return }
|
|
guard expectation.tabId == tabId && expectation.surfaceId == surfaceId else { return }
|
|
jumpUnreadFocusExpectation = nil
|
|
recordJumpToUnreadFocus(tabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
|
|
private func writeJumpUnreadTestData(_ updates: [String: String]) {
|
|
let env = ProcessInfo.processInfo.environment
|
|
guard let path = env["CMUX_UI_TEST_JUMP_UNREAD_PATH"], !path.isEmpty else { return }
|
|
var payload = loadJumpUnreadTestData(at: path)
|
|
for (key, value) in updates {
|
|
payload[key] = value
|
|
}
|
|
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
|
try? data.write(to: URL(fileURLWithPath: path), options: .atomic)
|
|
}
|
|
|
|
private func loadJumpUnreadTestData(at path: String) -> [String: String] {
|
|
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),
|
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: String] else {
|
|
return [:]
|
|
}
|
|
return object
|
|
}
|
|
#endif
|
|
|
|
func attachUpdateAccessory(to window: NSWindow) {
|
|
titlebarAccessoryController.start()
|
|
titlebarAccessoryController.attach(to: window)
|
|
}
|
|
|
|
func applyWindowDecorations(to window: NSWindow) {
|
|
windowDecorationsController.apply(to: window)
|
|
}
|
|
|
|
func toggleNotificationsPopover(animated: Bool = true) {
|
|
titlebarAccessoryController.toggleNotificationsPopover(animated: animated)
|
|
}
|
|
|
|
func jumpToLatestUnread() {
|
|
guard let notificationStore, let tabManager else { return }
|
|
guard let notification = notificationStore.notifications.first(where: { !$0.isRead }) else { return }
|
|
tabManager.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId)
|
|
}
|
|
|
|
private func installShortcutMonitor() {
|
|
// Local monitor only receives events when app is active (not global)
|
|
shortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
|
guard let self else { return event }
|
|
if self.handleCustomShortcut(event: event) {
|
|
return nil // Consume the event
|
|
}
|
|
return event // Pass through
|
|
}
|
|
}
|
|
|
|
private func handleCustomShortcut(event: NSEvent) -> Bool {
|
|
guard let chars = event.charactersIgnoringModifiers?.lowercased() else { return false }
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
|
|
// Check Show Notifications shortcut
|
|
let notifShortcut = KeyboardShortcutSettings.showNotificationsShortcut()
|
|
if chars == notifShortcut.key && flags == notifShortcut.modifierFlags {
|
|
toggleNotificationsPopover(animated: false)
|
|
return true
|
|
}
|
|
|
|
// Check Jump to Unread shortcut
|
|
let unreadShortcut = KeyboardShortcutSettings.jumpToUnreadShortcut()
|
|
if chars == unreadShortcut.key && flags == unreadShortcut.modifierFlags {
|
|
jumpToLatestUnread()
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func validateMenuItem(_ item: NSMenuItem) -> Bool {
|
|
updateController.validateMenuItem(item)
|
|
}
|
|
|
|
|
|
private func configureUserNotifications() {
|
|
let actions = [
|
|
UNNotificationAction(
|
|
identifier: TerminalNotificationStore.actionShowIdentifier,
|
|
title: "Show"
|
|
)
|
|
]
|
|
|
|
let category = UNNotificationCategory(
|
|
identifier: TerminalNotificationStore.categoryIdentifier,
|
|
actions: actions,
|
|
intentIdentifiers: [],
|
|
options: [.customDismissAction]
|
|
)
|
|
|
|
let center = UNUserNotificationCenter.current()
|
|
center.setNotificationCategories([category])
|
|
center.delegate = self
|
|
}
|
|
|
|
private func disableNativeTabbingShortcut() {
|
|
guard let menu = NSApp.mainMenu else { return }
|
|
disableMenuItemShortcut(in: menu, action: #selector(NSWindow.toggleTabBar(_:)))
|
|
}
|
|
|
|
private func disableMenuItemShortcut(in menu: NSMenu, action: Selector) {
|
|
for item in menu.items {
|
|
if item.action == action {
|
|
item.keyEquivalent = ""
|
|
item.keyEquivalentModifierMask = []
|
|
item.isEnabled = false
|
|
}
|
|
if let submenu = item.submenu {
|
|
disableMenuItemShortcut(in: submenu, action: action)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func ensureApplicationIcon() {
|
|
if let icon = NSImage(named: NSImage.applicationIconName) {
|
|
NSApplication.shared.applicationIconImage = icon
|
|
}
|
|
}
|
|
|
|
private func registerLaunchServicesBundle() {
|
|
let bundleURL = Bundle.main.bundleURL.standardizedFileURL
|
|
let registerStatus = LSRegisterURL(bundleURL as CFURL, true)
|
|
if registerStatus != noErr {
|
|
NSLog("LaunchServices registration failed (status: \(registerStatus)) for \(bundleURL.path)")
|
|
}
|
|
}
|
|
|
|
private func enforceSingleInstance() {
|
|
guard let bundleId = Bundle.main.bundleIdentifier else { return }
|
|
let currentPid = ProcessInfo.processInfo.processIdentifier
|
|
let currentURL = Bundle.main.bundleURL.standardizedFileURL
|
|
|
|
for app in NSRunningApplication.runningApplications(withBundleIdentifier: bundleId) {
|
|
guard app.processIdentifier != currentPid else { continue }
|
|
if let url = app.bundleURL?.standardizedFileURL, url == currentURL { continue }
|
|
app.terminate()
|
|
if !app.isTerminated {
|
|
_ = app.forceTerminate()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func observeDuplicateLaunches() {
|
|
guard let bundleId = Bundle.main.bundleIdentifier else { return }
|
|
let currentPid = ProcessInfo.processInfo.processIdentifier
|
|
let currentURL = Bundle.main.bundleURL.standardizedFileURL
|
|
|
|
workspaceObserver = NSWorkspace.shared.notificationCenter.addObserver(
|
|
forName: NSWorkspace.didLaunchApplicationNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
guard self != nil else { return }
|
|
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { return }
|
|
guard app.bundleIdentifier == bundleId, app.processIdentifier != currentPid else { return }
|
|
if let url = app.bundleURL?.standardizedFileURL, url == currentURL { return }
|
|
|
|
app.terminate()
|
|
if !app.isTerminated {
|
|
_ = app.forceTerminate()
|
|
}
|
|
NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
|
|
}
|
|
}
|
|
|
|
func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
didReceive response: UNNotificationResponse,
|
|
withCompletionHandler completionHandler: @escaping () -> Void
|
|
) {
|
|
handleNotificationResponse(response)
|
|
completionHandler()
|
|
}
|
|
|
|
func userNotificationCenter(
|
|
_ center: UNUserNotificationCenter,
|
|
willPresent notification: UNNotification,
|
|
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
|
) {
|
|
completionHandler([.banner, .sound, .list])
|
|
}
|
|
|
|
private func handleNotificationResponse(_ response: UNNotificationResponse) {
|
|
guard let tabIdString = response.notification.request.content.userInfo["tabId"] as? String,
|
|
let tabId = UUID(uuidString: tabIdString) else {
|
|
return
|
|
}
|
|
let surfaceId: UUID? = {
|
|
guard let surfaceIdString = response.notification.request.content.userInfo["surfaceId"] as? String else {
|
|
return nil
|
|
}
|
|
return UUID(uuidString: surfaceIdString)
|
|
}()
|
|
|
|
switch response.actionIdentifier {
|
|
case UNNotificationDefaultActionIdentifier, TerminalNotificationStore.actionShowIdentifier:
|
|
DispatchQueue.main.async {
|
|
self.tabManager?.focusTabFromNotification(tabId, surfaceId: surfaceId)
|
|
self.markReadIfFocused(response: response, tabId: tabId, surfaceId: surfaceId)
|
|
}
|
|
case UNNotificationDismissActionIdentifier:
|
|
DispatchQueue.main.async {
|
|
if let notificationId = UUID(uuidString: response.notification.request.identifier) {
|
|
self.notificationStore?.markRead(id: notificationId)
|
|
} else if let notificationIdString = response.notification.request.content.userInfo["notificationId"] as? String,
|
|
let notificationId = UUID(uuidString: notificationIdString) {
|
|
self.notificationStore?.markRead(id: notificationId)
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private func markReadIfFocused(response: UNNotificationResponse, tabId: UUID, surfaceId: UUID?) {
|
|
let notificationId: UUID? = {
|
|
if let id = UUID(uuidString: response.notification.request.identifier) {
|
|
return id
|
|
}
|
|
if let idString = response.notification.request.content.userInfo["notificationId"] as? String,
|
|
let id = UUID(uuidString: idString) {
|
|
return id
|
|
}
|
|
return nil
|
|
}()
|
|
|
|
guard let notificationId else { return }
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
guard let tabManager = self.tabManager else { return }
|
|
guard tabManager.selectedTabId == tabId else { return }
|
|
if let surfaceId {
|
|
guard tabManager.focusedSurfaceId(for: tabId) == surfaceId else { return }
|
|
}
|
|
self.notificationStore?.markRead(id: notificationId)
|
|
}
|
|
}
|
|
|
|
}
|