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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
85 changes: 61 additions & 24 deletions scripts/start-sourcecapsule-browser.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,18 @@
# 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
# powershell -ExecutionPolicy Bypass -File scripts\start-sourcecapsule-browser.ps1 -Status

[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 <repo>\dist\sourcecapsule-extension.
[string]$ExtensionDir,
Expand All @@ -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'
Expand All @@ -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,
Expand Down Expand Up @@ -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'
Expand All @@ -111,21 +134,25 @@ 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"
}

$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()) {
Expand All @@ -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'
Expand All @@ -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.'
Expand All @@ -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.'
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
79 changes: 78 additions & 1 deletion test/native-host.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Loading