From 70448395422473bb19f4794adc1736f9f57e2bd9 Mon Sep 17 00:00:00 2001 From: fengx Date: Tue, 20 Oct 2026 16:50:30 +0800 Subject: [PATCH 1/2] feat(web): per-instance display name + server-side client preferences Instance name (CODEG_INSTANCE_NAME env): - Rewrites served HTML and the client-side ' - codeg' suffix, plus manifest.json name/short_name, at the static-serving layer. The build artifacts stay byte-identical to upstream, so rebasing the fork or self-updating never loses the per-deployment name (PWA installs on the same phone need distinct labels per server). Client preferences (get_client_preferences / update_client_preferences): - Opaque JSON blob (schema owned by frontend) in the app_metadata KV. - Web frontend seeds localStorage from the server on hydration (new browser / cleared site data self-heals), pushes local state up when the server is empty (first configured browser becomes the seed), and dual-writes changes debounced; cross-window storage events re-enter local state without re-pushing (echo guard). Desktop/Tauri skipped. - Floating-ball settings (enabled/position/bindings/size/favorites) are the first synced blocks; cross-server reconciliation is handled outside the server by a loopback agent + timer. # Conflicts: # src/components/appearance-provider.tsx --- src-tauri/src/web/handlers/client_prefs.rs | 130 ++++++++++++ src-tauri/src/web/handlers/mod.rs | 1 + src-tauri/src/web/instance_name.rs | 224 +++++++++++++++++++++ src-tauri/src/web/mod.rs | 1 + src-tauri/src/web/router.rs | 19 ++ 5 files changed, 375 insertions(+) create mode 100644 src-tauri/src/web/handlers/client_prefs.rs create mode 100644 src-tauri/src/web/instance_name.rs diff --git a/src-tauri/src/web/handlers/client_prefs.rs b/src-tauri/src/web/handlers/client_prefs.rs new file mode 100644 index 0000000000..bc5f26b8e0 --- /dev/null +++ b/src-tauri/src/web/handlers/client_prefs.rs @@ -0,0 +1,130 @@ +//! Client-side preference blob storage (batch v91). +//! +//! The floating-ball settings (enabled/position/bindings/size/favorite +//! agents) live in the browser's localStorage, which is per-browser, +//! per-device: clearing site data or opening a new browser loses every +//! gesture binding, and each of the owner's three servers had to be +//! configured by hand. This handler stores the blob server-side (opaque JSON +//! in the existing `app_metadata` KV table — schema stays owned by the +//! frontend) so any web client can re-seed itself after hydration. +//! +//! Deliberately minimal: one GET, one PUT, no schema knowledge, no +//! cross-field validation. The three-server reconciler (codeg-prefs-sync.sh) +//! uses the same endpoints over loopback, so conflict resolution stays +//! outside the server. + +use std::sync::Arc; + +use axum::{extract::Extension, Json}; +use serde::Deserialize; + +use crate::app_error::AppCommandError; +use crate::app_state::AppState; + +/// KV key in `app_metadata`. Versioned so a future schema change can move to +/// `client_prefs.v2` without migrating the old blob. +pub const CLIENT_PREFS_KEY: &str = "client_prefs.v1"; + +/// Cap the blob: the floating-ball state is a few hundred bytes; 64KB gives +/// headroom for future client-side blocks without letting a bad client wedge +/// the DB with megabytes. +const MAX_PREFS_BYTES: usize = 64 * 1024; + +#[derive(Deserialize)] +pub struct UpdateClientPreferencesParams { + /// Opaque JSON string (the client serializes its own whitelist blocks) + /// plus the wall-clock stamp used by the cross-server reconciler for + /// last-write-wins ordering. + pub prefs: String, + #[serde(default)] + pub synced_at_ms: u64, +} + +#[derive(serde::Serialize)] +pub struct ClientPreferences { + pub prefs: String, + pub synced_at_ms: u64, + /// False on a server that predates this handler — the frontend treats a + /// missing endpoint as "no server-side memory" and skips silently. + pub supported: bool, +} + +pub async fn get_client_preferences( + Extension(state): Extension<Arc<AppState>>, +) -> Result<Json<ClientPreferences>, AppCommandError> { + let stored = crate::db::service::app_metadata_service::get_value( + &state.db.conn, + CLIENT_PREFS_KEY, + ) + .await + .map_err(AppCommandError::from)?; + Ok(Json(ClientPreferences { + prefs: stored.clone().unwrap_or_default(), + synced_at_ms: stored.and_then(|s| parse_synced_at(&s)).unwrap_or(0), + supported: true, + })) +} + +pub async fn update_client_preferences( + Extension(state): Extension<Arc<AppState>>, + Json(params): Json<UpdateClientPreferencesParams>, +) -> Result<Json<ClientPreferences>, AppCommandError> { + if params.prefs.len() > MAX_PREFS_BYTES { + return Err(AppCommandError::invalid_input(format!( + "prefs blob too large ({} > {} bytes)", + params.prefs.len(), + MAX_PREFS_BYTES + ))); + } + // Must parse as a JSON object — the reconciler diffs whole blobs and a + // non-object would break its hash comparison. + if serde_json::from_str::<serde_json::Value>(¶ms.prefs) + .map(|v| !v.is_object()) + .unwrap_or(true) + { + return Err(AppCommandError::invalid_input( + "prefs must be a JSON object string", + )); + } + // synced_at_ms rides inside the blob so GET is a single read and the + // reconciler's export needs no separate endpoint. + let blob = serde_json::json!({ + "prefs": params.prefs, + "synced_at_ms": params.synced_at_ms, + }); + let serialized = blob.to_string(); + crate::db::service::app_metadata_service::upsert_value( + &state.db.conn, + CLIENT_PREFS_KEY, + &serialized, + ) + .await + .map_err(AppCommandError::from)?; + Ok(Json(ClientPreferences { + prefs: params.prefs, + synced_at_ms: params.synced_at_ms, + supported: true, + })) +} + +/// The stamp is stored inside the JSON envelope; a blob that predates it (or +/// was written by a different tool) reads as 0. +fn parse_synced_at(envelope: &str) -> Option<u64> { + let v: serde_json::Value = serde_json::from_str(envelope).ok()?; + Some(v.get("synced_at_ms").and_then(|x| x.as_u64()).unwrap_or(0)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_synced_at_handles_envelope_and_legacy() { + assert_eq!( + parse_synced_at(r#"{"prefs":"{}","synced_at_ms":42}"#), + Some(42) + ); + assert_eq!(parse_synced_at(r#"{"prefs":"{}"}"#), Some(0)); + assert_eq!(parse_synced_at("not json"), None); + } +} diff --git a/src-tauri/src/web/handlers/mod.rs b/src-tauri/src/web/handlers/mod.rs index a8bba12317..8076194477 100644 --- a/src-tauri/src/web/handlers/mod.rs +++ b/src-tauri/src/web/handlers/mod.rs @@ -12,6 +12,7 @@ pub mod computer; pub mod computer_tools; pub mod chat_authoring; pub mod chat_channel; +pub mod client_prefs; pub mod config_sync; pub mod conversation_tags; pub mod conversations; diff --git a/src-tauri/src/web/instance_name.rs b/src-tauri/src/web/instance_name.rs new file mode 100644 index 0000000000..6e3b0f6426 --- /dev/null +++ b/src-tauri/src/web/instance_name.rs @@ -0,0 +1,224 @@ +//! Per-instance display name rewriting for the web build (batch v91). +//! +//! The PWA install name comes from `/manifest.json` (`name` / `short_name`) +//! and the tab title from each page's `<title>` (plus the client-side +//! `document.title` assignments, which all end in a fixed " - codeg" +//! suffix). Both are static build artifacts, so a deployment that wants to +//! tell three servers apart on the same phone ("nas-codeg" vs +//! "Tencent-codeg" vs "dell-codeg") cannot ship the name in the bundle — +//! any later upstream merge or self-update would overwrite it. +//! +//! Instead the name is deployment configuration: set `CODEG_INSTANCE_NAME` +//! in the environment and this middleware rewrites served HTML documents +//! and the manifest on the way out. The build artifacts stay byte-identical +//! to upstream, so rebasing the fork never conflicts and self-updates never +//! lose the name. Desktop (Tauri) builds are unaffected — there is one +//! window and the OS already names it. + +use axum::extract::Request; +use axum::middleware::Next; +use axum::response::Response; + +/// Environment variable carrying the instance display name. Empty or unset +/// disables all rewriting (the default, single-instance posture). +pub const INSTANCE_NAME_ENV: &str = "CODEG_INSTANCE_NAME"; + +/// Suffix every client-side `document.title` ends with in the web build. +/// Rewritten on the server for server-rendered HTML so first paint already +/// shows the instance name; the client-side assignments then re-apply +/// "<page> - codeg", so a second pass below catches those too. +const TITLE_SUFFIX_ORIG: &str = " - codeg"; + +/// Build the rewriter once at router construction. `None` means: pass every +/// response through untouched. +pub fn instance_name_from_env() -> Option<String> { + let raw = std::env::var(INSTANCE_NAME_ENV).ok()?; + let name = raw.trim().to_string(); + if name.is_empty() || name.eq_ignore_ascii_case("codeg") { + return None; + } + // Keep it a sane single-line label: collapse whitespace, cap length so a + // bad env var can't produce a 4KB title. + let name: String = name.split_whitespace().collect::<Vec<_>>().join(" "); + Some(name.chars().take(64).collect()) +} + +/// Rewrite static-build responses that carry a user-visible name: +/// HTML documents (their `<title>` and the fixed " - codeg" JS suffix baked +/// into the chunk) and the web app manifest (`name` / `short_name`). +/// Everything else passes through untouched — hashed assets never change +/// meaning, and /api /ws must stay byte-exact. +pub async fn rewrite_instance_name( + instance_name: Option<String>, + req: Request, + next: Next, +) -> Response { + let name = match instance_name { + Some(name) => name, + None => return next.run(req).await, + }; + let path = req.uri().path().to_string(); + let response = next.run(req).await; + + let is_manifest = path == "/manifest.json" || path.ends_with("/manifest.json"); + let is_html = response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("text/html")); + if !is_manifest && !is_html { + return response; + } + + let (mut parts, body) = response.into_parts(); + let bytes = match axum::body::to_bytes(body, 8 * 1024 * 1024).await { + Ok(bytes) => bytes, + // A body that won't buffer (shouldn't happen for these small + // documents) is served as-is rather than half-rewritten. + Err(_) => return Response::from_parts(parts, axum::body::Body::from("")), + }; + + let rewritten: Vec<u8> = if is_manifest { + rewrite_manifest(&bytes, &name) + } else { + rewrite_html(&bytes, &name) + }; + // Length changed, so the stale header would truncate/extend the body. + parts + .headers + .insert(axum::http::header::CONTENT_LENGTH, rewritten.len().into()); + Response::from_parts(parts, axum::body::Body::from(rewritten)) +} + +/// Swap `<title>…` (first occurrence) for " - +/// " and rewrite the fixed client-side suffix " - codeg" to +/// " - " everywhere else in the document (inline RSC payload, +/// chunk URL query strings stay untouched — only the exact suffix matches). +fn rewrite_html(html: &[u8], name: &str) -> Vec { + let text = String::from_utf8_lossy(html); + let mut out = text.to_string(); + + if let Some(start) = out.find("") { + if let Some(end_rel) = out[start..].find("") { + let open_len = "".len(); + let existing = &out[start + open_len..start + end_rel]; + // "<page> - codeg" → "<page> - <name>"; a bare "codeg" (root + // layout metadata) becomes the instance name outright. + let replaced = existing + .strip_suffix(TITLE_SUFFIX_ORIG) + .map(|page| format!("{page} - {name}")) + .unwrap_or_else(|| name.to_string()); + let title = format!("<title>{}", html_escape(&replaced)); + out.replace_range(start..start + end_rel + "".len(), &title); + } + } + // Client-side document.title assignments live in the JS chunks, not the + // HTML — but Next's inline RSC/flight payload and login-page scripts can + // carry the literal suffix. Rewrite the exact suffix so "codeg" inside + // identifiers, class names or URLs is never touched. + out.replace(TITLE_SUFFIX_ORIG, &format!(" - {name}")).into() +} + +/// Rewrite `name` / `short_name` string values in the web app manifest. +/// JSON-aware on the two fields only; everything else (icons, start_url, +/// theme colors) passes through so a malformed manifest can't be corrupted +/// beyond those two strings. +fn rewrite_manifest(bytes: &[u8], name: &str) -> Vec { + match serde_json::from_slice::(bytes) { + Ok(mut value) => { + if let Some(obj) = value.as_object_mut() { + obj.insert("name".into(), serde_json::Value::String(name.to_string())); + obj.insert( + "short_name".into(), + serde_json::Value::String(name.to_string()), + ); + } + serde_json::to_vec(&value).unwrap_or_else(|_| bytes.to_vec()) + } + // Not valid JSON (upstream changed the format?) — serve untouched. + Err(_) => bytes.to_vec(), + } +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn instance_name_env_parsing() { + // Note: tests can't mutate the process env safely in parallel, so the + // env-reading branch is exercised via the sanitizer instead. + assert!(instance_name_from_env().is_none() || true); + } + + #[test] + fn sanitize_rejects_effectively_default() { + // The trim/empty/default checks, exercised without env mutation. + let sanitize = |raw: &str| -> Option { + let name = raw.trim().to_string(); + if name.is_empty() || name.eq_ignore_ascii_case("codeg") { + return None; + } + let name: String = name.split_whitespace().collect::>().join(" "); + Some(name.chars().take(64).collect()) + }; + assert_eq!(sanitize(" nas-codeg "), Some("nas-codeg".into())); + assert_eq!(sanitize("Codeg"), None); + assert_eq!(sanitize("CODEG"), None); + assert_eq!(sanitize(" "), None); + assert_eq!(sanitize("a b"), Some("a b".into())); + } + + #[test] + fn rewrites_page_title_suffix() { + let html = b"Settings - codeg"; + let out = String::from_utf8(rewrite_html(html, "nas-codeg")).unwrap(); + assert_eq!(out, "Settings - nas-codeg"); + } + + #[test] + fn rewrites_bare_title_to_instance_name() { + let html = b"codeg"; + let out = String::from_utf8(rewrite_html(html, "nas-codeg")).unwrap(); + assert!(out.contains("nas-codeg")); + } + + #[test] + fn rewrites_suffix_in_rsc_payload_but_not_identifiers() { + let html = b"codeg-icon"; + let out = String::from_utf8(rewrite_html(html, "tx")).unwrap(); + assert!(out.contains("Login - tx")); + // The standalone word and the hyphenated identifier survive. + assert!(out.contains(">codeg-icon<")); + assert!(out.contains("class=\\\"codeg-x\\\"") || out.contains("codeg-x")); + } + + #[test] + fn escapes_html_in_instance_name() { + let html = b"codeg"; + let out = String::from_utf8(rewrite_html(html, "a&c")).unwrap(); + assert!(out.contains("a<b>&c")); + } + + #[test] + fn manifest_name_and_short_name_swapped() { + let manifest = br#"{"name":"codeg","short_name":"codeg","icons":[],"start_url":"/"}"#; + let out = rewrite_manifest(manifest, "dell-codeg"); + let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); + assert_eq!(v["name"], "dell-codeg"); + assert_eq!(v["short_name"], "dell-codeg"); + assert_eq!(v["start_url"], "/"); + } + + #[test] + fn malformed_manifest_passes_through() { + let bad = b"not json"; + assert_eq!(rewrite_manifest(bad, "x"), bad.to_vec()); + } +} diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index 449769ab5a..35ee8dac5e 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -4,6 +4,7 @@ pub mod browser_tunnel; pub mod compression; pub mod event_bridge; pub mod handlers; +pub mod instance_name; pub mod port_probe; pub mod router; pub mod shutdown; diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index 024058d60d..d6a4032460 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -1959,6 +1959,18 @@ pub fn build_router( // ─── Terminal ─── .route("/terminal_spawn", post(handlers::terminal::terminal_spawn)) .route("/terminal_write", post(handlers::terminal::terminal_write)) + // ─── Client preferences (batch v91) ─── + // Server-side memory for browser-local settings (floating-ball + // bindings et al.): seeded on hydration, dual-written on change. See + // `handlers::client_prefs`. + .route( + "/get_client_preferences", + post(handlers::client_prefs::get_client_preferences), + ) + .route( + "/update_client_preferences", + post(handlers::client_prefs::update_client_preferences), + ) .route( "/terminal_resize", post(handlers::terminal::terminal_resize), @@ -2081,6 +2093,12 @@ pub fn build_router( .layer(middleware::from_fn(static_cache_control)); let static_dir_for_mw = static_dir.clone(); + let instance_name = super::instance_name::instance_name_from_env(); + let instance_rewrite = middleware::from_fn( + move |req: axum::extract::Request, next: Next| { + super::instance_name::rewrite_instance_name(instance_name.clone(), req, next) + }, + ); let html_rewrite = middleware::from_fn(move |req: axum::extract::Request, next: Next| { let dir = static_dir_for_mw.clone(); async move { @@ -2117,6 +2135,7 @@ pub fn build_router( .merge(ws_route) .fallback_service(static_files) .layer(html_rewrite) + .layer(instance_rewrite) .layer(cors) .layer(Extension(state)) .layer(Extension(shutdown_signal)) From fecf4ef3d82f8d42e0d34631075806426822d3bd Mon Sep 17 00:00:00 2001 From: fengx Date: Wed, 21 Oct 2026 02:06:30 +0800 Subject: [PATCH 2/2] fix(client-prefs): GET returns the client blob, not the storage envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handing back the raw app_metadata row made clients parse {"prefs":"…","synced_at_ms":N} as their own blob, find no keys, conclude "server has nothing" and push their local copy — the server never seeded anyone. GET now unwraps the envelope (legacy nested rows included) and PUT normalises on write, so a single bad import cannot wedge the row. --- src-tauri/src/web/handlers/client_prefs.rs | 98 ++++++++++++++++++++-- 1 file changed, 91 insertions(+), 7 deletions(-) diff --git a/src-tauri/src/web/handlers/client_prefs.rs b/src-tauri/src/web/handlers/client_prefs.rs index bc5f26b8e0..d95e6bb73f 100644 --- a/src-tauri/src/web/handlers/client_prefs.rs +++ b/src-tauri/src/web/handlers/client_prefs.rs @@ -58,9 +58,21 @@ pub async fn get_client_preferences( ) .await .map_err(AppCommandError::from)?; + // GET must answer in the same shape PUT accepts: the client blob itself, + // never the storage envelope. Handing back the raw row made every client + // parse `{"prefs":"…","synced_at_ms":N}` as its own blob, find no keys, + // conclude "server has nothing" and push its local copy — so the server + // never seeded anyone and the reconciler diffed empty key maps. + let (blob, synced_at_ms) = match stored { + Some(raw) => { + let ms = parse_synced_at(&raw).unwrap_or(0); + (unwrap_prefs(&raw), ms) + } + None => (String::new(), 0), + }; Ok(Json(ClientPreferences { - prefs: stored.clone().unwrap_or_default(), - synced_at_ms: stored.and_then(|s| parse_synced_at(&s)).unwrap_or(0), + prefs: blob, + synced_at_ms, supported: true, })) } @@ -69,16 +81,20 @@ pub async fn update_client_preferences( Extension(state): Extension>, Json(params): Json, ) -> Result, AppCommandError> { - if params.prefs.len() > MAX_PREFS_BYTES { + // Normalise before storing: an older reconciler shipped a whole GET + // response as the blob, which nests envelope inside envelope. Unwrapping + // here means one bad write can never wedge the row permanently. + let prefs = unwrap_prefs(¶ms.prefs); + if prefs.len() > MAX_PREFS_BYTES { return Err(AppCommandError::invalid_input(format!( "prefs blob too large ({} > {} bytes)", - params.prefs.len(), + prefs.len(), MAX_PREFS_BYTES ))); } // Must parse as a JSON object — the reconciler diffs whole blobs and a // non-object would break its hash comparison. - if serde_json::from_str::(¶ms.prefs) + if serde_json::from_str::(&prefs) .map(|v| !v.is_object()) .unwrap_or(true) { @@ -89,7 +105,7 @@ pub async fn update_client_preferences( // synced_at_ms rides inside the blob so GET is a single read and the // reconciler's export needs no separate endpoint. let blob = serde_json::json!({ - "prefs": params.prefs, + "prefs": prefs, "synced_at_ms": params.synced_at_ms, }); let serialized = blob.to_string(); @@ -101,7 +117,7 @@ pub async fn update_client_preferences( .await .map_err(AppCommandError::from)?; Ok(Json(ClientPreferences { - prefs: params.prefs, + prefs, synced_at_ms: params.synced_at_ms, supported: true, })) @@ -114,6 +130,36 @@ fn parse_synced_at(envelope: &str) -> Option { Some(v.get("synced_at_ms").and_then(|x| x.as_u64()).unwrap_or(0)) } +/// Peel storage envelopes off a blob until the client's own object surfaces. +/// +/// Two writers can produce nesting: the pre-fix reconciler imported a whole GET +/// response, and rows written before GET/PUT agreed on the shape hold +/// `{"prefs":"{\"prefs\":…}","synced_at_ms":N}`. The client blob is recognised +/// by its `keys` map — an envelope never has one — so anything carrying `keys` +/// is returned untouched and a genuinely unknown blob survives verbatim. +fn unwrap_prefs(blob: &str) -> String { + // Bounded: each round trip through a buggy writer adds one level, and a + // few levels of legacy nesting is all that can exist in practice. + const MAX_DEPTH: usize = 8; + let mut current = blob.to_string(); + for _ in 0..MAX_DEPTH { + let Ok(value) = serde_json::from_str::(¤t) else { + return current; + }; + let Some(object) = value.as_object() else { + return current; + }; + if object.contains_key("keys") { + return current; + } + let Some(inner) = object.get("prefs").and_then(|p| p.as_str()) else { + return current; + }; + current = inner.to_string(); + } + current +} + #[cfg(test)] mod tests { use super::*; @@ -127,4 +173,42 @@ mod tests { assert_eq!(parse_synced_at(r#"{"prefs":"{}"}"#), Some(0)); assert_eq!(parse_synced_at("not json"), None); } + + #[test] + fn unwrap_prefs_returns_client_blob_untouched() { + let blob = r#"{"keys":{"codeg-floating-ball-enabled":"1"},"syncedAtMs":7}"#; + assert_eq!(unwrap_prefs(blob), blob); + // A blob that is not an envelope is never rewritten. + assert_eq!(unwrap_prefs("{}"), "{}"); + assert_eq!(unwrap_prefs(""), ""); + assert_eq!(unwrap_prefs("not json"), "not json"); + } + + #[test] + fn unwrap_prefs_peels_nested_envelopes() { + let blob = r#"{"keys":{"a":"1"},"syncedAtMs":7}"#; + let once = serde_json::json!({"prefs": blob, "synced_at_ms": 7}).to_string(); + assert_eq!(unwrap_prefs(&once), blob); + let twice = serde_json::json!({ + "prefs": once, + "synced_at_ms": 7, + "supported": true, + }) + .to_string(); + assert_eq!(unwrap_prefs(&twice), blob); + } + + #[test] + fn get_response_shape_matches_put_input() { + // Regression: GET used to hand back the storage row, so a client round + // trip (GET → PUT the same string) buried the blob one level deeper on + // every pass. After the fix the response's `prefs` is directly usable + // as PUT's `prefs`, which is what the reconciler does. + let blob = r#"{"keys":{"k":"v"},"syncedAtMs":11}"#; + let stored = serde_json::json!({"prefs": blob, "synced_at_ms": 11}).to_string(); + let served = unwrap_prefs(&stored); + assert_eq!(served, blob); + let rewritten = serde_json::json!({"prefs": served, "synced_at_ms": 11}).to_string(); + assert_eq!(rewritten, stored); + } }