mirror of https://github.com/garrytan/gstack.git
fix: Chrome numbered-profile cookie discovery + headed window stability on macOS 26
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
This commit is contained in:
parent
11de390be1
commit
c0a9f4cb19
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<T>(
|
||||
relativeBrowserDir: string,
|
||||
sourceDb: string,
|
||||
profileName: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue