Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src-tauri/src/commands/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,37 @@ pub async fn set_session_role(state: State<'_, AppState>, role: String) -> AppRe
state.save_settings(&settings)
}

/// The currently active wallet (group id), or `None` if the user hasn't chosen
/// one yet. Wallet actions are scoped to this group.
#[tauri::command]
pub async fn get_active_wallet(state: State<'_, AppState>) -> AppResult<Option<String>> {
Ok(state.load_settings().active_group_id)
}

/// Make `group_id` the active wallet. Cancels the previously active group's
/// in-flight sync so the app's processing follows the switch: only one wallet
/// syncs at a time, and stale work on the old wallet is abandoned promptly (a
/// sync is safe to cancel — it resumes from where it left off next time).
#[tauri::command]
pub async fn set_active_wallet(state: State<'_, AppState>, group_id: String) -> AppResult<()> {
let mut settings = state.load_settings();
// Already active — nothing to change (the wallet page re-asserts this on every
// open, so skip the redundant disk write and sync-cancel).
if settings.active_group_id.as_deref() == Some(group_id.as_str()) {
return Ok(());
}
let previous = settings.active_group_id.replace(group_id);
state.save_settings(&settings)?;

// Changing wallets: stop the old one's sync so processing follows the switch.
if let Some(prev) = previous {
if let Some(token) = state.sync_cancels.lock().await.get(&prev) {
token.cancel();
}
}
Ok(())
}

/// Determine trust for a given server URL: pinned certs for the embedded
/// sidecar and any TOFU-imported external certs, system roots otherwise.
pub async fn trust_for(state: &AppState, url: &str) -> ServerTrust {
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/src/commands/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ pub async fn wallet_sync(state: State<'_, AppState>, group_id: String) -> AppRes
}
}

// Hold the app-wide sync gate for the whole run so only one wallet ever syncs
// at a time (trial decryption is CPU-bound; concurrent group syncs only
// thrash). We register+cancel the prior same-group token above first, so a
// restart of this group releases the gate before we wait on it; an
// active-wallet switch cancels the other group's sync, so this rarely blocks.
let _gate = state.sync_gate.lock().await;

let batch_size = state.load_settings().sync_batch_size;
let result = wallet::sync_group(
&state.data_dir, &group_id, network, &url, db_key.as_ref(), batch_size, &cancel,
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ pub fn run() {
commands::server::set_server_url,
commands::server::set_session_config,
commands::server::set_session_role,
commands::server::get_active_wallet,
commands::server::set_active_wallet,
commands::server::test_server_connection,
commands::server::trust_server_cert,
commands::server::cert_fingerprint_of,
Expand Down
13 changes: 13 additions & 0 deletions src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ pub struct Settings {
/// they aren't prompted again unless they revisit it.
#[serde(default)]
pub session_configured: Option<bool>,
/// The single **active wallet**: the group whose wallet the app currently
/// works on. Wallet actions (sync, balance, send) are scoped to this one, and
/// only this group syncs — switching cancels the previous group's sync. `None`
/// until the user first selects one. Persisted so the choice survives restart.
#[serde(default)]
pub active_group_id: Option<String>,
}

/// Per-group rotating receive-address bookkeeping (#3). Non-secret; the actual
Expand Down Expand Up @@ -123,6 +129,12 @@ pub struct AppState {
/// Cancellation token for the in-flight wallet sync of each group, so a
/// "Sync Now" can abandon a stalled sync and restart it cleanly.
pub sync_cancels: Mutex<HashMap<String, CancellationToken>>,
/// App-wide "one wallet syncs at a time" gate. Trial decryption is CPU-bound
/// and saturates every core, so two groups syncing at once only thrash. Every
/// `wallet_sync` holds this for its whole run; combined with cancelling the
/// previous group's sync on an active-wallet switch, it guarantees the app's
/// processing stays focused on a single wallet.
pub sync_gate: Mutex<()>,
/// Epoch-millis of the last user activity, used to drive the idle auto-lock.
pub last_activity: AtomicI64,
}
Expand All @@ -147,6 +159,7 @@ impl AppState {
sidecar: Mutex::new(None),
tunnel: Mutex::new(None),
sync_cancels: Mutex::new(HashMap::new()),
sync_gate: Mutex::new(()),
last_activity: AtomicI64::new(now_millis()),
}
}
Expand Down
70 changes: 24 additions & 46 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect } from "react";
import {
createBrowserRouter,
RouterProvider,
Outlet,
NavLink,
Navigate,
useLocation,
useNavigate,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { listen } from "@tauri-apps/api/event";
Expand All @@ -17,6 +16,7 @@ import {
listGroups,
recordActivity,
getSettings,
getActiveWallet,
listPendingSessions,
} from "./ipc/commands";
import CeremonyListener from "./CeremonyListener";
Expand All @@ -31,57 +31,33 @@ import DkgWizard from "./screens/DkgWizard";
import NewSigningSession from "./screens/NewSigningSession";
import Inbox from "./screens/Inbox";
import Wallet from "./screens/Wallet";
import Wallets from "./screens/Wallets";

/** Groups nav entry: a single-select dropdown so exactly one group ("active
* wallet") is shown at a time, with only that group's links below it. Keeps
* the sidebar quiet no matter how many groups exist. Follows the current
* route, and switching the picker navigates into the chosen group. */
/** Groups nav entry: the Groups list plus, for the active group, quick sub-links
* to its Details (key info, participants) and its Wallet. The single active
* wallet is chosen on the Wallets switcher (4 · Zcash); this just surfaces its
* Details/Wallet where group management lives. */
function GroupsNavItem() {
const groups = useQuery({ queryKey: ["groups"], queryFn: listGroups });
const location = useLocation();
const navigate = useNavigate();

const activeGroupId = useMemo(() => {
const m = location.pathname.match(/^\/groups\/([^/]+)/);
return m ? m[1] : null;
}, [location.pathname]);
const [selectedId, setSelectedId] = useState<string | null>(activeGroupId);
useEffect(() => {
if (activeGroupId) setSelectedId(activeGroupId);
}, [activeGroupId]);

const list = groups.data ?? [];
if (!list.length) return null;

// Always resolve to one real group so the panel shows a single active wallet.
const current =
list.find((g) => g.id === selectedId) ?? list[0];
const active = useQuery({ queryKey: ["active-wallet"], queryFn: getActiveWallet });
const activeGroup = (groups.data ?? []).find((g) => g.id === active.data);

return (
<div className="nav-group">
<select
className="nav-group-select group-pick"
value={current.id}
title={current.description || current.id}
aria-label="Active group"
onChange={(e) => {
setSelectedId(e.target.value);
navigate(`/groups/${e.target.value}`);
}}
>
{list.map((g) => (
<option key={g.id} value={g.id}>
{g.description || `${g.id.slice(0, 10)}…`}
</option>
))}
</select>
<NavLink to={`/groups/${current.id}`} end className="nav-subsubitem">
Details
<NavLink to="/groups" end>
Groups
</NavLink>
{current.ciphersuite.includes("Pallas") && (
<NavLink to={`/groups/${current.id}/wallet`} className="nav-subsubitem">
Wallet
</NavLink>
{activeGroup && (
<>
<NavLink to={`/groups/${activeGroup.id}`} end className="nav-subsubitem">
Details
</NavLink>
{activeGroup.ciphersuite.includes("Pallas") && (
<NavLink to={`/groups/${activeGroup.id}/wallet`} className="nav-subsubitem">
Wallet
</NavLink>
)}
</>
)}
</div>
);
Expand Down Expand Up @@ -114,6 +90,7 @@ const NAV_SECTIONS: { title: string; links: { to: string; label: string }[] }[]
{
title: "4 · Zcash",
links: [
{ to: "/wallets", label: "Wallets" },
{ to: "/setup", label: "Session Configuration" },
{ to: "/wallet", label: "Wallet Settings" },
],
Expand Down Expand Up @@ -259,6 +236,7 @@ const router = createBrowserRouter([
{ path: "dkg", element: <DkgWizard /> },
{ path: "sign", element: <NewSigningSession /> },
{ path: "inbox", element: <Inbox /> },
{ path: "wallets", element: <Wallets /> },
{ path: "wallet", element: <Wallet /> },
{ path: "server", element: <ServerSettings /> },
{ path: "setup", element: <SessionSetup /> },
Expand Down
7 changes: 7 additions & 0 deletions src/ipc/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface Settings {
coordinator_exposure: string | null;
/** True once first-run Session Configuration has been saved. */
session_configured: boolean | null;
/** The active wallet (group id) the app is focused on, or null if unset. */
active_group_id: string | null;
}

export interface SidecarStatus {
Expand Down Expand Up @@ -330,6 +332,11 @@ export const setSessionConfig = (role: string, exposure?: string | null) =>
/** Switch the active session profile (coordinator/participant). */
export const setSessionRole = (role: string) =>
invoke<void>("set_session_role", { role });
/** The active wallet (group id) the app is focused on, or null if unset. */
export const getActiveWallet = () => invoke<string | null>("get_active_wallet");
/** Make a group the active wallet; cancels the previously active group's sync. */
export const setActiveWallet = (groupId: string) =>
invoke<void>("set_active_wallet", { groupId });
export const testServerConnection = (url: string) =>
invoke<ConnectionTestResult>("test_server_connection", { url });
export const trustServerCert = (url: string, certPem: string) =>
Expand Down
16 changes: 16 additions & 0 deletions src/screens/Groups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
walletInitAccount,
walletSync,
walletCancelSync,
setActiveWallet,
walletSyncProgress,
walletPrepareSend,
walletPrepareVote,
Expand Down Expand Up @@ -2452,6 +2453,21 @@ export function GroupWalletPage() {
const group = groups.data?.find((g) => g.id === id);
const walletConfig = useQuery({ queryKey: ["wallet-config"], queryFn: getWalletConfig });
const isMainnet = walletConfig.data?.network === "main";
const queryClient = useQueryClient();

// Opening a wallet makes it the active wallet, so the app's processing follows
// the view: the backend cancels the previously active wallet's sync, and only
// this one syncs. A no-op when it's already active. The Wallets switcher is the
// guarded path (it confirms before abandoning unfinished work); this keeps the
// invariant for direct navigation / deep links.
const isPallasGroup = group?.ciphersuite.includes("Pallas") ?? false;
useEffect(() => {
if (id && isPallasGroup) {
setActiveWallet(id)
.then(() => queryClient.invalidateQueries({ queryKey: ["active-wallet"] }))
.catch(() => {});
}
}, [id, isPallasGroup, queryClient]);

if (groups.isLoading) return <p className="dim">Loading…</p>;
if (!group) {
Expand Down
Loading
Loading