Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
"test:watch": "vitest --config vitest.config.ts"
},
"dependencies": {
"@endo/captp": "^4.2.2",
"@endo/eventual-send": "^1.2.2",
"@endo/exo": "^1.5.2",
"@endo/lockdown": "^1.0.9",
"@endo/patterns": "^1.4.2",
"@endo/promise-kit": "^1.1.4",
"@metamask/snaps-utils": "^7.8.0",
"@metamask/utils": "^9.1.0",
Expand Down
36 changes: 23 additions & 13 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/* eslint-disable import-x/no-unassigned-import */
import type { Json } from '@metamask/utils';
import './dev-console.js';
import './endoify.mjs';
/* eslint-enable import-x/no-unassigned-import */
Expand All @@ -8,15 +9,22 @@ import { Command, makeHandledCallback } from './shared.js';

// globalThis.kernel will exist due to dev-console.js
Object.defineProperties(globalThis.kernel, {
sendMessage: {
value: sendMessage,
capTpCall: {
value: async (method: string, params: Json[]) =>
sendMessage(Command.CapTpCall, { method, params }),
},
capTpInit: {
value: async () => sendMessage(Command.CapTpInit),
},
evaluate: {
value: async (source: string) => sendMessage(Command.Evaluate, source),
},
ping: {
value: async () => sendMessage(Command.Ping),
},
sendMessage: {
value: sendMessage,
},
});

const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html';
Expand All @@ -33,7 +41,7 @@ chrome.action.onClicked.addListener(() => {
* @param data - The message data.
* @param data.name - The name to include in the message.
*/
async function sendMessage(type: string, data?: string): Promise<void> {
async function sendMessage(type: string, data?: Json): Promise<void> {
await provideOffScreenDocument();

await chrome.runtime.sendMessage({
Expand Down Expand Up @@ -68,9 +76,10 @@ chrome.runtime.onMessage.addListener(

switch (message.type) {
case Command.Evaluate:
case Command.CapTpCall:
case Command.CapTpInit:
case Command.Ping:
console.log(message.data);
await closeOffscreenDocument();
break;
default:
console.error(
Expand All @@ -81,12 +90,13 @@ chrome.runtime.onMessage.addListener(
}),
);

/**
* Close the offscreen document if it exists.
*/
async function closeOffscreenDocument(): Promise<void> {
if (!(await chrome.offscreen.hasDocument())) {
return;
}
await chrome.offscreen.closeDocument();
}
// TODO: Add method to close offscreen document?
// /**
// * Close the offscreen document if it exists.
// */
// async function closeOffscreenDocument(): Promise<void> {
// if (!(await chrome.offscreen.hasDocument())) {
// return;
// }
// await chrome.offscreen.closeDocument();
// }
120 changes: 97 additions & 23 deletions packages/extension/src/iframe-manager.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { makeCapTP } from '@endo/captp';
import { E } from '@endo/eventual-send';
import type { PromiseKit } from '@endo/promise-kit';
import { makePromiseKit } from '@endo/promise-kit';
import { createWindow } from '@metamask/snaps-utils';
import type { Json } from '@metamask/utils';
import type { MessagePortReader, MessagePortStreamPair } from '@ocap/streams';
import {
initializeMessageChannel,
makeMessagePortStreamPair,
} from '@ocap/streams';

import type { IframeMessage, WrappedIframeMessage } from './shared.js';
import { Command, isWrappedIframeMessage } from './shared.js';
import type { IframeMessage, StreamPayloadEnvelope } from './shared.js';
import { isStreamPayloadEnvelope, Command } from './shared.js';

const IFRAME_URI = 'iframe.html';

Expand All @@ -24,6 +27,11 @@ type PromiseCallbacks = Omit<PromiseKit<unknown>, 'promise'>;

type GetPort = (targetWindow: Window) => Promise<MessagePort>;

type VatRecord = {
streams: MessagePortStreamPair<StreamPayloadEnvelope>;
capTp?: ReturnType<typeof makeCapTP>;
};

/**
* A singleton class to manage and message iframes.
*/
Expand All @@ -32,7 +40,7 @@ export class IframeManager {

readonly #unresolvedMessages: Map<string, PromiseCallbacks>;

readonly #vats: Map<string, MessagePortStreamPair<WrappedIframeMessage>>;
readonly #vats: Map<string, VatRecord>;

/**
* Create a new IframeManager.
Expand All @@ -59,10 +67,10 @@ export class IframeManager {

const newWindow = await createWindow(IFRAME_URI, getHtmlId(id));
const port = await getPort(newWindow);
const streams = makeMessagePortStreamPair<WrappedIframeMessage>(port);
this.#vats.set(id, streams);
const streams = makeMessagePortStreamPair<StreamPayloadEnvelope>(port);
this.#vats.set(id, { streams });
/* v8 ignore next 4: Not known to be possible. */
this.#receiveMessages(streams.reader).catch((error) => {
this.#receiveMessages(id, streams.reader).catch((error) => {
console.error(`Unexpected read error from vat "${id}"`, error);
this.delete(id).catch(() => undefined);
});
Expand All @@ -79,12 +87,12 @@ export class IframeManager {
* @returns A promise that resolves when the iframe is deleted.
*/
async delete(id: string): Promise<void> {
const streams = this.#vats.get(id);
if (streams === undefined) {
const vat = this.#vats.get(id);
if (vat === undefined) {
return undefined;
}

const closeP = streams.return();
const closeP = vat.streams.return();
// TODO: Handle orphaned messages
this.#vats.delete(id);

Expand All @@ -110,41 +118,107 @@ export class IframeManager {
id: string,
message: IframeMessage<Command, string | null>,
): Promise<unknown> {
const streams = this.#vats.get(id);
if (streams === undefined) {
throw new Error(`No vat with id "${id}"`);
}

const vat = this.#expectGetVat(id);
const { promise, reject, resolve } = makePromiseKit();
const messageId = this.#nextId();

this.#unresolvedMessages.set(messageId, { reject, resolve });
await streams.writer.next({ id: messageId, message });
await vat.streams.writer.next({
label: 'message',
payload: { id: messageId, message },
});
return promise;
}

async callCapTp(
id: string,
method: string,
...params: Json[]
): Promise<unknown> {
const { capTp } = this.#expectGetVat(id);
if (capTp === undefined) {
throw new Error(`Vat with id "${id}" does not have a CapTP connection.`);
}
// @ts-expect-error The types are unwell.
return E(capTp.getBootstrap())[method](...params);
}

async makeCapTp(id: string): Promise<void> {
const vat = this.#expectGetVat(id);
if (vat.capTp !== undefined) {
throw new Error(`Vat with id "${id}" already has a CapTP connection.`);
}

// Handle writes here. #receiveMessages() handles reads.
const { writer } = vat.streams;
// https://github.com/endojs/endo/issues/2412
// eslint-disable-next-line @typescript-eslint/no-misused-promises
const ctp = makeCapTP(id, async (payload: unknown) => {
console.log('CapTP to vat', JSON.stringify(payload, null, 2));
await writer.next({ label: 'capTp', payload });
});

vat.capTp = ctp;
await this.sendMessage(id, { type: Command.CapTpInit, data: null });
}

async #receiveMessages(
reader: MessagePortReader<WrappedIframeMessage>,
vatId: string,
reader: MessagePortReader<StreamPayloadEnvelope>,
): Promise<void> {
for await (const rawMessage of reader) {
console.debug('Offscreen received message', rawMessage);

if (!isWrappedIframeMessage(rawMessage)) {
if (!isStreamPayloadEnvelope(rawMessage)) {
console.warn(
'Offscreen received message with unexpected format',
rawMessage,
);
return;
}

const { id, message } = rawMessage;
const promiseCallbacks = this.#unresolvedMessages.get(id);
if (promiseCallbacks === undefined) {
console.error(`No unresolved message with id "${id}".`);
continue;
switch (rawMessage.label) {
case 'capTp': {
console.log(
'CapTP from vat',
JSON.stringify(rawMessage.payload, null, 2),
);
const { capTp } = this.#expectGetVat(vatId);
if (capTp !== undefined) {
capTp.dispatch(rawMessage.payload);
}
break;
}
case 'message': {
const { id, message } = rawMessage.payload;
const promiseCallbacks = this.#unresolvedMessages.get(id);
if (promiseCallbacks === undefined) {
console.error(`No unresolved message with id "${id}".`);
} else {
promiseCallbacks.resolve(message.data);
}
break;
}
/* v8 ignore next 3: Exhaustiveness check */
default:
// @ts-expect-error Exhaustiveness check
throw new Error(`Unexpected message label "${rawMessage.label}".`);
}
}
}

promiseCallbacks.resolve(message.data);
/**
* Get a vat record by id, or throw an error if it doesn't exist.
*
* @param id - The id of the vat to get.
* @returns The vat record.
*/
#expectGetVat(id: string): VatRecord {
const vat = this.#vats.get(id);
if (vat === undefined) {
throw new Error(`No vat with id "${id}"`);
}
return vat;
}

#nextId(): string {
Expand Down
Loading