Add Sync now button that uploads a bookmarks snapshot

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
  `<profile_data>/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.
This commit is contained in:
2026-05-15 17:15:07 -04:00
parent a47fbdc09c
commit 6bacb3faa8
3 changed files with 139 additions and 22 deletions
+34 -13
View File
@@ -136,19 +136,40 @@ fn render_metric(label: &'static str, value: usize, color: u32) -> AnyElement {
fn render_reset_button(cx: &mut Context<ElyShell>) -> AnyElement { fn render_reset_button(cx: &mut Context<ElyShell>) -> AnyElement {
div() div()
.id(SharedString::from("sync-reset")) .flex()
.px(px(12.0)) .gap(px(8.0))
.py(px(7.0)) .child(
.rounded(px(8.0)) div()
.bg(rgba(BUTTON_BG)) .id(SharedString::from("sync-upload"))
.text_size(px(12.0)) .px(px(12.0))
.font_weight(FontWeight(500.0)) .py(px(7.0))
.text_color(rgb(colors::INK_2)) .rounded(px(8.0))
.cursor_pointer() .bg(rgba(colors::ACCENT))
.hover(|style| style.bg(rgba(BUTTON_BG_HOVER))) .text_size(px(12.0))
.active(|style| style.opacity(0.85)) .font_weight(FontWeight(500.0))
.on_click(cx.listener(|shell, _, _, cx| shell.reset_sync_settings(cx))) .text_color(rgb(0xfff5e6))
.child("Reset to defaults") .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() .into_any_element()
} }
@@ -1,3 +1,4 @@
use ely_browser_core::SyncEngine;
use ely_domain::{ use ely_domain::{
ArchivePolicy, DEFAULT_TRANSLUCENCY_PCT, DiagnosticsReportingPolicy, DownloadPolicy, ArchivePolicy, DEFAULT_TRANSLUCENCY_PCT, DiagnosticsReportingPolicy, DownloadPolicy,
FavoriteLimit, HistoryRecordingPolicy, NewTabDestination, ProfileId, ProfileSyncPolicy, FavoriteLimit, HistoryRecordingPolicy, NewTabDestination, ProfileId, ProfileSyncPolicy,
@@ -6,6 +7,8 @@ use ely_domain::{
use gpui::Context; use gpui::Context;
use gpui_component::slider::SliderValue; use gpui_component::slider::SliderValue;
use crate::services::servo_profile_data::{default_profile_data_root, profile_data_dir};
use super::{ElyShell, ShellState}; use super::{ElyShell, ShellState};
impl ElyShell { 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<Self>) {
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( pub(super) fn set_update_policy(
&mut self, &mut self,
update_policy: UpdatePolicy, update_policy: UpdatePolicy,
@@ -264,3 +311,41 @@ impl ElyShell {
} }
} }
} }
fn run_sync_upload(profile_dir: std::path::PathBuf, device_name: String, bytes: Vec<u8>) {
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"
}
}
+20 -9
View File
@@ -75,20 +75,17 @@ impl SyncEngine {
self.bearer_store.load().map(|token| token.is_some()) self.bearer_store.load().map(|token| token.is_some())
} }
/// Build the JSON snapshot payload and ship it to the worker. The /// Ship a pre-serialised snapshot payload to the worker. Callers
/// caller passes the BrowserCore directly so the snapshot reads /// usually pair this with `BrowserCore::build_sync_snapshot_bytes`
/// the latest committed state; we don't keep a parallel copy. /// — building the bytes on the UI thread and only crossing the
pub fn upload_now(&mut self, core: &BrowserCore) -> Result<SyncOutcome, SyncClientError> { /// thread boundary with `Vec<u8>` keeps `BrowserCore` itself
/// single-threaded.
pub fn upload_bytes(&mut self, bytes: Vec<u8>) -> Result<SyncOutcome, SyncClientError> {
let Some(bearer) = self.bearer_store.load()? else { let Some(bearer) = self.bearer_store.load()? else {
let outcome = SyncOutcome::SignedOut; let outcome = SyncOutcome::SignedOut;
self.last_outcome = Some(outcome.clone()); self.last_outcome = Some(outcome.clone());
return Ok(outcome); 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 payload = SnapshotPayload::new(bytes)?;
let logical_clock = current_logical_clock(); let logical_clock = current_logical_clock();
let snapshot_id = snapshot_id_for_user(&self.identity); 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) 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<Vec<u8>, SyncClientError> {
let body = SyncSnapshotBody::from_core(self);
serde_json::to_vec(&body).map_err(|error| SyncClientError::Json {
endpoint: "snapshot".to_string(),
source: error,
})
}
}