From 6bacb3faa878fdd413ef7af6542291514bcec253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 15 May 2026 17:15:07 -0400 Subject: [PATCH] Add Sync now button that uploads a bookmarks snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire `SyncEngine::upload_bytes` to a Settings → Sync button: - `BrowserCore::build_sync_snapshot_bytes` serialises the user's bookmarks on the UI thread (cheap, synchronous). - `ElyShell::trigger_cloud_sync_upload` resolves the active profile data dir, spawns a dedicated `ely-sync-upload` thread, and lets the engine run the blocking HTTP round-trip there so the GPUI render loop never stalls on the network — the same invariant the Servo IPC worker enforces. - Outcomes go through `tracing` on the `ely::sync` target. Users drop a Better Auth bearer token into `/sync/bearer.token` to opt in; without one, the engine reports `SignedOut` and the click is a no-op. The Better Auth handshake + device-approval UX still need their own UI passes; this lands the data-plane plumbing so those pieces slot in without re-architecting the snapshot path. --- .../ely_app/src/shell/internal_pages/sync.rs | 47 +++++++--- crates/ely_app/src/shell/settings_actions.rs | 85 +++++++++++++++++++ crates/ely_browser_core/src/sync_engine.rs | 29 +++++-- 3 files changed, 139 insertions(+), 22 deletions(-) diff --git a/crates/ely_app/src/shell/internal_pages/sync.rs b/crates/ely_app/src/shell/internal_pages/sync.rs index 0fc7cf1..c20f10c 100644 --- a/crates/ely_app/src/shell/internal_pages/sync.rs +++ b/crates/ely_app/src/shell/internal_pages/sync.rs @@ -136,19 +136,40 @@ fn render_metric(label: &'static str, value: usize, color: u32) -> AnyElement { fn render_reset_button(cx: &mut Context) -> AnyElement { div() - .id(SharedString::from("sync-reset")) - .px(px(12.0)) - .py(px(7.0)) - .rounded(px(8.0)) - .bg(rgba(BUTTON_BG)) - .text_size(px(12.0)) - .font_weight(FontWeight(500.0)) - .text_color(rgb(colors::INK_2)) - .cursor_pointer() - .hover(|style| style.bg(rgba(BUTTON_BG_HOVER))) - .active(|style| style.opacity(0.85)) - .on_click(cx.listener(|shell, _, _, cx| shell.reset_sync_settings(cx))) - .child("Reset to defaults") + .flex() + .gap(px(8.0)) + .child( + div() + .id(SharedString::from("sync-upload")) + .px(px(12.0)) + .py(px(7.0)) + .rounded(px(8.0)) + .bg(rgba(colors::ACCENT)) + .text_size(px(12.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(0xfff5e6)) + .cursor_pointer() + .hover(|style| style.opacity(0.92)) + .active(|style| style.opacity(0.78)) + .on_click(cx.listener(|shell, _, _, cx| shell.trigger_cloud_sync_upload(cx))) + .child("Sync now"), + ) + .child( + div() + .id(SharedString::from("sync-reset")) + .px(px(12.0)) + .py(px(7.0)) + .rounded(px(8.0)) + .bg(rgba(BUTTON_BG)) + .text_size(px(12.0)) + .font_weight(FontWeight(500.0)) + .text_color(rgb(colors::INK_2)) + .cursor_pointer() + .hover(|style| style.bg(rgba(BUTTON_BG_HOVER))) + .active(|style| style.opacity(0.85)) + .on_click(cx.listener(|shell, _, _, cx| shell.reset_sync_settings(cx))) + .child("Reset to defaults"), + ) .into_any_element() } diff --git a/crates/ely_app/src/shell/settings_actions.rs b/crates/ely_app/src/shell/settings_actions.rs index 1130a23..a28fb4d 100644 --- a/crates/ely_app/src/shell/settings_actions.rs +++ b/crates/ely_app/src/shell/settings_actions.rs @@ -1,3 +1,4 @@ +use ely_browser_core::SyncEngine; use ely_domain::{ ArchivePolicy, DEFAULT_TRANSLUCENCY_PCT, DiagnosticsReportingPolicy, DownloadPolicy, FavoriteLimit, HistoryRecordingPolicy, NewTabDestination, ProfileId, ProfileSyncPolicy, @@ -6,6 +7,8 @@ use ely_domain::{ use gpui::Context; use gpui_component::slider::SliderValue; +use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir}; + use super::{ElyShell, ShellState}; impl ElyShell { @@ -238,6 +241,50 @@ impl ElyShell { } } + /// Push the active profile's bookmarks to `ely-browser-cloud` as a + /// snapshot. The HTTP round-trip runs on a dedicated worker thread + /// (the UI thread never blocks on the network), and the result is + /// emitted via `tracing` so the user can inspect it through + /// `RUST_LOG=ely::sync=info`. No bearer token on disk → the + /// engine reports `SignedOut` and the click is a no-op. + pub(super) fn trigger_cloud_sync_upload(&mut self, _cx: &mut Context) { + let ShellState::Ready(core) = &self.state else { + return; + }; + let Some(snapshot) = core.snapshot().ok() else { + return; + }; + let active_profile_id = snapshot.active_profile_id.clone(); + let device_name = format!("ELY · {}", snapshot.active_profile_name); + let Some(profile_root) = default_profile_data_root() else { + tracing::warn!(target: "ely::sync", "profile data root is unavailable"); + return; + }; + let profile_dir = profile_data_dir(&profile_root, &active_profile_id); + let bytes = match core.build_sync_snapshot_bytes() { + Ok(bytes) => bytes, + Err(error) => { + tracing::warn!( + target: "ely::sync", + error = %error, + "snapshot serialisation failed; aborting upload", + ); + return; + } + }; + std::thread::Builder::new() + .name("ely-sync-upload".to_string()) + .spawn(move || run_sync_upload(profile_dir, device_name, bytes)) + .map(|_| ()) + .unwrap_or_else(|error| { + tracing::warn!( + target: "ely::sync", + error = %error, + "failed to spawn ely-sync-upload thread", + ); + }); + } + pub(super) fn set_update_policy( &mut self, update_policy: UpdatePolicy, @@ -264,3 +311,41 @@ impl ElyShell { } } } + +fn run_sync_upload(profile_dir: std::path::PathBuf, device_name: String, bytes: Vec) { + let mut engine = match SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform()) { + Ok(engine) => engine, + Err(error) => { + tracing::warn!( + target: "ely::sync", + error = %error, + "could not initialise sync engine", + ); + return; + } + }; + match engine.upload_bytes(bytes) { + Ok(outcome) => tracing::info!( + target: "ely::sync", + outcome = ?outcome, + "snapshot upload complete", + ), + Err(error) => tracing::warn!( + target: "ely::sync", + error = %error, + "snapshot upload failed", + ), + } +} + +const fn sync_platform() -> &'static str { + if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "other" + } +} diff --git a/crates/ely_browser_core/src/sync_engine.rs b/crates/ely_browser_core/src/sync_engine.rs index c773ee5..a0fc558 100644 --- a/crates/ely_browser_core/src/sync_engine.rs +++ b/crates/ely_browser_core/src/sync_engine.rs @@ -75,20 +75,17 @@ impl SyncEngine { self.bearer_store.load().map(|token| token.is_some()) } - /// Build the JSON snapshot payload and ship it to the worker. The - /// caller passes the BrowserCore directly so the snapshot reads - /// the latest committed state; we don't keep a parallel copy. - pub fn upload_now(&mut self, core: &BrowserCore) -> Result { + /// Ship a pre-serialised snapshot payload to the worker. Callers + /// usually pair this with `BrowserCore::build_sync_snapshot_bytes` + /// — building the bytes on the UI thread and only crossing the + /// thread boundary with `Vec` keeps `BrowserCore` itself + /// single-threaded. + pub fn upload_bytes(&mut self, bytes: Vec) -> Result { let Some(bearer) = self.bearer_store.load()? else { let outcome = SyncOutcome::SignedOut; self.last_outcome = Some(outcome.clone()); return Ok(outcome); }; - let snapshot = SyncSnapshotBody::from_core(core); - let bytes = serde_json::to_vec(&snapshot).map_err(|error| SyncClientError::Json { - endpoint: "snapshot".to_string(), - source: error, - })?; let payload = SnapshotPayload::new(bytes)?; let logical_clock = current_logical_clock(); let snapshot_id = snapshot_id_for_user(&self.identity); @@ -201,3 +198,17 @@ impl SyncEngineBuilder { SyncEngine::for_profile_dir(&self.profile_data_dir, self.device_name, self.platform) } } + +impl BrowserCore { + /// Build the JSON byte payload the sync engine expects. Lives on + /// `BrowserCore` so the snapshot reads the live state and so the + /// UI thread does the (synchronous, cheap) serialization before + /// handing bytes off to the worker thread. + pub fn build_sync_snapshot_bytes(&self) -> Result, SyncClientError> { + let body = SyncSnapshotBody::from_core(self); + serde_json::to_vec(&body).map_err(|error| SyncClientError::Json { + endpoint: "snapshot".to_string(), + source: error, + }) + } +}