fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user