From 02ee1efe8360a903cc10a4e8674a4aeb7a8614d7 Mon Sep 17 00:00:00 2001 From: jelveh Date: Sun, 26 Jul 2026 11:21:27 -0700 Subject: [PATCH] fix: showSaveFilePicker from external websites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Saving a file via puter.ui.showSaveFilePicker from a third-party website (popup flow) consistently failed with a DataCloneError alert after clicking Save, and saving over an existing filename showed a raw error instead of the Replace/Cancel prompt. Two bugs: 1. privacy_aware_path is a curried factory (world => fspath => ...), and initgui.js is the only module that imports it directly — so the popup save handler's privacy_aware_path(res.path) returned the inner function, which postMessage cannot structured-clone. Every other call site resolves the bare name to the correctly bound window.privacy_aware_path global, which is why only the external-site popup flow was broken. Use the global at the call site and import the factory under a distinct name so a bare call can't silently resolve to it again. 2. The v2 backend returns `conflict` for a same-name write, but the v1 wire contract is `item_with_same_name_exists` + `entry_name`, which the GUI's save dialogs key on to offer the overwrite prompt. Restore the legacy code/field on the write-conflict error and carry HttpError.fields through the /batch per-op error serializer. Verified end-to-end against a local backend: fresh save resolves the caller's promise with the signed saved_file and closes the popup; saving an existing name shows Replace/Cancel and Replace overwrites. Backend suite shows no new failures. --- .../controllers/fs/LegacyFSController.ts | 26 ++++++++++--------- src/backend/services/fs/FSService.ts | 12 ++++++++- src/gui/src/initgui.js | 9 ++++--- 3 files changed, 31 insertions(+), 16 deletions(-) diff --git a/src/backend/controllers/fs/LegacyFSController.ts b/src/backend/controllers/fs/LegacyFSController.ts index 406241fb1..1b3c17ce3 100644 --- a/src/backend/controllers/fs/LegacyFSController.ts +++ b/src/backend/controllers/fs/LegacyFSController.ts @@ -167,8 +167,7 @@ export class LegacyFSController extends PuterController { router.get('/get-launch-apps', apiOptions, async (req, res) => { const recommendedSvc = this.services.recommendedApps as unknown as - | { getRecommendedApps?: () => Promise } - | undefined; + { getRecommendedApps?: () => Promise } | undefined; const recommended = recommendedSvc?.getRecommendedApps ? await recommendedSvc.getRecommendedApps() : []; @@ -640,9 +639,7 @@ export class LegacyFSController extends PuterController { // Trash, and `null`/`{}` when restoring. See // `src/gui/src/helpers.js` → `window.move_items`. newMetadata: (body.new_metadata ?? undefined) as - | Record - | null - | undefined, + Record | null | undefined, }); const oldPath = source.path; await this.#emitGuiEvent('outer.gui.item.moved', moved, { @@ -1071,8 +1068,7 @@ export class LegacyFSController extends PuterController { } type SignedOrEmpty = - | (SignedFile & { path?: string }) - | Record; + (SignedFile & { path?: string }) | Record; const result: { signatures: SignedOrEmpty[]; token?: string } = { signatures: [], }; @@ -1612,10 +1608,7 @@ export class LegacyFSController extends PuterController { const subjectRef = body.subject; const appRef = body.app; const mode = (getString(body, 'mode') ?? 'read') as - | 'see' - | 'list' - | 'read' - | 'write'; + 'see' | 'list' | 'read' | 'write'; if (!subjectRef || !appRef) throw new HttpError(400, '`subject` and `app` are required', { legacyCode: 'bad_request', @@ -2271,12 +2264,21 @@ export class LegacyFSController extends PuterController { #serializeBatchError(err: unknown): Record { if (err instanceof HttpError) { - return { + const payload: Record = { error: true, status: err.statusCode, message: err.message, code: err.legacyCode ?? err.code, }; + // Same as the terminal errorHandler: extra fields (e.g. + // `entry_name` on item_with_same_name_exists) ride along + // without clobbering the canonical slots. + if (err.fields) { + for (const [k, v] of Object.entries(err.fields)) { + if (!(k in payload)) payload[k] = v; + } + } + return payload; } if (err instanceof Error) { return { error: true, status: 500, message: err.message }; diff --git a/src/backend/services/fs/FSService.ts b/src/backend/services/fs/FSService.ts index e6de631f6..449159582 100644 --- a/src/backend/services/fs/FSService.ts +++ b/src/backend/services/fs/FSService.ts @@ -558,10 +558,20 @@ export class FSService extends PuterService { ); } if (existingEntry && !normalizedInput.overwrite) { + // v1 wire contract: clients (the GUI's save dialogs among + // them) key on `item_with_same_name_exists` + `entry_name` + // to offer an overwrite prompt. throw new HttpError( 409, 'A file already exists at this path and overwrite was not requested', - { legacyCode: 'conflict' }, + { + legacyCode: 'item_with_same_name_exists', + fields: { + entry_name: pathPosix.basename( + normalizedInput.path, + ), + }, + }, ); } diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index b4063947b..fd6ade0f3 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -54,7 +54,10 @@ import { LaunchOnInitService } from './services/LaunchOnInitService.js'; import { LocaleService } from './services/LocaleService.js'; import { ProcessService } from './services/ProcessService.js'; import { ThemeService } from './services/ThemeService.js'; -import { privacy_aware_path } from './util/desktop.js'; +// Curried: takes `{ window }` and returns the path mapper. Import under a +// factory name so a bare `privacy_aware_path(path)` call in this module can't +// silently resolve to the factory — use `window.privacy_aware_path` instead. +import { privacy_aware_path as privacy_aware_path_factory } from './util/desktop.js'; const postAuthActions = async (action) => { // ------------------------------------------------------------------------------------- @@ -378,7 +381,7 @@ const postAuthActions = async (action) => { metadataURL: file_signature.metadata_url, type: file_signature.type, uid: file_signature.uid, - path: privacy_aware_path(res.path), + path: window.privacy_aware_path(res.path), }, }, '*'); @@ -2076,7 +2079,7 @@ $(document).on('contextmenu', '.disable-context-menu', function (e) { }); // util/desktop.js -window.privacy_aware_path = privacy_aware_path({ window }); +window.privacy_aware_path = privacy_aware_path_factory({ window }); $(window).on('system-logout-event', function () { // Clear cookie