diff --git a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue index 1651754251..36de5814ae 100644 --- a/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue +++ b/apps/desktop/src/components/backup/ScheduledDatabaseBackupSettings.vue @@ -357,9 +357,28 @@ async function requestCancelRun(runId: string) { if (await cancelRun(runId)) toast(t("databaseBackup.cancelRequested"), 2500); } +async function confirmLegacyDestination(schedule: DatabaseBackupSchedule): Promise { + if (!(await api.databaseExportDestinationNeedsConfirmation(schedule.destinationDirectory))) return schedule; + + const { open } = await import("@tauri-apps/plugin-dialog"); + const selected = await open({ + directory: true, + multiple: false, + defaultPath: schedule.destinationDirectory, + title: t("databaseBackup.selectDestination"), + }); + if (typeof selected !== "string") return null; + + await api.recordDatabaseExportDestination(selected); + if (selected === schedule.destinationDirectory) return schedule; + return saveSchedule({ ...schedule, destinationDirectory: selected }); +} + async function runNow(schedule: DatabaseBackupSchedule) { try { - const run = await runSchedule(schedule.id, "manual"); + const confirmedSchedule = await confirmLegacyDestination(schedule); + if (!confirmedSchedule) return; + const run = await runSchedule(confirmedSchedule.id, "manual"); if (!run) return; if (run.status === "success") toast(t("databaseBackup.runSuccess", { count: run.files.length }), 3000); else if (run.status === "cancelled") toast(t("databaseBackup.runCancelled"), 3000); diff --git a/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts b/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts index 59c2dfa629..84fac1fc68 100644 --- a/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts +++ b/apps/desktop/src/components/backup/__tests__/ScheduledDatabaseBackupSettings.spec.ts @@ -15,7 +15,9 @@ const mocks = vi.hoisted(() => ({ activeRuns: [] as DatabaseBackupRun[], ensureConnected: vi.fn(async () => {}), listDatabases: vi.fn(async (_connectionId: string) => [{ name: "app" }]), + databaseExportDestinationNeedsConfirmation: vi.fn(async (_directory: string) => false), recordDatabaseExportDestination: vi.fn(async (_directory: string) => {}), + openDirectory: vi.fn(async (): Promise => "/backups"), toast: vi.fn(), saveSchedule: vi.fn(), setScheduleEnabled: vi.fn(), @@ -58,13 +60,14 @@ vi.mock("@/composables/useToast", () => ({ })); vi.mock("@tauri-apps/plugin-dialog", () => ({ - open: vi.fn(async () => "/backups"), + open: mocks.openDirectory, })); vi.mock("@/lib/backend/api", () => ({ listDatabases: mocks.listDatabases, deleteDatabaseBackupFiles: vi.fn(), revealPathInFileManager: vi.fn(), + databaseExportDestinationNeedsConfirmation: mocks.databaseExportDestinationNeedsConfirmation, recordDatabaseExportDestination: mocks.recordDatabaseExportDestination, })); @@ -230,6 +233,13 @@ function saveScheduleButton(): HTMLButtonElement { return button; } +function scheduleRunNowButton(): HTMLButtonElement { + const title = String(i18n.global.t("databaseBackup.runNow")); + const button = Array.from(document.body.querySelectorAll("button")).find((item) => item.title === title && !item.textContent?.trim()); + if (!button) throw new Error("Schedule run-now button not found"); + return button; +} + afterEach(() => { for (const app of mountedApps.splice(0)) app.unmount(); document.body.innerHTML = ""; @@ -242,11 +252,17 @@ afterEach(() => { mocks.ensureConnected.mockClear(); mocks.listDatabases.mockReset(); mocks.listDatabases.mockResolvedValue([{ name: "app" }]); + mocks.databaseExportDestinationNeedsConfirmation.mockReset(); + mocks.databaseExportDestinationNeedsConfirmation.mockResolvedValue(false); mocks.recordDatabaseExportDestination.mockReset(); mocks.recordDatabaseExportDestination.mockResolvedValue(undefined); + mocks.openDirectory.mockReset(); + mocks.openDirectory.mockResolvedValue("/backups"); mocks.toast.mockClear(); mocks.saveSchedule.mockClear(); mocks.cancelRun.mockClear(); + mocks.runSchedule.mockReset(); + mocks.runSchedule.mockResolvedValue(null); mocks.runOneShot.mockReset(); mocks.runOneShot.mockResolvedValue(null); }); @@ -500,6 +516,46 @@ describe("ScheduledDatabaseBackupSettings schedule dialog", () => { expect(document.body.textContent).toContain(String(i18n.global.t("databaseBackup.noSupportedConnections"))); }); + it("reconfirms a legacy destination before manually running its schedule", async () => { + mocks.schedules.push(schedule()); + mocks.databaseExportDestinationNeedsConfirmation.mockResolvedValueOnce(true); + mocks.runSchedule.mockResolvedValueOnce({ + id: "run-1", + scheduleId: "schedule-1", + scheduleName: "Nightly backup", + connectionId: "mysql-1", + connectionName: "Local MySQL", + trigger: "manual", + source: "scheduled", + status: "success", + startedAt: "2026-08-18T00:00:00.000Z", + completedAt: "2026-08-18T00:01:00.000Z", + files: [], + }); + await mountSettings(); + + scheduleRunNowButton().click(); + await flush(); + + expect(mocks.databaseExportDestinationNeedsConfirmation).toHaveBeenCalledWith("/backups"); + expect(mocks.openDirectory).toHaveBeenCalledWith(expect.objectContaining({ directory: true, defaultPath: "/backups" })); + expect(mocks.recordDatabaseExportDestination).toHaveBeenCalledWith("/backups"); + expect(mocks.runSchedule).toHaveBeenCalledWith("schedule-1", "manual"); + }); + + it("does not run a legacy schedule when destination confirmation is cancelled", async () => { + mocks.schedules.push(schedule()); + mocks.databaseExportDestinationNeedsConfirmation.mockResolvedValueOnce(true); + mocks.openDirectory.mockResolvedValueOnce(null); + await mountSettings(); + + scheduleRunNowButton().click(); + await flush(); + + expect(mocks.recordDatabaseExportDestination).not.toHaveBeenCalled(); + expect(mocks.runSchedule).not.toHaveBeenCalled(); + }); + it("removes unavailable databases from an edited schedule before saving", async () => { mocks.connections.push({ id: "mysql-1", name: "Local MySQL", db_type: "mysql" }); mocks.schedules.push(schedule({ databases: ["app", "deleted"] })); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index c6450be1b1..14da698426 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -457,6 +457,7 @@ export const beginDatabaseBackupSnapshot = forward("beginDatabaseBackupSnapshot" export const exportDatabaseSql = forward("exportDatabaseSql"); export const cancelDatabaseExport = forward("cancelDatabaseExport"); export const clearDatabaseExportCancellation = forward("clearDatabaseExportCancellation"); +export const databaseExportDestinationNeedsConfirmation = forward("databaseExportDestinationNeedsConfirmation"); export const recordDatabaseExportDestination = forward("recordDatabaseExportDestination"); export const exportQueryResultCsv = forward("exportQueryResultCsv"); export const exportTableDataCsv = forward("exportTableDataCsv"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index fde9ecf533..0d91be6710 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2480,6 +2480,10 @@ export async function clearDatabaseExportCancellation(_exportId: string): Promis // The web exporter owns and clears its cancellation marker on completion. } +export async function databaseExportDestinationNeedsConfirmation(_directory: string): Promise { + throw new Error("Scheduled database backups are only available in the desktop app."); +} + export async function recordDatabaseExportDestination(_directory: string): Promise { throw new Error("Scheduled database backups are only available in the desktop app."); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 710f428f48..5273362728 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -5055,6 +5055,10 @@ export async function clearDatabaseExportCancellation(exportId: string): Promise await invoke("clear_database_export_cancellation", { exportId }); } +export async function databaseExportDestinationNeedsConfirmation(directory: string): Promise { + return invoke("database_export_destination_needs_confirmation", { directory }); +} + export async function recordDatabaseExportDestination(directory: string): Promise { await invoke("record_database_export_destination", { directory }); } diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index 116a945f42..c3494f6bf5 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -2357,6 +2357,23 @@ async fn save_export_destination_identity( state.storage.save_state(&export_destination_state_key(dir), &value, "application/octet-stream").await } +/// Returns whether this macOS destination still has the transient, untagged +/// `st_dev` identity written by DBX versions before persistent volume UUIDs +/// were introduced. The caller must require an explicit directory selection +/// before replacing it; unattended backups must continue to fail closed. +pub async fn export_destination_identity_needs_confirmation( + state: &crate::connection::AppState, + dir: &std::path::Path, +) -> Result { + let recorded_identity = state + .storage + .load_state(&export_destination_state_key(dir)) + .await? + .map(|(bytes, _content_type)| ExportDestinationIdentity::decode(&bytes)) + .transpose()?; + Ok(cfg!(target_os = "macos") && matches!(recorded_identity, Some(Some(ExportDestinationIdentity::LegacyDevice(_))))) +} + /// Ensures `dir` exists for an export destination, without ever silently /// recreating a directory that previously produced a successful export (or /// was recorded via [`record_export_destination_identity`]) and has since @@ -6034,6 +6051,10 @@ mod tests { ) .await .unwrap(); + assert!( + super::export_destination_identity_needs_confirmation(&state, &destination).await.unwrap(), + "legacy macOS destination state should require explicit confirmation" + ); assert!( ensure_export_destination_dir(&state, &destination).await.is_err(), "an unattended export must not silently replace legacy identity state, even when st_dev still matches" @@ -6042,6 +6063,10 @@ mod tests { record_export_destination_identity(&state, &destination) .await .expect("explicitly confirming the destination should replace legacy state"); + assert!( + !super::export_destination_identity_needs_confirmation(&state, &destination).await.unwrap(), + "persistent volume identity should not require another confirmation" + ); ensure_export_destination_dir(&state, &destination) .await .expect("the confirmed persistent volume identity should match"); diff --git a/src-tauri/src/commands/database_export.rs b/src-tauri/src/commands/database_export.rs index c2908d1126..6ea35edc98 100644 --- a/src-tauri/src/commands/database_export.rs +++ b/src-tauri/src/commands/database_export.rs @@ -83,6 +83,17 @@ pub async fn clear_database_export_cancellation(export_id: String) -> Result<(), Ok(()) } +/// Returns whether a scheduled backup destination must be explicitly selected +/// again before DBX can replace a legacy macOS filesystem identity. +#[tauri::command] +pub async fn database_export_destination_needs_confirmation( + state: State<'_, Arc>, + directory: String, +) -> Result { + dbx_core::database_export::export_destination_identity_needs_confirmation(&state, std::path::Path::new(&directory)) + .await +} + /// Records a scheduled backup destination's filesystem identity as soon as /// the schedule is saved, not just after its first successful export. See /// `record_export_destination_identity` for why this eager recording is diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d0380100d1..211906b3cc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2462,6 +2462,7 @@ pub fn run() { commands::database_export::export_database_sql, commands::database_export::cancel_database_export, commands::database_export::clear_database_export_cancellation, + commands::database_export::database_export_destination_needs_confirmation, commands::database_export::record_database_export_destination, commands::table_export::start_table_export, commands::table_export::cancel_table_export,