diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f9f3317b5..443e9698d 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, @@ -1558,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}`); 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'); + }); + }); + }); });