From dad3de70a55edce985b3fc18fe87972de3c9d6ca Mon Sep 17 00:00:00 2001
From: Horizontal42 <75383519+Horizontal42@users.noreply.github.com>
Date: Sun, 9 Aug 2026 16:27:08 +0300
Subject: [PATCH] bound migrate_data_dir cleanup to known files, add
dialog-verified new_dir
---
src-tauri/src/main.rs | 46 +++++++++++++++++++++++++++++++++++++++----
src/storage.test.ts | 11 +++--------
src/storage.ts | 5 ++---
3 files changed, 47 insertions(+), 15 deletions(-)
diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs
index ba635d5..9b008a2 100644
--- a/src-tauri/src/main.rs
+++ b/src-tauri/src/main.rs
@@ -630,6 +630,23 @@ fn data_dir_has_documents(dir: String) -> bool {
PathBuf::from(dir).join("documents.json").exists()
}
+/// The folder the user last chose in the native picker. `migrate_data_dir`
+/// writes to and prunes whatever it is handed, and the frontend can name any
+/// path over IPC, so the target has to be proven user-chosen.
+#[derive(Default)]
+struct PickedDataDir(Mutex>);
+
+#[tauri::command]
+fn pick_data_dir(app: AppHandle) -> Result , String> {
+ let Some(picked) = app.dialog().file().blocking_pick_folder() else {
+ return Ok(None);
+ };
+ let path = picked.into_path().map_err(|e| e.to_string())?;
+ let canon = fs::canonicalize(&path).map_err(|e| e.to_string())?;
+ *app.state::().0.lock().unwrap() = Some(canon);
+ Ok(Some(path.to_string_lossy().into_owned()))
+}
+
/// strategy: "move" (copy mine, delete originals), "overwrite" (same, target
/// had documents), "use_existing" (just switch — keep the target's files)
#[tauri::command]
@@ -639,12 +656,19 @@ fn migrate_data_dir(
new_dir: String,
strategy: String,
) -> Result<(), String> {
- let old = docs_dir(&app, &old_dir);
let new = PathBuf::from(&new_dir);
- fs::create_dir_all(&new).map_err(|e| e.to_string())?;
// canonicalize: "C:\Data" and "c:\data" are the same folder on Windows
+ let canon_new = fs::canonicalize(&new).map_err(|e| e.to_string())?;
+ let state = app.state::();
+ {
+ let mut picked = state.0.lock().unwrap();
+ if picked.as_deref() != Some(canon_new.as_path()) {
+ return Err("target folder was not chosen by the user".into());
+ }
+ *picked = None;
+ }
+ let old = docs_dir(&app, &old_dir);
let canon_old = fs::canonicalize(&old).unwrap_or_else(|_| old.clone());
- let canon_new = fs::canonicalize(&new).unwrap_or_else(|_| new.clone());
if canon_old == canon_new {
return Ok(());
}
@@ -675,8 +699,20 @@ fn migrate_data_dir(
if old_docs.exists() {
fs::remove_file(&old_docs).ok();
}
+ // one level at a time, files only — `old` comes from the frontend, and a
+ // recursive delete of anything named "backups" is too big a hammer to hand
+ // over. run_backups/backup_deleted_sheet only ever write flat files here.
if all_copied {
- fs::remove_dir_all(old.join("backups")).ok();
+ for sub in ["backups", "backups/deleted"] {
+ if let Ok(entries) = fs::read_dir(old.join(sub)) {
+ for entry in entries.flatten() {
+ let p = entry.path();
+ if p.is_file() {
+ fs::remove_file(&p).ok();
+ }
+ }
+ }
+ }
}
Ok(())
}
@@ -834,6 +870,7 @@ fn main() {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(DroppedFiles::default())
+ .manage(PickedDataDir::default())
.invoke_handler(tauri::generate_handler![
save_file,
load_file,
@@ -843,6 +880,7 @@ fn main() {
backup_deleted_sheet,
open_backups_folder,
data_dir_has_documents,
+ pick_data_dir,
migrate_data_dir,
fetch_rates,
fetch_historical_rates,
diff --git a/src/storage.test.ts b/src/storage.test.ts
index 12a9dc2..35e00b2 100644
--- a/src/storage.test.ts
+++ b/src/storage.test.ts
@@ -1,16 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { isTauri, loadSettings, saveSettings, defaultSettingsData, loadAppData, saveAppData, flushAppData, setDataDir, runBackups, backupDeletedSheet, openBackupsFolder, chooseFolder, dataDirHasDocuments, migrateDataDir, fetchRates, fetchHistoricalRates } from './storage';
import { invoke } from '@tauri-apps/api/core';
-import { open } from '@tauri-apps/plugin-dialog';
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}));
-vi.mock('@tauri-apps/plugin-dialog', () => ({
- open: vi.fn(),
-}));
-
describe('storage', () => {
beforeEach(() => {
vi.resetAllMocks();
@@ -310,14 +305,14 @@ describe('storage', () => {
it('returns path when in Tauri and folder is chosen', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
- vi.mocked(open).mockResolvedValue('chosen_path');
+ vi.mocked(invoke).mockResolvedValue('chosen_path');
expect(await chooseFolder()).toBe('chosen_path');
- expect(open).toHaveBeenCalledWith({ directory: true, multiple: false });
+ expect(invoke).toHaveBeenCalledWith('pick_data_dir', undefined);
});
it('returns null when in Tauri and folder choice is cancelled', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
- vi.mocked(open).mockResolvedValue(null);
+ vi.mocked(invoke).mockResolvedValue(null);
expect(await chooseFolder()).toBeNull();
});
});
diff --git a/src/storage.ts b/src/storage.ts
index 833aab1..9544485 100644
--- a/src/storage.ts
+++ b/src/storage.ts
@@ -167,11 +167,10 @@ export async function openBackupsFolder(dataDir: string): Promise {
if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) });
}
+/** Rust-side picker: migrateDataDir only accepts a folder chosen through this. */
export async function chooseFolder(): Promise {
if (!isTauri()) return null;
- const { open } = await import("@tauri-apps/plugin-dialog");
- const picked = await open({ directory: true, multiple: false });
- return typeof picked === "string" ? picked : null;
+ return (await invoke("pick_data_dir")) ?? null;
}
export async function dataDirHasDocuments(dir: string): Promise {