mirror of https://github.com/garrytan/gstack.git
Merge 5c7058bb47 into a3259400a3
This commit is contained in:
commit
eaa57d4ac2
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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