feat(gui, tauri): Save settings in Tauri storage (#102)

- Implemented dual persistence strategy:
  - **User Settings**: Persisted across app restarts using `tauri-plugin-store`.
  - **Transient State**: Persisted across page reloads using `sessionStorage`.
- Added `settingsSlice` reducer for managing persistent user settings.
- Updated Redux store configuration to handle multiple persistence layers.
- Added a new Settings page in the GUI where users can specify custom Electrum RPC URLs for Bitcoin and Monero node URLs.
  - Users can input their preferred Electrum server (`ssl://host:port`) and Monero daemon (`http://host:port`).
  - Input fields include validation to ensure correct URL formats.
  - Settings persist across application restarts using Tauri's storage plugin.
  - A reset option is available to revert to default settings.
- Improved the Daemon Controller in the Help page:
  - Renamed `RpcControlBox` to `DaemonControlBox` for clarity.
  - Users can now start the daemon manually if it isn't running or has failed.
  - Added a "Restart GUI" button to apply new settings immediately.
  - Displayed the daemon's status within the controller.
- Upgraded Tauri and related plugins to stable version `2.0.0`:
  - Updated `tauri`, `tauri-build`, and `tauri-utils` to `2.0.0`.
  - Ensured compatibility with the latest stable release.
- Updated Tauri plugins to version `2.0.0`:
  - `tauri-plugin-clipboard-manager`
  - `tauri-plugin-shell`
  - Added new plugins:
    - `tauri-plugin-store` for settings persistence.
    - `tauri-plugin-process` to enable application relaunch.
- Deferred Context initialization until explicitly triggered from the frontend.
  - Moved Context setup from the `setup` function to a new `initialize_context` Tauri command.
  - Allows the application to start without immediately initializing the backend context.
  - Context initialization now considers user-provided settings for Electrum and Monero nodes.
- Introduced a `ValidatedTextField` component for form inputs with validation logic.
  - Provides immediate feedback on input validity.
  - Used in the Settings page for Electrum and Monero node URLs.
- If the user provides an override Monero remote daemon, we check if it reachable and on the correct network before starting the `monero-wallet-rpc`
- Changed `bitcoin_confirmation_target` type from `usize` to `u16`.
This commit is contained in:
binarybaron 2024-10-08 16:57:01 +06:00 committed by GitHub
parent d4503a6e9c
commit 253e0b0cf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1124 additions and 451 deletions

View file

@ -1,11 +1,13 @@
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 PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { useAppSelector, useIsContextAvailable } from "store/hooks";
import { useAppSelector } from "store/hooks";
import InfoBox from "../../modal/swap/InfoBox";
import CliLogsBox from "../../other/RenderedCliLog";
import { initializeContext } from "renderer/rpc";
import { relaunch } from "@tauri-apps/plugin-process";
import RotateLeftIcon from "@material-ui/icons/RotateLeft";
const useStyles = makeStyles((theme) => ({
actionsOuter: {
@ -15,17 +17,26 @@ const useStyles = makeStyles((theme) => ({
},
}));
export default function RpcControlBox() {
const isRunning = useIsContextAvailable();
export default function DaemonControlBox() {
const classes = useStyles();
const logs = useAppSelector((s) => s.rpc.logs);
// The daemon can be manually started if it has failed or if it has not been started yet
const canContextBeManuallyStarted = useAppSelector(
(s) => s.rpc.status?.type === "Failed" || s.rpc.status === null,
);
const isContextInitializing = useAppSelector(
(s) => s.rpc.status?.type === "Initializing",
);
const stringifiedDaemonStatus = useAppSelector((s) => s.rpc.status?.type ?? "not started");
return (
<InfoBox
title={`Daemon Controller`}
title={`Daemon Controller (${stringifiedDaemonStatus})`}
mainContent={
<CliLogsBox
label="Swap Daemon Logs (current session only)"
label="Logs (current session only)"
logs={logs}
/>
}
@ -34,22 +45,22 @@ export default function RpcControlBox() {
<PromiseInvokeButton
variant="contained"
endIcon={<PlayArrowIcon />}
disabled={isRunning}
onInvoke={() => {
throw new Error("Not implemented");
}}
onInvoke={initializeContext}
requiresContext={false}
disabled={!canContextBeManuallyStarted}
isLoadingOverride={isContextInitializing}
displayErrorSnackbar
>
Start Daemon
</PromiseInvokeButton>
<PromiseInvokeButton
variant="contained"
endIcon={<StopIcon />}
disabled={!isRunning}
onInvoke={() => {
throw new Error("Not implemented");
}}
endIcon={<RotateLeftIcon />}
onInvoke={relaunch}
requiresContext={false}
displayErrorSnackbar
>
Stop Daemon
Restart GUI
</PromiseInvokeButton>
<PromiseInvokeButton
endIcon={<FolderOpenIcon />}
@ -57,6 +68,7 @@ export default function RpcControlBox() {
size="small"
tooltipTitle="Open the data directory of the Swap Daemon in your file explorer"
onInvoke={() => {
// TODO: Implement this
throw new Error("Not implemented");
}}
/>

View file

@ -2,14 +2,15 @@ import { Box, makeStyles } from "@material-ui/core";
import ContactInfoBox from "./ContactInfoBox";
import DonateInfoBox from "./DonateInfoBox";
import FeedbackInfoBox from "./FeedbackInfoBox";
import RpcControlBox from "./RpcControlBox";
import TorInfoBox from "./TorInfoBox";
import DaemonControlBox from "./DaemonControlBox";
import SettingsBox from "./SettingsBox";
const useStyles = makeStyles((theme) => ({
outer: {
display: "flex",
gap: theme.spacing(2),
flexDirection: "column",
paddingBottom: theme.spacing(2),
},
}));
@ -18,8 +19,8 @@ export default function HelpPage() {
return (
<Box className={classes.outer}>
<RpcControlBox />
<TorInfoBox />
<DaemonControlBox />
<SettingsBox />
<FeedbackInfoBox />
<ContactInfoBox />
<DonateInfoBox />

View file

@ -0,0 +1,153 @@
import {
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Typography,
IconButton,
Box,
makeStyles,
Tooltip,
} from "@material-ui/core";
import InfoBox from "renderer/components/modal/swap/InfoBox";
import {
resetSettings,
setElectrumRpcUrl,
setMoneroNodeUrl,
} from "store/features/settingsSlice";
import { useAppDispatch, useAppSelector } from "store/hooks";
import ValidatedTextField from "renderer/components/other/ValidatedTextField";
import RefreshIcon from "@material-ui/icons/Refresh";
import HelpIcon from '@material-ui/icons/HelpOutline';
import { ReactNode } from "react";
const PLACEHOLDER_ELECTRUM_RPC_URL = "ssl://blockstream.info:700";
const PLACEHOLDER_MONERO_NODE_URL = "http://xmr-node.cakewallet.com:18081";
const useStyles = makeStyles((theme) => ({
title: {
display: "flex",
alignItems: "center",
gap: theme.spacing(1),
}
}));
export default function SettingsBox() {
const dispatch = useAppDispatch();
const classes = useStyles();
return (
<InfoBox
title={
<Box className={classes.title}>
Settings
<IconButton
size="small"
onClick={() => {
dispatch(resetSettings());
}}
>
<RefreshIcon />
</IconButton>
</Box>
}
additionalContent={
<TableContainer>
<Table>
<TableBody>
<ElectrumRpcUrlSetting />
<MoneroNodeUrlSetting />
</TableBody>
</Table>
</TableContainer>
}
mainContent={
<Typography variant="subtitle2">
Some of these settings require a restart to take effect.
</Typography>
}
icon={null}
loading={false}
/>
);
}
// URL validation function, forces the URL to be in the format of "protocol://host:port/"
function isValidUrl(url: string, allowedProtocols: string[]): boolean {
const urlPattern = new RegExp(`^(${allowedProtocols.join("|")})://[^\\s]+:\\d+/?$`);
return urlPattern.test(url);
}
function ElectrumRpcUrlSetting() {
const electrumRpcUrl = useAppSelector((s) => s.settings.electrum_rpc_url);
const dispatch = useAppDispatch();
function isValid(url: string): boolean {
return isValidUrl(url, ["ssl", "tcp"]);
}
return (
<TableRow>
<TableCell>
<SettingLabel label="Custom Electrum RPC URL" tooltip="This is the URL of the Electrum server that the GUI will connect to. It is used to sync Bitcoin transactions. If you leave this field empty, the GUI will choose from a list of known servers at random." />
</TableCell>
<TableCell>
<ValidatedTextField
label="Electrum RPC URL"
value={electrumRpcUrl}
isValid={isValid}
onValidatedChange={(value) => {
dispatch(setElectrumRpcUrl(value));
}}
fullWidth
placeholder={PLACEHOLDER_ELECTRUM_RPC_URL}
allowEmpty
/>
</TableCell>
</TableRow>
);
}
function SettingLabel({ label, tooltip }: { label: ReactNode, tooltip: string | null }) {
return <Box style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
<Box>
{label}
</Box>
<Tooltip title={tooltip}>
<IconButton size="small">
<HelpIcon />
</IconButton>
</Tooltip>
</Box>
}
function MoneroNodeUrlSetting() {
const moneroNodeUrl = useAppSelector((s) => s.settings.monero_node_url);
const dispatch = useAppDispatch();
function isValid(url: string): boolean {
return isValidUrl(url, ["http"]);
}
return (
<TableRow>
<TableCell>
<SettingLabel label="Custom Monero Node URL" tooltip="This is the URL of the Monero node that the GUI will connect to. Ensure the node is listening for RPC connections over HTTP. If you leave this field empty, the GUI will choose from a list of known nodes at random." />
</TableCell>
<TableCell>
<ValidatedTextField
label="Monero Node URL"
value={moneroNodeUrl}
isValid={isValid}
onValidatedChange={(value) => {
dispatch(setMoneroNodeUrl(value));
}}
fullWidth
placeholder={PLACEHOLDER_MONERO_NODE_URL}
allowEmpty
/>
</TableCell>
</TableRow>
);
}

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/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

@ -176,9 +176,7 @@ function HasProviderSwapWidget({
function HasNoProvidersSwapWidget() {
const forceShowDialog = useAppSelector((state) => state.swap.state !== null);
const isPublicRegistryDown = useAppSelector((state) =>
isRegistryDown(
state.providers.registry.connectionFailsCount,
),
isRegistryDown(state.providers.registry.connectionFailsCount),
);
const classes = useStyles();
@ -254,9 +252,7 @@ export default function SwapWidget() {
const providerLoading = useAppSelector(
(state) =>
state.providers.registry.providers === null &&
!isRegistryDown(
state.providers.registry.connectionFailsCount,
),
!isRegistryDown(state.providers.registry.connectionFailsCount),
);
if (providerLoading) {

View file

@ -48,9 +48,7 @@ export default function WithdrawWidget() {
endIcon={<SendIcon />}
size="large"
onClick={onShowDialog}
disabled={
walletBalance === null || walletBalance <= 0
}
disabled={walletBalance === null || walletBalance <= 0}
>
Withdraw
</Button>