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 0000000..dcc787e
Binary files /dev/null and b/extension/icons/icon-128.png differ
diff --git a/extension/icons/icon-16.png b/extension/icons/icon-16.png
new file mode 100644
index 0000000..096954f
Binary files /dev/null and b/extension/icons/icon-16.png differ
diff --git a/extension/icons/icon-32.png b/extension/icons/icon-32.png
new file mode 100644
index 0000000..af644e2
Binary files /dev/null and b/extension/icons/icon-32.png differ
diff --git a/extension/icons/icon-48.png b/extension/icons/icon-48.png
new file mode 100644
index 0000000..009eb4a
Binary files /dev/null and b/extension/icons/icon-48.png differ
diff --git a/extension/manifest.json b/extension/manifest.json
new file mode 100644
index 0000000..5c168df
--- /dev/null
+++ b/extension/manifest.json
@@ -0,0 +1,30 @@
+{
+ "manifest_version": 3,
+ "name": "BoJ Node Monitor",
+ "version": "0.1.0",
+ "description": "Dashboard for monitoring your local Bundle of Joy (BoJ) server instance.",
+ "author": "Jonathan D.A. Jewell",
+ "permissions": ["storage", "alarms"],
+ "host_permissions": [
+ "http://localhost:7700/*",
+ "http://[::1]:7700/*"
+ ],
+ "action": {
+ "default_popup": "popup.html",
+ "default_icon": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ }
+ },
+ "icons": {
+ "16": "icons/icon-16.png",
+ "32": "icons/icon-32.png",
+ "48": "icons/icon-48.png",
+ "128": "icons/icon-128.png"
+ },
+ "background": {
+ "service_worker": "background.js"
+ }
+}
diff --git a/extension/popup.html b/extension/popup.html
new file mode 100644
index 0000000..f63e542
--- /dev/null
+++ b/extension/popup.html
@@ -0,0 +1,305 @@
+
+
+
+
+
+
+
+
+
+
+
+ BoJ Node Monitor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Server Connection
+
+
+
+
+
The extension connects to your local boj-server instance. Change the URL if your server runs on a different host or port.
+
+
+
+
+
+
+
+
+
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 0000000..dcc787e
Binary files /dev/null and b/tray/src-tauri/icons/128x128.png differ
diff --git a/tray/src-tauri/icons/32x32.png b/tray/src-tauri/icons/32x32.png
new file mode 100644
index 0000000..af644e2
Binary files /dev/null and b/tray/src-tauri/icons/32x32.png differ
diff --git a/tray/src-tauri/icons/icon.png b/tray/src-tauri/icons/icon.png
new file mode 100644
index 0000000..6e35413
Binary files /dev/null and b/tray/src-tauri/icons/icon.png differ
diff --git a/tray/src-tauri/icons/tray-icon.png b/tray/src-tauri/icons/tray-icon.png
new file mode 100644
index 0000000..af644e2
Binary files /dev/null and b/tray/src-tauri/icons/tray-icon.png differ
diff --git a/tray/src-tauri/src/lib.rs b/tray/src-tauri/src/lib.rs
new file mode 100644
index 0000000..a769149
--- /dev/null
+++ b/tray/src-tauri/src/lib.rs
@@ -0,0 +1,127 @@
+// SPDX-License-Identifier: PMPL-1.0-or-later
+//! BoJ Node Operator — system tray application for boj-server.
+//!
+//! Provides a desktop interface for volunteer node operators to manage
+//! their boj-server instance, control resource allocation, subscribe
+//! to cartridge catalogues, and monitor federation status.
+
+mod server;
+mod tray;
+
+use tauri::Manager;
+
+/// Server health status, polled periodically.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ServerStatus {
+ pub healthy: bool,
+ pub uptime_secs: u64,
+ pub cartridges_loaded: u32,
+ pub peers_connected: u32,
+ pub requests_served: u64,
+}
+
+/// Resource allocation preferences (BOINC-style).
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct ResourcePrefs {
+ pub cpu_percent: u8,
+ pub memory_mb: u32,
+ pub bandwidth_kbps: u32,
+ pub run_when: RunSchedule,
+}
+
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub enum RunSchedule {
+ Always,
+ IdleOnly,
+ Scheduled { start_hour: u8, end_hour: u8 },
+}
+
+impl Default for ResourcePrefs {
+ fn default() -> 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
+
+
+
+
+
+
+
+
+
Server Status
+
+
+
+
+
+
+
+
+
Resource Allocation
+
+
+
+ 50%
+
+
+
+
+ 512 MB
+
+
+
+
+ Unlimited
+
+
+
+
+
+
+
+
+
Cartridges
+
+
+
+
+
+
+
+
+
+
+
+
+