feat: encrypted profile sync through a shared folder

Profiles can be kept in step across machines through any folder they
share (cloud drive, network share, git checkout): one file,
nexus-profiles.nexus, in the encrypted-export format under a sync
password kept in the keychain/vault. Each sync merges profile by profile
(newest updated_at wins), propagates deletions as dated tombstones (an
edit after a deletion brings the profile back; tombstones expire after
180 days), validates every incoming profile, writes the file only when
it changed, and never syncs credentials.

Settings → Security → Profile sync picks the folder and sets the
password; syncs run at launch, 3 s after a profile change, every 10
minutes and on Sync Now (automatic runs stay quiet unless something
changed). An end-to-end test drives create, edit and delete between two
data directories sharing a folder.
This commit is contained in:
Andy Dixon
2026-10-12 19:20:12 +01:00
parent 32890c6415
commit 1c590f47ff
11 changed files with 437 additions and 15 deletions
+53 -1
View File
@@ -124,6 +124,10 @@ pub struct App {
/// The tab layout as last saved for restoring (see restore.rs), and when it was last checked.
pub saved_layout: Option<String>,
layout_checked: f64,
/// A profile sync in flight; when the next automatic one is due; whether it was asked for.
sync_job: Option<crate::settings::Job<Result<nexus_core::service::SyncReport, String>>>,
sync_due: f64,
sync_manual: bool,
rx: mpsc::Receiver<Event>,
pub theme: ThemeKind,
pub editor: profile_editor::ProfileEditor,
@@ -200,6 +204,9 @@ impl App {
resize_sidebar: false,
saved_layout: None,
layout_checked: 0.0,
sync_job: None,
sync_due: 0.0,
sync_manual: false,
};
let restore = opts.connect.is_empty() && app.run_local.is_none() && opts.open.is_none() && app.s.prefs.restore_sessions;
if restore {
@@ -614,7 +621,15 @@ impl App {
Err(e) => self.s.error(format!("Can't connect to {name}: {e}")),
}
}
Request::ReloadProfiles => self.s.reload_profiles(),
Request::ReloadProfiles => {
self.s.reload_profiles();
// A profile changed: sync it out shortly (batched with any further edits).
self.sync_due = self.sync_due.min(self.s.now + 3.0);
}
Request::SyncProfiles => {
self.sync_manual = true;
self.sync_due = 0.0;
}
Request::ReloadPreferences => {
self.s.prefs = self.s.svc.preferences();
self.resize_sidebar = true;
@@ -784,6 +799,42 @@ impl App {
}
}
/// Profile sync: at launch, a few seconds after a profile changes, every 10 minutes and on
/// request, when a folder and password are set. Automatic runs stay quiet unless something
/// changed or a manual one failed.
fn drive_sync(&mut self, ctx: &egui::Context) {
if let Some(r) = crate::settings::poll(&mut self.sync_job) {
let manual = std::mem::take(&mut self.sync_manual);
match r {
Ok(rep) => {
if rep.pulled + rep.removed > 0 {
self.s.reload_profiles();
self.s.info(format!("Profile sync: {} updated, {} removed", rep.pulled, rep.removed));
} else if manual {
self.s.success(if rep.pushed { "Profiles synced" } else { "Profiles already in sync" });
}
if rep.skipped > 0 {
self.s.error(format!("Profile sync skipped {} invalid profile(s) from the shared file", rep.skipped));
}
}
Err(e) if manual => self.s.error(format!("Profile sync failed: {e}")),
Err(e) => tracing::warn!(error = %e, "profile sync failed"),
}
}
if self.screenshot.is_some() || self.sync_job.is_some() || self.s.now < self.sync_due {
return;
}
self.sync_due = self.s.now + 600.0;
if self.s.prefs.sync_folder.trim().is_empty() {
if std::mem::take(&mut self.sync_manual) {
self.s.error("Choose a sync folder first");
}
return;
}
let svc = self.s.svc.clone();
self.sync_job = Some(crate::settings::Job::spawn(ctx, move || svc.sync_profiles().map_err(|e| e.to_string())));
}
pub fn toggle_broadcast(&mut self) {
self.s.broadcast = !self.s.broadcast;
if !self.s.broadcast {
@@ -1012,6 +1063,7 @@ impl eframe::App for App {
chrome::palette(self, &ctx);
chrome::toasts(self, &ctx);
self.screenshot(&ctx);
self.drive_sync(&ctx);
if self.screenshot.is_none() && self.s.prefs.restore_sessions && self.s.now - self.layout_checked > 1.0 {
self.layout_checked = self.s.now;
crate::restore::save_if_changed(self);
+64
View File
@@ -104,6 +104,7 @@ pub struct Settings {
known: Vec<KnownHost>,
/// Add-CA form: (host pattern, public key), shown when Some.
new_ca: Option<(String, String)>,
sync_password: String,
default_shell: String,
help_topic: usize,
master: Option<MasterPrompt>,
@@ -381,6 +382,69 @@ impl Settings {
s.info("Secret store locked");
}
});
self.sync_section(ui, s);
}
fn sync_section(&mut self, ui: &mut Ui, s: &mut Shared) {
ui.add_space(12.0);
widgets::section(ui, "Profile sync");
widgets::hint(
ui,
"Keep connection profiles in step across machines through a folder they share (a cloud drive, a network share, a git checkout). \
Profiles are stored there encrypted with the sync password; passwords and keys are never synced.",
);
form(ui, "sync-form", |ui| {
field(ui, "Folder");
ui.horizontal(|ui| {
let shown = if self.prefs.sync_folder.is_empty() { "Not set".to_string() } else { self.prefs.sync_folder.clone() };
ui.label(RichText::new(shown).font(theme::mono(12.0)));
if widgets::button(ui, icon::FOLDER_OPEN, "Choose…").clicked()
&& let Some(dir) = rfd::FileDialog::new().set_title("Folder to sync profiles through").pick_folder()
{
self.set_sync_folder(s, dir.to_string_lossy().into_owned());
}
if !self.prefs.sync_folder.is_empty() && ui.button("Turn Off").clicked() {
self.set_sync_folder(s, String::new());
}
});
ui.end_row();
field(ui, "Sync password");
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.sync_password)
.password(true)
.hint_text(if s.svc.has_sync_password() { "Stored — type to replace" } else { "Same on every machine" })
.desired_width(220.0),
);
if ui.add_enabled(!self.sync_password.is_empty(), egui::Button::new("Save")).clicked() {
match s.svc.set_sync_password(&self.sync_password) {
Ok(()) => s.success("Sync password stored"),
Err(e) => s.error(format!("Couldn't store the sync password: {e}")),
}
self.sync_password.clear();
}
});
ui.end_row();
field(ui, "");
if widgets::button(ui, icon::ARROWS_CLOCKWISE, "Sync Now").clicked() {
s.request(Request::SyncProfiles);
}
ui.end_row();
});
}
/// Saves just the sync folder (the rest of the form keeps its unsaved edits).
fn set_sync_folder(&mut self, s: &mut Shared, folder: String) {
let mut stored = s.svc.preferences();
stored.sync_folder = folder.clone();
match s.svc.save_preferences(&stored) {
Ok(()) => {
self.prefs.sync_folder = folder.clone();
s.prefs.sync_folder = folder;
s.request(Request::SyncProfiles);
}
Err(e) => s.error(format!("Failed to save: {e}")),
}
}
fn master_dialog(&mut self, ctx: &egui::Context, s: &mut Shared) {
+2
View File
@@ -10,6 +10,8 @@ use std::sync::Arc;
/// Things a feature asks the main window to do (handled once per frame, in order).
#[allow(clippy::large_enum_variant)] // a per-frame queue; boxing buys nothing
pub enum Request {
/// Merge profiles with the sync folder (manual, or after a change).
SyncProfiles,
/// Open the profile editor for a new profile (optionally pre-set protocol / folder).
NewProfile {
protocol: Option<Protocol>,