From 3b1f8d81903a7f31644a4b7cdacdf517b3de0834 Mon Sep 17 00:00:00 2001 From: wolfgang-aura <169568318+wolfgang-aura@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:10:50 +0800 Subject: [PATCH] fix(launcher): stop relying on --load-extension for Google Chrome Branded Google Chrome 137+ ignores --load-extension. When the launcher picked Chrome, its shortcut and -Status looked healthy while the extension never loaded, and the CLI could not reach the host. The launcher now detects branded Chrome by product name (Chromium and Chrome for Testing also ship chrome.exe and still honor the flag). On Chrome it leaves the flag out, asks for a one-time Load unpacked, keeps the occlusion flag, and checks only that flag in -Status. Edge is preferred over Chrome. -ShowLaunchArgs prints what would run, and the native host test drives it with stand-in browser exes. Refs #44 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 ++ CHANGELOG.md | 4 ++ README.md | 10 ++- scripts/start-sourcecapsule-browser.ps1 | 85 ++++++++++++++++++------- test/native-host.test.mjs | 79 ++++++++++++++++++++++- 5 files changed, 157 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d73f8d5..1e89a8e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,6 +230,11 @@ the finished AI readable link as JSON, with no clicks, prompts, or clipboard use arguments to the existing process and the flag is dropped. A one-time Load unpacked records `UNPACKED` instead and needs no launcher, but it is a manual UI step and cannot coexist with the command-line copy (same `key`, same ID). +- **Branded Google Chrome 137+ ignores `--load-extension`** (#44). The launcher detects it by + `VersionInfo.ProductName -eq 'Google Chrome'` (Chromium and Chrome for Testing also ship a + `chrome.exe` and still honor the flag), leaves the flag out, and asks for Load unpacked. On + Chrome, `-Status` checks only the occlusion flag; `--ping` is the real check. + `test/native-host.test.mjs` covers this with stand-in exes, through `-ShowLaunchArgs`. - **The host must not outlive its browser.** Node inherits the browser's stdio through the `.cmd`, and the host exits on stdin end/close/error or a failed stdout write (`test/native-host.test.mjs` covers the stdin case). Without that, a killed browser left an diff --git a/CHANGELOG.md b/CHANGELOG.md index 965e218..e42febf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ All notable changes to this project are documented here. The format is based on - The native host installer exits with an error when it runs inside a packaged app's container. There its files and registry keys go to a private copy that a normally started browser cannot see, and it used to report success anyway. +- The browser launcher no longer relies on `--load-extension` for Google Chrome, which ignores + the flag since Chrome 137. Its shortcut used to look healthy while Chrome never loaded the + extension. On Chrome the launcher now asks for a one-time Load unpacked and still passes the + occlusion flag. It prefers Edge over Chrome, and `-ShowLaunchArgs` prints what it would run. ## [1.6.5] - 2026-10-05 diff --git a/README.md b/README.md index ecba940..cca91ce 100644 --- a/README.md +++ b/README.md @@ -344,6 +344,14 @@ The alternative is a one-time **Load unpacked** of `dist\sourcecapsule-extension without any launcher. It is a manual UI step, and it cannot coexist with the command-line copy: same `key`, same extension ID. +**Google Chrome needs Load unpacked.** Since Chrome 137, branded Google Chrome +[ignores `--load-extension`](https://groups.google.com/a/chromium.org/g/chromium-extensions/c/1-g8EFx2BBY/m/S0ET5wPjCAAJ). +The launcher picks Brave, then Edge, then Chrome. When it ends up on Chrome, it leaves out +`--load-extension`, says so, and still passes the occlusion flag. Load the extension once from +`chrome://extensions` (Developer mode, **Load unpacked**, select `dist\sourcecapsule-extension`). +Then start Chrome from the shortcut. `-Status` cannot see an unpacked extension on a command +line, so on Chrome confirm with `--ping`. Chromium and Chrome for Testing still honor the flag. + ### How it fits together The browser spawns native messaging hosts; nothing outside can dial into the browser. So the @@ -359,7 +367,7 @@ capture at a time. Timeouts are bounded at every hop and the URL is validated ag means starting it from the launcher above. - If the extension is not actually running, the bridge reports `Specified native messaging host not found`. That is almost always a start without - `--load-extension`, not a bad registration. + `--load-extension` (or, on Google Chrome, a missing Load unpacked), not a bad registration. - The capture window is visible but never focused. A hidden tab has `requestAnimationFrame` paused and timers throttled, which starves the media force-load pass and manufactures strict-mode blockers. diff --git a/scripts/start-sourcecapsule-browser.ps1 b/scripts/start-sourcecapsule-browser.ps1 index 5841c2b..b8ac7a4 100644 --- a/scripts/start-sourcecapsule-browser.ps1 +++ b/scripts/start-sourcecapsule-browser.ps1 @@ -15,6 +15,10 @@ # which records location 4 and survives restarts on its own. That is a manual UI step # and cannot coexist with the command-line copy: same `key`, same extension ID. # +# Google Chrome is the exception (#44). Its branded builds ignore `--load-extension` since +# Chrome 137, so on Chrome Load unpacked is the only way in. For Chrome this script still +# manages the occlusion flag below and the shortcut, but never passes `--load-extension`. +# # powershell -ExecutionPolicy Bypass -File scripts\start-sourcecapsule-browser.ps1 # powershell -ExecutionPolicy Bypass -File scripts\start-sourcecapsule-browser.ps1 -Restart -Verify # powershell -ExecutionPolicy Bypass -File scripts\start-sourcecapsule-browser.ps1 -InstallShortcut -InstallStartup @@ -22,7 +26,7 @@ [CmdletBinding()] param( - # Path to the browser executable. Brave first, then Chrome, then Edge. + # Path to the browser executable. Brave first, then Edge, then Chrome. [string]$BrowserPath, # Unpacked extension directory. Defaults to \dist\sourcecapsule-extension. [string]$ExtensionDir, @@ -39,7 +43,9 @@ param( # Report what is running and whether the flag is present; change nothing. [switch]$Status, # Seconds to wait for -Verify to see a healthy bridge. - [int]$VerifyTimeoutSeconds = 90 + [int]$VerifyTimeoutSeconds = 90, + # Print the browser and the arguments this script would start it with; change nothing. + [switch]$ShowLaunchArgs ) $ErrorActionPreference = 'Stop' @@ -60,15 +66,24 @@ function Resolve-BrowserPath { "$env:ProgramFiles\BraveSoftware\Brave-Browser\Application\brave.exe", "${env:ProgramFiles(x86)}\BraveSoftware\Brave-Browser\Application\brave.exe", "$env:LOCALAPPDATA\BraveSoftware\Brave-Browser\Application\brave.exe", + # Edge before Chrome: Edge honors --load-extension, so it needs no manual step. + "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe", + "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe", "$env:ProgramFiles\Google\Chrome\Application\chrome.exe", "${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe", - "$env:ProgramFiles\Microsoft\Edge\Application\msedge.exe", - "${env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe" + "$env:LOCALAPPDATA\Google\Chrome\Application\chrome.exe" ) foreach ($candidate in $candidates) { if ($candidate -and (Test-Path $candidate)) { return $candidate } } - throw 'No Brave, Chrome, or Edge executable found. Pass -BrowserPath explicitly.' + throw 'No Brave, Edge, or Chrome executable found. Pass -BrowserPath explicitly.' +} + +# Branded Google Chrome 137+ ignores --load-extension. Chromium and Chrome for Testing also +# ship a chrome.exe and still honor it, so tell them apart by product name, not file name. +function Test-IgnoresLoadExtension([string]$exePath) { + $product = (Get-Item $exePath).VersionInfo.ProductName + return ($product -and $product.Trim() -eq 'Google Chrome') } # Only the browser's main process carries the user's command line. Every renderer, GPU, @@ -100,6 +115,14 @@ function Test-HasExtensionFlag($proc, [string]$extensionDir) { return $proc.CommandLine.Replace('/', '\') -like "*$normalized*" } +# What "started correctly" means depends on the browser. Where --load-extension works, it +# must be on the command line. On Chrome the extension comes from Load unpacked, which no +# command line shows, so the occlusion flag is the only thing this script can check. +function Test-HasLaunchFlags($proc) { + if ($loadUnpacked) { return Test-HasOcclusionFlag $proc } + return Test-HasExtensionFlag $proc $extensionFull +} + function Get-ShortcutTargets { $desktop = [Environment]::GetFolderPath('Desktop') $startMenu = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' @@ -111,14 +134,14 @@ function Get-ShortcutTargets { } } -function New-BrowserShortcut([string]$path, [string]$exePath, [string]$extensionDir) { +function New-BrowserShortcut([string]$path, [string]$exePath) { $shell = New-Object -ComObject WScript.Shell $link = $shell.CreateShortcut($path) $link.TargetPath = $exePath - $link.Arguments = "--load-extension=`"$extensionDir`" $occlusionFlag --restore-last-session" + $link.Arguments = $launchArgs $link.WorkingDirectory = Split-Path -Parent $exePath $link.IconLocation = "$exePath,0" - $link.Description = 'Brave with the SourceCapsule extension loaded (unattended capture bridge)' + $link.Description = 'Browser with the SourceCapsule extension loaded (unattended capture bridge)' $link.Save() Write-Host "Installed $path" } @@ -126,6 +149,10 @@ function New-BrowserShortcut([string]$path, [string]$exePath, [string]$extension $browser = Resolve-BrowserPath $extensionFull = $ExtensionDir if (Test-Path $extensionFull) { $extensionFull = (Resolve-Path $extensionFull).Path } +$loadUnpacked = Test-IgnoresLoadExtension $browser +$launchArgs = "$occlusionFlag --restore-last-session" +if (-not $loadUnpacked) { $launchArgs = "--load-extension=`"$extensionFull`" $launchArgs" } +$loadUnpackedHelp = "Google Chrome ignores --load-extension, so load SourceCapsule once by hand: open chrome://extensions, turn on Developer mode, choose Load unpacked, and select $extensionFull. It stays loaded across restarts." if ($UninstallShortcut) { foreach ($entry in (Get-ShortcutTargets).GetEnumerator()) { @@ -142,18 +169,26 @@ if (-not (Test-Path (Join-Path $extensionFull 'manifest.json'))) { throw "No unpacked extension at $extensionFull. Build it first: npm run build:extension" } +if ($loadUnpacked) { Write-Host $loadUnpackedHelp } + +if ($ShowLaunchArgs) { + Write-Host "Browser: $browser" + Write-Host "Arguments: $launchArgs" + exit 0 +} + # @() around the call as well: PowerShell unrolls a single-element return, and a scalar # has no .Count in 5.1, which silently prints a blank instead of "1". $running = @(Get-BrowserMainProcesses $browser) -$withFlag = @($running | Where-Object { Test-HasExtensionFlag $_ $extensionFull }) -$withoutFlag = @($running | Where-Object { -not (Test-HasExtensionFlag $_ $extensionFull) }) +$withFlag = @($running | Where-Object { Test-HasLaunchFlags $_ }) +$withoutFlag = @($running | Where-Object { -not (Test-HasLaunchFlags $_) }) if ($Status) { Write-Host "Browser: $browser" Write-Host "Extension: $extensionFull" Write-Host "Running main processes: $($running.Count)" - Write-Host " with the extension flag: $($withFlag.Count)" - Write-Host " without the extension flag: $($withoutFlag.Count)" + Write-Host " with the launch flags: $($withFlag.Count)" + Write-Host " without the launch flags: $($withoutFlag.Count)" $occluded = @($withFlag | Where-Object { -not (Test-HasOcclusionFlag $_) }) foreach ($entry in (Get-ShortcutTargets).GetEnumerator()) { $state = 'missing' @@ -169,7 +204,12 @@ if ($Status) { exit 0 } if ($withoutFlag.Count -gt 0) { - Write-Warning 'The browser is running WITHOUT the extension. Unattended capture will fail.' + if ($loadUnpacked) { + Write-Warning ('Chrome is running without ' + $occlusionFlag + '. Captures will still publish, but a thread will come back as its root post alone. Repair with -Restart -Verify.') + } + else { + Write-Warning 'The browser is running WITHOUT the extension. Unattended capture will fail.' + } exit 2 } Write-Host 'The browser is not running.' @@ -179,11 +219,11 @@ if ($Status) { if ($InstallShortcut -or $InstallStartup) { $targets = Get-ShortcutTargets if ($InstallShortcut) { - New-BrowserShortcut $targets.Desktop $browser $extensionFull - New-BrowserShortcut $targets.StartMenu $browser $extensionFull + New-BrowserShortcut $targets.Desktop $browser + New-BrowserShortcut $targets.StartMenu $browser } if ($InstallStartup) { - New-BrowserShortcut $targets.Startup $browser $extensionFull + New-BrowserShortcut $targets.Startup $browser } Write-Host '' Write-Host 'Start the browser from this shortcut and the capture bridge is always present.' @@ -199,7 +239,7 @@ else { # would merely hand the URL to the process already running and drop the flag. if ($running.Count -gt 0) { if (-not $Restart) { - Write-Warning 'The browser is already running WITHOUT the SourceCapsule extension.' + Write-Warning 'The browser is already running WITHOUT the SourceCapsule launch flags.' Write-Warning 'Launching it again would only open a tab in that process; the flag would be ignored.' Write-Warning 'Re-run with -Restart to close it and start it with the extension.' exit 2 @@ -235,12 +275,8 @@ else { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } } - Write-Host "Starting $browser with $extensionFull" - Start-Process -FilePath $browser -ArgumentList @( - "--load-extension=$extensionFull", - $occlusionFlag, - '--restore-last-session' - ) + Write-Host "Starting $browser $launchArgs" + Start-Process -FilePath $browser -ArgumentList $launchArgs } if (-not $Verify) { @@ -268,5 +304,6 @@ while ((Get-Date) -lt $deadline) { } } Write-Warning "The bridge did not answer within $VerifyTimeoutSeconds seconds." -Write-Warning 'Open brave://extensions and confirm SourceCapsule is listed and enabled.' +if ($loadUnpacked) { Write-Warning $loadUnpackedHelp } +else { Write-Warning 'Open the browser''s extensions page and confirm SourceCapsule is listed and enabled.' } exit 1 diff --git a/test/native-host.test.mjs b/test/native-host.test.mjs index c07b4b8..71a285d 100644 --- a/test/native-host.test.mjs +++ b/test/native-host.test.mjs @@ -8,7 +8,7 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import net from 'node:net'; import os from 'node:os'; -import { spawn } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; @@ -286,8 +286,85 @@ async function cliChecks() { console.log('ok CLI rejects bad --timeout and --expiry-days before connecting'); } +// Branded Google Chrome 137+ ignores --load-extension, so a launcher that hands it that flag +// produces a shortcut that looks healthy while the extension never loads (#44). The stand-in +// browsers are empty executables carrying only the version resource the launcher reads. +function launcherChecks() { + if (process.platform !== 'win32') { + console.log('skip launcher browser selection (Windows only)'); + return; + } + const csc = path.join( + process.env.WINDIR, + 'Microsoft.NET', + 'Framework64', + 'v4.0.30319', + 'csc.exe' + ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sourcecapsule-launcher-')); + try { + const extensionDir = path.join(tmp, 'ext'); + fs.mkdirSync(extensionDir); + fs.writeFileSync(path.join(extensionDir, 'manifest.json'), '{}'); + // Windows resets ProgramFiles for every 64-bit process, so auto-detection cannot be + // pointed at stand-ins. Each one is passed explicitly with -BrowserPath instead. + const fakeBrowser = (relative, product) => { + const exe = path.join(tmp, relative); + fs.mkdirSync(path.dirname(exe), { recursive: true }); + const source = path.join(tmp, `${path.basename(exe)}.cs`); + fs.writeFileSync( + source, + `using System.Reflection;\n[assembly: AssemblyProduct("${product}")]\n` + + '[assembly: AssemblyFileVersion("141.0.0.0")]\nclass P { static void Main() {} }\n' + ); + const built = spawnSync(csc, ['/nologo', `/out:${exe}`, source], { encoding: 'utf8' }); + assert.equal(built.status, 0, built.stdout); + return exe; + }; + const launchArgs = (browser) => { + const run = spawnSync( + 'powershell', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + path.join(root, 'scripts', 'start-sourcecapsule-browser.ps1'), + '-ShowLaunchArgs', + '-BrowserPath', + browser, + '-ExtensionDir', + extensionDir, + ], + { encoding: 'utf8' } + ); + assert.equal(run.status, 0, run.stdout + run.stderr); + const args = run.stdout.match(/^Arguments: (.*)$/m); + assert.ok(args, run.stdout); + return { args: args[1], output: run.stdout }; + }; + + const chrome = launchArgs(fakeBrowser(path.join('chrome', 'chrome.exe'), 'Google Chrome')); + assert.doesNotMatch(chrome.args, /--load-extension/, 'Chrome must not get --load-extension'); + assert.match(chrome.args, /CalculateNativeWinOcclusion/, 'Chrome still needs occlusion off'); + assert.match(chrome.output, /Load unpacked/, 'Chrome users are told to load it once'); + + // Chrome for Testing ships a chrome.exe too, and still honors the flag. + const testing = launchArgs( + fakeBrowser(path.join('cft', 'chrome.exe'), 'Google Chrome for Testing') + ); + assert.match(testing.args, /--load-extension=/); + const edge = launchArgs(fakeBrowser(path.join('edge', 'msedge.exe'), 'Microsoft Edge')); + assert.match(edge.args, /--load-extension=/); + console.log('ok launcher never relies on --load-extension for Google Chrome'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +} + canonicalUrlChecks(); resultContractChecks(); +launcherChecks(); await transportChecks(); await cliChecks(); console.log('native-host transport tests passed');