Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,4 @@ DerivedData/
*.xcresult
*.log
*.profraw
.superpowers/
71 changes: 66 additions & 5 deletions App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate {
private let urlEventClass = AEEventClass(kInternetEventClass)
private let urlEventID = AEEventID(kAEGetURL)
private var mouseEventMonitor: Any?
private var wakeObserver: Any?
private let userDefaults = UserDefaults.standard
private var pendingURLs: [URL] = []



Expand All @@ -57,16 +59,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
setupURLEventHandling()
setupMouseButtonHandling()
setupSleepWakeHandling()
let didFinishOnboarding = userDefaults.bool(forKey: "settings.didFinishOnboarding")

if let window = NSApplication.shared.windows.first {
// Always hide titlebar immediately to prevent flash during transitions
// Always hide titlebar text immediately to prevent flash during transitions
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.toolbar?.isVisible = false
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true

if !didFinishOnboarding {
window.setContentSize(NSSize(width: 1200, height: 720))
Expand All @@ -77,6 +77,37 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate {
}
}

/// Observes system wake notifications and resets crash counters on all tabs.
///
/// When the system wakes from sleep, launchservicesd and other XPC services need
/// a few seconds to fully restart. During this window, new WebContent processes crash
/// immediately with XPC_ERROR_CONNECTION_INVALID. We reset crash counters on wake so
/// the exponential backoff in webViewWebContentProcessDidTerminate starts fresh and
/// the delayed reload eventually succeeds once XPC services are stable.
private func setupSleepWakeHandling() {
wakeObserver = NSWorkspace.shared.notificationCenter.addObserver(
forName: NSWorkspace.didWakeNotification,
object: nil,
queue: .main
) { [weak self] _ in
self?.handleSystemWake()
}
}

private func handleSystemWake() {
AppDelegate.log.info("System woke from sleep — resetting web process crash counters")
// Reset crash counters so tabs get fresh backoff windows after wake.
// Tabs that were mid-crash-loop before sleep will retry with a clean slate.
// Called on the main queue (per NSWorkspace notification delivery), so MainActor access is safe.
MainActor.assumeIsolated {
guard let manager = browserManager else { return }
for tab in manager.tabManager.allTabs() {
tab.webProcessCrashCount = 0
tab.lastWebProcessCrashDate = .distantPast
}
}
}

/// Registers handler for external URL events (e.g., clicking links from other apps)
private func setupURLEventHandling() {
NSAppleEventManager.shared().setEventHandler(
Expand Down Expand Up @@ -106,7 +137,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate {
MainActor.assumeIsolated {
switch event.buttonNumber {
case 2: // Middle mouse button
registry.activeWindow?.commandPalette?.open()
if let hoveredId = manager.hoveredPinnedTabId,
let tab = manager.tabManager.allTabs().first(where: { $0.id == hoveredId }),
tab.pinnedURL != nil {
tab.resetToPinnedURL()
} else {
registry.activeWindow?.commandPalette?.open()
}
case 3: // Back button
guard
let windowState = registry.activeWindow,
Expand Down Expand Up @@ -251,18 +288,42 @@ class AppDelegate: NSObject, NSApplicationDelegate, SPUUpdaterDelegate {
else {
return
}

// Security: Only allow http/https URLs from external automation
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https" else {
return
}

handleIncoming(url: url)
}

/// Routes incoming external URLs to the browser manager
///
/// If the browser manager isn't ready yet (cold launch via URL click),
/// queues the URL and drains it once `browserManager` is set.
private func handleIncoming(url: URL) {
guard let manager = browserManager else {
AppDelegate.log.info("Queuing URL for deferred open: \(url.absoluteString, privacy: .public)")
pendingURLs.append(url)
return
}
Task { @MainActor in
// Air Traffic Control — route to designated space if a rule matches
if manager.siteRoutingManager.applyRoute(url: url, from: nil) {
return
}
manager.presentExternalURL(url)
}
}

/// Opens any URLs that arrived before browserManager was available
func drainPendingURLs() {
guard !pendingURLs.isEmpty else { return }
let urls = pendingURLs
pendingURLs.removeAll()
urls.forEach { handleIncoming(url: $0) }
}
}

// MARK: - Sparkle Delegate
Expand Down
15 changes: 11 additions & 4 deletions App/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import AppKit

struct ContentView: View {
@EnvironmentObject var browserManager: BrowserManager
@EnvironmentObject var tabManager: TabManager
@Environment(WindowRegistry.self) private var windowRegistry
@State private var defaultWindowState = BrowserWindowState()
@State private var commandPalette = CommandPalette()
Expand All @@ -34,7 +35,7 @@ struct ContentView: View {
.frame(minWidth: 470, minHeight: 382)
.onAppear {
// Set TabManager reference for computed properties
windowState.tabManager = browserManager.tabManager
windowState.tabManager = tabManager
// Set CommandPalette reference for global shortcuts
windowState.commandPalette = commandPalette
// Register this window state with the registry
Expand Down Expand Up @@ -64,9 +65,9 @@ private struct WindowFocusBridge: NSViewRepresentable {
}

func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async {
context.coordinator.attach(to: nsView.window)
}
// Window is available at update time — call synchronously.
// The attach method guards against re-attachment to the same window.
context.coordinator.attach(to: nsView.window)
}

static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
Expand All @@ -90,6 +91,12 @@ private struct WindowFocusBridge: NSViewRepresentable {
self.window = window
guard let window else { return }

// Store NSWindow reference on the window state so other systems
// (e.g. KeyboardShortcutManager) can identify browser windows
Task { @MainActor in
windowState.window = window
}

keyObserver = NotificationCenter.default.addObserver(
forName: NSWindow.didBecomeKeyNotification,
object: window,
Expand Down
86 changes: 65 additions & 21 deletions App/NookApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import Carbon
import OSLog
import Sparkle
import SwiftUI
import WebKit

@main
struct NookApp: App {
Expand All @@ -22,6 +21,7 @@ struct NookApp: App {
@State private var aiConfigService: AIConfigService
@State private var mcpManager = MCPManager()
@State private var aiService: AIService
@State private var tabOrganizerManager = TabOrganizerManager()
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

// TEMPORARY: BrowserManager will be phased out as a global singleton.
Expand All @@ -47,13 +47,15 @@ struct NookApp: App {
.ignoresSafeArea(.all)
.background(BackgroundWindowModifier())
.environmentObject(browserManager)
.environmentObject(browserManager.tabManager)
.environment(windowRegistry)
.environment(webViewCoordinator)
.environment(\.nookSettings, settingsManager)
.environment(keyboardShortcutManager)
.environment(aiConfigService)
.environment(mcpManager)
.environment(aiService)
.environment(tabOrganizerManager)
.onAppear {
setupApplicationLifecycle()
setupAIServices()
Expand All @@ -68,21 +70,26 @@ struct NookApp: App {
NookCommands(
browserManager: browserManager,
windowRegistry: windowRegistry,
shortcutManager: keyboardShortcutManager
shortcutManager: keyboardShortcutManager,
tabOrganizerManager: tabOrganizerManager
)
}


// Native macOS Settings window
Settings {
SettingsView()
// macOS 26 style sidebar settings window
Window("Nook Settings", id: "nook-settings") {
SettingsWindow()
.environmentObject(browserManager)
.environmentObject(browserManager.tabManager)
.environmentObject(browserManager.gradientColorManager)
.environment(\.nookSettings, settingsManager)
.environment(keyboardShortcutManager)
.environment(aiConfigService)
.environment(mcpManager)
.environment(tabOrganizerManager)
}
.windowResizability(.contentSize)
.defaultPosition(.center)
}

// MARK: - Application Lifecycle Setup
Expand Down Expand Up @@ -120,6 +127,7 @@ struct NookApp: App {
appDelegate.browserManager = browserManager
appDelegate.windowRegistry = windowRegistry
appDelegate.mcpManager = mcpManager
appDelegate.drainPendingURLs()
browserManager.appDelegate = appDelegate

// TEMPORARY: Wire coordinators to BrowserManager
Expand All @@ -128,25 +136,45 @@ struct NookApp: App {
browserManager.windowRegistry = windowRegistry
browserManager.nookSettings = settingsManager
browserManager.tabManager.nookSettings = settingsManager
browserManager.siteRoutingManager.settingsService = settingsManager
browserManager.siteRoutingManager.browserManager = browserManager
browserManager.aiService = aiService
browserManager.aiConfigService = aiConfigService

// Configure managers that depend on settings
browserManager.compositorManager.setUnloadTimeout(
settingsManager.tabUnloadTimeout
browserManager.compositorManager.setMode(
settingsManager.tabManagementMode
)
browserManager.trackingProtectionManager.setEnabled(
settingsManager.blockCrossSiteTracking
browserManager.contentBlockerManager.setEnabled(
settingsManager.blockCrossSiteTracking || settingsManager.adBlockerEnabled
)

// Apply appearance mode
applyAppearanceMode(settingsManager.appearanceMode)
NotificationCenter.default.addObserver(
forName: .appearanceModeChanged,
object: nil,
queue: .main
) { [weak settingsManager] _ in
guard let settings = settingsManager else { return }
applyAppearanceMode(settings.appearanceMode)
}

// Initialize keyboard shortcut manager
keyboardShortcutManager.setBrowserManager(browserManager)
browserManager.keyboardShortcutManager = keyboardShortcutManager
browserManager.mcpManager = mcpManager
browserManager.tabOrganizerManager = tabOrganizerManager

// Set up window lifecycle callbacks
windowRegistry.onWindowRegister = { [weak browserManager] windowState in
browserManager?.setupWindowState(windowState)
}
// Retroactively set up any windows that registered before this callback was set
// (child .onAppear fires before parent .onAppear in SwiftUI)
for (_, windowState) in windowRegistry.windows {
browserManager.setupWindowState(windowState)
}

windowRegistry.onWindowClose = {
[webViewCoordinator, weak browserManager] windowId in
Expand All @@ -169,9 +197,6 @@ struct NookApp: App {
// BrowserManager was deallocated - perform minimal cleanup
// Remove compositor container view to prevent leaks
webViewCoordinator.removeCompositorContainerView(for: windowId)
print(
"⚠️ [NookApp] Window \(windowId) closed after BrowserManager deallocation - performed minimal cleanup"
)
}
}

Expand All @@ -182,12 +207,25 @@ struct NookApp: App {
}
}

// MARK: - Appearance Mode

private func applyAppearanceMode(_ mode: AppearanceMode) {
switch mode {
case .system:
NSApp.appearance = nil // Follow system
case .light:
NSApp.appearance = NSAppearance(named: .aqua)
case .dark:
NSApp.appearance = NSAppearance(named: .darkAqua)
}
}

// MARK: - Window Configuration

/// Configures the window appearance and behavior for Nook browser windows
///
/// This modifier:
/// - Hides the standard macOS title bar and window buttons
/// - Hides the title bar text while keeping native traffic light buttons visible
/// - Sets transparent background for custom window styling
/// - Configures minimum window size
/// - Enables full-size content view for edge-to-edge content
Expand All @@ -203,28 +241,34 @@ struct BackgroundWindowModifier: NSViewRepresentable {
window.isReleasedWhenClosed = false
// window.isMovableByWindowBackground = true // Disabled - use SwiftUI-based window drag system instead
window.isMovable = true
window.styleMask = [
var mask: NSWindow.StyleMask = [
.titled, .closable, .miniaturizable, .resizable,
.fullSizeContentView,
]
// Preserve fullScreen flag — removing it outside a transition crashes on macOS 15.5+
if window.styleMask.contains(.fullScreen) {
mask.insert(.fullScreen)
}
window.styleMask = mask

window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
window.minSize = NSSize(width: 470, height: 382)
window.contentMinSize = NSSize(width: 470, height: 382)

// Persist and restore window frame (position + size) across launches.
// setFrameAutosaveName makes macOS automatically save the frame to
// UserDefaults whenever it changes, so the window size is remembered
// on close — not just on quit.
window.setFrameAutosaveName("NookBrowserWindow")
}
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
guard let window = nsView.window else { return }
// Re-apply titlebar hiding on every update to prevent flash during view transitions
// Only re-apply if somehow reset (e.g., view transition flash)
guard !window.titlebarAppearsTransparent else { return }
window.titlebarAppearsTransparent = true
window.titleVisibility = .hidden
window.standardWindowButton(.closeButton)?.isHidden = true
window.standardWindowButton(.zoomButton)?.isHidden = true
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
}
}

Loading