diff --git a/src/browser/chrome.ts b/src/browser/chrome.ts index 727988dd7..fff4d91e4 100644 --- a/src/browser/chrome.ts +++ b/src/browser/chrome.ts @@ -121,6 +121,93 @@ function setDeep(obj: Record, keys: string[], value: unknown) { node[keys[keys.length - 1] ?? ""] = value; } +function parseHexRgbToSignedArgbInt(hex: string): number | null { + const cleaned = hex.trim().replace(/^#/, ""); + if (!/^[0-9a-fA-F]{6}$/.test(cleaned)) return null; + const rgb = Number.parseInt(cleaned, 16); + const argbUnsigned = (0xff << 24) | rgb; + // Chrome stores colors as signed 32-bit ints (SkColor). + return argbUnsigned > 0x7fffffff + ? argbUnsigned - 0x1_0000_0000 + : argbUnsigned; +} + +function isProfileDecorated( + userDataDir: string, + desiredName: string, + desiredColorHex: string, +): boolean { + const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColorHex); + + const localStatePath = path.join(userDataDir, "Local State"); + const preferencesPath = path.join(userDataDir, "Default", "Preferences"); + + const localState = safeReadJson(localStatePath); + const profile = localState?.profile; + const infoCache = + typeof profile === "object" && profile !== null && !Array.isArray(profile) + ? (profile as Record).info_cache + : null; + const info = + typeof infoCache === "object" && + infoCache !== null && + !Array.isArray(infoCache) && + typeof (infoCache as Record).Default === "object" && + (infoCache as Record).Default !== null && + !Array.isArray((infoCache as Record).Default) + ? ((infoCache as Record).Default as Record< + string, + unknown + >) + : null; + + const prefs = safeReadJson(preferencesPath); + const browserTheme = (() => { + const browser = prefs?.browser; + const theme = + typeof browser === "object" && browser !== null && !Array.isArray(browser) + ? (browser as Record).theme + : null; + return typeof theme === "object" && theme !== null && !Array.isArray(theme) + ? (theme as Record) + : null; + })(); + + const autogeneratedTheme = (() => { + const autogenerated = prefs?.autogenerated; + const theme = + typeof autogenerated === "object" && + autogenerated !== null && + !Array.isArray(autogenerated) + ? (autogenerated as Record).theme + : null; + return typeof theme === "object" && theme !== null && !Array.isArray(theme) + ? (theme as Record) + : null; + })(); + + const nameOk = + typeof info?.name === "string" ? info.name === desiredName : true; + + if (desiredColorInt == null) { + // If the user provided a non-#RRGGBB value, we can only do best-effort. + return nameOk; + } + + const localSeedOk = + typeof info?.profile_color_seed === "number" + ? info.profile_color_seed === desiredColorInt + : false; + + const prefOk = + (typeof browserTheme?.user_color2 === "number" && + browserTheme.user_color2 === desiredColorInt) || + (typeof autogeneratedTheme?.color === "number" && + autogeneratedTheme.color === desiredColorInt); + + return nameOk && localSeedOk && prefOk; +} + /** * Best-effort profile decoration (name + lobster-orange). Chrome preference keys * vary by version; we keep this conservative and idempotent. @@ -133,6 +220,7 @@ export function decorateClawdProfile( const desiredColor = ( opts?.color ?? DEFAULT_CLAWD_BROWSER_COLOR ).toUpperCase(); + const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor); const localStatePath = path.join(userDataDir, "Local State"); const preferencesPath = path.join(userDataDir, "Default", "Preferences"); @@ -165,12 +253,41 @@ export function decorateClawdProfile( ["profile", "info_cache", "Default", "user_color"], desiredColor, ); + if (desiredColorInt != null) { + // These are the fields Chrome actually uses for profile/avatar tinting. + setDeep( + localState, + ["profile", "info_cache", "Default", "profile_color_seed"], + desiredColorInt, + ); + setDeep( + localState, + ["profile", "info_cache", "Default", "profile_highlight_color"], + desiredColorInt, + ); + setDeep( + localState, + ["profile", "info_cache", "Default", "default_avatar_fill_color"], + desiredColorInt, + ); + setDeep( + localState, + ["profile", "info_cache", "Default", "default_avatar_stroke_color"], + desiredColorInt, + ); + } safeWriteJson(localStatePath, localState); const prefs = safeReadJson(preferencesPath) ?? {}; setDeep(prefs, ["profile", "name"], desiredName); setDeep(prefs, ["profile", "profile_color"], desiredColor); setDeep(prefs, ["profile", "user_color"], desiredColor); + if (desiredColorInt != null) { + // Chrome refresh stores the autogenerated theme in these prefs (SkColor ints). + setDeep(prefs, ["autogenerated", "theme", "color"], desiredColorInt); + // User-selected browser theme color (pref name: browser.theme.user_color2). + setDeep(prefs, ["browser", "theme", "user_color2"], desiredColorInt); + } safeWriteJson(preferencesPath, prefs); try { @@ -218,8 +335,11 @@ export async function launchClawdChrome( const userDataDir = resolveClawdUserDataDir(); fs.mkdirSync(userDataDir, { recursive: true }); - const marker = decoratedMarkerPath(userDataDir); - const needsDecorate = !exists(marker); + const needsDecorate = !isProfileDecorated( + userDataDir, + DEFAULT_CLAWD_BROWSER_PROFILE_NAME, + (resolved.color ?? DEFAULT_CLAWD_BROWSER_COLOR).toUpperCase(), + ); // First launch to create preference files if missing, then decorate and relaunch. const spawnOnce = () => { @@ -255,23 +375,33 @@ export async function launchClawdChrome( }; const startedAt = Date.now(); - let proc = spawnOnce(); - // If this is the first run, let Chrome create prefs, then decorate + restart. - if (needsDecorate) { - const deadline = Date.now() + 5000; + const localStatePath = path.join(userDataDir, "Local State"); + const preferencesPath = path.join(userDataDir, "Default", "Preferences"); + const needsBootstrap = !exists(localStatePath) || !exists(preferencesPath); + + // If the profile doesn't exist yet, bootstrap it once so Chrome creates defaults. + // Then decorate (if needed) before the "real" run. + if (needsBootstrap) { + const bootstrap = spawnOnce(); + const deadline = Date.now() + 10_000; while (Date.now() < deadline) { - const localStatePath = path.join(userDataDir, "Local State"); - const preferencesPath = path.join(userDataDir, "Default", "Preferences"); if (exists(localStatePath) && exists(preferencesPath)) break; await new Promise((r) => setTimeout(r, 100)); } try { - proc.kill("SIGTERM"); + bootstrap.kill("SIGTERM"); } catch { // ignore } - await new Promise((r) => setTimeout(r, 300)); + const exitDeadline = Date.now() + 5000; + while (Date.now() < exitDeadline) { + if (bootstrap.exitCode != null) break; + await new Promise((r) => setTimeout(r, 50)); + } + } + + if (needsDecorate) { try { decorateClawdProfile(userDataDir, { color: resolved.color }); logInfo( @@ -284,9 +414,10 @@ export async function launchClawdChrome( runtime, ); } - proc = spawnOnce(); } + const proc = spawnOnce(); + // Wait for CDP to come up. const readyDeadline = Date.now() + 15_000; while (Date.now() < readyDeadline) {