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

This commit is contained in:
Horizontal42 2026-08-09 16:27:08 +03:00 • committed by GitHub
parent 354705cf15
commit dad3de70a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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()
}
/// 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
/// 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::<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_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,

View file

@ -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();
});
});

View file

@ -167,11 +167,10 @@ export async function openBackupsFolder(dataDir: string): Promise<void> {
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> {
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<string | null>("pick_data_dir")) ?? null;
}
export async function dataDirHasDocuments(dir: string): Promise<boolean> {