From f2cc41ec1a183a42743da797ed615d46801214e6 Mon Sep 17 00:00:00 2001 From: FlowWater <1209041527@qq.com> Date: Fri, 28 Aug 2026 17:35:28 +0800 Subject: [PATCH 1/3] docs: design Windows server tray controls --- .../2026-08-28-windows-server-tray-design.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-28-windows-server-tray-design.md diff --git a/docs/superpowers/specs/2026-08-28-windows-server-tray-design.md b/docs/superpowers/specs/2026-08-28-windows-server-tray-design.md new file mode 100644 index 0000000000..a693d11c41 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-windows-server-tray-design.md @@ -0,0 +1,85 @@ +# Windows Server Tray Design + +## Goal + +Give the standalone `codeg-server.exe` Windows release a native system-tray +presence with the same Codeg icon used by the web and desktop products. The +tray is a control surface for the headless server, not a hidden desktop window. + +## Behavior + +### Standalone server + +- Tray icon: `src-tauri/icons/icon.ico` embedded in the Windows executable. +- Double click opens the server Web UI at the resolved local address + (`HOST:PORT`). A single click has no side effect, matching normal Windows + tray expectations and avoiding two browser launches for one double click. +- Menu item `Open Web Console` opens the same URL. +- Menu item `Open Logs Folder` opens the directory returned by + `paths::codeg_logs_root()` in Windows Explorer. The directory contains the + rotating `codeg-server..log` files. +- Menu item `Quit Codeg Server` requests graceful shutdown through the existing + shutdown signal, allowing the HTTP server and log guard to flush before the + process exits. +- Tray initialization failure is logged and does not prevent the HTTP server + from starting. + +### Desktop application + +- Keep the existing left-click behavior: show and focus the native workspace. +- Add `Open Logs Folder` to the existing tray menu. +- Keep `Show Workspace` and `Quit Codeg` unchanged. +- Desktop and server use the same icon asset, but their activation targets are + intentionally different because only the desktop build owns a native window. + +## Architecture + +The server build does not enable Tauri, so its tray implementation must live in +a small Windows-only module compiled into `codeg-server` under +`cfg(target_os = "windows")`. A native tray dependency with a message-loop +thread owns the icon and menu. The tray thread sends typed commands over a +channel to the server's async runtime: + +```text +Windows tray callback + | + v +TrayCommand channel ----> server runtime + |-- open browser + |-- open Explorer + `-- trigger shutdown +``` + +The tray thread is started after the bind address and log directory are +resolved, so menu actions always use the actual runtime configuration. It is +stopped by the same shutdown path as the HTTP server. Non-Windows builds +compile a no-op starter and retain their current behavior. + +The desktop menu is extended in the existing Tauri tray module. Its log action +calls the existing `open_logs_dir` command, while server mode opens the path +directly from the native tray handler because there is no Tauri command layer. + +## Data and error handling + +- The server passes a fully formed Web UI URL and an absolute logs directory to + the tray starter; no environment is read from callbacks. +- Browser and Explorer launch failures are written to the normal server log, + while the menu callback remains responsive. +- A closed command channel means the server is shutting down; callbacks become + no-ops and never panic. +- Tray startup errors are non-fatal and include the target platform and error + detail in the log. +- The icon is embedded at compile time so packaged releases do not depend on a + neighboring `web/` file or the user's working directory. + +## Testing and release checks + +- Unit-test command routing and URL/path propagation without creating a real + tray on non-Windows CI. +- Run `cargo check --no-default-features --bin codeg-server` on all existing + targets. +- Extend the Windows release smoke test to assert the server binary is present; + manual Windows validation confirms icon visibility, single/double activation, + opening the logs directory, and graceful quit. +- Verify the existing desktop tray tests and `cargo check` remain unchanged on + non-Windows targets. From 0114294ba9c3622168ca5c899508e525cdec283d Mon Sep 17 00:00:00 2001 From: FlowWater <1209041527@qq.com> Date: Fri, 28 Aug 2026 17:40:38 +0800 Subject: [PATCH 2/3] docs: add Windows server tray implementation plan --- .../plans/2026-08-28-windows-server-tray.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-28-windows-server-tray.md diff --git a/docs/superpowers/plans/2026-08-28-windows-server-tray.md b/docs/superpowers/plans/2026-08-28-windows-server-tray.md new file mode 100644 index 0000000000..06eecfcc3a --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-windows-server-tray.md @@ -0,0 +1,140 @@ +# Windows Server Tray Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Windows-native tray icon and controls to `codeg-server.exe`, and add an “Open Logs Folder” action to the existing desktop tray. + +**Architecture:** Add a `server_tray` library module compiled only for Windows. It owns a `tray-icon` + `tao` event-loop thread, embeds the existing PNG/ICO assets, and sends `OpenWeb`, `OpenLogs`, and `Quit` commands to the server runtime. The server wraps `axum::serve` with graceful shutdown driven by the tray’s `Quit` command. The existing Tauri tray menu receives one additional log item. + +**Tech Stack:** Rust 2021, Axum, Tokio, `tray-icon` 0.21, `tao` 0.34, `open` 5, Tauri 2 desktop tray. + +--- + +### Task 1: Add testable tray command routing + +**Files:** +- Create: `src-tauri/src/server_tray.rs` +- Modify: `src-tauri/src/lib.rs` +- Modify: `src-tauri/Cargo.toml` +- Modify: `src-tauri/Cargo.lock` + +- [ ] **Step 1: Define the Windows-only command API and non-Windows stub** + +Expose `TrayCommand`, `TrayHandle`, and `start(url, logs_dir)` from `server_tray`. On non-Windows targets, `start` returns `Ok(None)` so server builds remain headless and testable. + +- [ ] **Step 2: Add target-specific dependencies** + +Add `tray-icon = "0.21.3"`, `tao = "0.34.5"`, and `open = "5"` under `target.'cfg(target_os = "windows")'.dependencies`. Keep the existing Tauri tray dependency and server dependency graph separate through `cfg`. + +- [ ] **Step 3: Add pure tests for URL and log-path propagation** + +Test the public `TrayConfig`/command construction without creating a native event loop. The test must assert the exact URL and absolute log directory passed to the platform starter. + +- [ ] **Step 4: Run the focused library test** + +Run from `src-tauri`: + +```text +cargo test --no-default-features server_tray +``` + +Expected result: the non-Windows stub tests pass; on a Windows runner the same tests compile with the native module. + +### Task 2: Implement the Windows tray event loop + +**Files:** +- Modify: `src-tauri/src/server_tray.rs` +- Add binary asset reference: `src-tauri/icons/icon.png` + +- [ ] **Step 1: Build the event-loop thread** + +Create a `tao::EventLoop` on a dedicated thread, forward `TrayIconEvent` and `MenuEvent` through `EventLoopProxy`, and create the tray icon during `StartCause::Init`. + +- [ ] **Step 2: Build the menu and icon** + +Use `muda::Menu`, `MenuItem`, and `PredefinedMenuItem` through `tray_icon::menu`. Use `include_bytes!("../icons/icon.png")` plus the existing `image` decoder to construct `tray_icon::Icon`. Menu ids must be stable constants: `server-tray:open-web`, `server-tray:open-logs`, and `server-tray:quit`. + +- [ ] **Step 3: Route interactions** + +Handle `TrayIconEvent::DoubleClick` with the left button by sending `OpenWeb`. Handle menu ids by sending `OpenWeb`, `OpenLogs`, or `Quit`; on `Quit`, drop the tray icon and set the event loop control flow to exit. + +- [ ] **Step 4: Launch external applications safely** + +Perform `open::that(url)` and `open::that(logs_dir)` inside the tray thread, logging failures with `tracing::warn!`. Callback failures must not panic or terminate the tray loop. + +### Task 3: Connect the tray to server startup and graceful shutdown + +**Files:** +- Modify: `src-tauri/src/bin/codeg_server.rs` +- Modify: `src-tauri/src/web/mod.rs` + +- [ ] **Step 1: Add a server-owned shutdown sender** + +Create a `tokio::sync::oneshot` channel after the listener binds. Start the tray with the final advertised URL and `codeg_logs_root()` path, then spawn a blocking receiver task that resolves when the tray sends `Quit`. + +- [ ] **Step 2: Use Axum graceful shutdown** + +Replace the bare `axum::serve(listener, router).await` with `with_graceful_shutdown` awaiting either the tray receiver or the existing process shutdown condition. Trigger `state.web_server_state.shutdown_signal()` before the graceful future resolves so WebSocket handlers drain consistently. + +- [ ] **Step 3: Keep startup resilient** + +Log tray initialization errors and continue serving HTTP. Keep the `TrayHandle` alive until `axum::serve` returns, then drop it before the existing office-watch cleanup. + +- [ ] **Step 4: Run server checks** + +Run: + +```text +cargo check --no-default-features --bin codeg-server +cargo test --no-default-features --bin codeg-server --lib +``` + +Expected result: Linux/macOS compile without the tray dependency and existing server tests pass. + +### Task 4: Extend the desktop tray menu + +**Files:** +- Modify: `src-tauri/src/commands/windows.rs` +- Modify: `src-tauri/src/lib.rs` + +- [ ] **Step 1: Add a stable “Open Logs Folder” menu id and localized labels** + +Extend `TrayLabels` for all existing locales and add `TRAY_MENU_ID_LOGS`. + +- [ ] **Step 2: Add the menu item in install and refresh paths** + +Insert the log item between “Show Workspace” and the separator in both `install_tray_icon` and `refresh_tray_menu`. + +- [ ] **Step 3: Dispatch the action** + +Handle `TRAY_MENU_ID_LOGS` in the app-wide `on_menu_event` callback by invoking the existing `open_logs_dir` command/core implementation and logging any error. + +- [ ] **Step 4: Run desktop checks** + +Run `cargo check --features tauri-runtime` and the existing Rust test command for the desktop feature. + +### Task 5: Verify Windows release packaging + +**Files:** +- Modify: `.github/workflows/release.yml` +- Modify: `.github/workflows/test.yml` only if a focused Windows check is needed + +- [ ] **Step 1: Verify the release job carries the embedded icon** + +Keep the existing Windows packaging paths unchanged; the icon is inside the PE resource and requires no extra `web/` file. + +- [ ] **Step 2: Add a Windows smoke assertion** + +After building `codeg-server.exe`, use a PowerShell PE/resource inspection available on the hosted runner to assert the binary has an icon resource. Keep the existing executable-presence and `codeg-mcp --help` checks. + +- [ ] **Step 3: Run formatting and diff checks** + +Run `cargo fmt --check`, `git diff --check`, and inspect the final release diff for unrelated changes. + +- [ ] **Step 4: Commit implementation changes** + +```text +git add src-tauri/Cargo.toml src-tauri/Cargo.lock src-tauri/src/server_tray.rs src-tauri/src/bin/codeg_server.rs src-tauri/src/web/mod.rs src-tauri/src/commands/windows.rs src-tauri/src/lib.rs .github/workflows/release.yml +git commit -m "feat(server): add Windows tray controls" +``` + From 01feafb7a3f7152bc73fd737c73074cd892a0d10 Mon Sep 17 00:00:00 2001 From: FlowWater <1209041527@qq.com> Date: Sun, 30 Aug 2026 11:54:22 +0800 Subject: [PATCH 3/3] feat(server): add Windows tray controls --- src-tauri/Cargo.lock | 24 ++++ src-tauri/Cargo.toml | 4 + src-tauri/build.rs | 13 ++ src-tauri/src/bin/codeg_server.rs | 46 ++++++- src-tauri/src/commands/windows.rs | 20 ++- src-tauri/src/lib.rs | 14 +++ src-tauri/src/server_tray.rs | 194 ++++++++++++++++++++++++++++++ src-tauri/windows/codeg-server.rc | 1 + 8 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/server_tray.rs create mode 100644 src-tauri/windows/codeg-server.rc diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index a00b69fd4b..0b59759523 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1041,6 +1041,7 @@ dependencies = [ "chrono-tz", "cron", "dirs 6.0.0", + "embed-resource", "fix-path-env", "flate2", "futures", @@ -1061,6 +1062,7 @@ dependencies = [ "mac-notification-sys", "minisign-verify", "notify", + "open", "portable-pty", "prost", "qrcode", @@ -1077,6 +1079,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", + "tao", "tar", "tauri", "tauri-build", @@ -1100,6 +1103,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "tray-icon", "urlencoding", "uuid", "walkdir", @@ -3463,6 +3467,25 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3696,6 +3719,7 @@ dependencies = [ "dpi", "gtk", "keyboard-types", + "libxdo", "objc2", "objc2-app-kit", "objc2-core-foundation", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3569b91267..7f127a94ae 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -54,6 +54,7 @@ required-features = [] [build-dependencies] tauri-build = { version = "2", features = [], optional = true } +embed-resource = "3.0.6" [dependencies] tauri = { version = "2", features = ["macos-private-api", "tray-icon"], optional = true } @@ -156,6 +157,9 @@ mac-notification-sys = "0.6" [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Threading"] } junction = "1" +tray-icon = "0.21.3" +tao = "0.34.5" +open = "5" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 09e74bc16f..64ebf11a08 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,17 @@ fn main() { + #[cfg(target_os = "windows")] + { + println!("cargo:rerun-if-changed=windows/codeg-server.rc"); + println!("cargo:rerun-if-changed=icons/icon.ico"); + embed_resource::compile_for( + "windows/codeg-server.rc", + ["codeg-server"], + embed_resource::NONE, + ) + .manifest_required() + .expect("failed to embed the codeg-server Windows icon"); + } + #[cfg(feature = "tauri-runtime")] { ensure_sidecar_placeholder(); diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 2e95622191..90e4fce7ac 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -581,8 +581,52 @@ async fn async_main() -> ExitCode { tracing::info!(" {}", addr); } + // Standalone Windows builds have no Tauri window to own a tray icon. Keep + // the native tray alive for the lifetime of the server and use its quit + // action to enter Axum's graceful shutdown path. + #[cfg(target_os = "windows")] + let tray_quit_rx = { + let tray_handle = match codeg_lib::server_tray::start( + addresses + .first() + .cloned() + .unwrap_or_else(|| format!("http://{}:{}", advertised_host, actual_port)), + codeg_lib::commands::logging::open_logs_dir_core() + .map(PathBuf::from) + .unwrap_or_else(|err| { + tracing::warn!("[Tray] failed to prepare log directory: {}", err.message); + codeg_lib::paths::codeg_logs_root() + }), + ) { + Ok(handle) => handle, + Err(err) => { + tracing::warn!("[Tray] disabled: {err}"); + None + } + }; + tray_handle.map(|handle| handle.into_quit_receiver()) + }; + // Start serving - if let Err(e) = axum::serve(listener, router).await { + #[cfg(target_os = "windows")] + let shutdown_signal = state.web_server_state.shutdown_signal(); + #[cfg(target_os = "windows")] + let graceful_shutdown = async move { + if let Some(quit_rx) = tray_quit_rx { + let _ = quit_rx.await; + shutdown_signal.trigger(); + } else { + std::future::pending::<()>().await; + } + }; + #[cfg(target_os = "windows")] + let server_result = axum::serve(listener, router) + .with_graceful_shutdown(graceful_shutdown) + .await; + #[cfg(not(target_os = "windows"))] + let server_result = axum::serve(listener, router).await; + + if let Err(e) = server_result { tracing::error!("[SERVER] Server error: {}", e); return ExitCode::from(1); } diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 7ea73da104..9aa33b82e8 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -1933,6 +1933,7 @@ fn load_macos_tray_template_icon() -> Result, Strin /// `on_menu_event` handler in `lib.rs`. pub const TRAY_MENU_ID_PREFIX: &str = "tray:"; pub const TRAY_MENU_ID_SHOW: &str = "tray:show"; +pub const TRAY_MENU_ID_LOGS: &str = "tray:logs"; pub const TRAY_MENU_ID_QUIT: &str = "tray:quit"; pub const TRAY_ICON_ID: &str = "codeg-tray"; @@ -1975,6 +1976,7 @@ pub fn show_main_window(app: &AppHandle) { #[cfg(feature = "tauri-runtime")] struct TrayLabels { show_workspace: &'static str, + open_logs: &'static str, quit: &'static str, } @@ -1984,42 +1986,52 @@ fn tray_labels_for(locale: crate::models::system::AppLocale) -> TrayLabels { match locale { AppLocale::ZhCn => TrayLabels { show_workspace: "显示工作台", + open_logs: "打开日志目录", quit: "退出 Codeg", }, AppLocale::ZhTw => TrayLabels { show_workspace: "顯示工作臺", + open_logs: "開啟日誌目錄", quit: "退出 Codeg", }, AppLocale::Ja => TrayLabels { show_workspace: "ワークスペースを表示", + open_logs: "ログフォルダーを開く", quit: "Codeg を終了", }, AppLocale::Ko => TrayLabels { show_workspace: "워크스페이스 표시", + open_logs: "로그 폴더 열기", quit: "Codeg 종료", }, AppLocale::Es => TrayLabels { show_workspace: "Mostrar el área de trabajo", + open_logs: "Abrir carpeta de registros", quit: "Salir de Codeg", }, AppLocale::De => TrayLabels { show_workspace: "Arbeitsbereich anzeigen", + open_logs: "Protokollordner öffnen", quit: "Codeg beenden", }, AppLocale::Fr => TrayLabels { show_workspace: "Afficher l'espace de travail", + open_logs: "Ouvrir le dossier des journaux", quit: "Quitter Codeg", }, AppLocale::Pt => TrayLabels { show_workspace: "Mostrar área de trabalho", + open_logs: "Abrir pasta de registros", quit: "Sair do Codeg", }, AppLocale::Ar => TrayLabels { show_workspace: "إظهار مساحة العمل", + open_logs: "فتح مجلد السجلات", quit: "إنهاء Codeg", }, AppLocale::En => TrayLabels { show_workspace: "Show Workspace", + open_logs: "Open Logs Folder", quit: "Quit Codeg", }, } @@ -2046,10 +2058,12 @@ pub fn install_tray_icon( true, None::<&str>, )?; + let logs_item = + MenuItem::with_id(app, TRAY_MENU_ID_LOGS, labels.open_logs, true, None::<&str>)?; let separator = PredefinedMenuItem::separator(app)?; let quit_item = MenuItem::with_id(app, TRAY_MENU_ID_QUIT, labels.quit, true, None::<&str>)?; let menu = MenuBuilder::new(app) - .items(&[&show_item, &separator, &quit_item]) + .items(&[&show_item, &logs_item, &separator, &quit_item]) .build()?; let mut builder = TrayIconBuilder::with_id(TRAY_ICON_ID) @@ -2126,10 +2140,12 @@ pub fn refresh_tray_menu( true, None::<&str>, )?; + let logs_item = + MenuItem::with_id(app, TRAY_MENU_ID_LOGS, labels.open_logs, true, None::<&str>)?; let separator = PredefinedMenuItem::separator(app)?; let quit_item = MenuItem::with_id(app, TRAY_MENU_ID_QUIT, labels.quit, true, None::<&str>)?; let menu = MenuBuilder::new(app) - .items(&[&show_item, &separator, &quit_item]) + .items(&[&show_item, &logs_item, &separator, &quit_item]) .build()?; tray.set_menu(Some(menu))?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 039a5cf7aa..3335e61c38 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -38,6 +38,7 @@ pub mod pets; #[cfg(feature = "tauri-runtime")] pub mod preferences; pub mod process; +pub mod server_tray; pub mod supervise; mod terminal; pub mod turn_timings; @@ -847,6 +848,19 @@ mod tauri_app { if id.starts_with(windows::TRAY_MENU_ID_PREFIX) { match id.as_str() { windows::TRAY_MENU_ID_SHOW => windows::show_main_window(app), + windows::TRAY_MENU_ID_LOGS => { + use tauri_plugin_opener::OpenerExt; + match crate::commands::logging::open_logs_dir_core() { + Ok(path) => { + if let Err(err) = app.opener().open_path(path, None::<&str>) { + tracing::warn!("[Tray] failed to open log directory: {err}"); + } + } + Err(err) => tracing::warn!( + "[Tray] failed to prepare log directory: {err}" + ), + } + } windows::TRAY_MENU_ID_QUIT => app.exit(0), _ => {} } diff --git a/src-tauri/src/server_tray.rs b/src-tauri/src/server_tray.rs new file mode 100644 index 0000000000..a267f619a4 --- /dev/null +++ b/src-tauri/src/server_tray.rs @@ -0,0 +1,194 @@ +//! Native tray controls for the standalone server binary. +//! +//! The desktop binary has a Tauri-owned tray. The standalone server has no +//! webview or window, so Windows gets a small tao/tray-icon event loop here. + +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrayCommand { + OpenWeb, + OpenLogs, + Quit, +} + +const OPEN_WEB_ID: &str = "server-tray:open-web"; +const OPEN_LOGS_ID: &str = "server-tray:open-logs"; +const QUIT_ID: &str = "server-tray:quit"; + +fn command_for_menu_id(id: &str) -> Option { + match id { + OPEN_WEB_ID => Some(TrayCommand::OpenWeb), + OPEN_LOGS_ID => Some(TrayCommand::OpenLogs), + QUIT_ID => Some(TrayCommand::Quit), + _ => None, + } +} + +pub struct TrayHandle { + pub(crate) quit_rx: tokio::sync::oneshot::Receiver<()>, + #[cfg(target_os = "windows")] + _thread: Option>, +} + +impl TrayHandle { + pub fn into_quit_receiver(self) -> tokio::sync::oneshot::Receiver<()> { + self.quit_rx + } +} + +#[cfg(not(target_os = "windows"))] +pub fn start(_url: String, _logs_dir: PathBuf) -> Result, String> { + Ok(None) +} + +#[cfg(target_os = "windows")] +mod windows_impl { + use super::{ + command_for_menu_id, PathBuf, TrayCommand, TrayHandle, OPEN_LOGS_ID, OPEN_WEB_ID, QUIT_ID, + }; + use std::sync::mpsc; + + use tao::event::Event; + use tao::event_loop::{ControlFlow, EventLoopBuilder}; + use tao::platform::windows::EventLoopBuilderExtWindows; + use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}; + use tray_icon::{Icon, TrayIconBuilder, TrayIconEvent}; + + enum UserEvent { + Tray(TrayIconEvent), + Menu(MenuEvent), + } + + pub(super) fn start(url: String, logs_dir: PathBuf) -> Result, String> { + let (quit_tx, quit_rx) = tokio::sync::oneshot::channel(); + let (ready_tx, ready_rx) = mpsc::channel(); + + let thread = std::thread::Builder::new() + .name("codeg-server-tray".to_string()) + .spawn(move || { + let mut event_loop_builder = EventLoopBuilder::::with_user_event(); + event_loop_builder.with_any_thread(true); + let event_loop = event_loop_builder.build(); + + let proxy = event_loop.create_proxy(); + TrayIconEvent::set_event_handler(Some(move |event| { + let _ = proxy.send_event(UserEvent::Tray(event)); + })); + let proxy = event_loop.create_proxy(); + MenuEvent::set_event_handler(Some(move |event| { + let _ = proxy.send_event(UserEvent::Menu(event)); + })); + + let open_web = MenuItem::with_id(OPEN_WEB_ID, "Open Web Console", true, None); + let open_logs = MenuItem::with_id(OPEN_LOGS_ID, "Open Logs Folder", true, None); + let quit = MenuItem::with_id(QUIT_ID, "Quit Codeg Server", true, None); + let separator = PredefinedMenuItem::separator(); + let menu = Menu::new(); + let _ = menu.append_items(&[&open_web, &open_logs, &separator, &quit]); + + let mut tray_icon = None; + let mut quit_tx = Some(quit_tx); + let icon_bytes = include_bytes!("../icons/icon.png"); + let icon = match image::load_from_memory(icon_bytes) { + Ok(image) => { + let image = image.into_rgba8(); + Icon::from_rgba(image.as_raw().clone(), image.width(), image.height()) + .map_err(|err| format!("decode tray icon: {err}")) + } + Err(err) => Err(format!("decode tray icon: {err}")), + }; + + event_loop.run(move |event, _, control_flow| { + *control_flow = ControlFlow::Wait; + match event { + Event::NewEvents(tao::event::StartCause::Init) => match &icon { + Ok(icon) => match TrayIconBuilder::new() + .with_menu(Box::new(menu.clone())) + .with_tooltip("Codeg Server") + .with_icon(icon.clone()) + .build() + { + Ok(icon) => { + tray_icon = Some(icon); + let _ = ready_tx.send(Ok(())); + } + Err(err) => { + let _ = ready_tx.send(Err(format!("create tray icon: {err}"))); + *control_flow = ControlFlow::Exit; + } + }, + Err(err) => { + let _ = ready_tx.send(Err(err.clone())); + *control_flow = ControlFlow::Exit; + } + }, + Event::UserEvent(UserEvent::Tray(TrayIconEvent::DoubleClick { + button: tray_icon::MouseButton::Left, + .. + })) => { + if let Err(err) = open::that(&url) { + tracing::warn!("[Tray] failed to open web console: {err}"); + } + } + Event::UserEvent(UserEvent::Menu(event)) => { + let result = match command_for_menu_id(event.id.as_ref()) { + Some(TrayCommand::OpenWeb) => open::that(&url), + Some(TrayCommand::OpenLogs) => open::that(&logs_dir), + Some(TrayCommand::Quit) => { + if let Some(quit_tx) = quit_tx.take() { + let _ = quit_tx.send(()); + } + tray_icon.take(); + *control_flow = ControlFlow::Exit; + Ok(()) + } + None => Ok(()), + }; + if let Err(err) = result { + tracing::warn!("[Tray] failed to handle menu action: {err}"); + } + } + _ => {} + } + }); + }) + .map_err(|err| format!("spawn tray thread: {err}"))?; + + match ready_rx.recv() { + Ok(Ok(())) => Ok(Some(TrayHandle { + quit_rx, + _thread: Some(thread), + })), + Ok(Err(err)) => { + let _ = thread.join(); + Err(err) + } + Err(err) => { + let _ = thread.join(); + Err(format!("tray startup channel closed: {err}")) + } + } + } +} + +#[cfg(target_os = "windows")] +pub fn start(url: String, logs_dir: PathBuf) -> Result, String> { + windows_impl::start(url, logs_dir) +} + +#[cfg(test)] +mod tests { + use super::{command_for_menu_id, TrayCommand, OPEN_LOGS_ID, OPEN_WEB_ID, QUIT_ID}; + + #[test] + fn maps_menu_ids_to_commands() { + assert_eq!(command_for_menu_id(OPEN_WEB_ID), Some(TrayCommand::OpenWeb)); + assert_eq!( + command_for_menu_id(OPEN_LOGS_ID), + Some(TrayCommand::OpenLogs) + ); + assert_eq!(command_for_menu_id(QUIT_ID), Some(TrayCommand::Quit)); + assert_eq!(command_for_menu_id("server-tray:unknown"), None); + } +} diff --git a/src-tauri/windows/codeg-server.rc b/src-tauri/windows/codeg-server.rc new file mode 100644 index 0000000000..203d2834db --- /dev/null +++ b/src-tauri/windows/codeg-server.rc @@ -0,0 +1 @@ +1 ICON "../icons/icon.ico"