Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/browser/google-chrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
importChromeCookies,
isProfileRegisteredInLocalState,
listChromeCookieSources,
setProfileDisplayName,
} from './google-chrome.js';

describe('Google Chrome discovery', () => {
Expand Down Expand Up @@ -258,3 +259,37 @@ describe('isProfileRegisteredInLocalState', () => {
expect(isProfileRegisteredInLocalState(chromeRoot, 'webcmd-work', { readFileSync })).toBe(false);
});
});

describe('setProfileDisplayName', () => {
const chromeRoot = path.join('Users', 'test', 'Chrome');
const localStatePath = path.join(chromeRoot, 'Local State');

it('sets the name when the entry exists', () => {
const writeFileSync = vi.fn();
setProfileDisplayName(chromeRoot, 'test1', 'test1', {
readFileSync: (() => JSON.stringify({ profile: { info_cache: { test1: { name: 'Person 2' } } } })) as unknown as typeof import('node:fs').readFileSync,
writeFileSync: writeFileSync as unknown as typeof import('node:fs').writeFileSync,
});
expect(writeFileSync).toHaveBeenCalledOnce();
expect(writeFileSync.mock.calls[0][0]).toBe(localStatePath);
expect(JSON.parse(writeFileSync.mock.calls[0][1] as string).profile.info_cache.test1.name).toBe('test1');
});

it('leaves the file alone when the profileDirectory key is not in info_cache', () => {
const writeFileSync = vi.fn();
setProfileDisplayName(chromeRoot, 'test1', 'test1', {
readFileSync: (() => JSON.stringify({ profile: { info_cache: { Default: { name: 'Person 1' } } } })) as unknown as typeof import('node:fs').readFileSync,
writeFileSync: writeFileSync as unknown as typeof import('node:fs').writeFileSync,
});
expect(writeFileSync).not.toHaveBeenCalled();
});

it('does not throw when Local State is missing, unreadable, or malformed JSON', () => {
expect(() => setProfileDisplayName(chromeRoot, 'test1', 'test1', {
readFileSync: (() => { throw new Error('ENOENT'); }) as unknown as typeof import('node:fs').readFileSync,
})).not.toThrow();
expect(() => setProfileDisplayName(chromeRoot, 'test1', 'test1', {
readFileSync: (() => '{not json') as unknown as typeof import('node:fs').readFileSync,
})).not.toThrow();
});
});
32 changes: 32 additions & 0 deletions src/browser/google-chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,35 @@ export function isProfileRegisteredInLocalState(
return false;
}
}

export interface SetProfileDisplayNameOptions {
readFileSync?: typeof fs.readFileSync;
writeFileSync?: typeof fs.writeFileSync;
}

/**
* Sets a native Chrome profile's display name in Local State to exactly
* `name`. Best-effort and silent on any failure — this is cosmetic only;
* the profile still works correctly with Chrome's own generic name if this
* fails or never runs.
*/
export function setProfileDisplayName(
userDataDir: string,
profileDirectory: string,
name: string,
opts: SetProfileDisplayNameOptions = {},
): void {
const readFileSync = opts.readFileSync ?? fs.readFileSync;
const writeFileSync = opts.writeFileSync ?? fs.writeFileSync;
try {
const localStatePath = path.join(userDataDir, 'Local State');
const raw = readFileSync(localStatePath, 'utf-8');
const parsed = JSON.parse(raw) as { profile?: { info_cache?: Record<string, { name?: unknown }> } };
const entry = parsed.profile?.info_cache?.[profileDirectory];
if (!entry) return;
entry.name = name;
writeFileSync(localStatePath, JSON.stringify(parsed), 'utf-8');
} catch {
// Missing/unreadable/malformed Local State: leave the generic name in place.
}
}
53 changes: 53 additions & 0 deletions src/browser/runtime/local-cloak/chrome-profile-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ function deps(overrides: Partial<RegisterNativeChromeProfileDeps> = {}): Registe
delay: vi.fn().mockImplementation(async (ms: number) => { now += ms; }),
now: vi.fn(() => now),
platform: 'darwin',
setDisplayName: vi.fn(),
...overrides,
};
}
Expand Down Expand Up @@ -75,4 +76,56 @@ describe('registerNativeChromeProfile', () => {
expect(result).toEqual({ registered: true });
expect(d.terminate).not.toHaveBeenCalled();
});

it('sets the Chrome display name to the alias after killing a spawned registration process', async () => {
const d = deps({
isRegistered: vi.fn().mockReturnValue(true),
findProcesses: vi.fn().mockResolvedValue([4242]),
});

await registerNativeChromeProfile(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Users/test/Chrome',
'test1',
d,
);

expect(d.delay).toHaveBeenCalledWith(300);
expect(d.setDisplayName).toHaveBeenCalledWith('/Users/test/Chrome', 'test1', 'test1');
});

// Known limitation: Chrome already running absorbed the request, so the name write is best-effort and may not stick.
it('still attempts setDisplayName when no process was spawned (absorbed into existing Chrome; best-effort, may not stick)', async () => {
const d = deps({
isRegistered: vi.fn().mockReturnValue(true),
findProcesses: vi.fn().mockResolvedValue([]),
});

await registerNativeChromeProfile(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Users/test/Chrome',
'test1',
d,
);

expect(d.delay).not.toHaveBeenCalledWith(300);
expect(d.setDisplayName).toHaveBeenCalledWith('/Users/test/Chrome', 'test1', 'test1');
});

it('does not set the display name when registration never succeeded', async () => {
const d = deps({
isRegistered: vi.fn().mockReturnValue(false),
findProcesses: vi.fn().mockResolvedValue([9]),
});

const result = await registerNativeChromeProfile(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Users/test/Chrome',
'test1',
d,
);

expect(result).toEqual({ registered: false });
expect(d.setDisplayName).not.toHaveBeenCalled();
});
});
14 changes: 10 additions & 4 deletions src/browser/runtime/local-cloak/chrome-profile-export.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { execFile } from 'node:child_process';
import path from 'node:path';
import { promisify } from 'node:util';
import { isProfileRegisteredInLocalState } from '../../google-chrome.js';
import { isProfileRegisteredInLocalState, setProfileDisplayName } from '../../google-chrome.js';
import { findExactChromeProcesses, terminateChromeProcessTree } from './chrome-process.js';

const execFileAsync = promisify(execFile);
Expand All @@ -16,6 +16,7 @@ export interface RegisterNativeChromeProfileDeps {
delay(ms: number): Promise<void>;
now(): number;
platform: NodeJS.Platform;
setDisplayName(userDataDir: string, profileDirectory: string, name: string): void;
}

async function launchViaOpen(executablePath: string, args: string[]): Promise<void> {
Expand All @@ -33,6 +34,7 @@ const defaultDeps: RegisterNativeChromeProfileDeps = {
delay: ms => new Promise(resolve => setTimeout(resolve, ms)),
now: Date.now,
platform: process.platform,
setDisplayName: setProfileDisplayName,
};

/**
Expand Down Expand Up @@ -65,8 +67,12 @@ export async function registerNativeChromeProfile(
registered = deps.isRegistered(userDataDir, profileDirectory);
}

for (const pid of await deps.findProcesses(identity, deps.platform)) {
await deps.terminate(pid, deps.platform, false);
}
const pids = await deps.findProcesses(identity, deps.platform);
for (const pid of pids) await deps.terminate(pid, deps.platform, false);
if (pids.length > 0) await deps.delay(300);
// Best-effort: if Chrome was already running, this write was absorbed into
// that process and Chrome may later flush its in-memory Local State and
// clobber the name. Cosmetic only — never fail the export over it.
if (registered) deps.setDisplayName(userDataDir, profileDirectory, profileDirectory);
return { registered };
}
57 changes: 56 additions & 1 deletion src/browser/runtime/local-cloak/session-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1745,6 +1745,11 @@ describe('waitUntil plumbing', () => {
});

describe('syncToChrome export on profile close', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});

function chromeManager(overrides: Partial<CloakSessionManagerOptions> = {}) {
const launched = fakeContext();
const launchChromePersistentContext = vi.fn().mockResolvedValue(launched.context);
Expand All @@ -1763,9 +1768,11 @@ describe('syncToChrome export on profile close', () => {
registerNativeChromeProfile,
...overrides,
});
return { manager, ensureNativeProfileDirectory, exportCookiesToNativeChrome, registerNativeChromeProfile };
return { manager, launched, ensureNativeProfileDirectory, exportCookiesToNativeChrome, registerNativeChromeProfile };
}

const key = { profileId: 'webcmd-work', session: 's1', surface: 'browser' as const };

it('exports cookies and registers the profile once its runtime fully closes', async () => {
const { manager, ensureNativeProfileDirectory, exportCookiesToNativeChrome, registerNativeChromeProfile } = chromeManager();

Expand Down Expand Up @@ -1798,4 +1805,52 @@ describe('syncToChrome export on profile close', () => {

expect(exportCookiesToNativeChrome).not.toHaveBeenCalled();
});

it('exports cookies ~3s after a command even while a tab is still open', async () => {
vi.useFakeTimers();
const { manager, exportCookiesToNativeChrome } = chromeManager();

await manager.runWithProfileActivity('webcmd-work', () => manager.getPage(key));

expect(exportCookiesToNativeChrome).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(3_000);
expect(exportCookiesToNativeChrome).toHaveBeenCalledOnce();
});

it('collapses a burst of commands on the same profile into one export', async () => {
vi.useFakeTimers();
const { manager, exportCookiesToNativeChrome } = chromeManager();

await manager.runWithProfileActivity('webcmd-work', () => manager.getPage(key));
await vi.advanceTimersByTimeAsync(1_000);
await manager.runWithProfileActivity('webcmd-work', () => manager.getPage(key));
await vi.advanceTimersByTimeAsync(1_000);
await manager.runWithProfileActivity('webcmd-work', () => manager.getPage(key));

expect(exportCookiesToNativeChrome).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(3_000);
expect(exportCookiesToNativeChrome).toHaveBeenCalledOnce();
});

it('does not close the browser context or pages when the debounced export fires', async () => {
vi.useFakeTimers();
const { manager, launched, exportCookiesToNativeChrome } = chromeManager();

await manager.runWithProfileActivity('webcmd-work', () => manager.getPage(key));
await vi.advanceTimersByTimeAsync(3_000);

expect(exportCookiesToNativeChrome).toHaveBeenCalledOnce();
expect(launched.context.close).not.toHaveBeenCalled();
expect(launched.page.close).not.toHaveBeenCalled();
});

it('does not export on debounce for a non-syncToChrome profile', async () => {
vi.useFakeTimers();
const { manager, exportCookiesToNativeChrome } = chromeManager({ syncToChrome: false });

await manager.runWithProfileActivity('webcmd-work', () => manager.getPage(key));
await vi.advanceTimersByTimeAsync(10_000);

expect(exportCookiesToNativeChrome).not.toHaveBeenCalled();
});
});
26 changes: 26 additions & 0 deletions src/browser/runtime/local-cloak/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const UNRESOLVED = Symbol('unresolved');
const TARGET_PAGE_MATCH_TIMEOUT_MS = 1_000;
export const PROFILE_IDLE_TIMEOUT_MS = 60_000;
export const PROFILE_CLOSE_TIMEOUT_MS = 3_000;
export const CHROME_SYNC_EXPORT_DEBOUNCE_MS = 3_000;
let cachedCloakBrowserVersion: string | undefined | typeof UNRESOLVED = UNRESOLVED;

/**
Expand Down Expand Up @@ -220,6 +221,7 @@ export class CloakSessionManager {
private readonly exportCookiesToNativeChrome: typeof defaultExportCookies;
private readonly registerNativeChromeProfile: typeof defaultRegisterNativeChromeProfile;
private readonly syncExportsInFlight = new Set<string>();
private readonly chromeSyncExportTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly profiles = new Map<string, ProfileRuntime>();
private readonly profileLaunches = new Map<string, Promise<ProfileRuntime>>();
private readonly profileLifecycleQueues = new Map<string, Promise<void>>();
Expand Down Expand Up @@ -278,6 +280,7 @@ export class CloakSessionManager {
if (runtime) {
runtime.activeCommands = count;
this.cancelProfileIdle(runtime);
this.cancelChromeSyncExport(profileId);
}
});
try {
Expand All @@ -291,6 +294,7 @@ export class CloakSessionManager {
if (runtime) {
runtime.activeCommands = count;
this.scheduleProfileIdle(profileId, runtime);
this.scheduleChromeSyncExport(profileId, runtime);
}
});
}
Expand Down Expand Up @@ -718,6 +722,8 @@ export class CloakSessionManager {

async shutdown(): Promise<void> {
this.shuttingDown = true;
for (const timer of this.chromeSyncExportTimers.values()) clearTimeout(timer);
this.chromeSyncExportTimers.clear();
while (this.profileLaunches.size > 0) {
await Promise.allSettled([...this.profileLaunches.values()]);
}
Expand Down Expand Up @@ -837,6 +843,7 @@ export class CloakSessionManager {
if (runtime.disposed) return;
runtime.disposed = true;
this.cancelProfileIdle(runtime);
this.cancelChromeSyncExport(runtime.profileId);
for (const entry of runtime.targetPages.values()) {
if (entry.idleTimer) clearTimeout(entry.idleTimer);
this.networkCapture.stop(entry.page);
Expand Down Expand Up @@ -967,6 +974,25 @@ export class CloakSessionManager {
}
}

private scheduleChromeSyncExport(profileId: string, runtime: ProfileRuntime): void {
if (this.opts.runtimeKind !== 'chrome' || this.opts.syncToChrome !== true) return;
this.cancelChromeSyncExport(profileId);
const timer = setTimeout(() => {
this.chromeSyncExportTimers.delete(profileId);
this.scheduleChromeExport(profileId, runtime);
}, CHROME_SYNC_EXPORT_DEBOUNCE_MS);
timer.unref?.();
this.chromeSyncExportTimers.set(profileId, timer);
}

private cancelChromeSyncExport(profileId: string): void {
const timer = this.chromeSyncExportTimers.get(profileId);
if (timer) {
clearTimeout(timer);
this.chromeSyncExportTimers.delete(profileId);
}
}

private scheduleChromeExport(profileId: string, runtime: ProfileRuntime): void {
if (this.opts.runtimeKind !== 'chrome' || this.opts.syncToChrome !== true) return;
if (this.syncExportsInFlight.has(profileId)) return;
Expand Down
Loading