Spaces:
Sleeping
Sleeping
| import fs from "node:fs"; | |
| import path from "node:path"; | |
| import { | |
| DEFAULT_OPENCLAW_BROWSER_COLOR, | |
| DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME, | |
| } from "./constants.js"; | |
| function decoratedMarkerPath(userDataDir: string) { | |
| return path.join(userDataDir, ".openclaw-profile-decorated"); | |
| } | |
| function safeReadJson(filePath: string): Record<string, unknown> | null { | |
| try { | |
| if (!fs.existsSync(filePath)) { | |
| return null; | |
| } | |
| const raw = fs.readFileSync(filePath, "utf-8"); | |
| const parsed = JSON.parse(raw) as unknown; | |
| if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { | |
| return null; | |
| } | |
| return parsed as Record<string, unknown>; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function safeWriteJson(filePath: string, data: Record<string, unknown>) { | |
| fs.mkdirSync(path.dirname(filePath), { recursive: true }); | |
| fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); | |
| } | |
| function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) { | |
| let node: Record<string, unknown> = obj; | |
| for (const key of keys.slice(0, -1)) { | |
| const next = node[key]; | |
| if (typeof next !== "object" || next === null || Array.isArray(next)) { | |
| node[key] = {}; | |
| } | |
| node = node[key] as Record<string, 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; | |
| } | |
| export 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<string, unknown>).info_cache | |
| : null; | |
| const info = | |
| typeof infoCache === "object" && | |
| infoCache !== null && | |
| !Array.isArray(infoCache) && | |
| typeof (infoCache as Record<string, unknown>).Default === "object" && | |
| (infoCache as Record<string, unknown>).Default !== null && | |
| !Array.isArray((infoCache as Record<string, unknown>).Default) | |
| ? ((infoCache as Record<string, unknown>).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<string, unknown>).theme | |
| : null; | |
| return typeof theme === "object" && theme !== null && !Array.isArray(theme) | |
| ? (theme as Record<string, unknown>) | |
| : null; | |
| })(); | |
| const autogeneratedTheme = (() => { | |
| const autogenerated = prefs?.autogenerated; | |
| const theme = | |
| typeof autogenerated === "object" && autogenerated !== null && !Array.isArray(autogenerated) | |
| ? (autogenerated as Record<string, unknown>).theme | |
| : null; | |
| return typeof theme === "object" && theme !== null && !Array.isArray(theme) | |
| ? (theme as Record<string, unknown>) | |
| : 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. | |
| */ | |
| export function decorateOpenClawProfile( | |
| userDataDir: string, | |
| opts?: { name?: string; color?: string }, | |
| ) { | |
| const desiredName = opts?.name ?? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME; | |
| const desiredColor = (opts?.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase(); | |
| const desiredColorInt = parseHexRgbToSignedArgbInt(desiredColor); | |
| const localStatePath = path.join(userDataDir, "Local State"); | |
| const preferencesPath = path.join(userDataDir, "Default", "Preferences"); | |
| const localState = safeReadJson(localStatePath) ?? {}; | |
| // Common-ish shape: profile.info_cache.Default | |
| setDeep(localState, ["profile", "info_cache", "Default", "name"], desiredName); | |
| setDeep(localState, ["profile", "info_cache", "Default", "shortcut_name"], desiredName); | |
| setDeep(localState, ["profile", "info_cache", "Default", "user_name"], desiredName); | |
| // Color keys are best-effort (Chrome changes these frequently). | |
| setDeep(localState, ["profile", "info_cache", "Default", "profile_color"], desiredColor); | |
| setDeep(localState, ["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 { | |
| fs.writeFileSync(decoratedMarkerPath(userDataDir), `${Date.now()}\n`, "utf-8"); | |
| } catch { | |
| // ignore | |
| } | |
| } | |
| export function ensureProfileCleanExit(userDataDir: string) { | |
| const preferencesPath = path.join(userDataDir, "Default", "Preferences"); | |
| const prefs = safeReadJson(preferencesPath) ?? {}; | |
| setDeep(prefs, ["exit_type"], "Normal"); | |
| setDeep(prefs, ["exited_cleanly"], true); | |
| safeWriteJson(preferencesPath, prefs); | |
| } | |