From ccface98a1ebeef4ffc502bbc9afb2bb3ee8c2db Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 11 Mar 2026 09:04:13 +0000 Subject: [PATCH] Add node operator tray app (Tauri 2) and browser extension Scaffold a BOINC-style desktop tray application for volunteer node operators and a Manifest V3 browser extension for monitoring. Tray app (tray/src-tauri/): Rust backend with Tauri 2, system tray menu, health poller, resource allocation controls, cartridge management, and a dark-themed HTML dashboard frontend. Browser extension (extension/): polls boj-server health, shows badge status (ON/OFF), popup dashboard with status/cartridges/settings tabs, configurable server URL. Works in Chrome and Firefox. Co-Authored-By: Claude Opus 4.6 --- extension/background.js | 64 +++++ extension/icons/icon-128.png | Bin 0 -> 360 bytes extension/icons/icon-16.png | Bin 0 -> 83 bytes extension/icons/icon-32.png | Bin 0 -> 104 bytes extension/icons/icon-48.png | Bin 0 -> 157 bytes extension/manifest.json | 30 +++ extension/popup.html | 305 ++++++++++++++++++++++++ extension/popup.js | 234 +++++++++++++++++++ tray/src-tauri/Cargo.toml | 24 ++ tray/src-tauri/build.rs | 4 + tray/src-tauri/icons/128x128.png | Bin 0 -> 360 bytes tray/src-tauri/icons/32x32.png | Bin 0 -> 104 bytes tray/src-tauri/icons/icon.png | Bin 0 -> 857 bytes tray/src-tauri/icons/tray-icon.png | Bin 0 -> 104 bytes tray/src-tauri/src/lib.rs | 127 ++++++++++ tray/src-tauri/src/main.rs | 8 + tray/src-tauri/src/server.rs | 143 ++++++++++++ tray/src-tauri/src/tray.rs | 60 +++++ tray/src-tauri/tauri.conf.json | 40 ++++ tray/src/index.html | 359 +++++++++++++++++++++++++++++ 20 files changed, 1398 insertions(+) create mode 100644 extension/background.js create mode 100644 extension/icons/icon-128.png create mode 100644 extension/icons/icon-16.png create mode 100644 extension/icons/icon-32.png create mode 100644 extension/icons/icon-48.png create mode 100644 extension/manifest.json create mode 100644 extension/popup.html create mode 100644 extension/popup.js create mode 100644 tray/src-tauri/Cargo.toml create mode 100644 tray/src-tauri/build.rs create mode 100644 tray/src-tauri/icons/128x128.png create mode 100644 tray/src-tauri/icons/32x32.png create mode 100644 tray/src-tauri/icons/icon.png create mode 100644 tray/src-tauri/icons/tray-icon.png create mode 100644 tray/src-tauri/src/lib.rs create mode 100644 tray/src-tauri/src/main.rs create mode 100644 tray/src-tauri/src/server.rs create mode 100644 tray/src-tauri/src/tray.rs create mode 100644 tray/src-tauri/tauri.conf.json create mode 100644 tray/src/index.html diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..be6c953 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: MPL-2.0 +// (PMPL-1.0-or-later preferred; MPL-2.0 required for browser extension stores) + +/// Background service worker — polls boj-server health and updates +/// the extension badge to reflect current server status. + +const SERVER_URL = "http://localhost:7700"; +const POLL_INTERVAL_MINUTES = 1; + +/// Fetch server health and update the badge icon/text. +async function pollHealth() { + try { + const resp = await fetch(`${SERVER_URL}/health`, { + signal: AbortSignal.timeout(3000), + }); + + if (resp.ok) { + const status = await resp.json(); + await chrome.action.setBadgeBackgroundColor({ color: "#4ecca3" }); + await chrome.action.setBadgeText({ text: "ON" }); + // Store latest status for the popup to read immediately + await chrome.storage.local.set({ lastStatus: status, lastPoll: Date.now() }); + } else { + await setOfflineBadge(); + } + } catch (_err) { + await setOfflineBadge(); + } +} + +async function setOfflineBadge() { + await chrome.action.setBadgeBackgroundColor({ color: "#e74c3c" }); + await chrome.action.setBadgeText({ text: "OFF" }); + await chrome.storage.local.set({ + lastStatus: { + healthy: false, + uptime_secs: 0, + cartridges_loaded: 0, + peers_connected: 0, + requests_served: 0, + }, + lastPoll: Date.now(), + }); +} + +// Poll on alarm +chrome.alarms.create("health-poll", { periodInMinutes: POLL_INTERVAL_MINUTES }); +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === "health-poll") { + pollHealth(); + } +}); + +// Poll immediately on install/startup +chrome.runtime.onInstalled.addListener(() => pollHealth()); +chrome.runtime.onStartup.addListener(() => pollHealth()); + +// Allow popup to request an immediate refresh +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + if (msg.type === "poll-now") { + pollHealth().then(() => sendResponse({ ok: true })); + return true; // async response + } +}); diff --git a/extension/icons/icon-128.png b/extension/icons/icon-128.png new file mode 100644 index 0000000000000000000000000000000000000000..dcc787e6b4c38e0f5daa65f5d0f3153325896f6a GIT binary patch literal 360 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVAS_?aSW-L^Y)S=BZGp#fdl>? zp06%&CMlFX`g0_0Ztcri_ZcM^m=hRy8W;^2*bXp|%W1G+J|d}bj-imP!`NXC<0Bq{ c^r0xkFXhSQG|6acG6N8Jy85}Sb4q9e0EqEq$N&HU literal 0 HcmV?d00001 diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..096954fe9c5eb83e2aa9999d481e461113627a0e GIT binary patch literal 83 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ql2i3Ar*6yKUi=6VXrzP@k&D= evm3939#Dx>@E-ZBqB}M~RScf4elF{r5}E+@h7|z- literal 0 HcmV?d00001 diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..af644e2c6f49aea8a515c6f9028db1008b191b13 GIT binary patch literal 104 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzeNPw1kcv5P&nYr8FmNy%2n9{D qDru517yi8Z-p(aL0w}r_5J?iU zu3!?B;XT>+_wJ19x$~u^E;O*2EMVl#a9}2jc_3TdK4NaBYGS(<&^`uFS3j3^P6 + + + + + + + + +
+
+

BoJ Node Monitor

+ +
+ +
+ + + +
+ +
+ +
+
+

Server Health

+
+
+
--
+
Uptime
+
+
+
--
+
Cartridges
+
+
+
--
+
Peers
+
+
+
--
+
Requests
+
+
+
+ +
+

Resources

+
+ +
+ -- +
+
+ +
+ -- +
+
+ +
+ -- +
+
+ +
+ +
+
+ + +
+
+

Loaded Cartridges

+
+
Loading…
+
+
+
+ + +
+
+

Server Connection

+
+ + +
+

The extension connects to your local boj-server instance. Change the URL if your server runs on a different host or port.

+
+
+
+ +
Bundle of Joy — Node Monitor v0.1.0
+ + + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..8643f3b --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: MPL-2.0 +// (PMPL-1.0-or-later preferred; MPL-2.0 required for browser extension stores) + +/// Popup script — renders the monitoring dashboard by fetching +/// data from the local boj-server REST API. + +const DEFAULT_SERVER_URL = "http://localhost:7700"; + +// ── Helpers ────────────────────────────────────────────────────── + +function formatUptime(secs) { + if (secs === 0) return "--"; + if (secs < 60) return `${secs}s`; + if (secs < 3600) return `${Math.floor(secs / 60)}m`; + const h = Math.floor(secs / 3600); + const m = Math.floor((secs % 3600) / 60); + return `${h}h ${m}m`; +} + +function formatRequests(n) { + if (n === 0) return "--"; + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return String(n); +} + +async function getServerUrl() { + const result = await chrome.storage.local.get("serverUrl"); + return result.serverUrl || DEFAULT_SERVER_URL; +} + +async function apiFetch(path, options = {}) { + const base = await getServerUrl(); + const resp = await fetch(`${base}${path}`, { + signal: AbortSignal.timeout(5000), + ...options, + }); + return resp; +} + +// ── Tab switching ──────────────────────────────────────────────── + +document.querySelectorAll(".tab").forEach((tab) => { + tab.addEventListener("click", () => { + document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + document.querySelectorAll(".tab-content").forEach((c) => c.classList.remove("active")); + tab.classList.add("active"); + document.getElementById(`tab-${tab.dataset.tab}`).classList.add("active"); + }); +}); + +// ── Status rendering ───────────────────────────────────────────── + +function renderStatus(status) { + const dot = document.getElementById("status-dot"); + dot.className = "status-dot " + (status.healthy ? "healthy" : "unhealthy"); + + const uptimeEl = document.getElementById("uptime"); + const cartEl = document.getElementById("cartridges-count"); + const peersEl = document.getElementById("peers-count"); + const reqEl = document.getElementById("requests-count"); + + uptimeEl.textContent = formatUptime(status.uptime_secs); + uptimeEl.className = "value" + (status.healthy ? "" : " offline"); + cartEl.textContent = status.cartridges_loaded || "--"; + peersEl.textContent = status.peers_connected || "--"; + reqEl.textContent = formatRequests(status.requests_served); +} + +async function fetchAndRenderStatus() { + try { + const resp = await apiFetch("/health"); + if (resp.ok) { + const status = await resp.json(); + renderStatus(status); + return status; + } + } catch (_err) { + // fall through + } + renderStatus({ + healthy: false, + uptime_secs: 0, + cartridges_loaded: 0, + peers_connected: 0, + requests_served: 0, + }); + return null; +} + +// ── Resource bars ──────────────────────────────────────────────── + +async function fetchAndRenderResources() { + try { + const resp = await apiFetch("/api/prefs"); + if (resp.ok) { + const prefs = await resp.json(); + const cpuBar = document.getElementById("cpu-bar"); + const memBar = document.getElementById("mem-bar"); + const bwBar = document.getElementById("bw-bar"); + const cpuVal = document.getElementById("cpu-val"); + const memVal = document.getElementById("mem-val"); + const bwVal = document.getElementById("bw-val"); + + cpuBar.style.width = prefs.cpu_percent + "%"; + cpuVal.textContent = prefs.cpu_percent + "%"; + + // Memory bar: assume 4096 MB max for display + const memPct = Math.min(100, (prefs.memory_mb / 4096) * 100); + memBar.style.width = memPct + "%"; + memVal.textContent = prefs.memory_mb + " MB"; + + if (prefs.bandwidth_kbps === 0) { + bwBar.style.width = "100%"; + bwVal.textContent = "Unlimited"; + } else { + const bwPct = Math.min(100, (prefs.bandwidth_kbps / 10000) * 100); + bwBar.style.width = bwPct + "%"; + bwVal.textContent = prefs.bandwidth_kbps + " kbps"; + } + } + } catch (_err) { + // Leave bars at default + } +} + +// ── Cartridges ─────────────────────────────────────────────────── + +function renderCartridges(cartridges) { + const list = document.getElementById("cartridge-list"); + if (!cartridges || cartridges.length === 0) { + list.innerHTML = '
No cartridges loaded
'; + return; + } + + list.innerHTML = cartridges + .map( + (c) => ` +
+
+ ${c.name} + ${c.status} +
+ ` + ) + .join(""); + + // Toggle handlers + list.querySelectorAll(".toggle").forEach((el) => { + el.addEventListener("click", async () => { + const name = el.dataset.name; + const newState = el.dataset.enabled !== "true"; + try { + await apiFetch(`/api/cartridges/${name}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: newState }), + }); + await fetchAndRenderCartridges(); + } catch (err) { + console.error("Toggle failed:", err); + } + }); + }); +} + +async function fetchAndRenderCartridges() { + try { + const resp = await apiFetch("/api/cartridges"); + if (resp.ok) { + const cartridges = await resp.json(); + renderCartridges(cartridges); + } else { + renderCartridges([]); + } + } catch (_err) { + renderCartridges([]); + } +} + +// ── Settings ───────────────────────────────────────────────────── + +async function loadSettings() { + const url = await getServerUrl(); + document.getElementById("server-url").value = url; +} + +document.getElementById("save-url-btn").addEventListener("click", async () => { + const url = document.getElementById("server-url").value.trim(); + if (url) { + await chrome.storage.local.set({ serverUrl: url }); + // Re-fetch everything with new URL + fetchAndRenderStatus(); + fetchAndRenderResources(); + fetchAndRenderCartridges(); + } +}); + +// ── Actions ────────────────────────────────────────────────────── + +document.getElementById("restart-btn").addEventListener("click", async () => { + try { + await apiFetch("/api/restart", { method: "POST" }); + // Brief delay then re-poll + setTimeout(() => { + fetchAndRenderStatus(); + chrome.runtime.sendMessage({ type: "poll-now" }); + }, 2000); + } catch (err) { + console.error("Restart failed:", err); + } +}); + +document.getElementById("refresh-btn").addEventListener("click", () => { + fetchAndRenderStatus(); + fetchAndRenderResources(); + fetchAndRenderCartridges(); + chrome.runtime.sendMessage({ type: "poll-now" }); +}); + +// ── Initialise ─────────────────────────────────────────────────── + +// First try to load cached status from background worker (instant) +chrome.storage.local.get("lastStatus", (result) => { + if (result.lastStatus) { + renderStatus(result.lastStatus); + } +}); + +// Then fetch fresh data +fetchAndRenderStatus(); +fetchAndRenderResources(); +fetchAndRenderCartridges(); +loadSettings(); diff --git a/tray/src-tauri/Cargo.toml b/tray/src-tauri/Cargo.toml new file mode 100644 index 0000000..0807d88 --- /dev/null +++ b/tray/src-tauri/Cargo.toml @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: PMPL-1.0-or-later +[package] +name = "boj-tray" +version = "0.1.0" +description = "Bundle of Joy node operator tray application" +authors = ["Jonathan D.A. Jewell "] +license = "MPL-2.0" +edition = "2021" + +[lib] +name = "boj_tray_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-shell = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio = { version = "1", features = ["full"] } +dirs = "5" diff --git a/tray/src-tauri/build.rs b/tray/src-tauri/build.rs new file mode 100644 index 0000000..44493b7 --- /dev/null +++ b/tray/src-tauri/build.rs @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +fn main() { + tauri_build::build() +} diff --git a/tray/src-tauri/icons/128x128.png b/tray/src-tauri/icons/128x128.png new file mode 100644 index 0000000000000000000000000000000000000000..dcc787e6b4c38e0f5daa65f5d0f3153325896f6a GIT binary patch literal 360 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVAS_?aSW-L^Y)S=BZGp#fdl>? zp06%&CMlFX`g0_0Ztcri_ZcM^m=hRy8W;^2*bXp|%W1G+J|d}bj-imP!`NXC<0Bq{ c^r0xkFXhSQG|6acG6N8Jy85}Sb4q9e0EqEq$N&HU literal 0 HcmV?d00001 diff --git a/tray/src-tauri/icons/32x32.png b/tray/src-tauri/icons/32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..af644e2c6f49aea8a515c6f9028db1008b191b13 GIT binary patch literal 104 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzeNPw1kcv5P&nYr8FmNy%2n9{D qDru517yi8Z-p(aL0w}H=qyLfNA~SJLLzHZJ=q%kU?eK_G1ujfTL;3xRJ8E*CUe%eXmT1G5Z+r>mdK II;Vst0Pr%AYybcN literal 0 HcmV?d00001 diff --git a/tray/src-tauri/icons/tray-icon.png b/tray/src-tauri/icons/tray-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..af644e2c6f49aea8a515c6f9028db1008b191b13 GIT binary patch literal 104 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdzeNPw1kcv5P&nYr8FmNy%2n9{D qDru517yi8Z-p(aL0w} Self { + Self { + cpu_percent: 50, + memory_mb: 512, + bandwidth_kbps: 0, // unlimited + run_when: RunSchedule::Always, + } + } +} + +/// Cartridge subscription entry. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct CartridgeSubscription { + pub name: String, + pub source: String, // GitHub URL or registry ID + pub enabled: bool, + pub status: String, // "downloading", "ready", "error", "disabled" +} + +// ── Tauri commands (called from frontend) ────────────────────────── + +#[tauri::command] +async fn get_server_status() -> Result { + server::fetch_status().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn get_resource_prefs() -> Result { + Ok(server::load_prefs().unwrap_or_default()) +} + +#[tauri::command] +async fn set_resource_prefs(prefs: ResourcePrefs) -> Result<(), String> { + server::save_prefs(&prefs).map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn get_cartridges() -> Result, String> { + server::fetch_cartridges().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn add_cartridge_source(url: String) -> Result { + server::add_cartridge(&url).await.map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn toggle_cartridge(name: String, enabled: bool) -> Result<(), String> { + server::toggle_cartridge(&name, enabled) + .await + .map_err(|e| e.to_string()) +} + +#[tauri::command] +async fn restart_server() -> Result<(), String> { + server::restart().await.map_err(|e| e.to_string()) +} + +// ── App entry point ──────────────────────────────────────────────── + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .setup(|app| { + // Build system tray + tray::setup_tray(app.handle())?; + + // Start background health poller + let handle = app.handle().clone(); + tauri::async_runtime::spawn(async move { + server::health_poll_loop(handle).await; + }); + + Ok(()) + }) + .invoke_handler(tauri::generate_handler![ + get_server_status, + get_resource_prefs, + set_resource_prefs, + get_cartridges, + add_cartridge_source, + toggle_cartridge, + restart_server, + ]) + .run(tauri::generate_context!()) + .expect("error while running boj-tray"); +} diff --git a/tray/src-tauri/src/main.rs b/tray/src-tauri/src/main.rs new file mode 100644 index 0000000..35119de --- /dev/null +++ b/tray/src-tauri/src/main.rs @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +//! Binary entry point for the BoJ Node Operator tray application. + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + boj_tray_lib::run(); +} diff --git a/tray/src-tauri/src/server.rs b/tray/src-tauri/src/server.rs new file mode 100644 index 0000000..66b113c --- /dev/null +++ b/tray/src-tauri/src/server.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +//! HTTP client for communicating with the local boj-server instance. + +use crate::{CartridgeSubscription, ResourcePrefs, ServerStatus}; +use std::path::PathBuf; +use tauri::AppHandle; + +const BASE_URL: &str = "http://[::1]:7700"; + +/// Fetch server health status from the `/health` endpoint. +pub async fn fetch_status() -> Result> { + let client = reqwest::Client::new(); + let resp = client + .get(format!("{BASE_URL}/health")) + .timeout(std::time::Duration::from_secs(3)) + .send() + .await?; + + if resp.status().is_success() { + let status: ServerStatus = resp.json().await?; + Ok(status) + } else { + Ok(ServerStatus { + healthy: false, + uptime_secs: 0, + cartridges_loaded: 0, + peers_connected: 0, + requests_served: 0, + }) + } +} + +/// Fetch the list of loaded cartridges from the server. +pub async fn fetch_cartridges() -> Result, Box> { + let client = reqwest::Client::new(); + let resp = client + .get(format!("{BASE_URL}/api/cartridges")) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await?; + + let cartridges: Vec = resp.json().await?; + Ok(cartridges) +} + +/// Add a new cartridge source (GitHub URL or registry ID). +pub async fn add_cartridge( + url: &str, +) -> Result> { + let client = reqwest::Client::new(); + let resp = client + .post(format!("{BASE_URL}/api/cartridges")) + .json(&serde_json::json!({ "source": url })) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await?; + + let cartridge: CartridgeSubscription = resp.json().await?; + Ok(cartridge) +} + +/// Enable or disable a cartridge by name. +pub async fn toggle_cartridge( + name: &str, + enabled: bool, +) -> Result<(), Box> { + let client = reqwest::Client::new(); + client + .patch(format!("{BASE_URL}/api/cartridges/{name}")) + .json(&serde_json::json!({ "enabled": enabled })) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await?; + + Ok(()) +} + +/// Restart the boj-server process via its management endpoint. +pub async fn restart() -> Result<(), Box> { + let client = reqwest::Client::new(); + client + .post(format!("{BASE_URL}/api/restart")) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await?; + + Ok(()) +} + +// ── Local preferences persistence ──────────────────────────────── + +/// Path to the local preferences file. +fn prefs_path() -> PathBuf { + dirs::config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("boj-node") + .join("prefs.json") +} + +/// Load resource preferences from disk. +pub fn load_prefs() -> Result> { + let path = prefs_path(); + let data = std::fs::read_to_string(path)?; + let prefs: ResourcePrefs = serde_json::from_str(&data)?; + Ok(prefs) +} + +/// Save resource preferences to disk. +pub fn save_prefs(prefs: &ResourcePrefs) -> Result<(), Box> { + let path = prefs_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let data = serde_json::to_string_pretty(prefs)?; + std::fs::write(path, data)?; + Ok(()) +} + +// ── Background health poller ───────────────────────────────────── + +/// Polls the server health endpoint every 10 seconds and emits +/// status events to the frontend. +pub async fn health_poll_loop(handle: AppHandle) { + use tauri::Emitter; + + loop { + let status = match fetch_status().await { + Ok(s) => s, + Err(_) => ServerStatus { + healthy: false, + uptime_secs: 0, + cartridges_loaded: 0, + peers_connected: 0, + requests_served: 0, + }, + }; + + // Emit to all windows — frontend listens on "server-status" + let _ = handle.emit("server-status", &status); + + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + } +} diff --git a/tray/src-tauri/src/tray.rs b/tray/src-tauri/src/tray.rs new file mode 100644 index 0000000..ea5f7b2 --- /dev/null +++ b/tray/src-tauri/src/tray.rs @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +//! System tray setup and menu handling for the BoJ Node Operator. + +use tauri::{ + menu::{Menu, MenuItem}, + tray::TrayIconBuilder, + AppHandle, +}; + +/// Build and register the system tray icon with its context menu. +pub fn setup_tray(handle: &AppHandle) -> Result<(), Box> { + let show_item = MenuItem::with_id(handle, "show", "Show Dashboard", true, None::<&str>)?; + let status_item = MenuItem::with_id(handle, "status", "Status: checking…", false, None::<&str>)?; + let restart_item = MenuItem::with_id(handle, "restart", "Restart Server", true, None::<&str>)?; + let quit_item = MenuItem::with_id(handle, "quit", "Quit", true, None::<&str>)?; + + let menu = Menu::with_items( + handle, + &[&show_item, &status_item, &restart_item, &quit_item], + )?; + + TrayIconBuilder::new() + .menu(&menu) + .tooltip("BoJ Server — Node Operator") + .on_menu_event(move |app, event| match event.id.as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + "restart" => { + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = crate::server::restart().await; + // Give it a moment then refresh status + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + if let Ok(status) = crate::server::fetch_status().await { + use tauri::Emitter; + let _ = handle.emit("server-status", &status); + } + }); + } + "quit" => { + app.exit(0); + } + _ => {} + }) + .on_tray_icon_event(|tray_icon, event| { + if let tauri::tray::TrayIconEvent::Click { .. } = event { + if let Some(window) = tray_icon.app_handle().get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + }) + .build(handle)?; + + Ok(()) +} diff --git a/tray/src-tauri/tauri.conf.json b/tray/src-tauri/tauri.conf.json new file mode 100644 index 0000000..b588a6e --- /dev/null +++ b/tray/src-tauri/tauri.conf.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://raw.githubusercontent.com/nicholasio/tauri-docs-1/main/.schemas/config.schema.json", + "productName": "BoJ Node", + "version": "0.1.0", + "identifier": "dev.hyperpolymath.boj-node", + "build": { + "frontendDist": "../src", + "devUrl": "http://localhost:1420" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "BoJ Node Operator", + "width": 520, + "height": 640, + "resizable": true, + "visible": false, + "decorations": true + } + ], + "trayIcon": { + "iconPath": "icons/tray-icon.png", + "iconAsTemplate": true, + "tooltip": "BoJ Server — Node Operator" + }, + "security": { + "csp": "default-src 'self'; connect-src 'self' http://localhost:7700" + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/icon.png" + ] + } +} diff --git a/tray/src/index.html b/tray/src/index.html new file mode 100644 index 0000000..0fd8156 --- /dev/null +++ b/tray/src/index.html @@ -0,0 +1,359 @@ + + + + + + + BoJ Node Operator + + + +
+
+

BoJ Node Operator

+
+ +
+ +
+

Server Status

+
+
+
--
+
Uptime
+
+
+
--
+
Cartridges
+
+
+
--
+
Peers
+
+
+
--
+
Requests
+
+
+
+ +
+
+ + +
+

Resource Allocation

+
+ + + 50% +
+
+ + + 512 MB +
+
+ + + Unlimited +
+
+ +
+
+ + +
+

Cartridges

+
+
Loading…
+
+
+ + +
+
+
+ +
Bundle of Joy — Volunteer Node Operator v0.1.0
+ + + +