diff --git a/package-lock.json b/package-lock.json index 4ad0c7e..b138b15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,12 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.2.0", "@tauri-apps/plugin-shell": "^2.2.0", + "@tauri-apps/plugin-store": "^2.2.0", "@tauri-apps/plugin-upload": "^2.2.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router": "^7.1.5", + "zustand": "^5.0.3" }, "devDependencies": { "@tailwindcss/vite": "^4.0.6", @@ -1462,6 +1465,14 @@ "@tauri-apps/api": "^2.0.0" } }, + "node_modules/@tauri-apps/plugin-store": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.2.0.tgz", + "integrity": "sha512-hJTRtuJis4w5fW1dkcgftsYxKXK0+DbAqurZ3CURHG5WkAyyZgbxpeYctw12bbzF9ZbZREXZklPq8mocCC3Sgg==", + "dependencies": { + "@tauri-apps/api": "^2.0.0" + } + }, "node_modules/@tauri-apps/plugin-upload": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-upload/-/plugin-upload-2.2.1.tgz", @@ -1511,6 +1522,11 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, "node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", @@ -1521,13 +1537,13 @@ "version": "15.7.14", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", - "dev": true + "devOptional": true }, "node_modules/@types/react": { "version": "18.3.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.18.tgz", "integrity": "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ==", - "dev": true, + "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1619,11 +1635,19 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "devOptional": true }, "node_modules/daisyui": { "version": "5.0.0-beta.7", @@ -2151,6 +2175,29 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.5.tgz", + "integrity": "sha512-8BUF+hZEU4/z/JD201yK6S+UYhsf58bzYIDq2NS1iGpwxSXDu7F+DeGSkIXMFBuHZB21FSiCzEcUb18cQNdRkA==", + "dependencies": { + "@types/cookie": "^0.6.0", + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0", + "turbo-stream": "2.4.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/rollup": { "version": "4.32.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.32.1.tgz", @@ -2206,6 +2253,11 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2230,6 +2282,11 @@ "node": ">=6" } }, + "node_modules/turbo-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", + "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==" + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -2349,6 +2406,34 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true + }, + "node_modules/zustand": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.3.tgz", + "integrity": "sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 998eff2..a171528 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,12 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "^2.2.0", "@tauri-apps/plugin-shell": "^2.2.0", + "@tauri-apps/plugin-store": "^2.2.0", "@tauri-apps/plugin-upload": "^2.2.1", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "react-router": "^7.1.5", + "zustand": "^5.0.3" }, "devDependencies": { "@tailwindcss/vite": "^4.0.6", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a8544d0..5c9641e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4408,6 +4408,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-store" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c0c08fae6995909f5e9a0da6038273b750221319f2c0f3b526d6de1cde21505" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.11", + "tokio", + "tracing", +] + [[package]] name = "tauri-plugin-upload" version = "2.2.1" @@ -5971,7 +5987,7 @@ dependencies = [ [[package]] name = "zkn-client" -version = "0.1.0" +version = "0.1.0-dev" dependencies = [ "serde", "serde_json", @@ -5983,6 +5999,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-shell", + "tauri-plugin-store", "tauri-plugin-upload", "time", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a476f65..b724ae7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zkn-client" -version = "0.1.0" +version = "0.1.0-dev" description = "A Tauri App" authors = ["you"] edition = "2021" @@ -29,4 +29,5 @@ tauri-plugin-shell = "2" tauri-plugin-fs = "2" tauri-plugin-log = "2" time = { version = "0.3", features = ["formatting"] } +tauri-plugin-store = "2" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 9fae946..e7e7783 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "os:allow-platform", "shell:allow-kill", "shell:default", + "store:default", "upload:default", { "identifier": "fs:allow-exists", @@ -50,7 +51,12 @@ { "name": "walletshield-listen", "cmd": "walletshield", - "args": ["-listen", ":7070", "-config", "client.toml"], + "args": [ + "-listen", + { "validator": "^(\\S*:\\d+)$" }, + "-config", + "client.toml" + ], "sidecar": false } ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1993c06..4ec6ede 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ fn network_connect(network_id: &str) -> String { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_plugin_store::Builder::new().build()) .plugin( tauri_plugin_log::Builder::new() // https://tauri.app/plugin/logging/#formatting diff --git a/src/App.css b/src/App.css index 1353fb0..e594e82 100644 --- a/src/App.css +++ b/src/App.css @@ -44,6 +44,18 @@ -moz-osx-font-smoothing: grayscale; } +/* + * Set scrollbar-gutter to prevent sidebar-induced layout shift on Windows + * https://github.com/saadeghi/daisyui/issues/2859#issuecomment-2383862183 + * https://github.com/saadeghi/daisyui/discussions/3246#discussioncomment-11738876 + */ +html { + scrollbar-gutter: auto !important; +} +html:has(body.content-overflow-y) { + scrollbar-gutter: stable !important; +} + .logo { @apply h-48 p-6 transition duration-1000; will-change: filter; diff --git a/src/App.tsx b/src/App.tsx index be2926c..58f2bd7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,53 +1,20 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; +import { MemoryRouter, Route, Routes } from "react-router"; import * as app from "@tauri-apps/api/app"; import * as log from "@tauri-apps/plugin-log"; -import * as path from "@tauri-apps/api/path"; import { arch, platform } from "@tauri-apps/plugin-os"; -import { download } from "@tauri-apps/plugin-upload"; -import { fetch } from "@tauri-apps/plugin-http"; -import { Child, Command } from "@tauri-apps/plugin-shell"; -import { mkdir, exists, readDir, BaseDirectory } from "@tauri-apps/plugin-fs"; +import { Footer, Header, Message } from "./components"; +import { Networks, Settings } from "./pages"; +import { useStore } from "./store"; +import { getNetworks, getPlatformArch } from "./utils"; import "./App.css"; -const urlNetwork = "https://test.net.zknet.io"; - -// Map the os platform and architecture to a supported ZKN format -const getPlatformArch = (): string => { - const platArch = `${platform()}-${arch()}`; - switch (platArch) { - case "linux-aarch64": - return "linux-arm64"; - case "linux-x86_64": - return "linux-x64"; - case "macos-aarch64": - case "macos-x86_64": - return "macos"; - case "windows-x86_64": - return "windows-x64"; - default: - throw new Error(`Unsupported Operating System: ${platArch}`); - } -}; - -// Get networks with previously downloaded assets -const getNetworks = async () => { - const entries = await readDir("networks", { - baseDir: BaseDirectory.AppLocalData, - }); - return entries.filter((i) => i.isDirectory).map((i) => i.name); -}; - function App() { - const [msg, setMsg] = useState(""); - const [msgType, setMsgType] = useState(""); // error, info, success - const [networkId, setNetworkId] = useState(""); - const [dlProgress, setDlProgress] = useState(0); - const [clientPid, setClientPid] = useState(0); - const [appVersion, setAppVersion] = useState(""); - const [platformArch, setPlatformArch] = useState(""); - const [platformSupported, setPlatformSupported] = useState(false); - const [networks, setNetworks] = useState([]); - const [isConnected, setIsConnected] = useState(false); + const setAppVersion = useStore((s) => s.setAppVersion); + const setIsPlatformSupported = useStore((s) => s.setIsPlatformSupported); + const setMessage = useStore((s) => s.setMessage); + const setNetworks = useStore((s) => s.setNetworks); + const setPlatformArch = useStore((s) => s.setPlatformArch); // run once on startup (twice in dev mode) useEffect(() => { @@ -59,236 +26,29 @@ function App() { setAppVersion(v); setPlatformArch(getPlatformArch()); - setPlatformSupported(true); + setIsPlatformSupported(true); setNetworks(await getNetworks()); })(); } catch (error: any) { log.error(`${error}`); - setMsgType("error"); - setMsg(`${error}`); + setMessage("error", `${error}`); } }, []); - async function connect() { - try { - const pid = await clientStart(); - setClientPid(pid); - setMsgType("info"); - setMsg(""); - setIsConnected(true); - setNetworks(await getNetworks()); - } catch (error: any) { - log.error(`${error}`); - setMsgType("error"); - setMsg(`${error}`); - } - } - - async function disconnect() { - try { - await clientStop(); - setClientPid(0); - setMsgType("info"); - setMsg("Disconnected from Network"); - setIsConnected(false); - } catch (error: any) { - log.error(`${error}`); - setMsgType("error"); - setMsg(`${error}`); - } - } - - async function clientStop() { - if (clientPid > 0) { - const c = new Child(clientPid); - c.kill(); - } - } - - async function clientStart() { - const urlClientCfg = `${urlNetwork}/${networkId}/client.toml`; - const urlWalletshield = `${urlNetwork}/${networkId}/walletshield-${platformArch}`; - const appLocalDataDirPath = await path.appLocalDataDir(); - const dirNetworks = await path.join(appLocalDataDirPath, "networks"); - const dirNetwork = await path.join(dirNetworks, networkId); - const fileClientCfg = await path.join(dirNetwork, "client.toml"); - const fileWalletshield = - (await path.join(dirNetwork, "walletshield")) + - (platform() === "windows" ? ".exe" : ""); - const updateInterval = 1; // download progress update interval - - //////////////////////////////////////////////////////////////////////// - // check network existence - //////////////////////////////////////////////////////////////////////// - setMsgType(() => "info"); - setMsg(() => `Checking network...`); - const response = await fetch(urlClientCfg, { - method: "GET", - connectTimeout: 5000, - }); - if (!response.ok || response.body === null) { - log.warn(`Failed to download client config: ${response.statusText}`); - throw new Error("Invalid network id (or local network error)"); - } - - //////////////////////////////////////////////////////////////////////// - // save the network's client.toml in a network-specific directory - //////////////////////////////////////////////////////////////////////// - log.debug(`local network directory: ${dirNetwork}`); - if (!(await exists(dirNetwork))) - await mkdir(dirNetwork, { recursive: true }); - await download(urlClientCfg, fileClientCfg); - setMsgType(() => "success"); - setMsg(() => "Retrieved network client configuration"); - - //////////////////////////////////////////////////////////////////////// - // save the network's walletshield binary - //////////////////////////////////////////////////////////////////////// - if (!(await exists(fileWalletshield))) { - setMsgType(() => "info"); - setMsg(() => `Downloading network client...`); - await download( - urlWalletshield, - fileWalletshield, - ({ progressTotal, total }) => { - const percentComplete = Math.floor((progressTotal / total) * 100); - if ( - (dlProgress !== percentComplete && - percentComplete % updateInterval === 0) || - progressTotal === total - ) { - let msg = `Downloading client... ${percentComplete}%`; - if (progressTotal === total) - msg = `Downloaded client for OS: ${platformArch}`; - setMsg(() => msg); - setDlProgress(() => percentComplete); - } - }, - ); - } - - //////////////////////////////////////////////////////////////////////// - // prepare the walletshield binary for execution - //////////////////////////////////////////////////////////////////////// - if (platform() === "linux" || platform() === "macos") { - log.debug(`executing command: chmod +x walletshield`); - const output = await Command.create( - "chmod-walletshield", - ["+x", "walletshield"], - { - cwd: dirNetwork, - }, - ).execute(); - if (output.code !== 0) { - throw new Error(`Failed to chmod+x walletshield: ${output.stderr}`); - } - } - - //////////////////////////////////////////////////////////////////////// - // execute the walletshield client with the client.toml - //////////////////////////////////////////////////////////////////////// - setMsgType(() => "info"); - setMsg(() => `Starting network client...`); - const cmd = "walletshield"; - const args = ["-listen", ":7070", "-config", "client.toml"]; - const command = Command.create("walletshield-listen", args, { - cwd: dirNetwork, - env: { - PATH: dirNetwork, - }, - }); - log.debug(`spawning command: ${cmd} ${args.join(" ")}`); - command.on("close", (data) => { - log.debug(`closed: ${cmd} code=${data.code} signal=${data.signal}`); - setMsgType(() => "info"); - setMsg(() => `Network client stopped.`); - }); - command.on("error", (e) => log.error(`${cmd}: ${e.trim()}`)); - command.stdout.on("data", (d) => log.info(`${cmd}: ${d.trim()}`)); - command.stderr.on("data", (d) => log.error(`${cmd}: ${d.trim()}`)); - - const child = await command.spawn(); - return child.pid; - } - - const Footer = () => ( - - ); - return ( -
-
-

Zero Knowledge Network

- - ZKN isConnected && disconnect()} - className={`logo ${isConnected ? "pulsing" : ""}`} - /> - - {platformSupported && - (clientPid === 0 ? ( - <> -

Enter a network identifier for access.

-
{ - e.preventDefault(); - connect(); - - // blur the input field to clear visual artifact - e.currentTarget.querySelector("input")?.blur(); - }} - > - setNetworkId(e.currentTarget.value)} - placeholder="Enter a network id..." - maxLength={36} - minLength={5} - required - list="networks" - /> - - {networks.map((n) => ( - - -
- - ) : ( - <> -

- Connected Network: {networkId} -

- - - ))} - - {msg && ( -

- {msg} -

- )} -
-
-
+ +
+
+
+ + } /> + } /> + +
+ +
+
+
); } diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 0000000..52b84f7 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,17 @@ +import { useStore } from "../store"; + +export function Footer() { + const appVersion = useStore((s) => s.appVersion); + const platformArch = useStore((s) => s.platformArch); + return ( + + ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..c8da98c --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,84 @@ +import { useRef } from "react"; +import { Link } from "react-router"; +import { IconBars3, IconCog, IconGlobe } from "."; +import { useStore } from "../store"; + +export function Header() { + const drawerRef = useRef(null); + + const setMessage = useStore((s) => s.setMessage); + + const closeDrawer = () => { + if (drawerRef.current) { + drawerRef.current.checked = false; + } + }; + + const handleLink = () => { + closeDrawer(); + setMessage("info", ""); // clear any messages + }; + + // SideBar nav item + const NavItem = ({ + to, + icon, + label, + }: { + to: string; + icon: JSX.Element; + label: string; + }) => ( +
  • + + {icon} + {label} + +
  • + ); + + // https://v5.daisyui.com/components/drawer/#drawer-that-opens-from-right-side-of-page + const SideBar = () => ( +
    + +
    + +
    +
    + +
      + } /> + } /> +
    +
    +
    + ); + + // https://v5.daisyui.com/components/navbar/#navbar-with-dropdown-center-logo-and-icon + return ( +
    +
    + +
    +
    + Zero Knowledge Network +
    +
    +
    + ); +} diff --git a/src/components/Message.tsx b/src/components/Message.tsx new file mode 100644 index 0000000..a751cfc --- /dev/null +++ b/src/components/Message.tsx @@ -0,0 +1,23 @@ +import { useStore } from "../store"; + +export function Message() { + const msg = useStore((s) => s.message); + const msgType = useStore((s) => s.messageType); + + const msgClass = + { + error: "alert-error", + info: "alert-info", + success: "alert-success", + }[msgType] ?? ""; + + return ( + <> + {msg && ( +
    +

    {msg}

    +
    + )} + + ); +} diff --git a/src/components/icons.tsx b/src/components/icons.tsx new file mode 100644 index 0000000..b761c78 --- /dev/null +++ b/src/components/icons.tsx @@ -0,0 +1,42 @@ +// https://heroicons.com/ + +export const IconBars3 = () => ( + + + +); + +export const IconCog = () => ( + + + +); + +export const IconGlobe = () => ( + + + +); diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..5fbe34f --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,5 @@ +export { Footer } from "./Footer"; +export { Header } from "./Header"; +export { Message } from "./Message"; + +export * from "./icons.tsx"; diff --git a/src/pages/Networks.tsx b/src/pages/Networks.tsx new file mode 100644 index 0000000..7f75c04 --- /dev/null +++ b/src/pages/Networks.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import * as log from "@tauri-apps/plugin-log"; +import * as path from "@tauri-apps/api/path"; +import { exists, mkdir } from "@tauri-apps/plugin-fs"; +import { fetch } from "@tauri-apps/plugin-http"; +import { platform } from "@tauri-apps/plugin-os"; +import { Child, Command } from "@tauri-apps/plugin-shell"; +import { download } from "@tauri-apps/plugin-upload"; +import { useStore } from "../store"; +import { + defaultWalletshieldListenAddress, + getNetworks, + urlNetwork, +} from "../utils"; + +export function Networks() { + const [dlProgress, setDlProgress] = useState(0); + const [networkId, setNetworkId] = useState(""); + + const clientPid = useStore((s) => s.clientPid); + const isConnected = useStore((s) => s.isConnected); + const isPlatformSupported = useStore((s) => s.isPlatformSupported); + const networkConnected = useStore((s) => s.networkConnected); + const networks = useStore((s) => s.networks); + const platformArch = useStore((s) => s.platformArch); + const walletshieldListenAddress = useStore( + (s) => s.walletshieldListenAddress, + ); + + const setClientPid = useStore((s) => s.setClientPid); + const setIsConnected = useStore((s) => s.setIsConnected); + const setMessage = useStore((s) => s.setMessage); + const setNetworkConnected = useStore((s) => s.setNetworkConnected); + const setNetworks = useStore((s) => s.setNetworks); + + async function connect() { + try { + const pid = await clientStart(); + setClientPid(pid); + setMessage("info", ""); + setIsConnected(true); + setNetworkConnected(networkId); + setNetworks(await getNetworks()); + } catch (error: any) { + log.error(`${error}`); + setMessage("error", `${error}`); + } + } + + async function disconnect() { + try { + await clientStop(); + setClientPid(0); + setMessage("info", "Disconnected from Network"); + setIsConnected(false); + setNetworkConnected(""); + } catch (error: any) { + log.error(`${error}`); + setMessage("error", `${error}`); + } + } + + async function clientStop() { + if (clientPid > 0) { + const c = new Child(clientPid); + c.kill(); + } + } + + async function clientStart() { + const urlClientCfg = `${urlNetwork}/${networkId}/client.toml`; + const urlWalletshield = `${urlNetwork}/${networkId}/walletshield-${platformArch}`; + const appLocalDataDirPath = await path.appLocalDataDir(); + const dirNetworks = await path.join(appLocalDataDirPath, "networks"); + const dirNetwork = await path.join(dirNetworks, networkId); + const fileClientCfg = await path.join(dirNetwork, "client.toml"); + const fileWalletshield = + (await path.join(dirNetwork, "walletshield")) + + (platform() === "windows" ? ".exe" : ""); + const updateInterval = 1; // download progress update interval + + //////////////////////////////////////////////////////////////////////// + // check network existence + //////////////////////////////////////////////////////////////////////// + setMessage("info", `Checking network...`); + const response = await fetch(urlClientCfg, { + method: "GET", + connectTimeout: 5000, + }); + if (!response.ok || response.body === null) { + log.warn(`Failed to download client config: ${response.statusText}`); + throw new Error("Invalid network id (or local network error)"); + } + + //////////////////////////////////////////////////////////////////////// + // save the network's client.toml in a network-specific directory + //////////////////////////////////////////////////////////////////////// + log.debug(`local network directory: ${dirNetwork}`); + if (!(await exists(dirNetwork))) + await mkdir(dirNetwork, { recursive: true }); + await download(urlClientCfg, fileClientCfg); + setMessage("info", "Retrieved network client configuration"); + + //////////////////////////////////////////////////////////////////////// + // save the network's walletshield binary + //////////////////////////////////////////////////////////////////////// + if (!(await exists(fileWalletshield))) { + setMessage("info", "Downloading network client..."); + await download( + urlWalletshield, + fileWalletshield, + ({ progressTotal, total }) => { + const percentComplete = Math.floor((progressTotal / total) * 100); + if ( + (dlProgress !== percentComplete && + percentComplete % updateInterval === 0) || + progressTotal === total + ) { + let msg = `Downloading client... ${percentComplete}%`; + if (progressTotal === total) + msg = `Downloaded client for OS: ${platformArch}`; + setMessage("info", msg); + setDlProgress(() => percentComplete); + } + }, + ); + } + + //////////////////////////////////////////////////////////////////////// + // prepare the walletshield binary for execution + //////////////////////////////////////////////////////////////////////// + if (platform() === "linux" || platform() === "macos") { + log.debug(`executing command: chmod +x walletshield`); + const output = await Command.create( + "chmod-walletshield", + ["+x", "walletshield"], + { + cwd: dirNetwork, + }, + ).execute(); + if (output.code !== 0) { + throw new Error(`Failed to chmod+x walletshield: ${output.stderr}`); + } + } + + //////////////////////////////////////////////////////////////////////// + // execute the walletshield client with the client.toml + //////////////////////////////////////////////////////////////////////// + setMessage("info", "Starting network client..."); + const cmd = "walletshield"; + const wla = walletshieldListenAddress ?? defaultWalletshieldListenAddress; + const args = ["-listen", wla, "-config", "client.toml"]; + const command = Command.create("walletshield-listen", args, { + cwd: dirNetwork, + env: { + PATH: dirNetwork, + }, + }); + log.debug(`spawning command: ${cmd} ${args.join(" ")}`); + command.on("close", (data) => { + log.debug(`closed: ${cmd} code=${data.code} signal=${data.signal}`); + setMessage("info", "Network client stopped."); + }); + command.on("error", (e) => log.error(`${cmd}: ${e.trim()}`)); + command.stdout.on("data", (d) => log.info(`${cmd}: ${d.trim()}`)); + command.stderr.on("data", (d) => log.error(`${cmd}: ${d.trim()}`)); + + const child = await command.spawn(); + return child.pid; + } + + return ( +
    + ZKN isConnected && disconnect()} + className={`logo ${isConnected ? "pulsing" : ""}`} + /> + + {isPlatformSupported && + (clientPid === 0 ? ( + <> +

    Enter a network identifier for access.

    +
    { + e.preventDefault(); + connect(); + + // blur the input field to clear visual artifact + e.currentTarget.querySelector("input")?.blur(); + }} + > + setNetworkId(e.currentTarget.value)} + placeholder="Enter a network id..." + maxLength={36} + minLength={5} + required + list="networks" + /> + + {networks.map((n) => ( + + +
    + + ) : ( + <> +

    + Connected Network: {networkConnected} +

    + + + ))} +
    + ); +} diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx new file mode 100644 index 0000000..af49275 --- /dev/null +++ b/src/pages/Settings.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; +import { useStore } from "../store"; +import { defaultWalletshieldListenAddress } from "../utils"; + +export function Settings() { + const [listenAddress, setListenAddress] = useState(""); + + const walletshieldListenAddress = useStore( + (s) => s.walletshieldListenAddress, + ); + + const setMessage = useStore((s) => s.setMessage); + const setWalletshieldListenAddress = useStore( + (s) => s.setWalletshieldListenAddress, + ); + + useEffect(() => { + setListenAddress(walletshieldListenAddress); + }, []); + + const handleReset = () => { + setListenAddress(""); + setWalletshieldListenAddress(""); + setMessage("success", "Settings reset to default."); + }; + + const handleApply = () => { + setWalletshieldListenAddress(listenAddress); + setMessage("success", "Settings saved."); + }; + + return ( +
    +
    { + e.preventDefault(); + handleApply(); + }} + > +
    + Walletshield + + + setListenAddress(e.target.value)} + pattern="^((\d{1,3}\.){3}\d{1,3}|[a-zA-Z0-9.-]+)?:(\d{1,5})$" + required + /> +

    + Where the Walletshield listens for connections. +

    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    + ); +} diff --git a/src/pages/index.ts b/src/pages/index.ts new file mode 100644 index 0000000..d0b73ad --- /dev/null +++ b/src/pages/index.ts @@ -0,0 +1,2 @@ +export { Networks } from "./Networks"; +export { Settings } from "./Settings"; diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..6575d10 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,53 @@ +import { LazyStore } from "@tauri-apps/plugin-store"; +import { create } from "zustand"; +import { combine } from "zustand/middleware"; + +const store = new LazyStore("settings.json"); + +export const useStore = create( + combine( + { + appVersion: "", + clientPid: 0, + isConnected: false, + isPlatformSupported: false, + message: "", + messageType: "", + networkConnected: "", + networks: [] as string[], + platformArch: "", + walletshieldListenAddress: "", + }, + (set) => ({ + setAppVersion: (appVersion: string) => set({ appVersion }), + setClientPid: (clientPid: number) => set({ clientPid }), + setIsConnected: (isConnected: boolean) => set({ isConnected }), + setIsPlatformSupported: (isPlatformSupported: boolean) => + set({ isPlatformSupported }), + setMessage: ( + messageType: "error" | "info" | "success", + message: string, + ) => set({ message, messageType }), + setNetworkConnected: (networkConnected: string) => + set({ networkConnected }), + setNetworks: (networks: string[]) => set({ networks }), + setPlatformArch: (platformArch: string) => set({ platformArch }), + + setWalletshieldListenAddress: async ( + walletshieldListenAddress: string, + ) => { + set({ walletshieldListenAddress }); + await store.set("walletshieldListenAddress", walletshieldListenAddress); + await store.save(); + }, + + loadPersistedSettings: async () => { + const wla = await store.get("walletshieldListenAddress"); + if (wla) set({ walletshieldListenAddress: wla }); + }, + }), + ), +); + +// Load persisted settings on app start +useStore.getState().loadPersistedSettings(); diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..a4ecda5 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,32 @@ +import { BaseDirectory, readDir } from "@tauri-apps/plugin-fs"; +import { arch, platform } from "@tauri-apps/plugin-os"; + +export const defaultWalletshieldListenAddress = ":7070"; + +export const urlNetwork = "https://test.net.zknet.io"; + +// Map the os platform and architecture to a supported ZKN format +export const getPlatformArch = (): string => { + const platArch = `${platform()}-${arch()}`; + switch (platArch) { + case "linux-aarch64": + return "linux-arm64"; + case "linux-x86_64": + return "linux-x64"; + case "macos-aarch64": + case "macos-x86_64": + return "macos"; + case "windows-x86_64": + return "windows-x64"; + default: + throw new Error(`Unsupported Operating System: ${platArch}`); + } +}; + +// Get networks with previously downloaded assets +export const getNetworks = async () => { + const entries = await readDir("networks", { + baseDir: BaseDirectory.AppLocalData, + }); + return entries.filter((i) => i.isDirectory).map((i) => i.name); +};