diff --git a/README.md b/README.md index 8157d38a..0b284537 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta ### Browser +Browser developer-tool shortcuts follow Safari defaults and are customizable in `Settings → Keyboard Shortcuts`. + | Shortcut | Action | |----------|--------| | ⌘ ⇧ L | Open browser in split | @@ -143,7 +145,8 @@ Everything is scriptable through the CLI and socket API — create workspaces/ta | ⌘ [ | Back | | ⌘ ] | Forward | | ⌘ R | Reload page | -| ⌥ ⌘ I | Open Developer Tools | +| ⌥ ⌘ I | Toggle Developer Tools (Safari default) | +| ⌥ ⌘ C | Show JavaScript Console (Safari default) | ### Notifications diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index cede8954..45ee1aa1 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1878,6 +1878,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return true } + // Safari defaults: + // - Option+Command+I => Show/Toggle Web Inspector + // - Option+Command+C => Show JavaScript Console + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .toggleBrowserDeveloperTools)) { + let didHandle = tabManager?.toggleDeveloperToolsFocusedBrowser() ?? false + if !didHandle { NSSound.beep() } + return true + } + + if matchShortcut(event: event, shortcut: KeyboardShortcutSettings.shortcut(for: .showBrowserJavaScriptConsole)) { + let didHandle = tabManager?.showJavaScriptConsoleFocusedBrowser() ?? false + if !didHandle { NSSound.beep() } + return true + } + // Focus browser address bar: Cmd+L if flags == [.command] && chars == "l" { if let focusedPanel = tabManager?.focusedBrowserPanel { diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 82a7fe84..e3e27884 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -29,6 +29,8 @@ enum KeyboardShortcutSettings { // Panels case openBrowser + case toggleBrowserDeveloperTools + case showBrowserJavaScriptConsole var id: String { rawValue } @@ -52,6 +54,8 @@ enum KeyboardShortcutSettings { case .splitRight: return "Split Right" case .splitDown: return "Split Down" case .openBrowser: return "Open Browser" + case .toggleBrowserDeveloperTools: return "Toggle Browser Developer Tools" + case .showBrowserJavaScriptConsole: return "Show Browser JavaScript Console" } } @@ -75,6 +79,8 @@ enum KeyboardShortcutSettings { case .prevSurface: return "shortcut.prevSurface" case .newSurface: return "shortcut.newSurface" case .openBrowser: return "shortcut.openBrowser" + case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools" + case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole" } } @@ -116,6 +122,12 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "t", command: true, shift: false, option: false, control: false) case .openBrowser: return StoredShortcut(key: "l", command: true, shift: true, option: false, control: false) + case .toggleBrowserDeveloperTools: + // Safari default: Show Web Inspector. + return StoredShortcut(key: "i", command: true, shift: false, option: true, control: false) + case .showBrowserJavaScriptConsole: + // Safari default: Show JavaScript Console. + return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) } } @@ -182,6 +194,8 @@ enum KeyboardShortcutSettings { static func newSurfaceShortcut() -> StoredShortcut { shortcut(for: .newSurface) } static func openBrowserShortcut() -> StoredShortcut { shortcut(for: .openBrowser) } + static func toggleBrowserDeveloperToolsShortcut() -> StoredShortcut { shortcut(for: .toggleBrowserDeveloperTools) } + static func showBrowserJavaScriptConsoleShortcut() -> StoredShortcut { shortcut(for: .showBrowserJavaScriptConsole) } } /// A keyboard shortcut that can be stored in UserDefaults diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 6c405b3d..bd50abc6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -825,6 +825,8 @@ final class BrowserPanel: Panel, ObservableObject { private let minPageZoom: CGFloat = 0.25 private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 + // Persist user intent across WebKit detach/reattach churn (split/layout updates). + private var preferredDeveloperToolsVisible: Bool = false var displayTitle: String { if !pageTitle.isEmpty { @@ -865,6 +867,11 @@ final class BrowserPanel: Panel, ObservableObject { let webView = CmuxWebView(frame: .zero, configuration: config) webView.allowsBackForwardNavigationGestures = true + // Required for Web Inspector support on recent WebKit SDKs. + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + // Match the empty-page background to the window so newly-created browsers // don't flash white before content loads. webView.underPageBackgroundColor = .windowBackgroundColor @@ -1296,6 +1303,90 @@ extension BrowserPanel { webView.stopLoading() } + @discardableResult + func toggleDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + let targetVisible = !visible + let selector = NSSelectorFromString(targetVisible ? "show" : "close") + guard inspector.responds(to: selector) else { return false } + inspector.cmuxCallVoid(selector: selector) + preferredDeveloperToolsVisible = targetVisible + return true + } + + @discardableResult + func showDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if !visible { + let showSelector = NSSelectorFromString("show") + guard inspector.responds(to: showSelector) else { return false } + inspector.cmuxCallVoid(selector: showSelector) + } + preferredDeveloperToolsVisible = true + return true + } + + @discardableResult + func showDeveloperToolsConsole() -> Bool { + guard showDeveloperTools() else { return false } + guard let inspector = webView.cmuxInspectorObject() else { return true } + // WebKit private inspector API differs by OS; try known console selectors. + let consoleSelectors = [ + "showConsole", + "showConsoleTab", + "showConsoleView", + ] + for raw in consoleSelectors { + let selector = NSSelectorFromString(raw) + if inspector.responds(to: selector) { + inspector.cmuxCallVoid(selector: selector) + break + } + } + return true + } + + /// Called before WKWebView detaches so manual inspector closes are respected. + func syncDeveloperToolsPreferenceFromInspector() { + guard let inspector = webView.cmuxInspectorObject() else { return } + if let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) { + preferredDeveloperToolsVisible = visible + } + } + + /// Called after WKWebView reattaches to keep inspector stable across split/layout churn. + func restoreDeveloperToolsAfterAttachIfNeeded() { + guard preferredDeveloperToolsVisible else { return } + guard let inspector = webView.cmuxInspectorObject() else { return } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + guard !visible else { return } + let selector = NSSelectorFromString("show") + guard inspector.responds(to: selector) else { return } + inspector.cmuxCallVoid(selector: selector) + preferredDeveloperToolsVisible = true + } + + @discardableResult + func isDeveloperToolsVisible() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + return inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + } + + @discardableResult + func hideDeveloperTools() -> Bool { + guard let inspector = webView.cmuxInspectorObject() else { return false } + let visible = inspector.cmuxCallBool(selector: NSSelectorFromString("isVisible")) ?? false + if visible { + let selector = NSSelectorFromString("close") + guard inspector.responds(to: selector) else { return false } + inspector.cmuxCallVoid(selector: selector) + } + preferredDeveloperToolsVisible = false + return true + } + @discardableResult func zoomIn() -> Bool { applyPageZoom(webView.pageZoom + pageZoomStep) @@ -1427,6 +1518,33 @@ private extension BrowserPanel { } } +private extension WKWebView { + func cmuxInspectorObject() -> NSObject? { + let selector = NSSelectorFromString("_inspector") + guard responds(to: selector), + let inspector = perform(selector)?.takeUnretainedValue() as? NSObject else { + return nil + } + return inspector + } +} + +private extension NSObject { + func cmuxCallBool(selector: Selector) -> Bool? { + guard responds(to: selector) else { return nil } + typealias Fn = @convention(c) (AnyObject, Selector) -> Bool + let fn = unsafeBitCast(method(for: selector), to: Fn.self) + return fn(self, selector) + } + + func cmuxCallVoid(selector: Selector) { + guard responds(to: selector) else { return } + typealias Fn = @convention(c) (AnyObject, Selector) -> Void + let fn = unsafeBitCast(method(for: selector), to: Fn.self) + fn(self, selector) + } +} + // MARK: - Navigation Delegate private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 76e5e9a1..54ed2aab 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -3,6 +3,113 @@ import SwiftUI import WebKit import AppKit +enum BrowserDevToolsIconOption: String, CaseIterable, Identifiable { + case wrenchAndScrewdriver = "wrench.and.screwdriver" + case wrenchAndScrewdriverFill = "wrench.and.screwdriver.fill" + case curlyBracesSquare = "curlybraces.square" + case curlyBraces = "curlybraces" + case terminalFill = "terminal.fill" + case terminal = "terminal" + case hammer = "hammer" + case hammerCircle = "hammer.circle" + case ladybug = "ladybug" + case ladybugFill = "ladybug.fill" + case scope = "scope" + case codeChevrons = "chevron.left.slash.chevron.right" + case gearshape = "gearshape" + case gearshapeFill = "gearshape.fill" + case globe = "globe" + case globeAmericas = "globe.americas.fill" + + var id: String { rawValue } + + var title: String { + switch self { + case .wrenchAndScrewdriver: return "Wrench + Screwdriver" + case .wrenchAndScrewdriverFill: return "Wrench + Screwdriver (Fill)" + case .curlyBracesSquare: return "Curly Braces" + case .curlyBraces: return "Curly Braces (Plain)" + case .terminalFill: return "Terminal (Fill)" + case .terminal: return "Terminal" + case .hammer: return "Hammer" + case .hammerCircle: return "Hammer Circle" + case .ladybug: return "Bug" + case .ladybugFill: return "Bug (Fill)" + case .scope: return "Scope" + case .codeChevrons: return "Code Chevrons" + case .gearshape: return "Gear" + case .gearshapeFill: return "Gear (Fill)" + case .globe: return "Globe" + case .globeAmericas: return "Globe Americas (Fill)" + } + } +} + +enum BrowserDevToolsIconColorOption: String, CaseIterable, Identifiable { + case bonsplitInactive + case bonsplitActive + case accent + case tertiary + + var id: String { rawValue } + + var title: String { + switch self { + case .bonsplitInactive: return "Bonsplit Inactive (Terminal/Globe)" + case .bonsplitActive: return "Bonsplit Active (Terminal/Globe)" + case .accent: return "Accent" + case .tertiary: return "Tertiary" + } + } + + var color: Color { + switch self { + case .bonsplitInactive: + // Matches Bonsplit tab icon tint for inactive tabs. + return Color(nsColor: .secondaryLabelColor) + case .bonsplitActive: + // Matches Bonsplit tab icon tint for active tabs. + return Color(nsColor: .labelColor) + case .accent: + return .accentColor + case .tertiary: + return Color(nsColor: .tertiaryLabelColor) + } + } +} + +enum BrowserDevToolsButtonDebugSettings { + static let iconNameKey = "browserDevToolsIconName" + static let iconColorKey = "browserDevToolsIconColor" + static let defaultIcon = BrowserDevToolsIconOption.wrenchAndScrewdriver + static let defaultColor = BrowserDevToolsIconColorOption.bonsplitInactive + + static func iconOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconOption { + guard let raw = defaults.string(forKey: iconNameKey), + let option = BrowserDevToolsIconOption(rawValue: raw) else { + return defaultIcon + } + return option + } + + static func colorOption(defaults: UserDefaults = .standard) -> BrowserDevToolsIconColorOption { + guard let raw = defaults.string(forKey: iconColorKey), + let option = BrowserDevToolsIconColorOption(rawValue: raw) else { + return defaultColor + } + return option + } + + static func copyPayload(defaults: UserDefaults = .standard) -> String { + let icon = iconOption(defaults: defaults) + let color = colorOption(defaults: defaults) + return """ + browserDevToolsIconName=\(icon.rawValue) + browserDevToolsIconColor=\(color.rawValue) + """ + } +} + struct OmnibarInlineCompletion: Equatable { let typedText: String let displayText: String @@ -25,6 +132,8 @@ struct BrowserPanelView: View { @State private var addressBarFocused: Bool = false @AppStorage(BrowserSearchSettings.searchEngineKey) private var searchEngineRaw = BrowserSearchSettings.defaultSearchEngine.rawValue @AppStorage(BrowserSearchSettings.searchSuggestionsEnabledKey) private var searchSuggestionsEnabledStorage = BrowserSearchSettings.defaultSearchSuggestionsEnabled + @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var devToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var devToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue @State private var suggestionTask: Task? @State private var isLoadingRemoteSuggestions: Bool = false @State private var latestRemoteSuggestionQuery: String = "" @@ -38,6 +147,8 @@ struct BrowserPanelView: View { @State private var omnibarPillFrame: CGRect = .zero @State private var lastHandledAddressBarFocusRequestId: UUID? private let omnibarPillCornerRadius: CGFloat = 12 + private let addressBarButtonSize: CGFloat = 22 + private let devToolsButtonIconSize: CGFloat = 11 private var searchEngine: BrowserSearchEngine { BrowserSearchEngine(rawValue: searchEngineRaw) ?? BrowserSearchSettings.defaultSearchEngine @@ -63,6 +174,14 @@ struct BrowserPanelView: View { return searchSuggestionsEnabled } + private var devToolsIconOption: BrowserDevToolsIconOption { + BrowserDevToolsIconOption(rawValue: devToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + } + + private var devToolsColorOption: BrowserDevToolsIconColorOption { + BrowserDevToolsIconColorOption(rawValue: devToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + } + var body: some View { VStack(spacing: 0) { addressBar @@ -202,6 +321,8 @@ struct BrowserPanelView: View { omnibarField .accessibilityIdentifier("BrowserOmnibarPill") .accessibilityLabel("Browser omnibar") + + developerToolsButton } .padding(.horizontal, 8) .padding(.vertical, 6) @@ -211,8 +332,6 @@ struct BrowserPanelView: View { } private var addressBarButtonBar: some View { - let navButtonSize: CGFloat = 22 - return HStack(spacing: 0) { Button(action: { #if DEBUG @@ -222,10 +341,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.left") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoBack) .opacity(panel.canGoBack ? 1.0 : 0.4) .help("Go Back") @@ -238,10 +357,10 @@ struct BrowserPanelView: View { }) { Image(systemName: "chevron.right") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .disabled(!panel.canGoForward) .opacity(panel.canGoForward ? 1.0 : 0.4) .help("Go Forward") @@ -261,14 +380,29 @@ struct BrowserPanelView: View { }) { Image(systemName: panel.isLoading ? "xmark" : "arrow.clockwise") .font(.system(size: 12, weight: .medium)) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) } .buttonStyle(.plain) - .frame(width: navButtonSize, height: navButtonSize, alignment: .center) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) .help(panel.isLoading ? "Stop" : "Reload") } } + private var developerToolsButton: some View { + Button(action: { + openDevTools() + }) { + Image(systemName: devToolsIconOption.rawValue) + .font(.system(size: devToolsButtonIconSize, weight: .medium)) + .foregroundStyle(devToolsColorOption.color) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } + .buttonStyle(.plain) + .frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + .help("Toggle Developer Tools") + .accessibilityIdentifier("BrowserToggleDevToolsButton") + } + private var omnibarField: some View { let showSecureBadge = panel.currentURL?.scheme == "https" @@ -376,12 +510,6 @@ struct BrowserPanelView: View { } }) .zIndex(0) - .contextMenu { - Button("Open Developer Tools") { - openDevTools() - } - .keyboardShortcut("i", modifiers: [.command, .option]) - } } private func triggerFocusFlashAnimation() { @@ -445,10 +573,11 @@ struct BrowserPanelView: View { } private func openDevTools() { - // WKWebView with developerExtrasEnabled allows right-click > Inspect Element - // We can also trigger via JavaScript - Task { - try? await panel.evaluateJavaScript("window.webkit?.messageHandlers?.devTools?.postMessage('open')") + #if DEBUG + dlog("browser.toggleDevTools panel=\(panel.id.uuidString.prefix(5))") + #endif + if !panel.toggleDeveloperTools() { + NSSound.beep() } } @@ -2426,7 +2555,6 @@ struct WebViewRepresentable: NSViewRepresentable { final class Coordinator { weak var webView: WKWebView? - var constraints: [NSLayoutConstraint] = [] var attachRetryWorkItem: DispatchWorkItem? var attachRetryCount: Int = 0 var attachGeneration: Int = 0 @@ -2453,7 +2581,7 @@ struct WebViewRepresentable: NSViewRepresentable { return container } - private static func attachWebView(_ webView: WKWebView, to host: NSView, coordinator: Coordinator) { + private static func attachWebView(_ webView: WKWebView, to host: NSView) { // WebKit can crash if a WKWebView (or an internal first-responder object) stays first responder // while being detached/reparented during bonsplit/SwiftUI structural updates. if let window = webView.window, @@ -2466,15 +2594,11 @@ struct WebViewRepresentable: NSViewRepresentable { host.subviews.forEach { $0.removeFromSuperview() } host.addSubview(webView) - webView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints = [ - webView.leadingAnchor.constraint(equalTo: host.leadingAnchor), - webView.trailingAnchor.constraint(equalTo: host.trailingAnchor), - webView.topAnchor.constraint(equalTo: host.topAnchor), - webView.bottomAnchor.constraint(equalTo: host.bottomAnchor), - ] - NSLayoutConstraint.activate(coordinator.constraints) + // Work around WebKit bug 272474 where Inspect Element can render blank/flicker + // when WKWebView is edge-pinned using Auto Layout constraints. + webView.translatesAutoresizingMaskIntoConstraints = true + webView.autoresizingMask = [.width, .height] + webView.frame = host.bounds // Make reparenting resilient: WebKit can occasionally stay visually blank until forced to lay out. webView.needsLayout = true @@ -2483,7 +2607,13 @@ struct WebViewRepresentable: NSViewRepresentable { webView.displayIfNeeded() } - private static func scheduleAttachRetry(_ webView: WKWebView, to host: NSView, coordinator: Coordinator, generation: Int) { + private static func scheduleAttachRetry( + _ webView: WKWebView, + panel: BrowserPanel, + to host: NSView, + coordinator: Coordinator, + generation: Int + ) { // Don't schedule multiple overlapping retries. guard coordinator.attachRetryWorkItem == nil else { return } @@ -2506,14 +2636,21 @@ struct WebViewRepresentable: NSViewRepresentable { // container off-window longer than a few seconds under load. if coordinator.attachRetryCount < 400 { DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { - scheduleAttachRetry(webView, to: host, coordinator: coordinator, generation: generation) + scheduleAttachRetry( + webView, + panel: panel, + to: host, + coordinator: coordinator, + generation: generation + ) } } return } coordinator.attachRetryCount = 0 - attachWebView(webView, to: host, coordinator: coordinator) + attachWebView(webView, to: host) + panel.restoreDeveloperToolsAfterAttachIfNeeded() } coordinator.attachRetryWorkItem = work @@ -2528,6 +2665,7 @@ struct WebViewRepresentable: NSViewRepresentable { // in the window hierarchy while hidden and rapidly switching focus between tabs. To reduce // WebKit crashes, detach the WKWebView when this surface is not the selected tab in its pane. if !shouldAttachWebView { + panel.syncDeveloperToolsPreferenceFromInspector() context.coordinator.attachRetryWorkItem?.cancel() context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 @@ -2539,9 +2677,6 @@ struct WebViewRepresentable: NSViewRepresentable { window.makeFirstResponder(nil) } - NSLayoutConstraint.deactivate(context.coordinator.constraints) - context.coordinator.constraints.removeAll() - if webView.superview != nil { webView.removeFromSuperview() } @@ -2560,12 +2695,14 @@ struct WebViewRepresentable: NSViewRepresentable { // can create containers that are never inserted into the window. Self.scheduleAttachRetry( webView, + panel: panel, to: nsView, coordinator: context.coordinator, generation: context.coordinator.attachGeneration ) } else { - Self.attachWebView(webView, to: nsView, coordinator: context.coordinator) + Self.attachWebView(webView, to: nsView) + panel.restoreDeveloperToolsAfterAttachIfNeeded() } } else { // Already attached; no need for any pending retry. @@ -2573,6 +2710,7 @@ struct WebViewRepresentable: NSViewRepresentable { context.coordinator.attachRetryWorkItem = nil context.coordinator.attachRetryCount = 0 context.coordinator.attachGeneration += 1 + panel.restoreDeveloperToolsAfterAttachIfNeeded() } // Focus handling. Avoid fighting the address bar when it is focused. @@ -2601,9 +2739,6 @@ struct WebViewRepresentable: NSViewRepresentable { coordinator.attachRetryCount = 0 coordinator.attachGeneration += 1 - NSLayoutConstraint.deactivate(coordinator.constraints) - coordinator.constraints.removeAll() - guard let webView = coordinator.webView else { return } // If we're being torn down while the WKWebView (or one of its subviews) is first responder, diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index f3c43522..90baf6ac 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -823,6 +823,16 @@ class TabManager: ObservableObject { focusedBrowserPanel?.resetZoom() ?? false } + @discardableResult + func toggleDeveloperToolsFocusedBrowser() -> Bool { + focusedBrowserPanel?.toggleDeveloperTools() ?? false + } + + @discardableResult + func showJavaScriptConsoleFocusedBrowser() -> Bool { + focusedBrowserPanel?.showDeveloperToolsConsole() ?? false + } + /// Backwards compatibility: returns the focused surface ID func focusedSurfaceId(for tabId: UUID) -> UUID? { focusedPanelId(for: tabId) diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 973409e9..26e4d504 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -15,6 +15,10 @@ struct cmuxApp: App { @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(KeyboardShortcutSettings.Action.splitRight.defaultsKey) private var splitRightShortcutData = Data() @AppStorage(KeyboardShortcutSettings.Action.splitDown.defaultsKey) private var splitDownShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultsKey) + private var toggleBrowserDeveloperToolsShortcutData = Data() + @AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey) + private var showBrowserJavaScriptConsoleShortcutData = Data() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate init() { @@ -422,6 +426,20 @@ struct cmuxApp: App { } .keyboardShortcut("r", modifiers: .command) + splitCommandButton(title: "Toggle Developer Tools", shortcut: toggleBrowserDeveloperToolsMenuShortcut) { + let manager = (AppDelegate.shared?.tabManager ?? tabManager) + if !manager.toggleDeveloperToolsFocusedBrowser() { + NSSound.beep() + } + } + + splitCommandButton(title: "Show JavaScript Console", shortcut: showBrowserJavaScriptConsoleMenuShortcut) { + let manager = (AppDelegate.shared?.tabManager ?? tabManager) + if !manager.showJavaScriptConsoleFocusedBrowser() { + NSSound.beep() + } + } + Button("Zoom In") { _ = (AppDelegate.shared?.tabManager ?? tabManager).zoomInFocusedBrowser() } @@ -536,6 +554,20 @@ struct cmuxApp: App { decodeShortcut(from: splitDownShortcutData, fallback: KeyboardShortcutSettings.Action.splitDown.defaultShortcut) } + private var toggleBrowserDeveloperToolsMenuShortcut: StoredShortcut { + decodeShortcut( + from: toggleBrowserDeveloperToolsShortcutData, + fallback: KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + ) + } + + private var showBrowserJavaScriptConsoleMenuShortcut: StoredShortcut { + decodeShortcut( + from: showBrowserJavaScriptConsoleShortcutData, + fallback: KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut + ) + } + private var notificationMenuSnapshot: NotificationMenuSnapshot { NotificationMenuSnapshotBuilder.make(notifications: notificationStore.notifications) } @@ -1123,6 +1155,7 @@ private enum DebugWindowConfigSnapshot { """ let menuBarPayload = MenuBarIconDebugSettings.copyPayload(defaults: defaults) + let browserDevToolsPayload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) return """ # Sidebar Debug @@ -1133,6 +1166,9 @@ private enum DebugWindowConfigSnapshot { # Menu Bar Extra Debug \(menuBarPayload) + + # Browser DevTools Button + \(browserDevToolsPayload) """ } @@ -1199,6 +1235,16 @@ private struct DebugWindowControlsView: View { @AppStorage(ShortcutHintDebugSettings.paneHintYKey) private var paneShortcutHintYOffset = ShortcutHintDebugSettings.defaultPaneHintY @AppStorage(ShortcutHintDebugSettings.alwaysShowHintsKey) private var alwaysShowShortcutHints = ShortcutHintDebugSettings.defaultAlwaysShowHints @AppStorage("debugTitlebarLeadingExtra") private var titlebarLeadingExtra: Double = 0 + @AppStorage(BrowserDevToolsButtonDebugSettings.iconNameKey) private var browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + @AppStorage(BrowserDevToolsButtonDebugSettings.iconColorKey) private var browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + + private var selectedDevToolsIconOption: BrowserDevToolsIconOption { + BrowserDevToolsIconOption(rawValue: browserDevToolsIconNameRaw) ?? BrowserDevToolsButtonDebugSettings.defaultIcon + } + + private var selectedDevToolsColorOption: BrowserDevToolsIconColorOption { + BrowserDevToolsIconColorOption(rawValue: browserDevToolsIconColorRaw) ?? BrowserDevToolsButtonDebugSettings.defaultColor + } var body: some View { ScrollView { @@ -1282,12 +1328,58 @@ private struct DebugWindowControlsView: View { .padding(.top, 2) } + GroupBox("Browser DevTools Button") { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Text("Icon") + Picker("Icon", selection: $browserDevToolsIconNameRaw) { + ForEach(BrowserDevToolsIconOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Color") + Picker("Color", selection: $browserDevToolsIconColorRaw) { + ForEach(BrowserDevToolsIconColorOption.allCases) { option in + Text(option.title).tag(option.rawValue) + } + } + .labelsHidden() + .pickerStyle(.menu) + Spacer() + } + + HStack(spacing: 8) { + Text("Preview") + Spacer() + Image(systemName: selectedDevToolsIconOption.rawValue) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(selectedDevToolsColorOption.color) + } + + HStack(spacing: 12) { + Button("Reset Button") { + resetBrowserDevToolsButton() + } + Button("Copy Button Config") { + copyBrowserDevToolsButtonConfig() + } + } + } + .padding(.top, 2) + } + GroupBox("Copy") { VStack(alignment: .leading, spacing: 8) { Button("Copy All Debug Config") { DebugWindowConfigSnapshot.copyCombinedToPasteboard() } - Text("Copies sidebar, background, and menu bar debug settings as one payload.") + Text("Copies sidebar, background, menu bar, and browser devtools settings as one payload.") .font(.caption) .foregroundColor(.secondary) } @@ -1348,6 +1440,18 @@ private struct DebugWindowControlsView: View { pasteboard.clearContents() pasteboard.setString(payload, forType: .string) } + + private func resetBrowserDevToolsButton() { + browserDevToolsIconNameRaw = BrowserDevToolsButtonDebugSettings.defaultIcon.rawValue + browserDevToolsIconColorRaw = BrowserDevToolsButtonDebugSettings.defaultColor.rawValue + } + + private func copyBrowserDevToolsButtonConfig() { + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: .standard) + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(payload, forType: .string) + } } private final class AboutWindowController: NSWindowController, NSWindowDelegate { diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 1630f254..9728e504 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -88,6 +88,90 @@ final class CmuxWebViewKeyEquivalentTests: XCTestCase { } } +final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase { + private func makeIsolatedDefaults() -> UserDefaults { + let suiteName = "BrowserDevToolsButtonDebugSettingsTests.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suiteName) else { + fatalError("Failed to create defaults suite") + } + defaults.removePersistentDomain(forName: suiteName) + addTeardownBlock { + defaults.removePersistentDomain(forName: suiteName) + } + return defaults + } + + func testIconCatalogIncludesExpandedChoices() { + XCTAssertGreaterThanOrEqual(BrowserDevToolsIconOption.allCases.count, 10) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.terminal)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.globe)) + XCTAssertTrue(BrowserDevToolsIconOption.allCases.contains(.curlyBracesSquare)) + } + + func testIconOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("this.symbol.does.not.exist", forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.iconOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultIcon + ) + } + + func testColorOptionFallsBackToDefaultForUnknownRawValue() { + let defaults = makeIsolatedDefaults() + defaults.set("notAValidColor", forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + XCTAssertEqual( + BrowserDevToolsButtonDebugSettings.colorOption(defaults: defaults), + BrowserDevToolsButtonDebugSettings.defaultColor + ) + } + + func testCopyPayloadUsesPersistedValues() { + let defaults = makeIsolatedDefaults() + defaults.set(BrowserDevToolsIconOption.scope.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconNameKey) + defaults.set(BrowserDevToolsIconColorOption.bonsplitActive.rawValue, forKey: BrowserDevToolsButtonDebugSettings.iconColorKey) + + let payload = BrowserDevToolsButtonDebugSettings.copyPayload(defaults: defaults) + XCTAssertTrue(payload.contains("browserDevToolsIconName=scope")) + XCTAssertTrue(payload.contains("browserDevToolsIconColor=bonsplitActive")) + } +} + +final class BrowserDeveloperToolsShortcutDefaultsTests: XCTestCase { + func testSafariDefaultShortcutForToggleDeveloperTools() { + let shortcut = KeyboardShortcutSettings.Action.toggleBrowserDeveloperTools.defaultShortcut + XCTAssertEqual(shortcut.key, "i") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } + + func testSafariDefaultShortcutForShowJavaScriptConsole() { + let shortcut = KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultShortcut + XCTAssertEqual(shortcut.key, "c") + XCTAssertTrue(shortcut.command) + XCTAssertTrue(shortcut.option) + XCTAssertFalse(shortcut.shift) + XCTAssertFalse(shortcut.control) + } +} + +@MainActor +final class BrowserDeveloperToolsConfigurationTests: XCTestCase { + func testBrowserPanelEnablesInspectableWebViewAndDeveloperExtras() { + let panel = BrowserPanel(workspaceId: UUID()) + let developerExtras = panel.webView.configuration.preferences.value(forKey: "developerExtrasEnabled") as? Bool + XCTAssertEqual(developerExtras, true) + + if #available(macOS 13.3, *) { + XCTAssertTrue(panel.webView.isInspectable) + } + } +} + final class WorkspaceShortcutMapperTests: XCTestCase { func testCommandNineMapsToLastWorkspaceIndex() { XCTAssertEqual(WorkspaceShortcutMapper.workspaceIndex(forCommandDigit: 9, workspaceCount: 1), 0) diff --git a/skills/cmux-debug-windows/SKILL.md b/skills/cmux-debug-windows/SKILL.md index fb5dc859..885c5f49 100644 --- a/skills/cmux-debug-windows/SKILL.md +++ b/skills/cmux-debug-windows/SKILL.md @@ -10,6 +10,9 @@ Keep this workflow focused on existing debug windows and menu entries. Do not ad ## Workflow 1. Verify debug menu wiring in `Sources/cmuxApp.swift` under `CommandMenu("Debug")`. + - Menu path in app: `Debug` → `Debug Windows` → window entry. + - The `Debug` menu only exists in DEBUG builds (`./scripts/reload.sh --tag ...`). + - Release builds (`reloadp.sh`, `reloads.sh`) do not show this menu. 2. Keep these actions available in `Menu("Debug Windows")`: - `Sidebar Debug…` - `Background Debug…` diff --git a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh index c4461cb0..ac08502d 100755 --- a/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh +++ b/skills/cmux-debug-windows/scripts/debug_windows_snapshot.sh @@ -5,8 +5,8 @@ usage() { cat <<'USAGE' Usage: debug_windows_snapshot.sh [--domain ] [--copy] -Collect Sidebar Debug, Background Debug, and Menu Bar Extra debug values from macOS defaults -and print a combined payload. Use --copy to also copy the payload to clipboard. +Collect Sidebar Debug, Background Debug, Menu Bar Extra, and Browser DevTools debug values +from macOS defaults and print a combined payload. Use --copy to also copy the payload. Examples: debug_windows_snapshot.sh @@ -118,13 +118,16 @@ menubarDebugSingleDigitYOffset="$(format_number "$(read_value menubarDebugSingle menubarDebugMultiDigitYOffset="$(format_number "$(read_value menubarDebugMultiDigitYOffset 0.60)" 2)" legacySingleDigitX="$(read_value menubarDebugTextRectXAdjust '')" if [[ -n "$legacySingleDigitX" ]]; then - menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)" +menubarDebugSingleDigitXAdjust="$(format_number "$legacySingleDigitX" 2)" else menubarDebugSingleDigitXAdjust="$(format_number "$(read_value menubarDebugSingleDigitXAdjust -1.10)" 2)" fi menubarDebugMultiDigitXAdjust="$(format_number "$(read_value menubarDebugMultiDigitXAdjust 2.42)" 2)" menubarDebugTextRectWidthAdjust="$(format_number "$(read_value menubarDebugTextRectWidthAdjust 1.80)" 2)" +browserDevToolsIconName="$(read_value browserDevToolsIconName 'wrench.and.screwdriver')" +browserDevToolsIconColor="$(read_value browserDevToolsIconColor bonsplitInactive)" + payload="$(cat <