feat(gui): Partially available global state (#593)

* feat(gui): Partially availiable global state

* move tauri command into own module

* move commands list into src-tauri/src/commands.rs

* cleanup swap/src/cli/api.rs

* add contextRequirement attribute to PromiseInvokeButton

* amend

* allow wallet operation on partially availiable context

* improvements

* fix some linter errors

* limit amount of logs to 5k

* keep behaviour from before

* make sure if swapId is null useActiveSwapLogs, return no logs

* remove unused variable

* create ContextStatusType enum
This commit is contained in:
Mohan 2025-10-02 21:28:12 +02:00 committed by GitHub
parent 3b701fe1c5
commit 7d019bfb30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 2361 additions and 2080 deletions

View file

@ -31,9 +31,13 @@ function isCliLog(log: unknown): log is CliLog {
}
export function isCliLogRelatedToSwap(
log: CliLog | string,
log: CliLog | string | null | undefined,
swapId: string,
): boolean {
if (log === null || log === undefined) {
return false;
}
// If we only have a string, simply check if the string contains the swap id
// This provides reasonable backwards compatability
if (typeof log === "string") {
@ -44,7 +48,7 @@ export function isCliLogRelatedToSwap(
// - the log has the swap id as an attribute
// - there exists a span which has the swap id as an attribute
return (
log.fields["swap_id"] === swapId ||
("fields" in log && log.fields["swap_id"] === swapId) ||
(log.spans?.some((span) => span["swap_id"] === swapId) ?? false)
);
}

View file

@ -3,13 +3,17 @@ import {
ApprovalRequest,
ExpiredTimelocks,
GetSwapInfoResponse,
PendingCompleted,
QuoteWithAddress,
SelectMakerDetails,
TauriBackgroundProgress,
TauriSwapProgressEvent,
SendMoneroDetails,
ContextStatus,
} from "./tauriModel";
import {
ContextStatusType,
ResultContextStatus,
RPCSlice,
} from "store/features/rpcSlice";
export type TauriSwapProgressEventType = TauriSwapProgressEvent["type"];
@ -382,3 +386,30 @@ export function haveFundsBeenLocked(
return true;
}
export function isContextFullyInitialized(
status: ResultContextStatus,
): boolean {
if (status == null || status.type === ContextStatusType.Error) {
return false;
}
return (
status.status.bitcoin_wallet_available &&
status.status.monero_wallet_available &&
status.status.database_available &&
status.status.tor_available
);
}
export function isContextWithBitcoinWallet(
status: ContextStatus | null,
): boolean {
return status?.bitcoin_wallet_available ?? false;
}
export function isContextWithMoneroWallet(
status: ContextStatus | null,
): boolean {
return status?.monero_wallet_available ?? false;
}

View file

@ -227,9 +227,8 @@ export async function fetchAllConversations(): Promise<void> {
store.dispatch(setConversation({ feedbackId, messages }));
} catch (error) {
logger.error(
error,
"Error fetching messages for feedback id",
feedbackId,
{ error, feedbackId },
"Error fetching messages for feedback",
);
}
}

View file

@ -1,7 +1,8 @@
import { listen } from "@tauri-apps/api/event";
import { TauriContextStatusEvent, TauriEvent } from "models/tauriModel";
import { TauriEvent } from "models/tauriModel";
import {
contextStatusEventReceived,
contextInitializationFailed,
rpcSetBalance,
timelockChangeEventReceived,
approvalEventReceived,
@ -18,7 +19,7 @@ import {
updateRates,
} from "./api";
import {
checkContextAvailability,
checkContextStatus,
getSwapInfo,
initializeContext,
listSellersAtRendezvousPoint,
@ -53,6 +54,9 @@ const FETCH_CONVERSATIONS_INTERVAL = 10 * 60 * 1_000;
// Fetch pending approvals every 2 seconds
const FETCH_PENDING_APPROVALS_INTERVAL = 2 * 1_000;
// Check context status every 2 seconds
const CHECK_CONTEXT_STATUS_INTERVAL = 2 * 1_000;
function setIntervalImmediate(callback: () => void, interval: number): void {
callback();
setInterval(callback, interval);
@ -76,87 +80,86 @@ export async function setupBackgroundTasks(): Promise<void> {
// Setup Tauri event listeners
// Check if the context is already available. This is to prevent unnecessary re-initialization
if (await checkContextAvailability()) {
store.dispatch(
contextStatusEventReceived(TauriContextStatusEvent.Available),
);
} else {
setIntervalImmediate(async () => {
const contextStatus = await checkContextStatus();
store.dispatch(contextStatusEventReceived(contextStatus));
}, CHECK_CONTEXT_STATUS_INTERVAL);
const contextStatus = await checkContextStatus();
// If all components are unavailable, we need to initialize the context
if (
!contextStatus.bitcoin_wallet_available &&
!contextStatus.monero_wallet_available &&
!contextStatus.database_available &&
!contextStatus.tor_available
)
// Warning: If we reload the page while the Context is being initialized, this function will throw an error
initializeContext().catch((e) => {
logger.error(
e,
"Failed to initialize context on page load. This might be because we reloaded the page while the context was being initialized",
);
// Wait a short time before retrying
setTimeout(() => {
initializeContext().catch((e) => {
logger.error(e, "Failed to initialize context even after retry");
});
}, 2000);
store.dispatch(contextInitializationFailed(String(e)));
});
}
// Listen for the unified event
listen<TauriEvent>(TAURI_UNIFIED_EVENT_CHANNEL_NAME, (event) => {
const { channelName, event: eventData } = event.payload;
switch (channelName) {
case "SwapProgress":
store.dispatch(swapProgressEventReceived(eventData));
break;
case "ContextInitProgress":
store.dispatch(contextStatusEventReceived(eventData));
break;
case "CliLog":
store.dispatch(receivedCliLog(eventData));
break;
case "BalanceChange":
store.dispatch(rpcSetBalance(eventData.balance));
break;
case "SwapDatabaseStateUpdate":
getSwapInfo(eventData.swap_id);
// This is ugly but it's the best we can do for now
// Sometimes we are too quick to fetch the swap info and the new state is not yet reflected
// in the database. So we wait a bit before fetching the new state
setTimeout(() => getSwapInfo(eventData.swap_id), 3000);
break;
case "TimelockChange":
store.dispatch(timelockChangeEventReceived(eventData));
break;
case "Approval":
store.dispatch(approvalEventReceived(eventData));
break;
case "BackgroundProgress":
store.dispatch(backgroundProgressEventReceived(eventData));
break;
case "PoolStatusUpdate":
store.dispatch(poolStatusReceived(eventData));
break;
case "MoneroWalletUpdate":
console.log("MoneroWalletUpdate", eventData);
if (eventData.type === "BalanceChange") {
store.dispatch(setBalance(eventData.content));
}
if (eventData.type === "HistoryUpdate") {
store.dispatch(setHistory(eventData.content));
}
if (eventData.type === "SyncProgress") {
store.dispatch(setSyncProgress(eventData.content));
}
break;
default:
exhaustiveGuard(channelName);
}
});
}
// Listen for the unified event
listen<TauriEvent>(TAURI_UNIFIED_EVENT_CHANNEL_NAME, (event) => {
const { channelName, event: eventData } = event.payload;
switch (channelName) {
case "SwapProgress":
store.dispatch(swapProgressEventReceived(eventData));
break;
case "CliLog":
store.dispatch(receivedCliLog(eventData));
break;
case "BalanceChange":
store.dispatch(rpcSetBalance(eventData.balance));
break;
case "SwapDatabaseStateUpdate":
getSwapInfo(eventData.swap_id);
// This is ugly but it's the best we can do for now
// Sometimes we are too quick to fetch the swap info and the new state is not yet reflected
// in the database. So we wait a bit before fetching the new state
setTimeout(() => getSwapInfo(eventData.swap_id), 3000);
break;
case "TimelockChange":
store.dispatch(timelockChangeEventReceived(eventData));
break;
case "Approval":
store.dispatch(approvalEventReceived(eventData));
break;
case "BackgroundProgress":
store.dispatch(backgroundProgressEventReceived(eventData));
break;
case "PoolStatusUpdate":
store.dispatch(poolStatusReceived(eventData));
break;
case "MoneroWalletUpdate":
console.log("MoneroWalletUpdate", eventData);
if (eventData.type === "BalanceChange") {
store.dispatch(setBalance(eventData.content));
}
if (eventData.type === "HistoryUpdate") {
store.dispatch(setHistory(eventData.content));
}
if (eventData.type === "SyncProgress") {
store.dispatch(setSyncProgress(eventData.content));
}
break;
default:
exhaustiveGuard(channelName);
}
});

View file

@ -25,6 +25,7 @@ import SeedSelectionDialog from "./modal/seed-selection/SeedSelectionDialog";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import PasswordEntryDialog from "./modal/password-entry/PasswordEntryDialog";
import ContextErrorDialog from "./modal/context-error/ContextErrorDialog";
declare module "@mui/material/styles" {
interface Theme {
@ -54,6 +55,7 @@ export default function App() {
<IntroductionModal />
<SeedSelectionDialog />
<PasswordEntryDialog />
<ContextErrorDialog />
<Router>
<Navigation />
<InnerContent />

View file

@ -8,9 +8,12 @@ import {
Tooltip,
} from "@mui/material";
import CircularProgress from "@mui/material/CircularProgress";
import { ContextStatus } from "models/tauriModel";
import { isContextFullyInitialized } from "models/tauriModelExt";
import { useSnackbar } from "notistack";
import { ReactNode, useState } from "react";
import { useIsContextAvailable } from "store/hooks";
import { ContextStatusType } from "store/features/rpcSlice";
import { useAppSelector, useIsContextAvailable } from "store/hooks";
interface PromiseInvokeButtonProps<T> {
onSuccess?: (data: T) => void | null;
@ -23,7 +26,10 @@ interface PromiseInvokeButtonProps<T> {
disabled?: boolean;
displayErrorSnackbar?: boolean;
tooltipTitle?: string | null;
requiresContext?: boolean;
// true means that the entire context must be available
// false means that the context doesn't have to be available at all
// a custom function means that the context must satisfy the function
contextRequirement?: ((status: ContextStatus) => boolean) | false | true;
}
export default function PromiseInvokeButton<T>({
@ -39,13 +45,11 @@ export default function PromiseInvokeButton<T>({
isChipButton = false,
displayErrorSnackbar = false,
onPendingChange = null,
requiresContext = true,
contextRequirement = true,
tooltipTitle = null,
...rest
}: PromiseInvokeButtonProps<T> & ButtonProps) {
const { enqueueSnackbar } = useSnackbar();
const isContextAvailable = useIsContextAvailable();
const [isPending, setIsPending] = useState(false);
const isLoading = isPending || isLoadingOverride;
@ -73,7 +77,23 @@ export default function PromiseInvokeButton<T>({
}
}
const requiresContextButNotAvailable = requiresContext && !isContextAvailable;
const requiresContextButNotAvailable = useAppSelector((state) => {
const status = state.rpc.status;
if (contextRequirement === false) {
return false;
}
if (contextRequirement === true || contextRequirement == null) {
return !isContextFullyInitialized(status);
}
if (status == null || status.type === ContextStatusType.Error) {
return true;
}
return !contextRequirement(status.status);
});
const isDisabled = disabled || isLoading || requiresContextButNotAvailable;
const actualTooltipTitle =

View file

@ -5,10 +5,7 @@ import { useAppSelector, usePendingBackgroundProcesses } from "store/hooks";
import { exhaustiveGuard } from "utils/typescriptUtils";
import { LoadingSpinnerAlert } from "./LoadingSpinnerAlert";
import { bytesToMb } from "utils/conversionUtils";
import {
TauriBackgroundProgress,
TauriContextStatusEvent,
} from "models/tauriModel";
import { TauriBackgroundProgress } from "models/tauriModel";
import { useEffect, useState } from "react";
import TruncatedText from "../other/TruncatedText";
import BitcoinIcon from "../icons/BitcoinIcon";
@ -182,41 +179,6 @@ function PartialInitStatus({
}
}
export default function DaemonStatusAlert() {
const contextStatus = useAppSelector((s) => s.rpc.status);
const navigate = useNavigate();
switch (contextStatus) {
case null:
return null;
case TauriContextStatusEvent.NotInitialized:
return null;
case TauriContextStatusEvent.Initializing:
return null;
case TauriContextStatusEvent.Available:
return <Alert severity="success">The daemon is running</Alert>;
case TauriContextStatusEvent.Failed:
return (
<Alert
severity="error"
action={
<Button
size="small"
variant="outlined"
onClick={() => navigate("/settings#daemon-control-box")}
>
View Logs
</Button>
}
>
The daemon has stopped unexpectedly
</Alert>
);
default:
return exhaustiveGuard(contextStatus);
}
}
export function BackgroundProgressAlerts() {
const backgroundProgress = usePendingBackgroundProcesses();

View file

@ -72,6 +72,7 @@ export default function SwapSuspendAlert({
color="primary"
onSuccess={onClose}
onInvoke={suspendCurrentSwap}
contextRequirement={false}
>
Suspend
</PromiseInvokeButton>

View file

@ -0,0 +1,63 @@
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Typography,
} from "@mui/material";
import { relaunch } from "@tauri-apps/plugin-process";
import { useAppSelector } from "store/hooks";
import CliLogsBox from "renderer/components/other/RenderedCliLog";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
import ContactInfoBox from "renderer/components/other/ContactInfoBox";
import { ContextStatusType } from "store/features/rpcSlice";
export default function ContextErrorDialog() {
const logs = useAppSelector((state) => state.logs.state.logs);
const errorMessage = useAppSelector((state) =>
state.rpc.status?.type === ContextStatusType.Error
? state.rpc.status.error
: null,
);
if (errorMessage === null) {
return null;
}
return (
<Dialog open={true} maxWidth="md" fullWidth disableEscapeKeyDown>
<DialogTitle>Failed to start</DialogTitle>
<DialogContent>
<DialogContentText>
Check the logs below for details. Try restarting the GUI. Reach out to
the developers and the community if this continues.
</DialogContentText>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<Box sx={{ alignSelf: "center" }}>
<ContactInfoBox />
</Box>
<ActionableMonospaceTextBox
content={errorMessage}
displayCopyIcon={true}
enableQrCode={false}
/>
<CliLogsBox label="Logs" logs={logs} minHeight="30vh" />
</Box>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={() => relaunch()}>
Restart GUI
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -227,7 +227,7 @@ export default function FeedbackDialog({
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<PromiseInvokeButton
requiresContext={false}
contextRequirement={false}
color="primary"
variant="contained"
onInvoke={submitFeedback}

View file

@ -9,13 +9,13 @@ import {
Switch,
Typography,
} from "@mui/material";
import { CliLog } from "models/cliModel";
import CliLogsBox from "renderer/components/other/RenderedCliLog";
import { HashedLog } from "store/features/logsSlice";
interface LogViewerProps {
open: boolean;
setOpen: (_: boolean) => void;
logs: (string | CliLog)[] | null;
logs: HashedLog[];
setIsRedacted: (_: boolean) => void;
isRedacted: boolean;
}

View file

@ -3,12 +3,13 @@ import { store } from "renderer/store/storeRenderer";
import { useActiveSwapInfo } from "store/hooks";
import { logsToRawString } from "utils/parseUtils";
import { getLogsOfSwap, redactLogs } from "renderer/rpc";
import { CliLog, parseCliLogString } from "models/cliModel";
import { parseCliLogString } from "models/cliModel";
import logger from "utils/logger";
import { submitFeedbackViaHttp } from "renderer/api";
import { addFeedbackId } from "store/features/conversationsSlice";
import { AttachmentInput } from "models/apiModel";
import { useSnackbar } from "notistack";
import { HashedLog, hashLogs } from "store/features/logsSlice";
export const MAX_FEEDBACK_LENGTH = 4000;
@ -21,8 +22,8 @@ interface FeedbackInputState {
}
interface FeedbackLogsState {
swapLogs: (string | CliLog)[] | null;
daemonLogs: (string | CliLog)[] | null;
swapLogs: HashedLog[];
daemonLogs: HashedLog[];
}
const initialInputState: FeedbackInputState = {
@ -34,8 +35,8 @@ const initialInputState: FeedbackInputState = {
};
const initialLogsState: FeedbackLogsState = {
swapLogs: null,
daemonLogs: null,
swapLogs: [],
daemonLogs: [],
};
export function useFeedback() {
@ -55,56 +56,60 @@ export function useFeedback() {
useEffect(() => {
if (inputState.selectedSwap === null) {
setLogsState((prev) => ({ ...prev, swapLogs: null }));
setLogsState((prev) => ({ ...prev, swapLogs: [] }));
return;
}
getLogsOfSwap(inputState.selectedSwap, inputState.isSwapLogsRedacted)
.then((response) => {
const parsedLogs = response.logs.map(parseCliLogString);
setLogsState((prev) => ({
...prev,
swapLogs: response.logs.map(parseCliLogString),
swapLogs: hashLogs(parsedLogs),
}));
setError(null);
})
.catch((e) => {
logger.error(`Failed to fetch swap logs: ${e}`);
setLogsState((prev) => ({ ...prev, swapLogs: null }));
setLogsState((prev) => ({ ...prev, swapLogs: [] }));
setError(`Failed to fetch swap logs: ${e}`);
});
}, [inputState.selectedSwap, inputState.isSwapLogsRedacted]);
useEffect(() => {
if (!inputState.attachDaemonLogs) {
setLogsState((prev) => ({ ...prev, daemonLogs: null }));
setLogsState((prev) => ({ ...prev, daemonLogs: [] }));
return;
}
try {
const hashedLogs = store.getState().logs?.state.logs ?? [];
if (inputState.isDaemonLogsRedacted) {
redactLogs(store.getState().logs?.state.logs)
const logs = hashedLogs.map((h) => h.log);
redactLogs(logs)
.then((redactedLogs) => {
setLogsState((prev) => ({
...prev,
daemonLogs: redactedLogs,
daemonLogs: hashLogs(redactedLogs),
}));
setError(null);
})
.catch((e) => {
logger.error(`Failed to redact daemon logs: ${e}`);
setLogsState((prev) => ({ ...prev, daemonLogs: null }));
setLogsState((prev) => ({ ...prev, daemonLogs: [] }));
setError(`Failed to redact daemon logs: ${e}`);
});
} else {
setLogsState((prev) => ({
...prev,
daemonLogs: store.getState().logs?.state.logs,
daemonLogs: hashedLogs,
}));
setError(null);
}
} catch (e) {
logger.error(`Failed to fetch daemon logs: ${e}`);
setLogsState((prev) => ({ ...prev, daemonLogs: null }));
setLogsState((prev) => ({ ...prev, daemonLogs: [] }));
setError(`Failed to fetch daemon logs: ${e}`);
}
}, [inputState.attachDaemonLogs, inputState.isDaemonLogsRedacted]);
@ -123,18 +128,18 @@ export function useFeedback() {
const attachments: AttachmentInput[] = [];
// Add swap logs as an attachment
if (logsState.swapLogs) {
if (logsState.swapLogs.length > 0) {
attachments.push({
key: `swap_logs_${inputState.selectedSwap}.txt`,
content: logsToRawString(logsState.swapLogs),
content: logsToRawString(logsState.swapLogs.map((h) => h.log)),
});
}
// Handle daemon logs
if (logsState.daemonLogs) {
if (logsState.daemonLogs.length > 0) {
attachments.push({
key: "daemon_logs.txt",
content: logsToRawString(logsState.daemonLogs),
content: logsToRawString(logsState.daemonLogs.map((h) => h.log)),
});
}

View file

@ -116,7 +116,7 @@ export default function PasswordEntryDialog() {
<PromiseInvokeButton
onInvoke={accept}
variant="contained"
requiresContext={false}
contextRequirement={false}
>
Unlock
</PromiseInvokeButton>

View file

@ -364,7 +364,7 @@ export default function SeedSelectionDialog() {
<PromiseInvokeButton
variant="text"
onInvoke={Legacy}
requiresContext={false}
contextRequirement={false}
color="inherit"
>
No wallet (Legacy)
@ -373,7 +373,7 @@ export default function SeedSelectionDialog() {
onInvoke={accept}
variant="contained"
disabled={isDisabled}
requiresContext={false}
contextRequirement={false}
>
Continue
</PromiseInvokeButton>

View file

@ -1,15 +1,9 @@
import { Box, DialogContentText } from "@mui/material";
import {
useActiveSwapInfo,
useActiveSwapLogs,
useAppSelector,
} from "store/hooks";
import JsonTreeView from "../../../other/JSONViewTree";
import { useActiveSwapLogs } from "store/hooks";
import CliLogsBox from "../../../other/RenderedCliLog";
export default function DebugPage() {
const logs = useActiveSwapLogs();
const cliState = useActiveSwapInfo();
return (
<Box sx={{ padding: 2, display: "flex", flexDirection: "column", gap: 2 }}>

View file

@ -6,6 +6,7 @@ import DialogHeader from "../DialogHeader";
import AddressInputPage from "./pages/AddressInputPage";
import BtcTxInMempoolPageContent from "./pages/BitcoinWithdrawTxInMempoolPage";
import WithdrawDialogContent from "./WithdrawDialogContent";
import { isContextWithBitcoinWallet } from "models/tauriModelExt";
export default function WithdrawDialog({
open,
@ -61,6 +62,7 @@ export default function WithdrawDialog({
onInvoke={() => withdrawBtc(withdrawAddress)}
onPendingChange={setPending}
onSuccess={setWithdrawTxId}
contextRequirement={isContextWithBitcoinWallet}
>
Withdraw
</PromiseInvokeButton>

View file

@ -1,14 +1,9 @@
import { Box, Tooltip } from "@mui/material";
import GitHubIcon from "@mui/icons-material/GitHub";
import DaemonStatusAlert, {
BackgroundProgressAlerts,
} from "../alert/DaemonStatusAlert";
import { BackgroundProgressAlerts } from "../alert/DaemonStatusAlert";
import FundsLeftInWalletAlert from "../alert/FundsLeftInWalletAlert";
import UnfinishedSwapsAlert from "../alert/UnfinishedSwapsAlert";
import LinkIconButton from "../icons/LinkIconButton";
import BackgroundRefundAlert from "../alert/BackgroundRefundAlert";
import MatrixIcon from "../icons/MatrixIcon";
import { MenuBook } from "@mui/icons-material";
import ContactInfoBox from "../other/ContactInfoBox";
export default function NavigationFooter() {
return (
@ -23,36 +18,8 @@ export default function NavigationFooter() {
<FundsLeftInWalletAlert />
<UnfinishedSwapsAlert />
<BackgroundRefundAlert />
<DaemonStatusAlert />
<BackgroundProgressAlerts />
<Box
sx={{
display: "flex",
justifyContent: "space-evenly",
}}
>
<Tooltip title="Check out the GitHub repository">
<span>
<LinkIconButton url="https://github.com/eigenwallet/core">
<GitHubIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Join the Matrix room">
<span>
<LinkIconButton url="https://matrix.to/#/#unstoppableswap-space:matrix.org">
<MatrixIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Read our official documentation">
<span>
<LinkIconButton url="https://docs.unstoppableswap.net">
<MenuBook />
</LinkIconButton>
</span>
</Tooltip>
</Box>
<ContactInfoBox />
</Box>
);
}

View file

@ -0,0 +1,46 @@
import MatrixIcon from "../icons/MatrixIcon";
import { MenuBook } from "@mui/icons-material";
import DiscordIcon from "../icons/DiscordIcon";
import GitHubIcon from "@mui/icons-material/GitHub";
import LinkIconButton from "../icons/LinkIconButton";
import { Box, Tooltip } from "@mui/material";
export default function ContactInfoBox() {
return (
<Box
sx={{
display: "flex",
justifyContent: "space-evenly",
}}
>
<Tooltip title="Check out the GitHub repository">
<span>
<LinkIconButton url="https://github.com/eigenwallet/core">
<GitHubIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Join the Matrix room">
<span>
<LinkIconButton url="https://eigenwallet.org/matrix">
<MatrixIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Join the Discord server">
<span>
<LinkIconButton url="https://eigenwallet.org/discord">
<DiscordIcon />
</LinkIconButton>
</span>
</Tooltip>
<Tooltip title="Read our official documentation">
<span>
<LinkIconButton url="https://docs.unstoppableswap.net">
<MenuBook />
</LinkIconButton>
</span>
</Tooltip>
</Box>
);
}

View file

@ -1,5 +1,6 @@
import { Box, Chip, Typography } from "@mui/material";
import { CliLog } from "models/cliModel";
import { HashedLog } from "store/features/logsSlice";
import { ReactNode, useMemo, useState } from "react";
import { logsToRawString } from "utils/parseUtils";
import ScrollablePaperTextBox from "./ScrollablePaperTextBox";
@ -62,44 +63,54 @@ export default function CliLogsBox({
label,
logs,
topRightButton = null,
autoScroll = false,
autoScroll = true,
minHeight,
}: {
label: string;
logs: (CliLog | string)[];
logs: HashedLog[];
topRightButton?: ReactNode;
autoScroll?: boolean;
minHeight?: string;
}) {
const [searchQuery, setSearchQuery] = useState<string>("");
const memoizedLogs = useMemo(() => {
const filteredLogs = useMemo(() => {
if (searchQuery.length === 0) {
return logs;
}
return logs.filter((log) =>
return logs.filter(({ log }) =>
JSON.stringify(log).toLowerCase().includes(searchQuery.toLowerCase()),
);
}, [logs, searchQuery]);
const rows = useMemo(() => {
return filteredLogs.map(({ log, hash }) =>
typeof log === "string" ? (
<Typography key={hash} component="pre">
{log}
</Typography>
) : (
<RenderedCliLog log={log} key={hash} />
),
);
}, [filteredLogs]);
const rawStrings = useMemo(
() => filteredLogs.map(({ log }) => log),
[filteredLogs],
);
return (
<ScrollablePaperTextBox
minHeight={minHeight}
title={label}
copyValue={logsToRawString(logs)}
copyValue={logsToRawString(rawStrings)}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
topRightButton={topRightButton}
autoScroll={autoScroll}
rows={memoizedLogs.map((log) =>
typeof log === "string" ? (
<Typography key={log} component="pre">
{log}
</Typography>
) : (
<RenderedCliLog log={log} key={JSON.stringify(log)} />
),
)}
rows={rows}
/>
);
}

View file

@ -293,7 +293,7 @@ function ConversationModal({
// Fetch updated conversations
fetchAllConversations();
} catch (error) {
logger.error("Error sending message:", error);
logger.error(`Error sending message: ${error}`);
enqueueSnackbar("Failed to send message. Please try again.", {
variant: "error",
});

View file

@ -1,31 +1,27 @@
import { Box } from "@mui/material";
import FolderOpenIcon from "@mui/icons-material/FolderOpen";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { useAppSelector } from "store/hooks";
import InfoBox from "renderer/components/pages/swap/swap/components/InfoBox";
import CliLogsBox from "renderer/components/other/RenderedCliLog";
import { getDataDir, initializeContext } from "renderer/rpc";
import { getDataDir } from "renderer/rpc";
import { relaunch } from "@tauri-apps/plugin-process";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { TauriContextStatusEvent } from "models/tauriModel";
import { ContextStatusType } from "store/features/rpcSlice";
export default function DaemonControlBox() {
const logs = useAppSelector((s) => s.logs.state.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 === TauriContextStatusEvent.Failed || s.rpc.status === null,
);
const isContextInitializing = useAppSelector(
(s) => s.rpc.status === TauriContextStatusEvent.Initializing,
);
const stringifiedDaemonStatus = useAppSelector(
(s) => s.rpc.status ?? "not started",
);
const stringifiedDaemonStatus = useAppSelector((s) => {
if (s.rpc.status === null) {
return "not started";
}
if (s.rpc.status.type === ContextStatusType.Error) {
return "failed";
}
return "running";
});
return (
<InfoBox
@ -36,22 +32,11 @@ export default function DaemonControlBox() {
}
additionalContent={
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<PromiseInvokeButton
variant="contained"
endIcon={<PlayArrowIcon />}
onInvoke={initializeContext}
requiresContext={false}
disabled={!canContextBeManuallyStarted}
isLoadingOverride={isContextInitializing}
displayErrorSnackbar
>
Start Daemon
</PromiseInvokeButton>
<PromiseInvokeButton
variant="contained"
endIcon={<RotateLeftIcon />}
onInvoke={relaunch}
requiresContext={false}
contextRequirement={false}
displayErrorSnackbar
>
Restart GUI
@ -59,7 +44,7 @@ export default function DaemonControlBox() {
<PromiseInvokeButton
endIcon={<FolderOpenIcon />}
isIconButton
requiresContext={false}
contextRequirement={false}
size="small"
tooltipTitle="Open the data directory in your file explorer"
onInvoke={async () => {

View file

@ -15,6 +15,7 @@ import { getWalletDescriptor } from "renderer/rpc";
import { ExportBitcoinWalletResponse } from "models/tauriModel";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import ActionableMonospaceTextBox from "renderer/components/other/ActionableMonospaceTextBox";
import { isContextWithBitcoinWallet } from "models/tauriModelExt";
export default function ExportDataBox() {
const [walletDescriptor, setWalletDescriptor] =
@ -52,6 +53,7 @@ export default function ExportDataBox() {
onInvoke={getWalletDescriptor}
onSuccess={setWalletDescriptor}
displayErrorSnackbar={true}
contextRequirement={isContextWithBitcoinWallet}
>
Reveal Bitcoin Wallet Private Key
</PromiseInvokeButton>

View file

@ -14,7 +14,8 @@ export default function ExportLogsButton({
}: ExportLogsButtonProps) {
async function handleExportLogs() {
const swapLogs = await getLogsOfSwap(swap_id, false);
const daemonLogs = store.getState().logs?.state.logs;
const hashedDaemonLogs = store.getState().logs?.state.logs ?? [];
const daemonLogs = hashedDaemonLogs.map((h) => h.log);
const logContent = {
swap_logs: logsToRawString(swapLogs.logs),

View file

@ -6,21 +6,23 @@ import {
DialogTitle,
} from "@mui/material";
import { ButtonProps } from "@mui/material/Button";
import { CliLog, parseCliLogString } from "models/cliModel";
import { parseCliLogString } from "models/cliModel";
import { GetLogsResponse } from "models/tauriModel";
import { useState } from "react";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { getLogsOfSwap } from "renderer/rpc";
import CliLogsBox from "../../../other/RenderedCliLog";
import { HashedLog, hashLogs } from "store/features/logsSlice";
export default function SwapLogFileOpenButton({
swapId,
...props
}: { swapId: string } & ButtonProps) {
const [logs, setLogs] = useState<(CliLog | string)[] | null>(null);
const [logs, setLogs] = useState<HashedLog[] | null>(null);
function onLogsReceived(response: GetLogsResponse) {
setLogs(response.logs.map(parseCliLogString));
const parsedLogs = response.logs.map(parseCliLogString);
setLogs(hashLogs(parsedLogs));
}
return (

View file

@ -6,6 +6,7 @@ import {
GetMoneroSeedResponse,
GetRestoreHeightResponse,
} from "models/tauriModel";
import { isContextWithMoneroWallet } from "models/tauriModelExt";
interface SeedPhraseButtonProps {
onMenuClose: () => void;
@ -32,6 +33,7 @@ export default function SeedPhraseButton({
onSuccess={handleSeedPhraseSuccess}
displayErrorSnackbar={true}
variant="text"
contextRequirement={isContextWithMoneroWallet}
sx={{
justifyContent: "flex-start",
textTransform: "none",

View file

@ -17,6 +17,7 @@ import { getRestoreHeight, setMoneroRestoreHeight } from "renderer/rpc";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { Dayjs } from "dayjs";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { isContextWithMoneroWallet } from "models/tauriModelExt";
enum RestoreOption {
BlockHeight = "blockHeight",
@ -133,6 +134,7 @@ export default function SetRestoreHeightModal({
onSuccess={onClose}
displayErrorSnackbar={true}
onPendingChange={setIsPending}
contextRequirement={isContextWithMoneroWallet}
>
Confirm
</PromiseInvokeButton>

View file

@ -13,6 +13,7 @@ import DFXSwissLogo from "assets/dfx-logo.svg";
import { useState } from "react";
import { dfxAuthenticate } from "renderer/rpc";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { isContextWithMoneroWallet } from "models/tauriModelExt";
function DFXLogo({ height = 24 }: { height?: number }) {
return (
@ -53,6 +54,7 @@ export default function DfxButton() {
tooltipTitle="Buy Monero with fiat using DFX"
displayErrorSnackbar
isChipButton
contextRequirement={isContextWithMoneroWallet}
>
Buy Monero
</PromiseInvokeButton>

View file

@ -130,7 +130,7 @@ export default function SendApprovalContent({
color="error"
startIcon={<CloseIcon />}
displayErrorSnackbar={true}
requiresContext={false}
contextRequirement={false}
>
Reject
</PromiseInvokeButton>
@ -141,7 +141,7 @@ export default function SendApprovalContent({
color="primary"
startIcon={<CheckIcon />}
displayErrorSnackbar={true}
requiresContext={false}
contextRequirement={false}
>
Send
</PromiseInvokeButton>

View file

@ -14,6 +14,7 @@ import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { sendMoneroTransaction } from "renderer/rpc";
import { useAppSelector } from "store/hooks";
import { SendMoneroResponse } from "models/tauriModel";
import { isContextWithMoneroWallet } from "models/tauriModelExt";
interface SendTransactionContentProps {
balance: {
@ -168,6 +169,7 @@ export default function SendTransactionContent({
disabled={isSendDisabled}
onSuccess={handleSendSuccess}
onPendingChange={setIsSending}
contextRequirement={isContextWithMoneroWallet}
>
Send
</PromiseInvokeButton>

View file

@ -145,7 +145,6 @@ export default function SwapSetupInflightPage({
resolveApproval(request.request_id, false as unknown as object)
}
displayErrorSnackbar
requiresContext
>
Deny
</PromiseInvokeButton>
@ -158,7 +157,6 @@ export default function SwapSetupInflightPage({
resolveApproval(request.request_id, true as unknown as object)
}
displayErrorSnackbar
requiresContext
endIcon={<CheckIcon />}
>
{`Confirm`}

View file

@ -2,6 +2,7 @@ import RefreshIcon from "@mui/icons-material/Refresh";
import PromiseInvokeButton from "renderer/components/PromiseInvokeButton";
import { checkBitcoinBalance } from "renderer/rpc";
import { isSyncingBitcoin } from "store/hooks";
import { isContextWithBitcoinWallet } from "models/tauriModelExt";
export default function WalletRefreshButton() {
const isSyncing = isSyncingBitcoin();
@ -14,6 +15,7 @@ export default function WalletRefreshButton() {
onInvoke={() => checkBitcoinBalance()}
displayErrorSnackbar
size="small"
contextRequirement={isContextWithBitcoinWallet}
/>
);
}

View file

@ -46,11 +46,13 @@ import {
GetRestoreHeightResponse,
MoneroNodeConfig,
GetMoneroSeedResponse,
ContextStatus,
} from "models/tauriModel";
import {
rpcSetBalance,
rpcSetSwapInfo,
approvalRequestsReplaced,
contextInitializationFailed,
} from "store/features/rpcSlice";
import {
setMainAddress,
@ -282,9 +284,8 @@ export async function getMoneroRecoveryKeys(
);
}
export async function checkContextAvailability(): Promise<boolean> {
const available = await invokeNoArgs<boolean>("is_context_available");
return available;
export async function checkContextStatus(): Promise<ContextStatus> {
return await invokeNoArgs<ContextStatus>("get_context_status");
}
export async function getLogsOfSwap(
@ -335,7 +336,6 @@ export async function initializeContext() {
store.getState().settings.nodes[network][Blockchain.Monero][0] ?? null;
// Check the state of the Monero node
const moneroNodeConfig =
useMoneroRpcPool ||
moneroNodeUrl == null ||
@ -356,18 +356,17 @@ export async function initializeContext() {
enable_monero_tor: useMoneroTor,
};
logger.info("Initializing context with settings", tauriSettings);
logger.info({ tauriSettings }, "Initializing context with settings");
try {
await invokeUnsafe<void>("initialize_context", {
settings: tauriSettings,
testnet,
});
logger.info("Initialized context");
} catch (error) {
throw new Error("Couldn't initialize context: " + error);
throw new Error(error);
}
logger.info("Initialized context");
}
export async function getWalletDescriptor() {

View file

@ -2,9 +2,13 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { TauriLogEvent } from "models/tauriModel";
import { parseLogsFromString } from "utils/parseUtils";
import { CliLog } from "models/cliModel";
import { fnv1a } from "utils/hash";
/// We only keep the last 5000 logs in the store
const MAX_LOG_ENTRIES = 5000;
interface LogsState {
logs: (CliLog | string)[];
logs: HashedLog[];
}
export interface LogsSlice {
@ -17,21 +21,67 @@ const initialState: LogsSlice = {
},
};
export type HashedLog = {
log: CliLog | string;
hash: string;
};
export const logsSlice = createSlice({
name: "logs",
initialState,
reducers: {
receivedCliLog(slice, action: PayloadAction<TauriLogEvent>) {
const buffer = action.payload.buffer;
const logs = parseLogsFromString(buffer);
const logsWithoutExisting = logs.filter(
(log) => !slice.state.logs.includes(log),
);
slice.state.logs = slice.state.logs.concat(logsWithoutExisting);
const parsedLogs = parseLogsFromString(action.payload.buffer);
const hashedLogs = parsedLogs.map(createHashedLog);
for (const entry of hashedLogs) {
slice.state.logs.push(entry);
}
// If we have too many logs, discard 1/10 of them (oldest logs)
// We explictly discard more than we need to, such that we don't have to
// do this too often
if (slice.state.logs.length > MAX_LOG_ENTRIES) {
const removeCount = Math.floor(slice.state.logs.length / 10);
slice.state.logs = slice.state.logs.slice(removeCount);
}
},
clearLogs(slice) {
slice.state.logs = [];
},
},
});
export const { receivedCliLog } = logsSlice.actions;
function serializeLog(log: CliLog | string): string {
if (typeof log === "string") {
return `str:${log}`;
}
const parts = [
"obj",
log.timestamp,
log.level,
log.target ?? "",
JSON.stringify(log.fields),
];
if (log.spans != null && log.spans.length > 0) {
parts.push(JSON.stringify(log.spans));
}
return parts.join("|");
}
function createHashedLog(log: CliLog | string): HashedLog {
return {
log,
hash: fnv1a(serializeLog(log)),
};
}
export function hashLogs(logs: (CliLog | string)[]): HashedLog[] {
return logs.map(createHashedLog);
}
export const { receivedCliLog, clearLogs } = logsSlice.actions;
export default logsSlice.reducer;

View file

@ -2,7 +2,7 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ExtendedMakerStatus, MakerStatus } from "models/apiModel";
import {
GetSwapInfoResponse,
TauriContextStatusEvent,
ContextStatus,
TauriTimelockChangeEvent,
BackgroundRefundState,
ApprovalRequest,
@ -37,8 +37,17 @@ interface State {
};
}
export enum ContextStatusType {
Status = "status",
Error = "error",
}
export type ResultContextStatus =
| { type: ContextStatusType.Status; status: ContextStatus }
| { type: ContextStatusType.Error; error: string };
export interface RPCSlice {
status: TauriContextStatusEvent | null;
status: ResultContextStatus | null;
state: State;
}
@ -60,11 +69,18 @@ export const rpcSlice = createSlice({
name: "rpc",
initialState,
reducers: {
contextStatusEventReceived(
slice,
action: PayloadAction<TauriContextStatusEvent>,
) {
slice.status = action.payload;
contextStatusEventReceived(slice, action: PayloadAction<ContextStatus>) {
// Don't overwrite error state
//
// Once we're in an error state, stay there
if (slice.status?.type === ContextStatusType.Error) {
return;
}
slice.status = { type: ContextStatusType.Status, status: action.payload };
},
contextInitializationFailed(slice, action: PayloadAction<string>) {
slice.status = { type: ContextStatusType.Error, error: action.payload };
},
timelockChangeEventReceived(
slice: RPCSlice,
@ -160,6 +176,7 @@ export const rpcSlice = createSlice({
export const {
contextStatusEventReceived,
contextInitializationFailed,
rpcSetBalance,
rpcSetWithdrawTxId,
rpcResetWithdrawTxId,

View file

@ -16,6 +16,7 @@ import {
isPendingSendMoneroApprovalEvent,
PendingPasswordApprovalRequest,
isPendingPasswordApprovalEvent,
isContextFullyInitialized,
} from "models/tauriModelExt";
import { TypedUseSelectorHook, useDispatch, useSelector } from "react-redux";
import type { AppDispatch, RootState } from "renderer/store/storeRenderer";
@ -28,7 +29,6 @@ import { RatesState } from "./features/ratesSlice";
import {
TauriBackgroundProgress,
TauriBitcoinSyncProgress,
TauriContextStatusEvent,
} from "models/tauriModel";
export const useAppDispatch = () => useDispatch<AppDispatch>();
@ -111,9 +111,7 @@ export function useIsSpecificSwapRunning(swapId: string | null) {
}
export function useIsContextAvailable() {
return useAppSelector(
(state) => state.rpc.status === TauriContextStatusEvent.Available,
);
return useAppSelector((state) => isContextFullyInitialized(state.rpc.status));
}
/// We do not use a sanity check here, as opposed to the other useSwapInfo hooks,
@ -139,10 +137,13 @@ export function useActiveSwapLogs() {
const swapId = useActiveSwapId();
const logs = useAppSelector((s) => s.logs.state.logs);
return useMemo(
() => logs.filter((log) => isCliLogRelatedToSwap(log, swapId)),
[logs, swapId],
);
return useMemo(() => {
if (swapId == null) {
return [];
}
return logs.filter((log) => isCliLogRelatedToSwap(log.log, swapId));
}, [logs, swapId]);
}
export function useAllMakers() {

View file

@ -11,7 +11,10 @@ import {
getCurrentMoneroNodeConfig,
} from "renderer/rpc";
import logger from "utils/logger";
import { contextStatusEventReceived } from "store/features/rpcSlice";
import {
contextStatusEventReceived,
ContextStatusType,
} from "store/features/rpcSlice";
import {
addNode,
setFetchFiatPrices,
@ -21,14 +24,12 @@ import {
Network,
} from "store/features/settingsSlice";
import { fetchFeedbackMessagesViaHttp, updateRates } from "renderer/api";
import { store } from "renderer/store/storeRenderer";
import { RootState, store } from "renderer/store/storeRenderer";
import { swapProgressEventReceived } from "store/features/swapSlice";
import {
addFeedbackId,
setConversation,
} from "store/features/conversationsSlice";
import { TauriContextStatusEvent, MoneroNodeConfig } from "models/tauriModel";
import { getNetwork } from "store/config";
// Create a Map to store throttled functions per swap_id
const throttledGetSwapInfoFunctions = new Map<
@ -60,30 +61,80 @@ const getThrottledSwapInfoUpdater = (swapId: string) => {
export function createMainListeners() {
const listener = createListenerMiddleware();
// Listener for when the Context becomes available
// Listener for when the Context status state changes
// When the context becomes available, we check the bitcoin balance, fetch all swap infos and connect to the rendezvous point
listener.startListening({
actionCreator: contextStatusEventReceived,
effect: async (action) => {
const status = action.payload;
predicate: (action, currentState, previousState) => {
const currentStatus = (currentState as RootState).rpc.status;
const previousStatus = (previousState as RootState).rpc.status;
// If the context is available, check the Bitcoin balance and fetch all swap infos
if (status === TauriContextStatusEvent.Available) {
logger.debug(
"Context is available, checking Bitcoin balance and history",
// Only trigger if the status actually changed
return currentStatus !== previousStatus;
},
effect: async (action, api) => {
const currentStatus = (api.getState() as RootState).rpc.status;
const previousStatus = (api.getOriginalState() as RootState).rpc.status;
const status =
currentStatus?.type === ContextStatusType.Status
? currentStatus.status
: null;
const previousContextStatus =
previousStatus?.type === ContextStatusType.Status
? previousStatus.status
: null;
if (!status) return;
// If the Bitcoin wallet just came available, check the Bitcoin balance
if (
status.bitcoin_wallet_available &&
!previousContextStatus?.bitcoin_wallet_available
) {
logger.info(
"Bitcoin wallet just became available, checking balance...",
);
await Promise.allSettled([
checkBitcoinBalance(),
getAllSwapInfos(),
fetchSellersAtPresetRendezvousPoints(),
initializeMoneroWallet(),
]);
await checkBitcoinBalance();
}
// If the Monero wallet just came available, initialize the Monero wallet
if (
status.monero_wallet_available &&
!previousContextStatus?.monero_wallet_available
) {
logger.info("Monero wallet just became available, initializing...");
await initializeMoneroWallet();
// Also set the Monero node to the current one
// In case the user changed this WHILE the context was unavailable
const nodeConfig = await getCurrentMoneroNodeConfig();
await changeMoneroNode(nodeConfig);
}
// If the database and Bitcoin wallet just came available, fetch all swap infos
if (
status.database_available &&
status.bitcoin_wallet_available &&
!(
previousContextStatus?.database_available &&
previousContextStatus?.bitcoin_wallet_available
)
) {
logger.info(
"Database & Bitcoin wallet just became available, fetching swap infos...",
);
await getAllSwapInfos();
}
// If the database just became availiable, fetch sellers at preset rendezvous points
if (
status.database_available &&
!previousContextStatus?.database_available
) {
logger.info(
"Database just became available, fetching sellers at preset rendezvous points...",
);
await fetchSellersAtPresetRendezvousPoints();
}
},
});
@ -151,9 +202,9 @@ export function createMainListeners() {
try {
const nodeConfig = await getCurrentMoneroNodeConfig();
await changeMoneroNode(nodeConfig);
logger.info("Changed Monero node configuration to: ", nodeConfig);
logger.info({ nodeConfig }, "Changed Monero node configuration to: ");
} catch (error) {
logger.error("Failed to change Monero node configuration:", error);
logger.error({ error }, "Failed to change Monero node configuration:");
}
},
});

13
src-gui/src/utils/hash.ts Normal file
View file

@ -0,0 +1,13 @@
const FNV_OFFSET_BASIS = 0x811c9dc5;
const FNV_PRIME = 0x01000193;
export function fnv1a(value: string): string {
let hash = FNV_OFFSET_BASIS;
for (let i = 0; i < value.length; i += 1) {
hash ^= value.charCodeAt(i);
hash = (hash * FNV_PRIME) >>> 0;
}
return hash.toString(16).padStart(8, "0");
}

View file

@ -2,16 +2,7 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop-capability",
"description": "Capabilities for desktop windows",
"platforms": [
"macOS",
"windows",
"linux"
],
"windows": [
"main"
],
"permissions": [
"cli:default",
"cli:allow-cli-matches"
]
"platforms": ["macOS", "windows", "linux"],
"windows": ["main"],
"permissions": ["cli:default", "cli:allow-cli-matches"]
}

View file

@ -1,116 +1,116 @@
{
"images" : [
"images": [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@2x.png",
"scale" : "2x"
"size": "20x20",
"idiom": "iphone",
"filename": "AppIcon-20x20@2x.png",
"scale": "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "AppIcon-20x20@3x.png",
"scale" : "3x"
"size": "20x20",
"idiom": "iphone",
"filename": "AppIcon-20x20@3x.png",
"scale": "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@2x-1.png",
"scale" : "2x"
"size": "29x29",
"idiom": "iphone",
"filename": "AppIcon-29x29@2x-1.png",
"scale": "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "AppIcon-29x29@3x.png",
"scale" : "3x"
"size": "29x29",
"idiom": "iphone",
"filename": "AppIcon-29x29@3x.png",
"scale": "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@2x.png",
"scale" : "2x"
"size": "40x40",
"idiom": "iphone",
"filename": "AppIcon-40x40@2x.png",
"scale": "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "AppIcon-40x40@3x.png",
"scale" : "3x"
"size": "40x40",
"idiom": "iphone",
"filename": "AppIcon-40x40@3x.png",
"scale": "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@2x.png",
"scale" : "2x"
"size": "60x60",
"idiom": "iphone",
"filename": "AppIcon-60x60@2x.png",
"scale": "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "AppIcon-60x60@3x.png",
"scale" : "3x"
"size": "60x60",
"idiom": "iphone",
"filename": "AppIcon-60x60@3x.png",
"scale": "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@1x.png",
"scale" : "1x"
"size": "20x20",
"idiom": "ipad",
"filename": "AppIcon-20x20@1x.png",
"scale": "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "AppIcon-20x20@2x-1.png",
"scale" : "2x"
"size": "20x20",
"idiom": "ipad",
"filename": "AppIcon-20x20@2x-1.png",
"scale": "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@1x.png",
"scale" : "1x"
"size": "29x29",
"idiom": "ipad",
"filename": "AppIcon-29x29@1x.png",
"scale": "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "AppIcon-29x29@2x.png",
"scale" : "2x"
"size": "29x29",
"idiom": "ipad",
"filename": "AppIcon-29x29@2x.png",
"scale": "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@1x.png",
"scale" : "1x"
"size": "40x40",
"idiom": "ipad",
"filename": "AppIcon-40x40@1x.png",
"scale": "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "AppIcon-40x40@2x-1.png",
"scale" : "2x"
"size": "40x40",
"idiom": "ipad",
"filename": "AppIcon-40x40@2x-1.png",
"scale": "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@1x.png",
"scale" : "1x"
"size": "76x76",
"idiom": "ipad",
"filename": "AppIcon-76x76@1x.png",
"scale": "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "AppIcon-76x76@2x.png",
"scale" : "2x"
"size": "76x76",
"idiom": "ipad",
"filename": "AppIcon-76x76@2x.png",
"scale": "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "AppIcon-83.5x83.5@2x.png",
"scale" : "2x"
"size": "83.5x83.5",
"idiom": "ipad",
"filename": "AppIcon-83.5x83.5@2x.png",
"scale": "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "AppIcon-512@2x.png",
"scale" : "1x"
"size": "1024x1024",
"idiom": "ios-marketing",
"filename": "AppIcon-512@2x.png",
"scale": "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"info": {
"version": 1,
"author": "xcode"
}
}

View file

@ -1,6 +1,6 @@
{
"info" : {
"version" : 1,
"author" : "xcode"
"info": {
"version": 1,
"author": "xcode"
}
}

452
src-tauri/src/commands.rs Normal file
View file

@ -0,0 +1,452 @@
use std::collections::HashMap;
use std::io::Write;
use std::result::Result;
use swap::cli::{
api::{
data,
request::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ChangeMoneroNodeArgs,
CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs,
CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse,
ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs,
GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs,
GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSyncProgressArgs,
GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs,
GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs,
RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs,
SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
},
tauri_bindings::{ContextStatus, TauriSettings},
ContextBuilder,
},
command::Bitcoin,
};
use tauri_plugin_dialog::DialogExt;
use zip::{write::SimpleFileOptions, ZipWriter};
use crate::{commands::util::ToStringResult, State};
/// This macro returns the list of all command handlers
/// You can call this and insert the output into [`tauri::app::Builder::invoke_handler`]
///
/// Note: When you add a new command, add it here.
#[macro_export]
macro_rules! generate_command_handlers {
() => {
tauri::generate_handler![
get_balance,
get_monero_addresses,
get_swap_info,
get_swap_infos_all,
withdraw_btc,
buy_xmr,
resume_swap,
get_history,
monero_recovery,
get_logs,
list_sellers,
suspend_current_swap,
cancel_and_refund,
initialize_context,
check_monero_node,
check_electrum_node,
get_wallet_descriptor,
get_current_swap,
get_data_dir,
resolve_approval_request,
redact,
save_txt_files,
get_monero_history,
get_monero_main_address,
get_monero_balance,
send_monero,
get_monero_sync_progress,
get_monero_seed,
check_seed,
get_pending_approvals,
set_monero_restore_height,
reject_approval_request,
get_restore_height,
dfx_authenticate,
change_monero_node,
get_context_status
]
};
}
#[macro_use]
mod util {
use std::result::Result;
/// Trait to convert Result<T, E> to Result<T, String>
/// Tauri commands require the error type to be a string
pub(crate) trait ToStringResult<T> {
fn to_string_result(self) -> Result<T, String>;
}
impl<T, E: ToString> ToStringResult<T> for Result<T, E> {
fn to_string_result(self) -> Result<T, String> {
self.map_err(|e| e.to_string())
}
}
/// This macro is used to create boilerplate functions as tauri commands
/// that simply delegate handling to the respective request type.
///
/// # Example
/// ```ignored
/// tauri_command!(get_balance, BalanceArgs);
/// ```
/// will resolve to
/// ```ignored
/// #[tauri::command]
/// async fn get_balance(context: tauri::State<'...>, args: BalanceArgs) -> Result<BalanceArgs::Response, String> {
/// args.handle(context.inner().clone()).await.to_string_result()
/// }
/// ```
/// # Example 2
/// ```ignored
/// tauri_command!(get_balance, BalanceArgs, no_args);
/// ```
/// will resolve to
/// ```ignored
/// #[tauri::command]
/// async fn get_balance(context: tauri::State<'...>) -> Result<BalanceArgs::Response, String> {
/// BalanceArgs {}.handle(context.inner().clone()).await.to_string_result()
/// }
/// ```
macro_rules! tauri_command {
($fn_name:ident, $request_name:ident) => {
#[tauri::command]
pub async fn $fn_name(
state: tauri::State<'_, State>,
args: $request_name,
) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> {
<$request_name as swap::cli::api::request::Request>::request(args, state.context())
.await
.to_string_result()
}
};
($fn_name:ident, $request_name:ident, no_args) => {
#[tauri::command]
pub async fn $fn_name(
state: tauri::State<'_, State>,
) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> {
<$request_name as swap::cli::api::request::Request>::request(
$request_name {},
state.context(),
)
.await
.to_string_result()
}
};
}
}
/// Tauri command to initialize the Context
#[tauri::command]
pub async fn initialize_context(
settings: TauriSettings,
testnet: bool,
state: tauri::State<'_, State>,
) -> Result<(), String> {
// We want to prevent multiple initalizations at the same time
let _context_lock = state
.context_lock
.try_lock()
.map_err(|_| "Context is already being initialized".to_string())?;
// Fail if the context is already initialized
// TODO: Maybe skip the stuff below if one of the context fields is already initialized?
// if context_lock.is_some() {
// return Err("Context is already initialized".to_string());
// }
// Get tauri handle from the state
let tauri_handle = state.handle.clone();
// Now populate the context in the background
let context_result = ContextBuilder::new(testnet)
.with_bitcoin(Bitcoin {
bitcoin_electrum_rpc_urls: settings.electrum_rpc_urls.clone(),
bitcoin_target_block: None,
})
.with_monero(settings.monero_node_config)
.with_json(false)
.with_debug(true)
.with_tor(settings.use_tor)
.with_enable_monero_tor(settings.enable_monero_tor)
.with_tauri(tauri_handle.clone())
.build(state.context())
.await;
match context_result {
Ok(()) => {
tracing::info!("Context initialized");
Ok(())
}
Err(e) => {
tracing::error!(error = ?e, "Failed to initialize context");
Err(e.to_string())
}
}
}
#[tauri::command]
pub async fn get_context_status(state: tauri::State<'_, State>) -> Result<ContextStatus, String> {
Ok(state.context().status().await)
}
#[tauri::command]
pub async fn resolve_approval_request(
args: ResolveApprovalArgs,
state: tauri::State<'_, State>,
) -> Result<(), String> {
let request_id = args
.request_id
.parse()
.map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?;
state
.handle
.resolve_approval(request_id, args.accept)
.await
.to_string_result()?;
Ok(())
}
#[tauri::command]
pub async fn reject_approval_request(
args: RejectApprovalArgs,
state: tauri::State<'_, State>,
) -> Result<RejectApprovalResponse, String> {
let request_id = args
.request_id
.parse()
.map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?;
state
.handle
.reject_approval(request_id)
.await
.to_string_result()?;
Ok(RejectApprovalResponse { success: true })
}
#[tauri::command]
pub async fn get_pending_approvals(
state: tauri::State<'_, State>,
) -> Result<GetPendingApprovalsResponse, String> {
let approvals = state
.handle
.get_pending_approvals()
.await
.to_string_result()?;
Ok(GetPendingApprovalsResponse { approvals })
}
#[tauri::command]
pub async fn check_monero_node(
args: CheckMoneroNodeArgs,
_: tauri::State<'_, State>,
) -> Result<CheckMoneroNodeResponse, String> {
args.request().await.to_string_result()
}
#[tauri::command]
pub async fn check_electrum_node(
args: CheckElectrumNodeArgs,
_: tauri::State<'_, State>,
) -> Result<CheckElectrumNodeResponse, String> {
args.request().await.to_string_result()
}
#[tauri::command]
pub async fn check_seed(
args: CheckSeedArgs,
_: tauri::State<'_, State>,
) -> Result<CheckSeedResponse, String> {
args.request().await.to_string_result()
}
// Returns the data directory
// This is independent of the context to ensure the user can open the directory even if the context cannot
// be initialized (for troubleshooting purposes)
#[tauri::command]
pub async fn get_data_dir(
args: GetDataDirArgs,
_: tauri::State<'_, State>,
) -> Result<String, String> {
Ok(data::data_dir_from(None, args.is_testnet)
.to_string_result()?
.to_string_lossy()
.to_string())
}
#[tauri::command]
pub async fn save_txt_files(
app: tauri::AppHandle,
zip_file_name: String,
content: HashMap<String, String>,
) -> Result<(), String> {
// Step 1: Get the owned PathBuf from the dialog
let path_buf_from_dialog: tauri_plugin_dialog::FilePath = app
.dialog()
.file()
.set_file_name(format!("{}.zip", &zip_file_name).as_str())
.add_filter(&zip_file_name, &["zip"])
.blocking_save_file() // This returns Option<PathBuf>
.ok_or_else(|| "Dialog cancelled or file path not selected".to_string())?; // Converts to Result<PathBuf, String> and unwraps to PathBuf
// Step 2: Now get a &Path reference from the owned PathBuf.
// The user's code structure implied an .as_path().ok_or_else(...) chain which was incorrect for &Path.
// We'll directly use the PathBuf, or if &Path is strictly needed:
let selected_file_path: &std::path::Path = path_buf_from_dialog
.as_path()
.ok_or_else(|| "Could not convert file path".to_string())?;
let zip_file = std::fs::File::create(selected_file_path)
.map_err(|e| format!("Failed to create file: {}", e))?;
let mut zip = ZipWriter::new(zip_file);
for (filename, file_content_str) in content.iter() {
zip.start_file(
format!("{}.txt", filename).as_str(),
SimpleFileOptions::default(),
) // Pass &str to start_file
.map_err(|e| format!("Failed to start file {}: {}", &filename, e))?; // Use &filename
zip.write_all(file_content_str.as_bytes())
.map_err(|e| format!("Failed to write to file {}: {}", &filename, e))?;
// Use &filename
}
zip.finish()
.map_err(|e| format!("Failed to finish zip: {}", e))?;
Ok(())
}
#[tauri::command]
pub async fn dfx_authenticate(
state: tauri::State<'_, State>,
) -> Result<DfxAuthenticateResponse, String> {
use dfx_swiss_sdk::{DfxClient, SignRequest};
use tokio::sync::{mpsc, oneshot};
use tokio_util::task::AbortOnDropHandle;
let context = state.context();
// Get the monero wallet manager
let monero_manager = context
.try_get_monero_manager()
.await
.map_err(|_| "Monero wallet manager not available for DFX authentication".to_string())?;
let wallet = monero_manager.main_wallet().await;
let address = wallet.main_address().await.to_string();
// Create channel for authentication
let (auth_tx, mut auth_rx) = mpsc::channel::<(SignRequest, oneshot::Sender<String>)>(10);
// Create DFX client
let mut client = DfxClient::new(address, Some("https://api.dfx.swiss".to_string()), auth_tx);
// Start signing task with AbortOnDropHandle
let signing_task = tokio::spawn(async move {
tracing::info!("DFX signing service started and listening for requests");
while let Some((sign_request, response_tx)) = auth_rx.recv().await {
tracing::debug!(
message = %sign_request.message,
blockchains = ?sign_request.blockchains,
"Received DFX signing request"
);
// Sign the message using the main Monero wallet
let signature = match wallet
.sign_message(&sign_request.message, None, false)
.await
{
Ok(sig) => {
tracing::debug!(
signature_preview = %&sig[..std::cmp::min(50, sig.len())],
"Message signed successfully for DFX"
);
sig
}
Err(e) => {
tracing::error!(error = ?e, "Failed to sign message for DFX");
continue;
}
};
// Send signature back to DFX client
if let Err(_) = response_tx.send(signature) {
tracing::warn!("Failed to send signature response through channel to DFX client");
}
}
tracing::info!("DFX signing service stopped");
});
// Create AbortOnDropHandle so the task gets cleaned up
let _abort_handle = AbortOnDropHandle::new(signing_task);
// Authenticate with DFX
tracing::info!("Starting DFX authentication...");
client
.authenticate()
.await
.map_err(|e| format!("Failed to authenticate with DFX: {}", e))?;
let access_token = client
.access_token
.as_ref()
.ok_or("No access token available after authentication")?
.clone();
let kyc_url = format!("https://app.dfx.swiss/buy?session={}", access_token);
tracing::info!("DFX authentication completed successfully");
Ok(DfxAuthenticateResponse {
access_token,
kyc_url,
})
}
// Here we define the Tauri commands that will be available to the frontend
// The commands are defined using the `tauri_command!` macro.
// Implementations are handled by the Request trait
tauri_command!(get_balance, BalanceArgs);
tauri_command!(buy_xmr, BuyXmrArgs);
tauri_command!(resume_swap, ResumeSwapArgs);
tauri_command!(withdraw_btc, WithdrawBtcArgs);
tauri_command!(monero_recovery, MoneroRecoveryArgs);
tauri_command!(get_logs, GetLogsArgs);
tauri_command!(list_sellers, ListSellersArgs);
tauri_command!(cancel_and_refund, CancelAndRefundArgs);
tauri_command!(redact, RedactArgs);
tauri_command!(send_monero, SendMoneroArgs);
tauri_command!(change_monero_node, ChangeMoneroNodeArgs);
// These commands require no arguments
tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args);
tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args);
tauri_command!(get_swap_info, GetSwapInfoArgs);
tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args);
tauri_command!(get_history, GetHistoryArgs, no_args);
tauri_command!(get_monero_addresses, GetMoneroAddressesArgs, no_args);
tauri_command!(get_monero_history, GetMoneroHistoryArgs, no_args);
tauri_command!(get_current_swap, GetCurrentSwapArgs, no_args);
tauri_command!(set_monero_restore_height, SetRestoreHeightArgs);
tauri_command!(get_restore_height, GetRestoreHeightArgs, no_args);
tauri_command!(get_monero_main_address, GetMoneroMainAddressArgs, no_args);
tauri_command!(get_monero_balance, GetMoneroBalanceArgs, no_args);
tauri_command!(get_monero_sync_progress, GetMoneroSyncProgressArgs, no_args);
tauri_command!(get_monero_seed, GetMoneroSeedArgs, no_args);

View file

@ -1,121 +1,43 @@
use std::collections::HashMap;
use std::io::Write;
use std::result::Result;
use std::sync::Arc;
use swap::cli::{
api::{
data,
request::{
BalanceArgs, BuyXmrArgs, CancelAndRefundArgs, ChangeMoneroNodeArgs,
CheckElectrumNodeArgs, CheckElectrumNodeResponse, CheckMoneroNodeArgs,
CheckMoneroNodeResponse, CheckSeedArgs, CheckSeedResponse, DfxAuthenticateResponse,
ExportBitcoinWalletArgs, GetCurrentSwapArgs, GetDataDirArgs, GetHistoryArgs,
GetLogsArgs, GetMoneroAddressesArgs, GetMoneroBalanceArgs, GetMoneroHistoryArgs,
GetMoneroMainAddressArgs, GetMoneroSeedArgs, GetMoneroSyncProgressArgs,
GetPendingApprovalsResponse, GetRestoreHeightArgs, GetSwapInfoArgs,
GetSwapInfosAllArgs, ListSellersArgs, MoneroRecoveryArgs, RedactArgs,
RejectApprovalArgs, RejectApprovalResponse, ResolveApprovalArgs, ResumeSwapArgs,
SendMoneroArgs, SetRestoreHeightArgs, SuspendCurrentSwapArgs, WithdrawBtcArgs,
},
tauri_bindings::{TauriContextStatusEvent, TauriEmitter, TauriHandle, TauriSettings},
Context, ContextBuilder,
},
command::Bitcoin,
};
use tauri::{async_runtime::RwLock, Manager, RunEvent};
use tauri_plugin_dialog::DialogExt;
use zip::{write::SimpleFileOptions, ZipWriter};
use swap::cli::api::{tauri_bindings::TauriHandle, Context};
use tauri::{Manager, RunEvent};
use tokio::sync::Mutex;
/// Trait to convert Result<T, E> to Result<T, String>
/// Tauri commands require the error type to be a string
trait ToStringResult<T> {
fn to_string_result(self) -> Result<T, String>;
}
mod commands;
impl<T, E: ToString> ToStringResult<T> for Result<T, E> {
fn to_string_result(self) -> Result<T, String> {
self.map_err(|e| e.to_string())
}
}
/// This macro is used to create boilerplate functions as tauri commands
/// that simply delegate handling to the respective request type.
///
/// # Example
/// ```ignored
/// tauri_command!(get_balance, BalanceArgs);
/// ```
/// will resolve to
/// ```ignored
/// #[tauri::command]
/// async fn get_balance(context: tauri::State<'...>, args: BalanceArgs) -> Result<BalanceArgs::Response, String> {
/// args.handle(context.inner().clone()).await.to_string_result()
/// }
/// ```
/// # Example 2
/// ```ignored
/// tauri_command!(get_balance, BalanceArgs, no_args);
/// ```
/// will resolve to
/// ```ignored
/// #[tauri::command]
/// async fn get_balance(context: tauri::State<'...>) -> Result<BalanceArgs::Response, String> {
/// BalanceArgs {}.handle(context.inner().clone()).await.to_string_result()
/// }
/// ```
macro_rules! tauri_command {
($fn_name:ident, $request_name:ident) => {
#[tauri::command]
async fn $fn_name(
state: tauri::State<'_, State>,
args: $request_name,
) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> {
// Throw error if context is not available
let context = state.try_get_context()?;
<$request_name as swap::cli::api::request::Request>::request(args, context)
.await
.to_string_result()
}
};
($fn_name:ident, $request_name:ident, no_args) => {
#[tauri::command]
async fn $fn_name(
state: tauri::State<'_, State>,
) -> Result<<$request_name as swap::cli::api::request::Request>::Response, String> {
// Throw error if context is not available
let context = state.try_get_context()?;
<$request_name as swap::cli::api::request::Request>::request($request_name {}, context)
.await
.to_string_result()
}
};
}
use commands::*;
/// Represents the shared Tauri state. It is accessed by Tauri commands
struct State {
pub context: RwLock<Option<Arc<Context>>>,
pub context: Arc<Context>,
/// Whenever someone wants to modify the context, they should acquire this lock
///
/// [`Context`] uses RwLock internally which means we do not need write access to the context
/// to modify its internal state.
///
/// However, we want to avoid multiple processes intializing the context at the same time.
pub context_lock: Mutex<()>,
pub handle: TauriHandle,
}
impl State {
/// Creates a new State instance with no Context
fn new(handle: TauriHandle) -> Self {
let context = Arc::new(Context::new_with_tauri_handle(handle.clone()));
let context_lock = Mutex::new(());
Self {
context: RwLock::new(None),
context,
context_lock,
handle,
}
}
/// Attempts to retrieve the context
/// Returns an error if the context is not available
fn try_get_context(&self) -> Result<Arc<Context>, String> {
self.context
.try_read()
.map_err(|_| "Context is being modified".to_string())?
.clone()
.ok_or("Context not available".to_string())
fn context(&self) -> Arc<Context> {
self.context.clone()
}
}
@ -177,44 +99,7 @@ pub fn run() {
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
get_balance,
get_monero_addresses,
get_swap_info,
get_swap_infos_all,
withdraw_btc,
buy_xmr,
resume_swap,
get_history,
monero_recovery,
get_logs,
list_sellers,
suspend_current_swap,
cancel_and_refund,
is_context_available,
initialize_context,
check_monero_node,
check_electrum_node,
get_wallet_descriptor,
get_current_swap,
get_data_dir,
resolve_approval_request,
redact,
save_txt_files,
get_monero_history,
get_monero_main_address,
get_monero_balance,
send_monero,
get_monero_sync_progress,
get_monero_seed,
check_seed,
get_pending_approvals,
set_monero_restore_height,
reject_approval_request,
get_restore_height,
dfx_authenticate,
change_monero_node,
])
.invoke_handler(generate_command_handlers!())
.setup(setup)
.build(tauri::generate_context!())
.expect("error while building tauri application")
@ -225,335 +110,13 @@ pub fn run() {
// If the application is forcibly closed, this may not be called.
// TODO: fix that
let state = app.state::<State>();
let context_to_cleanup = if let Ok(context_lock) = state.context.try_read() {
context_lock.clone()
} else {
println!("Failed to acquire lock on context");
None
};
if let Some(context) = context_to_cleanup {
if let Err(err) = context.cleanup() {
println!("Cleanup failed {}", err);
let lock = state.context_lock.try_lock();
if let Ok(_) = lock {
if let Err(e) = state.context().cleanup() {
println!("Failed to cleanup context: {}", e);
}
}
}
_ => {}
})
}
// Here we define the Tauri commands that will be available to the frontend
// The commands are defined using the `tauri_command!` macro.
// Implementations are handled by the Request trait
tauri_command!(get_balance, BalanceArgs);
tauri_command!(buy_xmr, BuyXmrArgs);
tauri_command!(resume_swap, ResumeSwapArgs);
tauri_command!(withdraw_btc, WithdrawBtcArgs);
tauri_command!(monero_recovery, MoneroRecoveryArgs);
tauri_command!(get_logs, GetLogsArgs);
tauri_command!(list_sellers, ListSellersArgs);
tauri_command!(cancel_and_refund, CancelAndRefundArgs);
tauri_command!(redact, RedactArgs);
tauri_command!(send_monero, SendMoneroArgs);
tauri_command!(change_monero_node, ChangeMoneroNodeArgs);
// These commands require no arguments
tauri_command!(get_wallet_descriptor, ExportBitcoinWalletArgs, no_args);
tauri_command!(suspend_current_swap, SuspendCurrentSwapArgs, no_args);
tauri_command!(get_swap_info, GetSwapInfoArgs);
tauri_command!(get_swap_infos_all, GetSwapInfosAllArgs, no_args);
tauri_command!(get_history, GetHistoryArgs, no_args);
tauri_command!(get_monero_addresses, GetMoneroAddressesArgs, no_args);
tauri_command!(get_monero_history, GetMoneroHistoryArgs, no_args);
tauri_command!(get_current_swap, GetCurrentSwapArgs, no_args);
tauri_command!(set_monero_restore_height, SetRestoreHeightArgs);
tauri_command!(get_restore_height, GetRestoreHeightArgs, no_args);
tauri_command!(get_monero_main_address, GetMoneroMainAddressArgs, no_args);
tauri_command!(get_monero_balance, GetMoneroBalanceArgs, no_args);
tauri_command!(get_monero_sync_progress, GetMoneroSyncProgressArgs, no_args);
tauri_command!(get_monero_seed, GetMoneroSeedArgs, no_args);
/// Here we define Tauri commands whose implementation is not delegated to the Request trait
#[tauri::command]
async fn is_context_available(state: tauri::State<'_, State>) -> Result<bool, String> {
// TODO: Here we should return more information about status of the context (e.g. initializing, failed)
Ok(state.try_get_context().is_ok())
}
#[tauri::command]
async fn check_monero_node(
args: CheckMoneroNodeArgs,
_: tauri::State<'_, State>,
) -> Result<CheckMoneroNodeResponse, String> {
args.request().await.to_string_result()
}
#[tauri::command]
async fn check_electrum_node(
args: CheckElectrumNodeArgs,
_: tauri::State<'_, State>,
) -> Result<CheckElectrumNodeResponse, String> {
args.request().await.to_string_result()
}
#[tauri::command]
async fn check_seed(
args: CheckSeedArgs,
_: tauri::State<'_, State>,
) -> Result<CheckSeedResponse, String> {
args.request().await.to_string_result()
}
// Returns the data directory
// This is independent of the context to ensure the user can open the directory even if the context cannot
// be initialized (for troubleshooting purposes)
#[tauri::command]
async fn get_data_dir(args: GetDataDirArgs, _: tauri::State<'_, State>) -> Result<String, String> {
Ok(data::data_dir_from(None, args.is_testnet)
.to_string_result()?
.to_string_lossy()
.to_string())
}
#[tauri::command]
async fn save_txt_files(
app: tauri::AppHandle,
zip_file_name: String,
content: HashMap<String, String>,
) -> Result<(), String> {
// Step 1: Get the owned PathBuf from the dialog
let path_buf_from_dialog: tauri_plugin_dialog::FilePath = app
.dialog()
.file()
.set_file_name(format!("{}.zip", &zip_file_name).as_str())
.add_filter(&zip_file_name, &["zip"])
.blocking_save_file() // This returns Option<PathBuf>
.ok_or_else(|| "Dialog cancelled or file path not selected".to_string())?; // Converts to Result<PathBuf, String> and unwraps to PathBuf
// Step 2: Now get a &Path reference from the owned PathBuf.
// The user's code structure implied an .as_path().ok_or_else(...) chain which was incorrect for &Path.
// We'll directly use the PathBuf, or if &Path is strictly needed:
let selected_file_path: &std::path::Path = path_buf_from_dialog
.as_path()
.ok_or_else(|| "Could not convert file path".to_string())?;
let zip_file = std::fs::File::create(selected_file_path)
.map_err(|e| format!("Failed to create file: {}", e))?;
let mut zip = ZipWriter::new(zip_file);
for (filename, file_content_str) in content.iter() {
zip.start_file(
format!("{}.txt", filename).as_str(),
SimpleFileOptions::default(),
) // Pass &str to start_file
.map_err(|e| format!("Failed to start file {}: {}", &filename, e))?; // Use &filename
zip.write_all(file_content_str.as_bytes())
.map_err(|e| format!("Failed to write to file {}: {}", &filename, e))?;
// Use &filename
}
zip.finish()
.map_err(|e| format!("Failed to finish zip: {}", e))?;
Ok(())
}
#[tauri::command]
async fn resolve_approval_request(
args: ResolveApprovalArgs,
state: tauri::State<'_, State>,
) -> Result<(), String> {
let request_id = args
.request_id
.parse()
.map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?;
state
.handle
.resolve_approval(request_id, args.accept)
.await
.to_string_result()?;
Ok(())
}
#[tauri::command]
async fn reject_approval_request(
args: RejectApprovalArgs,
state: tauri::State<'_, State>,
) -> Result<RejectApprovalResponse, String> {
let request_id = args
.request_id
.parse()
.map_err(|e| format!("Invalid request ID '{}': {}", args.request_id, e))?;
state
.handle
.reject_approval(request_id)
.await
.to_string_result()?;
Ok(RejectApprovalResponse { success: true })
}
#[tauri::command]
async fn get_pending_approvals(
state: tauri::State<'_, State>,
) -> Result<GetPendingApprovalsResponse, String> {
let approvals = state
.handle
.get_pending_approvals()
.await
.to_string_result()?;
Ok(GetPendingApprovalsResponse { approvals })
}
/// Tauri command to initialize the Context
#[tauri::command]
async fn initialize_context(
settings: TauriSettings,
testnet: bool,
state: tauri::State<'_, State>,
) -> Result<(), String> {
// Lock at the beginning - fail immediately if already locked
let mut context_lock = state
.context
.try_write()
.map_err(|_| "Context is already being initialized".to_string())?;
// Fail if the context is already initialized
if context_lock.is_some() {
return Err("Context is already initialized".to_string());
}
// Get tauri handle from the state
let tauri_handle = state.handle.clone();
// Notify frontend that the context is being initialized
tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Initializing);
let context_result = ContextBuilder::new(testnet)
.with_bitcoin(Bitcoin {
bitcoin_electrum_rpc_urls: settings.electrum_rpc_urls.clone(),
bitcoin_target_block: None,
})
.with_monero(settings.monero_node_config)
.with_json(false)
.with_debug(true)
.with_tor(settings.use_tor)
.with_enable_monero_tor(settings.enable_monero_tor)
.with_tauri(tauri_handle.clone())
.build()
.await;
match context_result {
Ok(context_instance) => {
*context_lock = Some(Arc::new(context_instance));
tracing::info!("Context initialized");
// Emit event to frontend
tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Available);
Ok(())
}
Err(e) => {
tracing::error!(error = ?e, "Failed to initialize context");
// Emit event to frontend
tauri_handle.emit_context_init_progress_event(TauriContextStatusEvent::Failed);
Err(e.to_string())
}
}
}
#[tauri::command]
async fn dfx_authenticate(
state: tauri::State<'_, State>,
) -> Result<DfxAuthenticateResponse, String> {
use dfx_swiss_sdk::{DfxClient, SignRequest};
use tokio::sync::{mpsc, oneshot};
use tokio_util::task::AbortOnDropHandle;
let context = state.try_get_context()?;
// Get the monero wallet manager
let monero_manager = context
.monero_manager
.as_ref()
.ok_or("Monero wallet manager not available for DFX authentication")?;
let wallet = monero_manager.main_wallet().await;
let address = wallet.main_address().await.to_string();
// Create channel for authentication
let (auth_tx, mut auth_rx) = mpsc::channel::<(SignRequest, oneshot::Sender<String>)>(10);
// Create DFX client
let mut client = DfxClient::new(address, Some("https://api.dfx.swiss".to_string()), auth_tx);
// Start signing task with AbortOnDropHandle
let signing_task = tokio::spawn(async move {
tracing::info!("DFX signing service started and listening for requests");
while let Some((sign_request, response_tx)) = auth_rx.recv().await {
tracing::debug!(
message = %sign_request.message,
blockchains = ?sign_request.blockchains,
"Received DFX signing request"
);
// Sign the message using the main Monero wallet
let signature = match wallet
.sign_message(&sign_request.message, None, false)
.await
{
Ok(sig) => {
tracing::debug!(
signature_preview = %&sig[..std::cmp::min(50, sig.len())],
"Message signed successfully for DFX"
);
sig
}
Err(e) => {
tracing::error!(error = ?e, "Failed to sign message for DFX");
continue;
}
};
// Send signature back to DFX client
if let Err(_) = response_tx.send(signature) {
tracing::warn!("Failed to send signature response through channel to DFX client");
}
}
tracing::info!("DFX signing service stopped");
});
// Create AbortOnDropHandle so the task gets cleaned up
let _abort_handle = AbortOnDropHandle::new(signing_task);
// Authenticate with DFX
tracing::info!("Starting DFX authentication...");
client
.authenticate()
.await
.map_err(|e| format!("Failed to authenticate with DFX: {}", e))?;
let access_token = client
.access_token
.as_ref()
.ok_or("No access token available after authentication")?
.clone();
let kyc_url = format!("https://app.dfx.swiss/buy?session={}", access_token);
tracing::info!("DFX authentication completed successfully");
Ok(DfxAuthenticateResponse {
access_token,
kyc_url,
})
}

File diff suppressed because it is too large Load diff

View file

@ -421,7 +421,8 @@ impl Request for GetLogsArgs {
type Response = GetLogsResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let dir = self.logs_dir.unwrap_or(ctx.config.log_dir.clone());
let config = ctx.try_get_config().await?;
let dir = self.logs_dir.unwrap_or(config.log_dir.clone());
let logs = get_logs(dir, self.swap_id, self.redact).await?;
for msg in &logs {
@ -470,11 +471,8 @@ impl Request for GetRestoreHeightArgs {
type Response = GetRestoreHeightResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet.main_wallet().await;
let wallet_manager = ctx.try_get_monero_manager().await?;
let wallet = wallet_manager.main_wallet().await;
let height = wallet.get_restore_height().await?;
Ok(GetRestoreHeightResponse { height })
@ -496,7 +494,8 @@ impl Request for GetMoneroAddressesArgs {
type Response = GetMoneroAddressesResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let addresses = ctx.db.get_monero_addresses().await?;
let db = ctx.try_get_db().await?;
let addresses = db.get_monero_addresses().await?;
Ok(GetMoneroAddressesResponse { addresses })
}
}
@ -515,11 +514,8 @@ impl Request for GetMoneroHistoryArgs {
type Response = GetMoneroHistoryResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet.main_wallet().await;
let wallet_manager = ctx.try_get_monero_manager().await?;
let wallet = wallet_manager.main_wallet().await;
let transactions = wallet.history().await;
Ok(GetMoneroHistoryResponse { transactions })
@ -541,11 +537,8 @@ impl Request for GetMoneroMainAddressArgs {
type Response = GetMoneroMainAddressResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet.main_wallet().await;
let wallet_manager = ctx.try_get_monero_manager().await?;
let wallet = wallet_manager.main_wallet().await;
let address = wallet.main_address().await;
Ok(GetMoneroMainAddressResponse { address })
}
@ -582,11 +575,8 @@ impl Request for SetRestoreHeightArgs {
type Response = SetRestoreHeightResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet = wallet.main_wallet().await;
let wallet_manager = ctx.try_get_monero_manager().await?;
let wallet = wallet_manager.main_wallet().await;
let height = match self {
SetRestoreHeightArgs::Height(height) => height as u64,
@ -661,10 +651,7 @@ impl Request for GetMoneroBalanceArgs {
type Response = GetMoneroBalanceResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet_manager = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet_manager = ctx.try_get_monero_manager().await?;
let wallet = wallet_manager.main_wallet().await;
let total_balance = wallet.total_balance().await;
@ -706,10 +693,7 @@ impl Request for SendMoneroArgs {
type Response = SendMoneroResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet_manager = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet_manager = ctx.try_get_monero_manager().await?;
let wallet = wallet_manager.main_wallet().await;
// Parse the address
@ -717,7 +701,8 @@ impl Request for SendMoneroArgs {
.map_err(|e| anyhow::anyhow!("Invalid Monero address: {}", e))?;
let tauri_handle = ctx
.tauri_handle()
.tauri_handle
.clone()
.context("Tauri needs to be available to approve transactions")?;
// This is a closure that will be called by the monero-sys library to get approval for the transaction
@ -793,7 +778,8 @@ pub async fn suspend_current_swap(context: Arc<Context>) -> Result<SuspendCurren
#[tracing::instrument(fields(method = "get_swap_infos_all"), skip(context))]
pub async fn get_swap_infos_all(context: Arc<Context>) -> Result<Vec<GetSwapInfoResponse>> {
let swap_ids = context.db.all().await?;
let db = context.try_get_db().await?;
let swap_ids = db.all().await?;
let mut swap_infos = Vec::new();
for (swap_id, _) in swap_ids {
@ -813,27 +799,23 @@ pub async fn get_swap_info(
args: GetSwapInfoArgs,
context: Arc<Context>,
) -> Result<GetSwapInfoResponse> {
let bitcoin_wallet = context
.bitcoin_wallet
.as_ref()
.context("Could not get Bitcoin wallet")?;
let bitcoin_wallet = context.try_get_bitcoin_wallet().await?;
let db = context.try_get_db().await?;
let state = context.db.get_state(args.swap_id).await?;
let state = db.get_state(args.swap_id).await?;
let is_completed = state.swap_finished();
let peer_id = context
.db
let peer_id = db
.get_peer_id(args.swap_id)
.await
.with_context(|| "Could not get PeerID")?;
let addresses = context
.db
let addresses = db
.get_addresses(peer_id)
.await
.with_context(|| "Could not get addressess")?;
let start_date = context.db.get_swap_start_date(args.swap_id).await?;
let start_date = db.get_swap_start_date(args.swap_id).await?;
let swap_state: BobState = state.try_into()?;
@ -847,8 +829,7 @@ pub async fn get_swap_info(
btc_refund_address,
cancel_timelock,
punish_timelock,
) = context
.db
) = db
.get_states(args.swap_id)
.await?
.iter()
@ -884,7 +865,7 @@ pub async fn get_swap_info(
let timelock = swap_state.expired_timelocks(bitcoin_wallet.clone()).await?;
let monero_receive_pool = context.db.get_monero_address_pool(args.swap_id).await?;
let monero_receive_pool = db.get_monero_address_pool(args.swap_id).await?;
Ok(GetSwapInfoResponse {
swap_id: args.swap_id,
@ -924,15 +905,13 @@ pub async fn buy_xmr(
monero_receive_pool,
} = buy_xmr;
monero_receive_pool.assert_network(context.config.env_config.monero_network)?;
let config = context.try_get_config().await?;
let db = context.try_get_db().await?;
monero_receive_pool.assert_network(config.env_config.monero_network)?;
monero_receive_pool.assert_sum_to_one()?;
let bitcoin_wallet = Arc::clone(
context
.bitcoin_wallet
.as_ref()
.expect("Could not find Bitcoin wallet"),
);
let bitcoin_wallet = context.try_get_bitcoin_wallet().await?;
let bitcoin_change_address = match bitcoin_change_address {
Some(addr) => addr
@ -950,21 +929,15 @@ pub async fn buy_xmr(
}
};
let monero_wallet = Arc::clone(
context
.monero_manager
.as_ref()
.context("Could not get Monero wallet")?,
);
let monero_wallet = context.try_get_monero_manager().await?;
let env_config = context.config.env_config;
let seed = context.config.seed.clone().context("Could not get seed")?;
let env_config = config.env_config;
let seed = config.seed.clone().context("Could not get seed")?;
// Prepare variables for the quote fetching process
let identity = seed.derive_libp2p_identity();
let namespace = context.config.namespace;
let tor_client = context.tor_client.clone();
let db = Some(context.db.clone());
let namespace = config.namespace;
let tor_client = context.tor_client.read().await.clone();
let tauri_handle = context.tauri_handle.clone();
// Wait for the user to approve a seller and to deposit coins
@ -973,10 +946,18 @@ pub async fn buy_xmr(
let bitcoin_wallet_for_closures = Arc::clone(&bitcoin_wallet);
// Clone bitcoin_change_address before moving it in the emit call
// Clone variables before moving them into closures
let bitcoin_change_address_for_spawn = bitcoin_change_address.clone();
let rendezvous_points_clone = rendezvous_points.clone();
let sellers_clone = sellers.clone();
let db_for_fetch = db.clone();
let tor_client_for_swarm = tor_client.clone();
// Clone tauri_handle for different closures
let tauri_handle_for_fetch = tauri_handle.clone();
let tauri_handle_for_determine = tauri_handle.clone();
let tauri_handle_for_selection = tauri_handle.clone();
let tauri_handle_for_suspension = tauri_handle.clone();
// Acquire the lock before the user has selected a maker and we already have funds in the wallet
// because we need to be able to cancel the determine_btc_to_swap(..)
@ -989,9 +970,9 @@ pub async fn buy_xmr(
let sellers = sellers_clone.clone();
let namespace = namespace;
let identity = identity.clone();
let db = db.clone();
let db = db_for_fetch.clone();
let tor_client = tor_client.clone();
let tauri_handle = tauri_handle.clone();
let tauri_handle = tauri_handle_for_fetch.clone();
Box::pin(async move {
fetch_quotes_task(
@ -999,7 +980,7 @@ pub async fn buy_xmr(
namespace,
sellers,
identity,
db,
Some(db),
tor_client,
tauri_handle,
).await
@ -1027,10 +1008,10 @@ pub async fn buy_xmr(
async move { w.sync().await }
}
},
context.tauri_handle.clone(),
tauri_handle_for_determine,
swap_id,
|quote_with_address| {
let tauri_handle = context.tauri_handle.clone();
let tauri_handle_clone = tauri_handle_for_selection.clone();
Box::new(async move {
let details = SelectMakerDetails {
swap_id,
@ -1038,7 +1019,7 @@ pub async fn buy_xmr(
maker: quote_with_address,
};
tauri_handle.request_maker_selection(details, 300).await
tauri_handle_clone.request_maker_selection(details, 300).await
}) as Box<dyn Future<Output = Result<bool>> + Send>
},
) => {
@ -1046,55 +1027,51 @@ pub async fn buy_xmr(
}
_ = context.swap_lock.listen_for_swap_force_suspension() => {
context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active.");
context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
if let Some(handle) = tauri_handle_for_suspension {
handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
}
bail!("Shutdown signal received");
},
};
// Insert the peer_id into the database
context.db.insert_peer_id(swap_id, seller_peer_id).await?;
db.insert_peer_id(swap_id, seller_peer_id).await?;
context
.db
.insert_address(seller_peer_id, seller_multiaddr.clone())
db.insert_address(seller_peer_id, seller_multiaddr.clone())
.await?;
let behaviour = cli::Behaviour::new(
seller_peer_id,
env_config,
bitcoin_wallet.clone(),
(seed.derive_libp2p_identity(), context.config.namespace),
(seed.derive_libp2p_identity(), namespace),
);
let mut swarm = swarm::cli(
seed.derive_libp2p_identity(),
context.tor_client.clone(),
tor_client_for_swarm,
behaviour,
)
.await?;
swarm.add_peer_address(seller_peer_id, seller_multiaddr.clone());
context
.db
.insert_monero_address_pool(swap_id, monero_receive_pool.clone())
db.insert_monero_address_pool(swap_id, monero_receive_pool.clone())
.await?;
tracing::debug!(peer_id = %swarm.local_peer_id(), "Network layer initialized");
context.tauri_handle.emit_swap_progress_event(
tauri_handle.emit_swap_progress_event(
swap_id,
TauriSwapProgressEvent::ReceivedQuote(quote.clone()),
);
// Now create the event loop we use for the swap
let (event_loop, event_loop_handle) =
EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?;
EventLoop::new(swap_id, swarm, seller_peer_id, db.clone())?;
let event_loop = tokio::spawn(event_loop.run().in_current_span());
context
.tauri_handle
.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote));
tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::ReceivedQuote(quote));
context.tasks.clone().spawn(async move {
tokio::select! {
@ -1103,7 +1080,7 @@ pub async fn buy_xmr(
tracing::debug!("Shutdown signal received, exiting");
context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active.");
context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
bail!("Shutdown signal received");
},
@ -1120,9 +1097,9 @@ pub async fn buy_xmr(
},
swap_result = async {
let swap = Swap::new(
Arc::clone(&context.db),
db.clone(),
swap_id,
Arc::clone(&bitcoin_wallet),
bitcoin_wallet.clone(),
monero_wallet,
env_config,
event_loop_handle,
@ -1130,7 +1107,7 @@ pub async fn buy_xmr(
bitcoin_change_address_for_spawn,
tx_lock_amount,
tx_lock_fee
).with_event_emitter(context.tauri_handle.clone());
).with_event_emitter(tauri_handle.clone());
bob::run(swap).await
} => {
@ -1152,7 +1129,7 @@ pub async fn buy_xmr(
.await
.expect("Could not release swap lock");
context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
Ok::<_, anyhow::Error>(())
}.in_current_span()).await;
@ -1167,11 +1144,16 @@ pub async fn resume_swap(
) -> Result<ResumeSwapResponse> {
let ResumeSwapArgs { swap_id } = resume;
let seller_peer_id = context.db.get_peer_id(swap_id).await?;
let seller_addresses = context.db.get_addresses(seller_peer_id).await?;
let db = context.try_get_db().await?;
let config = context.try_get_config().await?;
let bitcoin_wallet = context.try_get_bitcoin_wallet().await?;
let monero_manager = context.try_get_monero_manager().await?;
let tor_client = context.tor_client.read().await.clone();
let seed = context
.config
let seller_peer_id = db.get_peer_id(swap_id).await?;
let seller_addresses = db.get_addresses(seller_peer_id).await?;
let seed = config
.seed
.as_ref()
.context("Could not get seed")?
@ -1179,16 +1161,11 @@ pub async fn resume_swap(
let behaviour = cli::Behaviour::new(
seller_peer_id,
context.config.env_config,
Arc::clone(
context
.bitcoin_wallet
.as_ref()
.context("Could not get Bitcoin wallet")?,
),
(seed.clone(), context.config.namespace),
config.env_config,
bitcoin_wallet.clone(),
(seed.clone(), config.namespace),
);
let mut swarm = swarm::cli(seed.clone(), context.tor_client.clone(), behaviour).await?;
let mut swarm = swarm::cli(seed.clone(), tor_client, behaviour).await?;
let our_peer_id = swarm.local_peer_id();
tracing::debug!(peer_id = %our_peer_id, "Network layer initialized");
@ -1199,36 +1176,27 @@ pub async fn resume_swap(
}
let (event_loop, event_loop_handle) =
EventLoop::new(swap_id, swarm, seller_peer_id, context.db.clone())?;
EventLoop::new(swap_id, swarm, seller_peer_id, db.clone())?;
let monero_receive_pool = context.db.get_monero_address_pool(swap_id).await?;
let monero_receive_pool = db.get_monero_address_pool(swap_id).await?;
let tauri_handle = context.tauri_handle.clone();
let swap = Swap::from_db(
Arc::clone(&context.db),
db.clone(),
swap_id,
Arc::clone(
context
.bitcoin_wallet
.as_ref()
.context("Could not get Bitcoin wallet")?,
),
context
.monero_manager
.as_ref()
.context("Could not get Monero wallet manager")?
.clone(),
context.config.env_config,
bitcoin_wallet,
monero_manager,
config.env_config,
event_loop_handle,
monero_receive_pool,
)
.await?
.with_event_emitter(context.tauri_handle.clone());
.with_event_emitter(tauri_handle.clone());
context.swap_lock.acquire_swap_lock(swap_id).await?;
context
.tauri_handle
.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Resuming);
tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Resuming);
context.tasks.clone().spawn(
async move {
@ -1239,7 +1207,7 @@ pub async fn resume_swap(
tracing::debug!("Shutdown signal received, exiting");
context.swap_lock.release_swap_lock().await.expect("Shutdown signal received but failed to release swap lock. The swap process has been terminated but the swap lock is still active.");
context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
bail!("Shutdown signal received");
},
@ -1272,7 +1240,7 @@ pub async fn resume_swap(
.await
.expect("Could not release swap lock");
context.tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
tauri_handle.emit_swap_progress_event(swap_id, TauriSwapProgressEvent::Released);
Ok::<(), anyhow::Error>(())
}
@ -1290,15 +1258,12 @@ pub async fn cancel_and_refund(
context: Arc<Context>,
) -> Result<serde_json::Value> {
let CancelAndRefundArgs { swap_id } = cancel_and_refund;
let bitcoin_wallet = context
.bitcoin_wallet
.as_ref()
.context("Could not get Bitcoin wallet")?;
let bitcoin_wallet = context.try_get_bitcoin_wallet().await?;
let db = context.try_get_db().await?;
context.swap_lock.acquire_swap_lock(swap_id).await?;
let state =
cli::cancel_and_refund(swap_id, Arc::clone(bitcoin_wallet), Arc::clone(&context.db)).await;
let state = cli::cancel_and_refund(swap_id, bitcoin_wallet, db).await;
context
.swap_lock
@ -1319,7 +1284,8 @@ pub async fn cancel_and_refund(
#[tracing::instrument(fields(method = "get_history"), skip(context))]
pub async fn get_history(context: Arc<Context>) -> Result<GetHistoryResponse> {
let swaps = context.db.all().await?;
let db = context.try_get_db().await?;
let swaps = db.all().await?;
let mut vec: Vec<GetHistoryEntry> = Vec::new();
for (swap_id, state) in swaps {
let state: BobState = state.try_into()?;
@ -1334,7 +1300,8 @@ pub async fn get_history(context: Arc<Context>) -> Result<GetHistoryResponse> {
#[tracing::instrument(fields(method = "get_config"), skip(context))]
pub async fn get_config(context: Arc<Context>) -> Result<serde_json::Value> {
let data_dir_display = context.config.data_dir.display();
let config = context.try_get_config().await?;
let data_dir_display = config.data_dir.display();
tracing::info!(path=%data_dir_display, "Data directory");
tracing::info!(path=%format!("{}/logs", data_dir_display), "Log files directory");
tracing::info!(path=%format!("{}/sqlite", data_dir_display), "Sqlite file location");
@ -1357,10 +1324,7 @@ pub async fn withdraw_btc(
context: Arc<Context>,
) -> Result<WithdrawBtcResponse> {
let WithdrawBtcArgs { address, amount } = withdraw_btc;
let bitcoin_wallet = context
.bitcoin_wallet
.as_ref()
.context("Could not get Bitcoin wallet")?;
let bitcoin_wallet = context.try_get_bitcoin_wallet().await?;
let (withdraw_tx_unsigned, amount) = match amount {
Some(amount) => {
@ -1402,10 +1366,7 @@ pub async fn withdraw_btc(
#[tracing::instrument(fields(method = "get_balance"), skip(context))]
pub async fn get_balance(balance: BalanceArgs, context: Arc<Context>) -> Result<BalanceResponse> {
let BalanceArgs { force_refresh } = balance;
let bitcoin_wallet = context
.bitcoin_wallet
.as_ref()
.context("Could not get Bitcoin wallet")?;
let bitcoin_wallet = context.try_get_bitcoin_wallet().await?;
if force_refresh {
bitcoin_wallet.sync().await?;
@ -1441,8 +1402,12 @@ pub async fn list_sellers(
.filter_map(|rendezvous_point| rendezvous_point.split_peer_id())
.collect();
let identity = context
.config
let config = context.try_get_config().await?;
let db = context.try_get_db().await?;
let tor_client = context.tor_client.read().await.clone();
let tauri_handle = context.tauri_handle.clone();
let identity = config
.seed
.as_ref()
.context("Cannot extract seed")?
@ -1450,11 +1415,11 @@ pub async fn list_sellers(
let sellers = list_sellers_impl(
rendezvous_nodes,
context.config.namespace,
context.tor_client.clone(),
config.namespace,
tor_client,
identity,
Some(context.db.clone()),
context.tauri_handle(),
Some(db.clone()),
tauri_handle,
)
.await?;
@ -1480,10 +1445,7 @@ pub async fn list_sellers(
// Add the peer as known to the database
// This'll allow us to later request a quote again
// without having to re-discover the peer at the rendezvous point
context
.db
.insert_address(*peer_id, multiaddr.clone())
.await?;
db.insert_address(*peer_id, multiaddr.clone()).await?;
}
SellerStatus::Unreachable(UnreachableSeller { peer_id }) => {
tracing::trace!(
@ -1500,10 +1462,7 @@ pub async fn list_sellers(
#[tracing::instrument(fields(method = "export_bitcoin_wallet"), skip(context))]
pub async fn export_bitcoin_wallet(context: Arc<Context>) -> Result<serde_json::Value> {
let bitcoin_wallet = context
.bitcoin_wallet
.as_ref()
.context("Could not get Bitcoin wallet")?;
let bitcoin_wallet = context.try_get_bitcoin_wallet().await?;
let wallet_export = bitcoin_wallet.wallet_export("cli").await?;
tracing::info!(descriptor=%wallet_export.to_string(), "Exported bitcoin wallet");
@ -1518,14 +1477,17 @@ pub async fn monero_recovery(
context: Arc<Context>,
) -> Result<serde_json::Value> {
let MoneroRecoveryArgs { swap_id } = monero_recovery;
let swap_state: BobState = context.db.get_state(swap_id).await?.try_into()?;
let db = context.try_get_db().await?;
let config = context.try_get_config().await?;
let swap_state: BobState = db.get_state(swap_id).await?.try_into()?;
if let BobState::BtcRedeemed(state5) = swap_state {
let (spend_key, view_key) = state5.xmr_keys();
let restore_height = state5.monero_wallet_restore_blockheight.height;
let address = monero::Address::standard(
context.config.env_config.monero_network,
config.env_config.monero_network,
monero::PublicKey::from_private_key(&spend_key),
monero::PublicKey::from(view_key.public()),
);
@ -1978,10 +1940,7 @@ impl Request for GetMoneroSyncProgressArgs {
type Response = GetMoneroSyncProgressResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet_manager = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet_manager = ctx.try_get_monero_manager().await?;
let wallet = wallet_manager.main_wallet().await;
let sync_progress = wallet.call(|wallet| wallet.sync_progress()).await;
@ -2008,10 +1967,7 @@ impl Request for GetMoneroSeedArgs {
type Response = GetMoneroSeedResponse;
async fn request(self, ctx: Arc<Context>) -> Result<Self::Response> {
let wallet_manager = ctx
.monero_manager
.as_ref()
.context("Monero wallet manager not available")?;
let wallet_manager = ctx.try_get_monero_manager().await?;
let wallet = wallet_manager.main_wallet().await;
let seed = wallet.seed().await?;

View file

@ -21,12 +21,13 @@ use tokio::sync::oneshot;
use typeshare::typeshare;
use uuid::Uuid;
const TAURI_UNIFIED_EVENT_NAME: &str = "tauri-unified-event";
#[typeshare]
#[derive(Clone, Serialize)]
#[serde(tag = "channelName", content = "event")]
pub enum TauriEvent {
SwapProgress(TauriSwapProgressEventWrapper),
ContextInitProgress(TauriContextStatusEvent),
CliLog(TauriLogEvent),
BalanceChange(BalanceResponse),
SwapDatabaseStateUpdate(TauriDatabaseStateEvent),
@ -46,7 +47,14 @@ pub enum MoneroWalletUpdate {
HistoryUpdate(GetMoneroHistoryResponse),
}
const TAURI_UNIFIED_EVENT_NAME: &str = "tauri-unified-event";
#[typeshare]
#[derive(Clone, Debug, Serialize)]
pub struct ContextStatus {
pub bitcoin_wallet_available: bool,
pub monero_wallet_available: bool,
pub database_available: bool,
pub tor_available: bool,
}
#[typeshare]
#[derive(Clone, Debug, Serialize, Deserialize)]
@ -456,10 +464,6 @@ pub trait TauriEmitter {
}));
}
fn emit_context_init_progress_event(&self, event: TauriContextStatusEvent) {
self.emit_unified_event(TauriEvent::ContextInitProgress(event));
}
fn emit_cli_log_event(&self, event: TauriLogEvent) {
self.emit_unified_event(TauriEvent::CliLog(event));
}

View file

@ -79,17 +79,16 @@ where
.transpose()?
.map(|address| address.into_unchecked());
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_tor(tor.enable_tor)
.with_bitcoin(bitcoin)
.with_monero(monero)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_tor(tor.enable_tor)
.with_bitcoin(bitcoin)
.with_monero(monero)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
BuyXmrArgs {
rendezvous_points: vec![],
@ -103,14 +102,13 @@ where
Ok(context)
}
CliCommand::History => {
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
GetHistoryArgs {}.request(context.clone()).await?;
@ -121,14 +119,13 @@ where
redact,
swap_id,
} => {
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
GetLogsArgs {
logs_dir,
@ -141,29 +138,27 @@ where
Ok(context)
}
CliCommand::Config => {
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
GetConfigArgs {}.request(context.clone()).await?;
Ok(context)
}
CliCommand::Balance { bitcoin } => {
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_bitcoin(bitcoin)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_bitcoin(bitcoin)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
BalanceArgs {
force_refresh: true,
@ -180,15 +175,14 @@ where
} => {
let address = bitcoin_address::validate(address, is_testnet)?;
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_bitcoin(bitcoin)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_bitcoin(bitcoin)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
WithdrawBtcArgs { amount, address }
.request(context.clone())
@ -202,17 +196,16 @@ where
monero,
tor,
} => {
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_tor(tor.enable_tor)
.with_bitcoin(bitcoin)
.with_monero(monero)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_tor(tor.enable_tor)
.with_bitcoin(bitcoin)
.with_monero(monero)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
ResumeSwapArgs { swap_id }.request(context.clone()).await?;
@ -222,15 +215,14 @@ where
swap_id: SwapId { swap_id },
bitcoin,
} => {
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_bitcoin(bitcoin)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_bitcoin(bitcoin)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
CancelAndRefundArgs { swap_id }
.request(context.clone())
@ -242,15 +234,14 @@ where
rendezvous_point,
tor,
} => {
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_tor(tor.enable_tor)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_tor(tor.enable_tor)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
ListSellersArgs {
rendezvous_points: vec![rendezvous_point],
@ -261,15 +252,14 @@ where
Ok(context)
}
CliCommand::ExportBitcoinWallet { bitcoin } => {
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_bitcoin(bitcoin)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_bitcoin(bitcoin)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
ExportBitcoinWalletArgs {}.request(context.clone()).await?;
@ -278,14 +268,13 @@ where
CliCommand::MoneroRecovery {
swap_id: SwapId { swap_id },
} => {
let context = Arc::new(
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build()
.await?,
);
let context = Arc::new(Context::new_without_tauri_handle());
ContextBuilder::new(is_testnet)
.with_data_dir(data)
.with_debug(debug)
.with_json(json)
.build(context.clone())
.await?;
MoneroRecoveryArgs { swap_id }
.request(context.clone())

View file

@ -33,17 +33,55 @@ pub fn init(
tauri_handle: Option<TauriHandle>,
trace_stdout: bool,
) -> Result<()> {
let TOR_CRATES: Vec<&str> = vec!["arti"];
let TOR_CRATES: Vec<&str> = vec![
"arti",
"arti-client",
"arti-fork",
"tor-api2",
"tor-async-utils",
"tor-basic-utils",
"tor-bytes",
"tor-cell",
"tor-cert",
"tor-chanmgr",
"tor-checkable",
"tor-circmgr",
"tor-config",
"tor-config-path",
"tor-consdiff",
"tor-dirclient",
"tor-dirmgr",
"tor-error",
"tor-general-addr",
"tor-guardmgr",
"tor-hsclient",
"tor-hscrypto",
"tor-hsservice",
"tor-key-forge",
"tor-keymgr",
"tor-linkspec",
"tor-llcrypto",
"tor-log-ratelim",
"tor-memquota",
"tor-netdir",
"tor-netdoc",
"tor-persist",
"tor-proto",
"tor-protover",
"tor-relay-selection",
"tor-rtcompat",
"tor-rtmock",
"tor-socksproto",
"tor-units",
];
let LIBP2P_CRATES: Vec<&str> = vec![
// Main libp2p crates
"libp2p",
"libp2p_swarm",
"libp2p_core",
"libp2p_tcp",
"libp2p_noise",
"libp2p_tor",
// Specific libp2p module targets that appear in logs
"libp2p_core::transport",
"libp2p_core::transport::choice",
"libp2p_core::transport::dummy",
@ -67,6 +105,7 @@ pub fn init(
"libp2p_dcutr",
"monero_cpp",
];
let OUR_CRATES: Vec<&str> = vec![
"swap",
"asb",
@ -79,8 +118,6 @@ pub fn init(
"monero_rpc_pool",
];
let INFO_LEVEL_CRATES: Vec<&str> = vec![];
// General log file for non-verbose logs
let file_appender: RollingFileAppender = tracing_appender::rolling::never(&dir, "swap-all.log");
@ -104,11 +141,10 @@ pub fn init(
.with_file(true)
.with_line_number(true)
.json()
.with_filter(env_filter_with_info_crates(
level_filter,
.with_filter(env_filter_with_all_crates(vec![(
OUR_CRATES.clone(),
INFO_LEVEL_CRATES.clone(),
)?);
level_filter,
)])?);
// Layer for writing to the verbose log file
// Crates: All crates with different levels (libp2p at INFO+, others at TRACE)
@ -121,13 +157,11 @@ pub fn init(
.with_file(true)
.with_line_number(true)
.json()
.with_filter(env_filter_with_all_crates(
LevelFilter::TRACE,
OUR_CRATES.clone(),
LIBP2P_CRATES.clone(),
TOR_CRATES.clone(),
INFO_LEVEL_CRATES.clone(),
)?);
.with_filter(env_filter_with_all_crates(vec![
(OUR_CRATES.clone(), LevelFilter::TRACE),
(LIBP2P_CRATES.clone(), LevelFilter::TRACE),
(TOR_CRATES.clone(), LevelFilter::TRACE),
])?);
// Layer for writing to the terminal
// Crates: swap, asb
@ -152,29 +186,21 @@ pub fn init(
.with_file(true)
.with_line_number(true)
.json()
.with_filter(env_filter_with_all_crates(
level_filter,
OUR_CRATES.clone(),
LIBP2P_CRATES.clone(),
TOR_CRATES.clone(),
INFO_LEVEL_CRATES.clone(),
)?);
.with_filter(env_filter_with_all_crates(vec![
(OUR_CRATES.clone(), LevelFilter::DEBUG),
(LIBP2P_CRATES.clone(), LevelFilter::INFO),
(TOR_CRATES.clone(), LevelFilter::INFO),
])?);
// If trace_stdout is true, we log all messages to the terminal
// Otherwise, we only log the bare minimum
let terminal_layer_env_filter = match trace_stdout {
true => env_filter_with_all_crates(
LevelFilter::TRACE,
OUR_CRATES.clone(),
LIBP2P_CRATES.clone(),
TOR_CRATES.clone(),
INFO_LEVEL_CRATES.clone(),
)?,
false => env_filter_with_info_crates(
level_filter,
OUR_CRATES.clone(),
INFO_LEVEL_CRATES.clone(),
)?,
true => env_filter_with_all_crates(vec![
(OUR_CRATES.clone(), level_filter),
(TOR_CRATES.clone(), level_filter),
(LIBP2P_CRATES.clone(), LevelFilter::INFO),
])?,
false => env_filter_with_all_crates(vec![(OUR_CRATES.clone(), level_filter)])?,
};
let final_terminal_layer = match format {
@ -201,60 +227,18 @@ pub fn init(
Ok(())
}
/// This function controls which crate's logs actually get logged and from which level, with info-level crates at INFO level or higher.
fn env_filter_with_info_crates(
level_filter: LevelFilter,
our_crates: Vec<&str>,
info_level_crates: Vec<&str>,
) -> Result<EnvFilter> {
let mut filter = EnvFilter::from_default_env();
// Add directives for each crate in the provided list
for crate_name in our_crates {
filter = filter.add_directive(Directive::from_str(&format!(
"{}={}",
crate_name, &level_filter
))?);
}
for crate_name in info_level_crates {
filter = filter.add_directive(Directive::from_str(&format!("{}=INFO", crate_name))?);
}
Ok(filter)
}
/// This function controls which crate's logs actually get logged and from which level, including all crate categories.
fn env_filter_with_all_crates(
level_filter: LevelFilter,
our_crates: Vec<&str>,
libp2p_crates: Vec<&str>,
tor_crates: Vec<&str>,
info_level_crates: Vec<&str>,
) -> Result<EnvFilter> {
fn env_filter_with_all_crates(crates: Vec<(Vec<&str>, LevelFilter)>) -> Result<EnvFilter> {
let mut filter = EnvFilter::from_default_env();
// Add directives for each crate in the provided list
for crate_name in our_crates {
filter = filter.add_directive(Directive::from_str(&format!(
"{}={}",
crate_name, &level_filter
))?);
}
for crate_name in libp2p_crates {
filter = filter.add_directive(Directive::from_str(&format!("{}=INFO", crate_name))?);
}
for crate_name in tor_crates {
filter = filter.add_directive(Directive::from_str(&format!(
"{}={}",
crate_name, &level_filter
))?);
}
for crate_name in info_level_crates {
filter = filter.add_directive(Directive::from_str(&format!("{}=INFO", crate_name))?);
// Add directives for each group of crates with their specified level filter
for (crate_names, level_filter) in crates {
for crate_name in crate_names {
filter = filter.add_directive(Directive::from_str(&format!(
"{}={}",
crate_name, &level_filter
))?);
}
}
Ok(filter)