/*
This file is part of GNU Taler
(C) 2022 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
*/
/**
* Messaging for the WebExtensions wallet. Should contain
* parts that are specific for WebExtensions, but as little business
* logic as possible.
*/
/**
* Imports.
*/
import {
AbsoluteTime,
LogLevel,
Logger,
NotificationType,
OpenedPromise,
SetTimeoutTimerAPI,
TalerError,
TalerErrorCode,
TalerErrorDetail,
TransactionMinorState,
WalletNotification,
getErrorDetailFromException,
makeErrorDetail,
openPromise,
setGlobalLogLevelFromString,
setLogLevelFromString,
} from "@gnu-taler/taler-util";
import { HttpRequestLibrary } from "@gnu-taler/taler-util/http";
import {
DbAccess,
SynchronousCryptoWorkerFactoryPlain,
Wallet,
WalletApiOperation,
WalletOperations,
WalletStoresV1,
deleteTalerDatabase,
exportDb,
importDb,
} from "@gnu-taler/taler-wallet-core";
import { BrowserFetchHttpLib } from "@gnu-taler/web-util/browser";
import { MessageFromFrontend, MessageResponse } from "./platform/api.js";
import { platform } from "./platform/background.js";
import { ExtensionOperations } from "./taler-wallet-interaction-loader.js";
import { BackgroundOperations } from "./wxApi.js";
/**
* Currently active wallet instance. Might be unloaded and
* re-instantiated when the database is reset.
*
* FIXME: Maybe move the wallet resetting into the Wallet class?
*/
let currentWallet: Wallet | undefined;
let currentDatabase: DbAccess | undefined;
const walletInit: OpenedPromise = openPromise();
const logger = new Logger("wxBackend.ts");
type BackendHandlerType = {
[Op in keyof BackgroundOperations]: (
req: BackgroundOperations[Op]["request"],
) => Promise;
};
type ExtensionHandlerType = {
[Op in keyof ExtensionOperations]: (
req: ExtensionOperations[Op]["request"],
) => Promise;
};
async function resetDb(): Promise {
await deleteTalerDatabase(indexedDB as any);
await reinitWallet();
}
export type WalletActivityTrack = {
id: number;
events: (WalletNotification & { when: AbsoluteTime })[];
start: AbsoluteTime;
type: NotificationType;
end: AbsoluteTime;
groupId: string;
};
let counter = 0;
function getUniqueId(): number {
return counter++;
}
//FIXME: maybe circular buffer
const activity: WalletActivityTrack[] = [];
function convertWalletActivityNotification(
knownEvents: WalletActivityTrack[],
event: WalletNotification & {
when: AbsoluteTime;
},
): WalletActivityTrack | undefined {
switch (event.type) {
case NotificationType.BalanceChange: {
const groupId = `${event.type}:${event.hintTransactionId}`;
const found = knownEvents.find((a) => a.groupId === groupId);
if (found) {
found.end = event.when;
found.events.unshift(event);
return found;
}
return {
id: getUniqueId(),
type: event.type,
start: event.when,
end: AbsoluteTime.never(),
events: [event],
groupId,
};
}
case NotificationType.BackupOperationError: {
const groupId = "";
return {
id: getUniqueId(),
type: event.type,
start: event.when,
end: AbsoluteTime.never(),
events: [event],
groupId,
};
}
case NotificationType.TransactionStateTransition: {
const groupId = `${event.type}:${event.transactionId}`;
const found = knownEvents.find((a) => a.groupId === groupId);
if (found) {
found.end = event.when;
found.events.unshift(event);
return found;
}
return {
id: getUniqueId(),
type: event.type,
start: event.when,
end: AbsoluteTime.never(),
events: [event],
groupId,
};
}
case NotificationType.WithdrawalOperationTransition: {
return undefined;
}
case NotificationType.ExchangeStateTransition: {
const groupId = `${event.type}:${event.exchangeBaseUrl}`;
const found = knownEvents.find((a) => a.groupId === groupId);
if (found) {
found.end = event.when;
found.events.unshift(event);
return found;
}
return {
id: getUniqueId(),
type: event.type,
start: event.when,
end: AbsoluteTime.never(),
events: [event],
groupId,
};
}
case NotificationType.Idle: {
const groupId = "";
return({
id: getUniqueId(),
type: event.type,
start: event.when,
end: AbsoluteTime.never(),
events: [event],
groupId,
});
}
case NotificationType.TaskObservabilityEvent: {
const groupId = `${event.type}:${event.taskId}`;
const found = knownEvents.find((a) => a.groupId === groupId);
if (found) {
found.end = event.when;
found.events.unshift(event);
return found;
}
return({
id: getUniqueId(),
type: event.type,
start: event.when,
end: AbsoluteTime.never(),
events: [event],
groupId,
});
}
case NotificationType.RequestObservabilityEvent: {
const groupId = `${event.type}:${event.operation}:${event.requestId}`;
const found = knownEvents.find((a) => a.groupId === groupId);
if (found) {
found.end = event.when;
found.events.unshift(event);
return found;
}
return({
id: getUniqueId(),
type: event.type,
start: event.when,
end: AbsoluteTime.never(),
events: [event],
groupId,
});
}
}
}
function addNewWalletActivityNotification(
list: WalletActivityTrack[],
n: WalletNotification,
) {
const start = AbsoluteTime.now();
const ev = { ...n, when: start };
const activity = convertWalletActivityNotification(list, ev);
if (activity) {
list.unshift(activity); // insert at start
}
}
async function getNotifications({
filter,
}: {
filter: string;
}): Promise {
if (!filter) return activity;
const rg = new RegExp(`.*${filter}.*`);
return activity.filter((event) => {
return rg.test(event.groupId.toLowerCase());
});
}
async function clearNotifications(): Promise {
activity.splice(0, activity.length);
}
async function runGarbageCollector(): Promise {
const dbBeforeGc = currentDatabase;
if (!dbBeforeGc) {
throw Error("no current db before running gc");
}
const dump = await exportDb(indexedDB as any);
await deleteTalerDatabase(indexedDB as any);
logger.info("cleaned");
await reinitWallet();
logger.info("init");
const dbAfterGc = currentDatabase;
if (!dbAfterGc) {
throw Error("no current db before running gc");
}
await importDb(dbAfterGc.idbHandle(), dump);
logger.info("imported");
}
const extensionHandlers: ExtensionHandlerType = {
isAutoOpenEnabled,
isDomainTrusted,
};
async function isAutoOpenEnabled(): Promise {
const settings = await platform.getSettingsFromStorage();
return settings.autoOpen === true;
}
async function isDomainTrusted(): Promise {
const settings = await platform.getSettingsFromStorage();
return settings.injectTalerSupport === true;
}
const backendHandlers: BackendHandlerType = {
resetDb,
runGarbageCollector,
getNotifications,
clearNotifications,
reinitWallet,
setLoggingLevel,
};
async function setLoggingLevel({
tag,
level,
}: {
tag?: string;
level: LogLevel;
}): Promise {
logger.info(`setting ${tag} to ${level}`);
if (!tag) {
setGlobalLogLevelFromString(level);
} else {
setLogLevelFromString(tag, level);
}
}
let nextMessageIndex = 0;
async function dispatch<
Op extends WalletOperations | BackgroundOperations | ExtensionOperations,
>(req: MessageFromFrontend & { id: string }): Promise {
nextMessageIndex = (nextMessageIndex + 1) % (Number.MAX_SAFE_INTEGER - 100);
switch (req.channel) {
case "background": {
const handler = backendHandlers[req.operation] as (req: any) => any;
if (!handler) {
return {
type: "error",
id: req.id,
operation: String(req.operation),
error: getErrorDetailFromException(
Error(`unknown background operation`),
),
};
}
try {
const result = await handler(req.payload);
return {
type: "response",
id: req.id,
operation: String(req.operation),
result,
};
} catch (er) {
return {
type: "error",
id: req.id,
error: getErrorDetailFromException(er),
operation: String(req.operation),
};
}
}
case "extension": {
const handler = extensionHandlers[req.operation] as (req: any) => any;
if (!handler) {
return {
type: "error",
id: req.id,
operation: String(req.operation),
error: getErrorDetailFromException(
Error(`unknown extension operation`),
),
};
}
try {
const result = await handler(req.payload);
return {
type: "response",
id: req.id,
operation: String(req.operation),
result,
};
} catch (er) {
return {
type: "error",
id: req.id,
error: getErrorDetailFromException(er),
operation: String(req.operation),
};
}
}
case "wallet": {
const w = currentWallet;
if (!w) {
const lastError: TalerErrorDetail =
walletInit.lastError instanceof TalerError
? walletInit.lastError.errorDetail
: undefined;
return {
type: "error",
id: req.id,
operation: req.operation,
error: makeErrorDetail(
TalerErrorCode.WALLET_CORE_NOT_AVAILABLE,
{ lastError },
`wallet core not available${
!lastError ? "" : `,last error: ${lastError.hint}`
}`,
),
};
}
//multiple client can create the same id, send the wallet an unique key
const newId = `${req.id}_${nextMessageIndex}`;
const resp = await w.handleCoreApiRequest(
req.operation,
newId,
req.payload,
);
//return to the client the original id
resp.id = req.id;
return resp;
}
}
const anyReq = req as any;
return {
type: "error",
id: anyReq.id,
operation: String(anyReq.operation),
error: getErrorDetailFromException(
Error(
`unknown channel ${anyReq.channel}, should be "background", "extension" or "wallet"`,
),
),
};
}
async function reinitWallet(): Promise {
if (currentWallet) {
await currentWallet.client.call(WalletApiOperation.Shutdown, {});
currentWallet = undefined;
}
currentDatabase = undefined;
// setBadgeText({ text: "" });
let cryptoWorker;
let timer;
const httpFactory = (): HttpRequestLibrary => {
return new BrowserFetchHttpLib({
// enableThrottling: false,
});
};
if (platform.useServiceWorkerAsBackgroundProcess()) {
cryptoWorker = new SynchronousCryptoWorkerFactoryPlain();
timer = new SetTimeoutTimerAPI();
} else {
// We could (should?) use the BrowserCryptoWorkerFactory here,
// but right now we don't, to have less platform differences.
// cryptoWorker = new BrowserCryptoWorkerFactory();
cryptoWorker = new SynchronousCryptoWorkerFactoryPlain();
timer = new SetTimeoutTimerAPI();
}
const settings = await platform.getSettingsFromStorage();
logger.info("Setting up wallet");
const wallet = await Wallet.create(
indexedDB as any,
httpFactory as any,
timer,
cryptoWorker,
);
try {
await wallet.handleCoreApiRequest("initWallet", "native-init", {
config: {
testing: {
emitObservabilityEvents: settings.showWalletActivity,
devModeActive: settings.advancedMode,
},
features: {
allowHttp: settings.walletAllowHttp,
},
},
});
} catch (e) {
logger.error("could not initialize wallet", e);
walletInit.reject(e);
return;
}
wallet.addNotificationListener((message) => {
if (settings.showWalletActivity) {
addNewWalletActivityNotification(activity, message);
}
processWalletNotification(message);
platform.sendMessageToAllChannels({
type: "wallet",
notification: message,
});
});
// Useful for debugging in the background page.
if (typeof window !== "undefined") {
(window as any).talerWallet = wallet;
}
currentWallet = wallet;
updateIconBasedOnBalance();
return walletInit.resolve();
}
/**
* Main function to run for the WebExtension backend.
*
* Sets up all event handlers and other machinery.
*/
export async function wxMain(): Promise {
logger.trace("starting");
const afterWalletIsInitialized = reinitWallet();
logger.trace("reload on new version");
platform.registerReloadOnNewVersion();
// Handlers for messages coming directly from the content
// script on the page
logger.trace("listen all channels");
platform.listenToAllChannels(async (message) => {
//wait until wallet is initialized
await afterWalletIsInitialized;
const result = await dispatch(message);
return result;
});
logger.trace("register all incoming connections");
platform.registerAllIncomingConnections();
logger.trace("redirect if first start");
try {
platform.registerOnInstalled(() => {
platform.openWalletPage("/welcome");
});
} catch (e) {
console.error(e);
}
}
async function updateIconBasedOnBalance() {
const balance = await currentWallet?.client.call(
WalletApiOperation.GetBalances,
{},
);
if (balance) {
let showAlert = false;
for (const b of balance.balances) {
if (b.flags.length > 0) {
console.log("b.flags", JSON.stringify(b.flags));
showAlert = true;
break;
}
}
if (showAlert) {
platform.setAlertedIcon();
} else {
platform.setNormalIcon();
}
}
}
/**
* All the actions triggered by notification that need to be
* run in the background.
*
* @param message
*/
async function processWalletNotification(message: WalletNotification) {
if (
message.type === NotificationType.TransactionStateTransition &&
(message.newTxState.minor === TransactionMinorState.KycRequired ||
message.oldTxState.minor === TransactionMinorState.KycRequired ||
message.newTxState.minor === TransactionMinorState.AmlRequired ||
message.oldTxState.minor === TransactionMinorState.AmlRequired ||
message.newTxState.minor === TransactionMinorState.BankConfirmTransfer ||
message.oldTxState.minor === TransactionMinorState.BankConfirmTransfer)
) {
await updateIconBasedOnBalance();
}
}