fix: showSaveFilePicker from external websites

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.
This commit is contained in:
jelveh
2026-07-26 11:21:27 -07:00
parent 2262975785
commit 02ee1efe83
3 changed files with 31 additions and 16 deletions
@@ -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<unknown[]> }
| undefined;
{ getRecommendedApps?: () => Promise<unknown[]> } | 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<string, unknown>
| null
| undefined,
Record<string, unknown> | 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<string, never>;
(SignedFile & { path?: string }) | Record<string, never>;
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<string, unknown> {
if (err instanceof HttpError) {
return {
const payload: Record<string, unknown> = {
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 };
+11 -1
View File
@@ -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,
),
},
},
);
}
+6 -3
View File
@@ -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