diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 91171023..89294b1b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -252,6 +252,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -1787,7 +1793,6 @@ dependencies = [ "security-framework", "serde", "serde_json", - "serde_yaml", "serial_test", "sha2 0.11.0", "shadowsocks", @@ -1825,6 +1830,7 @@ dependencies = [ "windows", "winreg", "wiremock", + "yaml-rust2", "zip", ] @@ -5920,19 +5926,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.2", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "serial_test" version = "4.0.1" @@ -7614,12 +7607,6 @@ dependencies = [ "ctutils", ] -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -8680,6 +8667,16 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" +[[package]] +name = "yaml-rust2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18" +dependencies = [ + "arraydeque", + "hashlink", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a3252b56..3ea864cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -113,7 +113,7 @@ async-socks5 = "0.6" # Wayfern CDP integration tokio-tungstenite = { version = "0.30", features = ["native-tls"] } rusqlite = { version = "0.40", features = ["bundled"] } -serde_yaml = "0.9" +yaml-rust2 = { version = "0.13", default-features = false } thiserror = "2.0" regex-lite = "0.1" tempfile = "3" diff --git a/src-tauri/src/mcp_integrations.rs b/src-tauri/src/mcp_integrations.rs index 01275a4d..cecd305f 100644 --- a/src-tauri/src/mcp_integrations.rs +++ b/src-tauri/src/mcp_integrations.rs @@ -32,6 +32,8 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, Item, Table}; +use yaml_rust2::yaml::Hash as YamlMap; +use yaml_rust2::{Yaml, YamlEmitter, YamlLoader}; pub const SERVER_NAME: &str = "donut-browser"; const REMOTE_MCP_PATH: &str = "/api/mcp"; @@ -745,14 +747,67 @@ fn toml_item_to_json(item: &Item) -> serde_json::Value { } } -fn entry_to_yaml(entry: &Entry) -> Result { - let mut mapping = serde_yaml::Mapping::new(); - for (key, value) in entry { - let yaml = - serde_yaml::to_value(value).map_err(|e| format!("Failed to convert to YAML: {e}"))?; - mapping.insert(serde_yaml::Value::String(key.to_string()), yaml); +fn json_to_yaml(value: &serde_json::Value) -> Yaml { + match value { + serde_json::Value::Null => Yaml::Null, + serde_json::Value::Bool(b) => Yaml::Boolean(*b), + serde_json::Value::Number(n) => match n.as_i64() { + Some(i) => Yaml::Integer(i), + None => Yaml::Real(n.to_string()), + }, + serde_json::Value::String(s) => Yaml::String(s.clone()), + serde_json::Value::Array(items) => Yaml::Array(items.iter().map(json_to_yaml).collect()), + serde_json::Value::Object(map) => Yaml::Hash( + map + .iter() + .map(|(k, v)| (Yaml::String(k.clone()), json_to_yaml(v))) + .collect(), + ), + } +} + +/// A YAML value as plain JSON, for inspection. Keys that are not strings and +/// values JSON cannot hold (aliases, bad values) read as null. +fn yaml_to_json(value: &Yaml) -> serde_json::Value { + match value { + Yaml::Boolean(b) => serde_json::Value::Bool(*b), + Yaml::Integer(i) => serde_json::Value::from(*i), + Yaml::Real(text) => text + .parse::() + .ok() + .and_then(serde_json::Number::from_f64) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Yaml::String(s) => serde_json::Value::String(s.clone()), + Yaml::Array(items) => serde_json::Value::Array(items.iter().map(yaml_to_json).collect()), + Yaml::Hash(map) => serde_json::Value::Object( + map + .iter() + .filter_map(|(k, v)| Some((k.as_str()?.to_string(), yaml_to_json(v)))) + .collect(), + ), + _ => serde_json::Value::Null, + } +} + +fn entry_to_yaml(entry: &Entry) -> Yaml { + Yaml::Hash( + entry + .iter() + .map(|(key, value)| (Yaml::String(key.to_string()), json_to_yaml(value))) + .collect(), + ) +} + +/// The mapping at `key` in a YAML mapping, if it is one. +fn yaml_child<'a>(root: &'a mut Yaml, key: &str) -> Option<&'a mut YamlMap> { + match root { + Yaml::Hash(map) => match map.get_mut(&Yaml::String(key.to_string())) { + Some(Yaml::Hash(child)) => Some(child), + _ => None, + }, + _ => None, } - Ok(serde_yaml::Value::Mapping(mapping)) } /// A parsed config file that can be edited without disturbing what the user @@ -760,7 +815,7 @@ fn entry_to_yaml(entry: &Entry) -> Result { enum Document { Json(CstRootNode), Toml(DocumentMut), - Yaml(serde_yaml::Value), + Yaml(Yaml), } impl Document { @@ -771,12 +826,11 @@ impl Document { ), ConfigFormat::Toml => Self::Toml(content.parse::().map_err(|e| e.to_string())?), ConfigFormat::Yaml => { - let value = if content.trim().is_empty() { - serde_yaml::Value::Null - } else { - serde_yaml::from_str(content).map_err(|e| e.to_string())? - }; - Self::Yaml(value) + let mut documents = YamlLoader::load_from_str(content).map_err(|e| e.to_string())?; + if documents.len() > 1 { + return Err("the file holds more than one YAML document".to_string()); + } + Self::Yaml(documents.pop().unwrap_or(Yaml::Null)) } }) } @@ -816,20 +870,13 @@ impl Document { .collect() }) .unwrap_or_default(), - Self::Yaml(root) => root - .get(key) - .and_then(serde_yaml::Value::as_mapping) - .map(|servers| { - servers - .iter() - .filter_map(|(name, value)| { - let name = name.as_str()?.to_string(); - let value = serde_json::to_value(value).ok()?; - Some((name, value)) - }) - .collect() - }) - .unwrap_or_default(), + Self::Yaml(root) => match &root[key] { + Yaml::Hash(servers) => servers + .iter() + .filter_map(|(name, value)| Some((name.as_str()?.to_string(), yaml_to_json(value)))) + .collect(), + _ => Vec::new(), + }, } } @@ -869,24 +916,21 @@ impl Document { } Self::Yaml(root) => { if root.is_null() { - *root = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + *root = Yaml::Hash(YamlMap::new()); } - let root_map = root - .as_mapping_mut() - .ok_or("the file's top level is not a YAML mapping")?; + let Yaml::Hash(root_map) = root else { + return Err("the file's top level is not a YAML mapping".to_string()); + }; let servers = root_map - .entry(serde_yaml::Value::String(key.to_string())) - .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + .entry(Yaml::String(key.to_string())) + .or_insert_with(|| Yaml::Hash(YamlMap::new())); if servers.is_null() { - *servers = serde_yaml::Value::Mapping(serde_yaml::Mapping::new()); + *servers = Yaml::Hash(YamlMap::new()); } - servers - .as_mapping_mut() - .ok_or_else(|| format!("\"{key}\" is not a YAML mapping"))? - .insert( - serde_yaml::Value::String(SERVER_NAME.to_string()), - entry_to_yaml(entry)?, - ); + let Yaml::Hash(servers) = servers else { + return Err(format!("\"{key}\" is not a YAML mapping")); + }; + servers.insert(Yaml::String(SERVER_NAME.to_string()), entry_to_yaml(entry)); } } Ok(()) @@ -911,12 +955,9 @@ impl Document { } } Self::Yaml(root) => { - if let Some(servers) = root - .get_mut(key) - .and_then(serde_yaml::Value::as_mapping_mut) - { + if let Some(servers) = yaml_child(root, key) { for name in names { - servers.remove(name.as_str()); + servers.remove(&Yaml::String(name.clone())); } } } @@ -928,7 +969,13 @@ impl Document { Self::Json(root) => Ok(root.to_string()), Self::Toml(doc) => Ok(doc.to_string()), Self::Yaml(root) => { - serde_yaml::to_string(root).map_err(|e| format!("Failed to serialize YAML: {e}")) + let mut text = String::new(); + YamlEmitter::new(&mut text) + .dump(root) + .map_err(|e| format!("Failed to serialize YAML: {e}"))?; + // The emitter opens with a document marker the file never had. + let body = text.strip_prefix("---\n").unwrap_or(&text); + Ok(format!("{body}\n")) } } } diff --git a/src/components/account-page.tsx b/src/components/account-page.tsx index 2362c874..9a0a81bd 100644 --- a/src/components/account-page.tsx +++ b/src/components/account-page.tsx @@ -15,6 +15,7 @@ import { LuTriangleAlert, LuUser, } from "react-icons/lu"; +import { AccountSyncServer } from "@/components/account-sync-server"; import { formatDate, formatHours, @@ -136,12 +137,11 @@ export function AccountPage({ useState("unknown"); const hasConfig = Boolean(serverUrl && token); - // Self-hosted and cloud are mutually exclusive — both share the same sync - // engine and a profile can't be sync'd to two backends. The tab trigger is - // disabled here AND the backend rejects mixed state (see `save_sync_settings` - // / `cloud_logout`), so even if someone bypasses the UI we don't end up - // with split-brain. - const selfHostedDisabled = isLoggedIn || isCloudLoading; + // Signed out, a server takes its shared token. Signed in, the account signs + // in to its own server instead (`AccountSyncServer`); the backend refuses a + // shared token then (`save_sync_settings`), so one device never syncs one + // profile to two places. + const selfHostedDisabled = isCloudLoading; const handleRefresh = async () => { setIsRefreshing(true); @@ -358,11 +358,6 @@ export function AccountPage({ {t("account.tabs.selfHosted")} @@ -582,16 +577,12 @@ export function AccountPage({ value="self-hosted" className="@container mt-4" > - {selfHostedDisabled ? ( - // Defensive: the tab trigger is disabled while the user is - // logged in, so this branch shouldn't be reachable via UI — - // but if state flips mid-render (e.g. a cloud login finishes - // while the tab is open), show the explanation instead of - // a silent empty card. -

- {t("account.selfHosted.disabledWhileLoggedIn")} -

- ) : ( + {isLoggedIn && user ? ( + + ) : selfHostedDisabled ? null : (
diff --git a/src/generated/licenses.json b/src/generated/licenses.json index 4f9b4a4d..f2113e35 100644 --- a/src/generated/licenses.json +++ b/src/generated/licenses.json @@ -451,6 +451,10 @@ "name": "aria-hidden", "license": "MIT" }, + { + "name": "arraydeque", + "license": "Apache-2.0 OR MIT" + }, { "name": "arrayref", "license": "BSD-2-Clause" @@ -2519,10 +2523,6 @@ "name": "serde_with_macros", "license": "Apache-2.0 OR MIT" }, - { - "name": "serde_yaml", - "license": "Apache-2.0 OR MIT" - }, { "name": "serialize-to-javascript", "license": "Apache-2.0 OR MIT" @@ -2983,10 +2983,6 @@ "name": "universal-hash", "license": "Apache-2.0 OR MIT" }, - { - "name": "unsafe-libyaml", - "license": "MIT" - }, { "name": "untrusted", "license": "ISC" @@ -3263,6 +3259,10 @@ "name": "y4m", "license": "MIT" }, + { + "name": "yaml-rust2", + "license": "Apache-2.0 OR MIT" + }, { "name": "yoke", "license": "Unicode-3.0"