From 1d6d0da950687231a08690887dcb281d0302422e Mon Sep 17 00:00:00 2001 From: Toshit Chawda <45221816+r58Playz@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:27:52 +0530 Subject: [PATCH] feat(puter.js): transferables in AppConnection.postMessage (#3776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `postMessage(message, transfer)` (or `{ transfer }`) moves ArrayBuffers, MessagePorts, streams and the rest to the target app instead of copying them. The second argument is optional, so existing callers are unchanged. The transfer list rides in the message body as well as the real transfer list: structured clone's memory map keeps those objects identical to the ones inside `contents`, which is how the desktop picks them out mid-relay and keeps transferring them onward rather than leaving copies behind. Both `messageToApp` paths carry it — the direct iframe relay and the connection path that `launchApp()` between apps actually uses. The desktop forwards the list as-is and lets postMessage judge it. A list that arrived through the SDK was already validated by the browser on the first hop, and validating again here would mean an allowlist of transferable types that silently downgrades an unrecognised one to a copy. A bad list only reaches us from an app that hand-wrote the envelope; that throws without detaching anything, and is caught so it cannot escape `ipc_listener` as an unhandled rejection. Co-authored-by: Claude Opus 5 (1M context) --- src/docs/src/Objects/AppConnection.md | 68 +++++++++- src/gui/src/IPC.js | 41 +++--- src/gui/src/definitions.js | 5 +- src/gui/src/services/IPCService.js | 4 +- src/puter-js/src/modules/UI.js | 42 ++++++- .../tests/e2e/fixtures/app-connection.html | 117 ++++++++++++++++++ .../tests/e2e/specs/appConnection.spec.js | 37 ++++++ 7 files changed, 290 insertions(+), 24 deletions(-) create mode 100644 src/puter-js/tests/e2e/fixtures/app-connection.html create mode 100644 src/puter-js/tests/e2e/specs/appConnection.spec.js diff --git a/src/docs/src/Objects/AppConnection.md b/src/docs/src/Objects/AppConnection.md index 2c0b0c58c..bb8acb41a 100755 --- a/src/docs/src/Objects/AppConnection.md +++ b/src/docs/src/Objects/AppConnection.md @@ -21,10 +21,12 @@ Listen to an event from the target app. Possible events are: #### `off(eventName, handler)` Remove an event listener added with `on(eventName, handler)`. -#### `postMessage(message)` +#### `postMessage(message, transfer)` Send a message to the target app. Think of it as a more limited version of [`window.postMessage()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage). `message` can be anything that [`window.postMessage()`](https://developer.mozilla.org/en-US/docs/Web/API/Window/postMessage) would accept for its `message` parameter. -If the target app is not using the SDK, or the connection is not open, then nothing will happen. +`transfer` is optional, and is an array of [transferable objects](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects) — an `ArrayBuffer`, `MessagePort`, `ReadableStream`, `ImageBitmap`, and so on. Each one is *moved* to the target app instead of being copied, and becomes unusable in the sending app. Every object you list must also appear somewhere inside `message`, because that is where the target app reads it from. An options object, `postMessage(message, { transfer })`, is accepted as well. + +If the target app is not using the SDK, or the connection is not open, then nothing will happen — and nothing is transferred. #### `close()` Attempt to close the target app. If you do not have permission to close it, or the target app is already closed, then nothing will happen. @@ -156,3 +158,65 @@ In this example, a parent app (with the name `traffic-light`) launches three chi ``` +### Transferring data instead of copying it + +Large binary data — a decoded image, an audio buffer, a file you just read — is copied every time it crosses `postMessage()`. Listing it in `transfer` hands the memory over instead: nothing is copied, and the buffer is gone from the sending app afterwards. + +This example sends a one-megabyte buffer to a child app, which sends it straight back after filling in a byte. + +```html + + + Parent app + + + + + + + + + + + + Child app + + + + + + +``` + +A `MessagePort` can be transferred the same way, which gives the two apps a direct channel that no longer goes through `AppConnection`: + +```js +const channel = new MessageChannel(); +channel.port1.onmessage = e => console.log('from the child:', e.data); +child.postMessage({ port: channel.port2 }, [channel.port2]); +``` diff --git a/src/gui/src/IPC.js b/src/gui/src/IPC.js index 1f96d01d5..cb132c921 100644 --- a/src/gui/src/IPC.js +++ b/src/gui/src/IPC.js @@ -2050,30 +2050,41 @@ const ipc_listener = async (event, handled) => { // messageToApp //-------------------------------------------------------- else if ( event.data.msg === 'messageToApp' ) { - const { appInstanceID, targetAppInstanceID, targetAppOrigin, contents } = event.data; + const { appInstanceID, targetAppInstanceID, targetAppOrigin, contents, transfer } = event.data; // TODO: Determine if we should allow the message // TODO: Track message traffic between apps const svc_ipc = globalThis.services.get('ipc'); // const svc_exec = globalThis.services() - const conn = svc_ipc.get_connection(targetAppInstanceID); - if ( conn ) { - conn.send(contents); - return; - } - // pass on the message - const target_iframe = window.iframe_for_app_instance(targetAppInstanceID); - if ( ! target_iframe ) { + const conn = svc_ipc.get_connection(targetAppInstanceID); + const target_iframe = conn ? null : window.iframe_for_app_instance(targetAppInstanceID); + if ( ! conn && ! target_iframe ) { console.error('Failed to send message to non-existent app', event); return; } - target_iframe.contentWindow.postMessage({ - msg: 'messageToApp', - appInstanceID, - targetAppInstanceID, - contents, - }, targetAppOrigin); + + // The sender transferred these to us, so they have to keep moving: the + // target app wants the objects themselves, not copies stranded here. + // `transfer` is whatever the sending app put in the message and + // postMessage is the authority on what may be transferred, so a bad + // list throws (detaching nothing) — and `ipc_listener` has no outer + // catch to stop that escaping. + try { + if ( conn ) { + conn.send(contents, { transfer }); + } else { + target_iframe.contentWindow.postMessage({ + msg: 'messageToApp', + appInstanceID, + targetAppInstanceID, + contents, + transfer, + }, targetAppOrigin, transfer); + } + } catch ( e ) { + console.error('Failed to relay message between apps', e); + } } //-------------------------------------------------------- // closeApp diff --git a/src/gui/src/definitions.js b/src/gui/src/definitions.js index 207994ae9..f24ce0d7a 100644 --- a/src/gui/src/definitions.js +++ b/src/gui/src/definitions.js @@ -162,15 +162,16 @@ export class PortalProcess extends Process { } } - send (channel, data, context) { + send (channel, data, { transfer = [] } = {}) { const target = this.references.iframe.contentWindow; target.postMessage({ msg: 'messageToApp', appInstanceID: channel.returnAddress, targetAppInstanceID: this.uuid, contents: data, + transfer, // }, new URL(this.references.iframe.src).origin); - }, '*'); + }, '*', transfer); } async handle_connection (connection, args) { diff --git a/src/gui/src/services/IPCService.js b/src/gui/src/services/IPCService.js index 5ff07e40f..491724537 100644 --- a/src/gui/src/services/IPCService.js +++ b/src/gui/src/services/IPCService.js @@ -28,13 +28,13 @@ class InternalConnection { this.reverse = reverse; } - send (data) { + send (data, options) { const svc_process = this.services.get('process'); const process = svc_process.get_by_uuid(this.target); const channel = { returnAddress: this.reverse, }; - process.send(channel, data); + process.send(channel, data, options); } } diff --git a/src/puter-js/src/modules/UI.js b/src/puter-js/src/modules/UI.js index 5a399c1fc..f7085c169 100644 --- a/src/puter-js/src/modules/UI.js +++ b/src/puter-js/src/modules/UI.js @@ -286,7 +286,7 @@ const PERMISSION_CHECK_TIMEOUT_MS = 2000; * An interface for interacting with another app. Returned by the UI methods * that launch or connect to one; it cannot be constructed directly. * - * - `postMessage(message)` sends a message to the target app. + * - `postMessage(message, transfer)` sends a message to the target app. * - `on('message', handler)` listens for messages from it. * - `on('close', handler)` fires when it closes. * @@ -388,15 +388,48 @@ export class AppConnection extends EventListener { return this.#usesSDK; } + /** + * @overload + * @param {unknown} message + * @returns {void} + */ + /** + * @overload + * @param {unknown} message + * @param {Transferable[]} transfer + * @returns {void} + */ + /** + * @overload + * @param {unknown} message + * @param {{ transfer?: Transferable[] }} options + * @returns {void} + */ /** * Sends a message to the target app. Does nothing — beyond a console * warning — if the target isn't using the SDK, or the connection has * already closed. * + * Objects listed in `transfer` move to the target app instead of being + * copied, and become unusable here. Each one must also appear somewhere + * inside `message`, which is where the target app reads it from. + * * @param {unknown} message + * @param {Transferable[] | { transfer?: Transferable[] }} [transferOrOptions] * @returns {void} */ - postMessage (message) { + postMessage (message, transferOrOptions) { + const transfer = Array.isArray(transferOrOptions) + ? transferOrOptions + : (transferOrOptions?.transfer ?? []); + + if ( ! Array.isArray(transfer) ) { + throw { + message: 'transfer must be an array of transferable objects', + code: 'invalid_transfer_list', + }; + } + if ( ! this.#isOpen ) { console.warn('Trying to post message on a closed AppConnection'); return; @@ -416,7 +449,10 @@ export class AppConnection extends EventListener { // on the other side where the expected origin for the app is known. targetAppOrigin: '*', contents: message, - }, this.#puterOrigin); + // In the body as well as the transfer list: Puter relays this + // message onward, and needs to know what to keep transferring. + transfer, + }, this.#puterOrigin, transfer); } /** diff --git a/src/puter-js/tests/e2e/fixtures/app-connection.html b/src/puter-js/tests/e2e/fixtures/app-connection.html new file mode 100644 index 000000000..43842e8d3 --- /dev/null +++ b/src/puter-js/tests/e2e/fixtures/app-connection.html @@ -0,0 +1,117 @@ + + + + + puter-js test fixture: AppConnection + + + +

puter-js AppConnection fixture

+

Loading puter.js…

+ + + +
+ + + + + + diff --git a/src/puter-js/tests/e2e/specs/appConnection.spec.js b/src/puter-js/tests/e2e/specs/appConnection.spec.js new file mode 100644 index 000000000..eb297bf20 --- /dev/null +++ b/src/puter-js/tests/e2e/specs/appConnection.spec.js @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; +import { registerTestApp, deleteTestApp, gotoTestApp, FIXTURE_URL } from '../helpers/testApp.js'; + +const APP_CONNECTION_FIXTURE_URL = FIXTURE_URL.replace( + 'menubar-contextmenu.html', + 'app-connection.html', +); + +test.describe('AppConnection.postMessage transferables (env=app)', () => { + test('a transferred buffer reaches the other app and back', async ({ page }) => { + const appName = await registerTestApp(page, { fixtureURL: APP_CONNECTION_FIXTURE_URL }); + try { + await gotoTestApp(page, appName); + // The parent's window is the first one open; the child it launches + // renders the same fixture in a second iframe. + const parentFrame = page.frameLocator('iframe.window-app-iframe').first(); + + await parentFrame.locator('#send-transferable').click(); + + const log = parentFrame.locator('#log'); + // The desktop relays between the two apps, so a buffer that was + // transferred rather than copied only survives if every hop keeps + // transferring it. + await expect(log.locator('[data-entry="child-received:ArrayBuffer[1,2,3,4]"]')) + .toBeVisible({ timeout: 30_000 }); + await expect(log.locator('[data-entry="parent-received:ArrayBuffer[9,8,7,6]"]')) + .toBeVisible(); + // Transferred, not copied: the sender no longer holds the buffer. + await expect(log.locator('[data-entry="sent:detached:true"]')).toBeVisible(); + // A port survives the relay as a working channel, not a dead copy. + await expect(log.locator('[data-entry="port-message:hello over the port"]')) + .toBeVisible(); + } finally { + await deleteTestApp(page, appName); + } + }); +});