refactor: cleanup

This commit is contained in:
zhom
2026-04-08 12:48:42 +04:00
parent a0205aafa9
commit 3f1f11001e
17 changed files with 535 additions and 312 deletions
+1
View File
@@ -1767,6 +1767,7 @@ dependencies = [
"serde_yaml",
"serial_test",
"sha1",
"sha2",
"smoltcp",
"sys-locale",
"sysinfo",
+1
View File
@@ -85,6 +85,7 @@ aes = "0.8"
cbc = "0.1"
pbkdf2 = "0.12"
sha1 = "0.10"
sha2 = "0.10"
hyper = { version = "1.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
+330 -39
View File
@@ -13,14 +13,25 @@ use tauri::AppHandle;
/// `encrypted_value` is empty, regardless of what other cookies store.
pub mod chrome_decrypt {
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
use sha2::{Digest, Sha256};
use std::path::Path;
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
/// PBKDF2 iteration count for deriving the AES key from the password stored
/// in `os_crypt_key`. Must match Chromium's `OSCryptImpl` on each platform:
/// macOS uses 1003 iterations, Linux uses 1. Getting this wrong produces a
/// different AES key → silent decryption failure → empty cookie values.
/// See `components/os_crypt/sync/os_crypt_{mac.mm,linux.cc}` in Chromium.
#[cfg(target_os = "macos")]
const PBKDF2_ITERATIONS: u32 = 1003;
#[cfg(not(target_os = "macos"))]
const PBKDF2_ITERATIONS: u32 = 1;
const KEY_LEN: usize = 16; // AES-128
const SALT: &[u8] = b"saltysalt";
const IV: [u8; 16] = [b' '; 16]; // 16 spaces
const HOST_HASH_LEN: usize = 32; // SHA-256 output length
fn derive_key(password: &[u8]) -> [u8; KEY_LEN] {
let mut key = [0u8; KEY_LEN];
@@ -29,49 +40,43 @@ pub mod chrome_decrypt {
}
/// Get the encryption key for Chrome cookies.
/// Wayfern stores os_crypt_key as a file inside the profile's user-data-dir on all platforms.
/// On macOS/Linux the key is a base64 string used as PBKDF2 password.
///
/// Wayfern stores `os_crypt_key` as a plain file inside the profile's
/// user-data-dir on all platforms (see the wayfern patches for
/// `os_crypt_mac.mm` and `os_crypt_linux.cc`). The file contains a
/// base64-encoded 128-bit random value that is used as the PBKDF2
/// password — not as the raw AES key — matching Chromium's
/// `OSCryptImpl::DeriveKey` flow.
///
/// If the file is missing we return `None`. We must NEVER fall back to the
/// real macOS Keychain or any other system credential store. Wayfern
/// profiles are fully self-contained and reaching into another app's entry
/// would trigger the macOS "confidential information stored in …" prompt
/// and the "prevented from modifying other apps" warning.
pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> {
let key_file = profile_data_path.join("os_crypt_key");
if let Ok(contents) = std::fs::read_to_string(&key_file) {
let contents = contents.trim();
if !contents.is_empty() {
return Some(derive_key(contents.as_bytes()));
}
// Read as raw bytes and do NOT trim — Chromium's `ReadFileToString`
// passes the exact file contents to `Pbkdf2(file_contents)`. Any
// normalisation we do here would produce a different derived key.
let contents = std::fs::read(&key_file).ok()?;
if contents.is_empty() {
return None;
}
// Fallback for macOS: try system Keychain (for profiles created before file-based keys)
#[cfg(target_os = "macos")]
{
let output = std::process::Command::new("security")
.args([
"find-generic-password",
"-w",
"-s",
"Chromium Safe Storage",
"-a",
"Chromium",
])
.output()
.ok()?;
if output.status.success() {
let password = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !password.is_empty() {
return Some(derive_key(password.as_bytes()));
}
}
}
None
Some(derive_key(&contents))
}
/// Decrypt a Chrome encrypted cookie value.
/// Chromium prefixes encrypted values with "v10" (macOS) or "v11" (Linux).
pub fn decrypt(encrypted: &[u8], key: &[u8; KEY_LEN]) -> Option<String> {
///
/// Chromium prefixes encrypted values with "v10" / "v11" and, since ~M100,
/// prepends `SHA-256(host_key)` to the plaintext before encryption as an
/// integrity check. After decryption we verify and strip those 32 bytes
/// when present. Passing `host_key` is required to do that verification —
/// without it we'd return 32 bytes of hash noise plus the actual value,
/// which is not valid UTF-8 and gets thrown away.
pub fn decrypt(encrypted: &[u8], host_key: &str, key: &[u8; KEY_LEN]) -> Option<String> {
if encrypted.len() < 3 {
return None;
}
// Check for v10/v11 prefix
let prefix = &encrypted[..3];
if prefix != b"v10" && prefix != b"v11" {
return None;
@@ -86,6 +91,16 @@ pub mod chrome_decrypt {
.decrypt_padded_mut::<Pkcs7>(&mut buf)
.ok()?;
// Strip the SHA-256(host_key) integrity prefix if present. Older cookies
// (pre-M100) didn't have this prefix, so we fall back to the raw bytes
// when the first 32 bytes don't match the expected hash.
if decrypted.len() >= HOST_HASH_LEN {
let expected: [u8; HOST_HASH_LEN] = Sha256::digest(host_key.as_bytes()).into();
if decrypted[..HOST_HASH_LEN] == expected {
return String::from_utf8(decrypted[HOST_HASH_LEN..].to_vec()).ok();
}
}
String::from_utf8(decrypted.to_vec()).ok()
}
}
@@ -166,7 +181,7 @@ impl CookieManager {
chrome_decrypt::get_encryption_key(&profile_data_path)
}
/// Get the cookie database path for a profile
/// Get the cookie database path for a profile (read-side: errors if missing).
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
@@ -194,6 +209,122 @@ impl CookieManager {
}
}
/// Get the cookie database path for a profile, creating an empty
/// browser-compatible database if it doesn't exist yet. Use this for write
/// paths (copy / import) so we can populate the cookie store of a profile
/// that has never been launched.
fn ensure_cookie_db_path(
profile: &BrowserProfile,
profiles_dir: &Path,
) -> Result<PathBuf, String> {
let profile_data_path = profile.get_profile_data_path(profiles_dir);
match profile.browser.as_str() {
"wayfern" => {
let path = profile_data_path.join("Default").join("Cookies");
if !path.exists() {
Self::create_empty_chrome_cookies_db(&path)?;
}
Ok(path)
}
"camoufox" => {
let path = profile_data_path.join("cookies.sqlite");
if !path.exists() {
Self::create_empty_firefox_cookies_db(&path)?;
}
Ok(path)
}
_ => Err(format!(
"Unsupported browser type for cookie operations: {}",
profile.browser
)),
}
}
/// Create an empty Chromium-format Cookies SQLite database at `path`.
///
/// Schema matches what recent Chromium versions write on first launch:
/// the `cookies` table, the `meta` table with version info, and the
/// `host_key/top_frame_site_key/name/path` unique index. Chromium's cookie
/// store migration code will upgrade this forward when Wayfern first
/// launches the profile.
fn create_empty_chrome_cookies_db(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create cookie directory: {e}"))?;
}
let conn =
Connection::open(path).map_err(|e| format!("Failed to create cookie database: {e}"))?;
conn
.execute_batch(
"CREATE TABLE cookies(
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
encrypted_value BLOB NOT NULL DEFAULT '',
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL,
last_access_utc INTEGER NOT NULL,
has_expires INTEGER NOT NULL DEFAULT 1,
is_persistent INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1,
samesite INTEGER NOT NULL DEFAULT -1,
source_scheme INTEGER NOT NULL DEFAULT 0,
source_port INTEGER NOT NULL DEFAULT -1,
last_update_utc INTEGER NOT NULL DEFAULT 0,
source_type INTEGER NOT NULL DEFAULT 0,
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX cookies_unique_index
ON cookies(host_key, top_frame_site_key, name, path);
CREATE TABLE meta(
key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY,
value LONGVARCHAR
);
INSERT INTO meta VALUES('version', '23');
INSERT INTO meta VALUES('last_compatible_version', '23');",
)
.map_err(|e| format!("Failed to initialize cookie database schema: {e}"))?;
Ok(())
}
/// Create an empty Firefox-format cookies.sqlite database at `path`.
fn create_empty_firefox_cookies_db(path: &Path) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create cookie directory: {e}"))?;
}
let conn =
Connection::open(path).map_err(|e| format!("Failed to create cookie database: {e}"))?;
conn
.execute_batch(
"CREATE TABLE moz_cookies (
id INTEGER PRIMARY KEY,
originAttributes TEXT NOT NULL DEFAULT '',
name TEXT,
value TEXT,
host TEXT,
path TEXT,
expiry INTEGER,
lastAccessed INTEGER,
creationTime INTEGER,
isSecure INTEGER,
isHttpOnly INTEGER,
inBrowserElement INTEGER DEFAULT 0,
sameSite INTEGER DEFAULT 0,
rawSameSite INTEGER DEFAULT 0,
schemeMap INTEGER DEFAULT 0,
CONSTRAINT moz_uniqueid UNIQUE (name, host, path, originAttributes)
);",
)
.map_err(|e| format!("Failed to initialize cookie database schema: {e}"))?;
Ok(())
}
/// Convert Chrome timestamp (Windows epoch, microseconds) to Unix timestamp (seconds)
fn chrome_time_to_unix(chrome_time: i64) -> i64 {
if chrome_time == 0 {
@@ -274,12 +405,14 @@ impl CookieManager {
let last_access_utc: i64 = row.get(9)?;
let encrypted_value: Vec<u8> = row.get(10)?;
// Use plaintext value if available, otherwise decrypt encrypted_value
// Use plaintext value if available, otherwise decrypt encrypted_value.
// Decryption needs the host_key (domain) to verify and strip the
// SHA-256 integrity prefix Chromium prepends before encryption.
let value = if !plaintext_value.is_empty() {
plaintext_value
} else if !encrypted_value.is_empty() {
encryption_key
.and_then(|key| chrome_decrypt::decrypt(&encrypted_value, key))
.and_then(|key| chrome_decrypt::decrypt(&encrypted_value, &domain, key))
.unwrap_or_default()
} else {
String::new()
@@ -627,7 +760,9 @@ impl CookieManager {
continue;
}
let target_db_path = match Self::get_cookie_db_path(target, &profiles_dir) {
// Target may be a brand-new profile that has never been launched, so
// its Cookies DB file doesn't exist yet. Create an empty one on demand.
let target_db_path = match Self::ensure_cookie_db_path(target, &profiles_dir) {
Ok(p) => p,
Err(e) => {
results.push(CookieCopyResult {
@@ -903,7 +1038,8 @@ impl CookieManager {
return Err("No valid cookies found in the file".to_string());
}
let db_path = Self::get_cookie_db_path(profile, &profiles_dir)?;
// Profile may have never been launched yet — create an empty DB on demand.
let db_path = Self::ensure_cookie_db_path(profile, &profiles_dir)?;
let write_result = match profile.browser.as_str() {
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies),
@@ -1537,4 +1673,159 @@ mod tests {
let _ = std::fs::remove_file(&ff_db);
let _ = std::fs::remove_file(&chrome_db);
}
/// Regression: decrypting a real v10-encrypted Chromium cookie with the
/// correct PBKDF2 iterations and the `SHA-256(host_key)` integrity-prefix
/// strip. Captured from a real Wayfern profile:
/// host_key = ".github.com"
/// name = "_octo"
/// password = "OSfgzI5GUqy/pK4ANrYugw==" (contents of os_crypt_key)
/// value = "GH1.1.2077424036.1774792325"
///
/// If PBKDF2 iterations or the host-hash prefix handling ever regress,
/// this test fails and we instantly know why all copied cookies end up
/// with empty values — which is exactly the bug that shipped and made
/// issue-265-style silent failures reappear.
#[test]
#[cfg(target_os = "macos")]
fn test_decrypt_v10_cookie_with_real_vector() {
let profile_dir =
std::env::temp_dir().join(format!("donut_decrypt_vector_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&profile_dir).unwrap();
std::fs::write(
profile_dir.join("os_crypt_key"),
b"OSfgzI5GUqy/pK4ANrYugw==",
)
.unwrap();
let key = chrome_decrypt::get_encryption_key(&profile_dir)
.expect("should derive key from os_crypt_key file");
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07";
let encrypted: Vec<u8> = (0..encrypted_hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&encrypted_hex[i..i + 2], 16).unwrap())
.collect();
let decrypted = chrome_decrypt::decrypt(&encrypted, ".github.com", &key)
.expect("decryption must succeed with correct key and host");
assert_eq!(decrypted, "GH1.1.2077424036.1774792325");
let _ = std::fs::remove_dir_all(&profile_dir);
}
/// Sanity: decrypting with the wrong host_key (hash mismatch) must not
/// return a half-garbage value — it should fall back to the full
/// decrypted bytes, which for a modern cookie includes the 32-byte hash
/// prefix and therefore won't be valid UTF-8 → `None`.
#[test]
#[cfg(target_os = "macos")]
fn test_decrypt_with_wrong_host_returns_none_or_raw() {
let profile_dir =
std::env::temp_dir().join(format!("donut_decrypt_wrong_host_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&profile_dir).unwrap();
std::fs::write(
profile_dir.join("os_crypt_key"),
b"OSfgzI5GUqy/pK4ANrYugw==",
)
.unwrap();
let key = chrome_decrypt::get_encryption_key(&profile_dir).unwrap();
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07";
let encrypted: Vec<u8> = (0..encrypted_hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&encrypted_hex[i..i + 2], 16).unwrap())
.collect();
// Wrong host: the prefix won't match, so we fall through to
// `String::from_utf8(full_decrypted)` which fails on the binary hash
// bytes and returns `None`. Either way, we must NOT return the real
// value "GH1.1.2077424036.1774792325".
let result = chrome_decrypt::decrypt(&encrypted, ".facebook.com", &key);
assert!(
result.as_deref() != Some("GH1.1.2077424036.1774792325"),
"decrypt must not return the real cookie value when host_key is wrong"
);
let _ = std::fs::remove_dir_all(&profile_dir);
}
/// Regression: a brand-new Wayfern profile has no `Default/Cookies` file
/// yet (Chromium only writes it on first launch). Copying/importing into
/// such a profile must create the file on demand.
#[test]
fn test_create_empty_chrome_cookies_db_then_write() {
let dir = std::env::temp_dir().join(format!("donut_empty_chrome_{}", uuid::Uuid::new_v4()));
let db_path = dir.join("Default").join("Cookies");
assert!(!db_path.exists());
CookieManager::create_empty_chrome_cookies_db(&db_path).unwrap();
assert!(db_path.exists());
// Round-trip: write a cookie into the freshly created DB, read it back.
let cookies = vec![UnifiedCookie {
name: "auth".to_string(),
value: "token123".to_string(),
domain: ".example.com".to_string(),
path: "/".to_string(),
expires: 1900000000,
is_secure: true,
is_http_only: true,
same_site: 0,
creation_time: 1700000000,
last_accessed: 1700000000,
}];
let (inserted, replaced) = CookieManager::write_chrome_cookies(&db_path, &cookies).unwrap();
assert_eq!(inserted, 1);
assert_eq!(replaced, 0);
let read = CookieManager::read_chrome_cookies(&db_path, None).unwrap();
assert_eq!(read.len(), 1);
assert_eq!(read[0].value, "token123");
// Schema sanity: `meta` table with version row exists so Chromium's
// cookie store migration code can upgrade this on first launch.
let conn = Connection::open(&db_path).unwrap();
let version: String = conn
.query_row("SELECT value FROM meta WHERE key = 'version'", [], |row| {
row.get(0)
})
.unwrap();
assert!(!version.is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
/// Same regression, Firefox side: a fresh Camoufox profile has no
/// `cookies.sqlite` until the browser launches.
#[test]
fn test_create_empty_firefox_cookies_db_then_write() {
let dir = std::env::temp_dir().join(format!("donut_empty_ff_{}", uuid::Uuid::new_v4()));
let db_path = dir.join("cookies.sqlite");
assert!(!db_path.exists());
CookieManager::create_empty_firefox_cookies_db(&db_path).unwrap();
assert!(db_path.exists());
let cookies = vec![UnifiedCookie {
name: "sid".to_string(),
value: "ff-session".to_string(),
domain: ".example.org".to_string(),
path: "/".to_string(),
expires: 1900000000,
is_secure: true,
is_http_only: false,
same_site: 1,
creation_time: 1700000000,
last_accessed: 1700000000,
}];
let (inserted, _) = CookieManager::write_firefox_cookies(&db_path, &cookies).unwrap();
assert_eq!(inserted, 1);
let read = CookieManager::read_firefox_cookies(&db_path).unwrap();
assert_eq!(read.len(), 1);
assert_eq!(read[0].value, "ff-session");
let _ = std::fs::remove_dir_all(&dir);
}
}
+23 -177
View File
@@ -89,96 +89,17 @@ pub mod macos {
}
}
// Fallback: Use AppleScript
let escaped_url = url
.replace("\"", "\\\"")
.replace("\\", "\\\\")
.replace("'", "\\'");
let script = format!(
r#"
try
tell application "System Events"
-- Find the exact process by PID
set targetProcess to (first application process whose unix id is {pid})
-- Verify the process exists
if not (exists targetProcess) then
error "No process found with PID {pid}"
end if
-- Get the process name for verification
set processName to name of targetProcess
-- Bring the process to the front first
set frontmost of targetProcess to true
delay 1.0
-- Check if the process has any visible windows
set windowList to windows of targetProcess
set hasVisibleWindow to false
repeat with w in windowList
if visible of w is true then
set hasVisibleWindow to true
exit repeat
end if
end repeat
if not hasVisibleWindow then
-- No visible windows, create a new one
tell targetProcess
keystroke "n" using command down
delay 2.0
end tell
end if
-- Ensure the process is frontmost again
set frontmost of targetProcess to true
delay 0.5
-- Focus on the address bar and open URL
tell targetProcess
-- Open a new tab
keystroke "t" using command down
delay 1.5
-- Focus address bar (Cmd+L)
keystroke "l" using command down
delay 0.5
-- Type the URL
keystroke "{escaped_url}"
delay 0.5
-- Press Enter to navigate
keystroke return
end tell
return "Successfully opened URL in " & processName & " (PID: {pid})"
end tell
on error errMsg number errNum
return "AppleScript failed: " & errMsg & " (Error " & errNum & ")"
end try
"#
);
log::info!("Executing AppleScript fallback for Firefox-based browser (PID: {pid})...");
let output = Command::new("osascript").args(["-e", &script]).output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
log::info!("AppleScript failed: {error_msg}");
return Err(
format!(
"Both Firefox remote command and AppleScript failed. AppleScript error: {error_msg}"
)
.into(),
);
} else {
log::info!("AppleScript succeeded");
}
Ok(())
// The Firefox `-new-tab` remote command failed. We intentionally do NOT
// fall back to an AppleScript `System Events` keystroke path: that would
// send Apple Events to another application and trigger the macOS TCC
// "<Donut> wants control of <Browser>" / "prevented from modifying other
// apps" prompts. Donut must never touch other apps on the user's Mac.
Err(
format!(
"Firefox remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
)
.into(),
)
}
pub async fn kill_browser_process_impl(
@@ -378,93 +299,18 @@ end try
}
}
// Fallback to AppleScript
let escaped_url = url
.replace("\"", "\\\"")
.replace("\\", "\\\\")
.replace("'", "\\'");
let script = format!(
r#"
try
tell application "System Events"
-- Find the exact process by PID
set targetProcess to (first application process whose unix id is {pid})
-- Verify the process exists
if not (exists targetProcess) then
error "No process found with PID {pid}"
end if
-- Get the process name for verification
set processName to name of targetProcess
-- Bring the process to the front first
set frontmost of targetProcess to true
delay 1.0
-- Check if the process has any visible windows
set windowList to windows of targetProcess
set hasVisibleWindow to false
repeat with w in windowList
if visible of w is true then
set hasVisibleWindow to true
exit repeat
end if
end repeat
if not hasVisibleWindow then
-- No visible windows, create a new one
tell targetProcess
keystroke "n" using command down
delay 2.0
end tell
end if
-- Ensure the process is frontmost again
set frontmost of targetProcess to true
delay 0.5
-- Focus on the address bar and open URL
tell targetProcess
-- Open a new tab
keystroke "t" using command down
delay 1.5
-- Focus address bar (Cmd+L)
keystroke "l" using command down
delay 0.5
-- Type the URL
keystroke "{escaped_url}"
delay 0.5
-- Press Enter to navigate
keystroke return
end tell
return "Successfully opened URL in " & processName & " (PID: {pid})"
end tell
on error errMsg number errNum
return "AppleScript failed: " & errMsg & " (Error " & errNum & ")"
end try
"#
);
log::info!("Executing AppleScript for Chromium-based browser (PID: {pid})...");
let output = Command::new("osascript").args(["-e", &script]).output()?;
if !output.status.success() {
let error_msg = String::from_utf8_lossy(&output.stderr);
log::info!("AppleScript failed: {error_msg}");
return Err(
format!("Failed to open URL in existing Chromium-based browser: {error_msg}").into(),
);
} else {
log::info!("AppleScript succeeded");
}
Ok(())
// The Chromium `--user-data-dir=<path> <url>` remote command failed.
// We intentionally do NOT fall back to an AppleScript `System Events`
// keystroke path: that would send Apple Events to another application
// and trigger the macOS TCC "<Donut> wants control of <Browser>" /
// "prevented from modifying other apps" prompts. Donut must never touch
// other apps on the user's Mac.
Err(
format!(
"Chromium remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
)
.into(),
)
}
}
+6 -13
View File
@@ -303,6 +303,11 @@ impl SynchronizerManager {
}
/// Bring the leader browser window to front.
///
/// On macOS this is a no-op on purpose: the only way to raise another
/// app's window from Rust is via `osascript` / Apple Events, which
/// triggers the TCC "prevented from modifying other apps" prompt. Donut
/// must never touch other apps on the user's Mac.
async fn focus_leader_window(leader: &BrowserProfile) {
let profile = match Self::get_profile(&leader.id.to_string()) {
Ok(p) => p,
@@ -312,18 +317,6 @@ impl SynchronizerManager {
return;
};
#[cfg(target_os = "macos")]
{
let _ = tokio::process::Command::new("osascript")
.arg("-e")
.arg(format!(
"tell application \"System Events\" to set frontmost of (first process whose unix id is {}) to true",
pid
))
.output()
.await;
}
#[cfg(target_os = "linux")]
{
let _ = tokio::process::Command::new("xdotool")
@@ -338,7 +331,7 @@ impl SynchronizerManager {
.await;
}
#[cfg(target_os = "windows")]
#[cfg(not(target_os = "linux"))]
{
let _ = pid;
}
+5 -5
View File
@@ -511,11 +511,11 @@ impl WayfernManager {
let name: String = row.get(0).unwrap_or_default();
let host: String = row.get(1).unwrap_or_default();
let encrypted: Vec<u8> = row.get(2).unwrap_or_default();
let decrypted =
crate::cookie_manager::chrome_decrypt::decrypt(
&encrypted,
&encryption_key,
);
let decrypted = crate::cookie_manager::chrome_decrypt::decrypt(
&encrypted,
&host,
&encryption_key,
);
match decrypted {
Some(val) => log::info!(
"Pre-launch: Cookie decryption SUCCEEDED for '{}' (host: {}, decrypted {} bytes)",
+26 -17
View File
@@ -77,11 +77,16 @@ export function CookieCopyDialog({
);
const [error, setError] = useState<string | null>(null);
// Never offer a selected profile as a source — you can't copy a profile's
// cookies onto itself, and including it here would leave the user in a
// dead-end state (source picked = target list empty = copy button disabled).
const eligibleSourceProfiles = useMemo(() => {
return profiles.filter(
(p) => p.browser === "wayfern" || p.browser === "camoufox",
(p) =>
!selectedProfiles.includes(p.id) &&
(p.browser === "wayfern" || p.browser === "camoufox"),
);
}, [profiles]);
}, [profiles, selectedProfiles]);
const targetProfiles = useMemo(() => {
return profiles.filter(
@@ -148,22 +153,21 @@ export function CookieCopyDialog({
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setSelection((prev) => {
const current = prev[domain];
const allSelected = current.allSelected;
if (allSelected) {
// `prev[domain]` is `undefined` for any domain not yet interacted with
// and after the user fully deselects it (toggleCookie deletes the
// entry on empty). Treat missing as "not selected".
if (prev[domain]?.allSelected) {
const newSelection = { ...prev };
delete newSelection[domain];
return newSelection;
} else {
return {
...prev,
[domain]: {
allSelected: true,
cookies: new Set(cookies.map((c) => c.name)),
},
};
}
return {
...prev,
[domain]: {
allSelected: true,
cookies: new Set(cookies.map((c) => c.name)),
},
};
});
},
[],
@@ -503,9 +507,13 @@ function DomainRow({
onToggleCookie,
onToggleExpand,
}: DomainRowProps) {
// `selection[domain.domain]` is `undefined` for domains the user hasn't
// touched yet (initial state after loading cookies is `{}`) and for any
// domain the user fully deselected (toggleCookie deletes the entry on
// empty). Default to "no cookies selected" instead of crashing.
const domainSelection = selection[domain.domain];
const isAllSelected = domainSelection.allSelected;
const selectedCount = domainSelection.cookies.size;
const isAllSelected = domainSelection?.allSelected ?? false;
const selectedCount = domainSelection?.cookies.size ?? 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
@@ -540,7 +548,8 @@ function DomainRow({
{isExpanded && (
<div className="ml-8 pl-2 border-l space-y-1">
{domain.cookies.map((cookie) => {
const isSelected = domainSelection.cookies.has(cookie.name);
const isSelected =
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
+9 -5
View File
@@ -309,8 +309,11 @@ export function CookieManagementDialog({
const toggleDomain = useCallback(
(domain: string, cookies: UnifiedCookie[]) => {
setExportSelection((prev) => {
const current = prev[domain];
if (current.allSelected) {
// `prev[domain]` is `undefined` when the domain was previously fully
// deselected (entries are deleted on empty — see toggleCookie). Treat
// missing as "not selected" so re-enabling falls through to the add
// branch instead of crashing on `.allSelected`.
if (prev[domain]?.allSelected) {
const next = { ...prev };
delete next[domain];
return next;
@@ -592,8 +595,8 @@ function ExportDomainRow({
onToggleExpand,
}: ExportDomainRowProps) {
const domainSelection = selection[domain.domain];
const isAllSelected = domainSelection.allSelected;
const selectedCount = domainSelection.cookies.size;
const isAllSelected = domainSelection?.allSelected ?? false;
const selectedCount = domainSelection?.cookies.size ?? 0;
const isPartial =
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
@@ -628,7 +631,8 @@ function ExportDomainRow({
{isExpanded && (
<div className="ml-7 pl-2 border-l space-y-0.5">
{domain.cookies.map((cookie) => {
const isSelected = domainSelection.cookies.has(cookie.name);
const isSelected =
domainSelection?.cookies.has(cookie.name) ?? false;
return (
<div
key={`${domain.domain}-${cookie.name}`}
+14
View File
@@ -33,6 +33,7 @@ import {
ProfileBypassRulesDialog,
ProfileDnsBlocklistDialog,
ProfileInfoDialog,
ProfileLaunchHookDialog,
} from "@/components/profile-info-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -937,6 +938,8 @@ export function ProfilesDataTable({
React.useState<BrowserProfile | null>(null);
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
React.useState<BrowserProfile | null>(null);
const [launchHookProfile, setLaunchHookProfile] =
React.useState<BrowserProfile | null>(null);
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
new Set(),
);
@@ -2680,6 +2683,9 @@ export function ProfilesDataTable({
onOpenDnsBlocklist={(profile) => {
setDnsBlocklistProfile(profile);
}}
onOpenLaunchHook={(profile) => {
setLaunchHookProfile(profile);
}}
onCloneProfile={onCloneProfile}
onLaunchWithSync={onLaunchWithSync}
onDeleteProfile={(profile) => {
@@ -2770,6 +2776,14 @@ export function ProfilesDataTable({
profileId={dnsBlocklistProfile?.id ?? null}
currentLevel={dnsBlocklistProfile?.dns_blocklist ?? null}
/>
<ProfileLaunchHookDialog
isOpen={launchHookProfile !== null}
onClose={() => {
setLaunchHookProfile(null);
}}
profileId={launchHookProfile?.id ?? null}
currentLaunchHook={launchHookProfile?.launch_hook ?? null}
/>
</>
);
}
+92 -49
View File
@@ -13,6 +13,7 @@ import {
LuFingerprint,
LuGlobe,
LuGroup,
LuLink,
LuPlus,
LuPuzzle,
LuRefreshCw,
@@ -66,6 +67,7 @@ interface ProfileInfoDialogProps {
onAssignExtensionGroup?: (profileIds: string[]) => void;
onOpenBypassRules?: (profile: BrowserProfile) => void;
onOpenDnsBlocklist?: (profile: BrowserProfile) => void;
onOpenLaunchHook?: (profile: BrowserProfile) => void;
onCloneProfile?: (profile: BrowserProfile) => void;
onDeleteProfile?: (profile: BrowserProfile) => void;
onLaunchWithSync?: (profile: BrowserProfile) => void;
@@ -113,6 +115,7 @@ export function ProfileInfoDialog({
onAssignExtensionGroup,
onOpenBypassRules,
onOpenDnsBlocklist,
onOpenLaunchHook,
onCloneProfile,
onDeleteProfile,
onLaunchWithSync,
@@ -128,8 +131,6 @@ export function ProfileInfoDialog({
const [extensionGroupName, setExtensionGroupName] = React.useState<
string | null
>(null);
const [launchHookValue, setLaunchHookValue] = React.useState("");
const [isSavingLaunchHook, setIsSavingLaunchHook] = React.useState(false);
React.useEffect(() => {
if (!isOpen || !profile?.group_id) {
@@ -171,12 +172,6 @@ export function ProfileInfoDialog({
}
}, [isOpen]);
React.useEffect(() => {
if (isOpen) {
setLaunchHookValue(profile?.launch_hook ?? "");
}
}, [isOpen, profile?.launch_hook]);
if (!profile) return null;
const ProfileIcon = getProfileIcon(profile);
@@ -225,23 +220,14 @@ export function ProfileInfoDialog({
const hasTags = profile.tags && profile.tags.length > 0;
const hasNote = !!profile.note;
const showCrossOs = isCrossOsProfile(profile);
const trimmedLaunchHook = launchHookValue.trim();
const savedLaunchHook = profile.launch_hook ?? "";
const handleSaveLaunchHook = async () => {
setIsSavingLaunchHook(true);
try {
await invoke("update_profile_launch_hook", {
profileId: profile.id,
launchHook: trimmedLaunchHook || null,
});
} catch (error) {
console.error("Failed to update launch hook:", error);
} finally {
setIsSavingLaunchHook(false);
}
};
// Items in the settings tab `actions` list MUST only open another dialog
// (or trigger a navigation/action that closes this one). Do NOT put inline
// settings UI — inputs, toggles, save buttons — directly in this dialog's
// settings tab. Each setting belongs in its own focused dialog (see
// `ProfileLaunchHookDialog`, `ProfileBypassRulesDialog`,
// `ProfileDnsBlocklistDialog` for the pattern). The settings tab is purely
// a navigation hub.
interface ActionItem {
icon: React.ReactNode;
label: string;
@@ -360,6 +346,14 @@ export function ProfileInfoDialog({
handleAction(() => onOpenDnsBlocklist?.(profile));
},
},
{
icon: <LuLink className="w-4 h-4" />,
label: t("profiles.actions.launchHook"),
onClick: () => {
handleAction(() => onOpenLaunchHook?.(profile));
},
hidden: !onOpenLaunchHook,
},
{
icon: <LuTrash2 className="w-4 h-4" />,
label: t("profiles.actions.delete"),
@@ -575,31 +569,6 @@ export function ProfileInfoDialog({
<TabsContent value="settings">
<div className="overflow-y-auto max-h-[calc(80vh-12rem)]">
<div className="flex flex-col gap-3 py-1">
<div className="rounded-md bg-muted/50 border px-3 py-3">
<p className="text-xs text-muted-foreground">
{t("profileInfo.launchHook.label")}
</p>
<div className="flex gap-2 mt-2">
<Input
value={launchHookValue}
onChange={(e) => {
setLaunchHookValue(e.target.value);
}}
placeholder={t("profileInfo.launchHook.placeholder")}
disabled={isSavingLaunchHook}
/>
<Button
onClick={() => void handleSaveLaunchHook()}
disabled={
isSavingLaunchHook ||
trimmedLaunchHook === savedLaunchHook
}
>
{t("common.buttons.save")}
</Button>
</div>
</div>
<div className="flex flex-col">
{visibleActions.map((action) => (
<button
@@ -639,6 +608,80 @@ export function ProfileInfoDialog({
);
}
interface ProfileLaunchHookDialogProps {
isOpen: boolean;
onClose: () => void;
profileId: string | null;
currentLaunchHook: string | null;
}
export function ProfileLaunchHookDialog({
isOpen,
onClose,
profileId,
currentLaunchHook,
}: ProfileLaunchHookDialogProps) {
const { t } = useTranslation();
const [value, setValue] = React.useState(currentLaunchHook ?? "");
const [isSaving, setIsSaving] = React.useState(false);
React.useEffect(() => {
if (isOpen) {
setValue(currentLaunchHook ?? "");
}
}, [isOpen, currentLaunchHook]);
const trimmed = value.trim();
const saved = currentLaunchHook ?? "";
const isDirty = trimmed !== saved;
const handleSave = async () => {
if (!profileId) return;
setIsSaving(true);
try {
await invoke("update_profile_launch_hook", {
profileId,
launchHook: trimmed || null,
});
onClose();
} catch (err) {
console.error("Failed to update launch hook:", err);
} finally {
setIsSaving(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("profileInfo.launchHook.title")}</DialogTitle>
</DialogHeader>
<p className="text-xs text-muted-foreground">
{t("profileInfo.launchHook.description")}
</p>
<Input
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
placeholder={t("profileInfo.launchHook.placeholder")}
disabled={isSaving}
/>
<DialogFooter>
<Button
onClick={() => void handleSave()}
disabled={isSaving || !isDirty}
className="w-full"
>
{t("common.buttons.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface ProfileDnsBlocklistDialogProps {
isOpen: boolean;
onClose: () => void;
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Sync Settings",
"assignToGroup": "Assign to Group",
"changeFingerprint": "Change Fingerprint",
"copyCookiesToProfile": "Copy Cookies to Profile"
"copyCookiesToProfile": "Copy Cookies to Profile",
"launchHook": "Launch Hook URL"
},
"synchronizer": {
"launchWithSync": "Launch with Synchronizer",
@@ -789,7 +790,9 @@
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
},
"launchHook": {
"title": "Launch Hook URL",
"label": "Launch Hook URL",
"description": "Donut Browser will POST to this URL whenever the profile is launched.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Configuración de Sincronización",
"assignToGroup": "Asignar a Grupo",
"changeFingerprint": "Cambiar Huella Digital",
"copyCookiesToProfile": "Copiar Cookies al Perfil"
"copyCookiesToProfile": "Copiar Cookies al Perfil",
"launchHook": "URL del hook de inicio"
},
"synchronizer": {
"launchWithSync": "Lanzar con Sincronizador",
@@ -789,7 +790,9 @@
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
},
"launchHook": {
"title": "URL del hook de inicio",
"label": "URL del hook de inicio",
"description": "Donut Browser enviará una solicitud POST a esta URL cada vez que se inicie el perfil.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Paramètres de Synchronisation",
"assignToGroup": "Assigner au Groupe",
"changeFingerprint": "Changer l'Empreinte",
"copyCookiesToProfile": "Copier les Cookies vers le Profil"
"copyCookiesToProfile": "Copier les Cookies vers le Profil",
"launchHook": "URL du hook de lancement"
},
"synchronizer": {
"launchWithSync": "Lancer avec le synchroniseur",
@@ -789,7 +790,9 @@
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
},
"launchHook": {
"title": "URL du hook de lancement",
"label": "URL du hook de lancement",
"description": "Donut Browser enverra une requête POST à cette URL chaque fois que le profil est lancé.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "同期設定",
"assignToGroup": "グループに割り当て",
"changeFingerprint": "フィンガープリントを変更",
"copyCookiesToProfile": "Cookieをプロファイルにコピー"
"copyCookiesToProfile": "Cookieをプロファイルにコピー",
"launchHook": "起動フックURL"
},
"synchronizer": {
"launchWithSync": "シンクロナイザーで起動",
@@ -789,7 +790,9 @@
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
},
"launchHook": {
"title": "起動フックURL",
"label": "起動フックURL",
"description": "プロファイルが起動されるたびに、Donut BrowserはこのURLにPOSTリクエストを送信します。",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Configurações de Sincronização",
"assignToGroup": "Atribuir ao Grupo",
"changeFingerprint": "Alterar Impressão Digital",
"copyCookiesToProfile": "Copiar Cookies para o Perfil"
"copyCookiesToProfile": "Copiar Cookies para o Perfil",
"launchHook": "URL do hook de inicialização"
},
"synchronizer": {
"launchWithSync": "Iniciar com Sincronizador",
@@ -789,7 +790,9 @@
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
},
"launchHook": {
"title": "URL do hook de inicialização",
"label": "URL do hook de inicialização",
"description": "O Donut Browser enviará uma requisição POST para esta URL sempre que o perfil for iniciado.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "Настройки синхронизации",
"assignToGroup": "Назначить группе",
"changeFingerprint": "Изменить отпечаток",
"copyCookiesToProfile": "Копировать Cookie в профиль"
"copyCookiesToProfile": "Копировать Cookie в профиль",
"launchHook": "URL хука запуска"
},
"synchronizer": {
"launchWithSync": "Запустить с синхронизатором",
@@ -789,7 +790,9 @@
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
},
"launchHook": {
"title": "URL хука запуска",
"label": "URL хука запуска",
"description": "Donut Browser будет отправлять POST-запрос на этот URL при каждом запуске профиля.",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {
+4 -1
View File
@@ -183,7 +183,8 @@
"syncSettings": "同步设置",
"assignToGroup": "分配到组",
"changeFingerprint": "更改指纹",
"copyCookiesToProfile": "复制 Cookies 到配置文件"
"copyCookiesToProfile": "复制 Cookies 到配置文件",
"launchHook": "启动钩子 URL"
},
"synchronizer": {
"launchWithSync": "使用同步器启动",
@@ -789,7 +790,9 @@
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
},
"launchHook": {
"title": "启动钩子 URL",
"label": "启动钩子 URL",
"description": "每次启动配置文件时,Donut Browser 都会向此 URL 发送 POST 请求。",
"placeholder": "https://example.com/hooks/profile-launch"
},
"actions": {