refactor: cleanup cloud sync

This commit is contained in:
zhom
2026-08-01 00:11:22 +04:00
parent a3737b39ce
commit b2a80c53e9
+132 -6
View File
@@ -264,6 +264,26 @@ pub struct RunRemoteRequest {
pub url: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetCloudSyncRequest {
/// `Disabled`, `Regular`, or `Encrypted`.
///
/// `Encrypted` derives its key from a passphrase that never leaves this
/// machine, so a profile in that mode can be synced but NOT run remotely —
/// a remote host would download ciphertext it cannot decrypt.
pub mode: String,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct SetCloudSyncResponse {
pub profile_id: String,
pub mode: String,
/// Whether the profile can now be launched on a remote host.
pub remote_launchable: bool,
/// Why not, when `remote_launchable` is false.
pub remote_blocked_reason: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct RunRemoteResponse {
pub profile_id: String,
@@ -573,6 +593,7 @@ impl ApiServer {
.routes(routes!(get_profile, update_profile, delete_profile))
.routes(routes!(run_profile))
.routes(routes!(run_profile_remote))
.routes(routes!(set_profile_cloud_sync))
.routes(routes!(open_url_in_profile))
.routes(routes!(kill_profile))
.routes(routes!(batch_run_profiles))
@@ -2220,6 +2241,88 @@ async fn run_profile_remote(
}))
}
#[utoipa::path(
post,
path = "/v1/profiles/{id}/cloud-sync",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = SetCloudSyncRequest,
responses(
(status = 200, description = "Cloud sync mode updated", body = SetCloudSyncResponse),
(status = 400, description = "Invalid mode, or the profile cannot be synced"),
(status = 401, description = "Unauthorized"),
(status = 402, description = "Active paid plan with cloud backup required"),
(status = 404, description = "Profile not found"),
(status = 409, description = "Profile is running — stop it before enabling sync"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn set_profile_cloud_sync(
Path(id): Path<String>,
State(state): State<ApiServerState>,
Json(request): Json<SetCloudSyncRequest>,
) -> Result<Json<SetCloudSyncResponse>, (StatusCode, String)> {
// Remote launch requires cloud sync, and until now sync could only be turned
// on from the GUI — so an automation-only caller could never reach the state
// that makes /run-remote work.
let mode = match request.mode.as_str() {
"Disabled" | "Regular" | "Encrypted" => request.mode.clone(),
other => {
return Err((
StatusCode::BAD_REQUEST,
format!("invalid sync mode {other:?}; expected Disabled, Regular or Encrypted"),
));
}
};
crate::sync::set_profile_sync_mode(state.app_handle.clone(), id.clone(), mode.clone())
.await
.map_err(sync_mode_error_response)?;
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
.map_err(manager_error_response)?;
let profile = profiles
.iter()
.find(|p| p.id.to_string() == id)
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
// Reported rather than left for the caller to discover at launch time: the
// most common reason a caller enables sync is to run the profile remotely,
// and Encrypted mode silently makes that impossible.
let blocked = remote_launch_precondition(profile).err();
Ok(Json(SetCloudSyncResponse {
profile_id: profile.id.to_string(),
mode,
remote_launchable: blocked.is_none(),
remote_blocked_reason: blocked,
}))
}
/// Map a sync-mode failure onto the status the caller can act on.
///
/// `set_profile_sync_mode` reports a running profile as a JSON body rather than
/// a plain message, because enabling sync under a live browser would race the
/// browser's own writes.
fn sync_mode_error_response(err: String) -> (StatusCode, String) {
if err.contains("PROFILE_RUNNING") {
return (
StatusCode::CONFLICT,
"profile is running; stop it before changing cloud sync".to_string(),
);
}
if err.contains("cross-OS") || err.contains("ephemeral") {
return (StatusCode::BAD_REQUEST, err);
}
(StatusCode::INTERNAL_SERVER_ERROR, err)
}
/// Whether a profile may be launched on a remote host.
///
/// Extracted so the rule is unit-testable without a running app: it is the one
@@ -2235,6 +2338,16 @@ pub fn remote_launch_precondition(
.to_string(),
);
}
if profile.is_encrypted_sync() {
// The key is derived from a passphrase that never leaves this machine, so
// the host would download ciphertext, launch Chromium on it, and push the
// corruption back over the user's real profile.
return Err(
"profile uses end-to-end encrypted sync; a remote host cannot decrypt \
it. Switch the profile to Regular sync to run it remotely."
.to_string(),
);
}
if profile.resolved_os().is_none() {
return Err(
"profile has no recorded operating system, so it cannot be scheduled \
@@ -2804,12 +2917,25 @@ mod tests {
.expect_err("a non-synced profile must be refused");
assert!(err.contains("cloud sync"), "unhelpful message: {err}");
for mode in [SyncMode::Regular, SyncMode::Encrypted] {
assert!(
remote_launch_precondition(&profile_with(mode, Some("macos"))).is_ok(),
"a synced profile must be allowed"
);
}
assert!(
remote_launch_precondition(&profile_with(SyncMode::Regular, Some("macos"))).is_ok(),
"a synced profile must be allowed"
);
}
#[test]
fn remote_launch_refuses_an_end_to_end_encrypted_profile() {
// The key is derived from a passphrase that never leaves this machine, so
// a remote host downloads ciphertext, launches Chromium on it, and pushes
// the corruption back over the user's real profile. Refusing here also
// saves taking the profile lock and a slot on leased hardware for a
// session that cannot possibly work.
let err = remote_launch_precondition(&profile_with(SyncMode::Encrypted, Some("macos")))
.expect_err("an encrypted profile must be refused");
assert!(
err.contains("encrypted") && err.contains("Regular"),
"the message must say what to change: {err}"
);
}
#[test]