Add React Grab inject button to browser toolbar (#2373)
* Add React Grab inject button to browser toolbar Adds a toolbar button (cursor click icon) that injects the react-grab script (unpkg.com/react-grab/dist/index.global.js) into the current page. Hover over React elements and Cmd+C to copy component context (file, component name, line number) for AI agents. Button highlights when active, resets on navigation. * Auto-activate selection mode on React Grab inject First click: injects the script and auto-activates selection mode via the react-grab:init event. Subsequent clicks toggle selection mode on/off via window.__REACT_GRAB__.toggle(). * Bridge React Grab state back to Swift via WKScriptMessageHandler Register a cmux-bridge plugin after injecting react-grab that posts state changes back to Swift via webkit.messageHandlers. The button now highlights accent color only when selection mode is actually active (not just when the script is loaded), and deactivates when the user exits selection mode via Escape or the react-grab toolbar. * Fetch react-grab script via URLSession to bypass CSP Sites like vercel.com block loading external scripts via CSP headers. Fetch the script with URLSession (not subject to page CSP), cache it, and inject inline via evaluateJavaScript. Also guard against duplicate injection on repeated clicks. * Prefetch react-grab script on first browser panel init Kick off a low-priority background fetch of the react-grab script when the first BrowserPanel is created. The script is cached statically so clicking the button is instant. * Eliminate react-grab button and callback lag Three changes: 1. Fire-and-forget: use evaluateJavaScript with completionHandler instead of await, so button taps return immediately. 2. Single JS payload: combine bootstrap listener + script source into one evaluateJavaScript call (one IPC round-trip, not two). 3. Dedupe state callbacks: only post webkit message when isActive actually changes, not on every hover/drag state update. * Fix duplicate state callback on react-grab toggle toggleReactGrab was sending an explicit postMessage AND the plugin's onStateChange hook was firing too, causing two @Published updates per toggle. Remove the explicit postMessage since the plugin hook handles it. Also add dlog instrumentation for debugging. * Add Cmd+Shift+G shortcut for React Grab (configurable) - Add toggleReactGrab to KeyboardShortcutSettings with Cmd+Shift+G default - Add View menu item with customizable shortcut - Add command palette entry (searchable as "react grab" or "inspect element") - Simplify button to use toggleOrInjectReactGrab, remove local state tracking * Fix Codex review findings: pin version, verify hash, fix retry and state 1. Pin react-grab to exact version (0.1.29) with SHA-256 integrity check. Script is verified before evaluation to prevent supply-chain attacks via compromised CDN responses. 2. Clear prefetchTask on failure so subsequent attempts retry the download instead of reusing a permanently failed task. 3. Remove premature isReactGrabActive=true. State is now only set by the onStateChange message handler callback after confirmed initialization, or explicitly reset on evaluation error. * Extract React Grab into own file, make version configurable Move all react-grab logic (settings, script loader, message handler, BrowserPanel extension) into Sources/Panels/ReactGrab.swift. Add a "React Grab Version" text field in Settings > Browser that lets the user pin which npm version is fetched. Only versions with a known SHA-256 integrity hash in ReactGrabSettings.knownHashes are accepted. The cache invalidates when the configured version changes. --------- Co-authored-by: Lawrence Chen <lawrencecchen@users.noreply.github.com>
This commit is contained in:
parent
978dd2c023
commit
dd54927cb9
9 changed files with 440 additions and 0 deletions
|
|
@ -31,6 +31,7 @@
|
||||||
A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; };
|
A5001400 /* Panel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001410 /* Panel.swift */; };
|
||||||
A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; };
|
A5001401 /* TerminalPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001411 /* TerminalPanel.swift */; };
|
||||||
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
|
A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; };
|
||||||
|
A500RG01 /* ReactGrab.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500RG00 /* ReactGrab.swift */; };
|
||||||
A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; };
|
A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; };
|
||||||
A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; };
|
A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; };
|
||||||
A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; };
|
A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; };
|
||||||
|
|
@ -230,6 +231,7 @@
|
||||||
A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = "<group>"; };
|
A5001410 /* Panel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/Panel.swift; sourceTree = "<group>"; };
|
||||||
A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = "<group>"; };
|
A5001411 /* TerminalPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanel.swift; sourceTree = "<group>"; };
|
||||||
A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = "<group>"; };
|
A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = "<group>"; };
|
||||||
|
A500RG00 /* ReactGrab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ReactGrab.swift; sourceTree = "<group>"; };
|
||||||
A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; };
|
A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = "<group>"; };
|
||||||
A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; };
|
A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = "<group>"; };
|
||||||
A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = "<group>"; };
|
A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = "<group>"; };
|
||||||
|
|
@ -481,6 +483,7 @@
|
||||||
A5001410 /* Panel.swift */,
|
A5001410 /* Panel.swift */,
|
||||||
A5001411 /* TerminalPanel.swift */,
|
A5001411 /* TerminalPanel.swift */,
|
||||||
A5001412 /* BrowserPanel.swift */,
|
A5001412 /* BrowserPanel.swift */,
|
||||||
|
A500RG00 /* ReactGrab.swift */,
|
||||||
A5001413 /* TerminalPanelView.swift */,
|
A5001413 /* TerminalPanelView.swift */,
|
||||||
A5001414 /* BrowserPanelView.swift */,
|
A5001414 /* BrowserPanelView.swift */,
|
||||||
A5007421 /* BrowserPopupWindowController.swift */,
|
A5007421 /* BrowserPopupWindowController.swift */,
|
||||||
|
|
@ -802,6 +805,7 @@
|
||||||
A5001400 /* Panel.swift in Sources */,
|
A5001400 /* Panel.swift in Sources */,
|
||||||
A5001401 /* TerminalPanel.swift in Sources */,
|
A5001401 /* TerminalPanel.swift in Sources */,
|
||||||
A5001402 /* BrowserPanel.swift in Sources */,
|
A5001402 /* BrowserPanel.swift in Sources */,
|
||||||
|
A500RG01 /* ReactGrab.swift in Sources */,
|
||||||
A5001403 /* TerminalPanelView.swift in Sources */,
|
A5001403 /* TerminalPanelView.swift in Sources */,
|
||||||
A5001404 /* BrowserPanelView.swift in Sources */,
|
A5001404 /* BrowserPanelView.swift in Sources */,
|
||||||
A5007420 /* BrowserPopupWindowController.swift in Sources */,
|
A5007420 /* BrowserPopupWindowController.swift in Sources */,
|
||||||
|
|
|
||||||
|
|
@ -10404,6 +10404,59 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"browser.reactGrab": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Inject React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "React Grabを注入"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "注入 React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "注入 React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "React Grab 주입"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "React Grab einfügen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Inyectar React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Injecter React Grab"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"browser.search.placeholder": {
|
"browser.search.placeholder": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|
@ -43562,6 +43615,41 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"menu.view.toggleReactGrab": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Toggle React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "React Grabの切り替え"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "切换 React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "切換 React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "React Grab 전환"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"menu.view.showNotifications": {
|
"menu.view.showNotifications": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|
@ -56855,6 +56943,40 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"settings.browser.reactGrabVersion": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "React Grab Version"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "React Grabバージョン"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings.browser.reactGrabVersion.subtitle": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Pinned npm version of react-grab injected by the toolbar button (Cmd+Shift+G). Only versions with a known integrity hash are accepted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "ツールバーボタン(Cmd+Shift+G)で注入されるreact-grabのnpmバージョン。既知の整合性ハッシュを持つバージョンのみ使用可能です。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"settings.browser.history": {
|
"settings.browser.history": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|
@ -67653,6 +67775,41 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"shortcut.toggleReactGrab.label": {
|
||||||
|
"extractionState": "manual",
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Toggle React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "React Grabの切り替え"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "切换 React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "切換 React Grab"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "React Grab 전환"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"shortcut.showNotifications.label": {
|
"shortcut.showNotifications.label": {
|
||||||
"extractionState": "manual",
|
"extractionState": "manual",
|
||||||
"localizations": {
|
"localizations": {
|
||||||
|
|
|
||||||
|
|
@ -5164,6 +5164,8 @@ struct ContentView: View {
|
||||||
return .toggleBrowserDeveloperTools
|
return .toggleBrowserDeveloperTools
|
||||||
case "palette.browserConsole":
|
case "palette.browserConsole":
|
||||||
return .showBrowserJavaScriptConsole
|
return .showBrowserJavaScriptConsole
|
||||||
|
case "palette.browserReactGrab":
|
||||||
|
return .toggleReactGrab
|
||||||
case "palette.browserSplitRight", "palette.terminalSplitBrowserRight":
|
case "palette.browserSplitRight", "palette.terminalSplitBrowserRight":
|
||||||
return .splitBrowserRight
|
return .splitBrowserRight
|
||||||
case "palette.browserSplitDown", "palette.terminalSplitBrowserDown":
|
case "palette.browserSplitDown", "palette.terminalSplitBrowserDown":
|
||||||
|
|
@ -5790,6 +5792,15 @@ struct ContentView: View {
|
||||||
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
contributions.append(
|
||||||
|
CommandPaletteCommandContribution(
|
||||||
|
commandId: "palette.browserReactGrab",
|
||||||
|
title: constant(String(localized: "command.browserReactGrab.title", defaultValue: "Toggle React Grab")),
|
||||||
|
subtitle: browserPanelSubtitle,
|
||||||
|
keywords: ["browser", "react", "grab", "inspect", "element"],
|
||||||
|
when: { $0.bool(CommandPaletteContextKeys.panelIsBrowser) }
|
||||||
|
)
|
||||||
|
)
|
||||||
contributions.append(
|
contributions.append(
|
||||||
CommandPaletteCommandContribution(
|
CommandPaletteCommandContribution(
|
||||||
commandId: "palette.browserZoomIn",
|
commandId: "palette.browserZoomIn",
|
||||||
|
|
@ -6274,6 +6285,9 @@ struct ContentView: View {
|
||||||
NSSound.beep()
|
NSSound.beep()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
registry.register(commandId: "palette.browserReactGrab") {
|
||||||
|
tabManager.toggleReactGrabFocusedBrowser()
|
||||||
|
}
|
||||||
registry.register(commandId: "palette.browserZoomIn") {
|
registry.register(commandId: "palette.browserZoomIn") {
|
||||||
if !tabManager.zoomInFocusedBrowser() {
|
if !tabManager.zoomInFocusedBrowser() {
|
||||||
NSSound.beep()
|
NSSound.beep()
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ enum KeyboardShortcutSettings {
|
||||||
case openBrowser
|
case openBrowser
|
||||||
case toggleBrowserDeveloperTools
|
case toggleBrowserDeveloperTools
|
||||||
case showBrowserJavaScriptConsole
|
case showBrowserJavaScriptConsole
|
||||||
|
case toggleReactGrab
|
||||||
|
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
|
@ -83,6 +84,7 @@ enum KeyboardShortcutSettings {
|
||||||
case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser")
|
case .openBrowser: return String(localized: "shortcut.openBrowser.label", defaultValue: "Open Browser")
|
||||||
case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools")
|
case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools")
|
||||||
case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console")
|
case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console")
|
||||||
|
case .toggleReactGrab: return String(localized: "shortcut.toggleReactGrab.label", defaultValue: "Toggle React Grab")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +122,7 @@ enum KeyboardShortcutSettings {
|
||||||
case .openBrowser: return "shortcut.openBrowser"
|
case .openBrowser: return "shortcut.openBrowser"
|
||||||
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
|
case .toggleBrowserDeveloperTools: return "shortcut.toggleBrowserDeveloperTools"
|
||||||
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
|
case .showBrowserJavaScriptConsole: return "shortcut.showBrowserJavaScriptConsole"
|
||||||
|
case .toggleReactGrab: return "shortcut.toggleReactGrab"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,6 +194,8 @@ enum KeyboardShortcutSettings {
|
||||||
case .showBrowserJavaScriptConsole:
|
case .showBrowserJavaScriptConsole:
|
||||||
// Safari default: Show JavaScript Console.
|
// Safari default: Show JavaScript Console.
|
||||||
return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false)
|
return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false)
|
||||||
|
case .toggleReactGrab:
|
||||||
|
return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2258,6 +2258,8 @@ final class BrowserPanel: Panel, ObservableObject {
|
||||||
private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow }
|
private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow }
|
||||||
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
|
// Persist user intent across WebKit detach/reattach churn (split/layout updates).
|
||||||
@Published private(set) var preferredDeveloperToolsVisible: Bool = false
|
@Published private(set) var preferredDeveloperToolsVisible: Bool = false
|
||||||
|
@Published var isReactGrabActive: Bool = false
|
||||||
|
var reactGrabMessageHandler: ReactGrabMessageHandler?
|
||||||
private var preferredDeveloperToolsPresentation: DeveloperToolsPresentation = .unknown
|
private var preferredDeveloperToolsPresentation: DeveloperToolsPresentation = .unknown
|
||||||
private var forceDeveloperToolsRefreshOnNextAttach: Bool = false
|
private var forceDeveloperToolsRefreshOnNextAttach: Bool = false
|
||||||
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
|
private var developerToolsRestoreRetryWorkItem: DispatchWorkItem?
|
||||||
|
|
@ -2547,6 +2549,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
||||||
webView.navigationDelegate = navigationDelegate
|
webView.navigationDelegate = navigationDelegate
|
||||||
webView.uiDelegate = uiDelegate
|
webView.uiDelegate = uiDelegate
|
||||||
setupObservers(for: webView)
|
setupObservers(for: webView)
|
||||||
|
setupReactGrabMessageHandler(for: webView)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func configureNavigationDelegateCallbacks() {
|
private func configureNavigationDelegateCallbacks() {
|
||||||
|
|
@ -2704,6 +2707,7 @@ final class BrowserPanel: Panel, ObservableObject {
|
||||||
bindWebView(webView)
|
bindWebView(webView)
|
||||||
installDetachedDeveloperToolsWindowCloseObserver()
|
installDetachedDeveloperToolsWindowCloseObserver()
|
||||||
applyBrowserThemeModeIfNeeded()
|
applyBrowserThemeModeIfNeeded()
|
||||||
|
ReactGrabScriptLoader.prefetch()
|
||||||
insecureHTTPAlertWindowProvider = { [weak self] in
|
insecureHTTPAlertWindowProvider = { [weak self] in
|
||||||
self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
self?.webView.window ?? NSApp.keyWindow ?? NSApp.mainWindow
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -588,6 +588,7 @@ struct BrowserPanelView: View {
|
||||||
if isWebViewBlank() {
|
if isWebViewBlank() {
|
||||||
refreshEmptyStateImportBrowsers()
|
refreshEmptyStateImportBrowsers()
|
||||||
}
|
}
|
||||||
|
panel.resetReactGrabState()
|
||||||
}
|
}
|
||||||
.onChange(of: browserThemeModeRaw) { _ in
|
.onChange(of: browserThemeModeRaw) { _ in
|
||||||
let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw)
|
let normalizedMode = BrowserThemeSettings.mode(for: browserThemeModeRaw)
|
||||||
|
|
@ -732,6 +733,7 @@ struct BrowserPanelView: View {
|
||||||
if shouldShowToolbarImportHintChip {
|
if shouldShowToolbarImportHintChip {
|
||||||
browserImportHintToolbarChip
|
browserImportHintToolbarChip
|
||||||
}
|
}
|
||||||
|
reactGrabButton
|
||||||
browserProfileButton
|
browserProfileButton
|
||||||
browserThemeModeButton
|
browserThemeModeButton
|
||||||
developerToolsButton
|
developerToolsButton
|
||||||
|
|
@ -823,6 +825,23 @@ struct BrowserPanelView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var reactGrabButton: some View {
|
||||||
|
Button(action: {
|
||||||
|
Task { await panel.toggleOrInjectReactGrab() }
|
||||||
|
}) {
|
||||||
|
Image(systemName: "cursorarrow.click.2")
|
||||||
|
.symbolRenderingMode(.monochrome)
|
||||||
|
.cmuxFlatSymbolColorRendering()
|
||||||
|
.font(.system(size: devToolsButtonIconSize, weight: .medium))
|
||||||
|
.foregroundStyle(panel.isReactGrabActive ? Color.accentColor : Color.secondary)
|
||||||
|
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||||
|
}
|
||||||
|
.buttonStyle(OmnibarAddressButtonStyle())
|
||||||
|
.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center)
|
||||||
|
.safeHelp(String(localized: "browser.reactGrab", defaultValue: "Inject React Grab"))
|
||||||
|
.accessibilityIdentifier("BrowserReactGrabButton")
|
||||||
|
}
|
||||||
|
|
||||||
private var developerToolsButton: some View {
|
private var developerToolsButton: some View {
|
||||||
Button(action: {
|
Button(action: {
|
||||||
openDevTools()
|
openDevTools()
|
||||||
|
|
|
||||||
205
Sources/Panels/ReactGrab.swift
Normal file
205
Sources/Panels/ReactGrab.swift
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
import WebKit
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
import Bonsplit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
enum ReactGrabSettings {
|
||||||
|
static let versionKey = "reactGrabVersion"
|
||||||
|
static let defaultVersion = "0.1.29"
|
||||||
|
|
||||||
|
/// Known versions and their SHA-256 integrity hashes.
|
||||||
|
/// Add new entries when bumping the default or to allow user-selected versions.
|
||||||
|
static let knownHashes: [String: String] = [
|
||||||
|
"0.1.29": "4a1e71090e8ad8bb6049de80ccccdc0f5bb147b9f8fb88886d871612ac7ca04b",
|
||||||
|
]
|
||||||
|
|
||||||
|
static func scriptURL(for version: String) -> URL {
|
||||||
|
URL(string: "https://unpkg.com/react-grab@\(version)/dist/index.global.js")!
|
||||||
|
}
|
||||||
|
|
||||||
|
static var configuredVersion: String {
|
||||||
|
let stored = UserDefaults.standard.string(forKey: versionKey)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
return stored.isEmpty ? defaultVersion : stored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Script Loader
|
||||||
|
|
||||||
|
/// Fetches, integrity-checks, and caches the react-grab script.
|
||||||
|
/// Shared across all BrowserPanel instances.
|
||||||
|
enum ReactGrabScriptLoader {
|
||||||
|
private static var cachedScript: String?
|
||||||
|
private static var cachedVersion: String?
|
||||||
|
private static var prefetchTask: Task<String?, Never>?
|
||||||
|
|
||||||
|
static func prefetch() {
|
||||||
|
let version = ReactGrabSettings.configuredVersion
|
||||||
|
// Invalidate cache if version changed.
|
||||||
|
if cachedVersion != version {
|
||||||
|
cachedScript = nil
|
||||||
|
cachedVersion = nil
|
||||||
|
}
|
||||||
|
guard cachedScript == nil else { return }
|
||||||
|
guard prefetchTask == nil else { return }
|
||||||
|
prefetchTask = Task.detached(priority: .low) {
|
||||||
|
let result = await doFetch(version: version)
|
||||||
|
await MainActor.run { prefetchTask = nil }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fetch() async -> String? {
|
||||||
|
let version = ReactGrabSettings.configuredVersion
|
||||||
|
if cachedVersion == version, let cached = cachedScript { return cached }
|
||||||
|
prefetch()
|
||||||
|
return await prefetchTask?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func doFetch(version: String) async -> String? {
|
||||||
|
let url = ReactGrabSettings.scriptURL(for: version)
|
||||||
|
do {
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
if let expectedHash = ReactGrabSettings.knownHashes[version] {
|
||||||
|
let hash = SHA256.hash(data: data)
|
||||||
|
let hex = hash.compactMap { String(format: "%02x", $0) }.joined()
|
||||||
|
guard hex == expectedHash else {
|
||||||
|
NSLog("ReactGrab: integrity mismatch for v%@ (got %@)", version, hex)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard let script = String(data: data, encoding: .utf8) else { return nil }
|
||||||
|
await MainActor.run {
|
||||||
|
cachedScript = script
|
||||||
|
cachedVersion = version
|
||||||
|
}
|
||||||
|
return script
|
||||||
|
} catch {
|
||||||
|
NSLog("ReactGrab: fetch failed for v%@: %@", version, error.localizedDescription)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WKScriptMessageHandler
|
||||||
|
|
||||||
|
private let reactGrabMessageHandlerName = "cmuxReactGrab"
|
||||||
|
|
||||||
|
class ReactGrabMessageHandler: NSObject, WKScriptMessageHandler {
|
||||||
|
private let onStateChange: @MainActor (Bool) -> Void
|
||||||
|
|
||||||
|
init(onStateChange: @escaping @MainActor (Bool) -> Void) {
|
||||||
|
self.onStateChange = onStateChange
|
||||||
|
}
|
||||||
|
|
||||||
|
func userContentController(
|
||||||
|
_ userContentController: WKUserContentController,
|
||||||
|
didReceive message: WKScriptMessage
|
||||||
|
) {
|
||||||
|
guard let body = message.body as? [String: Any],
|
||||||
|
let isActive = body["isActive"] as? Bool else { return }
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.messageHandler isActive=\(isActive)")
|
||||||
|
#endif
|
||||||
|
Task { @MainActor in
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.messageHandler.mainActor isActive=\(isActive)")
|
||||||
|
#endif
|
||||||
|
onStateChange(isActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - BrowserPanel extension
|
||||||
|
|
||||||
|
extension BrowserPanel {
|
||||||
|
func setupReactGrabMessageHandler(for webView: WKWebView) {
|
||||||
|
let handler = ReactGrabMessageHandler { [weak self] isActive in
|
||||||
|
self?.isReactGrabActive = isActive
|
||||||
|
}
|
||||||
|
reactGrabMessageHandler = handler
|
||||||
|
webView.configuration.userContentController.add(handler, name: reactGrabMessageHandlerName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func injectReactGrab() async {
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.inject.start")
|
||||||
|
#endif
|
||||||
|
guard let scriptSource = await ReactGrabScriptLoader.fetch() else {
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.inject.fetchFailed")
|
||||||
|
#endif
|
||||||
|
return
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.inject.fetched len=\(scriptSource.count)")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
let handlerName = reactGrabMessageHandlerName
|
||||||
|
let combined = """
|
||||||
|
(function() {
|
||||||
|
if (window.__REACT_GRAB__) { window.__REACT_GRAB__.activate(); return; }
|
||||||
|
window.addEventListener('react-grab:init', function(e) {
|
||||||
|
var api = e.detail;
|
||||||
|
if (!api) return;
|
||||||
|
api.activate();
|
||||||
|
var lastActive;
|
||||||
|
api.registerPlugin({
|
||||||
|
name: 'cmux-bridge',
|
||||||
|
hooks: {
|
||||||
|
onStateChange: function(state) {
|
||||||
|
if (state.isActive === lastActive) return;
|
||||||
|
lastActive = state.isActive;
|
||||||
|
var h = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.\(handlerName);
|
||||||
|
if (h) h.postMessage({ isActive: state.isActive });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { once: true });
|
||||||
|
})();
|
||||||
|
\(scriptSource)
|
||||||
|
"""
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.inject.evalJS len=\(combined.count)")
|
||||||
|
#endif
|
||||||
|
webView.evaluateJavaScript(combined) { [weak self] _, error in
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.inject.evalJS.done error=\(error?.localizedDescription ?? "none")")
|
||||||
|
#endif
|
||||||
|
if let error {
|
||||||
|
NSLog("ReactGrab: injection failed: %@", error.localizedDescription)
|
||||||
|
Task { @MainActor in self?.isReactGrabActive = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.inject.end")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleReactGrab() {
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.toggle.start")
|
||||||
|
#endif
|
||||||
|
let script = "window.__REACT_GRAB__?.toggle()"
|
||||||
|
webView.evaluateJavaScript(script, completionHandler: nil)
|
||||||
|
#if DEBUG
|
||||||
|
dlog("reactGrab.toggle.end")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleOrInjectReactGrab() async {
|
||||||
|
if isReactGrabActive {
|
||||||
|
toggleReactGrab()
|
||||||
|
} else {
|
||||||
|
await injectReactGrab()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetReactGrabState() {
|
||||||
|
isReactGrabActive = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3159,6 +3159,11 @@ class TabManager: ObservableObject {
|
||||||
focusedBrowserPanel?.showDeveloperToolsConsole() ?? false
|
focusedBrowserPanel?.showDeveloperToolsConsole() ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toggleReactGrabFocusedBrowser() {
|
||||||
|
guard let panel = focusedBrowserPanel else { return }
|
||||||
|
Task { await panel.toggleOrInjectReactGrab() }
|
||||||
|
}
|
||||||
|
|
||||||
/// Backwards compatibility: returns the focused surface ID
|
/// Backwards compatibility: returns the focused surface ID
|
||||||
func focusedSurfaceId(for tabId: UUID) -> UUID? {
|
func focusedSurfaceId(for tabId: UUID) -> UUID? {
|
||||||
focusedPanelId(for: tabId)
|
focusedPanelId(for: tabId)
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,8 @@ struct cmuxApp: App {
|
||||||
private var toggleBrowserDeveloperToolsShortcutData = Data()
|
private var toggleBrowserDeveloperToolsShortcutData = Data()
|
||||||
@AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey)
|
@AppStorage(KeyboardShortcutSettings.Action.showBrowserJavaScriptConsole.defaultsKey)
|
||||||
private var showBrowserJavaScriptConsoleShortcutData = Data()
|
private var showBrowserJavaScriptConsoleShortcutData = Data()
|
||||||
|
@AppStorage(KeyboardShortcutSettings.Action.toggleReactGrab.defaultsKey)
|
||||||
|
private var toggleReactGrabShortcutData = Data()
|
||||||
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data()
|
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserRight.defaultsKey) private var splitBrowserRightShortcutData = Data()
|
||||||
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data()
|
@AppStorage(KeyboardShortcutSettings.Action.splitBrowserDown.defaultsKey) private var splitBrowserDownShortcutData = Data()
|
||||||
@AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data()
|
@AppStorage(KeyboardShortcutSettings.Action.renameWorkspace.defaultsKey) private var renameWorkspaceShortcutData = Data()
|
||||||
|
|
@ -722,6 +724,10 @@ struct cmuxApp: App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
splitCommandButton(title: String(localized: "menu.view.toggleReactGrab", defaultValue: "Toggle React Grab"), shortcut: toggleReactGrabMenuShortcut) {
|
||||||
|
activeTabManager.toggleReactGrabFocusedBrowser()
|
||||||
|
}
|
||||||
|
|
||||||
Button(String(localized: "menu.view.zoomIn", defaultValue: "Zoom In")) {
|
Button(String(localized: "menu.view.zoomIn", defaultValue: "Zoom In")) {
|
||||||
_ = activeTabManager.zoomInFocusedBrowser()
|
_ = activeTabManager.zoomInFocusedBrowser()
|
||||||
}
|
}
|
||||||
|
|
@ -930,6 +936,13 @@ struct cmuxApp: App {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var toggleReactGrabMenuShortcut: StoredShortcut {
|
||||||
|
decodeShortcut(
|
||||||
|
from: toggleReactGrabShortcutData,
|
||||||
|
fallback: KeyboardShortcutSettings.Action.toggleReactGrab.defaultShortcut
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private var splitBrowserRightMenuShortcut: StoredShortcut {
|
private var splitBrowserRightMenuShortcut: StoredShortcut {
|
||||||
decodeShortcut(
|
decodeShortcut(
|
||||||
from: splitBrowserRightShortcutData,
|
from: splitBrowserRightShortcutData,
|
||||||
|
|
@ -4010,6 +4023,7 @@ struct SettingsView: View {
|
||||||
@AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue
|
@AppStorage(BrowserImportHintSettings.variantKey) private var browserImportHintVariantRaw = BrowserImportHintSettings.defaultVariant.rawValue
|
||||||
@AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
|
@AppStorage(BrowserImportHintSettings.showOnBlankTabsKey) private var showBrowserImportHintOnBlankTabs = BrowserImportHintSettings.defaultShowOnBlankTabs
|
||||||
@AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed
|
@AppStorage(BrowserImportHintSettings.dismissedKey) private var isBrowserImportHintDismissed = BrowserImportHintSettings.defaultDismissed
|
||||||
|
@AppStorage(ReactGrabSettings.versionKey) private var reactGrabVersion = ReactGrabSettings.defaultVersion
|
||||||
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
@AppStorage(BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey) private var openTerminalLinksInCmuxBrowser = BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser
|
||||||
@AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
|
@AppStorage(BrowserLinkOpenSettings.interceptTerminalOpenCommandInCmuxBrowserKey)
|
||||||
private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue()
|
private var interceptTerminalOpenCommandInCmuxBrowser = BrowserLinkOpenSettings.initialInterceptTerminalOpenCommandInCmuxBrowserValue()
|
||||||
|
|
@ -5703,6 +5717,19 @@ struct SettingsView: View {
|
||||||
|
|
||||||
SettingsCardDivider()
|
SettingsCardDivider()
|
||||||
|
|
||||||
|
SettingsCardRow(
|
||||||
|
String(localized: "settings.browser.reactGrabVersion", defaultValue: "React Grab Version"),
|
||||||
|
subtitle: String(localized: "settings.browser.reactGrabVersion.subtitle", defaultValue: "Pinned npm version of react-grab injected by the toolbar button (Cmd+Shift+G). Only versions with a known integrity hash are accepted.")
|
||||||
|
) {
|
||||||
|
TextField("", text: $reactGrabVersion)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.frame(width: 100)
|
||||||
|
.font(.system(.body, design: .monospaced))
|
||||||
|
.accessibilityIdentifier("SettingsReactGrabVersionField")
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCardDivider()
|
||||||
|
|
||||||
SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) {
|
SettingsCardRow(String(localized: "settings.browser.history", defaultValue: "Browsing History"), subtitle: browserHistorySubtitle) {
|
||||||
Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) {
|
Button(String(localized: "settings.browser.history.clearButton", defaultValue: "Clear History…")) {
|
||||||
showClearBrowserHistoryConfirmation = true
|
showClearBrowserHistoryConfirmation = true
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue