feat(tauri): Initialize Context in background (#59)

This PR does the following:
- The Context (including Bitcoin wallet, Monero wallet, ...) is initialized in the background. This allows the window to be displayed instantly upon startup.
- Host sends events to Guest about progress of Context initialization. Those events are used to display an alert in the navigation bar.
- If a Tauri command is invoked which requires the Context to be available, an error will be returned
- As soon as the Context becomes available the `Guest` requests the history and Bitcoin balance
- Re-enables Material UI animations
This commit is contained in:
binarybaron 2024-09-03 12:28:30 +02:00 committed by GitHub
parent 792fbbf746
commit e4141c763b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 369 additions and 191 deletions

View file

@ -26,19 +26,11 @@ const theme = createTheme({
},
secondary: indigo,
},
transitions: {
create: () => "none",
},
props: {
MuiButtonBase: {
disableRipple: true,
},
},
typography: {
overline: {
textTransform: 'none', // This prevents the text from being all caps
textTransform: "none", // This prevents the text from being all caps
},
}
},
});
function InnerContent() {

View file

@ -1,33 +1,44 @@
import { Button, ButtonProps, IconButton } from "@material-ui/core";
import {
Button,
ButtonProps,
IconButton,
IconButtonProps,
Tooltip,
} from "@material-ui/core";
import CircularProgress from "@material-ui/core/CircularProgress";
import { useSnackbar } from "notistack";
import { ReactNode, useState } from "react";
import { useIsContextAvailable } from "store/hooks";
interface PromiseInvokeButtonProps<T> {
onSuccess?: (data: T) => void;
onSuccess: (data: T) => void | null;
onClick: () => Promise<T>;
onPendingChange?: (isPending: boolean) => void;
isLoadingOverride?: boolean;
isIconButton?: boolean;
loadIcon?: ReactNode;
disabled?: boolean;
displayErrorSnackbar?: boolean;
tooltipTitle?: string;
onPendingChange: (isPending: boolean) => void | null;
isLoadingOverride: boolean;
isIconButton: boolean;
loadIcon: ReactNode;
disabled: boolean;
displayErrorSnackbar: boolean;
tooltipTitle: string | null;
requiresContext: boolean;
}
export default function PromiseInvokeButton<T>({
disabled,
onSuccess,
disabled = false,
onSuccess = null,
onClick,
endIcon,
loadIcon,
isLoadingOverride,
isIconButton,
displayErrorSnackbar,
onPendingChange,
loadIcon = null,
isLoadingOverride = false,
isIconButton = false,
displayErrorSnackbar = false,
onPendingChange = null,
requiresContext = true,
tooltipTitle = null,
...rest
}: ButtonProps & PromiseInvokeButtonProps<T>) {
const { enqueueSnackbar } = useSnackbar();
const isContextAvailable = useIsContextAvailable();
const [isPending, setIsPending] = useState(false);
@ -36,7 +47,7 @@ export default function PromiseInvokeButton<T>({
? loadIcon || <CircularProgress size={24} />
: endIcon;
async function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
async function handleClick() {
if (!isPending) {
try {
onPendingChange?.(true);
@ -57,18 +68,34 @@ export default function PromiseInvokeButton<T>({
}
}
const isDisabled = disabled || isLoading;
const requiresContextButNotAvailable = requiresContext && !isContextAvailable;
const isDisabled = disabled || isLoading || requiresContextButNotAvailable;
return isIconButton ? (
<IconButton onClick={handleClick} disabled={isDisabled} {...(rest as any)}>
{actualEndIcon}
</IconButton>
) : (
<Button
onClick={handleClick}
disabled={isDisabled}
endIcon={actualEndIcon}
{...rest}
/>
const actualTooltipTitle =
(requiresContextButNotAvailable
? "Wait for the application to load all required components"
: tooltipTitle) ?? "";
return (
<Tooltip title={actualTooltipTitle}>
<span>
{isIconButton ? (
<IconButton
onClick={handleClick}
disabled={isDisabled}
{...(rest as IconButtonProps)}
>
{actualEndIcon}
</IconButton>
) : (
<Button
onClick={handleClick}
disabled={isDisabled}
endIcon={actualEndIcon}
{...rest}
/>
)}
</span>
</Tooltip>
);
}

View file

@ -0,0 +1,76 @@
import { CircularProgress } from "@material-ui/core";
import { Alert, AlertProps } from "@material-ui/lab";
import { TauriContextInitializationProgress } from "models/tauriModel";
import { useState } from "react";
import { useAppSelector } from "store/hooks";
import { exhaustiveGuard } from "utils/typescriptUtils";
const FUNNY_INIT_MESSAGES = [
"Initializing quantum entanglement...",
"Generating one-time pads from cosmic background radiation...",
"Negotiating key exchange with aliens...",
"Optimizing elliptic curves for maximum sneakiness...",
"Transforming plaintext into ciphertext via arcane XOR rituals...",
"Salting your hash with exotic mathematical seasonings...",
"Performing advanced modular arithmetic gymnastics...",
"Consulting the Oracle of Randomness...",
"Executing top-secret permutation protocols...",
"Summoning prime factors from the mathematical aether...",
"Deploying steganographic squirrels to hide your nuts of data...",
"Initializing the quantum superposition of your keys...",
"Applying post-quantum cryptographic voodoo...",
"Encrypting your data with the tears of frustrated regulators...",
];
function LoadingSpinnerAlert({ ...rest }: AlertProps) {
return <Alert icon={<CircularProgress size={22} />} {...rest} />;
}
export default function DaemonStatusAlert() {
const contextStatus = useAppSelector((s) => s.rpc.status);
const [initMessage] = useState(
FUNNY_INIT_MESSAGES[Math.floor(Math.random() * FUNNY_INIT_MESSAGES.length)],
);
if (contextStatus == null) {
return (
<LoadingSpinnerAlert severity="warning">
{initMessage}
</LoadingSpinnerAlert>
);
}
switch (contextStatus.type) {
case "Initializing":
switch (contextStatus.content) {
case TauriContextInitializationProgress.OpeningBitcoinWallet:
return (
<LoadingSpinnerAlert severity="warning">
Connecting to the Bitcoin network
</LoadingSpinnerAlert>
);
case TauriContextInitializationProgress.OpeningMoneroWallet:
return (
<LoadingSpinnerAlert severity="warning">
Connecting to the Monero network
</LoadingSpinnerAlert>
);
case TauriContextInitializationProgress.OpeningDatabase:
return (
<LoadingSpinnerAlert severity="warning">
Opening the local database
</LoadingSpinnerAlert>
);
}
break;
case "Available":
return <Alert severity="success">The daemon is running</Alert>;
case "Failed":
return (
<Alert severity="error">The daemon has stopped unexpectedly</Alert>
);
default:
return exhaustiveGuard(contextStatus);
}
}

View file

@ -1,30 +0,0 @@
import { CircularProgress } from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import { RpcProcessStateType } from "models/rpcModel";
import { useAppSelector } from "store/hooks";
// TODO: Reimplement this using Tauri
// Currently the RPC process is always available, so this component is not needed
// since the UI is only displayed when the RPC process is available
export default function RpcStatusAlert() {
const rpcProcess = useAppSelector((s) => s.rpc.process);
if (rpcProcess.type === RpcProcessStateType.STARTED) {
return (
<Alert severity="warning" icon={<CircularProgress size={22} />}>
The swap daemon is starting
</Alert>
);
}
if (rpcProcess.type === RpcProcessStateType.LISTENING_FOR_CONNECTIONS) {
return <Alert severity="success">The swap daemon is running</Alert>;
}
if (rpcProcess.type === RpcProcessStateType.NOT_STARTED) {
return <Alert severity="warning">The swap daemon is being started</Alert>;
}
if (rpcProcess.type === RpcProcessStateType.EXITED) {
return (
<Alert severity="error">The swap daemon has stopped unexpectedly</Alert>
);
}
return <></>;
}

View file

@ -72,6 +72,7 @@ export default function InitPage() {
className={classes.initButton}
endIcon={<PlayArrowIcon />}
onClick={init}
displayErrorSnackbar
>
Start swap
</PromiseInvokeButton>

View file

@ -1,6 +1,7 @@
import { Box, makeStyles } from "@material-ui/core";
import GitHubIcon from "@material-ui/icons/GitHub";
import RedditIcon from "@material-ui/icons/Reddit";
import DaemonStatusAlert from "../alert/DaemonStatusAlert";
import FundsLeftInWalletAlert from "../alert/FundsLeftInWalletAlert";
import MoneroWalletRpcUpdatingAlert from "../alert/MoneroWalletRpcUpdatingAlert";
import UnfinishedSwapsAlert from "../alert/UnfinishedSwapsAlert";
@ -28,11 +29,7 @@ export default function NavigationFooter() {
<Box className={classes.outer}>
<FundsLeftInWalletAlert />
<UnfinishedSwapsAlert />
{
// TODO: Uncomment when we have implemented a way for the UI to be displayed before the context has been initialized
// <RpcStatusAlert />
}
<DaemonStatusAlert />
<MoneroWalletRpcUpdatingAlert />
<Box className={classes.linksOuter}>
<LinkIconButton url="https://reddit.com/r/unstoppableswap">

View file

@ -2,9 +2,8 @@ import { Box, makeStyles } from "@material-ui/core";
import FolderOpenIcon from "@material-ui/icons/FolderOpen";
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import StopIcon from "@material-ui/icons/Stop";
import { RpcProcessStateType } from "models/rpcModel";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { useAppSelector } from "store/hooks";
import { useIsContextAvailable } from "store/hooks";
import InfoBox from "../../modal/swap/InfoBox";
import CliLogsBox from "../../other/RenderedCliLog";
@ -17,20 +16,17 @@ const useStyles = makeStyles((theme) => ({
}));
export default function RpcControlBox() {
const rpcProcess = useAppSelector((state) => state.rpc.process);
const isRunning =
rpcProcess.type === RpcProcessStateType.STARTED ||
rpcProcess.type === RpcProcessStateType.LISTENING_FOR_CONNECTIONS;
const isRunning = useIsContextAvailable();
const classes = useStyles();
return (
<InfoBox
title={`Swap Daemon (${rpcProcess.type})`}
title={`Daemon Controller`}
mainContent={
isRunning || rpcProcess.type === RpcProcessStateType.EXITED ? (
isRunning ? (
<CliLogsBox
label="Swap Daemon Logs (current session only)"
logs={rpcProcess.logs}
logs={[]}
/>
) : null
}

View file

@ -1,21 +1,21 @@
import {
Box,
Link,
makeStyles,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Box,
Link,
makeStyles,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
} from "@material-ui/core";
import { OpenInNew } from "@material-ui/icons";
import { GetSwapInfoResponse } from "models/tauriModel";
import CopyableMonospaceTextBox from "renderer/components/other/CopyableAddress";
import MonospaceTextBox from "renderer/components/other/InlineCode";
import CopyableMonospaceTextBox from "renderer/components/other/CopyableMonospaceTextBox";
import MonospaceTextBox from "renderer/components/other/MonospaceTextBox";
import {
MoneroBitcoinExchangeRate,
PiconeroAmount,
SatsAmount,
MoneroBitcoinExchangeRate,
PiconeroAmount,
SatsAmount,
} from "renderer/components/other/Units";
import { isTestnet } from "store/config";
import { getBitcoinTxExplorerUrl } from "utils/conversionUtils";

View file

@ -12,12 +12,16 @@ import {
fetchXmrPrice,
} from "./api";
import App from "./components/App";
import { checkBitcoinBalance, getRawSwapInfos } from "./rpc";
import {
checkBitcoinBalance,
getAllSwapInfos,
initEventListeners,
} from "./rpc";
import { persistor, store } from "./store/storeRenderer";
setInterval(() => {
checkBitcoinBalance();
getRawSwapInfos();
getAllSwapInfos();
}, 30 * 1000);
const container = document.getElementById("root");

View file

@ -9,11 +9,16 @@ import {
ResumeSwapArgs,
ResumeSwapResponse,
SuspendCurrentSwapResponse,
TauriContextStatusEvent,
TauriSwapProgressEventWrapper,
WithdrawBtcArgs,
WithdrawBtcResponse,
} from "models/tauriModel";
import { rpcSetBalance, rpcSetSwapInfo } from "store/features/rpcSlice";
import {
contextStatusEventReceived,
rpcSetBalance,
rpcSetSwapInfo,
} from "store/features/rpcSlice";
import { swapTauriEventReceived } from "store/features/swapSlice";
import { store } from "./store/storeRenderer";
import { Provider } from "models/apiModel";
@ -24,6 +29,11 @@ export async function initEventListeners() {
console.log("Received swap progress event", event.payload);
store.dispatch(swapTauriEventReceived(event.payload));
});
listen<TauriContextStatusEvent>("context-init-progress-update", (event) => {
console.log("Received context init progress event", event.payload);
store.dispatch(contextStatusEventReceived(event.payload));
});
}
async function invoke<ARGS, RESPONSE>(
@ -47,7 +57,7 @@ export async function checkBitcoinBalance() {
store.dispatch(rpcSetBalance(response.balance));
}
export async function getRawSwapInfos() {
export async function getAllSwapInfos() {
const response =
await invokeNoArgs<GetSwapInfoResponse[]>("get_swap_infos_all");

View file

@ -2,6 +2,7 @@ import { combineReducers, configureStore } from "@reduxjs/toolkit";
import { persistReducer, persistStore } from "redux-persist";
import sessionStorage from "redux-persist/lib/storage/session";
import { reducers } from "store/combinedReducer";
import { createMainListeners } from "store/middleware/storeListener";
// We persist the redux store in sessionStorage
// The point of this is to preserve the store across reloads while not persisting it across GUI restarts
@ -20,6 +21,8 @@ const persistedReducer = persistReducer(
export const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().prepend(createMainListeners().middleware),
});
export const persistor = persistStore(store);

View file

@ -1,32 +1,12 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ExtendedProviderStatus, ProviderStatus } from "models/apiModel";
import { GetSwapInfoResponse } from "models/tauriModel";
import { CliLog } from "../../models/cliModel";
import {
MoneroRecoveryResponse,
RpcProcessStateType,
} from "../../models/rpcModel";
GetSwapInfoResponse,
TauriContextStatusEvent,
} from "models/tauriModel";
import { MoneroRecoveryResponse } from "../../models/rpcModel";
import { GetSwapInfoResponseExt } from "models/tauriModelExt";
type Process =
| {
type: RpcProcessStateType.STARTED;
logs: (CliLog | string)[];
}
| {
type: RpcProcessStateType.LISTENING_FOR_CONNECTIONS;
logs: (CliLog | string)[];
address: string;
}
| {
type: RpcProcessStateType.EXITED;
logs: (CliLog | string)[];
exitCode: number | null;
}
| {
type: RpcProcessStateType.NOT_STARTED;
};
interface State {
balance: number | null;
withdrawTxId: string | null;
@ -48,15 +28,13 @@ interface State {
}
export interface RPCSlice {
process: Process;
status: TauriContextStatusEvent | null;
state: State;
busyEndpoints: string[];
}
const initialState: RPCSlice = {
process: {
type: RpcProcessStateType.NOT_STARTED,
},
status: null,
state: {
balance: null,
withdrawTxId: null,
@ -77,35 +55,11 @@ export const rpcSlice = createSlice({
name: "rpc",
initialState,
reducers: {
rpcInitiate(slice) {
slice.process = {
type: RpcProcessStateType.STARTED,
logs: [],
};
},
rpcProcessExited(
contextStatusEventReceived(
slice,
action: PayloadAction<{
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
}>,
action: PayloadAction<TauriContextStatusEvent>,
) {
if (
slice.process.type === RpcProcessStateType.STARTED ||
slice.process.type === RpcProcessStateType.LISTENING_FOR_CONNECTIONS
) {
slice.process = {
type: RpcProcessStateType.EXITED,
logs: slice.process.logs,
exitCode: action.payload.exitCode,
};
slice.state.moneroWalletRpc = {
updateState: false,
};
slice.state.moneroWallet = {
isSyncing: false,
};
}
slice.status = action.payload;
},
rpcSetBalance(slice, action: PayloadAction<number>) {
slice.state.balance = action.payload;
@ -156,8 +110,7 @@ export const rpcSlice = createSlice({
});
export const {
rpcProcessExited,
rpcInitiate,
contextStatusEventReceived,
rpcSetBalance,
rpcSetWithdrawTxId,
rpcResetWithdrawTxId,

View file

@ -23,6 +23,10 @@ export function useIsSwapRunning() {
);
}
export function useIsContextAvailable() {
return useAppSelector((state) => state.rpc.status?.type === "Available");
}
export function useSwapInfo(swapId: string | null) {
return useAppSelector((state) =>
swapId ? (state.rpc.state.swapInfos[swapId] ?? null) : null,

View file

@ -0,0 +1,28 @@
import { createListenerMiddleware } from "@reduxjs/toolkit";
import { getAllSwapInfos, checkBitcoinBalance } from "renderer/rpc";
import logger from "utils/logger";
import { contextStatusEventReceived } from "store/features/rpcSlice";
export function createMainListeners() {
const listener = createListenerMiddleware();
// Listener for when the Context becomes available
// When the context becomes available, we check the bitcoin balance and fetch all swap infos
listener.startListening({
actionCreator: contextStatusEventReceived,
effect: async (action) => {
const status = action.payload;
// If the context is available, check the bitcoin balance and fetch all swap infos
if (status.type === "Available") {
logger.debug(
"Context is available, checking bitcoin balance and history",
);
await checkBitcoinBalance();
await getAllSwapInfos();
}
},
});
return listener;
}