From 54081bf77e9ac2b5afe49b800ac01490f3ee2ad3 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Tue, 30 Jun 2026 12:44:34 -0400 Subject: [PATCH 1/4] Lower default auto-save delay to 15 seconds Change the default auto-save interval from 60 seconds to 15 seconds so annotation work is persisted more frequently when auto-save is enabled. --- client/dive-common/store/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index 83dbdd47a..ea4dc8173 100644 --- a/client/dive-common/store/settings.ts +++ b/client/dive-common/store/settings.ts @@ -154,7 +154,7 @@ const defaultSettings: AnnotationSettings = { }, autoSaveSettings: { enabled: false, // Disabled by default for backward compatibility - delaySeconds: 60, + delaySeconds: 15, }, stereoSettings: { clearLengthOnCameraFileLoad: true, From c7fedd9bb067f6ba0e55a23e92bfc056e7d69e77 Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 1 Jul 2026 09:33:17 -0400 Subject: [PATCH 2/4] Apply shorter default auto-save delay only on desktop, keep 60s on web --- client/dive-common/store/settings.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index ea4dc8173..47adc672e 100644 --- a/client/dive-common/store/settings.ts +++ b/client/dive-common/store/settings.ts @@ -154,7 +154,8 @@ const defaultSettings: AnnotationSettings = { }, autoSaveSettings: { enabled: false, // Disabled by default for backward compatibility - delaySeconds: 15, + // Shorter default delay on desktop; web keeps the longer default to reduce server churn. + delaySeconds: isDesktopRuntime() ? 15 : 60, }, stereoSettings: { clearLengthOnCameraFileLoad: true, From 821d7f0923c3df8c1cb6dc7b070af89f641c0cc9 Mon Sep 17 00:00:00 2001 From: Bryon Lewis Date: Wed, 1 Jul 2026 11:40:06 -0400 Subject: [PATCH 3/4] desktop closing unsaved data guarding --- client/platform/desktop/background.ts | 27 ++++++++++++++++++- .../frontend/components/ViewerLoader.vue | 19 +++++++++++++ .../desktop/frontend/store/closeGuard.ts | 14 ++++++++++ client/platform/desktop/main.ts | 6 +++++ 4 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 client/platform/desktop/frontend/store/closeGuard.ts diff --git a/client/platform/desktop/background.ts b/client/platform/desktop/background.ts index d7ea06f1f..a6059cc7a 100644 --- a/client/platform/desktop/background.ts +++ b/client/platform/desktop/background.ts @@ -1,5 +1,5 @@ import { - app, protocol, screen, BrowserWindow, session, dialog, + app, protocol, screen, BrowserWindow, session, dialog, ipcMain, } from 'electron'; import fs from 'fs'; import os from 'os'; @@ -32,6 +32,21 @@ app.commandLine.appendSwitch('ignore-gpu-blacklist'); // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. let win: BrowserWindow | null; +let allowClose = false; +let closeGuardActive = false; + +ipcMain.on('desktop:close-guard-active', (event, active: boolean) => { + if (win && event.sender === win.webContents) { + closeGuardActive = active; + } +}); + +ipcMain.on('desktop:close-response', (event, allow: boolean) => { + if (win && event.sender === win.webContents && allow) { + allowClose = true; + win.close(); + } +}); // This application uses localStorage with persistent sessions. // In order to use this mechanism, only one application instance @@ -138,7 +153,17 @@ async function createWindow() { } } + allowClose = false; + closeGuardActive = false; + win.on('close', (e) => { + if (allowClose || !closeGuardActive) return; + e.preventDefault(); + win?.webContents.send('desktop:close-requested'); + }); + win.on('closed', () => { + allowClose = false; + closeGuardActive = false; win = null; }); } diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index a24099282..8c6912be4 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -35,6 +35,7 @@ import DatasetSourceInfo from './DatasetSourceInfo.vue'; import { datasets } from '../store/dataset'; import { settings } from '../store/settings'; import { runningJobs } from '../store/jobs'; +import { setCloseGuard } from '../store/closeGuard'; // Renderer-safe path helpers. Node's 'path' module is externalized in the // renderer (contextIsolation), so npath.* is unavailable here. @@ -69,6 +70,14 @@ export default defineComponent({ CalibrationMenu, ...context.getComponents(), }, + + // TODO: This will require an import from vue-router for Vue3 compatibility + async beforeRouteLeave(to, from, next) { + if (await this.viewerRef.navigateAwayGuard()) { + next(); + } + }, + props: { id: { // always the base ID type: String, @@ -1330,6 +1339,16 @@ export default defineComponent({ stereoCalibrationFile = undefined; } + onMounted(() => { + setCloseGuard(() => viewerRef.value.navigateAwayGuard()); + window.diveDesktop.send('desktop:close-guard-active', true); + }); + + onBeforeUnmount(() => { + setCloseGuard(null); + window.diveDesktop.send('desktop:close-guard-active', false); + }); + return { datasets, viewerRef, diff --git a/client/platform/desktop/frontend/store/closeGuard.ts b/client/platform/desktop/frontend/store/closeGuard.ts new file mode 100644 index 000000000..c12e50292 --- /dev/null +++ b/client/platform/desktop/frontend/store/closeGuard.ts @@ -0,0 +1,14 @@ +type CloseGuard = () => Promise; + +let closeGuard: CloseGuard | null = null; + +export function setCloseGuard(guard: CloseGuard | null) { + closeGuard = guard; +} + +export async function runCloseGuard(): Promise { + if (closeGuard) { + return closeGuard(); + } + return true; +} diff --git a/client/platform/desktop/main.ts b/client/platform/desktop/main.ts index 51861aafd..37963cc5a 100644 --- a/client/platform/desktop/main.ts +++ b/client/platform/desktop/main.ts @@ -6,6 +6,7 @@ import vMousetrap from 'dive-common/vue-utilities/v-mousetrap'; import vuetify from './plugins/vuetify'; import router from './router'; import { migrate } from './frontend/store'; +import { runCloseGuard } from './frontend/store/closeGuard'; import App from './App.vue'; Vue.config.productionTip = false; @@ -13,6 +14,11 @@ Vue.use(promptService(vuetify)); Vue.use(vMousetrap); migrate().then(() => { + window.diveDesktop.on('desktop:close-requested', async () => { + const allow = await runCloseGuard(); + window.diveDesktop.send('desktop:close-response', allow); + }); + new Vue({ vuetify, router, From 689fe1eb190e3bc0345c5a9fe4d97d279429574d Mon Sep 17 00:00:00 2001 From: Matt Dawkins Date: Wed, 1 Jul 2026 11:56:06 -0400 Subject: [PATCH 4/4] Add save option to desktop unsaved-changes close prompt --- client/platform/desktop/background.ts | 19 +++++++++++++++++ .../frontend/components/ViewerLoader.vue | 21 ++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/client/platform/desktop/background.ts b/client/platform/desktop/background.ts index a6059cc7a..c95e1ad59 100644 --- a/client/platform/desktop/background.ts +++ b/client/platform/desktop/background.ts @@ -48,6 +48,25 @@ ipcMain.on('desktop:close-response', (event, allow: boolean) => { } }); +// Native three-way prompt shown by the renderer close guard when the window is +// closed with unsaved changes. Returns the user's choice. +ipcMain.handle('desktop:confirm-close-unsaved', async (): Promise<'save' | 'discard' | 'cancel'> => { + if (!win) return 'discard'; + const { response } = await dialog.showMessageBox(win, { + type: 'warning', + buttons: ['Save and Exit', 'Exit Without Saving', 'Cancel'], + defaultId: 0, + cancelId: 2, + noLink: true, + title: 'Unsaved Changes', + message: 'You have unsaved changes.', + detail: 'Do you want to save your changes before exiting DIVE Desktop?', + }); + if (response === 0) return 'save'; + if (response === 1) return 'discard'; + return 'cancel'; +}); + // This application uses localStorage with persistent sessions. // In order to use this mechanism, only one application instance // can exist at a time. Acquire a lock or quit and focus the running window. diff --git a/client/platform/desktop/frontend/components/ViewerLoader.vue b/client/platform/desktop/frontend/components/ViewerLoader.vue index 8c6912be4..541947c1d 100644 --- a/client/platform/desktop/frontend/components/ViewerLoader.vue +++ b/client/platform/desktop/frontend/components/ViewerLoader.vue @@ -1339,8 +1339,27 @@ export default defineComponent({ stereoCalibrationFile = undefined; } + // Desktop window-close guard: on unsaved changes, offer a native three-way + // choice (save / discard / cancel) rather than the two-button navigate-away + // prompt. Resolves true to allow the close, false to keep the app open. + async function desktopCloseGuard(): Promise { + const count = viewerRef.value?.pendingSaveCount ?? 0; + if (!count) return true; + const choice = await window.diveDesktop.invoke('desktop:confirm-close-unsaved'); + if (choice === 'cancel') return false; + if (choice === 'save') { + try { + await viewerRef.value.save(); + } catch { + // Save failed; the Viewer surfaces its own error, so keep the app open. + return false; + } + } + return true; + } + onMounted(() => { - setCloseGuard(() => viewerRef.value.navigateAwayGuard()); + setCloseGuard(desktopCloseGuard); window.diveDesktop.send('desktop:close-guard-active', true); });