Reflect real sync state on the Sync settings page
`SyncConnectionState` was a one-variant enum (`SignedOut`), so the
Sync page rendered "Local-only · sign-in coming soon" even after the
bearer token landed on disk and the upload thread completed. The
state machine now mirrors the actual lifecycle.
What lands:
- `SyncConnectionState` gains `SignedIn`, `AwaitingDeviceApproval`,
`SyncReady { last_synced_at_secs }`, `SyncError { message }`.
`SyncObjectState::Synced` joins the per-object enum so individual
rows can advertise "Synced" once a successful upload lands.
- `BrowserCore` stores the current `SyncConnectionState` and exposes
`set_sync_connection_state`. `sync_status` now propagates the live
state into the snapshot the UI reads.
- `ElyShell::probe_initial_sync_state` inspects
`<profile_data>/sync/bearer.token` synchronously at construction
so the first render of the sync page is honest about whether the
user is signed in.
- A `std::sync::mpsc` channel ferries upload outcomes from the
off-thread worker back to the shell; the existing 8 ms tick
drains it and stamps `core.set_sync_connection_state` with the
freshest result. The UI now shows "Signed in · awaiting first
sync", "Synced · last upload Xm ago", "Sync error · …", and the
worker-special "Signed in · waiting for device approval" when the
server returns `device_not_approved`.
This commit is contained in:
@@ -266,6 +266,7 @@ fn render_state_dot(state: SyncObjectState) -> AnyElement {
|
||||
SyncObjectState::LocalOnly => colors::ink_4(),
|
||||
SyncObjectState::Paused => colors::ink_5(),
|
||||
SyncObjectState::PrivacyControlled => colors::accent(),
|
||||
SyncObjectState::Synced => colors::success(),
|
||||
};
|
||||
|
||||
div().size(px(8.0)).rounded_full().bg(rgb(color)).into_any_element()
|
||||
@@ -304,10 +305,46 @@ fn render_policy_toggle(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn connection_label(connection: &SyncConnectionState) -> &'static str {
|
||||
fn connection_label(connection: &SyncConnectionState) -> String {
|
||||
match connection {
|
||||
SyncConnectionState::SignedOut => "Local-only · sign-in coming soon",
|
||||
SyncConnectionState::SignedOut => "Local-only · drop a session token to enable".to_string(),
|
||||
SyncConnectionState::SignedIn => "Signed in · awaiting first sync".to_string(),
|
||||
SyncConnectionState::AwaitingDeviceApproval => {
|
||||
"Signed in · waiting for device approval".to_string()
|
||||
}
|
||||
SyncConnectionState::SyncReady { last_synced_at_secs } => {
|
||||
format!("Synced · last upload {}", relative_time_since(*last_synced_at_secs))
|
||||
}
|
||||
SyncConnectionState::SyncError { message } => {
|
||||
format!("Sync error · {}", short_message(message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_time_since(secs: u64) -> String {
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
let when = UNIX_EPOCH + Duration::from_secs(secs);
|
||||
let elapsed = SystemTime::now().duration_since(when).unwrap_or_default();
|
||||
let total_secs = elapsed.as_secs();
|
||||
if total_secs < 60 {
|
||||
return format!("{total_secs}s ago");
|
||||
}
|
||||
if total_secs < 3600 {
|
||||
return format!("{}m ago", total_secs / 60);
|
||||
}
|
||||
if total_secs < 86400 {
|
||||
return format!("{}h ago", total_secs / 3600);
|
||||
}
|
||||
format!("{}d ago", total_secs / 86400)
|
||||
}
|
||||
|
||||
fn short_message(message: &str) -> String {
|
||||
const MAX_LEN: usize = 72;
|
||||
if message.len() <= MAX_LEN {
|
||||
return message.to_string();
|
||||
}
|
||||
let truncated: String = message.chars().take(MAX_LEN - 1).collect();
|
||||
format!("{truncated}…")
|
||||
}
|
||||
|
||||
fn sync_object_kind_label(kind: SyncObjectKind) -> &'static str {
|
||||
@@ -329,6 +366,7 @@ fn sync_object_state_label(state: SyncObjectState) -> &'static str {
|
||||
SyncObjectState::LocalOnly => "Local only",
|
||||
SyncObjectState::Paused => "Paused",
|
||||
SyncObjectState::PrivacyControlled => "Privacy controlled",
|
||||
SyncObjectState::Synced => "Synced",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -99,10 +99,29 @@ pub struct ElyShell {
|
||||
pending_plugin_install: Option<PendingPluginInstall>,
|
||||
pending_plugin_uninstall: Option<PendingPluginUninstall>,
|
||||
web_surfaces: WebSurfaceStore,
|
||||
/// Receives sync upload outcomes from the off-thread worker so the
|
||||
/// shell can refresh the connection state without blocking. Sender
|
||||
/// is cloned for each spawned upload; receiver is drained on every
|
||||
/// `tick_external_web_surfaces`.
|
||||
sync_inbox_rx: std::sync::mpsc::Receiver<SyncStateUpdate>,
|
||||
pub(crate) sync_inbox_tx: std::sync::mpsc::Sender<SyncStateUpdate>,
|
||||
_command_subscription: Subscription,
|
||||
_translucency_subscription: Subscription,
|
||||
}
|
||||
|
||||
/// Messages the off-thread sync worker pushes back to the shell so the
|
||||
/// `SyncConnectionState` on `BrowserCore` reflects the live engine
|
||||
/// without the UI thread ever touching the network. `SignedIn` is the
|
||||
/// initial-probe state set synchronously on shell startup and does not
|
||||
/// flow through this channel.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum SyncStateUpdate {
|
||||
SignedOut,
|
||||
AwaitingDeviceApproval,
|
||||
SyncReady { last_synced_at_secs: u64 },
|
||||
SyncError { message: String },
|
||||
}
|
||||
|
||||
impl ElyShell {
|
||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
Self::new_with_config(InitialBrowserConfig::ely_defaults(), window, cx)
|
||||
@@ -182,7 +201,8 @@ impl ElyShell {
|
||||
Err(error) => ShellState::StartupError(error.to_string()),
|
||||
};
|
||||
|
||||
let shell = Self {
|
||||
let (sync_inbox_tx, sync_inbox_rx) = std::sync::mpsc::channel();
|
||||
let mut shell = Self {
|
||||
state,
|
||||
focus_handle: cx.focus_handle(),
|
||||
command_input,
|
||||
@@ -215,13 +235,76 @@ impl ElyShell {
|
||||
pending_plugin_install: None,
|
||||
pending_plugin_uninstall: None,
|
||||
web_surfaces: WebSurfaceStore::new(),
|
||||
sync_inbox_rx,
|
||||
sync_inbox_tx,
|
||||
_command_subscription: command_subscription,
|
||||
_translucency_subscription: translucency_subscription,
|
||||
};
|
||||
shell.probe_initial_sync_state();
|
||||
start_external_web_surface_timer(cx);
|
||||
shell
|
||||
}
|
||||
|
||||
/// Inspect the on-disk bearer token (if any) and seed the
|
||||
/// `SyncConnectionState` so the Sync settings page reads the right
|
||||
/// label on first render — without any sync setting page open it
|
||||
/// would otherwise stay `SignedOut` until the user clicks Sync now.
|
||||
fn probe_initial_sync_state(&mut self) {
|
||||
let ShellState::Ready(core) = &mut self.state else {
|
||||
return;
|
||||
};
|
||||
let Some(snapshot) = core.snapshot().ok() else {
|
||||
return;
|
||||
};
|
||||
let active_profile_id = snapshot.active_profile_id.clone();
|
||||
let Some(profile_root) = crate::services::servo_profile_data::default_profile_data_root()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let profile_dir = crate::services::servo_profile_data::profile_data_dir(
|
||||
&profile_root,
|
||||
&active_profile_id,
|
||||
);
|
||||
let bearer_path = profile_dir.join("sync").join("bearer.token");
|
||||
let bearer_present = std::fs::metadata(&bearer_path).map(|m| m.len() > 0).unwrap_or(false);
|
||||
let state = if bearer_present {
|
||||
ely_domain::SyncConnectionState::SignedIn
|
||||
} else {
|
||||
ely_domain::SyncConnectionState::SignedOut
|
||||
};
|
||||
core.set_sync_connection_state(state);
|
||||
}
|
||||
|
||||
/// Drain any sync upload outcomes the off-thread worker pushed
|
||||
/// since the previous tick and stamp the resulting connection
|
||||
/// state on `BrowserCore`. Returns `true` when at least one
|
||||
/// update was applied so callers can `cx.notify()` accordingly.
|
||||
pub(super) fn drain_sync_updates(&mut self) -> bool {
|
||||
let ShellState::Ready(core) = &mut self.state else {
|
||||
return false;
|
||||
};
|
||||
let mut latest: Option<ely_domain::SyncConnectionState> = None;
|
||||
while let Ok(update) = self.sync_inbox_rx.try_recv() {
|
||||
latest = Some(match update {
|
||||
SyncStateUpdate::SignedOut => ely_domain::SyncConnectionState::SignedOut,
|
||||
SyncStateUpdate::AwaitingDeviceApproval => {
|
||||
ely_domain::SyncConnectionState::AwaitingDeviceApproval
|
||||
}
|
||||
SyncStateUpdate::SyncReady { last_synced_at_secs } => {
|
||||
ely_domain::SyncConnectionState::SyncReady { last_synced_at_secs }
|
||||
}
|
||||
SyncStateUpdate::SyncError { message } => {
|
||||
ely_domain::SyncConnectionState::SyncError { message }
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(state) = latest {
|
||||
core.set_sync_connection_state(state);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn focus_command_mode(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let ShellState::Ready(core) = &mut self.state {
|
||||
core.set_command_query(">");
|
||||
|
||||
@@ -243,10 +243,9 @@ 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.
|
||||
/// (the UI thread never blocks on the network), and the worker
|
||||
/// reports back through the shell's `sync_inbox` so the sync page
|
||||
/// reflects the new state without waiting for a manual refresh.
|
||||
pub(super) fn trigger_cloud_sync_upload(&mut self, _cx: &mut Context<Self>) {
|
||||
let ShellState::Ready(core) = &self.state else {
|
||||
return;
|
||||
@@ -272,9 +271,10 @@ impl ElyShell {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let tx = self.sync_inbox_tx.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("ely-sync-upload".to_string())
|
||||
.spawn(move || run_sync_upload(profile_dir, device_name, bytes))
|
||||
.spawn(move || run_sync_upload(profile_dir, device_name, bytes, tx))
|
||||
.map(|_| ())
|
||||
.unwrap_or_else(|error| {
|
||||
tracing::warn!(
|
||||
@@ -312,29 +312,56 @@ impl ElyShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_sync_upload(profile_dir: std::path::PathBuf, device_name: String, bytes: Vec<u8>) {
|
||||
fn run_sync_upload(
|
||||
profile_dir: std::path::PathBuf,
|
||||
device_name: String,
|
||||
bytes: Vec<u8>,
|
||||
inbox: std::sync::mpsc::Sender<super::SyncStateUpdate>,
|
||||
) {
|
||||
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",
|
||||
);
|
||||
let message = error.to_string();
|
||||
tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine");
|
||||
let _ = inbox.send(super::SyncStateUpdate::SyncError { message });
|
||||
return;
|
||||
}
|
||||
};
|
||||
match engine.upload_bytes(bytes) {
|
||||
Ok(outcome) => tracing::info!(
|
||||
Ok(ely_browser_core::SyncOutcome::SignedOut) => {
|
||||
tracing::info!(target: "ely::sync", "no bearer token on disk; sync skipped");
|
||||
let _ = inbox.send(super::SyncStateUpdate::SignedOut);
|
||||
}
|
||||
Ok(ely_browser_core::SyncOutcome::Uploaded {
|
||||
snapshot_id,
|
||||
logical_clock,
|
||||
payload_bytes,
|
||||
device_id,
|
||||
}) => {
|
||||
tracing::info!(
|
||||
target: "ely::sync",
|
||||
outcome = ?outcome,
|
||||
snapshot_id = %snapshot_id,
|
||||
logical_clock,
|
||||
payload_bytes,
|
||||
device_id = %device_id,
|
||||
"snapshot upload complete",
|
||||
),
|
||||
Err(error) => tracing::warn!(
|
||||
target: "ely::sync",
|
||||
error = %error,
|
||||
"snapshot upload failed",
|
||||
),
|
||||
);
|
||||
let last_synced_at_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let _ = inbox.send(super::SyncStateUpdate::SyncReady { last_synced_at_secs });
|
||||
}
|
||||
Err(error) => {
|
||||
let message = error.to_string();
|
||||
tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed");
|
||||
let update = if message.contains("device_not_approved") {
|
||||
super::SyncStateUpdate::AwaitingDeviceApproval
|
||||
} else {
|
||||
super::SyncStateUpdate::SyncError { message }
|
||||
};
|
||||
let _ = inbox.send(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,8 @@ impl ElyShell {
|
||||
for metadata in result.page_metadata {
|
||||
metadata_changed |= self.apply_web_surface_page_metadata(metadata);
|
||||
}
|
||||
result.changed || url_changed || metadata_changed
|
||||
let sync_changed = self.drain_sync_updates();
|
||||
result.changed || url_changed || metadata_changed || sync_changed
|
||||
}
|
||||
|
||||
pub(super) fn record_external_web_viewport(
|
||||
|
||||
@@ -162,6 +162,7 @@ pub struct BrowserCore {
|
||||
update_policy: UpdatePolicy,
|
||||
appearance: AppearanceSettings,
|
||||
sync_object_policies: SyncObjectPolicies,
|
||||
sync_connection_state: ely_domain::SyncConnectionState,
|
||||
command_query: String,
|
||||
}
|
||||
|
||||
@@ -210,6 +211,7 @@ impl BrowserCore {
|
||||
update_policy: UpdatePolicy::default(),
|
||||
appearance: AppearanceSettings::default(),
|
||||
sync_object_policies: SyncObjectPolicies::default(),
|
||||
sync_connection_state: ely_domain::SyncConnectionState::SignedOut,
|
||||
spaces: vec![space],
|
||||
profiles: vec![profile],
|
||||
tabs: vec![tab],
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use ely_domain::{SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus, SyncStatus};
|
||||
use ely_domain::{
|
||||
SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus,
|
||||
SyncStatus,
|
||||
};
|
||||
|
||||
use super::BrowserCore;
|
||||
|
||||
@@ -75,42 +78,44 @@ impl BrowserCore {
|
||||
self.sync_object_policies.get(kind)
|
||||
}
|
||||
|
||||
pub fn set_sync_connection_state(&mut self, state: SyncConnectionState) {
|
||||
self.sync_connection_state = state;
|
||||
}
|
||||
|
||||
pub(super) fn sync_status(&self) -> SyncStatus {
|
||||
SyncStatus::signed_out(vec![
|
||||
self.sync_object_status(
|
||||
SyncObjectKind::Spaces,
|
||||
self.spaces.len(),
|
||||
SyncObjectState::LocalOnly,
|
||||
),
|
||||
let enabled_state = match &self.sync_connection_state {
|
||||
SyncConnectionState::SyncReady { .. } => SyncObjectState::Synced,
|
||||
_ => SyncObjectState::LocalOnly,
|
||||
};
|
||||
SyncStatus::new(
|
||||
self.sync_connection_state.clone(),
|
||||
vec![
|
||||
self.sync_object_status(SyncObjectKind::Spaces, self.spaces.len(), enabled_state),
|
||||
self.sync_object_status(
|
||||
SyncObjectKind::Tabs,
|
||||
self.sync_enabled_tab_count(),
|
||||
SyncObjectState::LocalOnly,
|
||||
enabled_state,
|
||||
),
|
||||
self.sync_object_status(
|
||||
SyncObjectKind::Bookmarks,
|
||||
self.bookmarks.len(),
|
||||
SyncObjectState::LocalOnly,
|
||||
),
|
||||
self.sync_object_status(
|
||||
SyncObjectKind::Notes,
|
||||
self.notes.len(),
|
||||
SyncObjectState::LocalOnly,
|
||||
enabled_state,
|
||||
),
|
||||
self.sync_object_status(SyncObjectKind::Notes, self.notes.len(), enabled_state),
|
||||
self.sync_object_status(
|
||||
SyncObjectKind::ReadingList,
|
||||
self.reading_list.len(),
|
||||
SyncObjectState::LocalOnly,
|
||||
enabled_state,
|
||||
),
|
||||
self.sync_object_status(
|
||||
SyncObjectKind::Profiles,
|
||||
self.profiles.len(),
|
||||
SyncObjectState::LocalOnly,
|
||||
enabled_state,
|
||||
),
|
||||
self.sync_object_status(
|
||||
SyncObjectKind::SitePermissions,
|
||||
self.site_permissions.len(),
|
||||
SyncObjectState::LocalOnly,
|
||||
enabled_state,
|
||||
),
|
||||
self.sync_object_status(
|
||||
SyncObjectKind::History,
|
||||
@@ -120,9 +125,10 @@ impl BrowserCore {
|
||||
self.sync_object_status(
|
||||
SyncObjectKind::PluginSettings,
|
||||
self.installed_plugins.len(),
|
||||
SyncObjectState::LocalOnly,
|
||||
enabled_state,
|
||||
),
|
||||
])
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn sync_object_status(
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
/// Connection lifecycle of the cloud sync client.
|
||||
///
|
||||
/// The previous variant set was a single `SignedOut`, which made the
|
||||
/// Sync settings page report "Local-only · sign-in coming soon" even
|
||||
/// after the user dropped a bearer token in. The state machine now
|
||||
/// progresses from `SignedOut` → `SignedIn` (token present, sync not
|
||||
/// yet attempted) → `SyncReady` (last upload landed) or `SyncError`
|
||||
/// (last upload failed). `AwaitingDeviceApproval` is the dedicated
|
||||
/// state for the 403 the worker returns when the device-id bound to
|
||||
/// the session has not yet been approved by another already-approved
|
||||
/// device — surfacing it as its own variant lets the UI explain the
|
||||
/// gap instead of bucketing it into a generic error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SyncConnectionState {
|
||||
SignedOut,
|
||||
SignedIn,
|
||||
AwaitingDeviceApproval,
|
||||
SyncReady { last_synced_at_secs: u64 },
|
||||
SyncError { message: String },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -28,6 +44,7 @@ pub enum SyncObjectState {
|
||||
LocalOnly,
|
||||
Paused,
|
||||
PrivacyControlled,
|
||||
Synced,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -85,13 +102,20 @@ impl SyncObjectStatus {
|
||||
|
||||
impl SyncStatus {
|
||||
#[must_use]
|
||||
pub fn signed_out(objects: Vec<SyncObjectStatus>) -> Self {
|
||||
Self {
|
||||
connection: SyncConnectionState::SignedOut,
|
||||
pending_objects: 0,
|
||||
failed_objects: 0,
|
||||
objects,
|
||||
pub fn new(connection: SyncConnectionState, objects: Vec<SyncObjectStatus>) -> Self {
|
||||
let failed_objects = match &connection {
|
||||
SyncConnectionState::SyncError { .. } => 1,
|
||||
_ => 0,
|
||||
};
|
||||
Self { connection, pending_objects: 0, failed_objects, objects }
|
||||
}
|
||||
|
||||
/// Convenience constructor preserved for tests + call sites that
|
||||
/// have not been migrated to [`SyncStatus::new`] yet. Same result
|
||||
/// as `SyncStatus::new(SyncConnectionState::SignedOut, objects)`.
|
||||
#[must_use]
|
||||
pub fn signed_out(objects: Vec<SyncObjectStatus>) -> Self {
|
||||
Self::new(SyncConnectionState::SignedOut, objects)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
|
||||
Reference in New Issue
Block a user