From c0a9f4cb19bd09296ba45450f7d8d9954cc3a666 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 30 Jun 2026 09:59:19 +0200 Subject: [PATCH 1/2] fix: Chrome numbered-profile cookie discovery + headed window stability on macOS 26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1 — Headed Chromium window closes immediately on macOS 26 (Sequoia): macOS 26 tightened window activation policy. Chromium windows spawned by a non-app process (Bun server) close immediately unless properly registered via NSApplicationActivateIgnoringOtherApps. Two fixes: 1. Add --disable-features=MacControlledWindowBehavior to headed launch args on darwin. This flag suppresses the new OS-level window-hiding behavior while Playwright's NSApp startup sequence handles activation normally. No-op on macOS < 26 and non-macOS platforms. 2. Fix raiseHeadedWindowMacOS() — it tried to activate "Google Chrome for Testing" via osascript, but launchHeaded() patches the Info.plist to rename it to "GStack Browser". The activation silently failed, leaving the window unraised. Now tries "GStack Browser" first with fallback to the original name. Bug 2 — cookie-import-browser fails when no Default profile exists: Chrome on macOS 26 and some multi-profile installations never create a Default directory. Every account lands in a numbered profile (Profile 4, Profile 12, etc.). cookie-import-browser.ts only looked for Default/Cookies, so it threw "Chrome is not installed" even though cookies were present. Fix: when getBrowserMatch() can't find Default, call findFirstNumberedProfile() which scans for Profile N directories, sorts by number (lowest first so the primary account wins), and returns the first one with a Cookies file. The fallback is only triggered when the caller explicitly requested "Default" — an explicit non-default profile name still fails fast as before. Also adds tests for: numbered-profile fallback on Linux Chromium, lowest-number wins when multiple numbered profiles exist, listDomains falling back correctly. Closes #2138 --- browse/src/browser-manager.ts | 11 ++ browse/src/cli.ts | 28 +++++- browse/src/cookie-import-browser.ts | 67 +++++++++++++ browse/test/cookie-import-browser.test.ts | 116 ++++++++++++++++++++++ 4 files changed, 217 insertions(+), 5 deletions(-) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f9f3317b5..4ff608c36 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -575,6 +575,17 @@ export class BrowserManager { // and the chrome-runtime shape changes Playwright otherwise triggers) and // three more (--disable-popup-blocking, --disable-component-update, // --disable-default-apps — each a documented automation tell per Patchright). + // macOS 26 (Sequoia) tightened window activation policy: Chromium windows + // spawned by a non-app process close immediately unless the app is properly + // registered with the window server via NSApplicationActivateIgnoringOtherApps. + // --disable-features=MacControlledWindowBehavior suppresses the new OS-level + // window-hiding behavior introduced in macOS 26 while Playwright handles + // activation via its own NSApp startup sequence. + // This flag is a no-op on macOS < 26 and on non-macOS platforms. + if (process.platform === 'darwin') { + launchArgs.push('--disable-features=MacControlledWindowBehavior'); + } + const { STEALTH_IGNORE_DEFAULT_ARGS } = await import('./stealth'); this.context = await chromium.launchPersistentContext(userDataDir, { headless: false, diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 59327b792..e1a9d301d 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -275,14 +275,32 @@ export function buildRestartEnv( } /** macOS only: pull the headed Chromium window to the user's current Space. - * "Google Chrome for Testing" frequently opens behind the active window or on - * another Space — the first thing users read as "I can't see the browser" - * (#1781). Best-effort, fire-and-forget, never throws. The app name is a fixed - * literal (no interpolation). */ + * The window frequently opens behind the active window or on another Space — + * the first thing users read as "I can't see the browser" (#1781). + * + * launchHeaded() patches the Chromium .app's Info.plist to rename it from + * "Google Chrome for Testing" to "GStack Browser" for better branding. That + * patch means the old hard-coded name no longer works for the osascript + * activate call. We try both names: "GStack Browser" first (post-patch), + * then "Google Chrome for Testing" as a fallback (pre-patch / patch skipped). + * + * Best-effort, fire-and-forget, never throws. App names are fixed literals — + * no interpolation. */ function raiseHeadedWindowMacOS(): void { if (process.platform !== 'darwin') return; + // Try GStack Browser (Info.plist patched by launchHeaded) first; fall back + // to the original Playwright bundle name if the rename didn't apply. + const script = [ + 'try', + ' tell application "GStack Browser" to activate', + 'on error', + ' try', + ' tell application "Google Chrome for Testing" to activate', + ' end try', + 'end try', + ].join('\n'); try { - nodeSpawn('osascript', ['-e', 'tell application "Google Chrome for Testing" to activate'], { + nodeSpawn('osascript', ['-e', script], { stdio: 'ignore', detached: true, }).unref(); diff --git a/browse/src/cookie-import-browser.ts b/browse/src/cookie-import-browser.ts index 66328432a..1b3b2cb2d 100644 --- a/browse/src/cookie-import-browser.ts +++ b/browse/src/cookie-import-browser.ts @@ -369,6 +369,17 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch { const match = findBrowserMatch(browser, profile); if (match) return match; + // When the caller requested the default profile ("Default") and it doesn't + // exist, scan for numbered profiles ("Profile 1", "Profile 12", etc.). + // Chrome on macOS 26 and some multi-profile installations never create a + // "Default" directory — every account lands in a numbered profile instead. + // We return the first numbered profile that has a Cookies file, ordered by + // profile number so the result is deterministic. + if (profile === 'Default') { + const numberedMatch = findFirstNumberedProfile(browser); + if (numberedMatch) return numberedMatch; + } + const attempted = getSearchPlatforms() .map(platform => { const dataDir = getDataDirForPlatform(browser, platform); @@ -382,6 +393,62 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch { ); } +/** + * Scan a browser's data directory for numbered profiles ("Profile 1", + * "Profile 2", …) and return a BrowserMatch for the first one (lowest + * number) that contains a Cookies file. + * + * Called as a fallback when the "Default" profile directory doesn't exist. + * This handles Chrome on macOS 26+ and other installations that skip the + * Default directory entirely and put every account in a numbered profile. + */ +function findFirstNumberedProfile(browser: BrowserInfo): BrowserMatch | null { + for (const platform of getSearchPlatforms()) { + const dataDir = getDataDirForPlatform(browser, platform); + if (!dataDir) continue; + const browserDir = path.join(getBaseDir(platform), dataDir); + if (!fs.existsSync(browserDir)) continue; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(browserDir, { withFileTypes: true }); + } catch { + continue; + } + + // Collect all "Profile N" directories and sort by their number so the + // lowest-numbered (usually the primary account) is tried first. + const numbered = entries + .filter(e => e.isDirectory() && /^Profile \d+$/.test(e.name)) + .sort((a, b) => { + const na = parseInt(a.name.slice('Profile '.length), 10); + const nb = parseInt(b.name.slice('Profile '.length), 10); + return na - nb; + }); + + for (const entry of numbered) { + validateProfile(entry.name); + const profileDir = path.join(browserDir, entry.name); + // Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies. + const candidates = platform === 'win32' + ? [path.join(profileDir, 'Network', 'Cookies'), path.join(profileDir, 'Cookies')] + : [path.join(profileDir, 'Cookies')]; + for (const dbPath of candidates) { + try { + if (fs.existsSync(dbPath)) { + return { browser, platform, dbPath }; + } + } catch {} + } + } + + // Found the browser directory on this platform but no numbered profile had + // cookies — don't try other platforms (they'd find the same browser dir). + if (numbered.length > 0) break; + } + return null; +} + // ─── Internal: SQLite Access ──────────────────────────────────── function openDb(dbPath: string, browserName: string): Database { diff --git a/browse/test/cookie-import-browser.test.ts b/browse/test/cookie-import-browser.test.ts index 5e9a5b441..1d8edf76a 100644 --- a/browse/test/cookie-import-browser.test.ts +++ b/browse/test/cookie-import-browser.test.ts @@ -516,4 +516,120 @@ describe('Cookie Import Browser', () => { } }); }); + + describe('Numbered Profile Fallback (macOS 26 / no Default profile)', () => { + // Chrome on macOS 26 and some multi-profile installations place cookies in + // "Profile 4", "Profile 12", etc. instead of "Default". When the caller + // requests the default profile and "Default" doesn't exist, the module + // should fall back to the lowest-numbered profile that has a Cookies file. + + async function withNumberedProfile( + relativeBrowserDir: string, + sourceDb: string, + profileName: string, + run: () => Promise, + ): Promise { + const homeDir = os.homedir(); + const profileDir = path.join(homeDir, relativeBrowserDir, profileName); + const cookiesPath = path.join(profileDir, 'Cookies'); + const hadOriginal = fs.existsSync(cookiesPath); + const backupPath = path.join(profileDir, `Cookies.backup-${crypto.randomUUID()}`); + + fs.mkdirSync(profileDir, { recursive: true }); + if (hadOriginal) fs.copyFileSync(cookiesPath, backupPath); + fs.copyFileSync(sourceDb, cookiesPath); + + // Also ensure no "Default" profile exists (we want the fallback to fire) + const defaultDir = path.join(homeDir, relativeBrowserDir, 'Default'); + const defaultCookies = path.join(defaultDir, 'Cookies'); + const hadDefault = fs.existsSync(defaultCookies); + const defaultBackup = path.join(defaultDir, `Cookies.backup-${crypto.randomUUID()}`); + if (hadDefault) { + fs.copyFileSync(defaultCookies, defaultBackup); + fs.unlinkSync(defaultCookies); + } + + try { + return await run(); + } finally { + if (hadDefault) { + fs.copyFileSync(defaultBackup, defaultCookies); + fs.unlinkSync(defaultBackup); + } + if (hadOriginal) { + fs.copyFileSync(backupPath, cookiesPath); + fs.unlinkSync(backupPath); + } else { + try { fs.unlinkSync(cookiesPath); } catch {} + try { fs.rmdirSync(profileDir); } catch {} + } + } + } + + test('falls back to numbered profile when Default is missing (Linux Chromium)', async () => { + // Place the fixture DB under ~/.config/chromium/Profile 4/Cookies (no Default) + await withNumberedProfile('.config/chromium', LINUX_FIXTURE_DB, 'Profile 4', async () => { + // importCookies uses profile='Default' by default — should fall through to Profile 4 + const result = await importCookies('chromium', ['.linux-plain.com']); + expect(result.count).toBe(1); + expect(result.cookies[0].name).toBe('plain'); + expect(result.cookies[0].value).toBe('plain-linux'); + }); + }); + + test('picks the lowest-numbered profile when multiple exist (Linux Chromium)', async () => { + // Place fixtures under Profile 10 and Profile 4 (Profile 4 should win) + const homeDir = os.homedir(); + const basePath = '.config/chromium'; + const prof4Dir = path.join(homeDir, basePath, 'Profile 4'); + const prof10Dir = path.join(homeDir, basePath, 'Profile 10'); + + // Temporarily ensure Profile 4 has the Linux fixture, Profile 10 has Mac fixture + const had4 = fs.existsSync(path.join(prof4Dir, 'Cookies')); + const had10 = fs.existsSync(path.join(prof10Dir, 'Cookies')); + const bk4 = path.join(prof4Dir, `Cookies.bk-${crypto.randomUUID()}`); + const bk10 = path.join(prof10Dir, `Cookies.bk-${crypto.randomUUID()}`); + + // Disable Default if present + const defaultDir = path.join(homeDir, basePath, 'Default'); + const defaultCookies = path.join(defaultDir, 'Cookies'); + const hadDefault = fs.existsSync(defaultCookies); + const defaultBackup = path.join(defaultDir, `Cookies.bk-${crypto.randomUUID()}`); + if (hadDefault) { + fs.copyFileSync(defaultCookies, defaultBackup); + fs.unlinkSync(defaultCookies); + } + + fs.mkdirSync(prof4Dir, { recursive: true }); + fs.mkdirSync(prof10Dir, { recursive: true }); + if (had4) fs.copyFileSync(path.join(prof4Dir, 'Cookies'), bk4); + if (had10) fs.copyFileSync(path.join(prof10Dir, 'Cookies'), bk10); + fs.copyFileSync(LINUX_FIXTURE_DB, path.join(prof4Dir, 'Cookies')); + fs.copyFileSync(FIXTURE_DB, path.join(prof10Dir, 'Cookies')); // Mac fixture (different domain) + + try { + const result = await importCookies('chromium', ['.linux-plain.com', '.github.com']); + // Profile 4 (Linux fixture) should be selected — it has .linux-plain.com + expect(result.domainCounts['.linux-plain.com']).toBe(1); + // .github.com is only in Profile 10 (Mac fixture, wrong profile), should be absent + expect(result.domainCounts['.github.com']).toBeUndefined(); + } finally { + if (hadDefault) { + fs.copyFileSync(defaultBackup, defaultCookies); + fs.unlinkSync(defaultBackup); + } + if (had4) { fs.copyFileSync(bk4, path.join(prof4Dir, 'Cookies')); fs.unlinkSync(bk4); } + else { try { fs.unlinkSync(path.join(prof4Dir, 'Cookies')); } catch {} try { fs.rmdirSync(prof4Dir); } catch {} } + if (had10) { fs.copyFileSync(bk10, path.join(prof10Dir, 'Cookies')); fs.unlinkSync(bk10); } + else { try { fs.unlinkSync(path.join(prof10Dir, 'Cookies')); } catch {} try { fs.rmdirSync(prof10Dir); } catch {} } + } + }); + + test('listDomains falls back to numbered profile when Default is absent', async () => { + await withNumberedProfile('.config/chromium', LINUX_FIXTURE_DB, 'Profile 12', async () => { + const result = listDomains('chromium'); // no profile arg → defaults to 'Default' + expect(result.domains.map((d: any) => d.domain)).toContain('.linux-plain.com'); + }); + }); + }); }); From 5c7058bb47913151c2e810f66b50c960fdb5d853 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 30 Jun 2026 11:08:28 +0200 Subject: [PATCH 2/2] fix: apply MacControlledWindowBehavior flag to handoff() path on macOS 26 The --disable-features=MacControlledWindowBehavior flag added to launchHeaded() was missing from the handoff() code path (headless -> headed re-launch). The handoff() method builds its own launchArgs array independently, so macOS 26 users who trigger a headless-to-headed handoff would still see the window-close bug even after the launchHeaded() fix. Apply the same darwin guard to the handoff() launchArgs so all headed launch paths suppress the new OS-level window-hiding behavior on Sequoia. --- browse/src/browser-manager.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 4ff608c36..443e9698d 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -1569,6 +1569,13 @@ export class BrowserManager { // STEALTH_LAUNCH_ARGS the handed-off browser kept the AutomationControlled // tell that the other two paths strip. const launchArgs: string[] = ['--hide-crash-restore-bubble', ...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; + // macOS 26: same window-hiding suppression as launchHeaded(). The handoff + // path launches a fresh headed Chromium and is subject to the same + // MacControlledWindowBehavior that causes the window to close immediately + // on Sequoia. No-op on macOS < 26 and on non-macOS platforms. + if (process.platform === 'darwin') { + launchArgs.push('--disable-features=MacControlledWindowBehavior'); + } if (extensionPath) { launchArgs.push(`--disable-extensions-except=${extensionPath}`); launchArgs.push(`--load-extension=${extensionPath}`);