diff options
26 files changed, 660 insertions, 835 deletions
diff --git a/packages/demobank-ui/src/Routing.tsx b/packages/demobank-ui/src/Routing.tsx index e73493d60..442a276a0 100644 --- a/packages/demobank-ui/src/Routing.tsx +++ b/packages/demobank-ui/src/Routing.tsx @@ -48,6 +48,8 @@ import { RemoveAccount } from "./pages/admin/RemoveAccount.js"; import { CreateCashout } from "./pages/business/CreateCashout.js"; import { ShowCashoutDetails } from "./pages/business/ShowCashoutDetails.js"; import { RouteParamsType, urlPattern, useCurrentLocation } from "./route.js"; +import { useNavigationContext } from "./context/navigation.js"; +import { useEffect } from "preact/hooks"; export function Routing(): VNode { const backend = useBackendState(); @@ -89,12 +91,18 @@ function PublicRounting({ }): VNode { const settings = useSettingsContext(); const { i18n } = useTranslationContext(); - const [loc, routeTo] = useCurrentLocation(publicPages); + const location = useCurrentLocation(publicPages); + const { navigateTo } = useNavigationContext() const { api } = useBankCoreApiContext(); const [notification, notify, handleError] = useLocalNotification(); - if (loc === undefined) { - routeTo("login", {}); + useEffect(() => { + if (location === undefined) { + navigateTo(privatePages.home.url({})) + } + }, [location]) + + if (location === undefined) { return <Fragment />; } @@ -132,7 +140,7 @@ function PublicRounting({ }); } - switch (loc.name) { + switch (location.name) { case "login": { return ( <Fragment> @@ -148,17 +156,17 @@ function PublicRounting({ return <PublicHistoriesPage />; } case "operationDetails": { - const { wopid } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { wopid } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <WithdrawalOperationPage operationId={wopid} - onOperationAborted={() => routeTo("login", {})} + onOperationAborted={() => navigateTo(publicPages.login.url({}))} routeClose={publicPages.login} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(publicPages.solveSecondFactor.url({}))} /> ); } @@ -176,18 +184,17 @@ function PublicRounting({ case "solveSecondFactor": { return ( <SolveChallengePage - onChallengeCompleted={() => routeTo("login", {})} + onChallengeCompleted={() => navigateTo(publicPages.login.url({}))} routeClose={publicPages.login} /> ); } default: - assertUnreachable(loc.name); + assertUnreachable(location.name); } } export const privatePages = { - home: urlPattern(/\/account/, () => "#/account"), homeChargeWallet: urlPattern( /\/account\/charge-wallet/, () => "#/account/charge-wallet", @@ -196,6 +203,7 @@ export const privatePages = { /\/account\/wire-transfer/, () => "#/account/wire-transfer", ), + home: urlPattern(/\/account/, () => "#/account"), solveSecondFactor: urlPattern(/\/2fa/, () => "#/2fa"), cashoutCreate: urlPattern(/\/new-cashout/, () => "#/new-cashout"), cashoutDetails: urlPattern<{ cid: string }>( @@ -233,7 +241,7 @@ export const privatePages = { ({ account }) => `#/profile/${account}/cashouts`, ), operationDetails: urlPattern<{ wopid: string }>( - /\/operation\/(?<wopid>[a-zA-Z0-9]+)/, + /\/operation\/(?<wopid>[a-zA-Z0-9-]+)/, ({ wopid }) => `#/operation/${wopid}`, ), }; @@ -245,33 +253,38 @@ function PrivateRouting({ username: string; isAdmin: boolean; }): VNode { - const [loc, routeTo] = useCurrentLocation(privatePages); + const { navigateTo } = useNavigationContext() + const location = useCurrentLocation(privatePages); + useEffect(() => { + if (location === undefined) { + navigateTo(privatePages.home.url({})) + } + }, [location]) - if (loc === undefined) { - routeTo("home", {}); + if (location === undefined) { return <Fragment />; } - switch (loc.name) { + switch (location.name) { case "operationDetails": { - const { wopid } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { wopid } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <WithdrawalOperationPage operationId={wopid} - onOperationAborted={() => routeTo("home", {})} + onOperationAborted={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} /> ); } case "solveSecondFactor": { return ( <SolveChallengePage - onChallengeCompleted={() => routeTo("home", {})} + onChallengeCompleted={() => navigateTo(privatePages.home.url({}))} routeClose={privatePages.home} /> ); @@ -286,64 +299,64 @@ function PrivateRouting({ return ( <CreateNewAccount routeCancel={privatePages.home} - onCreateSuccess={() => routeTo("home", {})} + onCreateSuccess={() => navigateTo(privatePages.home.url({}))} /> ); } case "accountDetails": { - const { account } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { account } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <ShowAccountDetails account={account} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); } case "accountChangePassword": { - const { account } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { account } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <UpdateAccountPassword focus account={account} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); } case "accountDelete": { - const { account } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { account } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <RemoveAccount account={account} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeCancel={privatePages.home} /> ); } case "accountCashouts": { - const { account } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { account } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <CashoutListForAccount account={account} routeCashoutDetails={privatePages.cashoutDetails} routeClose={privatePages.home} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} /> ); } @@ -351,8 +364,8 @@ function PrivateRouting({ return ( <RemoveAccount account={username} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeCancel={privatePages.home} /> ); @@ -361,8 +374,8 @@ function PrivateRouting({ return ( <ShowAccountDetails account={username} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); @@ -372,8 +385,8 @@ function PrivateRouting({ <UpdateAccountPassword focus account={username} - onUpdateSuccess={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onUpdateSuccess={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); @@ -383,7 +396,7 @@ function PrivateRouting({ <CashoutListForAccount account={username} routeCashoutDetails={privatePages.cashoutDetails} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); @@ -392,7 +405,7 @@ function PrivateRouting({ if (isAdmin) { return ( <AdminHome - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeCreate={privatePages.accountCreate} routeRemoveAccount={privatePages.accountDelete} routeShowAccount={privatePages.accountDetails} @@ -408,9 +421,9 @@ function PrivateRouting({ routeChargeWallet={privatePages.homeChargeWallet} routeWireTransfer={privatePages.homeWireTransfer} routeClose={privatePages.home} - onClose={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} - onOperationCreated={(wopid) => routeTo("operationDetails", { wopid })} + onClose={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} + onOperationCreated={(wopid) => navigateTo(privatePages.operationDetails.url({ wopid }))} /> ); } @@ -418,15 +431,15 @@ function PrivateRouting({ return ( <CreateCashout account={username} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeClose={privatePages.home} /> ); } case "cashoutDetails": { - const { cid } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { cid } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <ShowCashoutDetails @@ -436,16 +449,16 @@ function PrivateRouting({ ); } case "wireTranserCreate": { - const { destination } = loc.values as RouteParamsType< - typeof loc.parent, - typeof loc.name + const { destination } = location.values as RouteParamsType< + typeof location.parent, + typeof location.name >; return ( <WireTransfer toAccount={destination} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} routeCancel={privatePages.home} - onSuccess={() => routeTo("home", {})} + onSuccess={() => navigateTo(privatePages.home.url({}))} /> ); } @@ -457,9 +470,9 @@ function PrivateRouting({ routeChargeWallet={privatePages.homeChargeWallet} routeWireTransfer={privatePages.homeWireTransfer} routeClose={privatePages.home} - onClose={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} - onOperationCreated={(wopid) => routeTo("operationDetails", { wopid })} + onClose={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} + onOperationCreated={(wopid) => navigateTo(privatePages.operationDetails.url({ wopid }))} /> ); } @@ -471,13 +484,13 @@ function PrivateRouting({ routeChargeWallet={privatePages.homeChargeWallet} routeWireTransfer={privatePages.homeWireTransfer} routeClose={privatePages.home} - onClose={() => routeTo("home", {})} - onAuthorizationRequired={() => routeTo("solveSecondFactor", {})} - onOperationCreated={(wopid) => routeTo("operationDetails", { wopid })} + onClose={() => navigateTo(privatePages.home.url({}))} + onAuthorizationRequired={() => navigateTo(privatePages.solveSecondFactor.url({}))} + onOperationCreated={(wopid) => navigateTo(privatePages.operationDetails.url({ wopid }))} /> ); } default: - assertUnreachable(loc.name); + assertUnreachable(location.name); } } diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx index 31013388b..97778e6d7 100644 --- a/packages/demobank-ui/src/components/app.tsx +++ b/packages/demobank-ui/src/components/app.tsx @@ -30,6 +30,8 @@ import { SettingsProvider } from "../context/settings.js"; import { strings } from "../i18n/strings.js"; import { BankFrame } from "../pages/BankFrame.js"; import { BankUiSettings, fetchSettings } from "../settings.js"; +import { TalerWalletIntegrationBrowserProvider } from "../context/wallet-integration.js"; +import { BrowserHashNavigationProvider } from "../context/navigation.js"; const WITH_LOCAL_STORAGE_CACHE = false; const App: FunctionalComponent = () => { @@ -78,7 +80,11 @@ const App: FunctionalComponent = () => { keepPreviousData: true, }} > - <Routing /> + <TalerWalletIntegrationBrowserProvider> + <BrowserHashNavigationProvider> + <Routing /> + </BrowserHashNavigationProvider> + </TalerWalletIntegrationBrowserProvider> </SWRConfig> </BankCoreApiProvider> </BackendStateProvider> diff --git a/packages/demobank-ui/src/context/navigation.ts b/packages/demobank-ui/src/context/navigation.ts new file mode 100644 index 000000000..fc1460c02 --- /dev/null +++ b/packages/demobank-ui/src/context/navigation.ts @@ -0,0 +1,80 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useEffect, useState } from "preact/hooks"; + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +export type Type = { + path: string; + params: Record<string, string>; + navigateTo: (path: string) => void; + // addNavigationListener: (listener: (path: string, params: Record<string, string>) => void) => (() => void); +}; + +// @ts-expect-error shold not be used without provider +const Context = createContext<Type>(undefined); + +export const useNavigationContext = (): Type => useContext(Context); + +function getPathAndParamsFromWindow() { + const path = typeof window !== "undefined" ? window.location.hash.substring(1) : "/"; + const params: Record<string, string> = {} + if (typeof window !== "undefined") { + for (const [key, value] of new URLSearchParams(window.location.search)) { + params[key] = value; + } + } + return { path, params } +} + +const { path: initialPath, params: initialParams } = getPathAndParamsFromWindow() + +// there is a posibility that if the browser does a redirection +// (which doesn't go through navigatTo function) and that exectued +// too early (before addEventListener runs) it won't be taking +// into account +const PopStateEventType = "popstate"; + +export const BrowserHashNavigationProvider = ({ children }: { children: ComponentChildren }): VNode => { + const [{ path, params }, setState] = useState({ path: initialPath, params: initialParams }) + if (typeof window === "undefined") { + throw Error("Can't use BrowserHashNavigationProvider if there is no window object") + } + function navigateTo(path: string) { + const { params } = getPathAndParamsFromWindow() + setState({ path, params }) + window.location.href = path + } + + useEffect(() => { + function eventListener() { + setState(getPathAndParamsFromWindow()) + } + window.addEventListener(PopStateEventType, eventListener); + return () => { + window.removeEventListener(PopStateEventType, eventListener) + } + }, []) + return h(Context.Provider, { + value: { path, params, navigateTo }, + children, + }); +}; diff --git a/packages/demobank-ui/src/context/wallet-integration.ts b/packages/demobank-ui/src/context/wallet-integration.ts new file mode 100644 index 000000000..47bdc90ec --- /dev/null +++ b/packages/demobank-ui/src/context/wallet-integration.ts @@ -0,0 +1,90 @@ +/* + This file is part of GNU Taler + (C) 2022-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + stringifyTalerUri, + TalerUri +} from "@gnu-taler/taler-util"; +import { + ComponentChildren, + createContext, + h, + VNode +} from "preact"; +import { useContext } from "preact/hooks"; + +/** + * https://docs.taler.net/design-documents/039-taler-browser-integration.html + * + * @param uri + */ +function createHeadMetaTag(uri: TalerUri, onNotFound?: () => void) { + + const meta = document.createElement("meta"); + meta.setAttribute("name", "taler-uri"); + meta.setAttribute("content", stringifyTalerUri(uri)); + + document.head.appendChild(meta); + + let walletFound = false + window.addEventListener("beforeunload", () => { + walletFound = true + }) + setTimeout(() => { + if (!walletFound && onNotFound) { + onNotFound() + } + }, 10)//very short timeout +} +interface Type { + /** + * Tell the active wallet that an action is found + * + * @param uri + * @returns + */ + publishTalerAction: (uri: TalerUri, onNotFound?: () => void) => void; +} + +// @ts-expect-error default value to undefined, should it be another thing? +const Context = createContext<Type>(undefined); + +export const useTalerWalletIntegrationAPI = (): Type => useContext(Context); + +export const TalerWalletIntegrationBrowserProvider = ({ children }: { children: ComponentChildren }): VNode => { + const value: Type = { + publishTalerAction: createHeadMetaTag + }; + return h(Context.Provider, { + value, + children, + }); +}; + + +export const TalerWalletIntegrationTestingProvider = ({ + children, + value, +}: { + children: ComponentChildren; + value: Type; +}): VNode => { + + return h(Context.Provider, { + value, + children, + }); +}; diff --git a/packages/demobank-ui/src/index.html b/packages/demobank-ui/src/index.html index 720b678a3..6e0638e3f 100644 --- a/packages/demobank-ui/src/index.html +++ b/packages/demobank-ui/src/index.html @@ -17,25 +17,25 @@ --> <!doctype html> <html lang="en" class="h-full bg-gray-100"> - <head> - <meta http-equiv="content-type" content="text/html; charset=utf-8" /> - <meta charset="utf-8" /> - <meta name="viewport" content="width=device-width,initial-scale=1" /> - <meta name="taler-support" content="uri" /> - <meta name="mobile-web-app-capable" content="yes" /> - <meta name="apple-mobile-web-app-capable" content="yes" /> - <link - rel="icon" - href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" - /> - <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> - <title>Bank</title> - <!-- Entry point for the bank SPA. --> - <script type="module" src="index.js"></script> - <link rel="stylesheet" href="index.css" /> - </head> - <body class="h-full"> - <div id="app"></div> - </body> -</html> +<head> + <meta http-equiv="content-type" content="text/html; charset=utf-8" /> + <meta charset="utf-8" /> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <meta name="taler-support" content="uri,api" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + <link rel="icon" + href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" /> + <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> + <title>Bank</title> + <!-- Entry point for the bank SPA. --> + <script type="module" src="index.js"></script> + <link rel="stylesheet" href="index.css" /> +</head> + +<body class="h-full"> + <div id="app"></div> +</body> + +</html>
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/OperationState/views.tsx b/packages/demobank-ui/src/pages/OperationState/views.tsx index ac3724eb8..4d193505e 100644 --- a/packages/demobank-ui/src/pages/OperationState/views.tsx +++ b/packages/demobank-ui/src/pages/OperationState/views.tsx @@ -36,6 +36,7 @@ import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js"; import { State } from "./index.js"; +import { useTalerWalletIntegrationAPI } from "../../context/wallet-integration.js"; export function InvalidPaytoView({ payto }: State.InvalidPayto) { return <div>Payto from server is not valid "{payto}"</div>; @@ -328,23 +329,12 @@ export function ReadyView({ onAbort: doAbort, }: State.Ready): VNode<Record<string, never>> { const { i18n } = useTranslationContext(); + const walletInegrationApi = useTalerWalletIntegrationAPI() const [notification, notify, errorHandler] = useLocalNotification(); const talerWithdrawUri = stringifyWithdrawUri(uri); useEffect(() => { - // Taler Wallet WebExtension is listening to headers response and tab updates. - // In the SPA there is no header response with the Taler URI so - // this hack manually triggers the tab update after the QR is in the DOM. - // WebExtension will be using - // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated - document.title = `${document.title} ${uri.withdrawalOperationId}`; - const meta = document.createElement("meta"); - meta.setAttribute("name", "taler-uri"); - meta.setAttribute("content", talerWithdrawUri); - document.head.insertBefore( - meta, - document.head.children.length ? document.head.children[0] : null, - ); + walletInegrationApi.publishTalerAction(uri) }, []); async function onAbort() { diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index 53086d4cc..51a6a17a9 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -14,13 +14,43 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountJson } from "@gnu-taler/taler-util"; -import { VNode, h } from "preact"; +import { AmountJson, TalerError } from "@gnu-taler/taler-util"; +import { Fragment, VNode, h } from "preact"; import { useBankState } from "../hooks/bank-state.js"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; import { RouteDefinition } from "../route.js"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { useWithdrawalDetails } from "../hooks/access.js"; +import { useEffect } from "preact/hooks"; + +function ShowOperationPendingTag({ woid, onOperationAlreadyCompleted }: { woid: string, onOperationAlreadyCompleted?: () => void }): VNode { + const { i18n } = useTranslationContext(); + const result = useWithdrawalDetails(woid); + const error = !result || result instanceof TalerError || result.type === "fail" + const completed = !error && (result.body.status === "aborted" || result.body.status === "confirmed") + useEffect(() => { + if (completed && onOperationAlreadyCompleted) { + onOperationAlreadyCompleted() + } + }, [completed]) + + if (error || completed) { + return <Fragment />; + } + + return <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 whitespace-pre"> + <svg + class="h-1.5 w-1.5 fill-green-500" + viewBox="0 0 6 6" + aria-hidden="true" + > + <circle cx="3" cy="3" r="3" /> + </svg> + <i18n.Translate>operation ready</i18n.Translate> + </span> + +} /** * Let the user choose a payment option, @@ -46,7 +76,7 @@ export function PaymentOptions({ routeWireTransfer: RouteDefinition<Record<string, never>>; }): VNode { const { i18n } = useTranslationContext(); - const [bankState] = useBankState(); + const [bankState, updateBankState] = useBankState(); return ( <div class="mt-4"> @@ -98,16 +128,9 @@ export function PaymentOptions({ </i18n.Translate> </div> {!!bankState.currentWithdrawalOperationId && ( - <span class="flex items-center gap-x-1.5 w-fit rounded-md bg-green-100 px-2 py-1 text-xs font-medium text-green-700 whitespace-pre"> - <svg - class="h-1.5 w-1.5 fill-green-500" - viewBox="0 0 6 6" - aria-hidden="true" - > - <circle cx="3" cy="3" r="3" /> - </svg> - <i18n.Translate>operation ready</i18n.Translate> - </span> + <ShowOperationPendingTag woid={bankState.currentWithdrawalOperationId} onOperationAlreadyCompleted={() => { + updateBankState("currentWithdrawalOperationId", undefined) + }} /> )} </div> </label> diff --git a/packages/demobank-ui/src/pages/ProfileNavigation.tsx b/packages/demobank-ui/src/pages/ProfileNavigation.tsx index a6615d578..02f30d8e8 100644 --- a/packages/demobank-ui/src/pages/ProfileNavigation.tsx +++ b/packages/demobank-ui/src/pages/ProfileNavigation.tsx @@ -19,6 +19,7 @@ import { privatePages } from "../Routing.js"; import { useBankCoreApiContext } from "../context/config.js"; import { useBackendState } from "../hooks/backend.js"; import { assertUnreachable } from "@gnu-taler/taler-util"; +import { useNavigationContext } from "../context/navigation.js"; export function ProfileNavigation({ current, @@ -32,6 +33,7 @@ export function ProfileNavigation({ credentials.status !== "loggedIn" ? false : !credentials.isUserAdministrator; + const { navigateTo } = useNavigationContext() return ( <div> <div class="sm:hidden"> @@ -46,19 +48,19 @@ export function ProfileNavigation({ const op = e.currentTarget.value as typeof current; switch (op) { case "details": { - window.location.href = privatePages.myAccountDetails.url({}); + navigateTo(privatePages.myAccountDetails.url({})); return; } case "delete": { - window.location.href = privatePages.myAccountDelete.url({}); + navigateTo(privatePages.myAccountDelete.url({})); return; } case "credentials": { - window.location.href = privatePages.myAccountPassword.url({}); + navigateTo(privatePages.myAccountPassword.url({})); return; } case "cashouts": { - window.location.href = privatePages.myAccountCashouts.url({}); + navigateTo(privatePages.myAccountCashouts.url({})); return; } default: diff --git a/packages/demobank-ui/src/pages/QrCodeSection.tsx b/packages/demobank-ui/src/pages/QrCodeSection.tsx index f21134aa1..037849804 100644 --- a/packages/demobank-ui/src/pages/QrCodeSection.tsx +++ b/packages/demobank-ui/src/pages/QrCodeSection.tsx @@ -19,7 +19,7 @@ import { HttpStatusCode, stringifyWithdrawUri, TranslatedString, - WithdrawUriResult, + WithdrawUriResult } from "@gnu-taler/taler-util"; import { LocalNotificationBanner, @@ -30,6 +30,7 @@ import { Fragment, h, VNode } from "preact"; import { useEffect } from "preact/hooks"; import { QR } from "../components/QR.js"; import { useBankCoreApiContext } from "../context/config.js"; +import { useTalerWalletIntegrationAPI } from "../context/wallet-integration.js"; import { useBackendState } from "../hooks/backend.js"; export function QrCodeSection({ @@ -40,25 +41,15 @@ export function QrCodeSection({ onAborted: () => void; }): VNode { const { i18n } = useTranslationContext(); + const walletInegrationApi = useTalerWalletIntegrationAPI() const talerWithdrawUri = stringifyWithdrawUri(withdrawUri); const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; useEffect(() => { - // Taler Wallet WebExtension is listening to headers response and tab updates. - // In the SPA there is no header response with the Taler URI so - // this hack manually triggers the tab update after the QR is in the DOM. - // WebExtension will be using - // https://developer.chrome.com/docs/extensions/reference/tabs/#event-onUpdated - document.title = `${document.title} ${withdrawUri.withdrawalOperationId}`; - const meta = document.createElement("meta"); - meta.setAttribute("name", "taler-uri"); - meta.setAttribute("content", talerWithdrawUri); - document.head.insertBefore( - meta, - document.head.children.length ? document.head.children[0] : null, - ); + walletInegrationApi.publishTalerAction(withdrawUri) }, []); + const [notification, notify, handleError] = useLocalNotification(); const { api } = useBankCoreApiContext(); diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx index 1e48b818a..9f7f46c4f 100644 --- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx @@ -21,6 +21,7 @@ import { TranslatedString, assertUnreachable, parseWithdrawUri, + stringifyWithdrawUri, } from "@gnu-taler/taler-util"; import { Attention, @@ -41,6 +42,8 @@ import { RouteDefinition } from "../route.js"; import { undefinedIfEmpty } from "../utils.js"; import { OperationState } from "./OperationState/index.js"; import { InputAmount, doAutoFocus } from "./PaytoWireTransferForm.js"; +import { useTalerWalletIntegrationAPI } from "../context/wallet-integration.js"; +import { useNavigationContext } from "../context/navigation.js"; const RefAmount = forwardRef(InputAmount); @@ -57,18 +60,32 @@ function OldWithdrawalForm({ }): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreferences(); + + // const walletInegrationApi = useTalerWalletIntegrationAPI() + // const { navigateTo } = useNavigationContext(); + const [bankState, updateBankState] = useBankState(); + const { api } = useBankCoreApiContext(); const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials; - const { api } = useBankCoreApiContext(); const [amountStr, setAmountStr] = useState<string | undefined>( `${settings.maxWithdrawalAmount}`, ); const [notification, notify, handleError] = useLocalNotification(); if (bankState.currentWithdrawalOperationId) { + // FIXME: doing the preventDefault is not optimal + + // const suri = stringifyWithdrawUri({ + // bankIntegrationApiBaseUrl: api.getIntegrationAPI().baseUrl, + // withdrawalOperationId: bankState.currentWithdrawalOperationId, + // }); + // const uri = parseWithdrawUri(suri)! + const url = privatePages.operationDetails.url({ + wopid: bankState.currentWithdrawalOperationId, + }) return ( <Attention type="warning" title={i18n.str`There is an operation already`}> <span ref={focus ? doAutoFocus : undefined} /> @@ -77,9 +94,13 @@ function OldWithdrawalForm({ </i18n.Translate>{" "} <a class="font-semibold text-yellow-700 hover:text-yellow-600" - href={privatePages.operationDetails.url({ - wopid: bankState.currentWithdrawalOperationId, - })} + href={url} + // onClick={(e) => { + // e.preventDefault() + // walletInegrationApi.publishTalerAction(uri, () => { + // navigateTo(url) + // }) + // }} > <i18n.Translate>this page</i18n.Translate> </a> @@ -324,7 +345,7 @@ export function WalletWithdrawForm({ onAuthorizationRequired={onAuthorizationRequired} routeClose={routeCancel} onAbort={onOperationAborted} - // route={routeCancel} + // route={routeCancel} /> )} </div> diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx index 3cf552f39..03f6556af 100644 --- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx @@ -75,29 +75,39 @@ export function WithdrawalQRCode({ if (data.status === "aborted") { return ( - <section id="main" class="content"> - <h1 class="nav">{i18n.str`Operation aborted`}</h1> - <section> - <p> - <i18n.Translate> - The wire transfer to the Taler Exchange operator's account was - aborted, your balance was not affected. - </i18n.Translate> - </p> - <p> - <i18n.Translate> - You can close this page now or continue to the account page. - </i18n.Translate> - </p> + <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> + <div> + <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100"> + <svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /> + </svg> + </div> + <div class="mt-3 text-center sm:mt-5"> + <h3 + class="text-base font-semibold leading-6 text-gray-900" + id="modal-title" + > + <i18n.Translate>Operation aborted</i18n.Translate> + </h3> + <div class="mt-2"> + <p class="text-sm text-gray-500"> + <i18n.Translate> + The wire transfer to the Taler Exchange operator's account was + aborted from somewhere else, your balance was not affected. + </i18n.Translate> + </p> + </div> + </div> + </div> + <div class="mt-5 sm:mt-6"> <a href={routeClose.url({})} - class="pure-button pure-button-primary" - style={{ float: "right" }} + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" > <i18n.Translate>Continue</i18n.Translate> </a> - </section> - </section> + </div> + </div> ); } diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx index 75e0a9b84..46da5c847 100644 --- a/packages/demobank-ui/src/pages/business/CreateCashout.tsx +++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx @@ -316,7 +316,7 @@ export function CreateCashout({ const cashoutDisabled = config.supported_tan_channels.length < 1 || !resultAccount.body.cashout_payto_uri; - console.log("disab", cashoutDisabled); + const cashoutAccount = !resultAccount.body.cashout_payto_uri ? undefined : parsePaytoUri(resultAccount.body.cashout_payto_uri); diff --git a/packages/demobank-ui/src/route.ts b/packages/demobank-ui/src/route.ts index 72b405791..912ba274d 100644 --- a/packages/demobank-ui/src/route.ts +++ b/packages/demobank-ui/src/route.ts @@ -13,7 +13,7 @@ You should have received a copy of the GNU General Public License along with GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { useEffect, useState } from "preact/hooks"; +import { useNavigationContext } from "./context/navigation.js"; export function urlPattern< T extends Record<string, string> = Record<string, never>, @@ -24,29 +24,6 @@ export function urlPattern< }; } -// export function Router({ -// pageList, -// onNotFound, -// }: { -// pageList: Array<PageEntry<unknown>>; -// onNotFound: () => VNode; -// }): VNode { -// const current = useCurrentLocation<unknown>(pageList); -// if (current !== undefined) { -// const d = current.page.url -// if (typeof current.page.url === "string") { -// const p = current.page.url -// return create(current.page.view, {}); -// } -// const p = current.page.url -// return create(current.page.view, current.values); -// } -// return onNotFound(); -// } -// type PagesMap<T extends object> = { -// [name in keyof T]: PageDefinition<any>; -// }; - export type RouteDefinition<T> = { pattern: RegExp; url: (p: T) => string; @@ -72,17 +49,9 @@ export type RouteParamsType< type Location<E, T extends RouteMap<E>, NAME extends keyof T> = { parent: T; name: NAME; - // mapped values from params and url values: RouteParamsType<T, NAME>; }; -const STARTUP_SPA_LOCATION = - typeof window !== "undefined" ? window.location.hash.substring(1) : "/"; -const STARTUP_SPA_PARAMS = - typeof window !== "undefined" - ? new URLSearchParams(window.location.search) - : new URLSearchParams(); - /** * Search path in the pageList * get the values from the path found @@ -91,11 +60,11 @@ const STARTUP_SPA_PARAMS = * @param path * @param params */ -function doSync<DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM>( +function findMatch<DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM>( pagesMap: RM, pageList: Array<ROUTES>, path: string, - params: URLSearchParams, + params: Record<string, string>, ): Location<DEF, RM, ROUTES> | undefined { for (let idx = 0; idx < pageList.length; idx++) { const name = pageList[idx]; @@ -103,9 +72,10 @@ function doSync<DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM>( if (found !== null) { const values = found.groups === undefined ? {} : structuredClone(found.groups); - params.forEach((v, k) => { - values[k] = v; - }); + + Object.entries(params).forEach(([key, value]) => { + values[key] = value + }) // @ts-expect-error values is a map string which is equivalent to the RouteParamsType return { name, parent: pagesMap, values }; @@ -114,87 +84,13 @@ function doSync<DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM>( return undefined; } -const PopStateEventType = "popstate"; - export function useCurrentLocation< DEF, RM extends RouteMap<DEF>, ROUTES extends keyof RM, >(pagesMap: RM) { const pageList = Object.keys(pagesMap) as Array<ROUTES>; - const [currentLocation, setCurrentLocation] = useState< - Location<DEF, RM, ROUTES> | undefined - >(doSync(pagesMap, pageList, STARTUP_SPA_LOCATION, STARTUP_SPA_PARAMS)); - useEffect(() => { - window.addEventListener(PopStateEventType, () => { - const path = window.location.hash.substring(1); - console.log("event", path); - const l = doSync( - pagesMap, - pageList, - path, - new URLSearchParams(window.location.search), - ); - setCurrentLocation(l); - }); - }, []); - function routeTo<N extends ROUTES>( - n: N, - values: RouteParamsType<RM, N>, - ): void { - setCurrentLocation({ - parent: pagesMap, - name: n, - values, - }); - } - return [currentLocation, routeTo] as const; -} - -// function doestUrlMatchToRoute( -// url: string, -// route: string, -// ): undefined | Record<string, string> { -// const paramsPattern = /(?:\?([^#]*))?$/; + const { path, params } = useNavigationContext() -// const urlSeg = url.replace(paramsPattern, "").split("/"); -// const routeSeg = route.split("/"); -// let max = Math.max(urlSeg.length, routeSeg.length); - -// const result: Record<string, string> = {}; -// for (let i = 0; i < max; i++) { -// if (routeSeg[i] && routeSeg[i].charAt(0) === ":") { -// const param = routeSeg[i].replace(/(^:|[+*?]+$)/g, ""); - -// const flags = (routeSeg[i].match(/[+*?]+$/) || EMPTY)[0] || ""; -// const plus = ~flags.indexOf("+"); -// const star = ~flags.indexOf("*"); -// const val = urlSeg[i] || ""; - -// if (!val && !star && (flags.indexOf("?") < 0 || plus)) { -// return undefined; -// } -// result[param] = decodeURIComponent(val); -// if (plus || star) { -// result[param] = urlSeg.slice(i).map(decodeURIComponent).join("/"); -// break; -// } -// } else if (routeSeg[i] !== urlSeg[i]) { -// return undefined; -// } -// } - -// const params = url.match(paramsPattern); -// if (params && params[1]) { -// const paramList = params[1].split("&"); -// for (let i = 0; i < paramList.length; i++) { -// const idx = paramList[i].indexOf("="); -// const name = paramList[i].substring(0, idx); -// const value = paramList[i].substring(idx + 1); -// result[decodeURIComponent(name)] = decodeURIComponent(value); -// } -// } - -// return result; -// } -// const EMPTY: Record<string, string> = {}; + return findMatch(pagesMap, pageList, path, params); +} diff --git a/packages/taler-wallet-webextension/manifest-v2.json b/packages/taler-wallet-webextension/manifest-v2.json index 3475cd8aa..6f2096b05 100644 --- a/packages/taler-wallet-webextension/manifest-v2.json +++ b/packages/taler-wallet-webextension/manifest-v2.json @@ -18,7 +18,6 @@ "permissions": [ "unlimitedStorage", "storage", - "webRequest", "<all_urls>", "activeTab" ], diff --git a/packages/taler-wallet-webextension/manifest-v3.json b/packages/taler-wallet-webextension/manifest-v3.json index d6a303ed6..65a75824b 100644 --- a/packages/taler-wallet-webextension/manifest-v3.json +++ b/packages/taler-wallet-webextension/manifest-v3.json @@ -17,7 +17,6 @@ "storage", "activeTab", "scripting", - "webRequest", "declarativeContent", "alarms" ], diff --git a/packages/taler-wallet-webextension/src/platform/api.ts b/packages/taler-wallet-webextension/src/platform/api.ts index a2b26441b..c7d297db9 100644 --- a/packages/taler-wallet-webextension/src/platform/api.ts +++ b/packages/taler-wallet-webextension/src/platform/api.ts @@ -46,20 +46,32 @@ export interface Permissions { * Compatibility API that works on multiple browsers. */ export interface CrossBrowserPermissionsApi { - containsHostPermissions(): Promise<boolean>; - requestHostPermissions(): Promise<boolean>; - removeHostPermissions(): Promise<boolean>; containsClipboardPermissions(): Promise<boolean>; requestClipboardPermissions(): Promise<boolean>; removeClipboardPermissions(): Promise<boolean>; - addPermissionsListener( - callback: (p: Permissions, lastError?: string) => void, - ): void; } -export type MessageFromBackend = WalletNotification; +export enum ExtensionNotificationType { + SettingsChange = "settings-change", +} + +export interface SettingsChangeNotification { + type: ExtensionNotificationType.SettingsChange; + + currentValue: Settings; +} + +export type ExtensionNotification = SettingsChangeNotification + +export type MessageFromBackend = { + type: "wallet", + notification: WalletNotification +} | { + type: "web-extension", + notification: ExtensionNotification +}; export type MessageFromFrontend< Op extends BackgroundOperations | WalletOperations | ExtensionOperations, @@ -110,7 +122,7 @@ export interface Settings extends WebexWalletConfig { } export const defaultSettings: Settings = { - injectTalerSupport: true, + injectTalerSupport: false, autoOpen: true, advanceMode: false, backup: false, @@ -207,13 +219,6 @@ export interface BackgroundPlatformAPI { ) => Promise<MessageResponse>, ): void; - /** - * Use by the wallet backend to activate the listener of HTTP request - */ - registerTalerHeaderListener(): void; - - containsTalerHeaderListener(): boolean; - } export interface ForegroundPlatformAPI { /** diff --git a/packages/taler-wallet-webextension/src/platform/chrome.ts b/packages/taler-wallet-webextension/src/platform/chrome.ts index 18d282342..d791a560f 100644 --- a/packages/taler-wallet-webextension/src/platform/chrome.ts +++ b/packages/taler-wallet-webextension/src/platform/chrome.ts @@ -28,6 +28,7 @@ import { BackgroundOperations } from "../wxApi.js"; import { BackgroundPlatformAPI, CrossBrowserPermissionsApi, + ExtensionNotificationType, ForegroundPlatformAPI, MessageFromBackend, MessageFromFrontend, @@ -60,27 +61,31 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { useServiceWorkerAsBackgroundProcess, keepAlive, listenNetworkConnectionState, - registerTalerHeaderListener, - containsTalerHeaderListener, }; export default api; const logger = new Logger("chrome.ts"); -async function getSettingsFromStorage(): Promise<Settings> { - const data = await chrome.storage.local.get("wallet-settings"); - if (!data) return defaultSettings; - const settings = data["wallet-settings"]; - if (!settings) return defaultSettings; + +const WALLET_STORAGE_KEY = "wallet-settings"; + +function jsonParseOrDefault(unparsed: any, def: any) { + if (!unparsed) return def try { - const parsed = JSON.parse(settings); - return parsed; + return JSON.parse(unparsed); } catch (e) { - return defaultSettings; + return def; } } +async function getSettingsFromStorage(): Promise<Settings> { + const data = await chrome.storage.local.get(WALLET_STORAGE_KEY); + if (!data) return defaultSettings; + const settings = data[WALLET_STORAGE_KEY]; + return jsonParseOrDefault(settings, defaultSettings) +} + function keepAlive(callback: any): void { if (extensionIsManifestV3()) { chrome.alarms.create("wallet-worker", { periodInMinutes: 1 }); @@ -140,21 +145,8 @@ export function removeClipboardPermissions(): Promise<boolean> { }); } -function addPermissionsListener( - callback: (p: Permissions, lastError?: string) => void, -): void { - chrome.permissions.onAdded.addListener((perm: Permissions) => { - const lastError = chrome.runtime.lastError?.message; - callback(perm, lastError); - }); -} - function getPermissionsApi(): CrossBrowserPermissionsApi { return { - containsHostPermissions, - requestHostPermissions, - removeHostPermissions, - addPermissionsListener, requestClipboardPermissions, removeClipboardPermissions, containsClipboardPermissions, @@ -363,6 +355,18 @@ function registerAllIncomingConnections(): void { logger.error("error trying to save incoming connection", e); } }); + chrome.storage.onChanged.addListener((event) => { + if (event[WALLET_STORAGE_KEY]) { + sendMessageToAllChannels({ + type: "web-extension", + notification: { + type: ExtensionNotificationType.SettingsChange, + currentValue: jsonParseOrDefault(event[WALLET_STORAGE_KEY], defaultSettings) + } + }) + } + }) + } function listenToAllChannels( @@ -723,253 +727,3 @@ function listenNetworkConnectionState( }; } -type HeaderListenerFunc = ( - details: chrome.webRequest.WebResponseHeadersDetails, -) => void; -let currentHeaderListener: HeaderListenerFunc | undefined = undefined; - -// type TabListenerFunc = (tabId: number, info: chrome.tabs.TabChangeInfo) => void; -// let currentTabListener: TabListenerFunc | undefined = undefined; - - -function containsTalerHeaderListener(): boolean { - return ( - currentHeaderListener !== undefined - // || currentTabListener !== undefined - ); -} - -function headerListener( - details: chrome.webRequest.WebResponseHeadersDetails, -): chrome.webRequest.BlockingResponse | undefined { - logger.trace("header listener run", details.statusCode, chrome.runtime.lastError) - if (chrome.runtime.lastError) { - logger.error(JSON.stringify(chrome.runtime.lastError)); - return; - } - - if ( - details.statusCode === 402 || - details.statusCode === 202 || - details.statusCode === 200 - ) { - const values = (details.responseHeaders || []) - .filter((h) => h.name.toLowerCase() === "taler") - .map((h) => h.value) - .filter((value): value is string => !!value); - - const talerUri = values.length > 0 ? values[0] : undefined - if (talerUri) { - logger.info( - `Found a Taler URI in a response header for the request ${details.url} from tab ${details.tabId}: ${talerUri}`, - ); - parseTalerUriAndRedirect(details.tabId, talerUri); - return; - } - } - return details; -} -function parseTalerUriAndRedirect(tabId: number, maybeTalerUri: string): void { - const talerUri = maybeTalerUri.startsWith("ext+") - ? maybeTalerUri.substring(4) - : maybeTalerUri; - const uri = parseTalerUri(talerUri); - if (!uri) { - logger.warn( - `Response with HTTP 402 the Taler header but could not classify ${talerUri}`, - ); - return; - } - redirectTabToWalletPage( - tabId, - `/taler-uri/${encodeURIComponent(talerUri)}`, - ); -} - -/** - * Not needed anymore since SPA use taler support - */ - -// async function tabListener( -// tabId: number, -// info: chrome.tabs.TabChangeInfo, -// ): Promise<void> { -// if (tabId < 0) return; -// const tabLocationHasBeenUpdated = info.status === "complete"; -// const tabTitleHasBeenUpdated = info.title !== undefined; -// if (tabLocationHasBeenUpdated || tabTitleHasBeenUpdated) { -// const uri = await findTalerUriInTab(tabId); -// if (!uri) return; -// logger.info(`Found a Taler URI in the tab ${tabId}`); -// parseTalerUriAndRedirect(tabId, uri); -// } -// } - -/** - * unused, declarative redirect is not good enough - * - */ -// async function registerDeclarativeRedirect() { -// await chrome.declarativeNetRequest.updateDynamicRules({ -// removeRuleIds: [1], -// addRules: [ -// { -// id: 1, -// priority: 1, -// condition: { -// urlFilter: "https://developer.chrome.com/docs/extensions/mv2/", -// regexFilter: ".*taler_uri=([^&]*).*", -// // isUrlFilterCaseSensitive: false, -// // requestMethods: [chrome.declarativeNetRequest.RequestMethod.GET] -// // resourceTypes: [chrome.declarativeNetRequest.ResourceType.MAIN_FRAME], -// }, -// action: { -// type: chrome.declarativeNetRequest.RuleActionType.REDIRECT, -// redirect: { -// regexSubstitution: `chrome-extension://${chrome.runtime.id}/static/wallet.html?action=\\1`, -// }, -// }, -// }, -// ], -// }); -// } - -function registerTalerHeaderListener(): void { - logger.info("setting up header listener"); - - const prevHeaderListener = currentHeaderListener; - // const prevTabListener = currentTabListener; - - if ( - prevHeaderListener && - chrome?.webRequest?.onHeadersReceived?.hasListener(prevHeaderListener) - ) { - return; - // console.log("removming on header listener") - // chrome.webRequest.onHeadersReceived.removeListener(prevHeaderListener); - // chrome.webRequest.onCompleted.removeListener(prevHeaderListener); - // chrome.webRequest.onResponseStarted.removeListener(prevHeaderListener); - // chrome.webRequest.onErrorOccurred.removeListener(prevHeaderListener); - } - - // if ( - // prevTabListener && - // chrome?.tabs?.onUpdated?.hasListener(prevTabListener) - // ) { - // console.log("removming on tab listener") - // chrome.tabs.onUpdated.removeListener(prevTabListener); - // } - - console.log("headers on, disabled:", chrome?.webRequest?.onHeadersReceived === undefined) - if (chrome?.webRequest) { - if (extensionIsManifestV3()) { - chrome.webRequest.onHeadersReceived.addListener(headerListener, - { urls: ["<all_urls>"] }, - ["responseHeaders"] - ); - } else { - chrome.webRequest.onHeadersReceived.addListener(headerListener, - { urls: ["<all_urls>"] }, - ["responseHeaders"] - ); - } - // chrome.webRequest.onCompleted.addListener(headerListener, - // { urls: ["<all_urls>"] }, - // ["responseHeaders", "extraHeaders"] - // ); - // chrome.webRequest.onResponseStarted.addListener(headerListener, - // { urls: ["<all_urls>"] }, - // ["responseHeaders", "extraHeaders"] - // ); - // chrome.webRequest.onErrorOccurred.addListener(headerListener, - // { urls: ["<all_urls>"] }, - // ["extraHeaders"] - // ); - currentHeaderListener = headerListener; - } - - // const tabsEvent: chrome.tabs.TabUpdatedEvent | undefined = - // chrome?.tabs?.onUpdated; - // if (tabsEvent) { - // tabsEvent.addListener(tabListener); - // currentTabListener = tabListener; - // } - - //notify the browser about this change, this operation is expensive - chrome?.webRequest?.handlerBehaviorChanged(() => { - if (chrome.runtime.lastError) { - logger.error(JSON.stringify(chrome.runtime.lastError)); - } - }); -} - -const hostPermissions = { - permissions: ["webRequest"], - origins: ["http://*/*", "https://*/*"], -}; - -export function containsHostPermissions(): Promise<boolean> { - return new Promise((res, rej) => { - chrome.permissions.contains(hostPermissions, (resp) => { - const le = chrome.runtime.lastError?.message; - if (le) { - rej(le); - } - res(resp); - }); - }); -} - -export async function requestHostPermissions(): Promise<boolean> { - return new Promise((res, rej) => { - chrome.permissions.request(hostPermissions, (resp) => { - const le = chrome.runtime.lastError?.message; - if (le) { - rej(le); - } - res(resp); - }); - }); -} - -export async function removeHostPermissions(): Promise<boolean> { - //if there is a handler already, remove it - if ( - currentHeaderListener && - chrome?.webRequest?.onHeadersReceived?.hasListener(currentHeaderListener) - ) { - chrome.webRequest.onHeadersReceived.removeListener(currentHeaderListener); - } - // if ( - // currentTabListener && - // chrome?.tabs?.onUpdated?.hasListener(currentTabListener) - // ) { - // chrome.tabs.onUpdated.removeListener(currentTabListener); - // } - - currentHeaderListener = undefined; - // currentTabListener = undefined; - - //notify the browser about this change, this operation is expensive - if ("webRequest" in chrome) { - chrome.webRequest.handlerBehaviorChanged(() => { - if (chrome.runtime.lastError) { - logger.error(JSON.stringify(chrome.runtime.lastError)); - } - }); - } - - if (extensionIsManifestV3()) { - // Trying to remove host permissions with manifest >= v3 throws an error - return true; - } - return new Promise((res, rej) => { - chrome.permissions.remove(hostPermissions, (resp) => { - const le = chrome.runtime.lastError?.message; - if (le) { - rej(le); - } - res(resp); - }); - }); -}
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/platform/dev.ts b/packages/taler-wallet-webextension/src/platform/dev.ts index 51744e318..2993c88bc 100644 --- a/packages/taler-wallet-webextension/src/platform/dev.ts +++ b/packages/taler-wallet-webextension/src/platform/dev.ts @@ -37,18 +37,11 @@ const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { listenNetworkConnectionState, openNewURLFromPopup: () => undefined, getPermissionsApi: () => ({ - addPermissionsListener: () => undefined, - containsHostPermissions: async () => true, - removeHostPermissions: async () => false, - requestHostPermissions: async () => false, containsClipboardPermissions: async () => true, removeClipboardPermissions: async () => false, requestClipboardPermissions: async () => false, }), - // registerDeclarativeRedirect: () => false, - registerTalerHeaderListener: () => false, - containsTalerHeaderListener: () => false, getWalletWebExVersion: () => ({ version: "none", }), diff --git a/packages/taler-wallet-webextension/src/platform/firefox.ts b/packages/taler-wallet-webextension/src/platform/firefox.ts index 0bbe805cf..3d67423fd 100644 --- a/packages/taler-wallet-webextension/src/platform/firefox.ts +++ b/packages/taler-wallet-webextension/src/platform/firefox.ts @@ -26,9 +26,6 @@ import chromePlatform, { containsClipboardPermissions as chromeClipContains, removeClipboardPermissions as chromeClipRemove, requestClipboardPermissions as chromeClipRequest, - containsHostPermissions as chromeHostContains, - requestHostPermissions as chromeHostRequest, - removeHostPermissions as chromeHostRemove, } from "./chrome.js"; const api: BackgroundPlatformAPI & ForegroundPlatformAPI = { @@ -47,16 +44,8 @@ function isFirefox(): boolean { return true; } -function addPermissionsListener(callback: (p: Permissions) => void): void { - // throw Error("addPermissionListener is not supported for Firefox"); -} - function getPermissionsApi(): CrossBrowserPermissionsApi { return { - addPermissionsListener, - containsHostPermissions: chromeHostContains, - requestHostPermissions: chromeHostRequest, - removeHostPermissions: chromeHostRemove, containsClipboardPermissions: chromeClipContains, removeClipboardPermissions: chromeClipRemove, requestClipboardPermissions: chromeClipRequest, diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts index d1b1dc374..6cc4eb2b4 100644 --- a/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts +++ b/packages/taler-wallet-webextension/src/taler-wallet-interaction-loader.ts @@ -15,6 +15,7 @@ */ import { CoreApiResponse, TalerError, TalerErrorCode } from "@gnu-taler/taler-util"; +import type { MessageFromBackend } from "./platform/api.js"; /** * This will modify all the pages that the user load when navigating with Web Extension enabled @@ -46,6 +47,9 @@ const suffixIsNotXMLorPDF = const rootElementIsHTML = document.documentElement.nodeName && document.documentElement.nodeName.toLowerCase() === "html"; +// const pageAcceptsTalerSupport = document.head.querySelector( +// "meta[name=taler-support]", +// ); @@ -67,6 +71,7 @@ function convertURIToWebExtensionPath(uri: string) { const shouldNotInject = !documentDocTypeIsHTML || !suffixIsNotXMLorPDF || + // !pageAcceptsTalerSupport || !rootElementIsHTML; const logger = { @@ -93,16 +98,22 @@ function redirectToTalerActionHandler(element: HTMLMetaElement) { return; } - location.href = convertURIToWebExtensionPath(uri) + const walletPage = convertURIToWebExtensionPath(uri) + window.location.replace(walletPage) } -function injectTalerSupportScript(head: HTMLHeadElement) { +function injectTalerSupportScript(head: HTMLHeadElement, trusted: boolean) { const meta = head.querySelector("meta[name=taler-support]") + if (!meta) return; + const content = meta.getAttribute("content"); + if (!content) return; + const features = content.split(",") - const debugEnabled = meta?.getAttribute("debug") === "true"; + const debugEnabled = meta.getAttribute("debug") === "true"; + const hijackEnabled = features.indexOf("uri") !== -1 + const talerApiEnabled = features.indexOf("api") !== -1 && trusted const scriptTag = document.createElement("script"); - scriptTag.setAttribute("async", "false"); const url = new URL( chrome.runtime.getURL("/dist/taler-wallet-interaction-support.js"), @@ -111,6 +122,12 @@ function injectTalerSupportScript(head: HTMLHeadElement) { if (debugEnabled) { url.searchParams.set("debug", "true"); } + if (talerApiEnabled) { + url.searchParams.set("api", "true"); + } + if (hijackEnabled) { + url.searchParams.set("hijack", "true"); + } scriptTag.src = url.href; try { @@ -123,12 +140,14 @@ function injectTalerSupportScript(head: HTMLHeadElement) { export interface ExtensionOperations { - isInjectionEnabled: { + isAutoOpenEnabled: { request: void; response: boolean; }; - isAutoOpenEnabled: { - request: void; + isDomainTrusted: { + request: { + domain: string; + }; response: boolean; }; } @@ -200,48 +219,82 @@ async function sendMessageToBackground<Op extends keyof ExtensionOperations>( }); } +let notificationPort: chrome.runtime.Port | undefined; +function listenToWalletBackground(listener: (m: any) => void): () => void { + if (notificationPort === undefined) { + notificationPort = chrome.runtime.connect({ name: "notifications" }); + } + notificationPort.onMessage.addListener(listener); + function removeListener(): void { + if (notificationPort !== undefined) { + notificationPort.onMessage.removeListener(listener); + } + } + return removeListener; +} + +const loaderSettings = { + isAutoOpenEnabled: false, +} + function start( - onTalerMetaTagFound: (listener:(el: HTMLMetaElement)=>void) => void, - onHeadReady: (listener:(el: HTMLHeadElement)=>void) => void + onTalerMetaTagFound: (listener: (el: HTMLMetaElement) => void) => void, + onHeadReady: (listener: (el: HTMLHeadElement) => void) => void ) { - // do not run everywhere, this is just expected to run on html - // sites + // do not run everywhere, this is just expected to run on site + // that are aware of taler if (shouldNotInject) return; - const isAutoOpenEnabled_promise = callBackground("isAutoOpenEnabled", undefined) - const isInjectionEnabled_promise = callBackground("isInjectionEnabled", undefined) + callBackground("isAutoOpenEnabled", undefined).then(result => { + loaderSettings.isAutoOpenEnabled = result + }) + const isDomainTrusted_promise = callBackground("isDomainTrusted", { + domain: window.location.origin + }) - onTalerMetaTagFound(async (el)=> { - const enabled = await isAutoOpenEnabled_promise; - if (!enabled) return; + onTalerMetaTagFound(async (el) => { + if (!loaderSettings.isAutoOpenEnabled) return; redirectToTalerActionHandler(el) }) onHeadReady(async (el) => { - const enabled = await isInjectionEnabled_promise; - if (!enabled) return; - injectTalerSupportScript(el) + const trusted = await isDomainTrusted_promise + injectTalerSupportScript(el, trusted) + }) + + listenToWalletBackground((e: MessageFromBackend) => { + if (e.type === "web-extension" && e.notification.type === "settings-change") { + const settings = e.notification.currentValue + loaderSettings.isAutoOpenEnabled = settings.autoOpen + } + console.log("loader ->", e) }) } +function isCorrectMetaElement(el: HTMLMetaElement): boolean { + const name = el.getAttribute("name") + if (!name) return false; + if (name !== "taler-uri") return false; + const uri = el.getAttribute("content"); + if (!uri) return false; + return true +} + /** * Tries to find taler meta tag ASAP and report * @param notify * @returns */ -function onTalerMetaTag(notify: (el: HTMLMetaElement) => void) { +function notifyWhenTalerUriIsFound(notify: (el: HTMLMetaElement) => void) { if (document.head) { const element = document.head.querySelector("meta[name=taler-uri]") if (!element) return; if (!(element instanceof HTMLMetaElement)) return; - const name = element.getAttribute("name") - if (!name) return; - if (name !== "taler-uri") return; - const uri = element.getAttribute("content"); - if (!uri) return; - notify(element) + if (isCorrectMetaElement(element)) { + notify(element) + } return; } const obs = new MutationObserver(async function (mutations) { @@ -250,13 +303,10 @@ function onTalerMetaTag(notify: (el: HTMLMetaElement) => void) { if (mut.type === "childList") { mut.addedNodes.forEach((added) => { if (added instanceof HTMLMetaElement) { - const name = added.getAttribute("name") - if (!name) return; - if (name !== "taler-uri") return; - const uri = added.getAttribute("content"); - if (!uri) return; - notify(added) - obs.disconnect() + if (isCorrectMetaElement(added)) { + notify(added) + obs.disconnect() + } } }); } @@ -279,7 +329,7 @@ function onTalerMetaTag(notify: (el: HTMLMetaElement) => void) { * @param notify * @returns */ -function onHeaderReady(notify: (el: HTMLHeadElement) => void) { +function notifyWhenHeadIsFound(notify: (el: HTMLHeadElement) => void) { if (document.head) { notify(document.head) return; @@ -290,7 +340,6 @@ function onHeaderReady(notify: (el: HTMLHeadElement) => void) { if (mut.type === "childList") { mut.addedNodes.forEach((added) => { if (added instanceof HTMLHeadElement) { - notify(added) obs.disconnect() } @@ -309,4 +358,4 @@ function onHeaderReady(notify: (el: HTMLHeadElement) => void) { }) } -start(onTalerMetaTag, onHeaderReady); +start(notifyWhenTalerUriIsFound, notifyWhenHeadIsFound); diff --git a/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts b/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts index 993c12703..8b15380f9 100644 --- a/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts +++ b/packages/taler-wallet-webextension/src/taler-wallet-interaction-support.ts @@ -47,7 +47,7 @@ const shouldNotRun = !documentDocTypeIsHTML || !suffixIsNotXMLorPDF || - // !pageAcceptsTalerSupport || FIXME: removing this before release for testing + !pageAcceptsTalerSupport || !rootElementIsHTML; interface Info { @@ -154,32 +154,38 @@ function start() { if (shouldNotRun) return; - // FIXME: we can remove this if the script caller send information we need if (!(document.currentScript instanceof HTMLScriptElement)) return; const url = new URL(document.currentScript.src); const { protocol, searchParams, hostname } = url; const extensionId = searchParams.get("id") ?? ""; const debugEnabled = searchParams.get("debug") === "true"; - if (debugEnabled) { - logger.debug = logger.info; - } + const apiEnabled = searchParams.get("api") === "true"; + const hijackEnabled = searchParams.get("hijack") === "true"; const info: Info = Object.freeze({ extensionId, protocol, hostname, }); + + if (debugEnabled) { + logger.debug = logger.info; + } + const taler: TalerSupport = { info, __internal: buildApi(info), }; - //@ts-ignore - window.taler = taler; + if (apiEnabled) { + //@ts-ignore + window.taler = taler; + } - //default behavior: register on install - taler.__internal.registerProtocolHandler(); + if (hijackEnabled) { + taler.__internal.registerProtocolHandler(); + } } // utils functions @@ -189,6 +195,6 @@ ); } - return start + start(); })() diff --git a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx index faa64e07d..d12ae864b 100644 --- a/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/DeveloperPage.tsx @@ -40,6 +40,9 @@ import { Button } from "../mui/Button.js"; import { Grid } from "../mui/Grid.js"; import { Paper } from "../mui/Paper.js"; import { TextField } from "../mui/TextField.js"; +import { Checkbox } from "../components/Checkbox.js"; +import { useSettings } from "../hooks/useSettings.js"; +import { useAlertContext } from "../context/alert.js"; export function DeveloperPage(): VNode { const listenAllEvents = Array.from<NotificationType>({ length: 1 }); @@ -132,6 +135,8 @@ export function View({ operations, coins, onDownloadDatabase }: Props): VNode { dump: JSON.parse(str), }); } + const [settings, updateSettings] = useSettings(); + const { safely } = useAlertContext(); const hook = useAsyncAsHook(() => api.wallet.call(WalletApiOperation.ListExchanges, {}), @@ -256,26 +261,6 @@ export function View({ operations, coins, onDownloadDatabase }: Props): VNode { <Button variant="contained" onClick={async () => { - api.background.call("toggleHeaderListener", true); - }} - > - <i18n.Translate>enable header listener</i18n.Translate> - </Button> - </Grid> - <Grid item> - <Button - variant="contained" - onClick={async () => { - api.background.call("toggleHeaderListener", false); - }} - > - <i18n.Translate>disable header listener</i18n.Translate> - </Button> - </Grid> - <Grid item> - <Button - variant="contained" - onClick={async () => { navigator.registerProtocolHandler( "taler", `${window.location.origin}/static/wallet.html#/cta/withdraw?talerWithdrawUri=%s`, @@ -360,6 +345,22 @@ export function View({ operations, coins, onDownloadDatabase }: Props): VNode { </Button> </Grid>{" "} </Grid> + <Checkbox + label={i18n.str`Inject Taler support in all pages`} + name="inject" + description={ + <i18n.Translate> + Enabling this option will make `window.taler` be available + in all sites + </i18n.Translate> + } + enabled={settings.injectTalerSupport!} + onToggle={safely("update support injection", async () => { + updateSettings("injectTalerSupport", !settings.injectTalerSupport); + })} + /> + + <Paper style={{ padding: 10, margin: 10 }}> <h3>Logging</h3> <div> @@ -396,92 +397,98 @@ export function View({ operations, coins, onDownloadDatabase }: Props): VNode { Set log level </Button> </Paper> - {downloadedDatabase && ( - <div> - <i18n.Translate> - Database exported at{" "} - <Time - timestamp={AbsoluteTime.fromMilliseconds( - downloadedDatabase.time.getTime(), - )} - format="yyyy/MM/dd HH:mm:ss" - />{" "} - <a - href={`data:text/plain;charset=utf-8;base64,${toBase64( - downloadedDatabase.content, - )}`} - download={`taler-wallet-database-${format( - downloadedDatabase.time, - "yyyy/MM/dd_HH:mm", - )}.json`} - > - <i18n.Translate>click here</i18n.Translate> - </a>{" "} - to download - </i18n.Translate> - </div> - )} + { + downloadedDatabase && ( + <div> + <i18n.Translate> + Database exported at{" "} + <Time + timestamp={AbsoluteTime.fromMilliseconds( + downloadedDatabase.time.getTime(), + )} + format="yyyy/MM/dd HH:mm:ss" + />{" "} + <a + href={`data:text/plain;charset=utf-8;base64,${toBase64( + downloadedDatabase.content, + )}`} + download={`taler-wallet-database-${format( + downloadedDatabase.time, + "yyyy/MM/dd_HH:mm", + )}.json`} + > + <i18n.Translate>click here</i18n.Translate> + </a>{" "} + to download + </i18n.Translate> + </div> + ) + } <br /> <p> <i18n.Translate>Coins</i18n.Translate>: </p> - {Object.keys(money_by_exchange).map((ex, idx) => { - const allcoins = money_by_exchange[ex]; - allcoins.sort((a, b) => { - if (b.denom_value !== a.denom_value) { - return b.denom_value - a.denom_value; - } - return b.denom_fraction - a.denom_fraction; - }); + { + Object.keys(money_by_exchange).map((ex, idx) => { + const allcoins = money_by_exchange[ex]; + allcoins.sort((a, b) => { + if (b.denom_value !== a.denom_value) { + return b.denom_value - a.denom_value; + } + return b.denom_fraction - a.denom_fraction; + }); - const coins = allcoins.reduce( - (prev, cur) => { - if (cur.status === CoinStatus.Fresh) prev.usable.push(cur); - if (cur.status === CoinStatus.Dormant) prev.spent.push(cur); - return prev; - }, - { - spent: [], - usable: [], - } as SplitedCoinInfo, - ); + const coins = allcoins.reduce( + (prev, cur) => { + if (cur.status === CoinStatus.Fresh) prev.usable.push(cur); + if (cur.status === CoinStatus.Dormant) prev.spent.push(cur); + return prev; + }, + { + spent: [], + usable: [], + } as SplitedCoinInfo, + ); - return ( - <ShowAllCoins - key={idx} - coins={coins} - ex={ex} - currencies={currencies} - /> - ); - })} + return ( + <ShowAllCoins + key={idx} + coins={coins} + ex={ex} + currencies={currencies} + /> + ); + }) + } <br /> - {operations && operations.length > 0 && ( - <Fragment> - <p> - <i18n.Translate>Pending operations</i18n.Translate> - </p> - <dl> - {operations.reverse().map((o) => { - return ( - <NotifyUpdateFadeOut key={hashObjectId(o)}> - <dt> - {o.type}{" "} - <Time - timestamp={o.timestampDue} - format="yy/MM/dd HH:mm:ss" - /> - </dt> - <dd> - <pre>{JSON.stringify(o, undefined, 2)}</pre> - </dd> - </NotifyUpdateFadeOut> - ); - })} - </dl> - </Fragment> - )} - </div> + { + operations && operations.length > 0 && ( + <Fragment> + <p> + <i18n.Translate>Pending operations</i18n.Translate> + </p> + <dl> + {operations.reverse().map((o) => { + return ( + <NotifyUpdateFadeOut key={hashObjectId(o)}> + <dt> + {o.type}{" "} + <Time + timestamp={o.timestampDue} + format="yy/MM/dd HH:mm:ss" + /> + </dt> + <dd> + <pre>{JSON.stringify(o, undefined, 2)}</pre> + </dd> + </NotifyUpdateFadeOut> + ); + })} + </dl> + </Fragment> + ) + } + </div > ); } diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx index 86c420b91..a5d6972de 100644 --- a/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx @@ -55,7 +55,6 @@ export const AllOff = tests.createExample(TestedComponent, { deviceName: "this-is-the-device-name", advanceToggle: { value: false, button: {} }, autoOpenToggle: { value: false, button: {} }, - injectTalerToggle: { value: false, button: {} }, langToggle: { value: false, button: {} }, setDeviceName: () => Promise.resolve(), ...version, @@ -65,7 +64,6 @@ export const OneChecked = tests.createExample(TestedComponent, { deviceName: "this-is-the-device-name", advanceToggle: { value: false, button: {} }, autoOpenToggle: { value: false, button: {} }, - injectTalerToggle: { value: false, button: {} }, langToggle: { value: false, button: {} }, setDeviceName: () => Promise.resolve(), ...version, @@ -75,7 +73,6 @@ export const WithOneExchange = tests.createExample(TestedComponent, { deviceName: "this-is-the-device-name", advanceToggle: { value: false, button: {} }, autoOpenToggle: { value: false, button: {} }, - injectTalerToggle: { value: false, button: {} }, langToggle: { value: false, button: {} }, setDeviceName: () => Promise.resolve(), knownExchanges: [ @@ -100,7 +97,6 @@ export const WithExchangeInDifferentState = tests.createExample( deviceName: "this-is-the-device-name", advanceToggle: { value: false, button: {} }, autoOpenToggle: { value: false, button: {} }, - injectTalerToggle: { value: false, button: {} }, langToggle: { value: false, button: {} }, setDeviceName: () => Promise.resolve(), knownExchanges: [ diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.tsx index b27413a96..e25629148 100644 --- a/packages/taler-wallet-webextension/src/wallet/Settings.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Settings.tsx @@ -80,14 +80,6 @@ export function SettingsPage(): VNode { }), }, }} - injectTalerToggle={{ - value: settings.injectTalerSupport, - button: { - onClick: safely("update support injection", async () => { - updateSettings("injectTalerSupport", !settings.injectTalerSupport); - }), - }, - }} advanceToggle={{ value: settings.advanceMode, button: { @@ -117,7 +109,6 @@ export interface ViewProps { deviceName: string; setDeviceName: (s: string) => Promise<void>; autoOpenToggle: ToggleHandler; - injectTalerToggle: ToggleHandler; advanceToggle: ToggleHandler; langToggle: ToggleHandler; knownExchanges: Array<ExchangeListItem>; @@ -131,7 +122,6 @@ export interface ViewProps { export function SettingsView({ knownExchanges, autoOpenToggle, - injectTalerToggle, advanceToggle, langToggle, coreVersion, @@ -276,19 +266,6 @@ export function SettingsView({ <i18n.Translate>Navigator</i18n.Translate> </SubTitle> <Checkbox - label={i18n.str`Inject Taler support in all pages`} - name="inject" - description={ - <i18n.Translate> - Disabling this option will make some web application not able to - trigger the wallet when clicking links but you will be able to - open the wallet using the keyboard shortcut - </i18n.Translate> - } - enabled={injectTalerToggle.value!} - onToggle={injectTalerToggle.button.onClick!} - /> - <Checkbox label={i18n.str`Automatically open wallet`} name="autoOpen" description={ diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts index 8fb8211ae..d989c9662 100644 --- a/packages/taler-wallet-webextension/src/wxApi.ts +++ b/packages/taler-wallet-webextension/src/wxApi.ts @@ -83,14 +83,6 @@ export interface BackgroundOperations { }; response: void; }; - containsHeaderListener: { - request: void; - response: ExtendedPermissionsResponse; - }; - toggleHeaderListener: { - request: boolean; - response: ExtendedPermissionsResponse; - }; } export interface BackgroundApiClient { @@ -194,7 +186,7 @@ function onUpdateNotification( return; }; const onNewMessage = (message: MessageFromBackend): void => { - const shouldNotify = messageTypes.includes(message.type); + const shouldNotify = message.type === "wallet" && messageTypes.includes(message.notification.type); if (shouldNotify) { doCallback(); } diff --git a/packages/taler-wallet-webextension/src/wxBackend.ts b/packages/taler-wallet-webextension/src/wxBackend.ts index 1ecd66f05..95d31c519 100644 --- a/packages/taler-wallet-webextension/src/wxBackend.ts +++ b/packages/taler-wallet-webextension/src/wxBackend.ts @@ -122,18 +122,18 @@ async function sum(ns: Array<number>): Promise<number> { } const extensionHandlers: ExtensionHandlerType = { - isInjectionEnabled, isAutoOpenEnabled, + isDomainTrusted, }; -async function isInjectionEnabled(): Promise<boolean> { +async function isAutoOpenEnabled(): Promise<boolean> { const settings = await platform.getSettingsFromStorage(); - return settings.injectTalerSupport === true; + return settings.autoOpen === true; } -async function isAutoOpenEnabled(): Promise<boolean> { +async function isDomainTrusted(): Promise<boolean> { const settings = await platform.getSettingsFromStorage(); - return settings.autoOpen === true; + return settings.injectTalerSupport === true; } const backendHandlers: BackendHandlerType = { @@ -142,14 +142,8 @@ const backendHandlers: BackendHandlerType = { resetDb, runGarbageCollector, setLoggingLevel, - containsHeaderListener, - toggleHeaderListener, }; -async function containsHeaderListener(): Promise<ExtendedPermissionsResponse> { - const result = platform.containsTalerHeaderListener(); - return { newValue: result }; -} async function setLoggingLevel({ tag, @@ -309,8 +303,10 @@ async function reinitWallet(): Promise<void> { return; } wallet.addNotificationListener((message) => { - logger.info("wallet -> ui", message); - platform.sendMessageToAllChannels(message); + platform.sendMessageToAllChannels({ + type: "wallet", + notification: message + }); }); platform.keepAlive(() => { @@ -360,65 +356,6 @@ export async function wxMain(): Promise<void> { console.error(e); } - // platform.registerDeclarativeRedirect(); - // if (false) { - /** - * this is not working reliable on chrome, just - * intercepts queries after the user clicks the popups - * which doesn't make sense, keeping it to make more tests - */ - - logger.trace("check taler header listener"); - const enabled = platform.containsTalerHeaderListener() - if (!enabled) { - logger.info("header listener on") - const perm = await platform.getPermissionsApi().containsHostPermissions() - if (perm) { - logger.info("header listener allowed") - try { - platform.registerTalerHeaderListener(); - } catch (e) { - logger.error("could not register header listener", e); - } - } else { - logger.info("header listener requested") - await platform.getPermissionsApi().requestHostPermissions() - } - } - - // On platforms that support it, also listen to external - // modification of permissions. - platform.getPermissionsApi().addPermissionsListener((perm, lastError) => { - logger.info(`permission added: ${perm}`,) - if (lastError) { - logger.error( - `there was a problem trying to get permission ${perm}`, - lastError, - ); - return; - } - platform.registerTalerHeaderListener(); - }); - - // } } -async function toggleHeaderListener( - newVal: boolean, -): Promise<ExtendedPermissionsResponse> { - logger.trace("new extended permissions value", newVal); - if (newVal) { - try { - platform.registerTalerHeaderListener(); - return { newValue: true }; - } catch (e) { - logger.error("FAIL to toggle", e) - } - return { newValue: false } - } - - const rem = await platform.getPermissionsApi().removeHostPermissions(); - logger.trace("permissions removed:", rem); - return { newValue: false }; -} |