feat: own sync server

This commit is contained in:
zhom committed 2026-10-13 12:59:44 +04:00
1 parent d6c21de169
commit 8f64745b1a
42 files changed
+1965 -136

No files matched your search

+4 -1
View File
@@ -3746,6 +3746,9 @@ fn sync_mode_error_response(err: String) -> (StatusCode, String) {
pub async fn remote_launch_precondition(
profile: &crate::profile::types::BrowserProfile,
) -> Result<(), String> {
if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() {
return Err(refusal);
}
remote_launch_profile_rules(profile)?;
// The manifest is written last, so a host pulling mid-upload gets files
@@ -4219,7 +4222,7 @@ fn cookie_bot_eligible_profile(
.find(|p| p.id.to_string() == profile_id)
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
crate::cookie_bot::run_precondition(&profile)
.map_err(|reason| (StatusCode::BAD_REQUEST, reason))?;
Ok(profile)
}
+133 -3
View File
@@ -27,6 +27,13 @@ const MAX_RETRY_AFTER: Duration = Duration::from_secs(60 * 60);
static ACCESS_TOKEN_REFRESH: Streak = Streak::new(module_path!(), "Access token refresh");
static SYNC_TOKEN_FETCH: Streak = Streak::new(module_path!(), "Sync token fetch");
static SERVER_SYNC_TOKEN_FETCH: Streak = Streak::new(module_path!(), "Own server sync token fetch");
/// Tokens for the customer's own sync server, by address. In memory only:
/// they last minutes and cost one request to replace.
static SERVER_SYNC_TOKENS: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<String, String>>,
> = std::sync::LazyLock::new(Default::default);
static PROFILE_REFRESH: Streak = Streak::new(module_path!(), "Cloud profile refresh");
static PROXY_CONFIG_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy config fetch");
static PROXY_USAGE_FETCH: Streak = Streak::new(module_path!(), "Cloud proxy usage fetch");
@@ -557,7 +564,8 @@ impl CloudAuthManager {
// --- JWT expiry check ---
fn jwt_expiry(token: &str) -> Option<i64> {
/// A JWT's claims, unverified.
fn jwt_claims(token: &str) -> Option<serde_json::Value> {
let parts: Vec<&str> = token.split('.').collect();
if parts.len() != 3 {
return None;
@@ -572,8 +580,21 @@ impl CloudAuthManager {
}
};
let json: serde_json::Value = serde_json::from_slice(&payload).ok()?;
json.get("exp").and_then(|v| v.as_i64())
serde_json::from_slice(&payload).ok()
}
fn jwt_expiry(token: &str) -> Option<i64> {
Self::jwt_claims(token)?.get("exp").and_then(|v| v.as_i64())
}
/// Whether `token` is one the Donut API bound to `server`. Anything else,
/// such as a token for Donut cloud from an API that does not know the
/// `server` field yet, must never be sent to a third party's server.
fn is_server_sync_token_for(token: &str, server: &str) -> bool {
Self::jwt_claims(token).is_some_and(|claims| {
claims.get("scope").and_then(|v| v.as_str()) == Some("server-sync")
&& claims.get("aud").and_then(|v| v.as_str()) == Some(server)
})
}
fn jwt_expires_within(token: &str, margin_secs: i64, now: i64) -> bool {
@@ -1148,7 +1169,86 @@ impl CloudAuthManager {
Ok(Some(sync_token))
}
/// A token for the customer's own donut-sync at `server`, which the Donut
/// API binds to that one address (`sync::account_server`).
pub async fn server_sync_token(&self, server: &str) -> Result<String, String> {
if !self.is_logged_in().await {
return Err(crate::backend_error("ACCOUNT_SYNC_SIGN_IN_REQUIRED"));
}
let cached = SERVER_SYNC_TOKENS
.lock()
.ok()
.and_then(|tokens| tokens.get(server).cloned());
if let Some(token) = cached.filter(|token| !Self::is_jwt_expiring_soon(token)) {
return Ok(token);
}
let fetched = self
.api_call_with_retry(|access_token| {
let url = format!("{CLOUD_API_URL}/api/auth/sync-token");
let client = self.client.clone();
let body = serde_json::json!({ "server": server });
async move {
let response = client
.post(&url)
.header("Authorization", format!("Bearer {access_token}"))
.json(&body)
.send()
.await
.map_err(|e| format!("Failed to get sync token: {e}"))?;
let status = response.status();
if status == reqwest::StatusCode::FORBIDDEN {
return Err(crate::backend_error("ACCOUNT_SYNC_PLAN_REQUIRED"));
}
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("Sync token request failed ({status}): {body}"));
}
let result: SyncTokenResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse sync token response: {e}"))?;
Ok(result.sync_token)
}
})
.await;
let fetched = fetched.and_then(|token| {
if Self::is_server_sync_token_for(&token, server) {
Ok(token)
} else {
Err(crate::backend_error("ACCOUNT_SYNC_UNAVAILABLE"))
}
});
match fetched {
Ok(token) => {
SERVER_SYNC_TOKEN_FETCH.succeeded();
if let Ok(mut tokens) = SERVER_SYNC_TOKENS.lock() {
tokens.insert(server.to_string(), token.clone());
}
Ok(token)
}
Err(e) => {
SERVER_SYNC_TOKEN_FETCH.failed(&e);
Err(e)
}
}
}
/// Forget a token the own server refused, so the next request fetches one.
pub(crate) fn discard_server_sync_token(refused: &str) {
if let Ok(mut tokens) = SERVER_SYNC_TOKENS.lock() {
tokens.retain(|_, token| token != refused);
}
}
pub async fn logout(&self) -> Result<(), String> {
if let Ok(mut tokens) = SERVER_SYNC_TOKENS.lock() {
tokens.clear();
}
// The own server is this account's choice; the next account starts on
// Donut cloud.
crate::sync::account_server::clear();
// Clear wayfern token
self.clear_wayfern_token().await;
self.set_wayfern_device_refusal(None);
@@ -2150,6 +2250,36 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
mod tests {
use super::*;
fn jwt_with(claims: serde_json::Value) -> String {
use base64::{engine::general_purpose, Engine as _};
let encode =
|value: &serde_json::Value| general_purpose::URL_SAFE_NO_PAD.encode(value.to_string());
format!(
"{}.{}.signature",
encode(&serde_json::json!({ "alg": "RS256" })),
encode(&claims)
)
}
#[test]
fn only_a_token_bound_to_the_server_goes_to_it() {
let server = "https://sync.example.com";
let bound = jwt_with(serde_json::json!({ "scope": "server-sync", "aud": server }));
assert!(CloudAuthManager::is_server_sync_token_for(&bound, server));
// An API that ignores `server` answers with a token for Donut cloud, which
// must never reach someone else's server.
let cloud = jwt_with(serde_json::json!({ "scope": "sync", "prefix": "users/u1/" }));
assert!(!CloudAuthManager::is_server_sync_token_for(&cloud, server));
assert!(!CloudAuthManager::is_server_sync_token_for(
&bound,
"https://other.example.com"
));
assert!(!CloudAuthManager::is_server_sync_token_for(
"not-a-jwt",
server
));
}
#[test]
fn a_refused_token_request_yields_the_servers_message() {
let refused = r#"Wayfern token request failed (403 Forbidden): {"message":"Browser automation is restricted to your primary device. Log out other devices to use it here.","error":"Forbidden","statusCode":403}"#;
+9
View File
@@ -603,6 +603,15 @@ pub struct CookieBotUsage {
// --- Client-side preconditions ---------------------------------------------
/// [`bot_precondition`] for this device as it is now: also refused while the
/// account syncs with its own server, which the bot's hosts cannot reach.
pub fn run_precondition(profile: &BrowserProfile) -> Result<(), String> {
if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() {
return Err(refusal);
}
bot_precondition(profile, &exit_reachability(profile))
}
/// Whether this profile could ever be warmed by the bot.
///
/// The server is authoritative — it re-checks all of this and owns the parts
+4 -2
View File
@@ -2117,7 +2117,7 @@ async fn save_cookie_bot_schedule(
// Refused here rather than when the run is due: a profile that can never be
// warmed should never reach an enrolment, an hour of quota or a leased host.
let profile = cookie_bot_profile(&profile_id)?;
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
cookie_bot::run_precondition(&profile)?;
// The frontend sends the user's choices; the profile facts the server refuses
// a run on are stamped here, from the profile itself, so a caller cannot
// assert them.
@@ -2178,7 +2178,7 @@ async fn run_cookie_bot_now(
max_minutes: Option<u32>,
) -> Result<cookie_bot::CookieBotRunStarted, String> {
let profile = cookie_bot_profile(&profile_id)?;
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
cookie_bot::run_precondition(&profile)?;
cookie_bot::run_now(&profile_id, max_minutes)
.await
.map_err(|e| cookie_bot_error("run start", e))
@@ -3541,6 +3541,8 @@ pub fn run_with_builder(
get_sync_settings,
save_sync_settings,
check_sync_server_connection,
sync::account_server::get_account_sync_server,
sync::account_server::set_account_sync_server,
set_profile_sync_mode,
cancel_profile_sync,
request_profile_sync,
+5 -6
View File
@@ -10878,12 +10878,11 @@ impl McpServer {
data: None,
})?;
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
.map_err(|message| McpError {
code: -32000,
message,
data: None,
})?;
crate::cookie_bot::run_precondition(&profile).map_err(|message| McpError {
code: -32000,
message,
data: None,
})?;
Ok(profile)
}
+3
View File
@@ -259,6 +259,9 @@ pub async fn start_remote_session(
.to_string();
let profile_id = profile.id.to_string();
if let Some(refusal) = crate::sync::account_server::cloud_run_refusal() {
return Err(RemoteSessionError::Other(refusal));
}
if let Some(refusal) = local_launch_refusal(profile, &platform) {
return Err(refusal);
}
+19
View File
@@ -152,6 +152,11 @@ pub struct AppSettings {
pub api_token: Option<String>, // Displayed token for user to copy
#[serde(default)]
pub sync_server_url: Option<String>, // URL of the sync server
/// The customer's own donut-sync a signed-in paid account syncs with,
/// instead of Donut cloud (`sync::account_server`). Owned by
/// `set_account_sync_server`, never by a settings save.
#[serde(default)]
pub account_sync_server_url: Option<String>,
#[serde(default)]
pub first_launch_timestamp: Option<u64>, // Unix epoch seconds when app was first launched
#[serde(default)]
@@ -302,6 +307,7 @@ impl Default for AppSettings {
api_port: 10108,
api_token: None,
sync_server_url: None,
account_sync_server_url: None,
first_launch_timestamp: None,
commercial_trial_acknowledged: false,
mcp_remote_enabled: false,
@@ -711,10 +717,12 @@ pub async fn save_app_settings(
settings.mcp_remote_enabled = current.mcp_remote_enabled;
settings.mcp_remote_key_id = current.mcp_remote_key_id;
settings.feedback_sent_at = current.feedback_sent_at;
settings.account_sync_server_url = current.account_sync_server_url;
}
} else {
settings.mcp_remote_enabled = false;
settings.mcp_remote_key_id = None;
settings.account_sync_server_url = None;
}
let mut persist_settings = settings.clone();
@@ -893,6 +901,16 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
#[tauri::command]
pub async fn get_sync_settings(app_handle: tauri::AppHandle) -> Result<SyncSettings, String> {
// Cloud auth takes priority over self-hosted settings
if let Some(server) = crate::sync::account_server::configured().await {
let sync_token = crate::cloud_auth::CLOUD_AUTH
.server_sync_token(&server)
.await
.ok();
return Ok(SyncSettings {
sync_server_url: Some(server),
sync_token,
});
}
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
let sync_token = crate::cloud_auth::CLOUD_AUTH
.get_or_refresh_sync_token()
@@ -1449,6 +1467,7 @@ mod tests {
api_port: 10108,
api_token: None,
sync_server_url: None,
account_sync_server_url: None,
first_launch_timestamp: None,
commercial_trial_acknowledged: false,
mcp_remote_enabled: false,
+191
View File
@@ -0,0 +1,191 @@
//! A donut-sync server the customer runs, used while signed in to a paid
//! Donut account.
//!
//! The app signs in to that server with a short token the Donut API issues for
//! its one address, and the server asks the API whose the token is. The plan's
//! profile limit and the team's namespace, roles and group access thus apply
//! there as on Donut cloud, and every person who syncs needs their own seat. A
//! shared `SYNC_TOKEN` opens the whole bucket to anyone who has it, so it stays
//! the signed-out way to use a server.
use crate::settings_manager::SettingsManager;
/// The address in the one form the Donut API and donut-sync also write: scheme,
/// host and port as the URL parser writes them, then the path without trailing
/// slashes. A token is bound to this exact string.
pub fn normalize_server_url(raw: &str) -> Option<String> {
let raw = raw.trim();
if raw.is_empty() || raw.len() > 2048 {
return None;
}
let url = url::Url::parse(raw).ok()?;
if !matches!(url.scheme(), "http" | "https") {
return None;
}
let host = url.host_str().filter(|host| !host.is_empty())?;
if !url.username().is_empty() || url.password().is_some() {
return None;
}
if url.query().is_some_and(|q| !q.is_empty()) || url.fragment().is_some_and(|f| !f.is_empty()) {
return None;
}
let port = url
.port()
.map(|port| format!(":{port}"))
.unwrap_or_default();
Some(format!(
"{}://{host}{port}{}",
url.scheme(),
url.path().trim_end_matches('/')
))
}
/// The own server set on this device, signed in or not.
fn stored() -> Option<String> {
SettingsManager::instance()
.load_settings()
.ok()?
.account_sync_server_url
}
/// The own server this signed-in account syncs with, or `None` for Donut
/// cloud.
pub async fn configured() -> Option<String> {
if !crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
return None;
}
stored()
}
/// The refusal for work that runs a profile on Donut's hosts. They take the
/// profile from Donut cloud and cannot reach the customer's server.
pub fn cloud_run_refusal() -> Option<String> {
stored().map(|_| crate::backend_error("REMOTE_NEEDS_DONUT_SYNC"))
}
pub(crate) fn clear() {
let manager = SettingsManager::instance();
if let Ok(mut settings) = manager.load_settings() {
if settings.account_sync_server_url.take().is_some() {
let _ = manager.save_settings(&settings);
}
}
}
/// Check that `raw` is a server this account can sync with, and return its
/// address.
async fn check(raw: &str) -> Result<String, String> {
let auth = &crate::cloud_auth::CLOUD_AUTH;
if !auth.is_logged_in().await {
return Err(crate::backend_error("ACCOUNT_SYNC_SIGN_IN_REQUIRED"));
}
if !auth.can_use_cloud_backup().await {
return Err(crate::backend_error("ACCOUNT_SYNC_PLAN_REQUIRED"));
}
let server =
normalize_server_url(raw).ok_or_else(|| crate::backend_error("SYNC_SERVER_URL_INVALID"))?;
let probe = super::preflight::check_sync_server(&server).await;
if !probe.server_reachable || probe.storage_ready == Some(false) {
return Err(crate::backend_error("SYNC_SERVER_UNREACHABLE"));
}
if probe.storage_reachable == Some(false) {
return Err(
serde_json::json!({
"code": "SYNC_SERVER_STORAGE_UNREACHABLE",
"params": { "endpoint": probe.storage_endpoint.unwrap_or_default() },
})
.to_string(),
);
}
match probe.account_url.as_deref().and_then(normalize_server_url) {
None => return Err(crate::backend_error("SYNC_SERVER_NO_ACCOUNTS")),
Some(address) if address != server => {
return Err(
serde_json::json!({
"code": "SYNC_SERVER_ADDRESS_MISMATCH",
"params": { "address": address },
})
.to_string(),
);
}
Some(_) => {}
}
// The API decides who may sync. Asking now names a refusal here, not at the
// first sync.
auth.server_sync_token(&server).await?;
Ok(server)
}
#[tauri::command]
pub async fn get_account_sync_server() -> Result<Option<String>, String> {
Ok(stored())
}
/// Sync this signed-in account with its own server, or with Donut cloud again
/// when `server_url` is empty.
#[tauri::command]
pub async fn set_account_sync_server(server_url: Option<String>) -> Result<Option<String>, String> {
let server = match server_url.as_deref().map(str::trim) {
Some(raw) if !raw.is_empty() => Some(check(raw).await?),
_ => None,
};
let manager = SettingsManager::instance();
let mut settings = manager
.load_settings()
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
settings.account_sync_server_url = server.clone();
manager
.save_settings(&settings)
.map_err(|e| crate::backend_error_with_detail("INTERNAL_ERROR", e))?;
log::info!("Account sync server set own_server={}", server.is_some());
Ok(server)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_one_form_for_every_way_to_type_the_same_address() {
// The same cases as the API and donut-sync: a token only matches when all
// three write the address alike.
for typed in [
"https://Sync.Example.com",
"https://sync.example.com/",
" https://sync.example.com:443// ",
"https://sync.example.com/?",
] {
assert_eq!(
normalize_server_url(typed).as_deref(),
Some("https://sync.example.com"),
"{typed}"
);
}
assert_eq!(
normalize_server_url("http://192.168.1.5:3929/donut/").as_deref(),
Some("http://192.168.1.5:3929/donut")
);
assert_eq!(
normalize_server_url("http://[::1]:3929").as_deref(),
Some("http://[::1]:3929")
);
}
#[test]
fn refuses_what_cannot_be_a_server_address() {
let long = format!("https://{}.com", "a".repeat(2100));
for bad in [
"",
"sync.example.com",
"ftp://sync.example.com",
"https://user:pass@sync.example.com",
"https://sync.example.com/?a=1",
"https://sync.example.com/#x",
long.as_str(),
] {
assert_eq!(normalize_server_url(bad), None, "{bad}");
}
}
}
+21 -7
View File
@@ -574,12 +574,23 @@ impl SyncEngine {
pub async fn create_from_settings(app_handle: &tauri::AppHandle) -> Result<Self, String> {
// Cloud auth takes priority
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string();
let token = crate::cloud_auth::CLOUD_AUTH
.get_or_refresh_sync_token()
.await
.map_err(|e| format!("Failed to get cloud sync token: {e}"))?
.ok_or_else(|| "Cloud sync token not available".to_string())?;
let (url, token) = match super::account_server::configured().await {
Some(server) => {
let token = crate::cloud_auth::CLOUD_AUTH
.server_sync_token(&server)
.await
.map_err(|e| format!("Failed to get own server sync token: {e}"))?;
(server, token)
}
None => {
let token = crate::cloud_auth::CLOUD_AUTH
.get_or_refresh_sync_token()
.await
.map_err(|e| format!("Failed to get cloud sync token: {e}"))?
.ok_or_else(|| "Cloud sync token not available".to_string())?;
(crate::cloud_auth::CLOUD_SYNC_URL.to_string(), token)
}
};
let mut engine = Self::new(url, token);
engine.read_only = crate::team_roles::is_read_only();
return Ok(engine);
@@ -3923,7 +3934,10 @@ async fn apply_profile_sync_mode(
);
}
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
// The cloud counts what it stores; an own server counts for itself.
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await
&& super::account_server::configured().await.is_none()
{
let sync_count = profile_manager
.list_profiles()
.map(|profiles| profiles.iter().filter(|p| p.is_sync_enabled()).count())
+1
View File
@@ -1,3 +1,4 @@
pub mod account_server;
mod client;
pub mod config_entities;
pub mod encryption;
+24
View File
@@ -41,6 +41,10 @@ pub struct SyncServerCheck {
pub storage_reachable: Option<bool>,
/// Why the storage probe failed, for the log and the error surface.
pub storage_error: Option<String>,
/// The address Donut accounts sign in to the server with, when it lets
/// them (`SYNC_PUBLIC_URL`).
#[serde(default)]
pub account_url: Option<String>,
}
impl SyncServerCheck {
@@ -62,6 +66,8 @@ struct ReadyzBody {
s3: Option<bool>,
#[serde(default, rename = "storageEndpoint")]
storage_endpoint: Option<String>,
#[serde(default, rename = "accountUrl")]
account_url: Option<String>,
}
fn probe_client() -> reqwest::Client {
@@ -113,6 +119,7 @@ pub async fn check_sync_server(server_url: &str) -> SyncServerCheck {
let body = readyz.json::<ReadyzBody>().await.ok();
check.storage_ready = body.as_ref().and_then(|b| b.s3);
check.account_url = body.as_ref().and_then(|b| b.account_url.clone());
check.storage_endpoint = body.and_then(|b| b.storage_endpoint);
if let Some(endpoint) = check.storage_endpoint.clone() {
@@ -226,6 +233,7 @@ mod tests {
storage_endpoint: Some("http://minio:9000".to_string()),
storage_reachable: Some(false),
storage_error: Some("connection failed: dns error".to_string()),
account_url: None,
};
assert!(!check.is_usable());
}
@@ -238,6 +246,7 @@ mod tests {
storage_endpoint: Some("http://localhost:9101".to_string()),
storage_reachable: Some(true),
storage_error: None,
account_url: None,
};
assert!(check.is_usable());
}
@@ -253,6 +262,7 @@ mod tests {
storage_endpoint: None,
storage_reachable: None,
storage_error: None,
account_url: None,
};
assert!(check.is_usable());
}
@@ -267,6 +277,20 @@ mod tests {
assert!(!check.is_usable());
}
#[test]
fn readyz_names_the_account_address() {
let body: ReadyzBody = serde_json::from_str(
r#"{"status":"ready","s3":true,"accountUrl":"https://sync.example.com"}"#,
)
.unwrap();
assert_eq!(
body.account_url.as_deref(),
Some("https://sync.example.com")
);
let older: ReadyzBody = serde_json::from_str(r#"{"status":"ready","s3":true}"#).unwrap();
assert_eq!(older.account_url, None);
}
#[test]
fn unreachable_server_is_not_usable() {
assert!(!SyncServerCheck::default().is_usable());
+29 -2
View File
@@ -36,6 +36,8 @@ pub enum SyncWorkItem {
#[derive(Clone, Copy)]
enum TokenSource {
Cloud,
/// The customer's own server, with a token the Donut API binds to it.
Account,
SelfHosted,
}
@@ -134,6 +136,18 @@ impl SyncSubscription {
app_handle: &tauri::AppHandle,
work_tx: mpsc::UnboundedSender<SyncWorkItem>,
) -> Result<Option<Self>, String> {
if let Some(server) = super::account_server::configured().await {
let token = crate::cloud_auth::CLOUD_AUTH
.server_sync_token(&server)
.await?;
return Ok(Some(Self::new(
server,
token,
TokenSource::Account,
work_tx,
)));
}
// Cloud auth takes priority
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
let url = crate::cloud_auth::CLOUD_SYNC_URL.to_string();
@@ -221,8 +235,14 @@ impl SyncSubscription {
}
Err(StreamFailure::Refused(status)) => {
log::warn!("Sync subscription refused status={status}");
if matches!(source, TokenSource::Cloud) {
crate::cloud_auth::CloudAuthManager::discard_cloud_sync_token(&current);
match source {
TokenSource::Cloud => {
crate::cloud_auth::CloudAuthManager::discard_cloud_sync_token(&current)
}
TokenSource::Account => {
crate::cloud_auth::CloudAuthManager::discard_server_sync_token(&current)
}
TokenSource::SelfHosted => {}
}
refused = Some(current);
token = None;
@@ -289,6 +309,13 @@ impl SyncSubscription {
.get_or_refresh_sync_token()
.await
}
TokenSource::Account => match super::account_server::configured().await {
Some(server) => crate::cloud_auth::CLOUD_AUTH
.server_sync_token(&server)
.await
.map(Some),
None => Ok(None),
},
TokenSource::SelfHosted => SettingsManager::instance()
.get_sync_token(app_handle)
.await