From 0ec205eb2bcd583e6fe28dc74bd2395a944653dc Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Fri, 16 Oct 2026 20:31:19 -0400 Subject: [PATCH] fix(puter-js): handle file picker cancel message --- src/docs/src/UI/showDirectoryPicker.md | 11 ++- src/docs/src/UI/showOpenFilePicker.md | 11 ++- src/docs/src/UI/showSaveFilePicker.md | 9 +++ src/gui/src/IPC.js | 6 ++ src/gui/src/initgui.js | 33 ++++----- src/puter-js/src/modules/UI.js | 74 ++++++++++--------- .../src/modules/UI.pickerMessages.test.js | 62 ++++++++++++++++ 7 files changed, 150 insertions(+), 56 deletions(-) diff --git a/src/docs/src/UI/showDirectoryPicker.md b/src/docs/src/UI/showDirectoryPicker.md index 3585d7bec..f62daa204 100755 --- a/src/docs/src/UI/showDirectoryPicker.md +++ b/src/docs/src/UI/showDirectoryPicker.md @@ -19,7 +19,16 @@ A set of key/value pairs that configure the directory picker dialog. * `multiple` (Boolean): if set to `true`, user will be able to select multiple directories. Default is `false`. ## Return value -A `Promise` that resolves to either one [`FSItem`](/Objects/fsitem) or an array of [`FSItem`](/Objects/fsitem) objects, depending on how many directories were selected by the user. +A `Promise` that resolves to either one [`FSItem`](/Objects/fsitem) or an array of [`FSItem`](/Objects/fsitem) objects, depending on how many directories were selected by the user. If the user cancels, the promise stays pending. + +The returned promise also has an `undefinedOnCancel` property. It holds a second promise that resolves to the same result, or to `undefined` if the user cancels: + +```js +const directory = await puter.ui.showDirectoryPicker().undefinedOnCancel; +if (directory === undefined) { + // the user closed the picker without choosing a directory +} +``` ## Examples diff --git a/src/docs/src/UI/showOpenFilePicker.md b/src/docs/src/UI/showOpenFilePicker.md index f1534250e..631a10a6a 100755 --- a/src/docs/src/UI/showOpenFilePicker.md +++ b/src/docs/src/UI/showOpenFilePicker.md @@ -23,7 +23,16 @@ A set of key/value pairs that configure the file picker dialog. * `path` (String): The initial directory to open the file picker in. Default is the user's Desktop. The special prefix `%appdata%` resolves to your app's private appdata directory (for example, `%appdata%/saves` opens that subdirectory inside your appdata). ## Return value -A `Promise` that resolves to either one [`FSItem`](/Objects/fsitem) or an array of [`FSItem`](/Objects/fsitem) objects, depending on how many files were selected by the user. +A `Promise` that resolves to either one [`FSItem`](/Objects/fsitem) or an array of [`FSItem`](/Objects/fsitem) objects, depending on how many files were selected by the user. If the user cancels, the promise stays pending. + +The returned promise also has an `undefinedOnCancel` property. It holds a second promise that resolves to the same result, or to `undefined` if the user cancels: + +```js +const file = await puter.ui.showOpenFilePicker().undefinedOnCancel; +if (file === undefined) { + // the user closed the picker without choosing a file +} +``` ## Examples diff --git a/src/docs/src/UI/showSaveFilePicker.md b/src/docs/src/UI/showSaveFilePicker.md index 4129fd5a0..488070fea 100755 --- a/src/docs/src/UI/showSaveFilePicker.md +++ b/src/docs/src/UI/showSaveFilePicker.md @@ -35,6 +35,15 @@ How `content` should be interpreted. One of `'url'`, `'move'`, or `'copy'`. If o A `Promise` that resolves to an [`FSItem`](/Objects/fsitem) describing the saved file. If the user cancels, the promise stays pending. +The returned promise also has an `undefinedOnCancel` property. It holds a second promise that resolves to the same result, or to `undefined` if the user cancels: + +```js +const file = await puter.ui.showSaveFilePicker('Hello world!', 'hello.txt').undefinedOnCancel; +if (file === undefined) { + // the user closed the picker without saving +} +``` + ## Examples ```html diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index 5ef3385a8..dc88a4f28 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -665,6 +665,12 @@ const ipc_listener = async (event, handled) => { path: `/${ window.user.username }/Desktop`, // this is the uuid of the window to which this dialog will return parent_uuid: event.data.appInstanceID, + onDialogCancel: () => { + target_iframe.contentWindow.postMessage({ + msg: 'directoryOpenCancelled', + original_msg_id: msg_id, + }, '*'); + }, show_maximize_button: false, show_minimize_button: false, title: 'Open', diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index d440ffd71..73de60549 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -535,6 +535,15 @@ const postAuthActions = async (action) => { }; window.deliverPopupTokenToOpener = deliver_token_with_answer; + // The Cancel button closes the popup before on_close runs, so pickers + // report through onDialogCancel too. Escape only reaches on_close. + const tell_opener_cancelled = (msg) => { + window.opener?.postMessage({ + msg, + original_msg_id: msg_id, + }, '*'); + }; + if ( action === 'show-open-file-picker' ) { let options = window.url_query_params.get('options'); options = JSON.parse(options ?? '{}'); @@ -558,12 +567,8 @@ const postAuthActions = async (action) => { iframe_msg_uid: msg_id, center: true, initiating_app_uuid: app_uid, - on_close: function () { - window.opener.postMessage({ - msg: 'fileOpenCanceled', - original_msg_id: msg_id, - }, '*'); - }, + onDialogCancel: () => tell_opener_cancelled('fileOpenCancelled'), + on_close: () => tell_opener_cancelled('fileOpenCancelled'), }); } //-------------------------------------------------------------------------------------- @@ -588,12 +593,8 @@ const postAuthActions = async (action) => { iframe_msg_uid: msg_id, center: true, initiating_app_uuid: app_uid, - on_close: function () { - window.opener.postMessage({ - msg: 'directoryOpenCanceled', - original_msg_id: msg_id, - }, '*'); - }, + onDialogCancel: () => tell_opener_cancelled('directoryOpenCancelled'), + on_close: () => tell_opener_cancelled('directoryOpenCancelled'), }); } //-------------------------------------------------------------------------------------- @@ -632,12 +633,8 @@ const postAuthActions = async (action) => { iframe_msg_uid: msg_id, center: true, initiating_app_uuid: app_uid, - on_close: function () { - window.opener.postMessage({ - msg: 'fileSaveCanceled', - original_msg_id: msg_id, - }, '*'); - }, + onDialogCancel: () => tell_opener_cancelled('fileSaveCancelled'), + on_close: () => tell_opener_cancelled('fileSaveCancelled'), onSaveFileDialogSave: async function (target_path, el_filedialog_window) { $(el_filedialog_window).find('.window-disable-mask, .busy-indicator').show(); let busy_init_ts = Date.now(); diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 7ef0d2016..6968c2af2 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -262,8 +262,16 @@ const createDeferred = () => { return { promise, resolve, reject }; }; -const FILE_SAVE_CANCELLED = Symbol('FILE_SAVE_CANCELLED'); -const FILE_OPEN_CANCELLED = Symbol('FILE_OPEN_CANCELLED'); +const PICKER_CANCELLED = Symbol('PICKER_CANCELLED'); +// The popup pickers in older GUI builds spell these with one `l`. +const PICKER_CANCEL_MSGS = new Set([ + 'fileOpenCancelled', + 'fileOpenCanceled', + 'fileSaveCancelled', + 'fileSaveCanceled', + 'directoryOpenCancelled', + 'directoryOpenCanceled', +]); // A consent prompt covers a handful of scopes at most, and the popup carries // them in its URL. @@ -608,6 +616,19 @@ export class UIModule extends EventListener { callback(value); } + // A cancelled picker leaves its promise pending and settles only + // `undefinedOnCancel`, with `undefined`. + #registerPickerCallback (msgId, resolve, undefinedOnCancel) { + this.#callbackFunctions[msgId] = (result) => { + if ( result === PICKER_CANCELLED ) { + undefinedOnCancel.resolve(undefined); + return; + } + undefinedOnCancel.resolve(result); + resolve(result); + }; + } + #postMessageAsync (name, args = {}) { return new Promise(resolve => { this.#postMessageWithCallback(name, resolve, args); @@ -928,13 +949,8 @@ export class UIModule extends EventListener { // execute callback this.#callbackFunctions[e.data.original_msg_id](new FSItem(e.data.saved_file)); } - else if ( e.data.msg === 'fileSaveCancelled' ) { - // execute callback - this.#callbackFunctions[e.data.original_msg_id](FILE_SAVE_CANCELLED); - } - else if ( e.data.msg === 'fileOpenCancelled' ) { - // execute callback - this.#callbackFunctions[e.data.original_msg_id](FILE_OPEN_CANCELLED); + else if ( PICKER_CANCEL_MSGS.has(e.data.msg) ) { + this.#callbackFunctions[e.data.original_msg_id](PICKER_CANCELLED); } else { // execute callback @@ -1310,14 +1326,17 @@ export class UIModule extends EventListener { /** * Shows a directory picker over the user's Puter storage. Resolves to one - * `FSItem`, or an array of them when multiple selection is allowed. + * `FSItem`, or an array of them when multiple selection is allowed. The + * returned promise also carries `undefinedOnCancel`, which resolves to + * `undefined` instead of staying pending when the user cancels. * * @param {{ multiple?: boolean }} [options] * @param {(value: FSItem | FSItem[]) => void} [callback] - * @returns {Promise} + * @returns {CancelAwarePromise} */ showDirectoryPicker (options, callback) { - return new Promise((resolve, reject) => { + const undefinedOnCancel = createDeferred(); + const resolveOnlyPromise = new Promise((resolve, reject) => { if ( ! globalThis.open ) { return reject('This API is not compatible in Web Workers.'); } @@ -1348,9 +1367,10 @@ export class UIModule extends EventListener { )); } - //register callback - this.#callbackFunctions[msg_id] = resolve; + this.#registerPickerCallback(msg_id, resolve, undefinedOnCancel); }); + resolveOnlyPromise.undefinedOnCancel = undefinedOnCancel.promise; + return resolveOnlyPromise; }; /** @@ -1361,7 +1381,7 @@ export class UIModule extends EventListener { * * @param {{ multiple?: boolean, accept?: string }} [options] * @param {(value: FSItem | FSItem[]) => void} [callback] - * @returns {Promise} + * @returns {CancelAwarePromise} */ showOpenFilePicker (options, callback) { const undefinedOnCancel = createDeferred(); @@ -1392,16 +1412,7 @@ export class UIModule extends EventListener { `toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=${w}, height=${h}, top=${top}, left=${left}`, )); } - //register callback - this.#callbackFunctions[msg_id] = (maybe_result) => { - // Only resolve cancel events if this was called with `.undefinedOnCancel` - if ( maybe_result === FILE_OPEN_CANCELLED ) { - undefinedOnCancel.resolve(undefined); - return; - } - undefinedOnCancel.resolve(maybe_result); - resolve(maybe_result); - }; + this.#registerPickerCallback(msg_id, resolve, undefinedOnCancel); }); resolveOnlyPromise.undefinedOnCancel = undefinedOnCancel.promise; return resolveOnlyPromise; @@ -1550,7 +1561,7 @@ export class UIModule extends EventListener { * or the path of an existing file when 'move' or 'copy' * @param {string} [suggestedName] name to prefill in the dialog * @param {'url' | 'move' | 'copy'} [type] how `content` is read; inferred as 'url' for a URL - * @returns {Promise} + * @returns {CancelAwarePromise} */ showSaveFilePicker (content, suggestedName, type) { const undefinedOnCancel = createDeferred(); @@ -1627,16 +1638,7 @@ export class UIModule extends EventListener { // Same window, for the picker's own reply on the main listener. this.#trackPickerPopup(popup); } - //register callback - this.#callbackFunctions[msg_id] = (maybe_result) => { - // Only resolve cancel events if this was called with `.undefinedOnCancel` - if ( maybe_result === FILE_SAVE_CANCELLED ) { - undefinedOnCancel.resolve(undefined); - return; - } - undefinedOnCancel.resolve(maybe_result); - resolve(maybe_result); - }; + this.#registerPickerCallback(msg_id, resolve, undefinedOnCancel); }); resolveOnlyPromise.undefinedOnCancel = undefinedOnCancel.promise; diff --git a/src/puter-js/src/modules/UI.pickerMessages.test.js b/src/puter-js/src/modules/UI.pickerMessages.test.js index 21b1ba8b7..06ddedcbd 100644 --- a/src/puter-js/src/modules/UI.pickerMessages.test.js +++ b/src/puter-js/src/modules/UI.pickerMessages.test.js @@ -196,3 +196,65 @@ describe('host messages inside the desktop (env: app)', () => { await expect(settled(promise)).resolves.toBe(PENDING); }); }); + +// GUI popups and the desktop host have spelled the cancel replies both ways. +describe('picker cancel', () => { + const PICKERS = [ + { + name: 'showOpenFilePicker', + open: (ui) => ui.showOpenFilePicker(), + popup: 'Puter: Open File', + msgs: ['fileOpenCancelled', 'fileOpenCanceled'], + }, + { + name: 'showDirectoryPicker', + open: (ui) => ui.showDirectoryPicker(), + popup: 'Puter: Open Directory', + msgs: ['directoryOpenCancelled', 'directoryOpenCanceled'], + }, + { + name: 'showSaveFilePicker', + open: (ui) => ui.showSaveFilePicker('hello', 'a.txt'), + popup: 'Puter: Save File', + msgs: ['fileSaveCancelled', 'fileSaveCanceled'], + }, + ]; + const CASES = PICKERS.flatMap((picker) => + picker.msgs.map((msg) => [picker.name, msg, picker])); + + it.each(CASES)('%s on a third-party site handles %s', async (_name, msg, picker) => { + const ui = makeUI('web'); + const promise = picker.open(ui); + const popup = openedPopups.get(picker.popup); + + // The popup reads msg_id from its URL, so it echoes a string. + await deliver({ source: popup, origin: GUI_ORIGIN, data: { msg, original_msg_id: '1' } }); + + await expect(promise.undefinedOnCancel).resolves.toBeUndefined(); + await expect(settled(promise)).resolves.toBe(PENDING); + }); + + it.each(PICKERS.map((picker) => [picker.name, picker]))('%s inside the desktop handles the host cancel', async (_name, picker) => { + const ui = makeUI('app'); + const promise = picker.open(ui); + + await deliver({ + source: parentWindow, + origin: GUI_ORIGIN, + data: { msg: picker.msgs[0], original_msg_id: 1 }, + }); + + await expect(promise.undefinedOnCancel).resolves.toBeUndefined(); + await expect(settled(promise)).resolves.toBe(PENDING); + }); + + it('resolves undefinedOnCancel with the picked item', async () => { + const ui = makeUI('web'); + const promise = ui.showOpenFilePicker(); + + await deliver({ source: null, origin: GUI_ORIGIN, data: filePicked(1) }); + + await expect(promise).resolves.toMatchObject({ name: 'a.txt' }); + await expect(promise.undefinedOnCancel).resolves.toMatchObject({ name: 'a.txt' }); + }); +});