Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 105 additions & 48 deletions src-tauri/src/commands/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use chacha20poly1305::{
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;

use crate::storage::config::config_dir;
use crate::storage::secrets::SecretsStore;
Expand Down Expand Up @@ -186,13 +187,46 @@ fn strip_excluded(
clocks.retain(|k, _| !is_excluded_secret(k));
}

/// Collect every `*.json` in `dir` into `files`, keyed by `prefix` + filename.
/// Keys present in `skip` are omitted — that is how a device withholds a config
/// file (e.g. `theme.json`) from the uploaded blob. A missing directory is not
/// an error: not every install has plugins.
fn collect_json_dir(
files: &mut HashMap<String, String>,
dir: &Path,
prefix: &str,
skip: &HashSet<String>,
) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let (Some(name), Ok(content)) = (
path.file_name().and_then(|n| n.to_str()),
fs::read_to_string(&path),
) else {
continue;
};
let key = format!("{prefix}{name}");
if skip.contains(&key) {
continue;
}
files.insert(key, content);
}
}

#[tauri::command]
pub fn backup_export(
state: tauri::State<SecretsStore>,
enc_key: Vec<u8>,
account_id: String,
device_id: String,
excluded_ids: Option<Vec<String>>,
skip_files: Option<Vec<String>>,
) -> Result<Vec<u8>, String> {
if enc_key.len() != 32 {
return Err("enc_key must be 32 bytes".to_string());
Expand All @@ -209,54 +243,20 @@ pub fn backup_export(
let mut files = HashMap::new();
let dir = config_dir();

// Root JSON files (connections.json, identities.json, plugin-registry.json, …)
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") {
if let (Some(name), Ok(content)) = (
path.file_name().and_then(|n| n.to_str()).map(String::from),
std::fs::read_to_string(&path),
) {
files.insert(name, content);
}
}
}
}

// plugin-data/<id>.json — each plugin's api.storage
let plugin_data_dir = dir.join("plugin-data");
if let Ok(entries) = std::fs::read_dir(&plugin_data_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") {
if let (Some(name), Ok(content)) = (
path.file_name().and_then(|n| n.to_str()).map(String::from),
std::fs::read_to_string(&path),
) {
files.insert(format!("plugin-data/{name}"), content);
}
}
}
}

// plugins/__meta__/*.json — the installed-plugin list and marketplace sources.
// Carrying these is what lets a fresh device restore the user's plugin set;
// the reinstall itself happens client-side and stays hash-verified.
let plugin_meta_dir = dir.join("plugins").join(PLUGIN_META_ID);
if let Ok(entries) = std::fs::read_dir(&plugin_meta_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") {
if let (Some(name), Ok(content)) = (
path.file_name().and_then(|n| n.to_str()).map(String::from),
std::fs::read_to_string(&path),
) {
files.insert(format!("{PLUGIN_META_PREFIX}{name}"), content);
}
}
}
}
let skip: HashSet<String> = skip_files.unwrap_or_default().into_iter().collect();

// Root JSON files (connections.json, identities.json, plugin-registry.json, …),
// each plugin's api.storage, and the installed-plugin list that lets a fresh
// device restore the user's plugin set — the reinstall itself happens
// client-side and stays hash-verified.
collect_json_dir(&mut files, &dir, "", &skip);
collect_json_dir(&mut files, &dir.join("plugin-data"), "plugin-data/", &skip);
collect_json_dir(
&mut files,
&dir.join("plugins").join(PLUGIN_META_ID),
PLUGIN_META_PREFIX,
&skip,
);
let data = state.export_all()?;
let mut secrets = data.secrets;
let mut clocks = data.clocks;
Expand Down Expand Up @@ -517,6 +517,63 @@ pub fn updater_set_auto(enabled: bool) -> Result<(), String> {
mod tests {
use super::*;

#[test]
fn collect_json_dir_skips_named_files_and_prefixes_the_rest() {
let dir = std::env::temp_dir().join(format!("voltius-skip-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("theme.json"), r#"{"activeThemeId":"voltius"}"#).unwrap();
fs::write(dir.join("connections.json"), "[]").unwrap();
fs::write(dir.join("notes.txt"), "ignored").unwrap();

let mut files = HashMap::new();
let skip: HashSet<String> = ["theme.json".to_string()].into_iter().collect();
collect_json_dir(&mut files, &dir, "", &skip);

assert!(
!files.contains_key("theme.json"),
"skipped file must not be collected"
);
assert_eq!(
files.get("connections.json").map(String::as_str),
Some("[]")
);
assert!(!files.contains_key("notes.txt"), "non-json must be ignored");

let mut prefixed = HashMap::new();
collect_json_dir(&mut prefixed, &dir, "plugin-data/", &HashSet::new());
assert!(
prefixed.contains_key("plugin-data/theme.json"),
"prefix applies to the key"
);

fs::remove_dir_all(&dir).ok();
}

#[test]
fn collect_json_dir_skip_matches_the_prefixed_key_not_the_bare_filename() {
let dir = std::env::temp_dir().join(format!("voltius-skip-key-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("theme.json"), r#"{"activeThemeId":"voltius"}"#).unwrap();

let mut files = HashMap::new();
let skip: HashSet<String> = ["plugin-data/theme.json".to_string()].into_iter().collect();
collect_json_dir(&mut files, &dir, "plugin-data/", &skip);
assert!(
!files.contains_key("plugin-data/theme.json"),
"a skip entry for the prefixed key must skip the file"
);

let mut files = HashMap::new();
let skip: HashSet<String> = ["theme.json".to_string()].into_iter().collect();
collect_json_dir(&mut files, &dir, "plugin-data/", &skip);
assert!(
files.contains_key("plugin-data/theme.json"),
"a skip entry for the bare filename must not match a differently-prefixed key"
);

fs::remove_dir_all(&dir).ok();
}

#[test]
fn strip_excluded_removes_entity_and_its_secrets_from_both_maps() {
let mut files = HashMap::new();
Expand Down
3 changes: 3 additions & 0 deletions src/components/import-export/UserDataImportTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export function UserDataImportTab({ onClose }: { onClose: () => void }) {
</span>
<Icon icon={h.icon} width={13} className="text-(--t-text-muted)" />
<span className="text-sm text-(--t-text-primary)">{t(`importExport.userData.handlers.${h.key}.label`)}</span>
{h.key === "themes" && (
<span className="text-xs text-(--t-status-warning)">{t("importExport.userData.import.themesReplaceWarning")}</span>
)}
</label>
))}
</div>
Expand Down
66 changes: 66 additions & 0 deletions src/components/settings/sections/SyncSection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { useSyncPrefsStore } from "@/stores/syncPrefsStore";
import { USER_DATA_HANDLERS } from "@/services/user-data/registry";

vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (k: string) => k }) }));
vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } }));
vi.mock("@iconify/react", () => ({ Icon: () => null }));
vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn(async () => null) }));
vi.mock("@/utils/billing", () => ({ openPortal: vi.fn() }));
vi.mock("@/services/sync", () => ({
getSyncState: () => ({ status: "idle", lastSync: null, error: null, cloudActive: false, blobSizeBytes: null }),
onSyncStateChange: () => () => {},
syncNow: vi.fn(),
scheduleSync: vi.fn(),
}));

import { scheduleSync } from "@/services/sync";
import SyncSection from "./SyncSection";

const toggleFor = (c: HTMLElement, domain: string) =>
c.querySelector(`[data-sync-domain="${domain}"] button[role="switch"]`) as HTMLButtonElement | null;

describe("SyncSection settings domains", () => {
beforeEach(() => useSyncPrefsStore.setState({ syncSettingDomains: {}, syncTypes: {}, excludedIds: [] }));
afterEach(cleanup);

test("renders a toggle for each settings domain and none for vaults", () => {
const { container } = render(<SyncSection />);
expect(toggleFor(container, "themes")).toBeTruthy();
expect(toggleFor(container, "appSettings")).toBeTruthy();
expect(toggleFor(container, "recentPeople")).toBeTruthy();
expect(toggleFor(container, "vaults")).toBeNull();
});

test("switching a domain off records it", () => {
const { container } = render(<SyncSection />);
fireEvent.click(toggleFor(container, "themes")!);
expect(useSyncPrefsStore.getState().isDomainSynced("themes")).toBe(false);
});

test("switching a domain back on publishes this device's values", () => {
const themes = USER_DATA_HANDLERS.find((h) => h.key === "themes")!;
const touch = vi.spyOn(themes, "touch").mockImplementation(() => {});
useSyncPrefsStore.getState().setSyncSettingDomain("themes", false);
const { container } = render(<SyncSection />);
fireEvent.click(toggleFor(container, "themes")!);
expect(touch).toHaveBeenCalled();
touch.mockRestore();
});

test("switching a domain off does not touch it", () => {
const themes = USER_DATA_HANDLERS.find((h) => h.key === "themes")!;
const touch = vi.spyOn(themes, "touch").mockImplementation(() => {});
const { container } = render(<SyncSection />);
fireEvent.click(toggleFor(container, "themes")!);
expect(touch).not.toHaveBeenCalled();
touch.mockRestore();
});

test("switching a domain off schedules a push to withdraw the server copy", () => {
const { container } = render(<SyncSection />);
fireEvent.click(toggleFor(container, "themes")!);
expect(scheduleSync).toHaveBeenCalled();
});
});
Loading