cmux/Sources/AppDelegate.swift
2026-01-30 16:46:30 -08:00

484 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
}
#endif
@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)
}
@objc func copyFocusLogs(_ sender: Any?) {
let logText = FocusLogStore.shared.snapshot()
let payload: String
if logText.isEmpty {
payload = "No focus logs captured.\nLog file: \(FocusLogStore.shared.logPath())"
} else {
payload = logText + "\nLog file: \(FocusLogStore.shared.logPath())"
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
#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)
}
@objc func openDebugLoremTab(_ sender: Any?) {
guard let tabManager else { return }
let tab = tabManager.addTab()
let lineCount = 2000
let base = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore."
var lines: [String] = []
lines.reserveCapacity(lineCount)
for index in 1...lineCount {
lines.append(String(format: "%04d %@", index, base))
}
let payload = lines.joined(separator: "\n") + "\n"
sendTextWhenReady(payload, 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)
}
}
}