Skip to content

Commit

Permalink
Support NotLoggedIn status on connection page (#787)
Browse files Browse the repository at this point in the history
* Support NotLoggedIn status

* use toasts

* popup flow, error reasons, standardized penumbra failure reason

* style updates

---------

Co-authored-by: turbocrime <turbocrime@users.noreply.github.com>
  • Loading branch information
grod220 and turbocrime authored Mar 20, 2024
1 parent 19f17f9 commit 7a1efed
Show file tree
Hide file tree
Showing 18 changed files with 277 additions and 188 deletions.
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

0 comments on commit 7a1efed

Please sign in to comment.