fix(sync): secure encrypted snapshot lifecycle

This commit is contained in:
2026-07-10 06:24:53 -04:00
parent 556c5ff624
commit 540b901fd6
106 changed files with 18026 additions and 3309 deletions
+67 -24
View File
@@ -34,17 +34,17 @@ pub(crate) enum AuthFlowPhase {
Idle,
/// `send_email_otp` is in flight. UI disables the form so the
/// user can't resend before the worker confirms acceptance.
SendingCode { email: String },
SendingCode { profile_id: ProfileId, email: String },
/// The worker accepted the request and Cloudflare's `SEND_EMAIL`
/// binding handed the message to the recipient's MTA. UI now
/// reveals the OTP field.
AwaitingOtp { email: String },
AwaitingOtp { profile_id: ProfileId, email: String },
/// `verify_email_otp` is in flight. UI shows a transient
/// "Verifying…" state.
Verifying { email: String },
Verifying { profile_id: ProfileId, email: String },
/// Last attempt failed. UI surfaces the message inline so the
/// user knows what to retry.
Error { email: String, message: String },
Error { profile_id: ProfileId, email: String, message: String },
}
impl AuthFlowPhase {
@@ -58,6 +58,16 @@ impl AuthFlowPhase {
pub(crate) fn is_busy(&self) -> bool {
matches!(self, Self::SendingCode { .. } | Self::Verifying { .. })
}
pub(crate) fn belongs_to(&self, profile_id: &ProfileId) -> bool {
match self {
Self::Idle => true,
Self::SendingCode { profile_id: owner, .. }
| Self::AwaitingOtp { profile_id: owner, .. }
| Self::Verifying { profile_id: owner, .. }
| Self::Error { profile_id: owner, .. } => owner == profile_id,
}
}
}
impl ElyShell {
@@ -66,54 +76,66 @@ impl ElyShell {
/// through the shared `SyncStateUpdate` channel, which the next
/// shell tick reconciles into the `auth_flow_phase`.
pub(crate) fn submit_email_otp_request(&mut self, cx: &mut Context<Self>) {
if active_profile_sync_context_for(&self.state).is_none() {
let Some(active_profile) = active_profile_sync_context_for(&self.state) else {
return;
}
};
let email = self.read_auth_email_input(cx);
let Some(email) = normalize_email(&email) else {
self.auth_flow_phase = AuthFlowPhase::Error {
profile_id: active_profile.id,
email: String::new(),
message: "Enter a valid email to receive a code.".to_string(),
};
return;
};
self.auth_flow_phase = AuthFlowPhase::SendingCode { email: email.clone() };
let profile_id = active_profile.id;
self.auth_flow_phase =
AuthFlowPhase::SendingCode { profile_id: profile_id.clone(), email: email.clone() };
let tx = self.sync_inbox_tx.clone();
spawn_send_otp(email, tx);
spawn_send_otp(profile_id, email, tx);
}
/// Hand the typed OTP to the worker thread that calls
/// `verify_email_otp`, persists the bearer token, and triggers
/// the first snapshot upload on success.
pub(crate) fn submit_email_otp_verify(&mut self, cx: &mut Context<Self>) {
let email = match self.auth_flow_phase.clone() {
AuthFlowPhase::AwaitingOtp { email }
| AuthFlowPhase::Error { email, .. }
| AuthFlowPhase::Verifying { email } => email,
let (profile_id, email) = match self.auth_flow_phase.clone() {
AuthFlowPhase::AwaitingOtp { profile_id, email }
| AuthFlowPhase::Error { profile_id, email, .. }
| AuthFlowPhase::Verifying { profile_id, email } => (profile_id, email),
_ => return,
};
let otp = self.read_auth_otp_input(cx);
let normalized_otp = otp.trim().replace(['-', ' '], "");
if normalized_otp.is_empty() {
self.auth_flow_phase =
AuthFlowPhase::Error { email, message: "Enter the code you received.".to_string() };
self.auth_flow_phase = AuthFlowPhase::Error {
profile_id,
email,
message: "Enter the code you received.".to_string(),
};
return;
}
let active_profile = match active_profile_sync_context_for(&self.state) {
Some(profile) => profile,
None => return,
};
if active_profile.id != profile_id {
self.auth_flow_phase = AuthFlowPhase::Idle;
return;
}
let Some(profile_root) = default_profile_data_root() else {
self.auth_flow_phase = AuthFlowPhase::Error {
profile_id,
email,
message: "Profile data root is unavailable on this machine.".to_string(),
};
return;
};
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
self.auth_flow_phase = AuthFlowPhase::Verifying { email: email.clone() };
let profile_dir = sync_profile_data_dir(&profile_root, &profile_id);
self.auth_flow_phase =
AuthFlowPhase::Verifying { profile_id: profile_id.clone(), email: email.clone() };
let tx = self.sync_inbox_tx.clone();
spawn_verify_otp(email, normalized_otp, profile_dir, tx);
spawn_verify_otp(profile_id, email, normalized_otp, profile_dir, tx);
}
/// Drop the persisted bearer token and reset the local form.
@@ -121,6 +143,9 @@ impl ElyShell {
/// call to make, the token is the only artefact we own.
pub(crate) fn submit_sign_out(&mut self, _cx: &mut Context<Self>) {
self.auth_flow_phase = AuthFlowPhase::Idle;
self.sync_devices.reset();
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
let active_profile_id = match active_profile_id_for(&self.state) {
Some(profile_id) => profile_id,
None => return,
@@ -180,18 +205,21 @@ pub(super) fn clear_persisted_bearer(profile_dir: &Path) -> Result<(), SyncClien
BearerTokenStore::new(profile_dir.join("sync").join("bearer.token")).clear()
}
fn spawn_send_otp(email: String, tx: Sender<SyncStateUpdate>) {
fn spawn_send_otp(profile_id: ProfileId, email: String, tx: Sender<SyncStateUpdate>) {
std::thread::Builder::new()
.name("ely-sync-auth-send".to_string())
.spawn(move || {
let config = ApiClientConfig::production();
match send_email_otp(&config, &email) {
Ok(()) => {
let _ = tx.send(SyncStateUpdate::AuthOtpSent { email });
let _ = tx.send(SyncStateUpdate::AuthOtpSent { profile_id, email });
}
Err(error) => {
let _ =
tx.send(SyncStateUpdate::AuthError { email, message: error.to_string() });
let _ = tx.send(SyncStateUpdate::AuthError {
profile_id,
email,
message: error.to_string(),
});
}
}
})
@@ -202,6 +230,7 @@ fn spawn_send_otp(email: String, tx: Sender<SyncStateUpdate>) {
}
fn spawn_verify_otp(
profile_id: ProfileId,
email: String,
otp: String,
profile_dir: std::path::PathBuf,
@@ -215,6 +244,7 @@ fn spawn_verify_otp(
Ok(token) => token,
Err(error) => {
let _ = tx.send(SyncStateUpdate::AuthError {
profile_id,
email,
message: error.to_string(),
});
@@ -226,6 +256,7 @@ fn spawn_verify_otp(
Ok(engine) => engine,
Err(error) => {
let _ = tx.send(SyncStateUpdate::AuthError {
profile_id,
email,
message: error.to_string(),
});
@@ -233,10 +264,14 @@ fn spawn_verify_otp(
}
};
if let Err(error) = engine.install_bearer(token.as_str()) {
let _ = tx.send(SyncStateUpdate::AuthError { email, message: error.to_string() });
let _ = tx.send(SyncStateUpdate::AuthError {
profile_id,
email,
message: error.to_string(),
});
return;
}
let _ = tx.send(SyncStateUpdate::AuthSucceeded { email });
let _ = tx.send(SyncStateUpdate::AuthSucceeded { profile_id, email });
})
.map(|_| ())
.unwrap_or_else(|error| {
@@ -247,6 +282,7 @@ fn spawn_verify_otp(
#[cfg(test)]
mod tests {
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::ProfileId;
use super::{AuthFlowPhase, active_profile_sync_context_for, normalize_email};
use crate::shell::ShellState;
@@ -266,11 +302,18 @@ mod tests {
#[test]
fn auth_phase_helpers() {
let phase = AuthFlowPhase::Verifying { email: "you@there".to_string() };
let profile_id = ProfileId::new();
let phase = AuthFlowPhase::Verifying {
profile_id: profile_id.clone(),
email: "you@there".to_string(),
};
assert!(phase.is_busy());
assert!(phase.belongs_to(&profile_id));
assert!(!phase.belongs_to(&ProfileId::new()));
assert_eq!(phase.error_message(), None);
let phase = AuthFlowPhase::Error {
profile_id,
email: "you@there".to_string(),
message: "rate limited".to_string(),
};
+224 -9
View File
@@ -1,8 +1,10 @@
use ely_browser_core::BrowserSnapshot;
use ely_design_system::colors;
use ely_domain::{ProfileKind, SyncConnectionState, SyncObjectKind, SyncObjectStatus};
use ely_domain::{ProfileId, ProfileKind, SyncConnectionState, SyncObjectKind, SyncObjectStatus};
use ely_sync_client::DeviceRecord;
use gpui::{
AnyElement, Context, FontWeight, IntoElement, ParentElement, Styled, div, px, rgb, rgba,
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString,
StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, px, rgb, rgba,
};
use gpui_component::{input::Input, scroll::ScrollableElement};
@@ -10,7 +12,7 @@ use crate::shell::auth::AuthFlowPhase;
use super::sync_controls::{
button_bg, render_dual_button_row, render_policy_toggle, render_primary_button,
render_reset_button, render_sign_out_button,
render_reset_button, render_secondary_button, render_sign_out_button,
};
use super::{ElyShell, render_canvas_surface};
@@ -20,6 +22,11 @@ impl ElyShell {
snapshot: &BrowserSnapshot,
cx: &mut Context<Self>,
) -> AnyElement {
if profile_allows_sync_controls(&snapshot.active_profile_kind)
&& !matches!(snapshot.sync_status.connection(), SyncConnectionState::SignedOut)
{
self.ensure_sync_devices_loaded(cx);
}
render_canvas_surface(
div()
.size_full()
@@ -83,7 +90,7 @@ fn render_private_profile_card() -> AnyElement {
}
fn render_account_card(
shell: &ElyShell,
shell: &mut ElyShell,
snapshot: &BrowserSnapshot,
cx: &mut Context<ElyShell>,
) -> AnyElement {
@@ -93,21 +100,229 @@ fn render_account_card(
match snapshot.sync_status.connection() {
SyncConnectionState::SignedOut => card
.child(render_card_heading("Account"))
.children(account_form(shell, cx))
.children(account_form(shell, &snapshot.active_profile_id, cx))
.into_any_element(),
SyncConnectionState::SignedIn
| SyncConnectionState::AwaitingDeviceApproval
| SyncConnectionState::SyncReady { .. }
| SyncConnectionState::SyncError { .. } => card
.child(render_card_heading("Account"))
.child(render_sign_out_button(shell, cx))
.child(
div()
.flex()
.items_center()
.justify_between()
.child(render_card_heading("Account"))
.child(render_sign_out_button(shell, cx)),
)
.child(
div()
.text_size(px(12.0))
.text_color(rgb(colors::ink_3()))
.child("End-to-end encrypted"),
)
.child(render_devices(shell, cx))
.into_any_element(),
}
}
fn account_form(shell: &ElyShell, cx: &mut Context<ElyShell>) -> Vec<AnyElement> {
fn render_devices(shell: &mut ElyShell, cx: &mut Context<ElyShell>) -> AnyElement {
let loading = shell.sync_devices.is_loading();
let header =
div().flex().items_center().justify_between().child(render_card_heading("Devices")).child(
render_secondary_button(
shell,
"sync-devices-refresh",
"Refresh",
loading,
cx,
|shell, cx| shell.refresh_sync_devices(cx),
),
);
let mut section = div()
.pt(px(12.0))
.border_t_1()
.border_color(rgba(colors::divider()))
.flex()
.flex_col()
.gap(px(10.0))
.child(header);
if shell.sync_devices.is_loading() {
return section.child(render_device_note("Loading devices")).into_any_element();
}
if let Some(message) = shell.sync_devices.error() {
section = section.child(render_inline_error(message));
}
let devices = shell.sync_devices.devices().to_vec();
if devices.is_empty() {
return section.child(render_device_note("No devices")).into_any_element();
}
let current_approved = devices.iter().any(|device| device.current && device.is_approved());
if current_approved
&& devices.iter().any(|device| !device.current && device.approval_status == "pending")
{
section = section.child(
div().px(px(10.0)).py(px(7.0)).rounded(px(8.0)).bg(rgba(button_bg())).child(
Input::new(&shell.sync_verification_input).appearance(false).cleanable(false),
),
);
}
for device in devices {
section = section.child(render_device_row(shell, &device, current_approved, cx));
}
section.into_any_element()
}
fn render_device_row(
shell: &ElyShell,
device: &DeviceRecord,
current_approved: bool,
cx: &mut Context<ElyShell>,
) -> AnyElement {
let status = if device.current {
"This device"
} else if device.is_approved() {
"Approved"
} else if device.approval_status == "pending" {
"Pending"
} else {
"Revoked"
};
let mut row = div()
.py(px(8.0))
.border_b_1()
.border_color(rgba(colors::divider()))
.flex()
.flex_col()
.gap(px(7.0))
.child(
div()
.flex()
.items_center()
.justify_between()
.gap(px(10.0))
.child(
div()
.min_w_0()
.text_size(px(12.5))
.font_weight(FontWeight(500.0))
.text_color(rgb(colors::ink()))
.child(device.device_name.clone()),
)
.child(div().text_size(px(11.0)).text_color(rgb(colors::ink_4())).child(status)),
);
let code = if device.current {
shell.sync_devices.current_code().map(str::to_string)
} else {
device.verification_code().ok()
};
if let Some(code) = code {
row = row.child(div().text_size(px(11.5)).text_color(rgb(colors::ink_3())).child(code));
}
let can_revoke = current_approved
&& !device.current
&& device.revoked_at.is_none()
&& matches!(device.approval_status.as_str(), "pending" | "approved");
if can_revoke {
let device_id = device.device_id.clone();
let busy = shell.sync_devices.is_acting_on(&device_id);
let confirmed = shell.sync_devices.is_revoke_confirmation(&device_id);
row = row.child(
div()
.flex()
.items_center()
.justify_end()
.gap(px(8.0))
.when(device.approval_status == "pending", |buttons| {
buttons.child(render_device_approve_button(device_id.clone(), busy, cx))
})
.when(can_revoke, |buttons| {
buttons.child(render_device_revoke_button(device_id, confirmed, busy, cx))
}),
);
}
row.into_any_element()
}
fn render_device_approve_button(
device_id: String,
disabled: bool,
cx: &mut Context<ElyShell>,
) -> AnyElement {
let id = SharedString::from(format!("sync-device-approve-{device_id}"));
div()
.id(id)
.px(px(12.0))
.py(px(8.0))
.rounded(px(8.0))
.bg(rgba(colors::accent()))
.text_size(px(12.0))
.font_weight(FontWeight(500.0))
.text_color(rgb(0xfff5e6))
.when(!disabled, |element| {
element
.cursor_pointer()
.hover(|style| style.opacity(0.92))
.active(|style| style.opacity(0.78))
.on_click(cx.listener(move |shell, _, _, cx| {
shell.approve_sync_device(device_id.clone(), cx);
}))
})
.when(disabled, |element| element.opacity(0.6))
.child(if disabled { "Approving" } else { "Approve" })
.into_any_element()
}
fn render_device_revoke_button(
device_id: String,
confirmed: bool,
disabled: bool,
cx: &mut Context<ElyShell>,
) -> AnyElement {
let id = SharedString::from(format!("sync-device-revoke-{device_id}"));
div()
.id(id)
.px(px(12.0))
.py(px(8.0))
.rounded(px(8.0))
.bg(rgba(if confirmed { colors::error() } else { button_bg() }))
.text_size(px(12.0))
.font_weight(FontWeight(500.0))
.text_color(rgb(if confirmed { 0xffffff } else { colors::error() }))
.when(!disabled, |element| {
element
.cursor_pointer()
.hover(|style| style.opacity(0.9))
.active(|style| style.opacity(0.78))
.on_click(cx.listener(move |shell, _, _, cx| {
shell.revoke_sync_device(device_id.clone(), cx);
}))
})
.when(disabled, |element| element.opacity(0.6))
.child(if disabled {
"Revoking"
} else if confirmed {
"Confirm revoke"
} else {
"Revoke"
})
.into_any_element()
}
fn render_device_note(message: &'static str) -> AnyElement {
div().text_size(px(11.5)).text_color(rgb(colors::ink_4())).child(message).into_any_element()
}
fn account_form(
shell: &ElyShell,
profile_id: &ProfileId,
cx: &mut Context<ElyShell>,
) -> Vec<AnyElement> {
let mut elements: Vec<AnyElement> = Vec::new();
let phase = shell.auth_flow_phase.clone();
let phase = if shell.auth_flow_phase.belongs_to(profile_id) {
shell.auth_flow_phase.clone()
} else {
AuthFlowPhase::Idle
};
elements.push(render_field_label("Email"));
elements.push(render_input(&shell.auth_email_input));
@@ -133,7 +133,7 @@ pub(super) fn render_policy_toggle(
chrome_motion_feedback(press_id, selection_id, enabled, element)
}
fn render_secondary_button<F>(
pub(super) fn render_secondary_button<F>(
shell: &ElyShell,
id: &'static str,
label: &'static str,
+17 -3
View File
@@ -24,6 +24,7 @@ mod site_permissions;
mod space_files;
mod spaces;
mod splits;
mod sync_devices;
mod sync_state;
mod tab_groups;
mod tab_lifecycle;
@@ -57,7 +58,8 @@ use bookmarks::PendingBookmarkEdit;
use downloads::PendingDownloadFileAction;
use history::{PendingHistoryDomainClear, PendingHistoryTimeClear};
use plugins::{PendingPluginInstall, PendingPluginUninstall};
use sync_state::SyncStateUpdate;
use sync_devices::SyncDeviceUiState;
use sync_state::{PendingMergeUpload, SyncStateUpdate};
use web_surface::WebSurfaceStore;
enum ShellState {
@@ -111,7 +113,10 @@ pub struct ElyShell {
sync_upload_scheduled: bool,
sync_upload_in_flight: bool,
sync_upload_pending: bool,
sync_upload_pending_logical_clock_floor: Option<u64>,
sync_upload_pending_merge: Option<PendingMergeUpload>,
sync_retry_at: Option<std::time::Instant>,
pub(crate) sync_devices: SyncDeviceUiState,
pub(crate) sync_verification_input: Entity<InputState>,
pub(crate) auth_email_input: Entity<InputState>,
pub(crate) auth_otp_input: Entity<InputState>,
pub(crate) auth_flow_phase: auth::AuthFlowPhase,
@@ -153,6 +158,8 @@ impl ElyShell {
let auth_email_input =
cx.new(|cx| InputState::new(window, cx).placeholder("you@elydora.com"));
let auth_otp_input = cx.new(|cx| InputState::new(window, cx).placeholder("123456"));
let sync_verification_input =
cx.new(|cx| InputState::new(window, cx).placeholder("ABCD-EF01-2345-6789"));
let translucency_slider = cx.new(|_cx| {
SliderState::new()
.min(0.0)
@@ -252,7 +259,10 @@ impl ElyShell {
sync_upload_scheduled: false,
sync_upload_in_flight: false,
sync_upload_pending: false,
sync_upload_pending_logical_clock_floor: None,
sync_upload_pending_merge: None,
sync_retry_at: None,
sync_devices: SyncDeviceUiState::default(),
sync_verification_input,
auth_email_input,
auth_otp_input,
auth_flow_phase: auth::AuthFlowPhase::Idle,
@@ -308,6 +318,10 @@ impl ElyShell {
if let ShellState::Ready(core) = &mut self.state
&& core.select_profile(profile_id).is_ok()
{
self.auth_flow_phase = auth::AuthFlowPhase::Idle;
self.sync_devices.reset();
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
self.sync_address_input(window, cx);
self.schedule_cloud_sync_upload(cx);
cx.notify();
+37 -20
View File
@@ -9,7 +9,7 @@ use gpui_component::slider::SliderValue;
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
use super::sync_state::{SyncStateUpdate, sync_platform_label};
use super::sync_state::{PendingMergeUpload, SyncStateUpdate, sync_platform_label};
use super::{ElyShell, ShellState};
impl ElyShell {
@@ -247,14 +247,14 @@ impl ElyShell {
}
pub(crate) fn trigger_cloud_sync_upload(&mut self) {
self.trigger_cloud_sync_upload_with_clock_floor(None);
self.trigger_cloud_sync_upload_with_merge(None);
}
pub(crate) fn trigger_cloud_sync_upload_after_remote(&mut self, logical_clock_floor: u64) {
self.trigger_cloud_sync_upload_with_clock_floor(Some(logical_clock_floor));
pub(super) fn trigger_cloud_sync_upload_after_remote(&mut self, merge: PendingMergeUpload) {
self.trigger_cloud_sync_upload_with_merge(Some(merge));
}
fn trigger_cloud_sync_upload_with_clock_floor(&mut self, logical_clock_floor: Option<u64>) {
fn trigger_cloud_sync_upload_with_merge(&mut self, mut merge: Option<PendingMergeUpload>) {
let active_profile_allows_sync = match &self.state {
ShellState::Ready(core) => core.active_profile_allows_sync(),
ShellState::StartupError(_) => false,
@@ -266,7 +266,7 @@ impl ElyShell {
}
if self.sync_upload_in_flight {
self.sync_upload_scheduled = false;
self.queue_cloud_sync_upload(logical_clock_floor);
self.queue_cloud_sync_upload(merge);
return;
}
self.sync_upload_scheduled = false;
@@ -278,6 +278,9 @@ impl ElyShell {
return;
};
let active_profile_id = snapshot.active_profile_id.clone();
if merge.as_ref().is_some_and(|merge| merge.profile_id != active_profile_id) {
merge = None;
}
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");
@@ -296,12 +299,12 @@ impl ElyShell {
}
};
let tx = self.sync_inbox_tx.clone();
let thread_name =
if logical_clock_floor.is_some() { "ely-sync-merge-upload" } else { "ely-sync-upload" };
let worker_profile_id = active_profile_id.clone();
let thread_name = if merge.is_some() { "ely-sync-merge-upload" } else { "ely-sync-upload" };
self.sync_upload_in_flight = true;
if let Err(error) =
std::thread::Builder::new().name(thread_name.to_string()).spawn(move || {
run_sync_upload(profile_dir, device_name, bytes, logical_clock_floor, tx)
run_sync_upload(worker_profile_id, profile_dir, device_name, bytes, merge, tx)
})
{
self.sync_upload_in_flight = false;
@@ -341,10 +344,11 @@ impl ElyShell {
}
fn run_sync_upload(
profile_id: ProfileId,
profile_dir: std::path::PathBuf,
device_name: String,
bytes: Vec<u8>,
logical_clock_floor: Option<u64>,
merge: Option<PendingMergeUpload>,
inbox: std::sync::mpsc::Sender<SyncStateUpdate>,
) {
let mut engine = match SyncEngine::for_profile_dir(
@@ -356,18 +360,19 @@ fn run_sync_upload(
Err(error) => {
let message = error.to_string();
tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine");
let _ = inbox.send(SyncStateUpdate::SyncError { message });
let _ = inbox.send(SyncStateUpdate::SyncError { profile_id, message });
return;
}
};
let outcome = match logical_clock_floor {
Some(floor) => engine.upload_merged_bytes(bytes, floor),
let prior_conflict_count = merge.as_ref().map_or(0, |merge| merge.conflict_count);
let outcome = match merge {
Some(merge) => engine.upload_merged_bytes(bytes, merge.base),
None => engine.sync_bytes(bytes),
};
match outcome {
Ok(ely_browser_core::SyncOutcome::SignedOut) => {
tracing::info!(target: "ely::sync", "no bearer token on disk; sync skipped");
let _ = inbox.send(SyncStateUpdate::SignedOut);
let _ = inbox.send(SyncStateUpdate::SignedOut { profile_id });
}
Ok(ely_browser_core::SyncOutcome::AwaitingDeviceApproval { device_id }) => {
tracing::info!(
@@ -375,7 +380,7 @@ fn run_sync_upload(
device_id = %device_id,
"sync device is awaiting approval",
);
let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval);
let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval { profile_id });
}
Ok(ely_browser_core::SyncOutcome::RemoteSnapshot {
snapshot_id,
@@ -383,6 +388,8 @@ fn run_sync_upload(
payload_bytes,
device_id,
bytes,
merge_base,
cas_conflict,
}) => {
tracing::info!(
target: "ely::sync",
@@ -392,7 +399,13 @@ fn run_sync_upload(
device_id = %device_id,
"remote snapshot downloaded",
);
let _ = inbox.send(SyncStateUpdate::RemoteSnapshot { bytes, logical_clock });
let conflict_count =
if cas_conflict { prior_conflict_count.saturating_add(1) } else { 0 };
let _ = inbox.send(SyncStateUpdate::RemoteSnapshot {
profile_id: profile_id.clone(),
bytes,
merge: PendingMergeUpload { profile_id, base: merge_base, conflict_count },
});
}
Ok(ely_browser_core::SyncOutcome::AlreadyCurrent {
snapshot_id,
@@ -412,7 +425,7 @@ fn run_sync_upload(
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = inbox.send(SyncStateUpdate::SyncReady { last_synced_at_secs });
let _ = inbox.send(SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs });
}
Ok(ely_browser_core::SyncOutcome::Uploaded {
snapshot_id,
@@ -432,15 +445,19 @@ fn run_sync_upload(
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let _ = inbox.send(SyncStateUpdate::SyncReady { last_synced_at_secs });
let _ = inbox.send(SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs });
}
Err(ely_sync_client::SyncClientError::SnapshotBusy) => {
tracing::info!(target: "ely::sync", "snapshot head is busy; retry scheduled");
let _ = inbox.send(SyncStateUpdate::SyncBusy { profile_id });
}
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") {
SyncStateUpdate::AwaitingDeviceApproval
SyncStateUpdate::AwaitingDeviceApproval { profile_id }
} else {
SyncStateUpdate::SyncError { message }
SyncStateUpdate::SyncError { profile_id, message }
};
let _ = inbox.send(update);
}
+260
View File
@@ -0,0 +1,260 @@
use std::path::PathBuf;
use ely_browser_core::SyncEngine;
use ely_domain::ProfileId;
use ely_sync_client::DeviceRecord;
use gpui::Context;
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
use super::sync_state::sync_platform_label;
use super::{ElyShell, ShellState, sync_state::SyncStateUpdate};
#[derive(Clone, Debug, Default)]
enum DeviceUiPhase {
#[default]
Idle,
Loading,
Ready,
Acting {
device_id: String,
},
Error,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct SyncDeviceUiState {
profile_id: Option<ProfileId>,
phase: DeviceUiPhase,
devices: Vec<DeviceRecord>,
current_code: Option<String>,
revoke_confirmation: Option<String>,
error: Option<String>,
}
impl SyncDeviceUiState {
pub(crate) fn devices(&self) -> &[DeviceRecord] {
&self.devices
}
pub(crate) fn current_code(&self) -> Option<&str> {
self.current_code.as_deref()
}
pub(crate) fn error(&self) -> Option<&str> {
self.error.as_deref()
}
pub(crate) fn is_loading(&self) -> bool {
matches!(self.phase, DeviceUiPhase::Loading)
}
pub(crate) fn is_acting_on(&self, device_id: &str) -> bool {
matches!(&self.phase, DeviceUiPhase::Acting { device_id: active } if active == device_id)
}
pub(crate) fn is_revoke_confirmation(&self, device_id: &str) -> bool {
self.revoke_confirmation.as_deref() == Some(device_id)
}
pub(crate) fn reset(&mut self) {
*self = Self::default();
}
pub(crate) fn set_ready(
&mut self,
profile_id: ProfileId,
devices: Vec<DeviceRecord>,
current_code: String,
) {
self.profile_id = Some(profile_id);
self.phase = DeviceUiPhase::Ready;
self.devices = devices;
self.current_code = Some(current_code);
self.revoke_confirmation = None;
self.error = None;
}
pub(crate) fn set_error(&mut self, profile_id: ProfileId, message: String) {
self.profile_id = Some(profile_id);
self.phase = DeviceUiPhase::Error;
self.revoke_confirmation = None;
self.error = Some(message);
}
fn prepare_profile(&mut self, profile_id: &ProfileId) {
if self.profile_id.as_ref() != Some(profile_id) {
self.reset();
self.profile_id = Some(profile_id.clone());
}
}
fn begin_load(&mut self, force: bool) -> bool {
if !force && !matches!(self.phase, DeviceUiPhase::Idle) {
return false;
}
if matches!(self.phase, DeviceUiPhase::Loading | DeviceUiPhase::Acting { .. }) {
return false;
}
self.phase = DeviceUiPhase::Loading;
self.error = None;
true
}
fn begin_action(&mut self, device_id: String) -> bool {
if !matches!(self.phase, DeviceUiPhase::Ready | DeviceUiPhase::Error) {
return false;
}
self.phase = DeviceUiPhase::Acting { device_id };
self.revoke_confirmation = None;
self.error = None;
true
}
fn request_revoke_confirmation(&mut self, device_id: &str) -> bool {
if self.revoke_confirmation.as_deref() == Some(device_id) {
return true;
}
self.revoke_confirmation = Some(device_id.to_string());
false
}
}
impl ElyShell {
pub(crate) fn ensure_sync_devices_loaded(&mut self, cx: &mut Context<Self>) {
self.load_sync_devices(false, cx);
}
pub(crate) fn refresh_sync_devices(&mut self, cx: &mut Context<Self>) {
self.load_sync_devices(true, cx);
}
pub(crate) fn approve_sync_device(&mut self, device_id: String, cx: &mut Context<Self>) {
let verification_code = self.sync_verification_input.read(cx).value().to_string();
let Some((profile_id, profile_dir, device_name)) = self.sync_device_context() else {
self.sync_devices.reset();
return;
};
self.sync_devices.prepare_profile(&profile_id);
if !self.sync_devices.begin_action(device_id.clone()) {
return;
}
let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-approve", profile_id.clone(), tx, move || {
let engine =
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
engine.approve_cloud_device(&device_id, &verification_code)?;
load_devices(profile_id, engine)
});
}
pub(crate) fn revoke_sync_device(&mut self, device_id: String, cx: &mut Context<Self>) {
let Some((profile_id, profile_dir, device_name)) = self.sync_device_context() else {
self.sync_devices.reset();
return;
};
self.sync_devices.prepare_profile(&profile_id);
if !self.sync_devices.request_revoke_confirmation(&device_id) {
cx.notify();
return;
}
if !self.sync_devices.begin_action(device_id.clone()) {
return;
}
let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-revoke", profile_id.clone(), tx, move || {
let engine =
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
engine.revoke_cloud_device(&device_id)?;
load_devices(profile_id, engine)
});
}
fn load_sync_devices(&mut self, force: bool, _cx: &mut Context<Self>) {
let Some((profile_id, profile_dir, device_name)) = self.sync_device_context() else {
self.sync_devices.reset();
return;
};
self.sync_devices.prepare_profile(&profile_id);
if !self.sync_devices.begin_load(force) {
return;
}
let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-list", profile_id.clone(), tx, move || {
let engine =
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
load_devices(profile_id, engine)
});
}
fn sync_device_context(&self) -> Option<(ProfileId, PathBuf, String)> {
let ShellState::Ready(core) = &self.state else {
return None;
};
if !core.active_profile_allows_sync() {
return None;
}
let snapshot = core.snapshot().ok()?;
let root = default_profile_data_root()?;
Some((
snapshot.active_profile_id.clone(),
sync_profile_data_dir(&root, &snapshot.active_profile_id),
format!("ELY · {}", snapshot.active_profile_name),
))
}
}
fn load_devices(
profile_id: ProfileId,
engine: SyncEngine,
) -> Result<SyncStateUpdate, ely_sync_client::SyncClientError> {
let current_code = engine.identity().verification_code()?;
let devices = engine.cloud_devices()?.devices;
Ok(SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code })
}
fn spawn_device_task<F>(
name: &str,
profile_id: ProfileId,
tx: std::sync::mpsc::Sender<SyncStateUpdate>,
task: F,
) where
F: FnOnce() -> Result<SyncStateUpdate, ely_sync_client::SyncClientError> + Send + 'static,
{
let thread_name = name.to_string();
let worker_tx = tx.clone();
let worker_profile_id = profile_id.clone();
let spawn_result = std::thread::Builder::new().name(thread_name).spawn(move || {
let update = task().unwrap_or_else(|error| SyncStateUpdate::DevicesError {
profile_id: worker_profile_id,
message: error.to_string(),
});
let _ = worker_tx.send(update);
});
if let Err(error) = spawn_result {
tracing::warn!(target: "ely::sync", error = %error, "device task spawn failed");
let _ = tx.send(SyncStateUpdate::DevicesError { profile_id, message: error.to_string() });
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn changing_profiles_clears_device_ui_state() {
let first_profile = ProfileId::new();
let second_profile = ProfileId::new();
let mut state = SyncDeviceUiState::default();
state.prepare_profile(&first_profile);
state.set_error(first_profile.clone(), "first profile error".to_string());
state.prepare_profile(&first_profile);
assert_eq!(state.error(), Some("first profile error"));
state.prepare_profile(&second_profile);
assert!(state.devices().is_empty());
assert!(state.current_code().is_none());
assert!(state.error().is_none());
}
}
+133 -45
View File
@@ -1,11 +1,21 @@
use std::{path::Path, time::Duration};
use ely_domain::{ProfileKind, SyncConnectionState};
use ely_domain::{ProfileId, ProfileKind, SyncConnectionState};
use ely_sync_client::{AuthenticatedSnapshotHead, DeviceRecord};
use gpui::{Context, Timer};
use super::{ElyShell, ShellState, auth};
const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750);
const CAS_RETRY_LIMIT: u8 = 3;
const CAS_RETRY_DELAY: Duration = Duration::from_secs(2);
#[derive(Clone, Debug)]
pub(crate) struct PendingMergeUpload {
pub(super) profile_id: ProfileId,
pub(super) base: AuthenticatedSnapshotHead,
pub(super) conflict_count: u8,
}
/// Messages the off-thread sync workers push back to the shell so
/// `SyncConnectionState` on `BrowserCore` and the in-flight auth
@@ -14,14 +24,17 @@ const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750);
/// on shell startup and does not flow through this channel.
#[derive(Clone, Debug)]
pub(crate) enum SyncStateUpdate {
SignedOut,
AwaitingDeviceApproval,
RemoteSnapshot { bytes: Vec<u8>, logical_clock: u64 },
SyncReady { last_synced_at_secs: u64 },
SyncError { message: String },
AuthOtpSent { email: String },
AuthSucceeded { email: String },
AuthError { email: String, message: String },
SignedOut { profile_id: ProfileId },
AwaitingDeviceApproval { profile_id: ProfileId },
RemoteSnapshot { profile_id: ProfileId, bytes: Vec<u8>, merge: PendingMergeUpload },
SyncReady { profile_id: ProfileId, last_synced_at_secs: u64 },
SyncBusy { profile_id: ProfileId },
SyncError { profile_id: ProfileId, message: String },
DevicesLoaded { profile_id: ProfileId, devices: Vec<DeviceRecord>, current_code: String },
DevicesError { profile_id: ProfileId, message: String },
AuthOtpSent { profile_id: ProfileId, email: String },
AuthSucceeded { profile_id: ProfileId, email: String },
AuthError { profile_id: ProfileId, email: String, message: String },
}
/// Stable label for the current OS used by the device registration
@@ -64,13 +77,18 @@ impl ElyShell {
.detach();
}
pub(super) fn queue_cloud_sync_upload(&mut self, logical_clock_floor: Option<u64>) {
pub(super) fn queue_cloud_sync_upload(&mut self, merge: Option<PendingMergeUpload>) {
self.sync_upload_pending = true;
if let Some(floor) = logical_clock_floor {
self.sync_upload_pending_logical_clock_floor = Some(
self.sync_upload_pending_logical_clock_floor
.map_or(floor, |current| current.max(floor)),
);
if let Some(candidate) = merge {
let replace = self.sync_upload_pending_merge.as_ref().is_none_or(|current| {
candidate.profile_id != current.profile_id
|| candidate.base.revision() > current.base.revision()
|| candidate.base.revision() == current.base.revision()
&& candidate.conflict_count > current.conflict_count
});
if replace {
self.sync_upload_pending_merge = Some(candidate);
}
}
}
@@ -80,9 +98,9 @@ impl ElyShell {
}
self.sync_upload_pending = false;
let logical_clock_floor = self.sync_upload_pending_logical_clock_floor.take();
match logical_clock_floor {
Some(floor) => self.trigger_cloud_sync_upload_after_remote(floor),
let merge = self.sync_upload_pending_merge.take();
match merge {
Some(merge) => self.trigger_cloud_sync_upload_after_remote(merge),
None => self.trigger_cloud_sync_upload(),
}
true
@@ -90,7 +108,7 @@ impl ElyShell {
pub(super) fn clear_pending_cloud_sync_upload(&mut self) {
self.sync_upload_pending = false;
self.sync_upload_pending_logical_clock_floor = None;
self.sync_upload_pending_merge = None;
}
fn can_schedule_cloud_sync_upload(&self) -> bool {
@@ -118,23 +136,36 @@ impl ElyShell {
/// 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 retry_due =
self.sync_retry_at.is_some_and(|deadline| deadline <= std::time::Instant::now());
if retry_due {
self.sync_retry_at = None;
}
let mut latest_connection: Option<SyncConnectionState> = None;
let mut auth_changed = false;
let mut trigger_initial_sync = false;
let mut trigger_merged_upload = None;
let mut upload_finished = false;
let mut devices_changed = false;
while let Ok(update) = self.sync_inbox_rx.try_recv() {
match update {
SyncStateUpdate::SignedOut => {
latest_connection = Some(SyncConnectionState::SignedOut);
SyncStateUpdate::SignedOut { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SignedOut);
}
}
SyncStateUpdate::AwaitingDeviceApproval => {
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
SyncStateUpdate::AwaitingDeviceApproval { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
}
}
SyncStateUpdate::RemoteSnapshot { bytes, logical_clock } => {
SyncStateUpdate::RemoteSnapshot { profile_id, bytes, merge } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() != Some(&profile_id) {
continue;
}
if let ShellState::Ready(core) = &mut self.state {
match core.apply_sync_snapshot_bytes(&bytes) {
Ok(summary) => {
@@ -145,7 +176,15 @@ impl ElyShell {
skipped = summary.skipped(),
"remote snapshot applied",
);
trigger_merged_upload = Some(logical_clock);
if merge.conflict_count >= CAS_RETRY_LIMIT {
latest_connection = Some(SyncConnectionState::SyncError {
message: "Cloud Sync is busy; retrying shortly".to_string(),
});
self.sync_retry_at =
Some(std::time::Instant::now() + CAS_RETRY_DELAY);
} else {
trigger_merged_upload = Some(merge);
}
}
Err(error) => {
latest_connection = Some(SyncConnectionState::SyncError {
@@ -155,29 +194,63 @@ impl ElyShell {
}
}
}
SyncStateUpdate::SyncReady { last_synced_at_secs } => {
latest_connection =
Some(SyncConnectionState::SyncReady { last_synced_at_secs });
SyncStateUpdate::SyncReady { profile_id, last_synced_at_secs } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection =
Some(SyncConnectionState::SyncReady { last_synced_at_secs });
}
}
SyncStateUpdate::SyncError { message } => {
latest_connection = Some(SyncConnectionState::SyncError { message });
SyncStateUpdate::SyncBusy { profile_id } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SyncError {
message: "Cloud Sync is busy; retrying shortly".to_string(),
});
self.sync_retry_at = Some(std::time::Instant::now() + CAS_RETRY_DELAY);
}
}
SyncStateUpdate::AuthOtpSent { email } => {
self.auth_flow_phase = auth::AuthFlowPhase::AwaitingOtp { email };
auth_changed = true;
SyncStateUpdate::SyncError { profile_id, message } => {
upload_finished = true;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::SyncError { message });
}
}
SyncStateUpdate::AuthSucceeded { email } => {
self.auth_flow_phase = auth::AuthFlowPhase::Idle;
latest_connection = Some(SyncConnectionState::SignedIn);
trigger_initial_sync = true;
tracing::info!(target: "ely::sync", email = %email, "email OTP sign-in succeeded");
auth_changed = true;
SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.sync_devices.set_ready(profile_id, devices, current_code);
devices_changed = true;
}
}
SyncStateUpdate::AuthError { email, message } => {
self.auth_flow_phase = auth::AuthFlowPhase::Error { email, message };
auth_changed = true;
SyncStateUpdate::DevicesError { profile_id, message } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.sync_devices.set_error(profile_id, message);
devices_changed = true;
}
}
SyncStateUpdate::AuthOtpSent { profile_id, email } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.auth_flow_phase =
auth::AuthFlowPhase::AwaitingOtp { profile_id, email };
auth_changed = true;
}
}
SyncStateUpdate::AuthSucceeded { profile_id, email } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.auth_flow_phase = auth::AuthFlowPhase::Idle;
self.sync_devices.reset();
latest_connection = Some(SyncConnectionState::SignedIn);
trigger_initial_sync = true;
tracing::info!(target: "ely::sync", email = %email, "email OTP sign-in succeeded");
auth_changed = true;
}
}
SyncStateUpdate::AuthError { profile_id, email, message } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.auth_flow_phase =
auth::AuthFlowPhase::Error { profile_id, email, message };
auth_changed = true;
}
}
}
}
@@ -194,17 +267,32 @@ impl ElyShell {
}
let merged_upload_requested = trigger_merged_upload.is_some();
if let Some(logical_clock_floor) = trigger_merged_upload {
if let Some(merge) = trigger_merged_upload {
self.clear_pending_cloud_sync_upload();
self.trigger_cloud_sync_upload_after_remote(logical_clock_floor);
self.trigger_cloud_sync_upload_after_remote(merge);
} else if upload_finished {
self.trigger_pending_cloud_sync_upload();
}
if retry_due {
self.trigger_cloud_sync_upload();
}
auth_changed || trigger_initial_sync || merged_upload_requested || connection_changed
auth_changed
|| devices_changed
|| trigger_initial_sync
|| merged_upload_requested
|| retry_due
|| connection_changed
}
}
fn active_profile_id(state: &ShellState) -> Option<ProfileId> {
let ShellState::Ready(core) = state else {
return None;
};
core.snapshot().ok().map(|snapshot| snapshot.active_profile_id)
}
fn probe_initial_sync_state_at(
core: &mut ely_browser_core::BrowserCore,
profile_root: &Path,