From a1f7577acb5e91036cee2320f39c3b59f409df51 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Tue, 15 Sep 2026 13:07:43 -0700 Subject: [PATCH] fix: warn before closing the tab while an upload is in flight Uploads stream from the page, so closing the tab loses a nearly-finished one with no warning. The beforeunload handler only existed behind prompt_user_when_navigation_away_from_puter, which is off by default. Install it unconditionally and have it consult window.active_uploads, the registry the progress code already maintains. The feature flag keeps its open-window behavior. Uploads now register in active_uploads before the first await in the init callback: an upload failing while the progress window was still opening used to run its delete ahead of the insert, leaving a phantom entry that would have made the tab unclosable. --- src/gui/src/UI/Dashboard/TabFiles.js | 9 ++- src/gui/src/helpers.js | 5 +- src/gui/src/helpers/confirmBeforeUnload.js | 57 +++++++++++++++++++ .../src/helpers/confirmBeforeUnload.test.js | 33 +++++++++++ src/gui/src/initgui.js | 12 ++-- 5 files changed, 103 insertions(+), 13 deletions(-) create mode 100644 src/gui/src/helpers/confirmBeforeUnload.js create mode 100644 src/gui/src/helpers/confirmBeforeUnload.test.js diff --git a/src/gui/src/UI/Dashboard/TabFiles.js b/src/gui/src/UI/Dashboard/TabFiles.js index 288614a9b..356db07e5 100644 --- a/src/gui/src/UI/Dashboard/TabFiles.js +++ b/src/gui/src/UI/Dashboard/TabFiles.js @@ -1320,6 +1320,9 @@ const TabFiles = { thumbnailGenerator: createUploadThumbnailGenerator(), init: async (operation_id, xhr) => { opid = operation_id; + // register before the first await, so a failure while the progress + // window is still opening can't delete the entry before it exists + window.active_uploads[opid] = 0; // create upload progress window upload_progress_window = await UIWindowProgress({ title: i18n('upload'), @@ -1331,8 +1334,6 @@ const TabFiles = { xhr.abort(); }, }); - // add to active_uploads - window.active_uploads[opid] = 0; }, // start start: async function () { @@ -4806,6 +4807,9 @@ const TabFiles = { thumbnailGenerator: createUploadThumbnailGenerator(), init: async (operation_id, xhr) => { opid = operation_id; + // register before the first await, so a failure while the progress + // window is still opening can't delete the entry before it exists + window.active_uploads[opid] = 0; upload_progress_window = await UIWindowProgress({ title: i18n('upload'), icon: window.icons['app-icon-uploader.svg'], @@ -4816,7 +4820,6 @@ const TabFiles = { xhr.abort(); }, }); - window.active_uploads[opid] = 0; }, start: async function () { upload_progress_window.set_status('Uploading'); diff --git a/src/gui/src/helpers.js b/src/gui/src/helpers.js index f7a09779a..43aa9d4b2 100644 --- a/src/gui/src/helpers.js +++ b/src/gui/src/helpers.js @@ -2378,6 +2378,9 @@ window.upload_items = async function (items, dest_path) { // init init: async (operation_id, xhr) => { opid = operation_id; + // register before the first await, so a failure while the progress + // window is still opening can't delete the entry before it exists + window.active_uploads[opid] = 0; // create upload progress window upload_progress_window = await UIWindowProgress({ title: i18n('upload'), @@ -2389,8 +2392,6 @@ window.upload_items = async function (items, dest_path) { xhr.abort(); }, }); - // add to active_uploads - window.active_uploads[opid] = 0; }, // start start: async function () { diff --git a/src/gui/src/helpers/confirmBeforeUnload.js b/src/gui/src/helpers/confirmBeforeUnload.js new file mode 100644 index 000000000..3e6f0de8e --- /dev/null +++ b/src/gui/src/helpers/confirmBeforeUnload.js @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Whether closing the tab right now would destroy work in progress, so the + * browser should put its "leave site?" dialog in the way first. + * + * Uploads stream from this page, so they die with it — a nearly-finished + * upload has to start over. Downloads are handed to the browser's own + * downloader and outlive the tab, so they don't count. + * + * @param {{ activeUploadCount?: number, openWindowCount?: number, promptOnOpenWindows?: boolean }} state + * @returns {boolean} + */ +export const should_confirm_before_unload = ({ + activeUploadCount = 0, + openWindowCount = 0, + promptOnOpenWindows = false, +} = {}) => { + if ( activeUploadCount > 0 ) return true; + return promptOnOpenWindows && openWindowCount > 0; +}; + +/** + * `beforeunload` handler reading the live globals. Returning a truthy value is + * what triggers the dialog; its wording is the browser's, not ours. + * + * @returns {true|undefined} + */ +export const confirm_before_unload = () => { + const confirm = should_confirm_before_unload({ + activeUploadCount: Object.keys(window.active_uploads ?? {}).length, + // Explorer windows hold nothing unsaved, so they don't warrant a prompt. + openWindowCount: $('.window:not(.window[data-app="explorer"])').length, + promptOnOpenWindows: Boolean(window.feature_flags?.prompt_user_when_navigation_away_from_puter), + }); + + return confirm ? true : undefined; +}; + +export default confirm_before_unload; diff --git a/src/gui/src/helpers/confirmBeforeUnload.test.js b/src/gui/src/helpers/confirmBeforeUnload.test.js new file mode 100644 index 000000000..869693d7c --- /dev/null +++ b/src/gui/src/helpers/confirmBeforeUnload.test.js @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { should_confirm_before_unload } from './confirmBeforeUnload.js'; + +describe('should_confirm_before_unload', () => { + it('lets a quiet page close without a dialog', () => { + expect(should_confirm_before_unload()).toBe(false); + }); + + it('warns while an upload is still streaming', () => { + expect(should_confirm_before_unload({ activeUploadCount: 1 })).toBe(true); + }); + + it('warns during an upload even with the open-window prompt off', () => { + // The bug: a nearly-finished upload used to die silently on close. + expect(should_confirm_before_unload({ + activeUploadCount: 2, + openWindowCount: 0, + promptOnOpenWindows: false, + })).toBe(true); + }); + + it('stops warning once the last upload settles', () => { + expect(should_confirm_before_unload({ activeUploadCount: 0 })).toBe(false); + }); + + it('ignores open windows unless the feature flag asks for it', () => { + expect(should_confirm_before_unload({ openWindowCount: 3 })).toBe(false); + expect(should_confirm_before_unload({ + openWindowCount: 3, + promptOnOpenWindows: true, + })).toBe(true); + }); +}); diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index 55e84779e..2c0f67439 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -41,6 +41,7 @@ import UIWindowSessionList from './UI/UIWindowSessionList.js'; import UIWindowSignup from './UI/UIWindowSignup.js'; import UIWindowRecoverPassword from './UI/UIWindowRecoverPassword.js'; import { PROCESS_RUNNING } from './definitions.js'; +import confirm_before_unload from './helpers/confirmBeforeUnload.js'; import create_access_token from './helpers/createAccessToken.js'; import create_gui_token from './helpers/createGuiToken.js'; import { @@ -2500,14 +2501,9 @@ window.initgui = async function (options) { } } - // if there is at least one window open (only non-Explorer windows), ask user for confirmation when navigating away from puter - if (window.feature_flags.prompt_user_when_navigation_away_from_puter) { - window.onbeforeunload = function () { - if ($('.window:not(.window[data-app="explorer"])').length > 0) { - return true; - } - }; - } + // ask the user to confirm before leaving while an upload is still in flight + // (and, behind the feature flag, while any non-Explorer window is open) + window.onbeforeunload = confirm_before_unload; // ------------------------------------------------------------------------------------- // `login` event handler