Skip to content

Commit

Permalink
fulfill disconnect interface
Browse files Browse the repository at this point in the history
  • Loading branch information
turbocrime committed Jun 27, 2024
1 parent 402cd2f commit 80a738e
Show file tree
Hide file tree
Showing 24 changed files with 1,106 additions and 470 deletions.
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
26 changes: 13 additions & 13 deletions apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,19 @@
"@connectrpc/connect-web": "^1.4.0",
"@penumbra-labs/registry": "8.0.1",
"@penumbra-zone/bech32m": "^6.1.0",
"@penumbra-zone/client": "^8.0.0",
"@penumbra-zone/crypto-web": "^5.0.0",
"@penumbra-zone/getters": "^8.0.0",
"@penumbra-zone/keys": "^4.1.0",
"@penumbra-zone/perspective": "^6.0.0",
"@penumbra-zone/protobuf": "^5.1.0",
"@penumbra-zone/query": "^6.0.0",
"@penumbra-zone/services": "^6.0.0",
"@penumbra-zone/storage": "^6.0.0",
"@penumbra-zone/transport-chrome": "^4.0.0",
"@penumbra-zone/transport-dom": "^7.1.0",
"@penumbra-zone/types": "^9.0.0",
"@penumbra-zone/wasm": "^9.0.0",
"@penumbra-zone/client": "^9.0.0",
"@penumbra-zone/crypto-web": "^6.0.0",
"@penumbra-zone/getters": "^9.0.0",
"@penumbra-zone/keys": "^4.2.0",
"@penumbra-zone/perspective": "^7.0.0",
"@penumbra-zone/protobuf": "^5.2.0",
"@penumbra-zone/query": "^7.0.0",
"@penumbra-zone/services": "^7.0.0",
"@penumbra-zone/storage": "^7.0.0",
"@penumbra-zone/transport-chrome": "^5.0.0",
"@penumbra-zone/transport-dom": "^7.2.0",
"@penumbra-zone/types": "^10.0.0",
"@penumbra-zone/wasm": "^10.0.0",
"@repo/context": "workspace:*",
"@repo/ui": "workspace:*",
"@tanstack/react-query": "4.36.1",
Expand Down
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/*"],
"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);
267 changes: 191 additions & 76 deletions apps/extension/src/content-scripts/injected-penumbra-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,97 +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,
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

0 comments on commit 80a738e

Please sign in to comment.