diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 431c166ad64..4d2554d1738 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -166,6 +166,10 @@ jobs: fi fi + # Add MinGW target for rust_tray static library + echo "Adding MinGW target for rust_tray..." + rustup target add x86_64-pc-windows-gnu + - name: Verify Build Tools shell: msys2 {0} run: | diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 8662ca362a3..0178d4ae575 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -117,7 +117,7 @@ set(SUNSHINE_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/network.cpp" "${CMAKE_SOURCE_DIR}/src/network.h" "${CMAKE_SOURCE_DIR}/src/move_by_copy.h" - "${CMAKE_SOURCE_DIR}/src/system_tray.cpp" + "${CMAKE_SOURCE_DIR}/src/system_tray_rust.cpp" "${CMAKE_SOURCE_DIR}/src/system_tray.h" "${CMAKE_SOURCE_DIR}/src/system_tray_i18n.cpp" "${CMAKE_SOURCE_DIR}/src/system_tray_i18n.h" diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake index 5da5f15188b..ccd6e65afd9 100644 --- a/cmake/compile_definitions/windows.cmake +++ b/cmake/compile_definitions/windows.cmake @@ -128,6 +128,7 @@ list(PREPEND PLATFORM_LIBRARIES ) if(SUNSHINE_ENABLE_TRAY) - list(APPEND PLATFORM_TARGET_FILES - "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_windows.c") + # Rust tray implementation + include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) + list(APPEND PLATFORM_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) endif() \ No newline at end of file diff --git a/cmake/targets/rust_tray.cmake b/cmake/targets/rust_tray.cmake new file mode 100644 index 00000000000..bcc725243d7 --- /dev/null +++ b/cmake/targets/rust_tray.cmake @@ -0,0 +1,121 @@ +# rust_tray.cmake +# CMake configuration for building and linking the Rust tray library + +set(RUST_TRAY_SOURCE_DIR "${CMAKE_SOURCE_DIR}/rust_tray") +set(RUST_TARGET_DIR "${CMAKE_BINARY_DIR}/rust_tray") + +# Determine the Rust target and output filename based on platform +if(WIN32) + # Windows uses MinGW/UCRT toolchain - must use gnu target + set(RUST_TARGET "x86_64-pc-windows-gnu") + set(RUST_LIB_NAME "libtray.a") +elseif(APPLE) + set(RUST_LIB_NAME "libtray.a") + # Check for ARM64 + if(CMAKE_SYSTEM_PROCESSOR MATCHES "arm64" OR CMAKE_OSX_ARCHITECTURES MATCHES "arm64") + set(RUST_TARGET "aarch64-apple-darwin") + else() + set(RUST_TARGET "x86_64-apple-darwin") + endif() +else() + set(RUST_LIB_NAME "libtray.a") + # Check for ARM64 + if(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64") + set(RUST_TARGET "aarch64-unknown-linux-gnu") + else() + set(RUST_TARGET "x86_64-unknown-linux-gnu") + endif() +endif() + +# Set the output path based on build type +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(RUST_BUILD_TYPE "debug") + set(CARGO_BUILD_FLAGS "") +else() + set(RUST_BUILD_TYPE "release") + set(CARGO_BUILD_FLAGS "--release") +endif() + +# Output path: target/// +set(RUST_OUTPUT_LIB "${RUST_TARGET_DIR}/${RUST_TARGET}/${RUST_BUILD_TYPE}/${RUST_LIB_NAME}") + +# Find cargo +find_program(CARGO_EXECUTABLE cargo HINTS $ENV{HOME}/.cargo/bin $ENV{USERPROFILE}/.cargo/bin) +if(NOT CARGO_EXECUTABLE) + message(FATAL_ERROR "Cargo (Rust package manager) not found. Please install Rust: https://rustup.rs/") +endif() + +message(STATUS "Found Cargo: ${CARGO_EXECUTABLE}") +message(STATUS "Rust target: ${RUST_TARGET}") +message(STATUS "Rust tray library will be built at: ${RUST_OUTPUT_LIB}") + +# Custom command to build the Rust library +add_custom_command( + OUTPUT ${RUST_OUTPUT_LIB} + COMMAND ${CMAKE_COMMAND} -E env + CARGO_TARGET_DIR=${RUST_TARGET_DIR} + ${CARGO_EXECUTABLE} build + --manifest-path ${RUST_TRAY_SOURCE_DIR}/Cargo.toml + --target ${RUST_TARGET} + ${CARGO_BUILD_FLAGS} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Building Rust tray library (${RUST_BUILD_TYPE})" + DEPENDS + ${RUST_TRAY_SOURCE_DIR}/Cargo.toml + ${RUST_TRAY_SOURCE_DIR}/build.rs + ${RUST_TRAY_SOURCE_DIR}/src/lib.rs + ${RUST_TRAY_SOURCE_DIR}/src/i18n.rs + ${RUST_TRAY_SOURCE_DIR}/src/actions.rs + VERBATIM +) + +# Create a custom target for the Rust library +add_custom_target(rust_tray ALL DEPENDS ${RUST_OUTPUT_LIB}) + +# Create an imported static library target +add_library(rust_tray_lib STATIC IMPORTED GLOBAL) +set_target_properties(rust_tray_lib PROPERTIES + IMPORTED_LOCATION ${RUST_OUTPUT_LIB} +) +add_dependencies(rust_tray_lib rust_tray) + +# Export the library path for use in other CMake files +set(RUST_TRAY_LIBRARY ${RUST_OUTPUT_LIB} CACHE FILEPATH "Path to the Rust tray library") + +# Platform-specific dependencies for the Rust library +if(WIN32) + # MinGW/UCRT dependencies for Rust static library + set(RUST_TRAY_PLATFORM_LIBS + user32 + gdi32 + shell32 + ole32 + oleaut32 + uuid + comctl32 + bcrypt + ntdll + userenv + ws2_32 + pthread + ) +elseif(APPLE) + # macOS dependencies for tray-icon crate + set(RUST_TRAY_PLATFORM_LIBS + "-framework Cocoa" + "-framework AppKit" + "-framework Foundation" + ) +else() + # Linux dependencies for tray-icon crate + find_package(PkgConfig REQUIRED) + pkg_check_modules(GTK3 REQUIRED gtk+-3.0) + pkg_check_modules(GLIB REQUIRED glib-2.0) + + set(RUST_TRAY_PLATFORM_LIBS + ${GTK3_LIBRARIES} + ${GLIB_LIBRARIES} + ) +endif() + +message(STATUS "Rust tray platform libraries: ${RUST_TRAY_PLATFORM_LIBS}") diff --git a/rust_tray/.cargo/config.toml b/rust_tray/.cargo/config.toml new file mode 100644 index 00000000000..90bf248f126 --- /dev/null +++ b/rust_tray/.cargo/config.toml @@ -0,0 +1,5 @@ +# Cargo configuration for MinGW builds + +[target.x86_64-pc-windows-gnu] +# Static link libgcc to avoid runtime DLL dependency +rustflags = ["-C", "link-arg=-static-libgcc"] diff --git a/rust_tray/.gitignore b/rust_tray/.gitignore new file mode 100644 index 00000000000..e9e21997b1a --- /dev/null +++ b/rust_tray/.gitignore @@ -0,0 +1,2 @@ +/target/ +/Cargo.lock diff --git a/rust_tray/Cargo.toml b/rust_tray/Cargo.toml new file mode 100644 index 00000000000..176ab24e9f9 --- /dev/null +++ b/rust_tray/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "sunshine_tray" +version = "0.1.0" +edition = "2021" +build = "build.rs" +description = "Windows-only system tray implementation for Sunshine" + +[lib] +name = "tray" +crate-type = ["staticlib"] + +[dependencies] +tray-icon = "0.19" +muda = "0.15" +once_cell = "1.19" +parking_lot = "0.12" +log = "0.4" + +# Windows-only dependencies +[target.'cfg(windows)'.dependencies] +winit = { version = "0.30", default-features = false, features = ["rwh_06"] } +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_UI_WindowsAndMessaging", + "Win32_UI_Shell", + "Win32_UI_Controls_Dialogs", +] } +winrt-notification = "0.5" + +[build-dependencies] +# bindgen = "0.71" # Disabled: requires LLVM/Clang installation + +[profile.release] +lto = true +opt-level = "s" diff --git a/rust_tray/README.md b/rust_tray/README.md new file mode 100644 index 00000000000..27f137f3090 --- /dev/null +++ b/rust_tray/README.md @@ -0,0 +1,68 @@ +# Rust Tray Library + +This directory contains a Rust implementation of the system tray library, designed to replace the original C `tray` library. + +## Features + +- Cross-platform support (Windows, Linux, macOS) +- Compatible C API matching the original `tray.h` header +- Uses the `tray-icon` Rust crate for the underlying implementation +- Better maintainability and reduced cross-platform adaptation code + +## Prerequisites + +- [Rust](https://rustup.rs/) (latest stable version recommended) +- Cargo (comes with Rust) + +## Building + +The library is automatically built by CMake when `SUNSHINE_USE_RUST_TRAY=ON` is set. + +### Manual Build + +```bash +cd rust_tray +cargo build --release +``` + +The static library will be generated at: +- Windows (MSVC): `target/release/tray.lib` +- Windows (MinGW): `target/release/libtray.a` +- Linux/macOS: `target/release/libtray.a` + +## CMake Integration + +To enable the Rust tray library, configure CMake with: + +```bash +cmake -DSUNSHINE_USE_RUST_TRAY=ON .. +``` + +## API + +The library provides the following C-compatible functions: + +- `tray_init(struct tray *tray)` - Initialize the tray icon +- `tray_update(struct tray *tray)` - Update the tray icon and menu +- `tray_loop(int blocking)` - Run one iteration of the UI loop +- `tray_exit(void)` - Terminate the UI loop + +See `../third-party/tray/src/tray.h` for the complete API definition. + +## Architecture + +``` +rust_tray/ +├── Cargo.toml # Rust package manifest +├── build.rs # Build script (platform-specific setup) +├── src/ +│ ├── lib.rs # Main library implementation +│ └── ffi.rs # FFI type definitions +└── README.md # This file +``` + +## Notes + +- The Rust implementation maintains full API compatibility with the original C library +- No changes are required to `src/system_tray.cpp` +- The original `third-party/tray/src/tray.h` header is still used for the C++ code diff --git a/rust_tray/build.rs b/rust_tray/build.rs new file mode 100644 index 00000000000..aab37e54071 --- /dev/null +++ b/rust_tray/build.rs @@ -0,0 +1,23 @@ +//! Build script for sunshine_tray +//! +//! Note: We manually define the C structures in lib.rs instead of using bindgen +//! to avoid requiring LLVM/Clang installation on all build machines. + +fn main() { + // Tell cargo to rerun this script if the header file changes + println!("cargo:rerun-if-changed=../third-party/tray/src/tray.h"); + println!("cargo:rerun-if-changed=build.rs"); + + // Note: We manually define the FFI structures in src/ffi.rs + // to match the original tray.h header file. + + // Platform-specific linker flags + #[cfg(target_os = "windows")] + { + println!("cargo:rustc-link-lib=user32"); + println!("cargo:rustc-link-lib=gdi32"); + println!("cargo:rustc-link-lib=shell32"); + println!("cargo:rustc-link-lib=ole32"); + println!("cargo:rustc-link-lib=comctl32"); + } +} diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h new file mode 100644 index 00000000000..4eea26c030d --- /dev/null +++ b/rust_tray/include/rust_tray.h @@ -0,0 +1,163 @@ +/** + * @file rust_tray.h + * @brief C API for the Rust tray library + */ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Menu action ID strings (defined in Rust menu_items.rs) + * These match the IDs used in the Rust tray menu system. + */ +#define TRAY_ACTION_OPEN_SUNSHINE "open_sunshine" +#define TRAY_ACTION_VDD_CREATE "vdd_create" +#define TRAY_ACTION_VDD_CLOSE "vdd_close" +#define TRAY_ACTION_VDD_PERSISTENT "vdd_persistent" +#define TRAY_ACTION_CLOSE_APP "close_app" +#define TRAY_ACTION_RESET_DISPLAY "reset_display" +#define TRAY_ACTION_LANG_CHINESE "lang_chinese" +#define TRAY_ACTION_LANG_ENGLISH "lang_english" +#define TRAY_ACTION_LANG_JAPANESE "lang_japanese" +#define TRAY_ACTION_STAR_PROJECT "star_project" +#define TRAY_ACTION_VISIT_SUNSHINE "visit_sunshine" +#define TRAY_ACTION_VISIT_MOONLIGHT "visit_moonlight" +#define TRAY_ACTION_RESTART "restart" +#define TRAY_ACTION_QUIT "quit" +// Special action for notification click (not a menu item) +#define TRAY_ACTION_NOTIFICATION_CLICKED "notification_clicked" + +/** + * @brief Icon types for tray_set_icon + */ +typedef enum { + TRAY_ICON_TYPE_NORMAL = 0, + TRAY_ICON_TYPE_PLAYING = 1, + TRAY_ICON_TYPE_PAUSING = 2, + TRAY_ICON_TYPE_LOCKED = 3, +} TrayIconType; + +/** + * @brief Callback function type for menu actions + * @param action_id The action identifier string (null-terminated) + */ +typedef void (*TrayActionCallback)(const char* action_id); + +/** + * @brief Initialize the tray with extended options + * @param icon_normal Path to normal icon + * @param icon_playing Path to playing icon + * @param icon_pausing Path to pausing icon + * @param icon_locked Path to locked icon + * @param tooltip Tooltip text + * @param locale Initial locale (e.g., "zh", "en", "ja") + * @param callback Callback function for menu actions + * @return 0 on success, -1 on error + */ +int tray_init_ex( + const char* icon_normal, + const char* icon_playing, + const char* icon_pausing, + const char* icon_locked, + const char* tooltip, + const char* locale, + TrayActionCallback callback +); + +/** + * @brief Run one iteration of the event loop + * @param blocking If non-zero, block until an event is available + * @return 0 on success, -1 if exit was requested + */ +int tray_loop(int blocking); + +/** + * @brief Exit the tray event loop + */ +void tray_exit(void); + +/** + * @brief Set the tray icon + * @param icon_type Icon type (0=normal, 1=playing, 2=pausing, 3=locked) + */ +void tray_set_icon(int icon_type); + +/** + * @brief Set the tray tooltip + * @param tooltip Tooltip text + */ +void tray_set_tooltip(const char* tooltip); + +/** + * @brief Update VDD menu item states + * + * This unified function updates all VDD menu states at once. + * The C++ side is responsible for: + * - Tracking VDD active state + * - Managing 10-second cooldown + * - Determining which operations are allowed + * + * @param can_create Non-zero if "Create" item should be enabled + * @param can_close Non-zero if "Close" item should be enabled + * @param is_persistent Non-zero if "Keep Enabled" is checked + * @param is_active Non-zero if VDD is currently active (for checked states) + */ +void tray_update_vdd_menu(int can_create, int can_close, int is_persistent, int is_active); + +/** + * @brief Set the current locale + * @param locale Locale string (e.g., "zh", "en", "ja") + */ +void tray_set_locale(const char* locale); + +/** + * @brief Notification types for localized notifications + */ +typedef enum { + TRAY_NOTIFICATION_STREAM_STARTED = 0, + TRAY_NOTIFICATION_STREAM_PAUSED = 1, + TRAY_NOTIFICATION_APP_STOPPED = 2, + TRAY_NOTIFICATION_PAIRING_REQUEST = 3, +} TrayNotificationType; + +/** + * @brief Show a notification + * @param title Notification title + * @param text Notification text + * @param icon_type Icon type for the notification + */ +void tray_show_notification(const char* title, const char* text, int icon_type); + +/** + * @brief Show a localized notification + * @param notification_type Type of notification (see TrayNotificationType) + * @param app_name Application name for formatting (can be NULL) + */ +void tray_show_localized_notification(int notification_type, const char* app_name); + +/** + * @brief Enable dark mode for context menus (follow system setting) + * + * Call this before creating menus. The menu will automatically + * follow the system's dark/light mode setting. + * Note: Only effective on Windows 10 1903+ and Windows 11. + */ +void tray_enable_dark_mode(void); + +/** + * @brief Force dark mode for context menus + */ +void tray_force_dark_mode(void); + +/** + * @brief Force light mode for context menus + */ +void tray_force_light_mode(void); + +#ifdef __cplusplus +} +#endif diff --git a/rust_tray/plan.md b/rust_tray/plan.md new file mode 100644 index 00000000000..eab1012dde4 --- /dev/null +++ b/rust_tray/plan.md @@ -0,0 +1,63 @@ +# 系统托盘替换方案(已更新:大部分逻辑迁移至 Rust 库) + +要点结论: +- 大部分托盘逻辑、i18n 与菜单处理已迁移到 Rust 库,主实现见 [`rust_tray/src/lib.rs`](rust_tray/src/lib.rs:1)。 +- 对外 C API 以扩展接口为主:请使用 [`tray_init_ex`、`tray_loop`、`tray_exit` 等](rust_tray/include/rust_tray.h:61);旧 `tray_init` 为遗留且不推荐使用。 +- C++ 端使用薄包装器 [`src/system_tray_rust.cpp`](src/system_tray_rust.cpp:1) 与 Rust 库交互,CMake 已改为始终链接 Rust 实现。 + +关键文件(快速索引): +- Rust 实现:[`rust_tray/src/lib.rs`](rust_tray/src/lib.rs:1) +- 国际化:[`rust_tray/src/i18n.rs`](rust_tray/src/i18n.rs:1) +- 菜单/动作:[`rust_tray/src/actions.rs`](rust_tray/src/actions.rs:1) +- C 头(导出 API):[`rust_tray/include/rust_tray.h`](rust_tray/include/rust_tray.h:1) +- C++ 包装器:[`src/system_tray_rust.cpp`](src/system_tray_rust.cpp:1) +- CMake 目标:[`cmake/targets/rust_tray.cmake`](cmake/targets/rust_tray.cmake:1) + +架构要点: +1. Rust 负责:菜单结构、i18n、事件循环、图标/通知、动作映射(MenuAction -> TrayAction)。 +2. C++ 负责:应用内响应(打开 UI、重启、退出等)和平台特殊处理(如 Windows 特权/进程管理)。 +3. 边界:Rust 通过 C API 导出简单函数;C++ 通过回调接收用户操作事件。 + +构建与集成: +- CMake 现在包含并构建 `rust_tray`,使用 `cargo build` 生成静态库并链接到主程序(见 [`cmake/compile_definitions/*`](cmake/compile_definitions/common.cmake:1) 的改动)。 +- 头文件为 [`rust_tray/include/rust_tray.h`](rust_tray/include/rust_tray.h:1),C++ 仅需包含该头并注册回调。 +- CI:需保证 Rust toolchain 可用;建议在 CI 中添加 Rust 安装步骤。 + +运行时与 API 变化: +- 初始化:推荐使用 `tray_init_ex(icon_normal, icon_playing, icon_pausing, icon_locked, tooltip, locale, callback)`。 +- 事件循环:使用 `tray_loop(blocking)` 驱动;返回 -1 表示要求退出。可在单线程或分线程中调用(包装器提供线程化入口)。 +- 运行时更新:`tray_set_icon`、`tray_set_tooltip`、`tray_set_vdd_checked`、`tray_set_vdd_enabled`、`tray_set_locale`、`tray_show_notification`。 +- 兼容层:实现了 `tray_update`(部分支持);但 `tray_init` 已被降级(返回错误并打印警告)。 + +i18n 与菜单: +- i18n 数据与逻辑在 Rust 层管理,参见 [`rust_tray/src/i18n.rs`](rust_tray/src/i18n.rs:1)。 +- 语言切换由 Rust 处理并原子更新菜单文本,必要时会重设 TrayIcon 的菜单以确保生效(Windows 行为)。 + +图标与通知: +- 图标加载:Windows 优先使用 .ico(多分辨率),Linux 支持图标名称或文件路径,macOS 使用文件路径。 +- 通知:当前为占位实现(日志输出),需要按平台补全真实通知接口(待办)。 + +测试清单(必验): +- 编译通过并链接 Rust 静态库。 +- 托盘图标在 Windows / Linux / macOS 显示正确。 +- 菜单项触发后,C++ 回调收到匹配的 `TrayAction`(见头文件枚举)。 +- 语言切换后菜单文本更新并在 UI 上可见。 +- 图标切换与 tooltip 更新正常。 +- 通知调用至少不会崩溃(后续完善行为)。 + +已知限制与后续工作: +- 完成平台通知实现(Rust 层需要具体实现)。 +- 若需更细粒度的日志或错误上报,考虑在 Rust 层引入更丰富的日志接口并暴露给 C++。 +- 可选:清理 `third-party/tray` 中多余源文件,仅保留头文件以减小仓库体积。 +- 在 CI 中加入交叉编译与多平台验证。 + +迁移结论: +本次提交把「菜单、i18n、事件循环、图标管理、部分文件对话(导入/导出)」这些横跨平台且逻辑密集的功能迁移到 Rust,提高了可维护性与一致性。C++ 侧保留平台特性与应用逻辑,双方通过稳定的 C API 协作。 + +参考实现与调试入口: +- 查看实现:[`rust_tray/src/lib.rs`](rust_tray/src/lib.rs:1) +- 头文件:[`rust_tray/include/rust_tray.h`](rust_tray/include/rust_tray.h:1) +- C++ 包装示例:[`src/system_tray_rust.cpp`](src/system_tray_rust.cpp:1) +- i18n 数据:[`rust_tray/src/i18n.rs`](rust_tray/src/i18n.rs:1) + +完成。 \ No newline at end of file diff --git a/rust_tray/src/actions.rs b/rust_tray/src/actions.rs new file mode 100644 index 00000000000..8773d652631 --- /dev/null +++ b/rust_tray/src/actions.rs @@ -0,0 +1,28 @@ +//! Menu actions module +//! +//! Provides callback mechanism for menu actions. +//! C++ side registers a callback that receives action ID strings. + +use parking_lot::RwLock; +use once_cell::sync::Lazy; +use std::ffi::CString; + +/// Callback function type for menu actions (receives null-terminated C string) +pub type ActionCallback = extern "C" fn(action_id: *const std::ffi::c_char); + +/// Global callback storage +static ACTION_CALLBACK: Lazy>> = Lazy::new(|| RwLock::new(None)); + +/// Register the callback for menu actions +pub fn register_callback(callback: ActionCallback) { + *ACTION_CALLBACK.write() = Some(callback); +} + +/// Trigger a menu action by string ID +pub fn trigger_action(action_id: &str) { + if let Some(callback) = *ACTION_CALLBACK.read() { + if let Ok(c_str) = CString::new(action_id) { + callback(c_str.as_ptr()); + } + } +} diff --git a/rust_tray/src/dialogs.rs b/rust_tray/src/dialogs.rs new file mode 100644 index 00000000000..a56dba6d1fc --- /dev/null +++ b/rust_tray/src/dialogs.rs @@ -0,0 +1,31 @@ +//! Dialog utilities module (Windows only) +//! +//! 提供系统对话框功能。 + +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; + +/// 显示确认对话框 +pub fn show_confirm_dialog(title: &str, message: &str) -> bool { + use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_YESNO, MB_ICONQUESTION, IDYES}; + + let wide_title: Vec = OsStr::new(title) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let wide_message: Vec = OsStr::new(message) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + let result = MessageBoxW( + std::ptr::null_mut(), + wide_message.as_ptr(), + wide_title.as_ptr(), + MB_YESNO | MB_ICONQUESTION, + ); + result == IDYES + } +} diff --git a/rust_tray/src/i18n.rs b/rust_tray/src/i18n.rs new file mode 100644 index 00000000000..2a33f12a746 --- /dev/null +++ b/rust_tray/src/i18n.rs @@ -0,0 +1,278 @@ +//! Internationalization (i18n) module for tray icon +//! +//! Supports Chinese, English, and Japanese translations. + +use std::collections::HashMap; +use parking_lot::RwLock; +use once_cell::sync::Lazy; + +/// Supported locales +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Locale { + English, + Chinese, + Japanese, +} + +impl Default for Locale { + fn default() -> Self { + Locale::English + } +} + +impl From<&str> for Locale { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { + "zh" | "zh_cn" | "zh_tw" | "chinese" => Locale::Chinese, + "ja" | "ja_jp" | "japanese" => Locale::Japanese, + _ => Locale::English, + } + } +} + +/// String keys for localization +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum StringKey { + // Menu items + OpenSunshine, + // VDD submenu + VddBaseDisplay, + VddCreate, + VddClose, + VddPersistent, + VddPersistentConfirmTitle, + VddPersistentConfirmMsg, + // Advanced Settings submenu + AdvancedSettings, + CloseApp, + CloseAppConfirmTitle, + CloseAppConfirmMsg, + // Language submenu + Language, + Chinese, + English, + Japanese, + StarProject, + // Visit Project submenu + VisitProject, + VisitProjectSunshine, + VisitProjectMoonlight, + ResetDisplayDeviceConfig, + ResetDisplayConfirmTitle, + ResetDisplayConfirmMsg, + Restart, + Quit, + + // Notifications + StreamStarted, + StreamingStartedFor, + StreamPaused, + StreamingPausedFor, + ApplicationStopped, + ApplicationStoppedMsg, + IncomingPairingRequest, + ClickToCompletePairing, + + // Dialog messages + QuitTitle, + QuitMessage, + ErrorTitle, +} + +/// Current locale storage +static CURRENT_LOCALE: Lazy> = Lazy::new(|| RwLock::new(Locale::English)); + +/// Translation tables +static TRANSLATIONS: Lazy> = Lazy::new(|| { + let mut m = HashMap::new(); + + // English translations + m.insert((Locale::English, StringKey::OpenSunshine), "Open GUI"); + // VDD submenu + m.insert((Locale::English, StringKey::VddBaseDisplay), "Foundation Display"); + m.insert((Locale::English, StringKey::VddCreate), "Create Virtual Display"); + m.insert((Locale::English, StringKey::VddClose), "Close Virtual Display"); + m.insert((Locale::English, StringKey::VddPersistent), "Keep Enabled"); + m.insert((Locale::English, StringKey::VddPersistentConfirmTitle), "Keep Virtual Display Enabled"); + m.insert((Locale::English, StringKey::VddPersistentConfirmMsg), "By enabling this option, the virtual display will NOT be closed after you stop streaming.\n\nDo you want to enable this feature?"); + // Advanced Settings submenu + m.insert((Locale::English, StringKey::AdvancedSettings), "Advanced Settings"); + m.insert((Locale::English, StringKey::CloseApp), "Clear Cache"); + m.insert((Locale::English, StringKey::CloseAppConfirmTitle), "Clear Cache"); + m.insert((Locale::English, StringKey::CloseAppConfirmMsg), "This operation will clear streaming state, may terminate the streaming application, and clean up related processes and state. Do you want to continue?"); + m.insert((Locale::English, StringKey::Language), "Language"); + m.insert((Locale::English, StringKey::Chinese), "中文 (Chinese)"); + m.insert((Locale::English, StringKey::English), "English"); + m.insert((Locale::English, StringKey::Japanese), "日本語 (Japanese)"); + m.insert((Locale::English, StringKey::StarProject), "Visit Website"); + m.insert((Locale::English, StringKey::VisitProject), "Visit Project"); + m.insert((Locale::English, StringKey::VisitProjectSunshine), "Sunshine"); + m.insert((Locale::English, StringKey::VisitProjectMoonlight), "Moonlight"); + m.insert((Locale::English, StringKey::ResetDisplayDeviceConfig), "Reset Display"); + m.insert((Locale::English, StringKey::ResetDisplayConfirmTitle), "Reset Display"); + m.insert((Locale::English, StringKey::ResetDisplayConfirmMsg), "Are you sure you want to reset display device memory? This action cannot be undone."); + m.insert((Locale::English, StringKey::Restart), "Restart"); + m.insert((Locale::English, StringKey::Quit), "Quit"); + m.insert((Locale::English, StringKey::StreamStarted), "Stream Started"); + m.insert((Locale::English, StringKey::StreamingStartedFor), "Streaming started for %s"); + m.insert((Locale::English, StringKey::StreamPaused), "Stream Paused"); + m.insert((Locale::English, StringKey::StreamingPausedFor), "Streaming paused for %s"); + m.insert((Locale::English, StringKey::ApplicationStopped), "Application Stopped"); + m.insert((Locale::English, StringKey::ApplicationStoppedMsg), "Application %s successfully stopped"); + m.insert((Locale::English, StringKey::IncomingPairingRequest), "Incoming PIN Request From: %s"); + m.insert((Locale::English, StringKey::ClickToCompletePairing), "Click here to enter PIN"); + m.insert((Locale::English, StringKey::QuitTitle), "Wait! Don't Leave Me! T_T"); + m.insert((Locale::English, StringKey::QuitMessage), "Nooo! You can't just quit like that!\nAre you really REALLY sure you want to leave?\nI'll miss you... but okay, if you must...\n\n(This will also close the Sunshine GUI application.)"); + m.insert((Locale::English, StringKey::ErrorTitle), "Error"); + + // Chinese translations + m.insert((Locale::Chinese, StringKey::OpenSunshine), "打开基地面板"); + // VDD submenu + m.insert((Locale::Chinese, StringKey::VddBaseDisplay), "基地显示器"); + m.insert((Locale::Chinese, StringKey::VddCreate), "创建显示器"); + m.insert((Locale::Chinese, StringKey::VddClose), "关闭显示器"); + m.insert((Locale::Chinese, StringKey::VddPersistent), "保持启用"); + m.insert((Locale::Chinese, StringKey::VddPersistentConfirmTitle), "保持开启虚拟显示器"); + m.insert((Locale::Chinese, StringKey::VddPersistentConfirmMsg), "启用此选项后,在串流结束后基地显示器将不会被自动关闭。\n\n确定要开启此功能吗?"); + // Advanced Settings submenu + m.insert((Locale::Chinese, StringKey::AdvancedSettings), "高级设置"); + m.insert((Locale::Chinese, StringKey::CloseApp), "清理缓存"); + m.insert((Locale::Chinese, StringKey::CloseAppConfirmTitle), "清理缓存"); + m.insert((Locale::Chinese, StringKey::CloseAppConfirmMsg), "此操作将会清理串流状态,可能会终止串流应用,并清理相关进程和状态。是否继续?"); + m.insert((Locale::Chinese, StringKey::Language), "语言 Language"); + m.insert((Locale::Chinese, StringKey::Chinese), "中文"); + m.insert((Locale::Chinese, StringKey::English), "English (英语)"); + m.insert((Locale::Chinese, StringKey::Japanese), "日本語 (日语)"); + m.insert((Locale::Chinese, StringKey::StarProject), "访问官网"); + m.insert((Locale::Chinese, StringKey::VisitProject), "访问项目地址"); + m.insert((Locale::Chinese, StringKey::VisitProjectSunshine), "Sunshine"); + m.insert((Locale::Chinese, StringKey::VisitProjectMoonlight), "Moonlight"); + m.insert((Locale::Chinese, StringKey::ResetDisplayDeviceConfig), "重置显示器"); + m.insert((Locale::Chinese, StringKey::ResetDisplayConfirmTitle), "重置显示器"); + m.insert((Locale::Chinese, StringKey::ResetDisplayConfirmMsg), "确定要重置显示器设备记忆吗?此操作无法撤销。"); + m.insert((Locale::Chinese, StringKey::Restart), "重新启动"); + m.insert((Locale::Chinese, StringKey::Quit), "退出"); + m.insert((Locale::Chinese, StringKey::StreamStarted), "串流已开始"); + m.insert((Locale::Chinese, StringKey::StreamingStartedFor), "已开始串流:%s"); + m.insert((Locale::Chinese, StringKey::StreamPaused), "串流已暂停"); + m.insert((Locale::Chinese, StringKey::StreamingPausedFor), "已暂停串流:%s"); + m.insert((Locale::Chinese, StringKey::ApplicationStopped), "应用已停止"); + m.insert((Locale::Chinese, StringKey::ApplicationStoppedMsg), "应用 %s 已成功停止"); + m.insert((Locale::Chinese, StringKey::IncomingPairingRequest), "来自 %s 的PIN请求"); + m.insert((Locale::Chinese, StringKey::ClickToCompletePairing), "点击此处完成PIN验证"); + m.insert((Locale::Chinese, StringKey::QuitTitle), "真的要退出吗"); + m.insert((Locale::Chinese, StringKey::QuitMessage), "你不能退出!\n那么想退吗? 真拿你没办法呢, 继续点一下吧~\n\n这将同时关闭Sunshine GUI应用程序。"); + m.insert((Locale::Chinese, StringKey::ErrorTitle), "错误"); + + // Japanese translations + m.insert((Locale::Japanese, StringKey::OpenSunshine), "GUIを開く"); + // VDD submenu + m.insert((Locale::Japanese, StringKey::VddBaseDisplay), "基地ディスプレイ"); + m.insert((Locale::Japanese, StringKey::VddCreate), "仮想ディスプレイを作成"); + m.insert((Locale::Japanese, StringKey::VddClose), "仮想ディスプレイを閉じる"); + m.insert((Locale::Japanese, StringKey::VddPersistent), "常駐仮想ディスプレイを"); + m.insert((Locale::Japanese, StringKey::VddPersistentConfirmTitle), "仮想ディスプレイを有効に保つ"); + m.insert((Locale::Japanese, StringKey::VddPersistentConfirmMsg), "このオプションを有効にすると、ストリーミング終了後に仮想ディスプレイは**自動的に閉じられません**。\n\nこの機能を有効にしますか?"); + // Advanced Settings submenu + m.insert((Locale::Japanese, StringKey::AdvancedSettings), "詳細設定"); + m.insert((Locale::Japanese, StringKey::CloseApp), "キャッシュをクリア"); + m.insert((Locale::Japanese, StringKey::CloseAppConfirmTitle), "キャッシュをクリア"); + m.insert((Locale::Japanese, StringKey::CloseAppConfirmMsg), "この操作はストリーミング状態をクリアし、ストリーミングアプリケーションを終了する可能性があり、関連するプロセスと状態をクリーンアップします。続行しますか?"); + m.insert((Locale::Japanese, StringKey::Language), "言語 Language"); + m.insert((Locale::Japanese, StringKey::Chinese), "中文 (中国語)"); + m.insert((Locale::Japanese, StringKey::English), "English (英語)"); + m.insert((Locale::Japanese, StringKey::Japanese), "日本語"); + m.insert((Locale::Japanese, StringKey::StarProject), "公式サイトを訪問"); + m.insert((Locale::Japanese, StringKey::VisitProject), "プロジェクトアドレスを訪問"); + m.insert((Locale::Japanese, StringKey::VisitProjectSunshine), "Sunshine"); + m.insert((Locale::Japanese, StringKey::VisitProjectMoonlight), "Moonlight"); + m.insert((Locale::Japanese, StringKey::ResetDisplayDeviceConfig), "ディスプレイをリセット"); + m.insert((Locale::Japanese, StringKey::ResetDisplayConfirmTitle), "ディスプレイをリセット"); + m.insert((Locale::Japanese, StringKey::ResetDisplayConfirmMsg), "ディスプレイデバイスのメモリをリセットしてもよろしいですか?この操作は元に戻せません。"); + m.insert((Locale::Japanese, StringKey::Restart), "再起動"); + m.insert((Locale::Japanese, StringKey::Quit), "終了"); + m.insert((Locale::Japanese, StringKey::StreamStarted), "ストリーム開始"); + m.insert((Locale::Japanese, StringKey::StreamingStartedFor), "%s のストリーミングを開始しました"); + m.insert((Locale::Japanese, StringKey::StreamPaused), "ストリーム一時停止"); + m.insert((Locale::Japanese, StringKey::StreamingPausedFor), "%s のストリーミングを一時停止しました"); + m.insert((Locale::Japanese, StringKey::ApplicationStopped), "アプリケーション停止"); + m.insert((Locale::Japanese, StringKey::ApplicationStoppedMsg), "アプリケーション %s が正常に停止しました"); + m.insert((Locale::Japanese, StringKey::IncomingPairingRequest), "%s からのPIN要求"); + m.insert((Locale::Japanese, StringKey::ClickToCompletePairing), "クリックしてPIN認証を完了"); + m.insert((Locale::Japanese, StringKey::QuitTitle), "本当に終了しますか?"); + m.insert((Locale::Japanese, StringKey::QuitMessage), "終了できません!\n本当に終了したいですか?\n\nこれによりSunshine GUIアプリケーションも閉じられます。"); + m.insert((Locale::Japanese, StringKey::ErrorTitle), "エラー"); + + m +}); + +/// Get current locale +pub fn get_locale() -> Locale { + *CURRENT_LOCALE.read() +} + +/// Set current locale +pub fn set_locale(locale: Locale) { + *CURRENT_LOCALE.write() = locale; +} + +/// Set locale from string +pub fn set_locale_str(locale_str: &str) { + set_locale(Locale::from(locale_str)); +} + +/// Get localized string +pub fn get_string(key: StringKey) -> &'static str { + let locale = get_locale(); + + // Try current locale first + if let Some(s) = TRANSLATIONS.get(&(locale, key)) { + return s; + } + + // Fallback to English + if let Some(s) = TRANSLATIONS.get(&(Locale::English, key)) { + return s; + } + + // Return empty string if not found + "" +} + +/// Get localized string with format substitution +pub fn get_string_fmt(key: StringKey, arg: &str) -> String { + let template = get_string(key); + template.replace("%s", arg) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_locale_from_str() { + assert_eq!(Locale::from("zh"), Locale::Chinese); + assert_eq!(Locale::from("zh_CN"), Locale::Chinese); + assert_eq!(Locale::from("ja"), Locale::Japanese); + assert_eq!(Locale::from("en"), Locale::English); + assert_eq!(Locale::from("unknown"), Locale::English); + } + + #[test] + fn test_get_string() { + set_locale(Locale::English); + assert_eq!(get_string(StringKey::OpenSunshine), "Open Sunshine"); + + set_locale(Locale::Chinese); + assert_eq!(get_string(StringKey::OpenSunshine), "打开 Sunshine"); + + set_locale(Locale::Japanese); + assert_eq!(get_string(StringKey::OpenSunshine), "Sunshineを開く"); + } + + #[test] + fn test_get_string_fmt() { + set_locale(Locale::English); + assert_eq!(get_string_fmt(StringKey::StreamingStartedFor, "TestApp"), "Streaming started for TestApp"); + } +} diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs new file mode 100644 index 00000000000..9e9ff561570 --- /dev/null +++ b/rust_tray/src/lib.rs @@ -0,0 +1,541 @@ +//! Sunshine Tray - Rust implementation of the system tray (Windows only) +//! +//! This library provides a complete system tray implementation with: +//! - Multi-language support (Chinese, English, Japanese) +//! - Menu management +//! - Notification support +//! - Icon management +//! +//! Note: This crate is Windows-only. + +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![cfg(target_os = "windows")] + +pub mod i18n; +pub mod actions; +pub mod dialogs; +pub mod menu; +pub mod menu_items; +pub mod notification; + +use std::ffi::CStr; +use std::os::raw::{c_char, c_int}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; + +use muda::{Menu, MenuEvent}; +use once_cell::sync::OnceCell; +use parking_lot::Mutex; +use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; +use windows_sys::Win32::UI::WindowsAndMessaging::{ + DispatchMessageW, GetMessageW, PeekMessageW, PostQuitMessage, TranslateMessage, + MSG, WM_QUIT, PM_REMOVE, WM_DPICHANGED, +}; + +use i18n::set_locale_str; +use actions::{register_callback, ActionCallback}; + +/// Tray state - simplified to use menu registry +#[allow(dead_code)] // Fields are needed for lifetime management +struct TrayState { + icon: TrayIcon, + menu: Menu, +} + +// Safety: TrayState is only accessed from the main thread +unsafe impl Send for TrayState {} +unsafe impl Sync for TrayState {} + +/// Global state +static TRAY_STATE: OnceCell>> = OnceCell::new(); +static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); + +/// Current icon type (0=normal, 1=playing, 2=pausing, 3=locked) +/// Used to refresh icon when DPI changes +static CURRENT_ICON_TYPE: AtomicI32 = AtomicI32::new(0); + +/// Cached DPI value to detect DPI changes +static CACHED_DPI_SIZE: AtomicI32 = AtomicI32::new(0); + +/// Icon paths storage +static ICON_PATHS: OnceCell = OnceCell::new(); + +struct IconPaths { + normal: String, + playing: String, + pausing: String, + locked: String, +} + +/// Convert C string to Rust string +unsafe fn c_str_to_string(ptr: *const c_char) -> Option { + if ptr.is_null() { + return None; + } + CStr::from_ptr(ptr).to_str().ok().map(|s| s.to_string()) +} + +/// Get the system small icon size (used for notification area icons) +/// This size is DPI-aware and matches what Windows expects for tray icons +fn get_system_small_icon_size() -> (u32, u32) { + use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON, SM_CYSMICON}; + + unsafe { + let width = GetSystemMetrics(SM_CXSMICON); + let height = GetSystemMetrics(SM_CYSMICON); + + // Fallback to 16x16 if GetSystemMetrics fails (returns 0) + let width = if width > 0 { width as u32 } else { 16 }; + let height = if height > 0 { height as u32 } else { 16 }; + + // Cache the current size for DPI change detection + CACHED_DPI_SIZE.store(width as i32, Ordering::SeqCst); + + (width, height) + } +} + +/// Check if DPI has changed since last icon load +/// Returns true if DPI changed and icon needs refresh +fn check_dpi_changed() -> bool { + use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON}; + + unsafe { + let current_size = GetSystemMetrics(SM_CXSMICON); + let cached_size = CACHED_DPI_SIZE.load(Ordering::SeqCst); + + // If cached is 0, we haven't loaded yet - not a change + if cached_size == 0 { + return false; + } + + current_size != cached_size + } +} + +/// Refresh the current icon with new DPI settings +fn refresh_icon_for_dpi() { + let icon_type = CURRENT_ICON_TYPE.load(Ordering::SeqCst); + + let icon_paths = match ICON_PATHS.get() { + Some(p) => p, + None => return, + }; + + let icon_path = match icon_type { + 0 => &icon_paths.normal, + 1 => &icon_paths.playing, + 2 => &icon_paths.pausing, + 3 => &icon_paths.locked, + _ => &icon_paths.normal, + }; + + if let Some(icon) = load_icon(icon_path) { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + let _ = state.icon.set_icon(Some(icon)); + } + } + } +} + +/// Load icon from ICO file path using native Windows API +/// This properly handles DPI scaling by requesting the correct icon size +/// based on SM_CXSMICON/SM_CYSMICON system metrics +fn load_icon_from_path(path: &str) -> Option { + // Get the correct icon size for the notification area based on system DPI + let size = get_system_small_icon_size(); + + // Request the specific size - Windows will select the best matching + // icon from the ICO file's multiple resolutions + match Icon::from_path(path, Some(size)) { + Ok(icon) => Some(icon), + Err(e) => { + eprintln!("Failed to load icon '{}' with size {:?}: {}", path, size, e); + None + } + } +} + +/// Load icon from ICO file path +fn load_icon(icon_str: &str) -> Option { + if Path::new(icon_str).exists() { + return load_icon_from_path(icon_str); + } + + eprintln!("Icon not found: {}", icon_str); + None +} + +/// Identify which action corresponds to the menu event +/// Returns the item_id (if any) +fn identify_menu_item(event: &MenuEvent) -> Option { + menu::identify_item_id(event) +} + +/// Execute the identified action by item_id +/// Uses menu_items module for centralized handling +fn execute_action_by_id(item_id: &str) { + let (handled, needs_rebuild) = menu_items::execute_handler(item_id); + + if handled && needs_rebuild { + update_menu_texts(); + } +} + +/// Process a menu event - identifies the item and executes its handler +fn process_menu_event(event: &MenuEvent) { + if let Some(item_id) = identify_menu_item(event) { + execute_action_by_id(&item_id); + } +} + +/// Update menu texts after language change by rebuilding the menu +/// +/// On Windows, simply updating menu item texts with set_text() and calling set_menu() +/// can cause issues with the menu event handling. The safest approach is to rebuild +/// the entire menu with the new texts. +fn update_menu_texts() { + use menu_items::ids; + + // Save current VDD states + let vdd_create_checked = menu::get_check_state_by_id(ids::VDD_CREATE).unwrap_or(false); + let vdd_close_checked = menu::get_check_state_by_id(ids::VDD_CLOSE).unwrap_or(false); + let vdd_persistent_checked = menu::get_check_state_by_id(ids::VDD_PERSISTENT).unwrap_or(false); + + // Build new menu using the menu module + let new_menu = menu::rebuild_menu(); + + // Restore VDD states + menu::set_check_state_by_id(ids::VDD_CREATE, vdd_create_checked); + menu::set_check_state_by_id(ids::VDD_CLOSE, vdd_close_checked); + menu::set_check_state_by_id(ids::VDD_PERSISTENT, vdd_persistent_checked); + + // Update tray state + if let Some(state_mutex) = TRAY_STATE.get() { + let mut state_guard = state_mutex.lock(); + if let Some(ref mut state) = *state_guard { + let _ = state.icon.set_menu(Some(Box::new(new_menu.clone()))); + state.menu = new_menu; + } + } +} + +// ============================================================================ +// C FFI Interface +// ============================================================================ + +/// Initialize the tray with icon paths +/// +/// # Arguments +/// * `icon_normal` - Path to normal icon +/// * `icon_playing` - Path to playing icon +/// * `icon_pausing` - Path to pausing icon +/// * `icon_locked` - Path to locked icon +/// * `tooltip` - Tooltip text +/// * `locale` - Initial locale (e.g., "zh", "en", "ja") +/// * `callback` - Callback function for menu actions +/// +/// # Returns +/// 0 on success, -1 on error +#[no_mangle] +pub unsafe extern "C" fn tray_init_ex( + icon_normal: *const c_char, + icon_playing: *const c_char, + icon_pausing: *const c_char, + icon_locked: *const c_char, + tooltip: *const c_char, + locale: *const c_char, + callback: ActionCallback, +) -> c_int { + // Store icon paths + let normal = c_str_to_string(icon_normal).unwrap_or_default(); + let playing = c_str_to_string(icon_playing).unwrap_or_default(); + let pausing = c_str_to_string(icon_pausing).unwrap_or_default(); + let locked = c_str_to_string(icon_locked).unwrap_or_default(); + + let _ = ICON_PATHS.set(IconPaths { + normal: normal.clone(), + playing, + pausing, + locked, + }); + + // Set locale + if let Some(loc) = c_str_to_string(locale) { + set_locale_str(&loc); + } + + // Register callback + register_callback(callback); + + // Initialize global state + let _ = TRAY_STATE.get_or_init(|| Mutex::new(None)); + + // Reset exit flag + SHOULD_EXIT.store(false, Ordering::SeqCst); + + // Load icon + let icon = match load_icon(&normal) { + Some(i) => i, + None => { + eprintln!("Failed to load tray icon"); + return -1; + } + }; + + // Get tooltip + let tooltip_str = c_str_to_string(tooltip).unwrap_or_else(|| "Sunshine".to_string()); + + // Build menu using the menu module + let menu = menu::rebuild_menu(); + + // Create tray icon + let tray_icon = match TrayIconBuilder::new() + .with_icon(icon) + .with_tooltip(&tooltip_str) + .with_menu(Box::new(menu.clone())) + .build() + { + Ok(t) => t, + Err(e) => { + eprintln!("Failed to create tray icon: {}", e); + return -1; + } + }; + + // Store state + let state = TrayState { + icon: tray_icon, + menu, + }; + + if let Some(state_mutex) = TRAY_STATE.get() { + *state_mutex.lock() = Some(state); + } + + 0 +} + +/// Run one iteration of the event loop +/// +/// # Arguments +/// * `blocking` - If non-zero, block until an event is available +/// +/// # Returns +/// 0 on success, -1 if exit was requested +#[no_mangle] +pub extern "C" fn tray_loop(blocking: c_int) -> c_int { + if SHOULD_EXIT.load(Ordering::SeqCst) { + return -1; + } + + unsafe { + let mut msg: MSG = std::mem::zeroed(); + + if blocking != 0 { + if GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) <= 0 { + return -1; + } + } else { + if PeekMessageW(&mut msg, std::ptr::null_mut(), 0, 0, PM_REMOVE) == 0 { + return 0; + } + } + + if msg.message == WM_QUIT { + return -1; + } + + // Handle DPI change - refresh icon with new size + if msg.message == WM_DPICHANGED || check_dpi_changed() { + refresh_icon_for_dpi(); + } + + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + // Process menu events - use process_menu_event to avoid deadlocks + if let Ok(event) = MenuEvent::receiver().try_recv() { + process_menu_event(&event); + } + + if SHOULD_EXIT.load(Ordering::SeqCst) { + return -1; + } + + 0 +} + +/// Exit the tray event loop +#[no_mangle] +pub extern "C" fn tray_exit() { + SHOULD_EXIT.store(true, Ordering::SeqCst); + + unsafe { + PostQuitMessage(0); + } + + // Clean up state + if let Some(state_mutex) = TRAY_STATE.get() { + *state_mutex.lock() = None; + } +} + +/// Set the tray icon +/// +/// # Arguments +/// * `icon_type` - 0=normal, 1=playing, 2=pausing, 3=locked +#[no_mangle] +pub extern "C" fn tray_set_icon(icon_type: c_int) { + // Store current icon type for DPI change refresh + CURRENT_ICON_TYPE.store(icon_type, Ordering::SeqCst); + + let icon_paths = match ICON_PATHS.get() { + Some(p) => p, + None => return, + }; + + let icon_path = match icon_type { + 0 => &icon_paths.normal, + 1 => &icon_paths.playing, + 2 => &icon_paths.pausing, + 3 => &icon_paths.locked, + _ => &icon_paths.normal, + }; + + if let Some(icon) = load_icon(icon_path) { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + let _ = state.icon.set_icon(Some(icon)); + } + } + } +} + +/// Set the tray tooltip +#[no_mangle] +pub unsafe extern "C" fn tray_set_tooltip(tooltip: *const c_char) { + if let Some(tip) = c_str_to_string(tooltip) { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + let _ = state.icon.set_tooltip(Some(&tip)); + } + } + } +} + +/// Update VDD menu item states +/// +/// This unified function is called from C++ to update all VDD menu states at once. +/// The C++ side is responsible for: +/// - Tracking VDD active state +/// - Managing 10-second cooldown +/// - Determining which operations are allowed +/// +/// # Parameters +/// * `can_create` - 1 if Create item should be enabled, 0 otherwise +/// * `can_close` - 1 if Close item should be enabled, 0 otherwise +/// * `is_persistent` - 1 if Keep Enabled is checked, 0 otherwise +/// * `is_active` - 1 if VDD is currently active, 0 otherwise +#[no_mangle] +pub extern "C" fn tray_update_vdd_menu( + can_create: c_int, + can_close: c_int, + is_persistent: c_int, + is_active: c_int, +) { + menu::update_vdd_menu_state( + can_create != 0, + can_close != 0, + is_persistent != 0, + is_active != 0, + ); +} + +/// Set the current locale +#[no_mangle] +pub unsafe extern "C" fn tray_set_locale(locale: *const c_char) { + if let Some(loc) = c_str_to_string(locale) { + set_locale_str(&loc); + update_menu_texts(); + } +} + +/// Show a Windows toast notification +/// +/// # Arguments +/// * `title` - Notification title (UTF-8 string) +/// * `text` - Notification body text (UTF-8 string) +/// * `icon_type` - Icon type (0=normal, 1=playing, 2=pausing, 3=locked) +#[no_mangle] +pub unsafe extern "C" fn tray_show_notification( + title: *const c_char, + text: *const c_char, + icon_type: c_int, +) { + let title_str = c_str_to_string(title).unwrap_or_default(); + let text_str = c_str_to_string(text).unwrap_or_default(); + + let icon = notification::NotificationIcon::from(icon_type); + notification::show_notification(&title_str, &text_str, icon); +} + +/// Notification types for localized notifications +/// These values must match the C enum +#[repr(C)] +pub enum NotificationType { + StreamStarted = 0, + StreamPaused = 1, + ApplicationStopped = 2, + PairingRequest = 3, +} + +/// Show a localized toast notification +/// +/// # Arguments +/// * `notification_type` - Type of notification (0=stream_started, 1=stream_paused, 2=app_stopped, 3=pairing_request) +/// * `app_name` - Application name for formatting (optional, UTF-8 string) +#[no_mangle] +pub unsafe extern "C" fn tray_show_localized_notification( + notification_type: c_int, + app_name: *const c_char, +) { + let app_name_str = c_str_to_string(app_name).unwrap_or_default(); + + let (title, text, icon) = match notification_type { + 0 => { + // Stream started + let title = i18n::get_string(i18n::StringKey::StreamStarted); + let text = i18n::get_string_fmt(i18n::StringKey::StreamingStartedFor, &app_name_str); + (title.to_string(), text, notification::NotificationIcon::Playing) + }, + 1 => { + // Stream paused + let title = i18n::get_string(i18n::StringKey::StreamPaused); + let text = i18n::get_string_fmt(i18n::StringKey::StreamingPausedFor, &app_name_str); + (title.to_string(), text, notification::NotificationIcon::Pausing) + }, + 2 => { + // Application stopped + let title = i18n::get_string(i18n::StringKey::ApplicationStopped); + let text = i18n::get_string_fmt(i18n::StringKey::ApplicationStoppedMsg, &app_name_str); + (title.to_string(), text, notification::NotificationIcon::Normal) + }, + 3 => { + // Pairing request + let title = i18n::get_string_fmt(i18n::StringKey::IncomingPairingRequest, &app_name_str); + let text = i18n::get_string(i18n::StringKey::ClickToCompletePairing); + (title, text.to_string(), notification::NotificationIcon::Normal) + }, + _ => return, + }; + + notification::show_notification(&title, &text, icon); +} diff --git a/rust_tray/src/menu.rs b/rust_tray/src/menu.rs new file mode 100644 index 00000000000..31694e00083 --- /dev/null +++ b/rust_tray/src/menu.rs @@ -0,0 +1,237 @@ +//! Menu module - Menu building and state management +//! +//! This module handles the actual menu construction from menu_items definitions. +//! It converts the declarative MenuItemInfo into actual muda menu items. +//! +//! Note: muda menu items use Rc internally and are not thread-safe, +//! so we store MenuId strings and item IDs for cross-thread access. + +use muda::{Menu, MenuItem, PredefinedMenuItem, Submenu, CheckMenuItem, MenuEvent, MenuId}; +use std::collections::HashMap; +use parking_lot::RwLock; +use once_cell::sync::Lazy; + +use crate::i18n::get_string; +use crate::menu_items::{self, ItemKind}; + +/// Registry entry - maps string item_id to muda MenuId string +pub struct MenuIdEntry { + pub item_id: String, + pub menu_id: String, + pub kind: ItemKind, +} + +// Thread-local storage for actual muda items (not thread-safe) +thread_local! { + static CREATED_ITEMS: std::cell::RefCell> = + std::cell::RefCell::new(HashMap::new()); +} + +enum CreatedItem { + Regular(MenuItem), + Check(CheckMenuItem), + Submenu(Submenu), +} + +/// Global menu ID registry - maps item_id to muda MenuId string +/// This is thread-safe as it only stores strings +static MENU_ID_REGISTRY: Lazy>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +/// Clear the registries +fn clear_registry() { + MENU_ID_REGISTRY.write().clear(); + CREATED_ITEMS.with(|items| items.borrow_mut().clear()); +} + +/// Register a menu item +fn register_item(item_id: &str, menu_id: &MenuId, kind: ItemKind) { + let menu_id_str = format!("{:?}", menu_id); + MENU_ID_REGISTRY.write().insert(item_id.to_string(), MenuIdEntry { + item_id: item_id.to_string(), + menu_id: menu_id_str, + kind, + }); +} + +/// Store a created item for thread-local access +fn store_created_item(item_id: &str, item: CreatedItem) { + CREATED_ITEMS.with(|items| { + items.borrow_mut().insert(item_id.to_string(), item); + }); +} + +/// Set a checkbox item's state by item_id +pub fn set_check_state_by_id(item_id: &str, checked: bool) { + CREATED_ITEMS.with(|items| { + if let Some(CreatedItem::Check(item)) = items.borrow().get(item_id) { + item.set_checked(checked); + } + }); +} + +/// Get a checkbox item's current state by item_id +pub fn get_check_state_by_id(item_id: &str) -> Option { + CREATED_ITEMS.with(|items| { + items.borrow().get(item_id).and_then(|item| { + if let CreatedItem::Check(check_item) = item { + Some(check_item.is_checked()) + } else { + None + } + }) + }) +} + +/// Set a menu item's enabled state by item_id +pub fn set_item_enabled_by_id(item_id: &str, enabled: bool) { + CREATED_ITEMS.with(|items| { + match items.borrow().get(item_id) { + Some(CreatedItem::Regular(item)) => item.set_enabled(enabled), + Some(CreatedItem::Check(item)) => item.set_enabled(enabled), + Some(CreatedItem::Submenu(item)) => item.set_enabled(enabled), + None => {} + } + }); +} + +/// Identify the item_id for a menu event +pub fn identify_item_id(event: &MenuEvent) -> Option { + let event_id_str = format!("{:?}", event.id); + let registry = MENU_ID_REGISTRY.read(); + for (item_id, entry) in registry.iter() { + if entry.menu_id == event_id_str { + return Some(item_id.clone()); + } + } + None +} + +/// Build the menu from menu_items definitions +pub fn rebuild_menu() -> Menu { + // Clear old registry + clear_registry(); + + let items = menu_items::get_all_items(); + + // First pass: create all items and submenus + let mut submenus: HashMap<&str, Submenu> = HashMap::new(); + let mut regular_items: HashMap<&str, Box> = HashMap::new(); + + // Sort items by order + let mut sorted_items: Vec<_> = items.iter().collect(); + sorted_items.sort_by_key(|item| item.order); + + // Create submenus first + for info in &sorted_items { + if info.kind == ItemKind::Submenu { + if let Some(key) = info.label_key { + let submenu = Submenu::new(get_string(key), true); + register_item(info.id, submenu.id(), info.kind); + store_created_item(info.id, CreatedItem::Submenu(submenu.clone())); + submenus.insert(info.id, submenu); + } + } + } + + // Create all other items + for info in &sorted_items { + match info.kind { + ItemKind::Action => { + if let Some(key) = info.label_key { + let item = MenuItem::new(get_string(key), true, None); + register_item(info.id, item.id(), info.kind); + store_created_item(info.id, CreatedItem::Regular(item.clone())); + regular_items.insert(info.id, Box::new(item)); + } + } + ItemKind::Check => { + if let Some(key) = info.label_key { + let item = CheckMenuItem::new(get_string(key), true, info.default_checked, None); + register_item(info.id, item.id(), info.kind); + store_created_item(info.id, CreatedItem::Check(item.clone())); + regular_items.insert(info.id, Box::new(item)); + } + } + ItemKind::Separator => { + let item = PredefinedMenuItem::separator(); + regular_items.insert(info.id, Box::new(item)); + } + ItemKind::Submenu => { + // Already handled above + } + } + } + + // Second pass: add items to their parent submenus + for info in &sorted_items { + if let Some(parent_id) = info.parent { + if let Some(submenu) = submenus.get(parent_id) { + if info.kind == ItemKind::Submenu { + if let Some(child_submenu) = submenus.get(info.id) { + let _ = submenu.append(child_submenu); + } + } else if let Some(item) = regular_items.remove(info.id) { + let _ = submenu.append(item.as_ref()); + } + } + } + } + + // Third pass: build main menu with top-level items + let menu = Menu::new(); + for info in &sorted_items { + if info.parent.is_none() { + if info.kind == ItemKind::Submenu { + if let Some(submenu) = submenus.get(info.id) { + let _ = menu.append(submenu); + } + } else if let Some(item) = regular_items.remove(info.id) { + let _ = menu.append(item.as_ref()); + } + } + } + + menu +} + +// ============================================================================ +// VDD Menu State Update +// ============================================================================ + +/// Update VDD menu item states +/// +/// Called from C++ side to update menu item enabled/disabled/checked states. +/// +/// # Parameters +/// * `can_create` - Whether "Create" item should be enabled +/// * `can_close` - Whether "Close" item should be enabled +/// * `is_persistent` - Whether "Keep Enabled" is checked +/// * `is_active` - Whether VDD is currently active (for checked states) +pub fn update_vdd_menu_state(can_create: bool, can_close: bool, is_persistent: bool, is_active: bool) { + use menu_items::ids; + + // Update Create item + // Checked when VDD is active, enabled based on can_create + set_check_state_by_id(ids::VDD_CREATE, is_active); + set_item_enabled_by_id(ids::VDD_CREATE, can_create); + + // Update Close item + // Checked when VDD is NOT active, enabled based on can_close + set_check_state_by_id(ids::VDD_CLOSE, !is_active); + set_item_enabled_by_id(ids::VDD_CLOSE, can_close); + + // Update Keep Enabled item + set_check_state_by_id(ids::VDD_PERSISTENT, is_persistent); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_all_items() { + let items = menu_items::get_all_items(); + assert!(!items.is_empty()); + } +} diff --git a/rust_tray/src/menu_items.rs b/rust_tray/src/menu_items.rs new file mode 100644 index 00000000000..fc22b61553f --- /dev/null +++ b/rust_tray/src/menu_items.rs @@ -0,0 +1,296 @@ +//! Menu Items Module - Centralized menu item definitions and handlers +//! +//! This is the ONLY file you need to modify when adding new menu items. +//! +//! To add a new menu item: +//! 1. Add a new entry to `define_menu_items!` macro +//! 2. Add translation strings in i18n.rs (StringKey enum and TRANSLATIONS) +//! 3. Done! No need to modify any other files. + +use crate::i18n::{StringKey, get_string, set_locale_str}; +use crate::actions::trigger_action; +use crate::dialogs; + +/// Menu item handler function type +/// Returns true if action should proceed, false to cancel +pub type MenuHandler = fn() -> bool; + +/// Menu item type +#[derive(Clone, Copy, PartialEq)] +pub enum ItemKind { + /// Regular clickable item + Action, + /// Checkbox item + Check, + /// Separator line + Separator, + /// Submenu container (no direct action) + Submenu, +} + +/// Menu item definition +#[derive(Clone)] +pub struct MenuItemInfo { + /// Unique identifier for this item + pub id: &'static str, + /// String key for localization + pub label_key: Option, + /// Item type + pub kind: ItemKind, + /// Parent submenu ID (None for top-level items) + pub parent: Option<&'static str>, + /// Handler function (for action items) + pub handler: Option, + /// Whether to rebuild menu after this action (e.g., language change) + pub rebuild_menu: bool, + /// Default checked state (for checkbox items) + pub default_checked: bool, + /// Sort order (lower = higher in menu) + pub order: u32, +} + +impl MenuItemInfo { + /// Create an action item + pub const fn action(id: &'static str, label_key: StringKey, parent: Option<&'static str>, order: u32) -> Self { + Self { + id, + label_key: Some(label_key), + kind: ItemKind::Action, + parent, + handler: None, + rebuild_menu: false, + default_checked: false, + order, + } + } + + /// Create a checkbox item + pub const fn check(id: &'static str, label_key: StringKey, parent: Option<&'static str>, default: bool, order: u32) -> Self { + Self { + id, + label_key: Some(label_key), + kind: ItemKind::Check, + parent, + handler: None, + rebuild_menu: false, + default_checked: default, + order, + } + } + + /// Create a submenu + pub const fn submenu(id: &'static str, label_key: StringKey, parent: Option<&'static str>, order: u32) -> Self { + Self { + id, + label_key: Some(label_key), + kind: ItemKind::Submenu, + parent, + handler: None, + rebuild_menu: false, + default_checked: false, + order, + } + } + + /// Create a separator + pub const fn separator(id: &'static str, parent: Option<&'static str>, order: u32) -> Self { + Self { + id, + label_key: None, + kind: ItemKind::Separator, + parent, + handler: None, + rebuild_menu: false, + default_checked: false, + order, + } + } + + /// Set handler and return self (builder pattern) + pub const fn with_handler(mut self, handler: MenuHandler) -> Self { + self.handler = Some(handler); + self + } + + /// Mark this item as requiring menu rebuild after action + pub const fn with_rebuild(mut self) -> Self { + self.rebuild_menu = true; + self + } +} + +// ============================================================================ +// Menu Item IDs - Used for registration and lookup +// ============================================================================ + +pub mod ids { + // Top-level items + pub const OPEN_SUNSHINE: &str = "open_sunshine"; + pub const SEP_1: &str = "sep_1"; + pub const SEP_2: &str = "sep_2"; + pub const SEP_3: &str = "sep_3"; + pub const SEP_4: &str = "sep_4"; + pub const STAR_PROJECT: &str = "star_project"; + pub const RESTART: &str = "restart"; + pub const QUIT: &str = "quit"; + + // VDD submenu + pub const VDD_SUBMENU: &str = "vdd_submenu"; + pub const VDD_CREATE: &str = "vdd_create"; + pub const VDD_CLOSE: &str = "vdd_close"; + pub const VDD_PERSISTENT: &str = "vdd_persistent"; + + // Advanced Settings submenu + pub const ADVANCED_SUBMENU: &str = "advanced_submenu"; + pub const CLOSE_APP: &str = "close_app"; + pub const RESET_DISPLAY: &str = "reset_display"; + + // Language submenu + pub const LANGUAGE_SUBMENU: &str = "language_submenu"; + pub const LANG_CHINESE: &str = "lang_chinese"; + pub const LANG_ENGLISH: &str = "lang_english"; + pub const LANG_JAPANESE: &str = "lang_japanese"; + + // Visit Project submenu + pub const VISIT_SUBMENU: &str = "visit_submenu"; + pub const VISIT_SUNSHINE: &str = "visit_sunshine"; + pub const VISIT_MOONLIGHT: &str = "visit_moonlight"; +} + +// ============================================================================ +// Handler Functions - The actual logic for each menu item +// ============================================================================ + +mod handlers { + use super::*; + + /// 关闭应用前显示确认对话框 + pub fn close_app() -> bool { + dialogs::show_confirm_dialog( + get_string(StringKey::CloseAppConfirmTitle), + get_string(StringKey::CloseAppConfirmMsg), + ) + } + + /// 重置显示配置前显示确认对话框 + pub fn reset_display() -> bool { + dialogs::show_confirm_dialog( + get_string(StringKey::ResetDisplayConfirmTitle), + get_string(StringKey::ResetDisplayConfirmMsg), + ) + } + + /// 切换语言(只更新 UI,配置保存由 C++ 处理) + pub fn lang_chinese() -> bool { + set_locale_str("zh"); + true + } + + pub fn lang_english() -> bool { + set_locale_str("en"); + true + } + + pub fn lang_japanese() -> bool { + set_locale_str("ja"); + true + } + + /// 退出前显示确认对话框 + pub fn quit() -> bool { + dialogs::show_confirm_dialog( + get_string(StringKey::QuitTitle), + get_string(StringKey::QuitMessage), + ) + } +} + +// ============================================================================ +// Menu Item Definitions - THE SINGLE SOURCE OF TRUTH +// ============================================================================ + +/// Get all menu item definitions +/// This is the ONLY place that defines the menu structure +pub fn get_all_items() -> Vec { + use ids::*; + + vec![ + // ====== Top Level Items ====== + MenuItemInfo::action(OPEN_SUNSHINE, StringKey::OpenSunshine, None, 100), + + MenuItemInfo::separator(SEP_1, None, 200), + + // ====== VDD Submenu ====== + MenuItemInfo::submenu(VDD_SUBMENU, StringKey::VddBaseDisplay, None, 300), + MenuItemInfo::check(VDD_CREATE, StringKey::VddCreate, Some(VDD_SUBMENU), false, 310), + MenuItemInfo::check(VDD_CLOSE, StringKey::VddClose, Some(VDD_SUBMENU), false, 320), + MenuItemInfo::check(VDD_PERSISTENT, StringKey::VddPersistent, Some(VDD_SUBMENU), false, 330), + + // ====== Advanced Settings Submenu ====== + MenuItemInfo::submenu(ADVANCED_SUBMENU, StringKey::AdvancedSettings, None, 400), + MenuItemInfo::action(CLOSE_APP, StringKey::CloseApp, Some(ADVANCED_SUBMENU), 450) + .with_handler(handlers::close_app), + MenuItemInfo::action(RESET_DISPLAY, StringKey::ResetDisplayDeviceConfig, Some(ADVANCED_SUBMENU), 460) + .with_handler(handlers::reset_display), + + MenuItemInfo::separator(SEP_2, None, 500), + + // ====== Language Submenu ====== + MenuItemInfo::submenu(LANGUAGE_SUBMENU, StringKey::Language, None, 600), + MenuItemInfo::action(LANG_CHINESE, StringKey::Chinese, Some(LANGUAGE_SUBMENU), 610) + .with_handler(handlers::lang_chinese) + .with_rebuild(), + MenuItemInfo::action(LANG_ENGLISH, StringKey::English, Some(LANGUAGE_SUBMENU), 620) + .with_handler(handlers::lang_english) + .with_rebuild(), + MenuItemInfo::action(LANG_JAPANESE, StringKey::Japanese, Some(LANGUAGE_SUBMENU), 630) + .with_handler(handlers::lang_japanese) + .with_rebuild(), + + MenuItemInfo::separator(SEP_3, None, 700), + + // ====== Star Project ====== + MenuItemInfo::action(STAR_PROJECT, StringKey::StarProject, None, 800), + + // ====== Visit Project Submenu ====== + MenuItemInfo::submenu(VISIT_SUBMENU, StringKey::VisitProject, None, 900), + MenuItemInfo::action(VISIT_SUNSHINE, StringKey::VisitProjectSunshine, Some(VISIT_SUBMENU), 910), + MenuItemInfo::action(VISIT_MOONLIGHT, StringKey::VisitProjectMoonlight, Some(VISIT_SUBMENU), 920), + + MenuItemInfo::separator(SEP_4, None, 1000), + + // ====== Restart & Quit ====== + MenuItemInfo::action(RESTART, StringKey::Restart, None, 1100), + MenuItemInfo::action(QUIT, StringKey::Quit, None, 1200) + .with_handler(handlers::quit), + ] +} + +/// Execute the handler for a menu item by ID +/// Returns (handled_locally, needs_rebuild) +pub fn execute_handler(item_id: &str) -> (bool, bool) { + let items = get_all_items(); + + if let Some(item) = items.iter().find(|i| i.id == item_id) { + let needs_rebuild = item.rebuild_menu; + + // Execute Rust handler if present (for dialogs, language changes, etc.) + // Handler returns false to cancel the action (e.g., user clicked "No" on dialog) + if let Some(handler) = item.handler { + if !handler() { + return (true, false); // Handled but cancelled, no rebuild + } + } + + // Trigger C++ callback for action items + trigger_action_for_id(item_id); + return (true, needs_rebuild); + } + + (false, false) +} + +/// Trigger C++ callback with action ID +fn trigger_action_for_id(item_id: &str) { + trigger_action(item_id); +} diff --git a/rust_tray/src/notification.rs b/rust_tray/src/notification.rs new file mode 100644 index 00000000000..f1a475c1c91 --- /dev/null +++ b/rust_tray/src/notification.rs @@ -0,0 +1,73 @@ +//! Windows Toast Notification module +//! +//! Provides native Windows toast notifications using WinRT. + +use winrt_notification::{Duration, Sound, Toast}; + +/// Application ID for toast notifications +/// +/// Using "Sunshine" as a simple app identifier. For full Windows integration +/// (notification center grouping, settings, etc.), this should ideally match +/// a shortcut in the Start Menu with the same AppUserModelID property. +const APP_ID: &str = "Sunshine"; + +/// Notification icon type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotificationIcon { + Normal = 0, + Playing = 1, + Pausing = 2, + Locked = 3, +} + +impl From for NotificationIcon { + fn from(value: i32) -> Self { + match value { + 1 => NotificationIcon::Playing, + 2 => NotificationIcon::Pausing, + 3 => NotificationIcon::Locked, + _ => NotificationIcon::Normal, + } + } +} + +/// Show a Windows toast notification +/// +/// # Arguments +/// * `title` - The notification title +/// * `body` - The notification body text +/// * `icon_type` - The icon type to use +/// +/// # Returns +/// `true` if the notification was shown successfully, `false` otherwise +pub fn show_notification(title: &str, body: &str, _icon_type: NotificationIcon) -> bool { + // Use winrt-notification to show a toast + let result = Toast::new(APP_ID) + .title(title) + .text1(body) + .duration(Duration::Short) + .sound(Some(Sound::Default)) + .show(); + + match result { + Ok(_) => true, + Err(e) => { + eprintln!("Failed to show notification: {:?}", e); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_notification_icon_from() { + assert_eq!(NotificationIcon::from(0), NotificationIcon::Normal); + assert_eq!(NotificationIcon::from(1), NotificationIcon::Playing); + assert_eq!(NotificationIcon::from(2), NotificationIcon::Pausing); + assert_eq!(NotificationIcon::from(3), NotificationIcon::Locked); + assert_eq!(NotificationIcon::from(99), NotificationIcon::Normal); + } +} diff --git a/src/system_tray.h b/src/system_tray.h index bc0a7b5e755..f28f8b554ca 100644 --- a/src/system_tray.h +++ b/src/system_tray.h @@ -4,59 +4,12 @@ */ #pragma once +#include + /** * @brief Handles the system tray icon and notification system. */ namespace system_tray { - /** - * @brief Callback for opening the UI from the system tray. - * @param item The tray menu item. - */ - void - tray_open_ui_cb(struct tray_menu *item); - - /** - * @brief Callback for opening GitHub Sponsors from the system tray. - * @param item The tray menu item. - */ - void - tray_donate_github_cb(struct tray_menu *item); - - /** - * @brief Callback for opening Patreon from the system tray. - * @param item The tray menu item. - */ - void - tray_donate_patreon_cb(struct tray_menu *item); - - /** - * @brief Callback for opening PayPal donation from the system tray. - * @param item The tray menu item. - */ - void - tray_donate_paypal_cb(struct tray_menu *item); - - /** - * @brief Callback for resetting display device configuration. - * @param item The tray menu item. - */ - void - tray_reset_display_device_config_cb(struct tray_menu *item); - - /** - * @brief Callback for restarting Sunshine from the system tray. - * @param item The tray menu item. - */ - void - tray_restart_cb(struct tray_menu *item); - - /** - * @brief Callback for exiting Sunshine from the system tray. - * @param item The tray menu item. - */ - void - tray_quit_cb(struct tray_menu *item); - /** * @brief Initializes the system tray without starting a loop. * @return 0 if initialization was successful, non-zero otherwise. @@ -73,49 +26,43 @@ namespace system_tray { * @brief Exit the system tray. * @return 0 after exiting the system tray. */ - int - end_tray(); + int end_tray(); /** * @brief Sets the tray icon in playing mode and spawns the appropriate notification * @param app_name The started application name */ - void - update_tray_playing(std::string app_name); + void update_tray_playing(std::string app_name); /** * @brief Sets the tray icon in pausing mode (stream stopped but app running) and spawns the appropriate notification * @param app_name The paused application name */ - void - update_tray_pausing(std::string app_name); + void update_tray_pausing(std::string app_name); /** * @brief Sets the tray icon in stopped mode (app and stream stopped) and spawns the appropriate notification * @param app_name The started application name */ - void - update_tray_stopped(std::string app_name); + void update_tray_stopped(std::string app_name); /** * @brief Spawns a notification for PIN Pairing. Clicking it opens the PIN Web UI Page */ - void - update_tray_require_pin(std::string pin_name); + void update_tray_require_pin(std::string pin_name); /** * @brief Initializes and runs the system tray in a separate thread. * @return 0 if initialization was successful, non-zero otherwise. */ int init_tray_threaded(); - - // Internationalization support - std::string get_localized_string(const std::string& key); - std::wstring get_localized_wstring(const std::string& key); // GUI process management void terminate_gui_processes(); // VDD menu management void update_vdd_menu(); + + // Update VDD menu checkbox state + void update_tray_vmonitor_checked(int checked); } // namespace system_tray diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp new file mode 100644 index 00000000000..2da1c930740 --- /dev/null +++ b/src/system_tray_rust.cpp @@ -0,0 +1,354 @@ +/** + * @file src/system_tray_rust.cpp + * @brief System tray implementation using the Rust tray library + * + * This file provides a thin C++ wrapper around the Rust tray library. + * All menu logic, i18n, and event handling is done in Rust. + */ + +#if defined(SUNSHINE_TRAY) && SUNSHINE_TRAY >= 1 + +#include +#include +#include +#include + +#if defined(_WIN32) + #define WIN32_LEAN_AND_MEAN + #include + #include + #define ICON_PATH_NORMAL WEB_DIR "images/sunshine.ico" + #define ICON_PATH_PLAYING WEB_DIR "images/sunshine-playing.ico" + #define ICON_PATH_PAUSING WEB_DIR "images/sunshine-pausing.ico" + #define ICON_PATH_LOCKED WEB_DIR "images/sunshine-locked.ico" +#elif defined(__linux__) || defined(linux) || defined(__linux) + #define ICON_PATH_NORMAL "sunshine-tray" + #define ICON_PATH_PLAYING "sunshine-playing" + #define ICON_PATH_PAUSING "sunshine-pausing" + #define ICON_PATH_LOCKED "sunshine-locked" +#elif defined(__APPLE__) || defined(__MACH__) + #define ICON_PATH_NORMAL WEB_DIR "images/logo-sunshine-16.png" + #define ICON_PATH_PLAYING WEB_DIR "images/sunshine-playing-16.png" + #define ICON_PATH_PAUSING WEB_DIR "images/sunshine-pausing-16.png" + #define ICON_PATH_LOCKED WEB_DIR "images/sunshine-locked-16.png" +#endif + +// Boost includes +#include + +// Local includes +#include "config.h" +#include "confighttp.h" +#include "display_device/display_device.h" +#include "display_device/session.h" +#include "entry_handler.h" +#include "globals.h" +#include "file_handler.h" +#include "logging.h" +#include "platform/common.h" +#include "process.h" +#include "system_tray.h" +#include "version.h" + +// Rust tray API +#include "rust_tray/include/rust_tray.h" + +using namespace std::literals; + +namespace system_tray { + + static std::atomic tray_initialized = false; + static std::atomic end_tray_called = false; + + // VDD state management + static std::atomic s_vdd_in_cooldown = false; + + // Forward declarations + static void handle_tray_action(uint32_t action); + + /** + * @brief Check if VDD is active + */ + static bool is_vdd_active() { + auto vdd_device_id = display_device::find_device_by_friendlyname(ZAKO_NAME); + return !vdd_device_id.empty(); + } + + /** + * @brief Update VDD menu state in Rust tray + */ + static void update_vdd_menu_state() { + bool vdd_active = is_vdd_active(); + bool keep_enabled = config::video.vdd_keep_enabled; + bool in_cooldown = s_vdd_in_cooldown.load(); + + // Create: enabled when NOT active AND NOT in cooldown + int can_create = (!vdd_active && !in_cooldown) ? 1 : 0; + // Close: enabled when active AND NOT in cooldown AND NOT keep_enabled + int can_close = (vdd_active && !in_cooldown && !keep_enabled) ? 1 : 0; + + tray_update_vdd_menu(can_create, can_close, keep_enabled ? 1 : 0, vdd_active ? 1 : 0); + } + + /** + * @brief Start VDD cooldown (10 seconds) + */ + static void start_vdd_cooldown() { + s_vdd_in_cooldown = true; + update_vdd_menu_state(); + + std::thread([]() { + std::this_thread::sleep_for(10s); + s_vdd_in_cooldown = false; + update_vdd_menu_state(); + }).detach(); + } + + /** + * @brief Handle tray actions from Rust (string-based) + */ + static void handle_tray_action(const char* action_id) { + if (!action_id) return; + + std::string action(action_id); + + if (action == TRAY_ACTION_OPEN_SUNSHINE) { + launch_ui(); + } + else if (action == TRAY_ACTION_VDD_CREATE) { + BOOST_LOG(info) << "Creating VDD from system tray"sv; + if (!s_vdd_in_cooldown && !is_vdd_active()) { + if (display_device::session_t::get().toggle_display_power()) { + start_vdd_cooldown(); + } + } + } + else if (action == TRAY_ACTION_VDD_CLOSE) { + BOOST_LOG(info) << "Closing VDD from system tray"sv; + if (!s_vdd_in_cooldown && is_vdd_active() && !config::video.vdd_keep_enabled) { + display_device::session_t::get().destroy_vdd_monitor(); + start_vdd_cooldown(); + } + } + else if (action == TRAY_ACTION_VDD_PERSISTENT) { + BOOST_LOG(info) << "Toggling VDD persistent mode"sv; + config::video.vdd_keep_enabled = !config::video.vdd_keep_enabled; + config::update_config({{"vdd_keep_enabled", config::video.vdd_keep_enabled ? "true" : "false"}}); + update_vdd_menu_state(); + } + else if (action == TRAY_ACTION_LANG_CHINESE) { + config::update_config({{"tray_locale", "zh"}}); + } + else if (action == TRAY_ACTION_LANG_ENGLISH) { + config::update_config({{"tray_locale", "en"}}); + } + else if (action == TRAY_ACTION_LANG_JAPANESE) { + config::update_config({{"tray_locale", "ja"}}); + } + else if (action == TRAY_ACTION_CLOSE_APP) { + BOOST_LOG(info) << "Closing application from system tray"sv; + proc::proc.terminate(); + } + else if (action == TRAY_ACTION_STAR_PROJECT) { + platf::open_url_in_browser("https://sunshine-foundation.vercel.app/"); + } + else if (action == TRAY_ACTION_VISIT_SUNSHINE) { + platf::open_url_in_browser("https://github.com/qiin2333/Sunshine-Foundation"); + } + else if (action == TRAY_ACTION_VISIT_MOONLIGHT) { + platf::open_url_in_browser("https://github.com/qiin2333/moonlight-vplus"); + } + else if (action == TRAY_ACTION_RESET_DISPLAY) { + BOOST_LOG(info) << "Resetting display device config"sv; + display_device::session_t::get().reset_persistence(); + } + else if (action == TRAY_ACTION_RESTART) { + BOOST_LOG(info) << "Restarting from system tray"sv; + platf::restart(); + } + else if (action == TRAY_ACTION_QUIT) { + BOOST_LOG(info) << "Quitting from system tray"sv; +#ifdef _WIN32 + terminate_gui_processes(); + if (GetConsoleWindow() == NULL) { + lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); + } + else { + lifetime::exit_sunshine(0, true); + } +#else + lifetime::exit_sunshine(0, true); +#endif + } + // Other actions (language, close_app) are handled entirely in Rust + } + + void terminate_gui_processes() { +#ifdef _WIN32 + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot != INVALID_HANDLE_VALUE) { + PROCESSENTRY32W pe32; + pe32.dwSize = sizeof(PROCESSENTRY32W); + + if (Process32FirstW(snapshot, &pe32)) { + do { + if (wcscmp(pe32.szExeFile, L"sunshine-gui.exe") == 0) { + HANDLE process_handle = OpenProcess(PROCESS_TERMINATE, FALSE, pe32.th32ProcessID); + if (process_handle != NULL) { + TerminateProcess(process_handle, 0); + CloseHandle(process_handle); + } + } + } while (Process32NextW(snapshot, &pe32)); + } + CloseHandle(snapshot); + } +#endif + } + + int init_tray() { + if (tray_initialized.exchange(true)) { + BOOST_LOG(warning) << "Tray already initialized"sv; + return 0; + } + + // Get locale from config + std::string locale = "zh"; // Default to Chinese + try { + auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); + if (vars.count("tray_locale") > 0) { + locale = vars["tray_locale"]; + } + } catch (...) { + // Ignore errors, use default locale + } + + // Create tooltip with version + std::string tooltip = "Sunshine "s + PROJECT_VER; + + // Initialize the Rust tray + int result = tray_init_ex( + ICON_PATH_NORMAL, + ICON_PATH_PLAYING, + ICON_PATH_PAUSING, + ICON_PATH_LOCKED, + tooltip.c_str(), + locale.c_str(), + handle_tray_action + ); + + if (result != 0) { + BOOST_LOG(error) << "Failed to initialize Rust tray"sv; + tray_initialized = false; + return -1; + } + + // Initialize VDD menu state + update_vdd_menu_state(); + + return 0; + } + + int process_tray_events() { + if (!tray_initialized) { + return -1; + } + return tray_loop(0); // Non-blocking + } + + int end_tray() { + // Use atomic exchange to ensure only one call proceeds + if (end_tray_called.exchange(true)) { + return 0; + } + + if (!tray_initialized) { + return 0; + } + + tray_initialized = false; + tray_exit(); + + return 0; + } + + int init_tray_threaded() { + // Reset the end_tray flag for new tray instance + end_tray_called = false; + + std::thread tray_thread([]() { + if (init_tray() != 0) { + return; + } + + // Main tray event loop + while (process_tray_events() == 0); + }); + + // The tray thread doesn't require strong lifetime management. + // It will exit asynchronously when tray_exit() is called. + tray_thread.detach(); + + return 0; + } + + void update_tray_playing(std::string app_name) { + if (!tray_initialized) return; + + tray_set_icon(TRAY_ICON_TYPE_PLAYING); + + std::string tooltip = "Sunshine - Playing: " + app_name; + tray_set_tooltip(tooltip.c_str()); + + // Show localized notification + tray_show_localized_notification(TRAY_NOTIFICATION_STREAM_STARTED, app_name.c_str()); + } + + void update_tray_pausing(std::string app_name) { + if (!tray_initialized) return; + + tray_set_icon(TRAY_ICON_TYPE_PAUSING); + + std::string tooltip = "Sunshine - Paused: " + app_name; + tray_set_tooltip(tooltip.c_str()); + + // Show localized notification + tray_show_localized_notification(TRAY_NOTIFICATION_STREAM_PAUSED, app_name.c_str()); + } + + void update_tray_stopped(std::string app_name) { + if (!tray_initialized) return; + + tray_set_icon(TRAY_ICON_TYPE_NORMAL); + + std::string tooltip = "Sunshine "s + PROJECT_VER; + tray_set_tooltip(tooltip.c_str()); + + // Show localized notification for application stopped + if (!app_name.empty()) { + tray_show_localized_notification(TRAY_NOTIFICATION_APP_STOPPED, app_name.c_str()); + } + } + + void update_tray_require_pin(std::string pin_name) { + if (!tray_initialized) return; + + // Show localized pairing request notification + tray_show_localized_notification(TRAY_NOTIFICATION_PAIRING_REQUEST, pin_name.c_str()); + } + + void update_tray_vmonitor_checked(int checked) { + if (!tray_initialized) return; + // Use the unified VDD menu update function + update_vdd_menu_state(); + } + + void update_vdd_menu() { + if (!tray_initialized) return; + // Update VDD menu state (called by vdd_utils) + update_vdd_menu_state(); + } + +} // namespace system_tray + +#endif // SUNSHINE_TRAY