diff --git a/client/dive-common/store/settings.ts b/client/dive-common/store/settings.ts index 83dbdd47a..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: 60, + // Shorter default delay on desktop; web keeps the longer default to reduce server churn. + delaySeconds: isDesktopRuntime() ? 15 : 60, }, stereoSettings: { clearLengthOnCameraFileLoad: true, diff --git a/client/platform/desktop/background.ts b/client/platform/desktop/background.ts index d7ea06f1f..c95e1ad59 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,40 @@ 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(); + } +}); + +// 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 @@ -138,7 +172,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..541947c1d 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,35 @@ 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(desktopCloseGuard); + 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,