feat: per-profile window color with id-derived default

This commit is contained in:
zhom
2026-07-07 22:45:36 +04:00
parent 78803ab289
commit 63a1f4c92a
18 changed files with 224 additions and 9 deletions
+1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
+49
View File
@@ -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<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
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<String>,
) -> Result<BrowserProfile, String> {
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.
+2
View File
@@ -50,6 +50,8 @@ pub struct BrowserProfile {
#[serde(default)]
pub note: Option<String>, // User note
#[serde(default)]
pub window_color: Option<String>, // 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<String>,
+2
View File
@@ -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,
+69
View File
@@ -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::*;