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:
+53
-1
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user