From 63a1f4c92adaeb44e94e4b7490b36a5b30884f53 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:45:36 +0400 Subject: [PATCH] feat: per-profile window color with id-derived default --- src-tauri/src/auto_updater.rs | 1 + src-tauri/src/browser.rs | 1 + src-tauri/src/ephemeral_dirs.rs | 1 + src-tauri/src/profile/manager.rs | 49 ++++++++++++++++ src-tauri/src/profile/types.rs | 2 + src-tauri/src/profile_importer.rs | 2 + src-tauri/src/wayfern_manager.rs | 69 ++++++++++++++++++++++ src/components/profile-info-dialog.tsx | 80 ++++++++++++++++++++++++++ src/i18n/locales/en.json | 3 +- src/i18n/locales/es.json | 3 +- src/i18n/locales/fr.json | 3 +- src/i18n/locales/ja.json | 3 +- src/i18n/locales/ko.json | 3 +- src/i18n/locales/pt.json | 3 +- src/i18n/locales/ru.json | 3 +- src/i18n/locales/vi.json | 3 +- src/i18n/locales/zh.json | 3 +- src/types.ts | 1 + 18 files changed, 224 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/auto_updater.rs b/src-tauri/src/auto_updater.rs index 8285613..b4b8c94 100644 --- a/src-tauri/src/auto_updater.rs +++ b/src-tauri/src/auto_updater.rs @@ -691,6 +691,7 @@ mod tests { group_id: None, tags: Vec::new(), note: None, + window_color: None, sync_mode: crate::profile::types::SyncMode::Disabled, encryption_salt: None, last_sync: None, diff --git a/src-tauri/src/browser.rs b/src-tauri/src/browser.rs index f8f3e73..a0addb0 100644 --- a/src-tauri/src/browser.rs +++ b/src-tauri/src/browser.rs @@ -1208,6 +1208,7 @@ mod tests { group_id: None, tags: Vec::new(), note: None, + window_color: None, sync_mode: crate::profile::types::SyncMode::Disabled, encryption_salt: None, last_sync: None, diff --git a/src-tauri/src/ephemeral_dirs.rs b/src-tauri/src/ephemeral_dirs.rs index 68c47cf..04ef1fb 100644 --- a/src-tauri/src/ephemeral_dirs.rs +++ b/src-tauri/src/ephemeral_dirs.rs @@ -269,6 +269,7 @@ mod tests { group_id: None, tags: Vec::new(), note: None, + window_color: None, sync_mode: crate::profile::types::SyncMode::Disabled, encryption_salt: None, last_sync: None, diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index 1021ae3..1b8c1c6 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -188,6 +188,7 @@ impl ProfileManager { group_id: group_id.clone(), tags: Vec::new(), note: None, + window_color: None, sync_mode: SyncMode::Disabled, encryption_salt: None, last_sync: None, @@ -292,6 +293,7 @@ impl ProfileManager { group_id: group_id.clone(), tags: Vec::new(), note: None, + window_color: None, sync_mode: SyncMode::Disabled, encryption_salt: None, last_sync: None, @@ -363,6 +365,9 @@ impl ProfileManager { group_id: group_id.clone(), tags: Vec::new(), note: None, + // A random-looking pastel derived from the (random) profile id, so every + // new profile gets a distinct, stable window color it can later override. + window_color: Some(crate::wayfern_manager::derive_profile_color(&profile_id)), sync_mode: SyncMode::Disabled, encryption_salt: None, last_sync: None, @@ -843,6 +848,38 @@ impl ProfileManager { Ok(profile) } + pub fn update_profile_window_color( + &self, + _app_handle: &tauri::AppHandle, + profile_id: &str, + window_color: Option, + ) -> Result> { + let profile_uuid = + uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?; + let profiles = self.list_profiles()?; + let mut profile = profiles + .into_iter() + .find(|p| p.id == profile_uuid) + .ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?; + + // Normalize to lowercase #RRGGBB, or clear (None) for an invalid/empty value + // so it reverts to the auto id-derived color at next launch. + profile.window_color = window_color.and_then(|c| { + let hex = c.trim().trim_start_matches('#'); + (hex.len() == 6 && hex.chars().all(|ch| ch.is_ascii_hexdigit())) + .then(|| format!("#{}", hex.to_lowercase())) + }); + profile.updated_at = Some(crate::proxy_manager::now_secs()); + + self.save_profile(&profile)?; + crate::sync::queue_profile_sync_if_eligible(&profile); + if let Err(e) = events::emit_empty("profiles-changed") { + log::warn!("Warning: Failed to emit profiles-changed event: {e}"); + } + + Ok(profile) + } + pub fn update_profile_launch_hook( &self, _app_handle: &tauri::AppHandle, @@ -1064,6 +1101,7 @@ impl ProfileManager { group_id: source.group_id, tags: source.tags, note: source.note, + window_color: source.window_color, sync_mode: SyncMode::Disabled, encryption_salt: None, last_sync: None, @@ -2387,6 +2425,17 @@ pub fn update_profile_note( .map_err(|e| format!("Failed to update profile note: {e}")) } +#[tauri::command] +pub fn update_profile_window_color( + app_handle: tauri::AppHandle, + profile_id: String, + window_color: Option, +) -> Result { + ProfileManager::instance() + .update_profile_window_color(&app_handle, &profile_id, window_color) + .map_err(|e| format!("Failed to update profile window color: {e}")) +} + /// Validate a launch hook value. Returns `Ok(None)` for "clear the hook" /// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s) /// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload. diff --git a/src-tauri/src/profile/types.rs b/src-tauri/src/profile/types.rs index 1ef596e..81867dc 100644 --- a/src-tauri/src/profile/types.rs +++ b/src-tauri/src/profile/types.rs @@ -50,6 +50,8 @@ pub struct BrowserProfile { #[serde(default)] pub note: Option, // User note #[serde(default)] + pub window_color: Option, // Per-profile window frame color "#RRGGBB"; auto-derived from the id when unset + #[serde(default)] pub sync_mode: SyncMode, #[serde(default)] pub encryption_salt: Option, diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index e869e67..08123f1 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -317,6 +317,7 @@ impl ProfileImporter { group_id: None, tags: Vec::new(), note: None, + window_color: None, sync_mode: SyncMode::Disabled, encryption_salt: None, last_sync: None, @@ -371,6 +372,7 @@ impl ProfileImporter { group_id: None, tags: Vec::new(), note: None, + window_color: None, sync_mode: SyncMode::Disabled, encryption_salt: None, last_sync: None, diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index d0b78ee..487cc00 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -770,6 +770,39 @@ impl WayfernManager { args.push(format!("--load-extension={}", extension_paths.join(","))); } + // Per-profile window label + distinct frame color so concurrent profile + // windows are easy to tell apart. Wayfern reads these in + // BrowserView::GetWindowTitle() (label) and BrowserFrameView::GetFrameColor() + // (color). The label is the profile name; the color is the user's + // window_color when set, otherwise deterministically derived from the + // profile id so every profile still gets a stable, distinct color. + if !profile.name.is_empty() { + args.push(format!("--wayfern-profile-label={}", profile.name)); + } + // Profiles created before this feature have no stored color; persist the + // id-derived one so the info dialog shows the same frame color the window + // uses. It's deterministic per id, so no updated_at bump/sync is needed. + if profile + .window_color + .as_deref() + .map(str::trim) + .unwrap_or("") + .is_empty() + { + let mut backfilled = profile.clone(); + backfilled.window_color = Some(derive_profile_color(&backfilled.id)); + let _ = crate::profile::ProfileManager::instance().save_profile(&backfilled); + } + let profile_color = profile + .window_color + .clone() + .filter(|c| !c.trim().is_empty()) + .unwrap_or_else(|| derive_profile_color(&profile.id)); + // Wayfern expects the frame color as bare RRGGBB hex, with no leading '#' + // (the stored/user value may include one). + let profile_color = profile_color.trim().trim_start_matches('#'); + args.push(format!("--wayfern-profile-color={profile_color}")); + let mut wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await; if wayfern_token.is_none() && crate::cloud_auth::CLOUD_AUTH @@ -1342,6 +1375,42 @@ lazy_static::lazy_static! { static ref WAYFERN_MANAGER: WayfernManager = WayfernManager::new(); } +/// Deterministically derive a pleasant, distinct window frame color from a +/// profile id so concurrent profile windows are visually distinguishable even +/// when the user has not picked a custom color. Stable per profile (same id +/// always yields the same color). Returns "#RRGGBB". +pub fn derive_profile_color(id: &uuid::Uuid) -> String { + // FNV-1a over the 16 id bytes -> hue in [0,360). The hue varies per profile + // while saturation/lightness are fixed to a pastel band (see below). + let mut h: u32 = 2166136261; + for &b in id.as_bytes() { + h = (h ^ u32::from(b)).wrapping_mul(16777619); + } + let hue = f64::from(h % 360); + // Pastel: high lightness + soft saturation so windows stay easy to tell apart + // without a garish frame. + let (r, g, b) = hsl_to_rgb(hue, 0.6, 0.8); + format!("#{r:02x}{g:02x}{b:02x}") +} + +/// Convert HSL (h in [0,360), s/l in [0,1]) to 8-bit RGB. +fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let hp = h / 60.0; + let x = c * (1.0 - (hp % 2.0 - 1.0).abs()); + let (r1, g1, b1) = match hp as i32 { + 0 => (c, x, 0.0), + 1 => (x, c, 0.0), + 2 => (0.0, c, x), + 3 => (0.0, x, c), + 4 => (x, 0.0, c), + _ => (c, 0.0, x), + }; + let m = l - c / 2.0; + let to_u8 = |v: f64| ((v + m) * 255.0).round().clamp(0.0, 255.0) as u8; + (to_u8(r1), to_u8(g1), to_u8(b1)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 03831a9..df58615 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { save } from "@tauri-apps/plugin-dialog"; import { writeTextFile } from "@tauri-apps/plugin-fs"; +import Color from "color"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { FaApple, FaLinux, FaWindows } from "react-icons/fa"; @@ -34,6 +35,14 @@ import { } from "react-icons/lu"; import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form"; import { Button } from "@/components/ui/button"; +import { + ColorPicker, + ColorPickerEyeDropper, + ColorPickerFormat, + ColorPickerHue, + ColorPickerOutput, + ColorPickerSelection, +} from "@/components/ui/color-picker"; import { Dialog, DialogContent, @@ -49,6 +58,11 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, @@ -183,6 +197,71 @@ function LocalDataTransferCard({ ); } +// Shown only for legacy profiles that predate the feature and have no stored +// color yet (new profiles get a backend-derived one at creation/launch). +const DEFAULT_SWATCH_COLOR = "#94a3b8"; + +function WindowColorSwatch({ profile }: { profile: BrowserProfile }) { + const { t } = useTranslation(); + const [color, setColor] = React.useState(profile.window_color); + + React.useEffect(() => { + setColor(profile.window_color); + }, [profile.window_color]); + + const persist = React.useCallback( + async (hex: string) => { + try { + await invoke("update_profile_window_color", { + profileId: profile.id, + windowColor: hex, + }); + } catch { + setColor(profile.window_color); + } + }, + [profile.id, profile.window_color], + ); + + return ( + { + if (!open && color && color !== profile.window_color) + void persist(color); + }} + > + +