P0: scaffold Vite + Vue 3 + Tauri v2

- pnpm + TS + Tailwind 3 + Pinia + Vue Router with hash history
- 6 placeholder views (Status, Peers, Routes, Messages, Topics, Settings)
  rendered via lucide-icon sidebar in App.vue
- Tauri v2: shell, store, log, dialog plugins; bundle targets deb +
  appimage; sidecar wired via externalBin = binaries/mycelium
- scripts/fetch-mycelium.sh pins v0.6.1, maps musl asset onto
  gnu target triple expected by Tauri bundler
- CI: pnpm typecheck + cargo fmt/clippy/test
This commit is contained in:
syoul
2026-04-25 22:12:48 +02:00
parent 0a2514ac93
commit d79300caf8
34 changed files with 8538 additions and 3 deletions

52
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm typecheck
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install system deps
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libjavascriptcoregtk-4.1-dev \
libsoup-3.0-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libgtk-3-dev \
libxdo-dev \
pkg-config \
build-essential
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- run: cargo fmt --all -- --check
working-directory: src-tauri
- run: cargo clippy --all-targets --locked -- -D warnings
working-directory: src-tauri
- run: cargo test --locked
working-directory: src-tauri

6
.gitignore vendored
View File

@@ -12,9 +12,9 @@ dist-ssr
*.local
# Tauri
/app/src-tauri/target
/app/src-tauri/binaries/mycelium*
/app/src-tauri/gen/schemas
/src-tauri/target
/src-tauri/binaries/mycelium-*
/src-tauri/gen
# Editor / OS
.DS_Store

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Mycellium UI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "mycellium-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-log": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.2.0",
"@tauri-apps/plugin-store": "^2.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.469.0",
"pinia": "^2.3.0",
"radix-vue": "^1.9.11",
"tailwind-merge": "^2.6.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.2.0",
"@types/node": "^22.10.5",
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.7.2",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}

1980
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

65
scripts/fetch-mycelium.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
# Fetches the official mycelium release binary and places it in src-tauri/binaries/
# with the target-triple suffix expected by Tauri's externalBin bundler.
#
# Usage: scripts/fetch-mycelium.sh [VERSION]
# VERSION defaults to MYCELIUM_VERSION below.
MYCELIUM_VERSION="${1:-${MYCELIUM_VERSION:-v0.6.1}}"
REPO="threefoldtech/mycelium"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEST_DIR="${ROOT_DIR}/src-tauri/binaries"
mkdir -p "${DEST_DIR}"
detect_target_triple() {
local arch os
arch="$(uname -m)"
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "${os}-${arch}" in
linux-x86_64) echo "x86_64-unknown-linux-gnu" ;;
linux-aarch64) echo "aarch64-unknown-linux-gnu" ;;
*) echo "unsupported: ${os}-${arch}" >&2; exit 1 ;;
esac
}
# Map our target triple to the asset name pattern used by upstream releases.
asset_for_triple() {
case "$1" in
x86_64-unknown-linux-gnu) echo "mycelium-x86_64-unknown-linux-musl.tar.gz" ;;
aarch64-unknown-linux-gnu) echo "mycelium-aarch64-unknown-linux-musl.tar.gz" ;;
*) echo "unsupported triple: $1" >&2; exit 1 ;;
esac
}
TRIPLE="$(detect_target_triple)"
ASSET="$(asset_for_triple "${TRIPLE}")"
URL="https://github.com/${REPO}/releases/download/${MYCELIUM_VERSION}/${ASSET}"
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT
echo "→ downloading ${URL}"
curl -fsSL "${URL}" -o "${TMP_DIR}/mycelium.tar.gz"
echo "→ extracting"
tar -xzf "${TMP_DIR}/mycelium.tar.gz" -C "${TMP_DIR}"
# The archive contains a single 'mycelium' binary at the root.
SRC="${TMP_DIR}/mycelium"
if [[ ! -f "${SRC}" ]]; then
# Some releases nest the binary; find it.
SRC="$(find "${TMP_DIR}" -name 'mycelium' -type f -executable | head -n1)"
fi
if [[ -z "${SRC}" || ! -f "${SRC}" ]]; then
echo "could not locate mycelium binary in archive" >&2
exit 1
fi
DEST="${DEST_DIR}/mycelium-${TRIPLE}"
install -m 0755 "${SRC}" "${DEST}"
echo "✓ installed ${DEST}"
echo " version: ${MYCELIUM_VERSION}"
echo " size: $(stat -c%s "${DEST}") bytes"

5900
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

33
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,33 @@
[package]
name = "mycellium-ui"
version = "0.1.0"
description = "Mycelium overlay network desktop client"
authors = ["syoul"]
edition = "2021"
rust-version = "1.77"
[lib]
name = "mycellium_ui_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-shell = "2"
tauri-plugin-store = "2"
tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
portpicker = "0.1"
parking_lot = "0.12"
[features]
custom-protocol = ["tauri/custom-protocol"]

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for the main window. The mycelium sidecar is spawned from Rust setup and does not require JS-side shell permissions.",
"windows": ["main"],
"permissions": [
"core:default",
"store:default",
"log:default",
"dialog:default"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

21
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,21 @@
use tracing_subscriber::EnvFilter;
pub fn run() {
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_target(false)
.compact()
.try_init()
.ok();
tauri::Builder::default()
.plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

5
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
mycellium_ui_lib::run();
}

47
src-tauri/tauri.conf.json Normal file
View File

@@ -0,0 +1,47 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Mycellium UI",
"version": "0.1.0",
"identifier": "tech.threefold.mycellium-ui",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"label": "main",
"title": "Mycellium",
"width": 1100,
"height": 720,
"minWidth": 800,
"minHeight": 600,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["deb", "appimage"],
"category": "Network",
"shortDescription": "Mycelium overlay network client",
"longDescription": "Desktop GUI for the Mycelium end-to-end encrypted IPv6 overlay network.",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png"
],
"externalBin": ["binaries/mycelium"],
"linux": {
"deb": {
"depends": ["policykit-1"]
}
}
}
}

61
src/App.vue Normal file
View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import {
Activity,
Users,
Route as RouteIcon,
MessageSquare,
Hash,
Settings as SettingsIcon,
} from "lucide-vue-next";
const route = useRoute();
const navItems = [
{ to: "/status", label: "Status", icon: Activity },
{ to: "/peers", label: "Peers", icon: Users },
{ to: "/routes", label: "Routes", icon: RouteIcon },
{ to: "/messages", label: "Messages", icon: MessageSquare },
{ to: "/topics", label: "Topics", icon: Hash },
{ to: "/settings", label: "Settings", icon: SettingsIcon },
];
const currentTitle = computed(() => (route.meta?.title as string) ?? "Mycellium");
</script>
<template>
<div class="flex h-screen w-screen overflow-hidden bg-background text-foreground">
<aside
class="flex w-56 shrink-0 flex-col border-r border-border bg-card"
>
<div class="flex h-14 items-center px-4 border-b border-border">
<span class="font-semibold text-base">Mycellium</span>
</div>
<nav class="flex-1 overflow-y-auto px-2 py-3 space-y-1">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors"
active-class="bg-secondary text-secondary-foreground"
exact-active-class="bg-secondary text-secondary-foreground"
>
<component :is="item.icon" class="h-4 w-4" />
<span>{{ item.label }}</span>
</RouterLink>
</nav>
</aside>
<main class="flex flex-1 flex-col overflow-hidden">
<header
class="flex h-14 items-center border-b border-border px-6 shrink-0"
>
<h1 class="text-lg font-semibold">{{ currentTitle }}</h1>
</header>
<div class="flex-1 overflow-y-auto p-6">
<RouterView />
</div>
</main>
</div>
</template>

7
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<object, object, unknown>;
export default component;
}

23
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,23 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KiB", "MiB", "GiB", "TiB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(i === 0 ? 0 : 2)} ${sizes[i]}`;
}
export function formatRate(bytesPerSec: number): string {
return `${formatBytes(bytesPerSec)}/s`;
}
export function shortenIpv6(addr: string): string {
if (addr.length <= 24) return addr;
return `${addr.slice(0, 10)}${addr.slice(-8)}`;
}

10
src/main.ts Normal file
View File

@@ -0,0 +1,10 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { router } from "./router";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

49
src/router/index.ts Normal file
View File

@@ -0,0 +1,49 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
{
path: "/",
redirect: "/status",
},
{
path: "/status",
name: "status",
component: () => import("@/views/Status.vue"),
meta: { title: "Status" },
},
{
path: "/peers",
name: "peers",
component: () => import("@/views/Peers.vue"),
meta: { title: "Peers" },
},
{
path: "/routes",
name: "routes",
component: () => import("@/views/Routes.vue"),
meta: { title: "Routes" },
},
{
path: "/messages",
name: "messages",
component: () => import("@/views/Messages.vue"),
meta: { title: "Messages" },
},
{
path: "/topics",
name: "topics",
component: () => import("@/views/Topics.vue"),
meta: { title: "Topics" },
},
{
path: "/settings",
name: "settings",
component: () => import("@/views/Settings.vue"),
meta: { title: "Settings" },
},
];
export const router = createRouter({
history: createWebHashHistory(),
routes,
});

57
src/style.css Normal file
View File

@@ -0,0 +1,57 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial,
sans-serif;
}
}

5
src/views/Messages.vue Normal file
View File

@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<p class="text-sm text-muted-foreground">Messages view wired in P4.</p>
</template>

5
src/views/Peers.vue Normal file
View File

@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<p class="text-sm text-muted-foreground">Peers view wired in P2.</p>
</template>

5
src/views/Routes.vue Normal file
View File

@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<p class="text-sm text-muted-foreground">Routes view wired in P3.</p>
</template>

5
src/views/Settings.vue Normal file
View File

@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<p class="text-sm text-muted-foreground">Settings view wired in P5.</p>
</template>

9
src/views/Status.vue Normal file
View File

@@ -0,0 +1,9 @@
<script setup lang="ts"></script>
<template>
<div class="space-y-4">
<p class="text-sm text-muted-foreground">
Daemon status will appear here once the sidecar is wired up (P1).
</p>
</div>
</template>

5
src/views/Topics.vue Normal file
View File

@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<p class="text-sm text-muted-foreground">Topics view wired in P4.</p>
</template>

50
tailwind.config.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { Config } from "tailwindcss";
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{vue,ts,tsx}"],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
fontFamily: {
mono: ["ui-monospace", "SFMono-Regular", "Menlo", "Monaco", "monospace"],
},
},
},
plugins: [],
} satisfies Config;

28
tsconfig.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"noEmit": true,
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
}

30
vite.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "node:path";
const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [vue()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
clearScreen: false,
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
ignored: ["**/src-tauri/**"],
},
},
}));