mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-08 19:34:51 +02:00
refactor: cleanup
This commit is contained in:
Generated
+1
@@ -1767,6 +1767,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"serial_test",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"smoltcp",
|
||||
"sys-locale",
|
||||
"sysinfo",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user