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); + } + }); +});