Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fulfill disconnect interface #56

Merged
merged 2 commits into from
Jun 28, 2024
Merged
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
5 changes: 5 additions & 0 deletions .changeset/slow-geckos-fold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'chrome-extension': minor
---

fulfill disconnect interface
13 changes: 6 additions & 7 deletions apps/extension/public/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,18 @@
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["injected-connection-port.js"],
"js": [
"injected-connection-port.js",
"injected-disconnect-listener.js",
"injected-request-listener.js"
],
"run_at": "document_start"
},
{
"matches": ["<all_urls>"],
"js": ["injected-penumbra-global.js"],
"run_at": "document_start",
"world": "MAIN"
},
{
"matches": ["<all_urls>"],
"js": ["injected-request-listener.js"],
"run_at": "document_start"
}
],
"options_ui": {
Expand All @@ -41,7 +40,7 @@
},
"web_accessible_resources": [
{
"resources": ["manifest.json"],
"resources": ["manifest.json", "favicon/*"],
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

expose the extension icon so dapps may use it

"matches": ["<all_urls>"]
}
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const initOnce = (
// script in the same extension using chrome.tabs.sendMessage
sender: chrome.runtime.MessageSender,
// this handler will only ever send an empty response
emptyResponse: (no?: never) => void,
respond: (no?: never) => void,
) => {
if (req !== PraxConnection.Init) {
// boolean return in handlers signals intent to respond
Expand All @@ -31,7 +31,7 @@ const initOnce = (
window.postMessage({ [PRAX]: port } satisfies PraxMessage<MessagePort>, '/', [port]);

// handler is done
emptyResponse();
respond();

// boolean return in handlers signals intent to respond
return true;
Expand Down
10 changes: 10 additions & 0 deletions apps/extension/src/content-scripts/injected-disconnect-listener.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { isPraxEndMessageEvent } from './message-event';
import { PraxConnection } from '../message/prax';

const handleDisconnect = (ev: MessageEvent<unknown>) => {
if (ev.origin === window.origin && isPraxEndMessageEvent(ev)) {
window.removeEventListener('message', handleDisconnect);
void chrome.runtime.sendMessage(PraxConnection.Disconnect);
}
};
window.addEventListener('message', handleDisconnect);
268 changes: 191 additions & 77 deletions apps/extension/src/content-scripts/injected-penumbra-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,98 +5,212 @@
*
* The global is identified by `Symbol.for('penumbra')` and consists of a record
* with string keys referring to `PenumbraInjection` objects that contain a
* simple api. The identifiers on this record should be unique and correspond to
* an id in a manifest, and providers should provide a link to the manifest in
* their record entry.
* simple API. The identifiers on this record should be unique, and correspond
* to a browser extension id. Providers should provide a link to their extension
* manifest in their record entry.
*
* The global is frozen to prevent mutation, but you should consider that the
* The global is frozen to discourage mutation, but you should consider that the
* global and everything on it is only as trustable as the scripts running on
* the page. Imports, includes, and packages your webapp depends on could all
* mutate or preempt the global. User-agent injections like userscripts or other
* content scripts could interfere or intercept connections.
* the page. Imports, requires, includes, script tags, packages your webapp
* depends on, userscripts, or other extensions' content scripts could all
* mutate or preempt this, and all have the power to interfere or intercept
* connections.
*/

import { PenumbraInjection, PenumbraRequestFailure, PenumbraSymbol } from '@penumbra-zone/client';
import { isPraxFailureMessageEvent, isPraxPortMessageEvent, PraxMessage } from './message-event';
import { PenumbraInjection, PenumbraSymbol } from '@penumbra-zone/client';

import {
isPraxFailureMessageEvent,
isPraxPortMessageEvent,
PraxMessage,
unwrapPraxMessageEvent,
} from './message-event';

import { PraxConnection } from '../message/prax';

const request = Promise.withResolvers<void>();
type PromiseSettledResultStatus = PromiseSettledResult<unknown>['status'];

// this is just withResolvers, plus a sync-queryable state attribute
const connection = Object.assign(Promise.withResolvers<MessagePort>(), { state: false });
connection.promise.then(
() => {
connection.state = true;
request.resolve();
},
() => {
connection.state = false;
request.reject();
},
);
class PraxInjection {
private static singleton?: PraxInjection = new PraxInjection();

public static get penumbra() {
return new PraxInjection().injection;
}

private manifestUrl = `${PRAX_ORIGIN}/manifest.json`;
private _request = Promise.withResolvers<void>();
private _connect = Promise.withResolvers<MessagePort>();
private _disconnect = Promise.withResolvers<void>();

private connectState?: PromiseSettledResultStatus;
private requestState?: PromiseSettledResultStatus;
private disconnectState?: PromiseSettledResultStatus;

private injection: Readonly<PenumbraInjection> = Object.freeze({
disconnect: () => this.endConnection(),
connect: () => (this.state() !== false ? this._connect.promise : this.connectionFailure),
isConnected: () => this.state(),
request: () => this.postRequest(),
manifest: String(this.manifestUrl),
});

private constructor() {
if (PraxInjection.singleton) {
return PraxInjection.singleton;
}

window.addEventListener('message', this.connectionListener);

void this._connect.promise
.then(
() => (this.connectState ??= 'fulfilled'),
() => (this.connectState ??= 'rejected'),
)
.finally(() => window.removeEventListener('message', this.connectionListener));

void this._disconnect.promise.then(
() => (this.disconnectState = 'fulfilled'),
() => (this.disconnectState = 'rejected'),
);

void this._request.promise.then(
() => (this.requestState = 'fulfilled'),
() => (this.requestState = 'rejected'),
);
}

// this resolves the connection promise when the isolated port script indicates
const connectionListener = (msg: MessageEvent<unknown>) => {
if (isPraxPortMessageEvent(msg) && msg.origin === window.origin) {
// @ts-expect-error - ts can't understand the injected string
const praxPort: unknown = msg.data[PRAX];
if (praxPort instanceof MessagePort) connection.resolve(praxPort);
/**
* Calling this function will synchronously return a unified
* true/false/undefined answer to the page connection state of this provider.
*
* `true` indicates active connection.
* `false` indicates connection is closed or rejected.
* `undefined` indicates connection may be attempted.
*/
private state(): boolean | undefined {
if (this.disconnectState !== undefined) {
return false;
}
if (this.requestState === 'rejected') {
return false;
}
switch (this.connectState) {
case 'rejected':
return false;
case 'fulfilled':
return true;
case undefined:
return undefined;
}
}
};
window.addEventListener('message', connectionListener);
void connection.promise.finally(() => window.removeEventListener('message', connectionListener));

// declared outside of postRequest to prevent attaching multiple identical listeners
const requestResponseListener = (msg: MessageEvent<unknown>) => {
if (msg.origin === window.origin) {
if (isPraxFailureMessageEvent(msg)) {
// @ts-expect-error - ts can't understand the injected string
const status: unknown = msg.data[PRAX];
const failure = new Error('Connection request failed');
failure.cause =
typeof status === 'string' && status in PenumbraRequestFailure
? status
: `Unknown failure: ${String(status)}`;
request.reject(failure);

// this listener will resolve the connection promise AND request promise when
// the isolated content script injected-connection-port sends a `MessagePort`
private connectionListener = (msg: MessageEvent<unknown>) => {
if (msg.origin === window.origin && isPraxPortMessageEvent(msg)) {
const praxPort = unwrapPraxMessageEvent(msg);
this._connect.resolve(praxPort);
this._request.resolve();
}
};

// this listener only rejects the request promise. success of the request
// promise is indicated by the connection promise being resolved.
private requestFailureListener = (msg: MessageEvent<unknown>) => {
if (msg.origin === window.origin && isPraxFailureMessageEvent(msg)) {
const cause = unwrapPraxMessageEvent(msg);
const failure = new Error('Connection request failed', { cause });
this._request.reject(failure);
}
};

// always reject with the most important reason at time of access
// 1. disconnect
// 2. connection failure
// 3. request
private get connectionFailure() {
// Promise.race checks in order of the list index. so if more than one
// promise in the list is already settled, it responds with the result of
// the earlier index
return Promise.race([
// rejects with disconnect failure, or 'Disconnected' if disconnect was successful
this._disconnect.promise.then(() => Promise.reject(Error('Disconnected'))),
// rejects with connect failure, never resolves
this._connect.promise.then(() => new Promise<never>(() => null)),
// rejects with previous failure, or 'Disconnected' if request was successful
this._request.promise.then(() => Promise.reject(Error('Disconnected'))),
// this should be unreachable
Promise.reject(Error('Unknown failure')),
]);
}
};

// Called to request a connection to the extension.
const postRequest = () => {
if (!connection.state) {
window.addEventListener('message', requestResponseListener);
private postRequest() {
const state = this.state();
if (state === true) {
// connection is already active
this._request.resolve();
} else if (state === false) {
// connection is already failed
const failure = this.connectionFailure;
failure.catch((u: unknown) => this._request.reject(u));
// a previous request may have succeeded, so return the failure directly
return failure;
} else {
// no request made yet. attach listener and emit
window.addEventListener('message', this.requestFailureListener);
void this._request.promise.finally(() =>
window.removeEventListener('message', this.requestFailureListener),
);
window.postMessage(
{
[PRAX]: PraxConnection.Request,
} satisfies PraxMessage<PraxConnection.Request>,
window.origin,
);
}

return this._request.promise;
}

private endConnection() {
// attempt actual disconnect
void this._connect.promise
.then(
port => {
port.postMessage(false);
port.close();
},
(e: unknown) => console.warn('Could not attempt disconnect', e),
)
.catch((e: unknown) => console.error('Disconnect failed', e));
window.postMessage(
{
[PRAX]: PraxConnection.Request,
} satisfies PraxMessage<PraxConnection.Request>,
window.origin,
{ [PRAX]: PraxConnection.Disconnect } satisfies PraxMessage<PraxConnection.Disconnect>,
'/',
);
request.promise
.catch((e: unknown) => connection.reject(e))
.finally(() => window.removeEventListener('message', requestResponseListener));

// resolve the promise by state
const state = this.state();
if (state === true) {
this._disconnect.resolve();
} else if (state === false) {
this._disconnect.reject(Error('Connection already inactive'));
} else {
this._disconnect.reject(Error('Connection not yet active'));
}

return this._disconnect.promise;
}
return request.promise;
};

// the actual object we attach to the global record, frozen
const praxProvider: PenumbraInjection = Object.freeze({
manifest: `${PRAX_ORIGIN}/manifest.json`,
connect: () => connection.promise,
disconnect: () => Promise.resolve(), // TODO: PR in progress
isConnected: () => connection.state,
request: () => postRequest(),
});

// if the global isn't present, create it.
if (!window[PenumbraSymbol]) {
Object.defineProperty(window, PenumbraSymbol, { value: {}, writable: false });
}

// reveal
Object.defineProperty(window[PenumbraSymbol], PRAX_ORIGIN, {
value: praxProvider,
writable: false,
enumerable: true,
});
// inject prax
Object.defineProperty(
window[PenumbraSymbol] ??
// create the global if not present
Object.defineProperty(window, PenumbraSymbol, { value: {}, writable: false })[PenumbraSymbol],
PRAX_ORIGIN,
{
value: PraxInjection.penumbra,
writable: false,
enumerable: true,
},
);
Loading
Loading