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');