From f36ea432217b0bccf131e1a419d89eb0baf557af Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 9 Sep 2026 02:17:18 +0530 Subject: [PATCH 1/2] fix(chrome): debounce profile export independent of open tabs Export cookies ~3s after the last command on a sync-to-chrome profile, even while tabs stay open. Idle-close export remains as a backstop. --- .../local-cloak/session-manager.test.ts | 57 ++++++++++++++++++- .../runtime/local-cloak/session-manager.ts | 26 +++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/browser/runtime/local-cloak/session-manager.test.ts b/src/browser/runtime/local-cloak/session-manager.test.ts index fed87bf5..7ad04258 100644 --- a/src/browser/runtime/local-cloak/session-manager.test.ts +++ b/src/browser/runtime/local-cloak/session-manager.test.ts @@ -1745,6 +1745,11 @@ describe('waitUntil plumbing', () => { }); describe('syncToChrome export on profile close', () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + function chromeManager(overrides: Partial = {}) { const launched = fakeContext(); const launchChromePersistentContext = vi.fn().mockResolvedValue(launched.context); @@ -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(); @@ -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(); + }); }); diff --git a/src/browser/runtime/local-cloak/session-manager.ts b/src/browser/runtime/local-cloak/session-manager.ts index 2bb2d29e..f45d44e2 100644 --- a/src/browser/runtime/local-cloak/session-manager.ts +++ b/src/browser/runtime/local-cloak/session-manager.ts @@ -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; /** @@ -220,6 +221,7 @@ export class CloakSessionManager { private readonly exportCookiesToNativeChrome: typeof defaultExportCookies; private readonly registerNativeChromeProfile: typeof defaultRegisterNativeChromeProfile; private readonly syncExportsInFlight = new Set(); + private readonly chromeSyncExportTimers = new Map>(); private readonly profiles = new Map(); private readonly profileLaunches = new Map>(); private readonly profileLifecycleQueues = new Map>(); @@ -278,6 +280,7 @@ export class CloakSessionManager { if (runtime) { runtime.activeCommands = count; this.cancelProfileIdle(runtime); + this.cancelChromeSyncExport(profileId); } }); try { @@ -291,6 +294,7 @@ export class CloakSessionManager { if (runtime) { runtime.activeCommands = count; this.scheduleProfileIdle(profileId, runtime); + this.scheduleChromeSyncExport(profileId, runtime); } }); } @@ -718,6 +722,8 @@ export class CloakSessionManager { async shutdown(): Promise { 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()]); } @@ -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); @@ -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; From 7943b400347890d0843b33d19b589a2d2a27399d Mon Sep 17 00:00:00 2001 From: Ankit Ranjan Date: Wed, 9 Sep 2026 02:20:01 +0530 Subject: [PATCH 2/2] fix(chrome): set exported profile display name to the webcmd alias Chrome auto-registers exported profiles as "Person N". Wait for the registration process to exit, then write Local State name to the alias. --- src/browser/google-chrome.test.ts | 35 ++++++++++++ src/browser/google-chrome.ts | 32 +++++++++++ .../local-cloak/chrome-profile-export.test.ts | 53 +++++++++++++++++++ .../local-cloak/chrome-profile-export.ts | 14 +++-- 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/src/browser/google-chrome.test.ts b/src/browser/google-chrome.test.ts index 7a027744..9cb04ed7 100644 --- a/src/browser/google-chrome.test.ts +++ b/src/browser/google-chrome.test.ts @@ -10,6 +10,7 @@ import { importChromeCookies, isProfileRegisteredInLocalState, listChromeCookieSources, + setProfileDisplayName, } from './google-chrome.js'; describe('Google Chrome discovery', () => { @@ -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(); + }); +}); diff --git a/src/browser/google-chrome.ts b/src/browser/google-chrome.ts index 4192272a..b9189031 100644 --- a/src/browser/google-chrome.ts +++ b/src/browser/google-chrome.ts @@ -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 } }; + 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. + } +} diff --git a/src/browser/runtime/local-cloak/chrome-profile-export.test.ts b/src/browser/runtime/local-cloak/chrome-profile-export.test.ts index 76d9d6f1..708cb13a 100644 --- a/src/browser/runtime/local-cloak/chrome-profile-export.test.ts +++ b/src/browser/runtime/local-cloak/chrome-profile-export.test.ts @@ -11,6 +11,7 @@ function deps(overrides: Partial = {}): Registe delay: vi.fn().mockImplementation(async (ms: number) => { now += ms; }), now: vi.fn(() => now), platform: 'darwin', + setDisplayName: vi.fn(), ...overrides, }; } @@ -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(); + }); }); diff --git a/src/browser/runtime/local-cloak/chrome-profile-export.ts b/src/browser/runtime/local-cloak/chrome-profile-export.ts index 24ebb64c..529af451 100644 --- a/src/browser/runtime/local-cloak/chrome-profile-export.ts +++ b/src/browser/runtime/local-cloak/chrome-profile-export.ts @@ -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); @@ -16,6 +16,7 @@ export interface RegisterNativeChromeProfileDeps { delay(ms: number): Promise; now(): number; platform: NodeJS.Platform; + setDisplayName(userDataDir: string, profileDirectory: string, name: string): void; } async function launchViaOpen(executablePath: string, args: string[]): Promise { @@ -33,6 +34,7 @@ const defaultDeps: RegisterNativeChromeProfileDeps = { delay: ms => new Promise(resolve => setTimeout(resolve, ms)), now: Date.now, platform: process.platform, + setDisplayName: setProfileDisplayName, }; /** @@ -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 }; }