bound migrate_data_dir cleanup to known files, add dialog-verified new_dir

This commit is contained in:
Horizontal42 2026-08-09 16:17:32 +03:00
parent 354705cf15
commit d5b88577b4
3 changed files with 47 additions and 15 deletions

View file

@ -630,6 +630,23 @@ fn data_dir_has_documents(dir: String) -> bool {
PathBuf::from(dir).join("documents.json").exists() 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<Option<PathBuf>>);
#[tauri::command]
fn pick_data_dir(app: AppHandle) -> Result<Option<String>, 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::<PickedDataDir>().0.lock().unwrap() = Some(canon);
Ok(Some(path.to_string_lossy().into_owned()))
}
/// strategy: "move" (copy mine, delete originals), "overwrite" (same, target /// strategy: "move" (copy mine, delete originals), "overwrite" (same, target
/// had documents), "use_existing" (just switch — keep the target's files) /// had documents), "use_existing" (just switch — keep the target's files)
#[tauri::command] #[tauri::command]
@ -639,12 +656,19 @@ fn migrate_data_dir(
new_dir: String, new_dir: String,
strategy: String, strategy: String,
) -> Result<(), String> { ) -> Result<(), String> {
let old = docs_dir(&app, &old_dir);
let new = PathBuf::from(&new_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 // 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::<PickedDataDir>();
{
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_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 { if canon_old == canon_new {
return Ok(()); return Ok(());
} }
@ -675,8 +699,20 @@ fn migrate_data_dir(
if old_docs.exists() { if old_docs.exists() {
fs::remove_file(&old_docs).ok(); 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 { 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(()) Ok(())
} }
@ -834,6 +870,7 @@ fn main() {
.plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_process::init())
.manage(DroppedFiles::default()) .manage(DroppedFiles::default())
.manage(PickedDataDir::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
save_file, save_file,
load_file, load_file,
@ -843,6 +880,7 @@ fn main() {
backup_deleted_sheet, backup_deleted_sheet,
open_backups_folder, open_backups_folder,
data_dir_has_documents, data_dir_has_documents,
pick_data_dir,
migrate_data_dir, migrate_data_dir,
fetch_rates, fetch_rates,
fetch_historical_rates, fetch_historical_rates,

View file

@ -1,16 +1,11 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 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 { 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 { invoke } from '@tauri-apps/api/core';
import { open } from '@tauri-apps/plugin-dialog';
vi.mock('@tauri-apps/api/core', () => ({ vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(), invoke: vi.fn(),
})); }));
vi.mock('@tauri-apps/plugin-dialog', () => ({
open: vi.fn(),
}));
describe('storage', () => { describe('storage', () => {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
@ -310,14 +305,14 @@ describe('storage', () => {
it('returns path when in Tauri and folder is chosen', async () => { it('returns path when in Tauri and folder is chosen', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(open).mockResolvedValue('chosen_path'); vi.mocked(invoke).mockResolvedValue('chosen_path');
expect(await chooseFolder()).toBe('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 () => { it('returns null when in Tauri and folder choice is cancelled', async () => {
vi.stubGlobal('window', { __TAURI_INTERNALS__: {} }); vi.stubGlobal('window', { __TAURI_INTERNALS__: {} });
vi.mocked(open).mockResolvedValue(null); vi.mocked(invoke).mockResolvedValue(null);
expect(await chooseFolder()).toBeNull(); expect(await chooseFolder()).toBeNull();
}); });
}); });

View file

@ -167,11 +167,10 @@ export async function openBackupsFolder(dataDir: string): Promise<void> {
if (isTauri()) await invoke("open_backups_folder", { dir: dirArg(dataDir) }); 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<string | null> { export async function chooseFolder(): Promise<string | null> {
if (!isTauri()) return null; if (!isTauri()) return null;
const { open } = await import("@tauri-apps/plugin-dialog"); return (await invoke<string | null>("pick_data_dir")) ?? null;
const picked = await open({ directory: true, multiple: false });
return typeof picked === "string" ? picked : null;
} }
export async function dataDirHasDocuments(dir: string): Promise<boolean> { export async function dataDirHasDocuments(dir: string): Promise<boolean> {