mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-11 07:13:43 +02:00
feat: per-profile window color with id-derived default
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 (
|
||||
<Popover
|
||||
onOpenChange={(open) => {
|
||||
if (!open && color && color !== profile.window_color)
|
||||
void persist(color);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("profileInfo.fields.windowColor")}
|
||||
title={t("profileInfo.fields.windowColor")}
|
||||
className="size-9 shrink-0 cursor-pointer rounded-lg border shadow-sm ring-offset-background transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
style={{ backgroundColor: color ?? DEFAULT_SWATCH_COLOR }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" sideOffset={6} className="w-[264px] p-3">
|
||||
<ColorPicker
|
||||
className="rounded-md border bg-background p-3 shadow-sm"
|
||||
value={color ?? DEFAULT_SWATCH_COLOR}
|
||||
onColorChange={([r, g, b]) => {
|
||||
setColor(Color({ r, g, b }).hex().toLowerCase());
|
||||
}}
|
||||
>
|
||||
<ColorPickerSelection className="h-32 rounded" />
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<ColorPickerEyeDropper />
|
||||
<ColorPickerHue className="flex-1" />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<ColorPickerOutput />
|
||||
<ColorPickerFormat />
|
||||
</div>
|
||||
</ColorPicker>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileInfoDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -820,6 +899,7 @@ function ProfileInfoLayout({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<WindowColorSwatch profile={profile} />
|
||||
</div>
|
||||
|
||||
{/* ID */}
|
||||
|
||||
@@ -1156,7 +1156,8 @@
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies stored",
|
||||
"localDataTransfer": "Local data transfer",
|
||||
"created": "Created"
|
||||
"created": "Created",
|
||||
"windowColor": "Window color"
|
||||
},
|
||||
"values": {
|
||||
"none": "None",
|
||||
|
||||
@@ -1156,7 +1156,8 @@
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies guardadas",
|
||||
"localDataTransfer": "Transferencia de datos local",
|
||||
"created": "Creado"
|
||||
"created": "Creado",
|
||||
"windowColor": "Color de ventana"
|
||||
},
|
||||
"values": {
|
||||
"none": "Ninguno",
|
||||
|
||||
@@ -1156,7 +1156,8 @@
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies stockés",
|
||||
"localDataTransfer": "Transfert de données local",
|
||||
"created": "Créé le"
|
||||
"created": "Créé le",
|
||||
"windowColor": "Couleur de la fenêtre"
|
||||
},
|
||||
"values": {
|
||||
"none": "Aucun",
|
||||
|
||||
@@ -1156,7 +1156,8 @@
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "保存された Cookie",
|
||||
"localDataTransfer": "ローカルデータ転送量",
|
||||
"created": "作成日"
|
||||
"created": "作成日",
|
||||
"windowColor": "ウィンドウの色"
|
||||
},
|
||||
"values": {
|
||||
"none": "なし",
|
||||
|
||||
@@ -1156,7 +1156,8 @@
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "저장된 쿠키",
|
||||
"localDataTransfer": "로컬 데이터 전송",
|
||||
"created": "생성일"
|
||||
"created": "생성일",
|
||||
"windowColor": "창 색상"
|
||||
},
|
||||
"values": {
|
||||
"none": "없음",
|
||||
|
||||
@@ -1156,7 +1156,8 @@
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies armazenados",
|
||||
"localDataTransfer": "Transferência de dados local",
|
||||
"created": "Criado em"
|
||||
"created": "Criado em",
|
||||
"windowColor": "Cor da janela"
|
||||
},
|
||||
"values": {
|
||||
"none": "Nenhum",
|
||||
|
||||
@@ -1156,7 +1156,8 @@
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Хранится Cookie",
|
||||
"localDataTransfer": "Локальный трафик",
|
||||
"created": "Создан"
|
||||
"created": "Создан",
|
||||
"windowColor": "Цвет окна"
|
||||
},
|
||||
"values": {
|
||||
"none": "Нет",
|
||||
|
||||
@@ -1156,7 +1156,8 @@
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookie đã lưu",
|
||||
"localDataTransfer": "Truyền dữ liệu cục bộ",
|
||||
"created": "Đã tạo"
|
||||
"created": "Đã tạo",
|
||||
"windowColor": "Màu cửa sổ"
|
||||
},
|
||||
"values": {
|
||||
"none": "Không có",
|
||||
|
||||
@@ -1156,7 +1156,8 @@
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "存储的 Cookie",
|
||||
"localDataTransfer": "本地数据传输",
|
||||
"created": "创建时间"
|
||||
"created": "创建时间",
|
||||
"windowColor": "窗口颜色"
|
||||
},
|
||||
"values": {
|
||||
"none": "无",
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface BrowserProfile {
|
||||
group_id?: string; // Reference to profile group
|
||||
tags?: string[];
|
||||
note?: string; // User note
|
||||
window_color?: string; // Per-profile window frame color "#RRGGBB"; auto-derived from the id when unset
|
||||
sync_mode?: SyncMode;
|
||||
encryption_salt?: string;
|
||||
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
|
||||
|
||||
Reference in New Issue
Block a user