diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c35baef..dd2da0d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1767,6 +1767,7 @@ dependencies = [ "serde_yaml", "serial_test", "sha1", + "sha2", "smoltcp", "sys-locale", "sysinfo", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fc22be4..d2beecf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/cookie_manager.rs b/src-tauri/src/cookie_manager.rs index 55da99f..7e7a241 100644 --- a/src-tauri/src/cookie_manager.rs +++ b/src-tauri/src/cookie_manager.rs @@ -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; + /// 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 { + /// + /// 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 { 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::(&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 { 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 { + 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 = 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 = (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 = (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); + } } diff --git a/src-tauri/src/platform_browser.rs b/src-tauri/src/platform_browser.rs index 9814225..cd75da2 100644 --- a/src-tauri/src/platform_browser.rs +++ b/src-tauri/src/platform_browser.rs @@ -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 + // " wants control of " / "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= ` 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 " wants control of " / + // "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(), + ) } } diff --git a/src-tauri/src/synchronizer.rs b/src-tauri/src/synchronizer.rs index 7de687d..d89de58 100644 --- a/src-tauri/src/synchronizer.rs +++ b/src-tauri/src/synchronizer.rs @@ -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; } diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 01b59a3..8761a37 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -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 = 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)", diff --git a/src/components/cookie-copy-dialog.tsx b/src/components/cookie-copy-dialog.tsx index 2532032..5cb1816 100644 --- a/src/components/cookie-copy-dialog.tsx +++ b/src/components/cookie-copy-dialog.tsx @@ -77,11 +77,16 @@ export function CookieCopyDialog({ ); const [error, setError] = useState(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 && (
{domain.cookies.map((cookie) => { - const isSelected = domainSelection.cookies.has(cookie.name); + const isSelected = + domainSelection?.cookies.has(cookie.name) ?? false; return (
{ 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 && (
{domain.cookies.map((cookie) => { - const isSelected = domainSelection.cookies.has(cookie.name); + const isSelected = + domainSelection?.cookies.has(cookie.name) ?? false; return (
(null); const [dnsBlocklistProfile, setDnsBlocklistProfile] = React.useState(null); + const [launchHookProfile, setLaunchHookProfile] = + React.useState(null); const [launchingProfiles, setLaunchingProfiles] = React.useState>( 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} /> + { + setLaunchHookProfile(null); + }} + profileId={launchHookProfile?.id ?? null} + currentLaunchHook={launchHookProfile?.launch_hook ?? null} + /> ); } diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 19675b6..92a96a2 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -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: , + label: t("profiles.actions.launchHook"), + onClick: () => { + handleAction(() => onOpenLaunchHook?.(profile)); + }, + hidden: !onOpenLaunchHook, + }, { icon: , label: t("profiles.actions.delete"), @@ -575,31 +569,6 @@ export function ProfileInfoDialog({
-
-

- {t("profileInfo.launchHook.label")} -

-
- { - setLaunchHookValue(e.target.value); - }} - placeholder={t("profileInfo.launchHook.placeholder")} - disabled={isSavingLaunchHook} - /> - -
-
-
{visibleActions.map((action) => ( + + + + ); +} + interface ProfileDnsBlocklistDialogProps { isOpen: boolean; onClose: () => void; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 61c3d3c..85ba5e7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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": { diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3ab5a97..8d59fcc 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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": { diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c89ddc7..db1704f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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": { diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index a520c56..fcb8985 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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": { diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 267e393..00908a5 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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": { diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 31d24ce..d752b3d 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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": { diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 2e2241b..1c79480 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": {