mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-18 19:02:18 +02:00
fix(browse): headed mode on macOS 26 — stop mutating the signed Chromium bundle, heal the ones we already broke (#2242, #2138, #2139)
The in-place rebrand rewrote the Chrome-for-Testing bundle's Info.plist (global name replace — which also renamed CFBundleExecutable to a binary that doesn't exist) and overwrote its Resources/*.icns, breaking the codesign seal: GPU process exit_code=5, headed mode dead on macOS 26. The mutation lived in the SHARED Playwright cache, so it also poisoned the user's other Playwright projects. Three layers land together: (1) the rebrand block is gone — branding lives in the GStack Browser.app wrapper via GSTACK_CHROMIUM_PATH, with a tombstone and a static tripwire (no plist/icns writes into the bundle; the tripwire allows the read-only probe below); (2) a launch-time self-heal detects an already-poisoned cache bundle, removes it, and errors with the exact re-fetch command — covering deploy paths that never run migrations; (3) migration v1.64.0.0 sweeps every cached bundle, removes poisoned ones, and re-fetches clean Chromium immediately (migrations run after ./setup, so without the re-fetch an upgrade would end with zero working browser). Functionally verified against fixture caches: poisoned removed, clean untouched, rerun no-op. Migration filename tracks the final VERSION at ship. The #2242 watchdog half is the absorbed PR #2565 (thanks @Screddyice). Tombstone/tripwire ported from time-attack/gstack (GStack 2); self-heal and migration are ours. Co-authored-by: Sina Matian <sina@time-attack.dev> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Sina Matian
Claude Fable 5
parent
01adcf8e8e
commit
a30ca53c1a
@@ -509,46 +509,45 @@ export class BrowserManager {
|
|||||||
// Used by GStack Browser.app to point at the bundled Chromium.
|
// Used by GStack Browser.app to point at the bundled Chromium.
|
||||||
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
||||||
|
|
||||||
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
|
// NOTE (#2242): the in-place "rebrand" that patched the Chromium .app's
|
||||||
// Patch the Chromium .app's Info.plist so macOS shows our name.
|
// Info.plist (global "Google Chrome for Testing" → "GStack Browser"
|
||||||
// This works for both dev mode (system Playwright cache) and .app bundle.
|
// replace) and overwrote its Resources/*.icns is deliberately GONE.
|
||||||
const chromePath = executablePath || chromium.executablePath();
|
// Chrome for Testing is a code-signed bundle: the global replace renamed
|
||||||
try {
|
// CFBundleExecutable to a binary that doesn't exist and the plist/icon
|
||||||
// Walk up from binary to the .app's Info.plist
|
// writes broke the codesign seal — GPU process exit_code=5, headed mode
|
||||||
// e.g. .../Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing
|
// dead on macOS 26 (#2242, #2138, #2139). Branding belongs in the
|
||||||
// → .../Google Chrome for Testing.app/Contents/Info.plist
|
// GStack Browser.app wrapper (GSTACK_CHROMIUM_PATH), never in a mutation
|
||||||
const chromeContentsDir = path.resolve(path.dirname(chromePath), '..');
|
// of the signed bundle. Do not reintroduce writes into the Chromium
|
||||||
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
|
// bundle here — browse/test/rebrand-signed-bundle.test.ts fails CI if
|
||||||
if (fs.existsSync(chromePlist)) {
|
// you do.
|
||||||
const plistContent = fs.readFileSync(chromePlist, 'utf-8');
|
//
|
||||||
if (plistContent.includes('Google Chrome for Testing')) {
|
// Self-heal for bundles the OLD code already poisoned: the mutation
|
||||||
const patched = plistContent
|
// lives in the SHARED Playwright cache, so deleting the rebrand code
|
||||||
.replace(/Google Chrome for Testing/g, 'GStack Browser');
|
// fixes fresh installs only, and the documented deploy paths never run
|
||||||
fs.writeFileSync(chromePlist, patched);
|
// upgrade migrations. Detect the mutated plist and remove the bundle so
|
||||||
}
|
// the next `playwright install chromium` (or the upgrade migration)
|
||||||
// Replace Chromium's Dock icon with ours (Chromium's process owns the Dock icon)
|
// re-fetches a clean one. Scoped to the Playwright cache copy — a
|
||||||
const iconCandidates = [
|
// GSTACK_CHROMIUM_PATH bundle belongs to the wrapper/embedder and is
|
||||||
path.join(__dirname, '..', '..', 'scripts', 'app', 'icon.icns'), // repo dev mode
|
// never touched.
|
||||||
path.join(process.env.HOME || '', '.claude', 'skills', 'gstack', 'scripts', 'app', 'icon.icns'), // global install
|
if (!executablePath) {
|
||||||
];
|
try {
|
||||||
const iconSrc = iconCandidates.find(p => fs.existsSync(p));
|
const chromePath = chromium.executablePath();
|
||||||
if (iconSrc) {
|
const chromeContentsDir = path.resolve(path.dirname(chromePath), '..');
|
||||||
const chromeResources = path.join(chromeContentsDir, 'Resources');
|
const chromePlist = path.join(chromeContentsDir, 'Info.plist');
|
||||||
// Read original icon name from plist
|
if (fs.existsSync(chromePlist) && fs.readFileSync(chromePlist, 'utf-8').includes('GStack Browser')) {
|
||||||
const iconMatch = plistContent.match(/<key>CFBundleIconFile<\/key>\s*<string>([^<]+)<\/string>/);
|
const appDir = path.resolve(chromeContentsDir, '..');
|
||||||
let origIcon = iconMatch ? iconMatch[1] : 'app';
|
fs.rmSync(appDir, { recursive: true, force: true });
|
||||||
if (!origIcon.endsWith('.icns')) origIcon += '.icns';
|
throw new Error(
|
||||||
const destIcon = path.join(chromeResources, origIcon);
|
'Chromium bundle was mutated by a previous gstack version (broken codesign seal — ' +
|
||||||
try {
|
'GPU exit_code=5 on macOS 26). The poisoned bundle has been removed. ' +
|
||||||
fs.copyFileSync(iconSrc, destIcon);
|
'Re-fetch a clean one with: bunx playwright install chromium — then retry.',
|
||||||
} catch (err: any) {
|
);
|
||||||
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.message?.includes('poisoned bundle')) throw err;
|
||||||
|
// Probe failures (no bundle yet, EACCES) fall through to launch,
|
||||||
|
// which produces its own actionable error.
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
|
||||||
// Non-fatal: app name stays as Chrome for Testing (ENOENT/EACCES expected)
|
|
||||||
if (err?.code !== 'ENOENT' && err?.code !== 'EACCES') throw err;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build custom user agent: report as stock Chrome with the version
|
// Build custom user agent: report as stock Chrome with the version
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* #2242 bug 1 regression tripwire: never mutate the signed Chrome-for-Testing
|
||||||
|
* bundle.
|
||||||
|
*
|
||||||
|
* The old launchHeaded() "rebrand" ran a global
|
||||||
|
* `.replace(/Google Chrome for Testing/g, 'GStack Browser')` over the
|
||||||
|
* bundle's Info.plist — which renamed CFBundleExecutable to a binary that
|
||||||
|
* doesn't exist — and overwrote Resources/*.icns. Both writes broke the
|
||||||
|
* codesign seal: GPU process exit_code=5, headed mode dead on macOS 26
|
||||||
|
* (#2242, #2138, #2139).
|
||||||
|
*
|
||||||
|
* Static invariant (same pattern as cdp-session-cleanup.test.ts): the
|
||||||
|
* browser lifecycle code must contain NO write into the Chromium .app
|
||||||
|
* bundle. Branding lives in the wrapper .app / custom GBrowser build.
|
||||||
|
* These assertions fail on the pre-fix code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, test, expect } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const SRC = fs.readFileSync(
|
||||||
|
path.join(import.meta.dir, '..', 'src', 'browser-manager.ts'),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('#2242: signed Chromium bundle is never mutated', () => {
|
||||||
|
test('no global Google-Chrome-for-Testing plist replace', () => {
|
||||||
|
expect(SRC).not.toContain("replace(/Google Chrome for Testing/g");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no Info.plist write into the Chromium bundle (reads allowed: self-heal probe)', () => {
|
||||||
|
// The old code built `Info.plist` under the bundle's Contents dir and
|
||||||
|
// wrote it back. Any reappearance of a WRITE is a regression. The
|
||||||
|
// launch-time self-heal legitimately READS the plist to detect bundles
|
||||||
|
// the old code already poisoned (EV4), so the path construction itself
|
||||||
|
// is allowed — writes into it are not.
|
||||||
|
expect(SRC).not.toMatch(/writeFileSync\(\s*chromePlist/);
|
||||||
|
const plistWrites = SRC.match(/writeFileSync\([^)]*[Pp]list/g) || [];
|
||||||
|
expect(plistWrites).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no icon overwrite into the Chromium bundle Resources dir', () => {
|
||||||
|
expect(SRC).not.toMatch(/copyFileSync\([^)]*destIcon/);
|
||||||
|
expect(SRC).not.toContain("CFBundleIconFile");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the tombstone comment documenting why stays put', () => {
|
||||||
|
// If someone deletes the explanation, the next contributor reintroduces
|
||||||
|
// the mutation in good faith. Keep the why next to the where.
|
||||||
|
expect(SRC).toContain('#2242');
|
||||||
|
});
|
||||||
|
});
|
||||||
Executable
+75
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Migration: v1.64.0.0 — repair Chrome-for-Testing bundles poisoned by the
|
||||||
|
# old in-place rebrand (#2242).
|
||||||
|
#
|
||||||
|
# Why a migration: pre-v1.64 launchHeaded() rewrote the Chromium .app's
|
||||||
|
# Info.plist ("Google Chrome for Testing" → "GStack Browser") and overwrote
|
||||||
|
# its Resources/*.icns — inside the SHARED Playwright cache. That broke the
|
||||||
|
# codesign seal (GPU process exit_code=5; headed mode dead on macOS 26) and
|
||||||
|
# poisoned the cache for the user's OTHER Playwright projects too. Deleting
|
||||||
|
# the rebrand code fixes fresh installs only; every existing macOS install
|
||||||
|
# still has the mutated bundle on disk. This migration removes poisoned
|
||||||
|
# bundles and re-fetches a clean one so the upgrade doesn't leave the user
|
||||||
|
# with zero working browser (the browse launch path also self-heals, as the
|
||||||
|
# belt to this suspenders, for installs that never run migrations).
|
||||||
|
#
|
||||||
|
# Affected: macOS installs that ever ran headed mode before v1.64.
|
||||||
|
#
|
||||||
|
# Idempotent: detection is content-based (plist contains "GStack Browser");
|
||||||
|
# a clean cache is a no-op, and the .done touchfile gates re-runs. The
|
||||||
|
# re-fetch is best-effort and non-fatal per the migration contract.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
GSTACK_HOME="${GSTACK_HOME:-${HOME}/.gstack}"
|
||||||
|
MIGRATION_DIR="${GSTACK_HOME}/.migrations"
|
||||||
|
DONE="${MIGRATION_DIR}/v1.64.0.0.done"
|
||||||
|
mkdir -p "${MIGRATION_DIR}" 2>/dev/null || true
|
||||||
|
[ -f "${DONE}" ] && exit 0
|
||||||
|
|
||||||
|
# macOS only: the mutation targeted .app bundle plists.
|
||||||
|
if [ "$(uname -s 2>/dev/null)" != "Darwin" ]; then
|
||||||
|
touch "${DONE}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
PW_CACHE="${PLAYWRIGHT_BROWSERS_PATH:-${HOME}/Library/Caches/ms-playwright}"
|
||||||
|
REMOVED=0
|
||||||
|
|
||||||
|
if [ -d "${PW_CACHE}" ]; then
|
||||||
|
# Every Chrome-for-Testing bundle in the cache (one per pinned chromium build).
|
||||||
|
while IFS= read -r plist; do
|
||||||
|
if grep -q "GStack Browser" "${plist}" 2>/dev/null; then
|
||||||
|
app_dir="$(dirname "$(dirname "${plist}")")"
|
||||||
|
case "${app_dir}" in
|
||||||
|
"${PW_CACHE}"/*.app|"${PW_CACHE}"/*/*.app|"${PW_CACHE}"/*/*/*.app)
|
||||||
|
echo " [v1.64.0.0] removing rebrand-poisoned bundle: ${app_dir}" >&2
|
||||||
|
rm -rf "${app_dir}"
|
||||||
|
REMOVED=1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo " [v1.64.0.0] WARNING: poisoned plist outside the Playwright cache shape, skipping: ${plist}" >&2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
done < <(find "${PW_CACHE}" -maxdepth 5 -name "Info.plist" -path "*.app/Contents/Info.plist" 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${REMOVED}" = "1" ]; then
|
||||||
|
# Re-fetch immediately: migrations run AFTER ./setup, so without this the
|
||||||
|
# user finishes the upgrade with no working browser at all (headless AND
|
||||||
|
# headed use the same bundle). Best-effort — a failed download leaves the
|
||||||
|
# actionable command printed and the launch-time self-heal message covers
|
||||||
|
# the rest.
|
||||||
|
echo " [v1.64.0.0] re-fetching a clean Chromium (bunx playwright install chromium)..." >&2
|
||||||
|
if command -v bunx >/dev/null 2>&1 && bunx playwright install chromium >&2; then
|
||||||
|
echo " [v1.64.0.0] clean Chromium installed." >&2
|
||||||
|
else
|
||||||
|
echo " [v1.64.0.0] WARNING: automatic re-fetch failed. Run manually: bunx playwright install chromium" >&2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " [v1.64.0.0] no rebrand-poisoned bundles found — no-op." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
touch "${DONE}"
|
||||||
|
exit 0
|
||||||
Reference in New Issue
Block a user