diff --git a/.changeset/beige-hornets-camp.md b/.changeset/beige-hornets-camp.md new file mode 100644 index 0000000000..740fe6052d --- /dev/null +++ b/.changeset/beige-hornets-camp.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/client': major +--- + +isPraxInstalled -> isPraxAvailable renaming diff --git a/.changeset/hot-frogs-stare.md b/.changeset/hot-frogs-stare.md new file mode 100644 index 0000000000..9c1fad35c1 --- /dev/null +++ b/.changeset/hot-frogs-stare.md @@ -0,0 +1,5 @@ +--- +'@penumbra-zone/ui': minor +--- + +Added warning toast diff --git a/apps/extension/src/approve-origin.ts b/apps/extension/src/approve-origin.ts index 791f2e5687..f9a986e631 100644 --- a/apps/extension/src/approve-origin.ts +++ b/apps/extension/src/approve-origin.ts @@ -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'; @@ -19,7 +16,7 @@ export const approveOrigin = async ({ origin: senderOrigin, tab, frameId, -}: chrome.runtime.MessageSender): Promise => { +}: chrome.runtime.MessageSender): Promise => { if (!senderOrigin?.startsWith('https://') || !tab?.id || frameId) throw new Error('Unsupported sender'); @@ -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({ - 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({ + 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; }; diff --git a/apps/extension/src/approve-transaction.ts b/apps/extension/src/approve-transaction.ts index 6efe33648f..d99db14fcb 100644 --- a/apps/extension/src/approve-transaction.ts +++ b/apps/extension/src/approve-transaction.ts @@ -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'; @@ -14,7 +12,7 @@ export const approveTransaction = async ( const authorizeRequest = new AuthorizeRequest(partialAuthorizeRequest); const transactionView = new TransactionView(partialTransactionView); - const res = await popup({ + const popupResponse = await popup({ type: PopupType.TxApproval, request: { authorizeRequest: new AuthorizeRequest( @@ -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) || @@ -37,5 +33,5 @@ export const approveTransaction = async ( throw new Error('Invalid response from popup'); } - return res.data?.choice; + return popupResponse?.choice; }; diff --git a/apps/extension/src/content-scripts/injected-connection-port.ts b/apps/extension/src/content-scripts/injected-connection-port.ts index b1921e6782..6e7e1e1151 100644 --- a/apps/extension/src/content-scripts/injected-connection-port.ts +++ b/apps/extension/src/content-scripts/injected-connection-port.ts @@ -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, '/', [port]); respond(); return true; }; diff --git a/apps/extension/src/content-scripts/injected-penumbra-global.ts b/apps/extension/src/content-scripts/injected-penumbra-global.ts index 00812a4e11..cc6dc05d94 100644 --- a/apps/extension/src/content-scripts/injected-penumbra-global.ts +++ b/apps/extension/src/content-scripts/injected-penumbra-global.ts @@ -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]: Prax.RequestConnection }; +const request = Promise.withResolvers(); // this is just withResolvers, plus a sync-queryable state attribute const connection = Object.assign(Promise.withResolvers(), { 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) => { - 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) => { + 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) => { - 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, + 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 diff --git a/apps/extension/src/content-scripts/injected-request-listener.ts b/apps/extension/src/content-scripts/injected-request-listener.ts index c4943559a0..0ec1a16e89 100644 --- a/apps/extension/src/content-scripts/injected-request-listener.ts +++ b/apps/extension/src/content-scripts/injected-request-listener.ts @@ -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) => { - 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.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); diff --git a/apps/extension/src/content-scripts/message.ts b/apps/extension/src/content-scripts/message-event.ts similarity index 53% rename from apps/extension/src/content-scripts/message.ts rename to apps/extension/src/content-scripts/message-event.ts index e59e77cd60..aa2573abbf 100644 --- a/apps/extension/src/content-scripts/message.ts +++ b/apps/extension/src/content-scripts/message-event.ts @@ -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 = { [PRAX]: T }; -export type PraxRequestConnection = PraxMessage; -export type PraxConnectionPort = PraxMessage; - export const isPraxMessageEvent = (ev: MessageEvent): ev is MessageEvent => 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, -): ev is MessageEvent => +): ev is MessageEvent> => // @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, -): ev is MessageEvent => - isPraxMessageEventData(ev.data) && +): ev is MessageEvent> => { + 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, -): ev is MessageEvent => +): ev is MessageEvent> => // @ts-expect-error - ts can't understand the injected string isPraxMessageEventData(ev.data) && ev.data[PRAX] instanceof MessagePort; diff --git a/apps/extension/src/listeners.ts b/apps/extension/src/listeners.ts index a8d2942aa5..01a09e8c3b 100644 --- a/apps/extension/src/listeners.ts +++ b/apps/extension/src/listeners.ts @@ -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( @@ -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, - ) => { - 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 }, ); diff --git a/apps/extension/src/message/popup.ts b/apps/extension/src/message/popup.ts index f90eaf9460..3a985dafe4 100644 --- a/apps/extension/src/message/popup.ts +++ b/apps/extension/src/message/popup.ts @@ -7,6 +7,7 @@ import type { } from '@penumbra-zone/types/src/internal-msg/shared'; import type { UserChoice } from '@penumbra-zone/types/src/user-choice'; import type { Jsonified } from '@penumbra-zone/types/src/jsonified'; +import { OriginRecord } from '@penumbra-zone/storage'; export enum PopupType { TxApproval = 'TxApproval', @@ -20,7 +21,7 @@ export type PopupResponse = InternalRespo export type OriginApproval = InternalMessage< PopupType.OriginApproval, { origin: string; favIconUrl?: string; title?: string; lastRequest?: number }, - null | { origin: string; choice: UserChoice } + null | OriginRecord >; export type TxApproval = InternalMessage< diff --git a/apps/extension/src/message/prax.ts b/apps/extension/src/message/prax.ts index 47cb1cc865..eaeeed9d65 100644 --- a/apps/extension/src/message/prax.ts +++ b/apps/extension/src/message/prax.ts @@ -1,10 +1,6 @@ -export enum Prax { - InitConnection = 'InitConnection', - RequestConnection = 'RequestConnection', - ApprovedConnection = 'ApprovedConnection', - DeniedConnection = 'DeniedConnection', +export enum PraxConnection { + Init = 'Init', + Request = 'Request', + Denied = 'Denied', + NeedsLogin = 'NeedsLogin', } - -export type PraxResponder = T extends Prax.RequestConnection - ? (r?: Prax.ApprovedConnection | Prax.DeniedConnection) => void - : never; diff --git a/apps/extension/src/popup.ts b/apps/extension/src/popup.ts index 1f9aea6483..2e7360dbdd 100644 --- a/apps/extension/src/popup.ts +++ b/apps/extension/src/popup.ts @@ -1,36 +1,31 @@ -import { sessionExtStorage } from '@penumbra-zone/storage'; -import { PopupMessage, PopupRequest, PopupResponse, PopupType } from './message/popup'; +import { PopupMessage, PopupRequest, PopupType } from './message/popup'; import { PopupPath } from './routes/popup/paths'; import type { InternalRequest, InternalResponse, } from '@penumbra-zone/types/src/internal-msg/shared'; -import { Code, ConnectError } from '@connectrpc/connect'; import { isChromeResponderDroppedError } from '@penumbra-zone/types/src/internal-msg/chrome-error'; +import { sessionExtStorage } from '@penumbra-zone/storage'; +import { Code, ConnectError } from '@connectrpc/connect'; +import { errorFromJson } from '@connectrpc/connect/protocol-connect'; +import { JsonValue } from '@bufbuild/protobuf'; export const popup = async ( req: PopupRequest, -): Promise> => { +): Promise => { await spawnPopup(req.type); // We have to wait for React to bootup, navigate to the page, and render the components await new Promise(resolve => setTimeout(resolve, 800)); - return chrome.runtime.sendMessage, InternalResponse>(req).catch(e => { - if (isChromeResponderDroppedError(e)) - return { - type: req.type, - data: null, - }; - else throw e; - }); -}; - -const spawnExtensionPopup = async (path: string) => { - try { - await throwIfAlreadyOpen(path); - await chrome.action.setPopup({ popup: path }); - await chrome.action.openPopup({}); - } finally { - void chrome.action.setPopup({ popup: 'popup.html' }); + const response = await chrome.runtime + .sendMessage, InternalResponse>(req) + .catch(e => { + if (isChromeResponderDroppedError(e)) return null; + else throw e; + }); + if (response && 'error' in response) { + throw errorFromJson(response.error as JsonValue, undefined, ConnectError.from(response)); + } else { + return response && response.data; } }; @@ -61,16 +56,17 @@ const throwIfAlreadyOpen = (path: string) => if (popupContexts.length) throw Error('Popup already open'); }); -const spawnPopup = async (pop: PopupType) => { - const popUrl = new URL(chrome.runtime.getURL('popup.html')); - - const loggedIn = Boolean(await sessionExtStorage.get('passwordKey')); - +const throwIfNeedsLogin = async () => { + const loggedIn = await sessionExtStorage.get('passwordKey'); if (!loggedIn) { - popUrl.hash = PopupPath.LOGIN; - void spawnExtensionPopup(popUrl.href); throw new ConnectError('User must login to extension', Code.Unauthenticated); } +}; + +const spawnPopup = async (pop: PopupType) => { + const popUrl = new URL(chrome.runtime.getURL('popup.html')); + + await throwIfNeedsLogin(); switch (pop) { case PopupType.OriginApproval: diff --git a/apps/extension/src/state/origin-approval.ts b/apps/extension/src/state/origin-approval.ts index be57caab4e..63e9ff69eb 100644 --- a/apps/extension/src/state/origin-approval.ts +++ b/apps/extension/src/state/origin-approval.ts @@ -61,6 +61,7 @@ export const createOriginApprovalSlice = (): SliceCreator = data: { choice, origin: requestOrigin, + date: Date.now(), }, }); } catch (e) { diff --git a/apps/minifront/src/components/extension-not-connected.tsx b/apps/minifront/src/components/extension-not-connected.tsx index bfa627356f..691709ef9d 100644 --- a/apps/minifront/src/components/extension-not-connected.tsx +++ b/apps/minifront/src/components/extension-not-connected.tsx @@ -1,44 +1,75 @@ -import { Button, SplashPage } from '@penumbra-zone/ui'; +import { Button, errorToast, SplashPage, Toaster, warningToast } from '@penumbra-zone/ui'; import { HeadTag } from './metadata/head-tag'; -import { requestPraxConnection } from '@penumbra-zone/client'; -import { useEffect, useState } from 'react'; +import { + requestPraxConnection, + throwIfPraxNotAvailable, + throwIfPraxNotInstalled, +} from '@penumbra-zone/client'; +import { useState } from 'react'; +import { PenumbraRequestFailure } from '@penumbra-zone/client/src/global'; -export const ExtensionNotConnected = () => { - const [approved, setApproved] = useState(undefined as boolean | undefined); - const request = () => - void requestPraxConnection().then( - () => setApproved(true), - () => setApproved(false), - ); +const handleErr = (e: unknown) => { + if (e instanceof Error && e.cause) { + switch (e.cause) { + case PenumbraRequestFailure.Denied: + errorToast( + 'You may need to un-ignore this site in your extension settings.', + 'Connection denied', + ).render(); + break; + case PenumbraRequestFailure.NeedsLogin: + warningToast( + 'Not logged in', + 'Please login into the extension and reload the page', + ).render(); + break; + default: + errorToast(e, 'Connection error').render(); + } + } else { + console.warn('Unknown connection failure', e); + errorToast(e, 'Unknown connection failure').render(); + } +}; + +const useExtConnector = () => { + const [result, setResult] = useState(); + + const request = async () => { + try { + throwIfPraxNotAvailable(); + await throwIfPraxNotInstalled(); + await requestPraxConnection(); + location.reload(); + } catch (e) { + handleErr(e); + } finally { + setResult(true); + } + }; - useEffect(() => { - if (approved === true) location.reload(); - else document.title = 'Penumbra Minifront'; - }, [approved]); + return { request, result }; +}; + +export const ExtensionNotConnected = () => { + const { request, result } = useExtConnector(); return ( <> +
- {approved !== false ? ( - <> -
To get started, connect the Penumbra Chrome extension.
- - +
To get started, connect the Penumbra Chrome extension.
+ {!result ? ( + ) : ( - <> -
-
Connection failed - reload to try again.
-
You may need to un-ignore this site in your extension settings.
-
- - + )}
diff --git a/apps/minifront/src/components/layout.tsx b/apps/minifront/src/components/layout.tsx index c0922f906c..d8cd820d91 100644 --- a/apps/minifront/src/components/layout.tsx +++ b/apps/minifront/src/components/layout.tsx @@ -7,7 +7,7 @@ import '@penumbra-zone/ui/styles/globals.css'; import { ExtensionNotConnected } from './extension-not-connected'; import { ExtensionNotInstalled } from './extension-not-installed'; import { Footer } from './footer'; -import { isPraxConnected, isPraxConnectedTimeout, isPraxInstalled } from '@penumbra-zone/client'; +import { isPraxConnected, isPraxConnectedTimeout, isPraxAvailable } from '@penumbra-zone/client'; export type LayoutLoaderResult = | { isInstalled: boolean; isConnected: boolean } @@ -18,7 +18,7 @@ export type LayoutLoaderResult = }; export const LayoutLoader: LoaderFunction = async (): Promise => { - const isInstalled = isPraxInstalled(); + const isInstalled = isPraxAvailable(); if (!isInstalled) return { isInstalled, isConnected: false }; const isConnected = isPraxConnected() || (await isPraxConnectedTimeout(1000)); if (!isConnected) return { isInstalled, isConnected }; diff --git a/packages/client/src/global.ts b/packages/client/src/global.ts index c15ce9b634..7840c6ba85 100644 --- a/packages/client/src/global.ts +++ b/packages/client/src/global.ts @@ -1,3 +1,8 @@ +export enum PenumbraRequestFailure { + Denied = 'Denied', + NeedsLogin = 'NeedsLogin', +} + export const PenumbraSymbol = Symbol.for('penumbra'); export interface PenumbraProvider { diff --git a/packages/client/src/prax.ts b/packages/client/src/prax.ts index 301076872e..818ec189d6 100644 --- a/packages/client/src/prax.ts +++ b/packages/client/src/prax.ts @@ -13,9 +13,11 @@ import { jsonOptions } from '@penumbra-zone/types/src/registry'; const prax_id = 'lkpmkhpnhknhmibgnmmhdhgdilepfghe' as const; const prax_origin = `chrome-extension://${prax_id}`; +const prax_manifest = `chrome-extension://${prax_id}/manifest.json`; export class PraxNotAvailableError extends Error {} export class PraxNotConnectedError extends Error {} +export class PraxNotInstalledError extends Error {} export class PraxManifestError extends Error {} export const getPraxPort = async () => { @@ -24,7 +26,12 @@ export const getPraxPort = async () => { return provider.connect(); }; -export const requestPraxConnection = async () => window[PenumbraSymbol]?.[prax_origin]?.request(); +export const requestPraxConnection = async () => { + if (window[PenumbraSymbol]?.[prax_origin]?.manifest !== prax_manifest) { + throw new PraxManifestError('Incorrect Prax manifest href'); + } + return window[PenumbraSymbol][prax_origin]?.request(); +}; export const isPraxConnected = () => Boolean(window[PenumbraSymbol]?.[prax_origin]?.isConnected()); @@ -50,10 +57,10 @@ export const throwIfPraxNotConnectedTimeout = async (timeout = 500) => { if (!isConnected) throw new PraxNotConnectedError('Prax not connected'); }; -export const isPraxInstalled = () => Boolean(window[PenumbraSymbol]?.[prax_origin]); +export const isPraxAvailable = () => Boolean(window[PenumbraSymbol]?.[prax_origin]); export const throwIfPraxNotAvailable = () => { - if (!isPraxInstalled()) throw new PraxNotAvailableError('Prax not available'); + if (!isPraxAvailable()) throw new PraxNotAvailableError('Prax not available'); }; export const throwIfPraxNotConnected = () => { @@ -61,14 +68,21 @@ export const throwIfPraxNotConnected = () => { }; export const getPraxManifest = async () => { - const manifestHref = window[PenumbraSymbol]?.[prax_origin]?.manifest; - if (manifestHref !== `${prax_origin}/manifest.json`) - throw new PraxManifestError('Incorrect Prax manifest href'); - - const response = await fetch(manifestHref); + const response = await fetch(prax_manifest); return (await response.json()) as JsonValue; }; +export const isPraxInstalled = () => + getPraxManifest().then( + () => true, + () => false, + ); + +export const throwIfPraxNotInstalled = async () => { + const isInstalled = await isPraxInstalled(); + if (!isInstalled) throw new PraxNotInstalledError('Prax not installed'); +}; + let praxTransport: Transport | undefined; export const createPraxClient = (serviceType: T) => { praxTransport ??= createChannelTransport({ diff --git a/packages/ui/lib/toast/presets.ts b/packages/ui/lib/toast/presets.ts index 5cb0106cf6..43356388be 100644 --- a/packages/ui/lib/toast/presets.ts +++ b/packages/ui/lib/toast/presets.ts @@ -16,3 +16,6 @@ export const errorToast = (error: unknown, message = 'An error occurred') => // inspect the error. .duration(Infinity) .closeButton(); + +export const warningToast = (title: string, subtitle: string) => + new Toast().warning().message(title).description(subtitle).duration(5_000);