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

Support NotLoggedIn status on connection page #787

Merged
merged 4 commits into from
Mar 20, 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/beige-hornets-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/client': major
---

isPraxInstalled -> isPraxAvailable renaming
5 changes: 5 additions & 0 deletions .changeset/hot-frogs-stare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@penumbra-zone/ui': minor
---

Added warning toast
62 changes: 27 additions & 35 deletions apps/extension/src/approve-origin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import { JsonValue } from '@bufbuild/protobuf';
import { ConnectError } from '@connectrpc/connect';
import { errorFromJson } from '@connectrpc/connect/protocol-connect';
import { localExtStorage } from '@penumbra-zone/storage';
import { OriginApproval, PopupType } from './message/popup';
import { popup } from './popup';
Expand All @@ -19,7 +16,7 @@ export const approveOrigin = async ({
origin: senderOrigin,
tab,
frameId,
}: chrome.runtime.MessageSender): Promise<boolean> => {
}: chrome.runtime.MessageSender): Promise<UserChoice> => {
if (!senderOrigin?.startsWith('https://') || !tab?.id || frameId)
throw new Error('Unsupported sender');

Expand All @@ -33,38 +30,33 @@ export const approveOrigin = async ({

if (extraRecords.length) throw new Error('Multiple records for the same origin');

switch (existingRecord?.choice) {
case UserChoice.Approved:
return true;
case UserChoice.Ignored:
return false;
case UserChoice.Denied:
default: {
const res = await popup<OriginApproval>({
type: PopupType.OriginApproval,
request: {
origin: urlOrigin,
favIconUrl: tab.favIconUrl,
title: tab.title,
lastRequest: existingRecord?.date,
},
});
const choice = existingRecord?.choice;

if ('error' in res)
throw errorFromJson(res.error as JsonValue, undefined, ConnectError.from(res));
else if (res.data != null) {
// TODO: is there a race condition here?
// if something has written after our initial read, we'll clobber them
void localExtStorage.set('knownSites', [
{
...res.data,
date: Date.now(),
},
...irrelevant,
]);
}
// Choice already made
if (choice === UserChoice.Approved || choice === UserChoice.Ignored) {
return choice;
}

return res.data?.choice === UserChoice.Approved;
}
// It's the first or repeat ask
const popupResponse = await popup<OriginApproval>({
type: PopupType.OriginApproval,
request: {
origin: urlOrigin,
favIconUrl: tab.favIconUrl,
title: tab.title,
lastRequest: existingRecord?.date,
},
});

if (popupResponse) {
void localExtStorage.set(
// user interacted with popup, update record
// TODO: is there a race condition here? if this object has been
// written after our initial read, we'll clobber them
'knownSites',
[popupResponse, ...irrelevant],
);
}

return popupResponse?.choice ?? UserChoice.Denied;
};
16 changes: 6 additions & 10 deletions apps/extension/src/approve-transaction.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { TransactionView } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/core/transaction/v1/transaction_pb';
import { AuthorizeRequest } from '@buf/penumbra-zone_penumbra.bufbuild_es/penumbra/custody/v1/custody_pb';
import { JsonValue, PartialMessage } from '@bufbuild/protobuf';
import { ConnectError } from '@connectrpc/connect';
import { errorFromJson } from '@connectrpc/connect/protocol-connect';
import { PartialMessage } from '@bufbuild/protobuf';
import type { Jsonified } from '@penumbra-zone/types/src/jsonified';
import { PopupType, TxApproval } from './message/popup';
import { popup } from './popup';
Expand All @@ -14,7 +12,7 @@ export const approveTransaction = async (
const authorizeRequest = new AuthorizeRequest(partialAuthorizeRequest);
const transactionView = new TransactionView(partialTransactionView);

const res = await popup<TxApproval>({
const popupResponse = await popup<TxApproval>({
type: PopupType.TxApproval,
request: {
authorizeRequest: new AuthorizeRequest(
Expand All @@ -24,11 +22,9 @@ export const approveTransaction = async (
},
});

if ('error' in res)
throw errorFromJson(res.error as JsonValue, undefined, ConnectError.from(res));
else if (res.data != null) {
const resAuthorizeRequest = AuthorizeRequest.fromJson(res.data.authorizeRequest);
const resTransactionView = TransactionView.fromJson(res.data.transactionView);
if (popupResponse) {
const resAuthorizeRequest = AuthorizeRequest.fromJson(popupResponse.authorizeRequest);
const resTransactionView = TransactionView.fromJson(popupResponse.transactionView);

if (
!authorizeRequest.equals(resAuthorizeRequest) ||
Expand All @@ -37,5 +33,5 @@ export const approveTransaction = async (
throw new Error('Invalid response from popup');
}

return res.data?.choice;
return popupResponse?.choice;
};
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Prax } from '../message/prax';
import { PraxConnectionPort } from './message';
import { PraxMessage } from './message-event';
import { CRSessionClient } from '@penumbra-zone/transport-chrome/session-client';
import { PraxConnection } from '../message/prax';

// this inits the session client that transports messages on the DOM channel through the Chrome runtime
const initOnce = (req: unknown, _sender: chrome.runtime.MessageSender, respond: () => void) => {
if (req !== Prax.InitConnection) return false;
if (req !== PraxConnection.Init) return false;
chrome.runtime.onMessage.removeListener(initOnce);

const port = CRSessionClient.init(PRAX);
window.postMessage({ [PRAX]: port } satisfies PraxConnectionPort, '/', [port]);
window.postMessage({ [PRAX]: port } satisfies PraxMessage<MessagePort>, '/', [port]);
respond();
return true;
};
Expand Down
82 changes: 54 additions & 28 deletions apps/extension/src/content-scripts/injected-penumbra-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,54 +17,80 @@
* other content scripts could interfere or intercept connections.
*/

import { PenumbraProvider, PenumbraSymbol } from '@penumbra-zone/client/src/global';
import {
isPraxConnectionPortMessageEvent,
isPraxRequestResponseMessageEvent,
PraxMessage,
} from './message';
import { Prax } from '../message/prax';
PenumbraProvider,
PenumbraRequestFailure,
PenumbraSymbol,
} from '@penumbra-zone/client/src/global';
import { PraxMessage, isPraxFailureMessageEvent, isPraxPortMessageEvent } from './message-event';

import '@penumbra-zone/polyfills/src/Promise.withResolvers';
import { PraxConnection } from '../message/prax';

const requestMessage: PraxMessage<Prax.RequestConnection> = { [PRAX]: Prax.RequestConnection };
const request = Promise.withResolvers();

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

// this resolves the connection promise when the isolated port script indicates
const connectionListener = (msg: MessageEvent<unknown>) => {
if (isPraxConnectionPortMessageEvent(msg) && msg.origin === window.origin) {
if (isPraxPortMessageEvent(msg) && msg.origin === window.origin) {
// @ts-expect-error - ts can't understand the injected string
connection.resolve(msg.data[PRAX] as MessagePort);
window.removeEventListener('message', connectionListener);
const praxPort: unknown = msg.data[PRAX];
if (praxPort instanceof MessagePort) connection.resolve(praxPort);
}
};
window.addEventListener('message', connectionListener);
void connection.promise.finally(() => window.removeEventListener('message', connectionListener));

const requestPromise = Promise.withResolvers();
requestPromise.promise.catch(() => connection.reject());
// 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 = msg.data[PRAX] as PraxConnection;
const failure = new Error('Connection request failed');
switch (status) {
case PraxConnection.Denied:
failure.cause = PenumbraRequestFailure.Denied;
break;
case PraxConnection.NeedsLogin:
failure.cause = PenumbraRequestFailure.NeedsLogin;
break;
default:
failure.cause = 'Unknown';
break;
}
request.reject(failure);
}
}
};

// Called to request a connection to the extension.
const postRequest = () => {
window.addEventListener('message', requestResponseHandler);
window.postMessage(requestMessage, window.origin);
return requestPromise.promise;
};

// declared outside of postRequest to prevent attaching multiple identical listeners
const requestResponseHandler = (msg: MessageEvent<unknown>) => {
if (msg.origin === window.origin && isPraxRequestResponseMessageEvent(msg)) {
// @ts-expect-error - ts can't understand the injected string
const choice = msg.data[PRAX] as Prax.ApprovedConnection | Prax.DeniedConnection;
if (choice === Prax.ApprovedConnection) requestPromise.resolve();
if (choice === Prax.DeniedConnection) requestPromise.reject();
window.removeEventListener('message', requestResponseHandler);
if (!connection.state) {
window.addEventListener('message', requestResponseListener);
window.postMessage(
{
[PRAX]: PraxConnection.Request,
} satisfies PraxMessage<PraxConnection.Request>,
window.origin,
);
request.promise
.catch(e => connection.reject(e))
.finally(() => window.removeEventListener('message', requestResponseListener));
}
return request.promise;
};

// the actual object we attach to the global record, frozen
Expand Down
23 changes: 16 additions & 7 deletions apps/extension/src/content-scripts/injected-request-listener.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import { isPraxRequestConnectionMessageEvent } from './message';
import { Prax } from '../message/prax';
import { PraxMessage, isPraxRequestMessageEvent } from './message-event';
import { PraxConnection } from '../message/prax';

const handleRequest = (ev: MessageEvent<unknown>) => {
if (isPraxRequestConnectionMessageEvent(ev) && ev.origin === window.origin)
if (ev.origin === window.origin && isPraxRequestMessageEvent(ev)) {
void (async () => {
window.removeEventListener('message', handleRequest);
const result = await chrome.runtime.sendMessage<
Prax.RequestConnection,
Prax.ApprovedConnection | Prax.DeniedConnection
>(Prax.RequestConnection);
window.postMessage({ [PRAX]: result }, '/');
PraxConnection,
Exclude<PraxConnection, PraxConnection.Request>
>(PraxConnection.Request);
// init is handled by injected-connection-port
if (result !== PraxConnection.Init)
window.postMessage(
{ [PRAX]: result } satisfies PraxMessage<
PraxConnection.Denied | PraxConnection.NeedsLogin
>,
'/',
);
})();
}
};

window.addEventListener('message', handleRequest);
Original file line number Diff line number Diff line change
@@ -1,33 +1,32 @@
import { Prax } from '../message/prax';
import { PraxConnection } from '../message/prax';

// @ts-expect-error - ts can't understand the injected string
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type PraxMessage<T = unknown> = { [PRAX]: T };

export type PraxRequestConnection = PraxMessage<Prax.RequestConnection>;
export type PraxConnectionPort = PraxMessage<MessagePort>;

export const isPraxMessageEvent = (ev: MessageEvent<unknown>): ev is MessageEvent<PraxMessage> =>
isPraxMessageEventData(ev.data);

const isPraxMessageEventData = (p: unknown): p is PraxMessage =>
typeof p === 'object' && p != null && PRAX in p;

export const isPraxRequestConnectionMessageEvent = (
export const isPraxRequestMessageEvent = (
ev: MessageEvent<unknown>,
): ev is MessageEvent<PraxRequestConnection> =>
): ev is MessageEvent<PraxMessage<PraxConnection.Request>> =>
// @ts-expect-error - ts can't understand the injected string
isPraxMessageEventData(ev.data) && ev.data[PRAX] === Prax.RequestConnection;
isPraxMessageEventData(ev.data) && ev.data[PRAX] === PraxConnection.Request;

export const isPraxRequestResponseMessageEvent = (
export const isPraxFailureMessageEvent = (
ev: MessageEvent<unknown>,
): ev is MessageEvent<Prax.ApprovedConnection | Prax.DeniedConnection> =>
isPraxMessageEventData(ev.data) &&
): ev is MessageEvent<PraxMessage<PraxConnection.Denied | PraxConnection.NeedsLogin>> => {
if (!isPraxMessageEventData(ev.data)) return false;
// @ts-expect-error - ts can't understand the injected string
(ev.data[PRAX] === Prax.ApprovedConnection || ev.data[PRAX] === Prax.DeniedConnection);
const status = ev.data[PRAX] as unknown;
return status === PraxConnection.Denied || status === PraxConnection.NeedsLogin;
};

export const isPraxConnectionPortMessageEvent = (
export const isPraxPortMessageEvent = (
ev: MessageEvent<unknown>,
): ev is MessageEvent<PraxConnectionPort> =>
): ev is MessageEvent<PraxMessage<MessagePort>> =>
// @ts-expect-error - ts can't understand the injected string
isPraxMessageEventData(ev.data) && ev.data[PRAX] instanceof MessagePort;
36 changes: 23 additions & 13 deletions apps/extension/src/listeners.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Code, ConnectError } from '@connectrpc/connect';
import { approveOrigin, originAlreadyApproved } from './approve-origin';
import { Prax, PraxResponder } from './message/prax';
import { PraxConnection } from './message/prax';
import { JsonValue } from '@bufbuild/protobuf';
import { UserChoice } from '@penumbra-zone/types/src/user-choice';

// trigger injected-connection-port to init when a known page is loaded.
chrome.tabs.onUpdated.addListener(
Expand All @@ -12,29 +14,37 @@ chrome.tabs.onUpdated.addListener(
url?.startsWith('https://') &&
(await originAlreadyApproved(url))
)
void chrome.tabs.sendMessage(tabId, Prax.InitConnection);
void chrome.tabs.sendMessage(tabId, PraxConnection.Init);
})(),
);

// listen for page connection requests.
// this is the only message we handle from an unapproved content script.
chrome.runtime.onMessage.addListener(
(
req: Prax.RequestConnection | JsonValue,
sender,
respond: PraxResponder<Prax.RequestConnection>,
) => {
if (req !== Prax.RequestConnection) return false; // instruct chrome we will not respond
(req: PraxConnection.Request | JsonValue, sender, respond: (arg: PraxConnection) => void) => {
if (req !== PraxConnection.Request) return false; // instruct chrome we will not respond

void approveOrigin(sender).then(
approval => {
status => {
// user made a choice
respond(approval ? Prax.ApprovedConnection : Prax.DeniedConnection);
if (approval) void chrome.tabs.sendMessage(sender.tab!.id!, Prax.InitConnection);
if (status === UserChoice.Approved) {
respond(PraxConnection.Init);
void chrome.tabs.sendMessage(sender.tab!.id!, PraxConnection.Init);
} else {
respond(PraxConnection.Denied);
}
},
e => {
if (process.env['NODE_ENV'] === 'development') {
console.warn('Connection request listener failed:', e);
}
if (e instanceof ConnectError && e.code === Code.Unauthenticated) {
respond(PraxConnection.NeedsLogin);
} else {
respond(PraxConnection.Denied);
}
},
() => respond(),
);

return true; // instruct chrome to wait for the response
},
);
Loading
Loading