From a29b0f64a004b55ce2ff72cf6ae8284fa39c0737 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 2 Dec 2025 22:27:38 +0800 Subject: [PATCH 01/36] =?UTF-8?q?feat:=20rust=E6=89=98=E7=9B=98=E5=9B=BE?= =?UTF-8?q?=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmake/compile_definitions/linux.cmake | 9 +- cmake/compile_definitions/macos.cmake | 11 +- cmake/compile_definitions/windows.cmake | 11 +- cmake/prep/options.cmake | 1 + cmake/targets/rust_tray.cmake | 122 +++++ rust_tray/.gitignore | 2 + rust_tray/Cargo.toml | 41 ++ rust_tray/README.md | 68 +++ rust_tray/build.rs | 23 + rust_tray/plan.md | 193 ++++++++ rust_tray/src/ffi.rs | 96 ++++ rust_tray/src/lib.rs | 571 ++++++++++++++++++++++++ 12 files changed, 1143 insertions(+), 5 deletions(-) create mode 100644 cmake/targets/rust_tray.cmake create mode 100644 rust_tray/.gitignore create mode 100644 rust_tray/Cargo.toml create mode 100644 rust_tray/README.md create mode 100644 rust_tray/build.rs create mode 100644 rust_tray/plan.md create mode 100644 rust_tray/src/ffi.rs create mode 100644 rust_tray/src/lib.rs diff --git a/cmake/compile_definitions/linux.cmake b/cmake/compile_definitions/linux.cmake index 704da29e536..b7bce1d1c26 100644 --- a/cmake/compile_definitions/linux.cmake +++ b/cmake/compile_definitions/linux.cmake @@ -195,7 +195,14 @@ if(${SUNSHINE_ENABLE_TRAY}) include_directories(SYSTEM ${APPINDICATOR_INCLUDE_DIRS} ${LIBNOTIFY_INCLUDE_DIRS}) link_directories(${APPINDICATOR_LIBRARY_DIRS} ${LIBNOTIFY_LIBRARY_DIRS}) - list(APPEND PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_linux.c") + if(SUNSHINE_USE_RUST_TRAY) + # Use Rust tray implementation + include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) + list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) + else() + # Use original C tray implementation + list(APPEND PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_linux.c") + endif() list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${APPINDICATOR_LIBRARIES} ${LIBNOTIFY_LIBRARIES}) endif() else() diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index 25529a981d8..76895b4bdb3 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -53,6 +53,13 @@ set(PLATFORM_TARGET_FILES if(SUNSHINE_ENABLE_TRAY) list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${COCOA}) - list(APPEND PLATFORM_TARGET_FILES - "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_darwin.m") + if(SUNSHINE_USE_RUST_TRAY) + # Use Rust tray implementation + include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) + list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) + else() + # Use original C tray implementation + list(APPEND PLATFORM_TARGET_FILES + "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_darwin.m") + endif() endif() diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake index 1b19acdb139..d8d01c12148 100644 --- a/cmake/compile_definitions/windows.cmake +++ b/cmake/compile_definitions/windows.cmake @@ -124,6 +124,13 @@ list(PREPEND PLATFORM_LIBRARIES ) if(SUNSHINE_ENABLE_TRAY) - list(APPEND PLATFORM_TARGET_FILES - "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_windows.c") + if(SUNSHINE_USE_RUST_TRAY) + # Use Rust tray implementation + include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) + list(APPEND PLATFORM_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) + else() + # Use original C tray implementation + list(APPEND PLATFORM_TARGET_FILES + "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_windows.c") + endif() endif() \ No newline at end of file diff --git a/cmake/prep/options.cmake b/cmake/prep/options.cmake index a511e3099e7..8bfbafdb31c 100644 --- a/cmake/prep/options.cmake +++ b/cmake/prep/options.cmake @@ -18,6 +18,7 @@ option(SUNSHINE_CONFIGURE_ONLY "Configure special files only, then exit." OFF) option(SUNSHINE_ENABLE_TRAY "Enable system tray icon. This option will be ignored on macOS." ON) option(SUNSHINE_REQUIRE_TRAY "Require system tray icon. Fail the build if tray requirements are not met." ON) +option(SUNSHINE_USE_RUST_TRAY "Use Rust tray-icon library instead of the C tray library." OFF) option(SUNSHINE_SYSTEM_NLOHMANN_JSON "Use system installation of nlohmann_json rather than the submodule." OFF) option(SUNSHINE_SYSTEM_WAYLAND_PROTOCOLS "Use system installation of wayland-protocols rather than the submodule." OFF) diff --git a/cmake/targets/rust_tray.cmake b/cmake/targets/rust_tray.cmake new file mode 100644 index 00000000000..c4e817a0bca --- /dev/null +++ b/cmake/targets/rust_tray.cmake @@ -0,0 +1,122 @@ +# 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) + if(MSVC) + set(RUST_TARGET "x86_64-pc-windows-msvc") + set(RUST_LIB_NAME "tray.lib") + else() + # MinGW - Rust still generates .lib files on Windows + set(RUST_TARGET "x86_64-pc-windows-gnu") + set(RUST_LIB_NAME "tray.lib") + endif() +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() + +# For default target (no cross-compilation), the path is simpler +set(RUST_OUTPUT_LIB "${RUST_TARGET_DIR}/${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 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 + ${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/ffi.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) + # Windows dependencies for tray-icon crate + set(RUST_TRAY_PLATFORM_LIBS + user32 + gdi32 + shell32 + ole32 + oleaut32 + uuid + comctl32 + bcrypt + ntdll + userenv + ws2_32 + ) +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/.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..a65b09955b0 --- /dev/null +++ b/rust_tray/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "sunshine_tray" +version = "0.1.0" +edition = "2021" +build = "build.rs" + +[lib] +name = "tray" +crate-type = ["staticlib"] + +[dependencies] +tray-icon = "0.19" +muda = "0.15" +image = { version = "0.25", default-features = false, features = ["png", "ico"] } +once_cell = "1.19" +parking_lot = "0.12" +log = "0.4" + +[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_System_LibraryLoader", +] } + +[target.'cfg(target_os = "linux")'.dependencies] +gtk = "0.18" +glib = "0.20" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.5" +objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSRunningApplication"] } +objc2-foundation = { version = "0.2", features = ["NSRunLoop", "NSDate"] } + +[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/plan.md b/rust_tray/plan.md new file mode 100644 index 00000000000..3b4439403ce --- /dev/null +++ b/rust_tray/plan.md @@ -0,0 +1,193 @@ +# 系统托盘替换方案 + +## 目标 +将现有的基于 C 库 `tray` 的系统托盘实现替换为 Rust 的 `tray-icon` 库,以提高可维护性并减少跨平台适配代码。 + +## 总体方案 +1. 在项目根目录下创建 Rust 子目录 `rust_tray`,编写静态库实现与原 `tray` 库完全兼容的 C API(`tray_init`、`tray_update`、`tray_loop`、`tray_exit`)。 +2. 使用 `bindgen` 从原 `third-party/tray/src/tray.h` 生成 Rust 绑定,保证结构体布局一致。 +3. 修改 CMake 构建系统,移除对原 `tray` 库的编译,改为构建并链接 Rust 静态库。 +4. 保留 `third-party/tray` 子模块中的头文件,确保 C++ 代码 `#include "tray/src/tray.h"` 仍然有效。 +5. 不修改 `src/system_tray.cpp` 的业务逻辑,仅替换底层库的实现。 + +## 详细步骤 + +### 1. 创建 Rust 项目 +在 `rust_tray/` 下初始化 Cargo 库: + +``` +rust_tray/ +├── Cargo.toml +├── build.rs +└── src/ + └── lib.rs +``` + +**Cargo.toml 内容:** + +```toml +[package] +name = "sunshine_tray" +version = "0.1.0" +edition = "2021" + +[lib] +name = "tray" +crate-type = ["staticlib"] + +[dependencies] +tray-icon = { version = "0.10", default-features = false, features = ["tray"] } +anyhow = "1.0" +lazy_static = "1.4" +libc = "0.2" +log = "0.4" + +[build-dependencies] +bindgen = "0.69" +``` + +### 2. 生成 FFI 绑定(build.rs) +利用 `bindgen` 解析原头文件,生成与 C 完全一致的 Rust 结构体定义。 + +```rust +// build.rs +use std::env; +use std::path::PathBuf; + +fn main() { + let bindings = bindgen::Builder::default() + .header("third-party/tray/src/tray.h") + .parse_callbacks(Box::new(bindgen::CargoCallbacks)) + .generate() + .expect("Unable to generate bindings"); + + let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); + bindings + .write_to_file(out_path.join("bindings.rs")) + .expect("Couldn't write bindings"); +} +``` + +### 3. 实现 C API(lib.rs) + +主要任务: + +- 全局状态管理(`OnceLock>>`),存储 `TrayIcon` 实例及菜单项到 C 菜单的映射。 +- `tray_init`:根据传入的 `tray` 结构体创建托盘图标和菜单,注册回调(调用 C 回调)。 +- `tray_update`:更新图标、工具提示、菜单文本、勾选状态等;若设置了 `notification_*` 字段,显示通知。 +- `tray_loop`:启动平台事件循环(例如 GTK 的 `main` 或 Windows 消息循环),阻塞直到 `tray_exit` 被调用。 +- `tray_exit`:退出事件循环,清理资源。 + +关键代码框架: + +```rust +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); + +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int, c_void}; +use std::sync::{Mutex, OnceLock}; +use tray_icon::{TrayIcon, TrayIconBuilder, Menu, MenuItem, MenuId, TrayIconEvent}; +use anyhow::Result; + +struct TrayState { + icon: TrayIcon, + menu_map: Vec<(MenuId, *const tray_menu)>, // 用于回调查找 + // 事件循环句柄(例如 gtk::Application 或 winit 事件循环) + event_loop: Option<...>, +} + +static TRAY_STATE: OnceLock>> = OnceLock::new(); + +#[no_mangle] +pub extern "C" fn tray_init(t: *mut tray) -> c_int { + // 错误处理返回 -1,成功 0 + match unsafe { do_init(t) } { + Ok(_) => 0, + Err(_) => -1, + } +} + +unsafe fn do_init(t: *mut tray) -> Result<()> { + // 构建菜单(递归) + let (menu, menu_map) = build_menu((*t).menu)?; + + // 加载图标 + let icon = load_icon(CStr::from_ptr((*t).icon).to_str()?)?; + + let builder = TrayIconBuilder::new() + .with_icon(icon) + .with_tooltip(CStr::from_ptr((*t).tooltip).to_str()?) + .with_menu(Box::new(menu)); + + let tray_icon = builder.build()?; + + // 存储状态 + let state = TrayState { + icon: tray_icon, + menu_map, + event_loop: None, + }; + TRAY_STATE.get_or_init(|| Mutex::new(None)) + .lock() + .unwrap() + .replace(state); + + Ok(()) +} + +// 其他函数类似实现 +``` + +菜单构建时需递归处理子菜单,并为每个 `MenuItem` 设置回调:当用户点击时,根据 `MenuId` 从 `menu_map` 中找到对应的 C `tray_menu` 指针,调用其 `cb` 字段(若存在)。 + +### 4. 修改 CMake 构建 + +编辑 `cmake/targets/common.cmake`,注释或删除对原 `tray` 库的引用,替换为自定义命令构建 Rust 库。 + +```cmake +# 禁用原 tray 库 +# add_subdirectory(third-party/tray) + +# 添加 Rust 托盘库构建 +set(RUST_TRAY_SOURCE_DIR "${CMAKE_SOURCE_DIR}/rust_tray") +set(RUST_TARGET_DIR "${CMAKE_BINARY_DIR}/rust_tray") +set(RUST_OUTPUT_DEBUG "${RUST_TARGET_DIR}/debug/libtray.a") +# Release 构建可根据需要选择 +add_custom_command( + OUTPUT ${RUST_OUTPUT_DEBUG} + COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${RUST_TARGET_DIR} cargo build --manifest-path ${RUST_TRAY_SOURCE_DIR}/Cargo.toml + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Building Rust Tray library (debug)" + VERBATIM +) +add_custom_target(rust_tray ALL DEPENDS ${RUST_OUTPUT_DEBUG}) + +# 链接静态库 +target_link_libraries(sunshine PRIVATE ${RUST_OUTPUT_DEBUG}) +target_include_directories(sunshine PRIVATE third-party/tray/src) +``` + +注意:根据实际构建类型(Debug/Release)调整 cargo 参数和输出路径,也可同时构建 Release 版本并通过 CMake 变量选择。 + +### 5. 验证与测试 + +编译 Sunshine,确保无链接错误。运行后验证: +- 系统托盘图标显示正常。 +- 菜单点击功能正常(打开 UI、开关显示器、导入导出配置、语言切换等)。 +- 通知正常显示(例如开始/暂停串流、配对请求)。 +- 图标切换(播放、暂停、锁定)正常。 + +## 可能的问题及应对 + +- **回调中 C 字符串的生命周期**:`tray_menu.text` 在语言切换时会指向新的 `std::string` 内部数据,而 Rust 在 `tray_update` 时会重新拷贝字符串,因此安全。 +- **事件循环集成**:不同平台事件循环实现方式不同,需确保 `tray_loop` 阻塞且能正确响应退出。可参考 `tray-icon` 示例中的事件循环代码(如 winit、gtk)。 +- **Windows 系统权限问题**:原 `system_tray.cpp` 中的线程 DACL 修改和等待 Shell 的代码保留,不影响 Rust 库。 +- **跨平台图标格式**:原代码已通过宏区分各平台图标路径/名称,直接传递给 `tray-icon` 即可,该库会自动处理。 + +## 后续工作 + +- 移除 `third-party/tray` 中除头文件外的源文件(可选,但保留子模块可方便获取头文件)。 +- 完善 Rust 实现中的错误处理和日志记录。 +- 如有必要,在 CI 中增加 Rust 工具链安装步骤。 + +本方案通过最小侵入式修改实现了核心功能替换,可显著降低维护成本并提高跨平台稳定性。 \ No newline at end of file diff --git a/rust_tray/src/ffi.rs b/rust_tray/src/ffi.rs new file mode 100644 index 00000000000..b65da4dadbd --- /dev/null +++ b/rust_tray/src/ffi.rs @@ -0,0 +1,96 @@ +//! FFI definitions matching the original tray.h header +//! +//! These structures are manually defined to match the C header file exactly, +//! avoiding the need for bindgen and LLVM/Clang dependencies. + +use std::os::raw::{c_char, c_int, c_void}; + +/// Tray menu item callback type +pub type tray_menu_cb = Option; + +/// Notification callback type +pub type notification_cb = Option; + +/// Tray menu item. +/// +/// Represents a single item in the tray menu. Items can be: +/// - Regular menu items with text and callback +/// - Separators (text = "-") +/// - Checkbox items (checkbox = 1) +/// - Submenus (submenu != null) +#[repr(C)] +#[derive(Debug)] +pub struct tray_menu { + /// Text to display. Use "-" for separator. + pub text: *const c_char, + /// Whether the item is disabled (grayed out). + pub disabled: c_int, + /// Whether the item is checked (for checkbox items). + pub checked: c_int, + /// Whether the item is a checkbox. + pub checkbox: c_int, + /// Callback to invoke when the item is clicked. + pub cb: tray_menu_cb, + /// Context pointer passed to the callback. + pub context: *mut c_void, + /// Pointer to submenu items (null-terminated array). + pub submenu: *mut tray_menu, +} + +/// Tray icon. +/// +/// Main tray structure containing icon, tooltip, menu, and notification data. +#[repr(C)] +#[derive(Debug)] +pub struct tray { + /// Icon path/name to display. + pub icon: *const c_char, + /// Tooltip text to display on hover. + pub tooltip: *const c_char, + /// Icon for notifications. + pub notification_icon: *const c_char, + /// Notification message text. + pub notification_text: *const c_char, + /// Notification title. + pub notification_title: *const c_char, + /// Callback when notification is clicked. + pub notification_cb: notification_cb, + /// Menu items. + pub menu: *mut tray_menu, + /// Number of icon paths in allIconPaths. + pub iconPathCount: c_int, + /// Array of all possible icon paths (flexible array member). + /// Note: In C, this is `const char *allIconPaths[]`, which is a flexible array member. + /// We represent it as a pointer to the first element. + pub allIconPaths: [*const c_char; 0], +} + +impl Default for tray_menu { + fn default() -> Self { + Self { + text: std::ptr::null(), + disabled: 0, + checked: 0, + checkbox: 0, + cb: None, + context: std::ptr::null_mut(), + submenu: std::ptr::null_mut(), + } + } +} + +impl Default for tray { + fn default() -> Self { + Self { + icon: std::ptr::null(), + tooltip: std::ptr::null(), + notification_icon: std::ptr::null(), + notification_text: std::ptr::null(), + notification_title: std::ptr::null(), + notification_cb: None, + menu: std::ptr::null_mut(), + iconPathCount: 0, + allIconPaths: [], + } + } +} diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs new file mode 100644 index 00000000000..64c03b55950 --- /dev/null +++ b/rust_tray/src/lib.rs @@ -0,0 +1,571 @@ +//! Sunshine Tray - Rust implementation of the tray icon library +//! +//! This library provides a C-compatible API that mirrors the original tray library, +//! but uses the `tray-icon` Rust crate for the underlying implementation. + +#![allow(non_upper_case_globals)] +#![allow(non_camel_case_types)] +#![allow(non_snake_case)] +#![allow(dead_code)] + +mod ffi; + +use ffi::{tray, tray_menu}; + +use std::ffi::CStr; +use std::os::raw::{c_char, c_int}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +use image::ImageReader; +use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use once_cell::sync::OnceCell; +use parking_lot::Mutex; +use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; + +#[cfg(target_os = "windows")] +mod platform { + pub use windows_sys::Win32::UI::WindowsAndMessaging::{ + DispatchMessageW, GetMessageW, PeekMessageW, PostQuitMessage, TranslateMessage, + MSG, PM_REMOVE, WM_QUIT, + }; +} + +#[cfg(target_os = "linux")] +mod platform { + pub use glib::MainContext; + pub use gtk::prelude::*; +} + +#[cfg(target_os = "macos")] +mod platform { + pub use objc2_app_kit::NSApplication; + pub use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSRunLoop}; +} + +/// Global tray state +struct TrayState { + /// The tray icon instance + icon: TrayIcon, + /// Menu items with their callbacks + menu_items: Vec, + /// Pointer to the original C tray struct (for accessing callbacks) + c_tray: *mut tray, +} + +/// Information about a menu item +struct MenuItemInfo { + /// The menu item ID + id: muda::MenuId, + /// Pointer to the original C menu item + c_menu: *const tray_menu, +} + +// Safety: TrayState is only accessed from the main thread +unsafe impl Send for TrayState {} +unsafe impl Sync for TrayState {} + +/// Global state storage +static TRAY_STATE: OnceCell>> = OnceCell::new(); + +/// Flag to control the event loop +static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); + +/// Convert a C string pointer to a Rust string slice +unsafe fn c_str_to_str<'a>(ptr: *const c_char) -> Option<&'a str> { + if ptr.is_null() { + return None; + } + CStr::from_ptr(ptr).to_str().ok() +} + +/// Load an icon from file path +fn load_icon_from_path(path: &str) -> Option { + let path = Path::new(path); + + // Try to load the image + let img = match ImageReader::open(path) { + Ok(reader) => match reader.decode() { + Ok(img) => img.into_rgba8(), + Err(e) => { + log::error!("Failed to decode icon image: {}", e); + return None; + } + }, + Err(e) => { + log::error!("Failed to open icon file '{}': {}", path.display(), e); + return None; + } + }; + + let (width, height) = img.dimensions(); + let rgba = img.into_raw(); + + match Icon::from_rgba(rgba, width, height) { + Ok(icon) => Some(icon), + Err(e) => { + log::error!("Failed to create icon: {}", e); + None + } + } +} + +#[cfg(target_os = "linux")] +fn load_icon_by_name(name: &str) -> Option { + // On Linux, icons are typically loaded by name from the icon theme + // For now, we'll try some common paths + let paths = [ + format!("/usr/share/icons/hicolor/256x256/apps/{}.png", name), + format!("/usr/share/icons/hicolor/128x128/apps/{}.png", name), + format!("/usr/share/icons/hicolor/64x64/apps/{}.png", name), + format!("/usr/share/icons/hicolor/48x48/apps/{}.png", name), + format!("/usr/share/pixmaps/{}.png", name), + ]; + + for path in &paths { + if Path::new(path).exists() { + if let Some(icon) = load_icon_from_path(path) { + return Some(icon); + } + } + } + + log::warn!("Could not find icon by name: {}", name); + None +} + +/// Load icon based on the icon path/name +fn load_icon(icon_str: &str) -> Option { + // Check if it's a file path + if Path::new(icon_str).exists() { + return load_icon_from_path(icon_str); + } + + // On Linux, it might be an icon name + #[cfg(target_os = "linux")] + { + return load_icon_by_name(icon_str); + } + + #[cfg(not(target_os = "linux"))] + { + log::error!("Icon not found: {}", icon_str); + None + } +} + +/// Build menu recursively from C tray_menu structure +unsafe fn build_menu(menu_ptr: *const tray_menu) -> (Menu, Vec) { + let menu = Menu::new(); + let mut items = Vec::new(); + + if menu_ptr.is_null() { + return (menu, items); + } + + let mut current = menu_ptr; + while !current.is_null() { + let menu_item = &*current; + + // Check for null terminator + if menu_item.text.is_null() { + break; + } + + let text = c_str_to_str(menu_item.text).unwrap_or(""); + + // Check for separator + if text == "-" { + let _ = menu.append(&PredefinedMenuItem::separator()); + current = current.add(1); + continue; + } + + // Check for submenu + if !menu_item.submenu.is_null() { + let (sub_menu_obj, sub_items) = build_menu(menu_item.submenu); + let submenu = Submenu::new(text, true); + + // Copy items from sub_menu_obj to submenu + for item in sub_menu_obj.items() { + match item { + muda::MenuItemKind::MenuItem(mi) => { let _ = submenu.append(&mi); } + muda::MenuItemKind::Submenu(sm) => { let _ = submenu.append(&sm); } + muda::MenuItemKind::Predefined(pi) => { let _ = submenu.append(&pi); } + muda::MenuItemKind::Check(ci) => { let _ = submenu.append(&ci); } + muda::MenuItemKind::Icon(ii) => { let _ = submenu.append(&ii); } + } + } + + let _ = menu.append(&submenu); + items.extend(sub_items); + } else { + // Regular menu item + let item = if menu_item.checkbox != 0 { + // Checkbox item + let check_item = + muda::CheckMenuItem::new(text, true, menu_item.checked != 0, None); + let id = check_item.id().clone(); + let _ = menu.append(&check_item); + id + } else { + // Normal item + let normal_item = MenuItem::new(text, menu_item.disabled == 0, None); + let id = normal_item.id().clone(); + let _ = menu.append(&normal_item); + id + }; + + items.push(MenuItemInfo { + id: item, + c_menu: current, + }); + } + + current = current.add(1); + } + + (menu, items) +} + +/// Initialize the tray icon +/// +/// # Safety +/// The caller must ensure that `t` points to a valid `tray` structure +/// that remains valid for the lifetime of the tray. +#[no_mangle] +pub unsafe extern "C" fn tray_init(t: *mut tray) -> c_int { + if t.is_null() { + return -1; + } + + // Initialize the global state + let _ = TRAY_STATE.get_or_init(|| Mutex::new(None)); + + #[cfg(target_os = "linux")] + { + // Initialize GTK + if gtk::init().is_err() { + log::error!("Failed to initialize GTK"); + return -1; + } + } + + // Reset exit flag + SHOULD_EXIT.store(false, Ordering::SeqCst); + + let tray_ref = &*t; + + // Load icon + let icon = match c_str_to_str(tray_ref.icon) { + Some(icon_path) => match load_icon(icon_path) { + Some(i) => i, + None => { + log::error!("Failed to load tray icon"); + return -1; + } + }, + None => { + log::error!("No icon path provided"); + return -1; + } + }; + + // Get tooltip + let tooltip = c_str_to_str(tray_ref.tooltip); + + // Build menu + let (menu, menu_items) = build_menu(tray_ref.menu); + + // Create tray icon + let mut builder = TrayIconBuilder::new().with_icon(icon).with_menu(Box::new(menu)); + + if let Some(tip) = tooltip { + builder = builder.with_tooltip(tip); + } + + match builder.build() { + Ok(tray_icon) => { + let state = TrayState { + icon: tray_icon, + menu_items, + c_tray: t, + }; + + if let Some(state_mutex) = TRAY_STATE.get() { + *state_mutex.lock() = Some(state); + } + + 0 + } + Err(e) => { + log::error!("Failed to create tray icon: {}", e); + -1 + } + } +} + +/// Update the tray icon and menu +/// +/// # Safety +/// The caller must ensure that `t` points to a valid `tray` structure. +#[no_mangle] +pub unsafe extern "C" fn tray_update(t: *mut tray) { + if t.is_null() { + return; + } + + let state_mutex = match TRAY_STATE.get() { + Some(s) => s, + None => return, + }; + + let mut state_guard = state_mutex.lock(); + let state = match state_guard.as_mut() { + Some(s) => s, + None => return, + }; + + let tray_ref = &*t; + + // Update icon if changed + if let Some(icon_path) = c_str_to_str(tray_ref.icon) { + if let Some(new_icon) = load_icon(icon_path) { + let _ = state.icon.set_icon(Some(new_icon)); + } + } + + // Update tooltip if changed + if let Some(tip) = c_str_to_str(tray_ref.tooltip) { + let _ = state.icon.set_tooltip(Some(tip)); + } + + // Check for notification + if !tray_ref.notification_title.is_null() && !tray_ref.notification_text.is_null() { + let title = c_str_to_str(tray_ref.notification_title).unwrap_or(""); + let text = c_str_to_str(tray_ref.notification_text).unwrap_or(""); + + if !title.is_empty() || !text.is_empty() { + // Show notification - tray-icon doesn't have built-in notification support + // We would need to use a separate notification library here + log::info!("Notification: {} - {}", title, text); + + #[cfg(target_os = "windows")] + { + // On Windows, we can use the tray icon's balloon notification + // This requires accessing the underlying Windows API + // For now, just log + } + + #[cfg(target_os = "linux")] + { + // On Linux, we can use libnotify + // For now, just log + } + } + } + + // Rebuild menu (simpler approach - recreate menu on update) + let (menu, menu_items) = build_menu(tray_ref.menu); + let _ = state.icon.set_menu(Some(Box::new(menu))); + state.menu_items = menu_items; + state.c_tray = t; +} + +/// Process menu events and invoke callbacks +unsafe fn process_menu_event(state: &TrayState, event: &MenuEvent) { + for item_info in &state.menu_items { + if item_info.id == event.id { + let menu_item = &*item_info.c_menu; + if let Some(cb) = menu_item.cb { + // Call the C callback + cb(item_info.c_menu as *mut tray_menu); + } + break; + } + } +} + +/// Run one iteration of the UI loop +/// +/// # Arguments +/// * `blocking` - If non-zero, block until an event is available +/// +/// # Returns +/// * 0 on success +/// * -1 if `tray_exit()` was called +#[no_mangle] +pub extern "C" fn tray_loop(blocking: c_int) -> c_int { + if SHOULD_EXIT.load(Ordering::SeqCst) { + return -1; + } + + #[cfg(target_os = "windows")] + { + use platform::*; + + unsafe { + let mut msg: MSG = std::mem::zeroed(); + + if blocking != 0 { + // Blocking mode - wait for message + if GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) <= 0 { + return -1; + } + } else { + // Non-blocking mode - peek for message + if PeekMessageW(&mut msg, std::ptr::null_mut(), 0, 0, PM_REMOVE) == 0 { + return 0; + } + } + + if msg.message == WM_QUIT { + return -1; + } + + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + + // Process menu events + if let Ok(event) = MenuEvent::receiver().try_recv() { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + unsafe { + process_menu_event(state, &event); + } + } + } + } + + if SHOULD_EXIT.load(Ordering::SeqCst) { + return -1; + } + + 0 + } + + #[cfg(target_os = "linux")] + { + // Process GTK events + if blocking != 0 { + // Process one event, blocking if necessary + while gtk::events_pending() { + gtk::main_iteration(); + } + + // Wait a bit if no events + std::thread::sleep(std::time::Duration::from_millis(100)); + } else { + // Process pending events without blocking + while gtk::events_pending() { + gtk::main_iteration(); + } + } + + // Process menu events + if let Ok(event) = MenuEvent::receiver().try_recv() { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + unsafe { + process_menu_event(state, &event); + } + } + } + } + + if SHOULD_EXIT.load(Ordering::SeqCst) { + return -1; + } + + 0 + } + + #[cfg(target_os = "macos")] + { + use objc2::rc::autoreleasepool; + use platform::*; + + autoreleasepool(|_| { + unsafe { + let app = NSApplication::sharedApplication(); + let run_loop = NSRunLoop::currentRunLoop(); + + if blocking != 0 { + // Run until a date in the future (blocking) + let date = NSDate::dateWithTimeIntervalSinceNow(0.1); + run_loop.runUntilDate(&date); + } else { + // Run once without blocking + let date = NSDate::dateWithTimeIntervalSinceNow(0.0); + run_loop.runUntilDate(&date); + } + } + }); + + // Process menu events + if let Ok(event) = MenuEvent::receiver().try_recv() { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + unsafe { + process_menu_event(state, &event); + } + } + } + } + + if SHOULD_EXIT.load(Ordering::SeqCst) { + return -1; + } + + 0 + } + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + log::error!("Unsupported platform"); + -1 + } +} + +/// Terminate the UI loop +#[no_mangle] +pub extern "C" fn tray_exit() { + SHOULD_EXIT.store(true, Ordering::SeqCst); + + #[cfg(target_os = "windows")] + { + unsafe { + platform::PostQuitMessage(0); + } + } + + #[cfg(target_os = "linux")] + { + gtk::main_quit(); + } + + // Clean up state + if let Some(state_mutex) = TRAY_STATE.get() { + let mut state_guard = state_mutex.lock(); + *state_guard = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_c_str_to_str() { + unsafe { + assert_eq!(c_str_to_str(std::ptr::null()), None); + + let s = std::ffi::CString::new("hello").unwrap(); + assert_eq!(c_str_to_str(s.as_ptr()), Some("hello")); + } + } +} From c79bd1e8333a1c14ac5b2df65453fd27c2500d5d Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 00:42:27 +0800 Subject: [PATCH 02/36] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E5=AD=90?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E9=A1=B9=E7=9B=AE=E7=9A=84=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/lib.rs | 94 +++++++++++++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 27 deletions(-) diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 64c03b55950..162c5b17e36 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -154,6 +154,60 @@ fn load_icon(icon_str: &str) -> Option { } } +/// Build submenu recursively and append items directly to parent +unsafe fn build_submenu_items(submenu: &Submenu, menu_ptr: *const tray_menu, items: &mut Vec) { + if menu_ptr.is_null() { + return; + } + + let mut current = menu_ptr; + while !current.is_null() { + let menu_item = &*current; + + // Check for null terminator + if menu_item.text.is_null() { + break; + } + + let text = c_str_to_str(menu_item.text).unwrap_or(""); + + // Check for separator + if text == "-" { + let _ = submenu.append(&PredefinedMenuItem::separator()); + current = current.add(1); + continue; + } + + // Check for nested submenu + if !menu_item.submenu.is_null() { + let nested_submenu = Submenu::new(text, true); + build_submenu_items(&nested_submenu, menu_item.submenu, items); + let _ = submenu.append(&nested_submenu); + } else { + // Regular menu item + if menu_item.checkbox != 0 { + // Checkbox item + let check_item = muda::CheckMenuItem::new(text, true, menu_item.checked != 0, None); + items.push(MenuItemInfo { + id: check_item.id().clone(), + c_menu: current, + }); + let _ = submenu.append(&check_item); + } else { + // Normal item + let normal_item = MenuItem::new(text, menu_item.disabled == 0, None); + items.push(MenuItemInfo { + id: normal_item.id().clone(), + c_menu: current, + }); + let _ = submenu.append(&normal_item); + } + } + + current = current.add(1); + } +} + /// Build menu recursively from C tray_menu structure unsafe fn build_menu(menu_ptr: *const tray_menu) -> (Menu, Vec) { let menu = Menu::new(); @@ -183,43 +237,29 @@ unsafe fn build_menu(menu_ptr: *const tray_menu) -> (Menu, Vec) { // Check for submenu if !menu_item.submenu.is_null() { - let (sub_menu_obj, sub_items) = build_menu(menu_item.submenu); let submenu = Submenu::new(text, true); - - // Copy items from sub_menu_obj to submenu - for item in sub_menu_obj.items() { - match item { - muda::MenuItemKind::MenuItem(mi) => { let _ = submenu.append(&mi); } - muda::MenuItemKind::Submenu(sm) => { let _ = submenu.append(&sm); } - muda::MenuItemKind::Predefined(pi) => { let _ = submenu.append(&pi); } - muda::MenuItemKind::Check(ci) => { let _ = submenu.append(&ci); } - muda::MenuItemKind::Icon(ii) => { let _ = submenu.append(&ii); } - } - } - + // Build submenu items directly into the submenu + build_submenu_items(&submenu, menu_item.submenu, &mut items); let _ = menu.append(&submenu); - items.extend(sub_items); } else { // Regular menu item - let item = if menu_item.checkbox != 0 { + if menu_item.checkbox != 0 { // Checkbox item - let check_item = - muda::CheckMenuItem::new(text, true, menu_item.checked != 0, None); - let id = check_item.id().clone(); + let check_item = muda::CheckMenuItem::new(text, true, menu_item.checked != 0, None); + items.push(MenuItemInfo { + id: check_item.id().clone(), + c_menu: current, + }); let _ = menu.append(&check_item); - id } else { // Normal item let normal_item = MenuItem::new(text, menu_item.disabled == 0, None); - let id = normal_item.id().clone(); + items.push(MenuItemInfo { + id: normal_item.id().clone(), + c_menu: current, + }); let _ = menu.append(&normal_item); - id - }; - - items.push(MenuItemInfo { - id: item, - c_menu: current, - }); + } } current = current.add(1); From 5ee688209a1d9509e8f829b4f27887bfcbdd13ae Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 01:27:42 +0800 Subject: [PATCH 03/36] =?UTF-8?q?rafactor:=20=E6=8A=8A=E5=A4=A7=E9=83=A8?= =?UTF-8?q?=E5=88=86=E9=80=BB=E8=BE=91=E8=BF=81=E7=A7=BB=E5=88=B0rust?= =?UTF-8?q?=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmake/compile_definitions/common.cmake | 4 +- cmake/compile_definitions/linux.cmake | 11 +- cmake/compile_definitions/macos.cmake | 12 +- cmake/compile_definitions/windows.cmake | 12 +- cmake/prep/options.cmake | 1 - cmake/targets/rust_tray.cmake | 3 +- rust_tray/Cargo.toml | 1 + rust_tray/include/rust_tray.h | 123 ++++ rust_tray/src/actions.rs | 116 ++++ rust_tray/src/ffi.rs | 96 --- rust_tray/src/i18n.rs | 336 +++++++++ rust_tray/src/lib.rs | 868 ++++++++++++++---------- src/system_tray_rust.cpp | 336 +++++++++ 13 files changed, 1424 insertions(+), 495 deletions(-) create mode 100644 rust_tray/include/rust_tray.h create mode 100644 rust_tray/src/actions.rs delete mode 100644 rust_tray/src/ffi.rs create mode 100644 rust_tray/src/i18n.rs create mode 100644 src/system_tray_rust.cpp diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 8662ca362a3..46088c50127 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -117,10 +117,8 @@ 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" "${CMAKE_SOURCE_DIR}/src/task_pool.h" "${CMAKE_SOURCE_DIR}/src/thread_pool.h" "${CMAKE_SOURCE_DIR}/src/thread_safe.h" diff --git a/cmake/compile_definitions/linux.cmake b/cmake/compile_definitions/linux.cmake index b7bce1d1c26..729e5fe90fd 100644 --- a/cmake/compile_definitions/linux.cmake +++ b/cmake/compile_definitions/linux.cmake @@ -195,14 +195,9 @@ if(${SUNSHINE_ENABLE_TRAY}) include_directories(SYSTEM ${APPINDICATOR_INCLUDE_DIRS} ${LIBNOTIFY_INCLUDE_DIRS}) link_directories(${APPINDICATOR_LIBRARY_DIRS} ${LIBNOTIFY_LIBRARY_DIRS}) - if(SUNSHINE_USE_RUST_TRAY) - # Use Rust tray implementation - include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) - list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) - else() - # Use original C tray implementation - list(APPEND PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_linux.c") - endif() + # Rust tray implementation + include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) + list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${APPINDICATOR_LIBRARIES} ${LIBNOTIFY_LIBRARIES}) endif() else() diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index 76895b4bdb3..f815c901f01 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -53,13 +53,7 @@ set(PLATFORM_TARGET_FILES if(SUNSHINE_ENABLE_TRAY) list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${COCOA}) - if(SUNSHINE_USE_RUST_TRAY) - # Use Rust tray implementation - include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) - list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) - else() - # Use original C tray implementation - list(APPEND PLATFORM_TARGET_FILES - "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_darwin.m") - endif() + # Rust tray implementation + include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) + list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) endif() diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake index d8d01c12148..0c1bcd37bf5 100644 --- a/cmake/compile_definitions/windows.cmake +++ b/cmake/compile_definitions/windows.cmake @@ -124,13 +124,7 @@ list(PREPEND PLATFORM_LIBRARIES ) if(SUNSHINE_ENABLE_TRAY) - if(SUNSHINE_USE_RUST_TRAY) - # Use Rust tray implementation - include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) - list(APPEND PLATFORM_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) - else() - # Use original C tray implementation - list(APPEND PLATFORM_TARGET_FILES - "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_windows.c") - endif() + # 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/prep/options.cmake b/cmake/prep/options.cmake index 8bfbafdb31c..a511e3099e7 100644 --- a/cmake/prep/options.cmake +++ b/cmake/prep/options.cmake @@ -18,7 +18,6 @@ option(SUNSHINE_CONFIGURE_ONLY "Configure special files only, then exit." OFF) option(SUNSHINE_ENABLE_TRAY "Enable system tray icon. This option will be ignored on macOS." ON) option(SUNSHINE_REQUIRE_TRAY "Require system tray icon. Fail the build if tray requirements are not met." ON) -option(SUNSHINE_USE_RUST_TRAY "Use Rust tray-icon library instead of the C tray library." OFF) option(SUNSHINE_SYSTEM_NLOHMANN_JSON "Use system installation of nlohmann_json rather than the submodule." OFF) option(SUNSHINE_SYSTEM_WAYLAND_PROTOCOLS "Use system installation of wayland-protocols rather than the submodule." OFF) diff --git a/cmake/targets/rust_tray.cmake b/cmake/targets/rust_tray.cmake index c4e817a0bca..3458143ce41 100644 --- a/cmake/targets/rust_tray.cmake +++ b/cmake/targets/rust_tray.cmake @@ -67,7 +67,8 @@ add_custom_command( ${RUST_TRAY_SOURCE_DIR}/Cargo.toml ${RUST_TRAY_SOURCE_DIR}/build.rs ${RUST_TRAY_SOURCE_DIR}/src/lib.rs - ${RUST_TRAY_SOURCE_DIR}/src/ffi.rs + ${RUST_TRAY_SOURCE_DIR}/src/i18n.rs + ${RUST_TRAY_SOURCE_DIR}/src/actions.rs VERBATIM ) diff --git a/rust_tray/Cargo.toml b/rust_tray/Cargo.toml index a65b09955b0..d3bbf023857 100644 --- a/rust_tray/Cargo.toml +++ b/rust_tray/Cargo.toml @@ -22,6 +22,7 @@ windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader", + "Win32_UI_Shell", ] } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h new file mode 100644 index 00000000000..9f340784551 --- /dev/null +++ b/rust_tray/include/rust_tray.h @@ -0,0 +1,123 @@ +/** + * @file rust_tray.h + * @brief C API for the Rust tray library + */ +#pragma once + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Menu action identifiers (must match Rust MenuAction enum) + */ +typedef enum { + TRAY_ACTION_OPEN_UI = 1, + TRAY_ACTION_TOGGLE_VDD_MONITOR = 2, + TRAY_ACTION_IMPORT_CONFIG = 3, + TRAY_ACTION_EXPORT_CONFIG = 4, + TRAY_ACTION_RESET_CONFIG = 5, + TRAY_ACTION_LANGUAGE_CHINESE = 6, + TRAY_ACTION_LANGUAGE_ENGLISH = 7, + TRAY_ACTION_LANGUAGE_JAPANESE = 8, + TRAY_ACTION_STAR_PROJECT = 9, + TRAY_ACTION_DONATE_YUNDI339 = 10, + TRAY_ACTION_DONATE_QIIN = 11, + TRAY_ACTION_RESET_DISPLAY_DEVICE_CONFIG = 12, + TRAY_ACTION_RESTART = 13, + TRAY_ACTION_QUIT = 14, + TRAY_ACTION_NOTIFICATION_CLICKED = 15, +} TrayAction; + +/** + * @brief Icon types for tray_set_icon + */ +typedef enum { + TRAY_ICON_NORMAL = 0, + TRAY_ICON_PLAYING = 1, + TRAY_ICON_PAUSING = 2, + TRAY_ICON_LOCKED = 3, +} TrayIconType; + +/** + * @brief Callback function type for menu actions + * @param action The action identifier + */ +typedef void (*TrayActionCallback)(uint32_t action); + +/** + * @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 the VDD monitor toggle checkbox state + * @param checked Non-zero to check, zero to uncheck + */ +void tray_set_vdd_checked(int checked); + +/** + * @brief Set the VDD toggle menu item enabled state + * @param enabled Non-zero to enable, zero to disable + */ +void tray_set_vdd_enabled(int enabled); + +/** + * @brief Set the current locale + * @param locale Locale string (e.g., "zh", "en", "ja") + */ +void tray_set_locale(const char* locale); + +/** + * @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); + +#ifdef __cplusplus +} +#endif diff --git a/rust_tray/src/actions.rs b/rust_tray/src/actions.rs new file mode 100644 index 00000000000..98f7f154e35 --- /dev/null +++ b/rust_tray/src/actions.rs @@ -0,0 +1,116 @@ +//! Menu actions module +//! +//! Defines all menu actions and their identifiers. +//! C++ side will register callbacks for these actions. + +use std::sync::RwLock; +use once_cell::sync::Lazy; + +/// Menu action identifiers +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u32)] +pub enum MenuAction { + OpenUI = 1, + ToggleVddMonitor = 2, + ImportConfig = 3, + ExportConfig = 4, + ResetConfig = 5, + LanguageChinese = 6, + LanguageEnglish = 7, + LanguageJapanese = 8, + StarProject = 9, + DonateYundi339 = 10, + DonateQiin = 11, + ResetDisplayDeviceConfig = 12, + Restart = 13, + Quit = 14, + NotificationClicked = 15, +} + +impl TryFrom for MenuAction { + type Error = (); + + fn try_from(value: u32) -> Result { + match value { + 1 => Ok(MenuAction::OpenUI), + 2 => Ok(MenuAction::ToggleVddMonitor), + 3 => Ok(MenuAction::ImportConfig), + 4 => Ok(MenuAction::ExportConfig), + 5 => Ok(MenuAction::ResetConfig), + 6 => Ok(MenuAction::LanguageChinese), + 7 => Ok(MenuAction::LanguageEnglish), + 8 => Ok(MenuAction::LanguageJapanese), + 9 => Ok(MenuAction::StarProject), + 10 => Ok(MenuAction::DonateYundi339), + 11 => Ok(MenuAction::DonateQiin), + 12 => Ok(MenuAction::ResetDisplayDeviceConfig), + 13 => Ok(MenuAction::Restart), + 14 => Ok(MenuAction::Quit), + 15 => Ok(MenuAction::NotificationClicked), + _ => Err(()), + } + } +} + +/// Callback function type for menu actions +pub type ActionCallback = extern "C" fn(action: u32); + +/// 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().unwrap() = Some(callback); +} + +/// Trigger a menu action +pub fn trigger_action(action: MenuAction) { + if let Some(callback) = *ACTION_CALLBACK.read().unwrap() { + callback(action as u32); + } +} + +/// URLs for opening in browser +pub mod urls { + pub const GITHUB_PROJECT: &str = "https://github.com/qiin2333/Sunshine-Foundation"; + pub const DONATE_YUNDI339: &str = "https://www.ifdian.net/a/Yundi339"; + pub const DONATE_QIIN: &str = "https://www.ifdian.net/a/qiin2333"; +} + +/// Open URL in default browser +#[cfg(target_os = "windows")] +pub fn open_url(url: &str) { + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + use std::ptr::null_mut; + + let wide_url: Vec = OsStr::new(url) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + unsafe { + windows_sys::Win32::UI::Shell::ShellExecuteW( + null_mut(), + ['o' as u16, 'p' as u16, 'e' as u16, 'n' as u16, 0].as_ptr(), + wide_url.as_ptr(), + null_mut(), + null_mut(), + windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL as i32, + ); + } +} + +#[cfg(target_os = "linux")] +pub fn open_url(url: &str) { + let _ = std::process::Command::new("xdg-open") + .arg(url) + .spawn(); +} + +#[cfg(target_os = "macos")] +pub fn open_url(url: &str) { + let _ = std::process::Command::new("open") + .arg(url) + .spawn(); +} diff --git a/rust_tray/src/ffi.rs b/rust_tray/src/ffi.rs deleted file mode 100644 index b65da4dadbd..00000000000 --- a/rust_tray/src/ffi.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! FFI definitions matching the original tray.h header -//! -//! These structures are manually defined to match the C header file exactly, -//! avoiding the need for bindgen and LLVM/Clang dependencies. - -use std::os::raw::{c_char, c_int, c_void}; - -/// Tray menu item callback type -pub type tray_menu_cb = Option; - -/// Notification callback type -pub type notification_cb = Option; - -/// Tray menu item. -/// -/// Represents a single item in the tray menu. Items can be: -/// - Regular menu items with text and callback -/// - Separators (text = "-") -/// - Checkbox items (checkbox = 1) -/// - Submenus (submenu != null) -#[repr(C)] -#[derive(Debug)] -pub struct tray_menu { - /// Text to display. Use "-" for separator. - pub text: *const c_char, - /// Whether the item is disabled (grayed out). - pub disabled: c_int, - /// Whether the item is checked (for checkbox items). - pub checked: c_int, - /// Whether the item is a checkbox. - pub checkbox: c_int, - /// Callback to invoke when the item is clicked. - pub cb: tray_menu_cb, - /// Context pointer passed to the callback. - pub context: *mut c_void, - /// Pointer to submenu items (null-terminated array). - pub submenu: *mut tray_menu, -} - -/// Tray icon. -/// -/// Main tray structure containing icon, tooltip, menu, and notification data. -#[repr(C)] -#[derive(Debug)] -pub struct tray { - /// Icon path/name to display. - pub icon: *const c_char, - /// Tooltip text to display on hover. - pub tooltip: *const c_char, - /// Icon for notifications. - pub notification_icon: *const c_char, - /// Notification message text. - pub notification_text: *const c_char, - /// Notification title. - pub notification_title: *const c_char, - /// Callback when notification is clicked. - pub notification_cb: notification_cb, - /// Menu items. - pub menu: *mut tray_menu, - /// Number of icon paths in allIconPaths. - pub iconPathCount: c_int, - /// Array of all possible icon paths (flexible array member). - /// Note: In C, this is `const char *allIconPaths[]`, which is a flexible array member. - /// We represent it as a pointer to the first element. - pub allIconPaths: [*const c_char; 0], -} - -impl Default for tray_menu { - fn default() -> Self { - Self { - text: std::ptr::null(), - disabled: 0, - checked: 0, - checkbox: 0, - cb: None, - context: std::ptr::null_mut(), - submenu: std::ptr::null_mut(), - } - } -} - -impl Default for tray { - fn default() -> Self { - Self { - icon: std::ptr::null(), - tooltip: std::ptr::null(), - notification_icon: std::ptr::null(), - notification_text: std::ptr::null(), - notification_title: std::ptr::null(), - notification_cb: None, - menu: std::ptr::null_mut(), - iconPathCount: 0, - allIconPaths: [], - } - } -} diff --git a/rust_tray/src/i18n.rs b/rust_tray/src/i18n.rs new file mode 100644 index 00000000000..3edd5da27f4 --- /dev/null +++ b/rust_tray/src/i18n.rs @@ -0,0 +1,336 @@ +//! Internationalization (i18n) module for tray icon +//! +//! Supports Chinese, English, and Japanese translations. + +use std::collections::HashMap; +use std::sync::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, + VddMonitorToggle, + Configuration, + ImportConfig, + ExportConfig, + ResetToDefault, + Language, + Chinese, + English, + Japanese, + StarProject, + HelpUs, + DeveloperYundi339, + DeveloperQiin, + ResetDisplayDeviceConfig, + Restart, + Quit, + + // Notifications + StreamStarted, + StreamingStartedFor, + StreamPaused, + StreamingPausedFor, + ApplicationStopped, + ApplicationStoppedMsg, + IncomingPairingRequest, + ClickToCompletePairing, + + // Dialog messages + QuitTitle, + QuitMessage, + ErrorTitle, + ErrorNoUserSession, + ImportSuccessTitle, + ImportSuccessMsg, + ImportErrorTitle, + ImportErrorWrite, + ImportErrorRead, + ImportErrorException, + ExportSuccessTitle, + ExportSuccessMsg, + ExportErrorTitle, + ExportErrorWrite, + ExportErrorNoConfig, + ExportErrorException, + ResetConfirmTitle, + ResetConfirmMsg, + ResetSuccessTitle, + ResetSuccessMsg, + ResetErrorTitle, + ResetErrorMsg, + ResetErrorException, + FileDialogSelectImport, + FileDialogSaveExport, + FileDialogConfigFiles, + FileDialogAllFiles, +} + +/// 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 Sunshine"); + m.insert((Locale::English, StringKey::VddMonitorToggle), "VDD Monitor Toggle"); + m.insert((Locale::English, StringKey::Configuration), "Configuration"); + m.insert((Locale::English, StringKey::ImportConfig), "Import Config"); + m.insert((Locale::English, StringKey::ExportConfig), "Export Config"); + m.insert((Locale::English, StringKey::ResetToDefault), "Reset to Default"); + m.insert((Locale::English, StringKey::Language), "Language"); + m.insert((Locale::English, StringKey::Chinese), "中文"); + m.insert((Locale::English, StringKey::English), "English"); + m.insert((Locale::English, StringKey::Japanese), "日本語"); + m.insert((Locale::English, StringKey::StarProject), "Star Project"); + m.insert((Locale::English, StringKey::HelpUs), "Sponsor Us"); + m.insert((Locale::English, StringKey::DeveloperYundi339), "Developer: Yundi339"); + m.insert((Locale::English, StringKey::DeveloperQiin), "Developer: Qiin"); + m.insert((Locale::English, StringKey::ResetDisplayDeviceConfig), "Reset Display Memory"); + 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"); + m.insert((Locale::English, StringKey::ErrorNoUserSession), "Cannot open file dialog: No active user session found."); + m.insert((Locale::English, StringKey::ImportSuccessTitle), "Import Success"); + m.insert((Locale::English, StringKey::ImportSuccessMsg), "Configuration imported successfully!\nPlease restart Sunshine to apply changes."); + m.insert((Locale::English, StringKey::ImportErrorTitle), "Import Error"); + m.insert((Locale::English, StringKey::ImportErrorWrite), "Failed to import configuration file."); + m.insert((Locale::English, StringKey::ImportErrorRead), "Failed to read the selected configuration file."); + m.insert((Locale::English, StringKey::ImportErrorException), "An error occurred while importing configuration."); + m.insert((Locale::English, StringKey::ExportSuccessTitle), "Export Success"); + m.insert((Locale::English, StringKey::ExportSuccessMsg), "Configuration exported successfully!"); + m.insert((Locale::English, StringKey::ExportErrorTitle), "Export Error"); + m.insert((Locale::English, StringKey::ExportErrorWrite), "Failed to export configuration file."); + m.insert((Locale::English, StringKey::ExportErrorNoConfig), "No configuration found to export."); + m.insert((Locale::English, StringKey::ExportErrorException), "An error occurred while exporting configuration."); + m.insert((Locale::English, StringKey::ResetConfirmTitle), "Reset Configuration"); + m.insert((Locale::English, StringKey::ResetConfirmMsg), "This will reset all configuration to default values.\nThis action cannot be undone.\n\nDo you want to continue?"); + m.insert((Locale::English, StringKey::ResetSuccessTitle), "Reset Success"); + m.insert((Locale::English, StringKey::ResetSuccessMsg), "Configuration has been reset to default values.\nPlease restart Sunshine to apply changes."); + m.insert((Locale::English, StringKey::ResetErrorTitle), "Reset Error"); + m.insert((Locale::English, StringKey::ResetErrorMsg), "Failed to reset configuration file."); + m.insert((Locale::English, StringKey::ResetErrorException), "An error occurred while resetting configuration."); + m.insert((Locale::English, StringKey::FileDialogSelectImport), "Select Configuration File to Import"); + m.insert((Locale::English, StringKey::FileDialogSaveExport), "Save Configuration File As"); + m.insert((Locale::English, StringKey::FileDialogConfigFiles), "Configuration Files"); + m.insert((Locale::English, StringKey::FileDialogAllFiles), "All Files"); + + // Chinese translations + m.insert((Locale::Chinese, StringKey::OpenSunshine), "打开 Sunshine"); + m.insert((Locale::Chinese, StringKey::VddMonitorToggle), "虚拟显示器切换"); + m.insert((Locale::Chinese, StringKey::Configuration), "配置"); + m.insert((Locale::Chinese, StringKey::ImportConfig), "导入配置"); + m.insert((Locale::Chinese, StringKey::ExportConfig), "导出配置"); + m.insert((Locale::Chinese, StringKey::ResetToDefault), "恢复默认"); + m.insert((Locale::Chinese, StringKey::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), "Star项目"); + m.insert((Locale::Chinese, StringKey::HelpUs), "赞助我们"); + m.insert((Locale::Chinese, StringKey::DeveloperYundi339), "开发者:Yundi339"); + m.insert((Locale::Chinese, StringKey::DeveloperQiin), "开发者:Qiin"); + m.insert((Locale::Chinese, StringKey::ResetDisplayDeviceConfig), "重置显示器记忆"); + 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), "错误"); + m.insert((Locale::Chinese, StringKey::ErrorNoUserSession), "无法打开文件对话框:未找到活动的用户会话。"); + m.insert((Locale::Chinese, StringKey::ImportSuccessTitle), "导入成功"); + m.insert((Locale::Chinese, StringKey::ImportSuccessMsg), "配置已成功导入!\n请重新启动 Sunshine 以应用更改。"); + m.insert((Locale::Chinese, StringKey::ImportErrorTitle), "导入失败"); + m.insert((Locale::Chinese, StringKey::ImportErrorWrite), "无法写入配置文件。"); + m.insert((Locale::Chinese, StringKey::ImportErrorRead), "无法读取所选的配置文件。"); + m.insert((Locale::Chinese, StringKey::ImportErrorException), "导入配置时发生错误。"); + m.insert((Locale::Chinese, StringKey::ExportSuccessTitle), "导出成功"); + m.insert((Locale::Chinese, StringKey::ExportSuccessMsg), "配置已成功导出!"); + m.insert((Locale::Chinese, StringKey::ExportErrorTitle), "导出失败"); + m.insert((Locale::Chinese, StringKey::ExportErrorWrite), "无法导出配置文件。"); + m.insert((Locale::Chinese, StringKey::ExportErrorNoConfig), "未找到可导出的配置。"); + m.insert((Locale::Chinese, StringKey::ExportErrorException), "导出配置时发生错误。"); + m.insert((Locale::Chinese, StringKey::ResetConfirmTitle), "重置配置"); + m.insert((Locale::Chinese, StringKey::ResetConfirmMsg), "这将把所有配置重置为默认值。\n此操作无法撤销。\n\n确定要继续吗?"); + m.insert((Locale::Chinese, StringKey::ResetSuccessTitle), "重置成功"); + m.insert((Locale::Chinese, StringKey::ResetSuccessMsg), "配置已重置为默认值。\n请重新启动 Sunshine 以应用更改。"); + m.insert((Locale::Chinese, StringKey::ResetErrorTitle), "重置失败"); + m.insert((Locale::Chinese, StringKey::ResetErrorMsg), "无法重置配置文件。"); + m.insert((Locale::Chinese, StringKey::ResetErrorException), "重置配置时发生错误。"); + m.insert((Locale::Chinese, StringKey::FileDialogSelectImport), "选择要导入的配置文件"); + m.insert((Locale::Chinese, StringKey::FileDialogSaveExport), "配置文件另存为"); + m.insert((Locale::Chinese, StringKey::FileDialogConfigFiles), "配置文件"); + m.insert((Locale::Chinese, StringKey::FileDialogAllFiles), "所有文件"); + + // Japanese translations + m.insert((Locale::Japanese, StringKey::OpenSunshine), "Sunshineを開く"); + m.insert((Locale::Japanese, StringKey::VddMonitorToggle), "仮想ディスプレイの切り替え"); + m.insert((Locale::Japanese, StringKey::Configuration), "設定"); + m.insert((Locale::Japanese, StringKey::ImportConfig), "設定をインポート"); + m.insert((Locale::Japanese, StringKey::ExportConfig), "設定をエクスポート"); + m.insert((Locale::Japanese, StringKey::ResetToDefault), "デフォルトに戻す"); + m.insert((Locale::Japanese, StringKey::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::HelpUs), "スポンサー"); + m.insert((Locale::Japanese, StringKey::DeveloperYundi339), "開発者:Yundi339"); + m.insert((Locale::Japanese, StringKey::DeveloperQiin), "開発者:Qiin"); + m.insert((Locale::Japanese, StringKey::ResetDisplayDeviceConfig), "ディスプレイメモリをリセット"); + 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.insert((Locale::Japanese, StringKey::ErrorNoUserSession), "ファイルダイアログを開けません:アクティブなユーザーセッションが見つかりません。"); + m.insert((Locale::Japanese, StringKey::ImportSuccessTitle), "インポート成功"); + m.insert((Locale::Japanese, StringKey::ImportSuccessMsg), "設定のインポートに成功しました!\n変更を適用するにはSunshineを再起動してください。"); + m.insert((Locale::Japanese, StringKey::ImportErrorTitle), "インポート失敗"); + m.insert((Locale::Japanese, StringKey::ImportErrorWrite), "設定ファイルを書き込めませんでした。"); + m.insert((Locale::Japanese, StringKey::ImportErrorRead), "選択した設定ファイルを読み取れませんでした。"); + m.insert((Locale::Japanese, StringKey::ImportErrorException), "設定のインポート中にエラーが発生しました。"); + m.insert((Locale::Japanese, StringKey::ExportSuccessTitle), "エクスポート成功"); + m.insert((Locale::Japanese, StringKey::ExportSuccessMsg), "設定のエクスポートに成功しました!"); + m.insert((Locale::Japanese, StringKey::ExportErrorTitle), "エクスポート失敗"); + m.insert((Locale::Japanese, StringKey::ExportErrorWrite), "設定ファイルをエクスポートできませんでした。"); + m.insert((Locale::Japanese, StringKey::ExportErrorNoConfig), "エクスポートする設定が見つかりません。"); + m.insert((Locale::Japanese, StringKey::ExportErrorException), "設定のエクスポート中にエラーが発生しました。"); + m.insert((Locale::Japanese, StringKey::ResetConfirmTitle), "設定のリセット"); + m.insert((Locale::Japanese, StringKey::ResetConfirmMsg), "すべての設定をデフォルト値にリセットします。\nこの操作は元に戻せません。\n\n続行しますか?"); + m.insert((Locale::Japanese, StringKey::ResetSuccessTitle), "リセット成功"); + m.insert((Locale::Japanese, StringKey::ResetSuccessMsg), "設定をデフォルト値にリセットしました。\n変更を適用するにはSunshineを再起動してください。"); + m.insert((Locale::Japanese, StringKey::ResetErrorTitle), "リセット失敗"); + m.insert((Locale::Japanese, StringKey::ResetErrorMsg), "設定ファイルをリセットできませんでした。"); + m.insert((Locale::Japanese, StringKey::ResetErrorException), "設定のリセット中にエラーが発生しました。"); + m.insert((Locale::Japanese, StringKey::FileDialogSelectImport), "インポートする設定ファイルを選択"); + m.insert((Locale::Japanese, StringKey::FileDialogSaveExport), "設定ファイルに名前を付けて保存"); + m.insert((Locale::Japanese, StringKey::FileDialogConfigFiles), "設定ファイル"); + m.insert((Locale::Japanese, StringKey::FileDialogAllFiles), "すべてのファイル"); + + m +}); + +/// Get current locale +pub fn get_locale() -> Locale { + *CURRENT_LOCALE.read().unwrap() +} + +/// Set current locale +pub fn set_locale(locale: Locale) { + *CURRENT_LOCALE.write().unwrap() = 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 index 162c5b17e36..c90d867cf36 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -1,16 +1,17 @@ -//! Sunshine Tray - Rust implementation of the tray icon library +//! Sunshine Tray - Rust implementation of the system tray //! -//! This library provides a C-compatible API that mirrors the original tray library, -//! but uses the `tray-icon` Rust crate for the underlying implementation. +//! This library provides a complete system tray implementation with: +//! - Multi-language support (Chinese, English, Japanese) +//! - Menu management +//! - Notification support +//! - Icon management #![allow(non_upper_case_globals)] #![allow(non_camel_case_types)] #![allow(non_snake_case)] -#![allow(dead_code)] -mod ffi; - -use ffi::{tray, tray_menu}; +pub mod i18n; +pub mod actions; use std::ffi::CStr; use std::os::raw::{c_char, c_int}; @@ -18,82 +19,93 @@ use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use image::ImageReader; -use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu, CheckMenuItem}; use once_cell::sync::OnceCell; use parking_lot::Mutex; use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; -#[cfg(target_os = "windows")] -mod platform { - pub use windows_sys::Win32::UI::WindowsAndMessaging::{ - DispatchMessageW, GetMessageW, PeekMessageW, PostQuitMessage, TranslateMessage, - MSG, PM_REMOVE, WM_QUIT, - }; -} - -#[cfg(target_os = "linux")] -mod platform { - pub use glib::MainContext; - pub use gtk::prelude::*; -} +use i18n::{StringKey, get_string, set_locale_str}; +use actions::{MenuAction, trigger_action, register_callback, ActionCallback, open_url, urls}; -#[cfg(target_os = "macos")] -mod platform { - pub use objc2_app_kit::NSApplication; - pub use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSRunLoop}; -} +#[cfg(target_os = "windows")] +use windows_sys::Win32::UI::WindowsAndMessaging::{ + DispatchMessageW, GetMessageW, PeekMessageW, PostQuitMessage, TranslateMessage, + MSG, WM_QUIT, PM_REMOVE, +}; -/// Global tray state +/// Tray state +#[allow(dead_code)] // Fields are needed for lifetime management struct TrayState { - /// The tray icon instance icon: TrayIcon, - /// Menu items with their callbacks - menu_items: Vec, - /// Pointer to the original C tray struct (for accessing callbacks) - c_tray: *mut tray, + menu: Menu, + // Menu item IDs for dynamic updates + vdd_toggle_id: MenuId, + // Submenu references for language + config_submenu: Submenu, + language_submenu: Submenu, + help_submenu: Submenu, + // All menu items for rebuilding + menu_items: MenuItems, } -/// Information about a menu item -struct MenuItemInfo { - /// The menu item ID - id: muda::MenuId, - /// Pointer to the original C menu item - c_menu: *const tray_menu, +struct MenuItems { + open_sunshine: MenuItem, + vdd_toggle: CheckMenuItem, + import_config: MenuItem, + export_config: MenuItem, + reset_config: MenuItem, + lang_chinese: MenuItem, + lang_english: MenuItem, + lang_japanese: MenuItem, + star_project: MenuItem, + donate_yundi339: MenuItem, + donate_qiin: MenuItem, + #[cfg(target_os = "windows")] + reset_display: MenuItem, + restart: MenuItem, + quit: MenuItem, } // Safety: TrayState is only accessed from the main thread unsafe impl Send for TrayState {} unsafe impl Sync for TrayState {} -/// Global state storage +/// Global state static TRAY_STATE: OnceCell>> = OnceCell::new(); - -/// Flag to control the event loop static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); -/// Convert a C string pointer to a Rust string slice -unsafe fn c_str_to_str<'a>(ptr: *const c_char) -> Option<&'a str> { +/// 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() + CStr::from_ptr(ptr).to_str().ok().map(|s| s.to_string()) } -/// Load an icon from file path +/// Load icon from file path fn load_icon_from_path(path: &str) -> Option { let path = Path::new(path); - - // Try to load the image + let img = match ImageReader::open(path) { Ok(reader) => match reader.decode() { Ok(img) => img.into_rgba8(), Err(e) => { - log::error!("Failed to decode icon image: {}", e); + eprintln!("Failed to decode icon: {}", e); return None; } }, Err(e) => { - log::error!("Failed to open icon file '{}': {}", path.display(), e); + eprintln!("Failed to open icon '{}': {}", path.display(), e); return None; } }; @@ -101,24 +113,15 @@ fn load_icon_from_path(path: &str) -> Option { let (width, height) = img.dimensions(); let rgba = img.into_raw(); - match Icon::from_rgba(rgba, width, height) { - Ok(icon) => Some(icon), - Err(e) => { - log::error!("Failed to create icon: {}", e); - None - } - } + Icon::from_rgba(rgba, width, height).ok() } #[cfg(target_os = "linux")] fn load_icon_by_name(name: &str) -> Option { - // On Linux, icons are typically loaded by name from the icon theme - // For now, we'll try some common paths let paths = [ format!("/usr/share/icons/hicolor/256x256/apps/{}.png", name), format!("/usr/share/icons/hicolor/128x128/apps/{}.png", name), format!("/usr/share/icons/hicolor/64x64/apps/{}.png", name), - format!("/usr/share/icons/hicolor/48x48/apps/{}.png", name), format!("/usr/share/pixmaps/{}.png", name), ]; @@ -129,19 +132,15 @@ fn load_icon_by_name(name: &str) -> Option { } } } - - log::warn!("Could not find icon by name: {}", name); None } -/// Load icon based on the icon path/name +/// Load icon (handles both path and name) fn load_icon(icon_str: &str) -> Option { - // Check if it's a file path if Path::new(icon_str).exists() { return load_icon_from_path(icon_str); } - // On Linux, it might be an icon name #[cfg(target_os = "linux")] { return load_icon_by_name(icon_str); @@ -149,291 +148,295 @@ fn load_icon(icon_str: &str) -> Option { #[cfg(not(target_os = "linux"))] { - log::error!("Icon not found: {}", icon_str); + eprintln!("Icon not found: {}", icon_str); None } } -/// Build submenu recursively and append items directly to parent -unsafe fn build_submenu_items(submenu: &Submenu, menu_ptr: *const tray_menu, items: &mut Vec) { - if menu_ptr.is_null() { - return; - } - - let mut current = menu_ptr; - while !current.is_null() { - let menu_item = &*current; - - // Check for null terminator - if menu_item.text.is_null() { - break; - } - - let text = c_str_to_str(menu_item.text).unwrap_or(""); - - // Check for separator - if text == "-" { - let _ = submenu.append(&PredefinedMenuItem::separator()); - current = current.add(1); - continue; - } - - // Check for nested submenu - if !menu_item.submenu.is_null() { - let nested_submenu = Submenu::new(text, true); - build_submenu_items(&nested_submenu, menu_item.submenu, items); - let _ = submenu.append(&nested_submenu); - } else { - // Regular menu item - if menu_item.checkbox != 0 { - // Checkbox item - let check_item = muda::CheckMenuItem::new(text, true, menu_item.checked != 0, None); - items.push(MenuItemInfo { - id: check_item.id().clone(), - c_menu: current, - }); - let _ = submenu.append(&check_item); - } else { - // Normal item - let normal_item = MenuItem::new(text, menu_item.disabled == 0, None); - items.push(MenuItemInfo { - id: normal_item.id().clone(), - c_menu: current, - }); - let _ = submenu.append(&normal_item); - } - } - - current = current.add(1); - } -} - -/// Build menu recursively from C tray_menu structure -unsafe fn build_menu(menu_ptr: *const tray_menu) -> (Menu, Vec) { +/// Build the tray menu with current language +fn build_menu() -> (Menu, MenuItems, Submenu, Submenu, Submenu, MenuId) { let menu = Menu::new(); - let mut items = Vec::new(); + + // Open Sunshine + let open_sunshine = MenuItem::new(get_string(StringKey::OpenSunshine), true, None); + let _ = menu.append(&open_sunshine); + + // Separator + let _ = menu.append(&PredefinedMenuItem::separator()); + + // VDD Monitor Toggle (checkbox) + let vdd_toggle = CheckMenuItem::new(get_string(StringKey::VddMonitorToggle), true, false, None); + let vdd_toggle_id = vdd_toggle.id().clone(); + let _ = menu.append(&vdd_toggle); + + // Separator + let _ = menu.append(&PredefinedMenuItem::separator()); + + // Configuration submenu + let import_config = MenuItem::new(get_string(StringKey::ImportConfig), true, None); + let export_config = MenuItem::new(get_string(StringKey::ExportConfig), true, None); + let reset_config = MenuItem::new(get_string(StringKey::ResetToDefault), true, None); + + let config_submenu = Submenu::new(get_string(StringKey::Configuration), true); + let _ = config_submenu.append(&import_config); + let _ = config_submenu.append(&export_config); + let _ = config_submenu.append(&reset_config); + let _ = menu.append(&config_submenu); + + // Separator + let _ = menu.append(&PredefinedMenuItem::separator()); + + // Language submenu + let lang_chinese = MenuItem::new(get_string(StringKey::Chinese), true, None); + let lang_english = MenuItem::new(get_string(StringKey::English), true, None); + let lang_japanese = MenuItem::new(get_string(StringKey::Japanese), true, None); + + let language_submenu = Submenu::new(get_string(StringKey::Language), true); + let _ = language_submenu.append(&lang_chinese); + let _ = language_submenu.append(&lang_english); + let _ = language_submenu.append(&lang_japanese); + let _ = menu.append(&language_submenu); + + // Separator + let _ = menu.append(&PredefinedMenuItem::separator()); + + // Star Project + let star_project = MenuItem::new(get_string(StringKey::StarProject), true, None); + let _ = menu.append(&star_project); + + // Help Us submenu + let donate_yundi339 = MenuItem::new(get_string(StringKey::DeveloperYundi339), true, None); + let donate_qiin = MenuItem::new(get_string(StringKey::DeveloperQiin), true, None); + + let help_submenu = Submenu::new(get_string(StringKey::HelpUs), true); + let _ = help_submenu.append(&donate_yundi339); + let _ = help_submenu.append(&donate_qiin); + let _ = menu.append(&help_submenu); + + // Separator + let _ = menu.append(&PredefinedMenuItem::separator()); + + // Windows-specific: Reset Display Device Config + #[cfg(target_os = "windows")] + let reset_display = { + let item = MenuItem::new(get_string(StringKey::ResetDisplayDeviceConfig), true, None); + let _ = menu.append(&item); + item + }; + + // Restart + let restart = MenuItem::new(get_string(StringKey::Restart), true, None); + let _ = menu.append(&restart); + + // Quit + let quit = MenuItem::new(get_string(StringKey::Quit), true, None); + let _ = menu.append(&quit); + + let menu_items = MenuItems { + open_sunshine, + vdd_toggle, + import_config, + export_config, + reset_config, + lang_chinese, + lang_english, + lang_japanese, + star_project, + donate_yundi339, + donate_qiin, + #[cfg(target_os = "windows")] + reset_display, + restart, + quit, + }; + + (menu, menu_items, config_submenu, language_submenu, help_submenu, vdd_toggle_id) +} - if menu_ptr.is_null() { - return (menu, items); +/// Handle menu events +fn handle_menu_event(event: &MenuEvent, state: &TrayState) { + let items = &state.menu_items; + + if event.id == items.open_sunshine.id() { + trigger_action(MenuAction::OpenUI); + } else if event.id == items.vdd_toggle.id() { + trigger_action(MenuAction::ToggleVddMonitor); + } else if event.id == items.import_config.id() { + trigger_action(MenuAction::ImportConfig); + } else if event.id == items.export_config.id() { + trigger_action(MenuAction::ExportConfig); + } else if event.id == items.reset_config.id() { + trigger_action(MenuAction::ResetConfig); + } else if event.id == items.lang_chinese.id() { + set_locale_str("zh"); + trigger_action(MenuAction::LanguageChinese); + update_menu_texts(); + } else if event.id == items.lang_english.id() { + set_locale_str("en"); + trigger_action(MenuAction::LanguageEnglish); + update_menu_texts(); + } else if event.id == items.lang_japanese.id() { + set_locale_str("ja"); + trigger_action(MenuAction::LanguageJapanese); + update_menu_texts(); + } else if event.id == items.star_project.id() { + open_url(urls::GITHUB_PROJECT); + trigger_action(MenuAction::StarProject); + } else if event.id == items.donate_yundi339.id() { + open_url(urls::DONATE_YUNDI339); + trigger_action(MenuAction::DonateYundi339); + } else if event.id == items.donate_qiin.id() { + open_url(urls::DONATE_QIIN); + trigger_action(MenuAction::DonateQiin); + } else if event.id == items.restart.id() { + trigger_action(MenuAction::Restart); + } else if event.id == items.quit.id() { + trigger_action(MenuAction::Quit); } + #[cfg(target_os = "windows")] + if event.id == items.reset_display.id() { + trigger_action(MenuAction::ResetDisplayDeviceConfig); + } +} - let mut current = menu_ptr; - while !current.is_null() { - let menu_item = &*current; - - // Check for null terminator - if menu_item.text.is_null() { - break; - } - - let text = c_str_to_str(menu_item.text).unwrap_or(""); - - // Check for separator - if text == "-" { - let _ = menu.append(&PredefinedMenuItem::separator()); - current = current.add(1); - continue; - } - - // Check for submenu - if !menu_item.submenu.is_null() { - let submenu = Submenu::new(text, true); - // Build submenu items directly into the submenu - build_submenu_items(&submenu, menu_item.submenu, &mut items); - let _ = menu.append(&submenu); - } else { - // Regular menu item - if menu_item.checkbox != 0 { - // Checkbox item - let check_item = muda::CheckMenuItem::new(text, true, menu_item.checked != 0, None); - items.push(MenuItemInfo { - id: check_item.id().clone(), - c_menu: current, - }); - let _ = menu.append(&check_item); - } else { - // Normal item - let normal_item = MenuItem::new(text, menu_item.disabled == 0, None); - items.push(MenuItemInfo { - id: normal_item.id().clone(), - c_menu: current, - }); - let _ = menu.append(&normal_item); - } +/// Update menu texts after language change +fn update_menu_texts() { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + // Update menu item texts + state.menu_items.open_sunshine.set_text(get_string(StringKey::OpenSunshine)); + state.menu_items.vdd_toggle.set_text(get_string(StringKey::VddMonitorToggle)); + state.menu_items.import_config.set_text(get_string(StringKey::ImportConfig)); + state.menu_items.export_config.set_text(get_string(StringKey::ExportConfig)); + state.menu_items.reset_config.set_text(get_string(StringKey::ResetToDefault)); + state.menu_items.lang_chinese.set_text(get_string(StringKey::Chinese)); + state.menu_items.lang_english.set_text(get_string(StringKey::English)); + state.menu_items.lang_japanese.set_text(get_string(StringKey::Japanese)); + state.menu_items.star_project.set_text(get_string(StringKey::StarProject)); + state.menu_items.donate_yundi339.set_text(get_string(StringKey::DeveloperYundi339)); + state.menu_items.donate_qiin.set_text(get_string(StringKey::DeveloperQiin)); + #[cfg(target_os = "windows")] + state.menu_items.reset_display.set_text(get_string(StringKey::ResetDisplayDeviceConfig)); + state.menu_items.restart.set_text(get_string(StringKey::Restart)); + state.menu_items.quit.set_text(get_string(StringKey::Quit)); + + // Update submenu texts + state.config_submenu.set_text(get_string(StringKey::Configuration)); + state.language_submenu.set_text(get_string(StringKey::Language)); + state.help_submenu.set_text(get_string(StringKey::HelpUs)); } - - current = current.add(1); } - - (menu, items) } -/// Initialize the tray icon -/// -/// # Safety -/// The caller must ensure that `t` points to a valid `tray` structure -/// that remains valid for the lifetime of the tray. +// ============================================================================ +// 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(t: *mut tray) -> c_int { - if t.is_null() { - return -1; +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); } - - // Initialize the global state + + // Register callback + register_callback(callback); + + // Initialize global state let _ = TRAY_STATE.get_or_init(|| Mutex::new(None)); - - #[cfg(target_os = "linux")] - { - // Initialize GTK - if gtk::init().is_err() { - log::error!("Failed to initialize GTK"); - return -1; - } - } - + // Reset exit flag SHOULD_EXIT.store(false, Ordering::SeqCst); - - let tray_ref = &*t; - + // Load icon - let icon = match c_str_to_str(tray_ref.icon) { - Some(icon_path) => match load_icon(icon_path) { - Some(i) => i, - None => { - log::error!("Failed to load tray icon"); - return -1; - } - }, + let icon = match load_icon(&normal) { + Some(i) => i, None => { - log::error!("No icon path provided"); + eprintln!("Failed to load tray icon"); return -1; } }; - + // Get tooltip - let tooltip = c_str_to_str(tray_ref.tooltip); - + let tooltip_str = c_str_to_string(tooltip).unwrap_or_else(|| "Sunshine".to_string()); + // Build menu - let (menu, menu_items) = build_menu(tray_ref.menu); - + let (menu, menu_items, config_submenu, language_submenu, help_submenu, vdd_toggle_id) = build_menu(); + // Create tray icon - let mut builder = TrayIconBuilder::new().with_icon(icon).with_menu(Box::new(menu)); - - if let Some(tip) = tooltip { - builder = builder.with_tooltip(tip); - } - - match builder.build() { - Ok(tray_icon) => { - let state = TrayState { - icon: tray_icon, - menu_items, - c_tray: t, - }; - - if let Some(state_mutex) = TRAY_STATE.get() { - *state_mutex.lock() = Some(state); - } - - 0 - } + 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) => { - log::error!("Failed to create tray icon: {}", e); - -1 + eprintln!("Failed to create tray icon: {}", e); + return -1; } - } -} - -/// Update the tray icon and menu -/// -/// # Safety -/// The caller must ensure that `t` points to a valid `tray` structure. -#[no_mangle] -pub unsafe extern "C" fn tray_update(t: *mut tray) { - if t.is_null() { - return; - } - - let state_mutex = match TRAY_STATE.get() { - Some(s) => s, - None => return, }; - - let mut state_guard = state_mutex.lock(); - let state = match state_guard.as_mut() { - Some(s) => s, - None => return, + + // Store state + let state = TrayState { + icon: tray_icon, + menu, + vdd_toggle_id, + config_submenu, + language_submenu, + help_submenu, + menu_items, }; - - let tray_ref = &*t; - - // Update icon if changed - if let Some(icon_path) = c_str_to_str(tray_ref.icon) { - if let Some(new_icon) = load_icon(icon_path) { - let _ = state.icon.set_icon(Some(new_icon)); - } - } - - // Update tooltip if changed - if let Some(tip) = c_str_to_str(tray_ref.tooltip) { - let _ = state.icon.set_tooltip(Some(tip)); - } - - // Check for notification - if !tray_ref.notification_title.is_null() && !tray_ref.notification_text.is_null() { - let title = c_str_to_str(tray_ref.notification_title).unwrap_or(""); - let text = c_str_to_str(tray_ref.notification_text).unwrap_or(""); - - if !title.is_empty() || !text.is_empty() { - // Show notification - tray-icon doesn't have built-in notification support - // We would need to use a separate notification library here - log::info!("Notification: {} - {}", title, text); - - #[cfg(target_os = "windows")] - { - // On Windows, we can use the tray icon's balloon notification - // This requires accessing the underlying Windows API - // For now, just log - } - - #[cfg(target_os = "linux")] - { - // On Linux, we can use libnotify - // For now, just log - } - } - } - - // Rebuild menu (simpler approach - recreate menu on update) - let (menu, menu_items) = build_menu(tray_ref.menu); - let _ = state.icon.set_menu(Some(Box::new(menu))); - state.menu_items = menu_items; - state.c_tray = t; -} - -/// Process menu events and invoke callbacks -unsafe fn process_menu_event(state: &TrayState, event: &MenuEvent) { - for item_info in &state.menu_items { - if item_info.id == event.id { - let menu_item = &*item_info.c_menu; - if let Some(cb) = menu_item.cb { - // Call the C callback - cb(item_info.c_menu as *mut tray_menu); - } - break; - } + + if let Some(state_mutex) = TRAY_STATE.get() { + *state_mutex.lock() = Some(state); } + + 0 } -/// Run one iteration of the UI loop -/// +/// Run one iteration of the event loop +/// /// # Arguments /// * `blocking` - If non-zero, block until an event is available -/// +/// /// # Returns -/// * 0 on success -/// * -1 if `tray_exit()` was called +/// 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) { @@ -442,18 +445,14 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { #[cfg(target_os = "windows")] { - use platform::*; - unsafe { let mut msg: MSG = std::mem::zeroed(); if blocking != 0 { - // Blocking mode - wait for message if GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) <= 0 { return -1; } } else { - // Non-blocking mode - peek for message if PeekMessageW(&mut msg, std::ptr::null_mut(), 0, 0, PM_REMOVE) == 0 { return 0; } @@ -472,9 +471,7 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { if let Some(state_mutex) = TRAY_STATE.get() { let state_guard = state_mutex.lock(); if let Some(ref state) = *state_guard { - unsafe { - process_menu_event(state, &event); - } + handle_menu_event(&event, state); } } } @@ -488,20 +485,13 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { #[cfg(target_os = "linux")] { - // Process GTK events - if blocking != 0 { - // Process one event, blocking if necessary - while gtk::events_pending() { - gtk::main_iteration(); - } + // GTK event loop + while gtk::events_pending() { + gtk::main_iteration(); + } - // Wait a bit if no events + if blocking != 0 { std::thread::sleep(std::time::Duration::from_millis(100)); - } else { - // Process pending events without blocking - while gtk::events_pending() { - gtk::main_iteration(); - } } // Process menu events @@ -509,9 +499,7 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { if let Some(state_mutex) = TRAY_STATE.get() { let state_guard = state_mutex.lock(); if let Some(ref state) = *state_guard { - unsafe { - process_menu_event(state, &event); - } + handle_menu_event(&event, state); } } } @@ -526,22 +514,14 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { #[cfg(target_os = "macos")] { use objc2::rc::autoreleasepool; - use platform::*; + use objc2_foundation::{NSDate, NSRunLoop}; autoreleasepool(|_| { unsafe { - let app = NSApplication::sharedApplication(); let run_loop = NSRunLoop::currentRunLoop(); - - if blocking != 0 { - // Run until a date in the future (blocking) - let date = NSDate::dateWithTimeIntervalSinceNow(0.1); - run_loop.runUntilDate(&date); - } else { - // Run once without blocking - let date = NSDate::dateWithTimeIntervalSinceNow(0.0); - run_loop.runUntilDate(&date); - } + let interval = if blocking != 0 { 0.1 } else { 0.0 }; + let date = NSDate::dateWithTimeIntervalSinceNow(interval); + run_loop.runUntilDate(&date); } }); @@ -550,9 +530,7 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { if let Some(state_mutex) = TRAY_STATE.get() { let state_guard = state_mutex.lock(); if let Some(ref state) = *state_guard { - unsafe { - process_menu_event(state, &event); - } + handle_menu_event(&event, state); } } } @@ -566,21 +544,18 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] { - log::error!("Unsupported platform"); -1 } } -/// Terminate the UI loop +/// Exit the tray event loop #[no_mangle] pub extern "C" fn tray_exit() { SHOULD_EXIT.store(true, Ordering::SeqCst); #[cfg(target_os = "windows")] - { - unsafe { - platform::PostQuitMessage(0); - } + unsafe { + PostQuitMessage(0); } #[cfg(target_os = "linux")] @@ -590,22 +565,179 @@ pub extern "C" fn tray_exit() { // Clean up state if let Some(state_mutex) = TRAY_STATE.get() { - let mut state_guard = state_mutex.lock(); - *state_guard = None; + *state_mutex.lock() = None; } } -#[cfg(test)] -mod tests { - use super::*; +/// 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) { + 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)); + } + } + } +} - #[test] - fn test_c_str_to_str() { - unsafe { - assert_eq!(c_str_to_str(std::ptr::null()), None); +/// 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 the VDD monitor toggle checkbox state +#[no_mangle] +pub extern "C" fn tray_set_vdd_checked(checked: c_int) { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + state.menu_items.vdd_toggle.set_checked(checked != 0); + } + } +} + +/// Set the VDD toggle menu item enabled state +#[no_mangle] +pub extern "C" fn tray_set_vdd_enabled(enabled: c_int) { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + state.menu_items.vdd_toggle.set_enabled(enabled != 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 notification (placeholder - needs platform-specific implementation) +#[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(); + + // Log for now - proper notification support needs platform-specific implementation + eprintln!("Notification: {} - {}", title_str, text_str); +} + +// ============================================================================ +// Legacy C API compatibility (for existing C++ code) +// ============================================================================ + +/// Legacy tray structure (for compatibility) +#[repr(C)] +pub struct tray { + pub icon: *const c_char, + pub tooltip: *const c_char, + pub notification_icon: *const c_char, + pub notification_text: *const c_char, + pub notification_title: *const c_char, + pub notification_cb: Option, + pub menu: *mut tray_menu, + pub iconPathCount: c_int, + pub allIconPaths: [*const c_char; 4], +} + +/// Legacy tray menu structure (for compatibility) +#[repr(C)] +pub struct tray_menu { + pub text: *const c_char, + pub disabled: c_int, + pub checked: c_int, + pub checkbox: c_int, + pub cb: Option, + pub context: *mut std::ffi::c_void, + pub submenu: *mut tray_menu, +} - let s = std::ffi::CString::new("hello").unwrap(); - assert_eq!(c_str_to_str(s.as_ptr()), Some("hello")); +/// Legacy tray_init - not recommended, use tray_init_ex instead +#[no_mangle] +pub unsafe extern "C" fn tray_init(_tray: *mut tray) -> c_int { + eprintln!("Warning: tray_init is deprecated, use tray_init_ex instead"); + -1 +} + +/// Legacy tray_update - partially supported +#[no_mangle] +pub unsafe extern "C" fn tray_update(t: *mut tray) { + if t.is_null() { + return; + } + + let tray_ref = &*t; + + // Update icon if changed + if !tray_ref.icon.is_null() { + if let Some(icon_str) = c_str_to_string(tray_ref.icon) { + if let Some(icon) = load_icon(&icon_str) { + 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)); + } + } + } + } + } + + // Update tooltip + if !tray_ref.tooltip.is_null() { + if let Some(tip) = c_str_to_string(tray_ref.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)); + } + } + } + } + + // Handle notifications + if !tray_ref.notification_title.is_null() && !tray_ref.notification_text.is_null() { + let title = c_str_to_string(tray_ref.notification_title).unwrap_or_default(); + let text = c_str_to_string(tray_ref.notification_text).unwrap_or_default(); + if !title.is_empty() || !text.is_empty() { + tray_show_notification( + tray_ref.notification_title, + tray_ref.notification_text, + 0, + ); } } } diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp new file mode 100644 index 00000000000..ddd366c84bd --- /dev/null +++ b/src/system_tray_rust.cpp @@ -0,0 +1,336 @@ +/** + * @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 TRAY_ICON WEB_DIR "images/sunshine.ico" + #define TRAY_ICON_PLAYING WEB_DIR "images/sunshine-playing.ico" + #define TRAY_ICON_PAUSING WEB_DIR "images/sunshine-pausing.ico" + #define TRAY_ICON_LOCKED WEB_DIR "images/sunshine-locked.ico" +#elif defined(__linux__) || defined(linux) || defined(__linux) + #define TRAY_ICON "sunshine-tray" + #define TRAY_ICON_PLAYING "sunshine-playing" + #define TRAY_ICON_PAUSING "sunshine-pausing" + #define TRAY_ICON_LOCKED "sunshine-locked" +#elif defined(__APPLE__) || defined(__MACH__) + #define TRAY_ICON WEB_DIR "images/logo-sunshine-16.png" + #define TRAY_ICON_PLAYING WEB_DIR "images/sunshine-playing-16.png" + #define TRAY_ICON_PAUSING WEB_DIR "images/sunshine-pausing-16.png" + #define TRAY_ICON_LOCKED WEB_DIR "images/sunshine-locked-16.png" +#endif + +// Boost includes +#include + +// Local includes +#include "config.h" +#include "confighttp.h" +#include "display_device/session.h" +#include "entry_handler.h" +#include "file_handler.h" +#include "logging.h" +#include "platform/common.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::thread tray_thread; + static std::atomic tray_thread_running = false; + static std::atomic tray_thread_should_exit = false; + + // Forward declarations + static void handle_tray_action(uint32_t action); + + /** + * @brief Handle tray actions from Rust + */ + static void handle_tray_action(uint32_t action) { + switch (action) { + case TRAY_ACTION_OPEN_UI: + BOOST_LOG(debug) << "Opening UI from system tray"sv; + launch_ui(); + break; + + case TRAY_ACTION_TOGGLE_VDD_MONITOR: + BOOST_LOG(info) << "Toggling display power from system tray"sv; + display_device::session_t::get().toggle_display_power(); + // Disable toggle for 10 seconds + tray_set_vdd_enabled(0); + std::thread([]() { + std::this_thread::sleep_for(10s); + tray_set_vdd_enabled(1); + }).detach(); + break; + + case TRAY_ACTION_IMPORT_CONFIG: + BOOST_LOG(info) << "Import config requested"sv; + // Config import is handled in Rust for file dialog + break; + + case TRAY_ACTION_EXPORT_CONFIG: + BOOST_LOG(info) << "Export config requested"sv; + // Config export is handled in Rust for file dialog + break; + + case TRAY_ACTION_RESET_CONFIG: + BOOST_LOG(info) << "Reset config requested"sv; + // Reset config handled in C++ for now + break; + + case TRAY_ACTION_LANGUAGE_CHINESE: + case TRAY_ACTION_LANGUAGE_ENGLISH: + case TRAY_ACTION_LANGUAGE_JAPANESE: { + std::string locale; + switch (action) { + case TRAY_ACTION_LANGUAGE_CHINESE: locale = "zh"; break; + case TRAY_ACTION_LANGUAGE_ENGLISH: locale = "en"; break; + case TRAY_ACTION_LANGUAGE_JAPANESE: locale = "ja"; break; + } + + // Save to config file + try { + auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); + std::stringstream configStream; + vars["tray_locale"] = locale; + for (const auto& [key, value] : vars) { + if (!value.empty() && value != "null") { + configStream << key << " = " << value << std::endl; + } + } + file_handler::write_file(config::sunshine.config_file.c_str(), configStream.str()); + BOOST_LOG(info) << "Tray language setting saved"sv; + } catch (const std::exception& e) { + BOOST_LOG(warning) << "Failed to save tray language: " << e.what(); + } + break; + } + + case TRAY_ACTION_STAR_PROJECT: + // Handled in Rust (opens URL) + BOOST_LOG(debug) << "Star project clicked"sv; + break; + + case TRAY_ACTION_DONATE_YUNDI339: + case TRAY_ACTION_DONATE_QIIN: + // Handled in Rust (opens URL) + BOOST_LOG(debug) << "Donation link clicked"sv; + break; + + case TRAY_ACTION_RESET_DISPLAY_DEVICE_CONFIG: + BOOST_LOG(info) << "Resetting display device config"sv; + display_device::session_t::get().reset_persistence(); + break; + + case TRAY_ACTION_RESTART: + BOOST_LOG(info) << "Restarting from system tray"sv; + platf::restart(); + break; + + case TRAY_ACTION_QUIT: + BOOST_LOG(info) << "Quitting from system tray"sv; +#ifdef _WIN32 + terminate_gui_processes(); + lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); +#else + lifetime::exit_sunshine(0, true); +#endif + break; + + default: + BOOST_LOG(warning) << "Unknown tray action: " << action; + break; + } + } + + void terminate_gui_processes() { +#ifdef _WIN32 + BOOST_LOG(info) << "Terminating sunshine-gui.exe processes..."sv; + + 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) { + BOOST_LOG(info) << "Found sunshine-gui.exe (PID: " << pe32.th32ProcessID << "), terminating..."sv; + HANDLE process_handle = OpenProcess(PROCESS_TERMINATE, FALSE, pe32.th32ProcessID); + if (process_handle != NULL) { + if (TerminateProcess(process_handle, 0)) { + BOOST_LOG(info) << "Successfully terminated sunshine-gui.exe"sv; + } + 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( + TRAY_ICON, + TRAY_ICON_PLAYING, + TRAY_ICON_PAUSING, + TRAY_ICON_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; + } + + BOOST_LOG(info) << "Rust tray initialized successfully"sv; + return 0; + } + + int process_tray_events() { + if (!tray_initialized) { + return -1; + } + return tray_loop(0); // Non-blocking + } + + int end_tray() { + if (!tray_initialized) { + return 0; + } + + tray_exit(); + tray_initialized = false; + + BOOST_LOG(info) << "Rust tray shut down"sv; + return 0; + } + + int init_tray_threaded() { + if (tray_thread_running.exchange(true)) { + BOOST_LOG(warning) << "Tray thread already running"sv; + return 0; + } + + tray_thread_should_exit = false; + + tray_thread = std::thread([]() { + if (init_tray() != 0) { + tray_thread_running = false; + return; + } + + while (!tray_thread_should_exit.load()) { + if (tray_loop(1) < 0) { // Blocking with timeout + break; + } + } + + end_tray(); + tray_thread_running = false; + }); + + return 0; + } + + void update_tray_playing(std::string app_name) { + if (!tray_initialized) return; + + tray_set_icon(TRAY_ICON_PLAYING); + + std::string tooltip = "Sunshine - Playing: " + app_name; + tray_set_tooltip(tooltip.c_str()); + } + + void update_tray_pausing(std::string app_name) { + if (!tray_initialized) return; + + tray_set_icon(TRAY_ICON_PAUSING); + + std::string tooltip = "Sunshine - Paused: " + app_name; + tray_set_tooltip(tooltip.c_str()); + } + + void update_tray_stopped(std::string app_name) { + if (!tray_initialized) return; + + tray_set_icon(TRAY_ICON_NORMAL); + + std::string tooltip = "Sunshine "s + PROJECT_VER; + tray_set_tooltip(tooltip.c_str()); + } + + void update_tray_require_pin(std::string pin_name) { + if (!tray_initialized) return; + + tray_show_notification( + "Sunshine", + ("PIN required for: " + pin_name).c_str(), + TRAY_ICON_NORMAL + ); + } + + void update_tray_vmonitor_checked(int checked) { + if (!tray_initialized) return; + tray_set_vdd_checked(checked); + } + + // Stub implementations for compatibility + std::string get_localized_string(const std::string& key) { + // Localization is handled in Rust + return key; + } + + std::wstring get_localized_wstring(const std::string& key) { + std::string s = get_localized_string(key); + return std::wstring(s.begin(), s.end()); + } + +} // namespace system_tray + +#endif // SUNSHINE_TRAY From 0a8e43408e46a0cc8171054fed277ee3698e4203 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 03:47:50 +0800 Subject: [PATCH 04/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=8F?= =?UTF-8?q?=E4=B8=8E=E6=9E=9A=E4=B8=BE=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/include/rust_tray.h | 8 ++--- src/system_tray_rust.cpp | 60 +++++++++++++++++------------------ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h index 9f340784551..cdd43f92142 100644 --- a/rust_tray/include/rust_tray.h +++ b/rust_tray/include/rust_tray.h @@ -35,10 +35,10 @@ typedef enum { * @brief Icon types for tray_set_icon */ typedef enum { - TRAY_ICON_NORMAL = 0, - TRAY_ICON_PLAYING = 1, - TRAY_ICON_PAUSING = 2, - TRAY_ICON_LOCKED = 3, + TRAY_ICON_TYPE_NORMAL = 0, + TRAY_ICON_TYPE_PLAYING = 1, + TRAY_ICON_TYPE_PAUSING = 2, + TRAY_ICON_TYPE_LOCKED = 3, } TrayIconType; /** diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index ddd366c84bd..76eff4edda4 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -1,7 +1,7 @@ /** * @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. */ @@ -17,20 +17,20 @@ #define WIN32_LEAN_AND_MEAN #include #include - #define TRAY_ICON WEB_DIR "images/sunshine.ico" - #define TRAY_ICON_PLAYING WEB_DIR "images/sunshine-playing.ico" - #define TRAY_ICON_PAUSING WEB_DIR "images/sunshine-pausing.ico" - #define TRAY_ICON_LOCKED WEB_DIR "images/sunshine-locked.ico" + #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 TRAY_ICON "sunshine-tray" - #define TRAY_ICON_PLAYING "sunshine-playing" - #define TRAY_ICON_PAUSING "sunshine-pausing" - #define TRAY_ICON_LOCKED "sunshine-locked" + #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 TRAY_ICON WEB_DIR "images/logo-sunshine-16.png" - #define TRAY_ICON_PLAYING WEB_DIR "images/sunshine-playing-16.png" - #define TRAY_ICON_PAUSING WEB_DIR "images/sunshine-pausing-16.png" - #define TRAY_ICON_LOCKED WEB_DIR "images/sunshine-locked-16.png" + #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 @@ -107,7 +107,7 @@ namespace system_tray { case TRAY_ACTION_LANGUAGE_ENGLISH: locale = "en"; break; case TRAY_ACTION_LANGUAGE_JAPANESE: locale = "ja"; break; } - + // Save to config file try { auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); @@ -213,10 +213,10 @@ namespace system_tray { // Initialize the Rust tray int result = tray_init_ex( - TRAY_ICON, - TRAY_ICON_PLAYING, - TRAY_ICON_PAUSING, - TRAY_ICON_LOCKED, + ICON_PATH_NORMAL, + ICON_PATH_PLAYING, + ICON_PATH_PAUSING, + ICON_PATH_LOCKED, tooltip.c_str(), locale.c_str(), handle_tray_action @@ -246,7 +246,7 @@ namespace system_tray { tray_exit(); tray_initialized = false; - + BOOST_LOG(info) << "Rust tray shut down"sv; return 0; } @@ -280,38 +280,38 @@ namespace system_tray { void update_tray_playing(std::string app_name) { if (!tray_initialized) return; - - tray_set_icon(TRAY_ICON_PLAYING); - + + tray_set_icon(TRAY_ICON_TYPE_PLAYING); + std::string tooltip = "Sunshine - Playing: " + app_name; tray_set_tooltip(tooltip.c_str()); } void update_tray_pausing(std::string app_name) { if (!tray_initialized) return; - - tray_set_icon(TRAY_ICON_PAUSING); - + + tray_set_icon(TRAY_ICON_TYPE_PAUSING); + std::string tooltip = "Sunshine - Paused: " + app_name; tray_set_tooltip(tooltip.c_str()); } void update_tray_stopped(std::string app_name) { if (!tray_initialized) return; - - tray_set_icon(TRAY_ICON_NORMAL); - + + tray_set_icon(TRAY_ICON_TYPE_NORMAL); + std::string tooltip = "Sunshine "s + PROJECT_VER; tray_set_tooltip(tooltip.c_str()); } void update_tray_require_pin(std::string pin_name) { if (!tray_initialized) return; - + tray_show_notification( "Sunshine", ("PIN required for: " + pin_name).c_str(), - TRAY_ICON_NORMAL + TRAY_ICON_TYPE_NORMAL ); } From cfa8b43b23d4154fe959fb7ef64c0f3f33e11b57 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 04:37:41 +0800 Subject: [PATCH 05/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E7=9B=AE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/main.yml | 4 ++++ cmake/targets/rust_tray.cmake | 21 ++++++++++----------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3371caadb05..54f8195bc51 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/targets/rust_tray.cmake b/cmake/targets/rust_tray.cmake index 3458143ce41..6fbabd5ccc8 100644 --- a/cmake/targets/rust_tray.cmake +++ b/cmake/targets/rust_tray.cmake @@ -6,14 +6,9 @@ set(RUST_TARGET_DIR "${CMAKE_BINARY_DIR}/rust_tray") # Determine the Rust target and output filename based on platform if(WIN32) - if(MSVC) - set(RUST_TARGET "x86_64-pc-windows-msvc") - set(RUST_LIB_NAME "tray.lib") - else() - # MinGW - Rust still generates .lib files on Windows - set(RUST_TARGET "x86_64-pc-windows-gnu") - set(RUST_LIB_NAME "tray.lib") - endif() + # 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 @@ -41,8 +36,8 @@ else() set(CARGO_BUILD_FLAGS "--release") endif() -# For default target (no cross-compilation), the path is simpler -set(RUST_OUTPUT_LIB "${RUST_TARGET_DIR}/${RUST_BUILD_TYPE}/${RUST_LIB_NAME}") +# 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) @@ -51,6 +46,7 @@ if(NOT CARGO_EXECUTABLE) 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 @@ -60,6 +56,7 @@ add_custom_command( 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})" @@ -87,7 +84,7 @@ set(RUST_TRAY_LIBRARY ${RUST_OUTPUT_LIB} CACHE FILEPATH "Path to the Rust tray l # Platform-specific dependencies for the Rust library if(WIN32) - # Windows dependencies for tray-icon crate + # MinGW/UCRT dependencies for Rust static library set(RUST_TRAY_PLATFORM_LIBS user32 gdi32 @@ -100,6 +97,8 @@ if(WIN32) ntdll userenv ws2_32 + gcc_s + pthread ) elseif(APPLE) # macOS dependencies for tray-icon crate From 2b5e784120b08cc36eedaece67fe3cde5a2f33b6 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 05:07:26 +0800 Subject: [PATCH 06/36] =?UTF-8?q?fix:=20=E9=9D=99=E6=80=81=E9=93=BE?= =?UTF-8?q?=E6=8E=A5libgcc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmake/targets/rust_tray.cmake | 1 - rust_tray/.cargo/config.toml | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 rust_tray/.cargo/config.toml diff --git a/cmake/targets/rust_tray.cmake b/cmake/targets/rust_tray.cmake index 6fbabd5ccc8..bcc725243d7 100644 --- a/cmake/targets/rust_tray.cmake +++ b/cmake/targets/rust_tray.cmake @@ -97,7 +97,6 @@ if(WIN32) ntdll userenv ws2_32 - gcc_s pthread ) elseif(APPLE) 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"] From 18f3265b41e9e2fadec0bc099c7488a6aee7f758 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:40:37 +0800 Subject: [PATCH 07/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index c90d867cf36..84d78ffda31 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -320,11 +320,15 @@ fn update_menu_texts() { state.menu_items.reset_display.set_text(get_string(StringKey::ResetDisplayDeviceConfig)); state.menu_items.restart.set_text(get_string(StringKey::Restart)); state.menu_items.quit.set_text(get_string(StringKey::Quit)); - + // Update submenu texts state.config_submenu.set_text(get_string(StringKey::Configuration)); state.language_submenu.set_text(get_string(StringKey::Language)); state.help_submenu.set_text(get_string(StringKey::HelpUs)); + + // Re-set the menu on the tray icon to ensure click events work after text update + // This is necessary on Windows where menu updates don't automatically propagate + let _ = state.icon.set_menu(Some(Box::new(state.menu.clone()))); } } } From 40490f5f8caade68a73d185dc99a640c76a54580 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:53:09 +0800 Subject: [PATCH 08/36] =?UTF-8?q?fix:=20Windows=E4=B8=8B=E5=8E=9F=E7=94=9F?= =?UTF-8?q?=E5=8A=A0=E8=BD=BDico=E8=80=8C=E4=B8=8D=E6=98=AF=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/lib.rs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 84d78ffda31..73dad19d2de 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -18,6 +18,7 @@ use std::os::raw::{c_char, c_int}; use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(not(target_os = "windows"))] use image::ImageReader; use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu, CheckMenuItem}; use once_cell::sync::OnceCell; @@ -92,7 +93,25 @@ unsafe fn c_str_to_string(ptr: *const c_char) -> Option { CStr::from_ptr(ptr).to_str().ok().map(|s| s.to_string()) } -/// Load icon from file path +/// Load icon from ICO file path using native Windows API +/// This supports multi-resolution icons - Windows will automatically select +/// the appropriate size based on DPI settings +#[cfg(target_os = "windows")] +fn load_icon_from_path(path: &str) -> Option { + // Use native Windows ICO loading (like ExtractIconEx in C++) + // Passing None for size lets Windows choose based on system DPI + match Icon::from_path(path, None) { + Ok(icon) => Some(icon), + Err(e) => { + eprintln!("Failed to load icon '{}': {}", path, e); + None + } + } +} + +/// Load icon from file path on non-Windows platforms +/// On Linux/macOS, ICO files are decoded using the image crate +#[cfg(not(target_os = "windows"))] fn load_icon_from_path(path: &str) -> Option { let path = Path::new(path); @@ -100,7 +119,7 @@ fn load_icon_from_path(path: &str) -> Option { Ok(reader) => match reader.decode() { Ok(img) => img.into_rgba8(), Err(e) => { - eprintln!("Failed to decode icon: {}", e); + eprintln!("Failed to decode icon '{}': {}", path.display(), e); return None; } }, @@ -135,12 +154,18 @@ fn load_icon_by_name(name: &str) -> Option { None } -/// Load icon (handles both path and name) +/// Load icon +/// +/// On Windows: expects .ico file path (supports multi-resolution) +/// On Linux: can be either a file path or an icon name (searches system dirs) +/// On macOS: expects file path fn load_icon(icon_str: &str) -> Option { + // First, try as a direct file path if Path::new(icon_str).exists() { return load_icon_from_path(icon_str); } + // On Linux, try searching by icon name #[cfg(target_os = "linux")] { return load_icon_by_name(icon_str); From 1338d624af86f3e84b601c9e47904df88e28dbaf Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:00:31 +0800 Subject: [PATCH 09/36] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/plan.md | 256 ++++++++++++---------------------------------- 1 file changed, 63 insertions(+), 193 deletions(-) diff --git a/rust_tray/plan.md b/rust_tray/plan.md index 3b4439403ce..eab1012dde4 100644 --- a/rust_tray/plan.md +++ b/rust_tray/plan.md @@ -1,193 +1,63 @@ -# 系统托盘替换方案 - -## 目标 -将现有的基于 C 库 `tray` 的系统托盘实现替换为 Rust 的 `tray-icon` 库,以提高可维护性并减少跨平台适配代码。 - -## 总体方案 -1. 在项目根目录下创建 Rust 子目录 `rust_tray`,编写静态库实现与原 `tray` 库完全兼容的 C API(`tray_init`、`tray_update`、`tray_loop`、`tray_exit`)。 -2. 使用 `bindgen` 从原 `third-party/tray/src/tray.h` 生成 Rust 绑定,保证结构体布局一致。 -3. 修改 CMake 构建系统,移除对原 `tray` 库的编译,改为构建并链接 Rust 静态库。 -4. 保留 `third-party/tray` 子模块中的头文件,确保 C++ 代码 `#include "tray/src/tray.h"` 仍然有效。 -5. 不修改 `src/system_tray.cpp` 的业务逻辑,仅替换底层库的实现。 - -## 详细步骤 - -### 1. 创建 Rust 项目 -在 `rust_tray/` 下初始化 Cargo 库: - -``` -rust_tray/ -├── Cargo.toml -├── build.rs -└── src/ - └── lib.rs -``` - -**Cargo.toml 内容:** - -```toml -[package] -name = "sunshine_tray" -version = "0.1.0" -edition = "2021" - -[lib] -name = "tray" -crate-type = ["staticlib"] - -[dependencies] -tray-icon = { version = "0.10", default-features = false, features = ["tray"] } -anyhow = "1.0" -lazy_static = "1.4" -libc = "0.2" -log = "0.4" - -[build-dependencies] -bindgen = "0.69" -``` - -### 2. 生成 FFI 绑定(build.rs) -利用 `bindgen` 解析原头文件,生成与 C 完全一致的 Rust 结构体定义。 - -```rust -// build.rs -use std::env; -use std::path::PathBuf; - -fn main() { - let bindings = bindgen::Builder::default() - .header("third-party/tray/src/tray.h") - .parse_callbacks(Box::new(bindgen::CargoCallbacks)) - .generate() - .expect("Unable to generate bindings"); - - let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); - bindings - .write_to_file(out_path.join("bindings.rs")) - .expect("Couldn't write bindings"); -} -``` - -### 3. 实现 C API(lib.rs) - -主要任务: - -- 全局状态管理(`OnceLock>>`),存储 `TrayIcon` 实例及菜单项到 C 菜单的映射。 -- `tray_init`:根据传入的 `tray` 结构体创建托盘图标和菜单,注册回调(调用 C 回调)。 -- `tray_update`:更新图标、工具提示、菜单文本、勾选状态等;若设置了 `notification_*` 字段,显示通知。 -- `tray_loop`:启动平台事件循环(例如 GTK 的 `main` 或 Windows 消息循环),阻塞直到 `tray_exit` 被调用。 -- `tray_exit`:退出事件循环,清理资源。 - -关键代码框架: - -```rust -include!(concat!(env!("OUT_DIR"), "/bindings.rs")); - -use std::ffi::{CStr, CString}; -use std::os::raw::{c_char, c_int, c_void}; -use std::sync::{Mutex, OnceLock}; -use tray_icon::{TrayIcon, TrayIconBuilder, Menu, MenuItem, MenuId, TrayIconEvent}; -use anyhow::Result; - -struct TrayState { - icon: TrayIcon, - menu_map: Vec<(MenuId, *const tray_menu)>, // 用于回调查找 - // 事件循环句柄(例如 gtk::Application 或 winit 事件循环) - event_loop: Option<...>, -} - -static TRAY_STATE: OnceLock>> = OnceLock::new(); - -#[no_mangle] -pub extern "C" fn tray_init(t: *mut tray) -> c_int { - // 错误处理返回 -1,成功 0 - match unsafe { do_init(t) } { - Ok(_) => 0, - Err(_) => -1, - } -} - -unsafe fn do_init(t: *mut tray) -> Result<()> { - // 构建菜单(递归) - let (menu, menu_map) = build_menu((*t).menu)?; - - // 加载图标 - let icon = load_icon(CStr::from_ptr((*t).icon).to_str()?)?; - - let builder = TrayIconBuilder::new() - .with_icon(icon) - .with_tooltip(CStr::from_ptr((*t).tooltip).to_str()?) - .with_menu(Box::new(menu)); - - let tray_icon = builder.build()?; - - // 存储状态 - let state = TrayState { - icon: tray_icon, - menu_map, - event_loop: None, - }; - TRAY_STATE.get_or_init(|| Mutex::new(None)) - .lock() - .unwrap() - .replace(state); - - Ok(()) -} - -// 其他函数类似实现 -``` - -菜单构建时需递归处理子菜单,并为每个 `MenuItem` 设置回调:当用户点击时,根据 `MenuId` 从 `menu_map` 中找到对应的 C `tray_menu` 指针,调用其 `cb` 字段(若存在)。 - -### 4. 修改 CMake 构建 - -编辑 `cmake/targets/common.cmake`,注释或删除对原 `tray` 库的引用,替换为自定义命令构建 Rust 库。 - -```cmake -# 禁用原 tray 库 -# add_subdirectory(third-party/tray) - -# 添加 Rust 托盘库构建 -set(RUST_TRAY_SOURCE_DIR "${CMAKE_SOURCE_DIR}/rust_tray") -set(RUST_TARGET_DIR "${CMAKE_BINARY_DIR}/rust_tray") -set(RUST_OUTPUT_DEBUG "${RUST_TARGET_DIR}/debug/libtray.a") -# Release 构建可根据需要选择 -add_custom_command( - OUTPUT ${RUST_OUTPUT_DEBUG} - COMMAND ${CMAKE_COMMAND} -E env CARGO_TARGET_DIR=${RUST_TARGET_DIR} cargo build --manifest-path ${RUST_TRAY_SOURCE_DIR}/Cargo.toml - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - COMMENT "Building Rust Tray library (debug)" - VERBATIM -) -add_custom_target(rust_tray ALL DEPENDS ${RUST_OUTPUT_DEBUG}) - -# 链接静态库 -target_link_libraries(sunshine PRIVATE ${RUST_OUTPUT_DEBUG}) -target_include_directories(sunshine PRIVATE third-party/tray/src) -``` - -注意:根据实际构建类型(Debug/Release)调整 cargo 参数和输出路径,也可同时构建 Release 版本并通过 CMake 变量选择。 - -### 5. 验证与测试 - -编译 Sunshine,确保无链接错误。运行后验证: -- 系统托盘图标显示正常。 -- 菜单点击功能正常(打开 UI、开关显示器、导入导出配置、语言切换等)。 -- 通知正常显示(例如开始/暂停串流、配对请求)。 -- 图标切换(播放、暂停、锁定)正常。 - -## 可能的问题及应对 - -- **回调中 C 字符串的生命周期**:`tray_menu.text` 在语言切换时会指向新的 `std::string` 内部数据,而 Rust 在 `tray_update` 时会重新拷贝字符串,因此安全。 -- **事件循环集成**:不同平台事件循环实现方式不同,需确保 `tray_loop` 阻塞且能正确响应退出。可参考 `tray-icon` 示例中的事件循环代码(如 winit、gtk)。 -- **Windows 系统权限问题**:原 `system_tray.cpp` 中的线程 DACL 修改和等待 Shell 的代码保留,不影响 Rust 库。 -- **跨平台图标格式**:原代码已通过宏区分各平台图标路径/名称,直接传递给 `tray-icon` 即可,该库会自动处理。 - -## 后续工作 - -- 移除 `third-party/tray` 中除头文件外的源文件(可选,但保留子模块可方便获取头文件)。 -- 完善 Rust 实现中的错误处理和日志记录。 -- 如有必要,在 CI 中增加 Rust 工具链安装步骤。 - -本方案通过最小侵入式修改实现了核心功能替换,可显著降低维护成本并提高跨平台稳定性。 \ No newline at end of file +# 系统托盘替换方案(已更新:大部分逻辑迁移至 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 From 96a3769e8606a67272a2fde165c49e4f412fc704 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:38:21 +0800 Subject: [PATCH 10/36] =?UTF-8?q?fix:=E5=86=8D=E6=AC=A1=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/lib.rs | 55 ++++++++++++++++++++++---------------------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 73dad19d2de..505fa7c1afd 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -324,36 +324,35 @@ fn handle_menu_event(event: &MenuEvent, state: &TrayState) { } } -/// Update menu texts after language change +/// 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() { if let Some(state_mutex) = TRAY_STATE.get() { - let state_guard = state_mutex.lock(); - if let Some(ref state) = *state_guard { - // Update menu item texts - state.menu_items.open_sunshine.set_text(get_string(StringKey::OpenSunshine)); - state.menu_items.vdd_toggle.set_text(get_string(StringKey::VddMonitorToggle)); - state.menu_items.import_config.set_text(get_string(StringKey::ImportConfig)); - state.menu_items.export_config.set_text(get_string(StringKey::ExportConfig)); - state.menu_items.reset_config.set_text(get_string(StringKey::ResetToDefault)); - state.menu_items.lang_chinese.set_text(get_string(StringKey::Chinese)); - state.menu_items.lang_english.set_text(get_string(StringKey::English)); - state.menu_items.lang_japanese.set_text(get_string(StringKey::Japanese)); - state.menu_items.star_project.set_text(get_string(StringKey::StarProject)); - state.menu_items.donate_yundi339.set_text(get_string(StringKey::DeveloperYundi339)); - state.menu_items.donate_qiin.set_text(get_string(StringKey::DeveloperQiin)); - #[cfg(target_os = "windows")] - state.menu_items.reset_display.set_text(get_string(StringKey::ResetDisplayDeviceConfig)); - state.menu_items.restart.set_text(get_string(StringKey::Restart)); - state.menu_items.quit.set_text(get_string(StringKey::Quit)); - - // Update submenu texts - state.config_submenu.set_text(get_string(StringKey::Configuration)); - state.language_submenu.set_text(get_string(StringKey::Language)); - state.help_submenu.set_text(get_string(StringKey::HelpUs)); - - // Re-set the menu on the tray icon to ensure click events work after text update - // This is necessary on Windows where menu updates don't automatically propagate - let _ = state.icon.set_menu(Some(Box::new(state.menu.clone()))); + let mut state_guard = state_mutex.lock(); + if let Some(ref mut state) = *state_guard { + // Get the current VDD toggle state before rebuilding + let vdd_checked = state.menu_items.vdd_toggle.is_checked(); + + // Build a completely new menu with the updated language + let (new_menu, new_menu_items, new_config_submenu, new_language_submenu, new_help_submenu, new_vdd_toggle_id) = build_menu(); + + // Restore the VDD toggle state + new_menu_items.vdd_toggle.set_checked(vdd_checked); + + // Set the new menu on the tray icon + // This properly detaches the old menu subclass and attaches the new one + let _ = state.icon.set_menu(Some(Box::new(new_menu.clone()))); + + // Update the state with the new menu and items + state.menu = new_menu; + state.menu_items = new_menu_items; + state.config_submenu = new_config_submenu; + state.language_submenu = new_language_submenu; + state.help_submenu = new_help_submenu; + state.vdd_toggle_id = new_vdd_toggle_id; } } } From 8f7c9e9a26e01398298e8aa746bbfdca605bee89 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 15:37:25 +0800 Subject: [PATCH 11/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=98?= =?UTF-8?q?=E7=9B=98=E9=80=80=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/system_tray_rust.cpp | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index 76eff4edda4..cb57f372bb1 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -151,7 +151,11 @@ namespace system_tray { BOOST_LOG(info) << "Quitting from system tray"sv; #ifdef _WIN32 terminate_gui_processes(); - lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); + if (GetConsoleWindow() == NULL) { + lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); + } else { + lifetime::exit_sunshine(0, true); + } #else lifetime::exit_sunshine(0, true); #endif From f6af9ceb9a47681e2edacf723493a589a1db7b39 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:26:24 +0800 Subject: [PATCH 12/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=98?= =?UTF-8?q?=E7=9B=98=E8=8F=9C=E5=8D=95=E6=AD=BB=E9=94=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将菜单事件处理拆分为 identify_menu_action 和 execute_action 两步 - 先释放 mutex 锁再执行实际操作,避免切换语言、重启、退出时死锁 --- rust_tray/Cargo.toml | 1 + rust_tray/src/config.rs | 440 +++++++++++++++++++++++++++++++++++++++ rust_tray/src/lib.rs | 201 +++++++++++++----- src/system_tray_rust.cpp | 38 +--- 4 files changed, 604 insertions(+), 76 deletions(-) create mode 100644 rust_tray/src/config.rs diff --git a/rust_tray/Cargo.toml b/rust_tray/Cargo.toml index d3bbf023857..3f5991e7fdd 100644 --- a/rust_tray/Cargo.toml +++ b/rust_tray/Cargo.toml @@ -23,6 +23,7 @@ windows-sys = { version = "0.59", features = [ "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader", "Win32_UI_Shell", + "Win32_UI_Controls_Dialogs", ] } [target.'cfg(target_os = "linux")'.dependencies] diff --git a/rust_tray/src/config.rs b/rust_tray/src/config.rs new file mode 100644 index 00000000000..ab8c4a00cab --- /dev/null +++ b/rust_tray/src/config.rs @@ -0,0 +1,440 @@ +//! Configuration file operations module +//! +//! Provides functionality for reading, writing, importing, exporting, +//! and resetting the Sunshine configuration file. + +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use crate::i18n::{get_string, StringKey}; + +#[cfg(target_os = "windows")] +use std::ffi::OsStr; +#[cfg(target_os = "windows")] +use std::os::windows::ffi::OsStrExt; + +/// Result type for config operations +pub type ConfigResult = Result; + +/// Error types for configuration operations +#[derive(Debug, Clone)] +pub enum ConfigError { + PathNotFound, + ReadFailed(String), + WriteFailed(String), + NoConfigFound, + DialogCancelled, + NoUserSession, +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigError::PathNotFound => write!(f, "Configuration path not found"), + ConfigError::ReadFailed(msg) => write!(f, "Read failed: {}", msg), + ConfigError::WriteFailed(msg) => write!(f, "Write failed: {}", msg), + ConfigError::NoConfigFound => write!(f, "No configuration found"), + ConfigError::DialogCancelled => write!(f, "Dialog cancelled"), + ConfigError::NoUserSession => write!(f, "No active user session"), + } + } +} + +/// Get the Sunshine configuration file path +/// +/// On Windows: %PROGRAMDATA%/Sunshine/config/sunshine.conf +/// On Linux: ~/.config/sunshine/sunshine.conf or /etc/sunshine/sunshine.conf +/// On macOS: ~/Library/Application Support/Sunshine/config/sunshine.conf +#[cfg(target_os = "windows")] +pub fn get_config_file_path() -> Option { + std::env::var("PROGRAMDATA") + .ok() + .map(|data| PathBuf::from(data).join("Sunshine").join("config").join("sunshine.conf")) + .filter(|p| p.exists()) +} + +#[cfg(target_os = "linux")] +pub fn get_config_file_path() -> Option { + // Try user config first + if let Some(home) = std::env::var("HOME").ok() { + let user_config = PathBuf::from(home).join(".config/sunshine/sunshine.conf"); + if user_config.exists() { + return Some(user_config); + } + } + // Fallback to system config + let system_config = PathBuf::from("/etc/sunshine/sunshine.conf"); + if system_config.exists() { + return Some(system_config); + } + None +} + +#[cfg(target_os = "macos")] +pub fn get_config_file_path() -> Option { + if let Some(home) = std::env::var("HOME").ok() { + let config = PathBuf::from(home) + .join("Library/Application Support/Sunshine/config/sunshine.conf"); + if config.exists() { + return Some(config); + } + } + None +} + +/// Parse configuration file content into a key-value map +pub fn parse_config(content: &str) -> HashMap { + let mut vars = HashMap::new(); + + for line in content.lines() { + let trimmed = line.trim(); + + // Skip empty lines and comments + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + // Parse key = value + if let Some(pos) = trimmed.find('=') { + let key = trimmed[..pos].trim().to_string(); + let value = trimmed[pos + 1..].trim().to_string(); + if !key.is_empty() { + vars.insert(key, value); + } + } + } + + vars +} + +/// Write configuration map to string +pub fn serialize_config(vars: &HashMap) -> String { + let mut config_str = String::new(); + + for (key, value) in vars { + if !value.is_empty() && value != "null" { + config_str.push_str(&format!("{} = {}\n", key, value)); + } + } + + config_str +} + +/// Read the configuration file +pub fn read_config() -> ConfigResult> { + let path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; + + let content = fs::read_to_string(&path) + .map_err(|e| ConfigError::ReadFailed(e.to_string()))?; + + Ok(parse_config(&content)) +} + +/// Write configuration to file +pub fn write_config(vars: &HashMap) -> ConfigResult<()> { + let path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; + + let content = serialize_config(vars); + + fs::write(&path, content) + .map_err(|e| ConfigError::WriteFailed(e.to_string())) +} + +/// Save a single configuration value +pub fn save_config_value(key: &str, value: &str) -> ConfigResult<()> { + let mut vars = read_config().unwrap_or_default(); + vars.insert(key.to_string(), value.to_string()); + write_config(&vars) +} + +/// Show message box (Windows) +#[cfg(target_os = "windows")] +pub fn show_message_box(title: &str, message: &str, is_error: bool) { + use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONERROR, MB_ICONINFORMATION}; + + 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(); + + let icon = if is_error { MB_ICONERROR } else { MB_ICONINFORMATION }; + + unsafe { + MessageBoxW( + std::ptr::null_mut(), + wide_message.as_ptr(), + wide_title.as_ptr(), + MB_OK | icon, + ); + } +} + +/// Show message box (non-Windows - just log for now) +#[cfg(not(target_os = "windows"))] +pub fn show_message_box(title: &str, message: &str, _is_error: bool) { + eprintln!("[{}] {}", title, message); +} + +/// Show confirmation dialog (Windows) +#[cfg(target_os = "windows")] +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 + } +} + +/// Show confirmation dialog (non-Windows) +#[cfg(not(target_os = "windows"))] +pub fn show_confirm_dialog(title: &str, message: &str) -> bool { + eprintln!("[{}] {}", title, message); + // On non-Windows, default to yes for now + true +} + +/// Open file dialog for importing config (Windows) +#[cfg(target_os = "windows")] +pub fn open_import_dialog() -> ConfigResult { + use windows_sys::Win32::UI::Controls::Dialogs::{ + GetOpenFileNameW, OPENFILENAMEW, OFN_EXPLORER, OFN_FILEMUSTEXIST, OFN_PATHMUSTEXIST, + }; + + let mut file_name: [u16; 260] = [0; 260]; + + // Build filter string: "Config Files (*.conf)\0*.conf\0All Files (*.*)\0*.*\0\0" + let filter_label1 = get_string(StringKey::FileDialogConfigFiles); + let filter_label2 = get_string(StringKey::FileDialogAllFiles); + let filter = format!("{} (*.conf)\0*.conf\0{} (*.*)\0*.*\0\0", filter_label1, filter_label2); + let filter_wide: Vec = filter.encode_utf16().collect(); + + let title = get_string(StringKey::FileDialogSelectImport); + let title_wide: Vec = OsStr::new(title) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut ofn: OPENFILENAMEW = unsafe { std::mem::zeroed() }; + ofn.lStructSize = std::mem::size_of::() as u32; + ofn.lpstrFilter = filter_wide.as_ptr(); + ofn.lpstrFile = file_name.as_mut_ptr(); + ofn.nMaxFile = 260; + ofn.lpstrTitle = title_wide.as_ptr(); + ofn.Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST; + + unsafe { + if GetOpenFileNameW(&mut ofn) != 0 { + let len = file_name.iter().position(|&c| c == 0).unwrap_or(260); + let path_str = String::from_utf16_lossy(&file_name[..len]); + Ok(PathBuf::from(path_str)) + } else { + Err(ConfigError::DialogCancelled) + } + } +} + +/// Open file dialog for exporting config (Windows) +#[cfg(target_os = "windows")] +pub fn open_export_dialog() -> ConfigResult { + use windows_sys::Win32::UI::Controls::Dialogs::{ + GetSaveFileNameW, OPENFILENAMEW, OFN_EXPLORER, OFN_OVERWRITEPROMPT, + }; + + let mut file_name: [u16; 260] = [0; 260]; + + // Default filename + let default_name = "sunshine_backup.conf"; + for (i, c) in default_name.encode_utf16().enumerate() { + if i < 259 { + file_name[i] = c; + } + } + + // Build filter string + let filter_label1 = get_string(StringKey::FileDialogConfigFiles); + let filter_label2 = get_string(StringKey::FileDialogAllFiles); + let filter = format!("{} (*.conf)\0*.conf\0{} (*.*)\0*.*\0\0", filter_label1, filter_label2); + let filter_wide: Vec = filter.encode_utf16().collect(); + + let title = get_string(StringKey::FileDialogSaveExport); + let title_wide: Vec = OsStr::new(title) + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + let mut ofn: OPENFILENAMEW = unsafe { std::mem::zeroed() }; + ofn.lStructSize = std::mem::size_of::() as u32; + ofn.lpstrFilter = filter_wide.as_ptr(); + ofn.lpstrFile = file_name.as_mut_ptr(); + ofn.nMaxFile = 260; + ofn.lpstrTitle = title_wide.as_ptr(); + ofn.Flags = OFN_EXPLORER | OFN_OVERWRITEPROMPT; + + unsafe { + if GetSaveFileNameW(&mut ofn) != 0 { + let len = file_name.iter().position(|&c| c == 0).unwrap_or(260); + let path_str = String::from_utf16_lossy(&file_name[..len]); + let mut path = PathBuf::from(path_str); + + // Ensure .conf extension + if path.extension().map_or(true, |ext| ext != "conf") { + path.set_extension("conf"); + } + + Ok(path) + } else { + Err(ConfigError::DialogCancelled) + } + } +} + +/// Open file dialog for importing config (non-Windows placeholder) +#[cfg(not(target_os = "windows"))] +pub fn open_import_dialog() -> ConfigResult { + // For non-Windows, use a simple approach or external crate + eprintln!("File dialog not implemented for this platform"); + Err(ConfigError::NoUserSession) +} + +/// Open file dialog for exporting config (non-Windows placeholder) +#[cfg(not(target_os = "windows"))] +pub fn open_export_dialog() -> ConfigResult { + eprintln!("File dialog not implemented for this platform"); + Err(ConfigError::NoUserSession) +} + +/// Import configuration from file +pub fn import_config() -> ConfigResult<()> { + // Open file dialog + let source_path = open_import_dialog()?; + + // Read source file + let content = fs::read_to_string(&source_path) + .map_err(|e| ConfigError::ReadFailed(e.to_string()))?; + + // Get destination path + let dest_path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; + + // Write to config file + fs::write(&dest_path, content) + .map_err(|e| ConfigError::WriteFailed(e.to_string()))?; + + show_message_box( + get_string(StringKey::ImportSuccessTitle), + get_string(StringKey::ImportSuccessMsg), + false, + ); + + Ok(()) +} + +/// Export configuration to file +pub fn export_config() -> ConfigResult<()> { + // Get source config path + let source_path = get_config_file_path().ok_or(ConfigError::NoConfigFound)?; + + // Read current config + let content = fs::read_to_string(&source_path) + .map_err(|e| ConfigError::ReadFailed(e.to_string()))?; + + // Open save dialog + let dest_path = open_export_dialog()?; + + // Write to destination + fs::write(&dest_path, content) + .map_err(|e| ConfigError::WriteFailed(e.to_string()))?; + + show_message_box( + get_string(StringKey::ExportSuccessTitle), + get_string(StringKey::ExportSuccessMsg), + false, + ); + + Ok(()) +} + +/// Reset configuration to default +pub fn reset_config() -> ConfigResult<()> { + // Show confirmation dialog + if !show_confirm_dialog( + get_string(StringKey::ResetConfirmTitle), + get_string(StringKey::ResetConfirmMsg), + ) { + return Err(ConfigError::DialogCancelled); + } + + // Get config path + let config_path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; + + // Create empty config (or minimal default) + let default_config = "# Sunshine Configuration\n# Reset to default\n"; + + fs::write(&config_path, default_config) + .map_err(|e| ConfigError::WriteFailed(e.to_string()))?; + + show_message_box( + get_string(StringKey::ResetSuccessTitle), + get_string(StringKey::ResetSuccessMsg), + false, + ); + + Ok(()) +} + +/// Save tray locale to configuration file +pub fn save_tray_locale(locale: &str) -> ConfigResult<()> { + save_config_value("tray_locale", locale) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_config() { + let content = r#" +# Comment line +key1 = value1 +key2 = value2 + +key3=value3 +"#; + let vars = parse_config(content); + assert_eq!(vars.get("key1"), Some(&"value1".to_string())); + assert_eq!(vars.get("key2"), Some(&"value2".to_string())); + assert_eq!(vars.get("key3"), Some(&"value3".to_string())); + } + + #[test] + fn test_serialize_config() { + let mut vars = HashMap::new(); + vars.insert("key1".to_string(), "value1".to_string()); + vars.insert("key2".to_string(), "value2".to_string()); + + let result = serialize_config(&vars); + assert!(result.contains("key1 = value1")); + assert!(result.contains("key2 = value2")); + } +} diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 505fa7c1afd..710a1493ce6 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -12,6 +12,7 @@ pub mod i18n; pub mod actions; +pub mod config; use std::ffi::CStr; use std::os::raw::{c_char, c_int}; @@ -278,49 +279,168 @@ fn build_menu() -> (Menu, MenuItems, Submenu, Submenu, Submenu, MenuId) { (menu, menu_items, config_submenu, language_submenu, help_submenu, vdd_toggle_id) } -/// Handle menu events -fn handle_menu_event(event: &MenuEvent, state: &TrayState) { +/// Identify which action corresponds to the menu event +/// Returns the action to perform (if any) and whether menu rebuild is needed +fn identify_menu_action(event: &MenuEvent, state: &TrayState) -> (Option, bool) { let items = &state.menu_items; if event.id == items.open_sunshine.id() { - trigger_action(MenuAction::OpenUI); + (Some(MenuAction::OpenUI), false) } else if event.id == items.vdd_toggle.id() { - trigger_action(MenuAction::ToggleVddMonitor); + (Some(MenuAction::ToggleVddMonitor), false) } else if event.id == items.import_config.id() { - trigger_action(MenuAction::ImportConfig); + (Some(MenuAction::ImportConfig), false) } else if event.id == items.export_config.id() { - trigger_action(MenuAction::ExportConfig); + (Some(MenuAction::ExportConfig), false) } else if event.id == items.reset_config.id() { - trigger_action(MenuAction::ResetConfig); + (Some(MenuAction::ResetConfig), false) } else if event.id == items.lang_chinese.id() { - set_locale_str("zh"); - trigger_action(MenuAction::LanguageChinese); - update_menu_texts(); + (Some(MenuAction::LanguageChinese), true) } else if event.id == items.lang_english.id() { - set_locale_str("en"); - trigger_action(MenuAction::LanguageEnglish); - update_menu_texts(); + (Some(MenuAction::LanguageEnglish), true) } else if event.id == items.lang_japanese.id() { - set_locale_str("ja"); - trigger_action(MenuAction::LanguageJapanese); - update_menu_texts(); + (Some(MenuAction::LanguageJapanese), true) } else if event.id == items.star_project.id() { - open_url(urls::GITHUB_PROJECT); - trigger_action(MenuAction::StarProject); + (Some(MenuAction::StarProject), false) } else if event.id == items.donate_yundi339.id() { - open_url(urls::DONATE_YUNDI339); - trigger_action(MenuAction::DonateYundi339); + (Some(MenuAction::DonateYundi339), false) } else if event.id == items.donate_qiin.id() { - open_url(urls::DONATE_QIIN); - trigger_action(MenuAction::DonateQiin); + (Some(MenuAction::DonateQiin), false) } else if event.id == items.restart.id() { - trigger_action(MenuAction::Restart); + (Some(MenuAction::Restart), false) } else if event.id == items.quit.id() { - trigger_action(MenuAction::Quit); + (Some(MenuAction::Quit), false) + } else { + #[cfg(target_os = "windows")] + if event.id == items.reset_display.id() { + return (Some(MenuAction::ResetDisplayDeviceConfig), false); + } + (None, false) } - #[cfg(target_os = "windows")] - if event.id == items.reset_display.id() { - trigger_action(MenuAction::ResetDisplayDeviceConfig); +} + +/// Execute the identified action +/// This is called AFTER releasing the state lock to avoid deadlocks +fn execute_action(action: MenuAction, needs_menu_rebuild: bool) { + match action { + MenuAction::ImportConfig => { + // Handle config import in Rust + std::thread::spawn(|| { + if let Err(e) = config::import_config() { + match e { + config::ConfigError::DialogCancelled => {} + _ => { + config::show_message_box( + i18n::get_string(i18n::StringKey::ImportErrorTitle), + &format!("{}", e), + true, + ); + } + } + } + }); + trigger_action(action); + } + MenuAction::ExportConfig => { + // Handle config export in Rust + std::thread::spawn(|| { + if let Err(e) = config::export_config() { + match e { + config::ConfigError::DialogCancelled => {} + _ => { + config::show_message_box( + i18n::get_string(i18n::StringKey::ExportErrorTitle), + &format!("{}", e), + true, + ); + } + } + } + }); + trigger_action(action); + } + MenuAction::ResetConfig => { + // Handle config reset in Rust + std::thread::spawn(|| { + if let Err(e) = config::reset_config() { + match e { + config::ConfigError::DialogCancelled => {} + _ => { + config::show_message_box( + i18n::get_string(i18n::StringKey::ResetErrorTitle), + &format!("{}", e), + true, + ); + } + } + } + }); + trigger_action(action); + } + MenuAction::LanguageChinese => { + set_locale_str("zh"); + let _ = config::save_tray_locale("zh"); + trigger_action(action); + if needs_menu_rebuild { + update_menu_texts(); + } + } + MenuAction::LanguageEnglish => { + set_locale_str("en"); + let _ = config::save_tray_locale("en"); + trigger_action(action); + if needs_menu_rebuild { + update_menu_texts(); + } + } + MenuAction::LanguageJapanese => { + set_locale_str("ja"); + let _ = config::save_tray_locale("ja"); + trigger_action(action); + if needs_menu_rebuild { + update_menu_texts(); + } + } + MenuAction::StarProject => { + open_url(urls::GITHUB_PROJECT); + trigger_action(action); + } + MenuAction::DonateYundi339 => { + open_url(urls::DONATE_YUNDI339); + trigger_action(action); + } + MenuAction::DonateQiin => { + open_url(urls::DONATE_QIIN); + trigger_action(action); + } + _ => { + // For all other actions, just trigger the callback + trigger_action(action); + } + } +} + +/// Process a menu event - identifies the action while holding the lock, +/// then releases the lock before executing to avoid deadlocks +fn process_menu_event(event: &MenuEvent) { + let (action, needs_rebuild) = { + // Hold lock only while identifying the action + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + identify_menu_action(event, state) + } else { + (None, false) + } + } else { + (None, false) + } + // Lock is released here + }; + + // Execute action without holding the lock + if let Some(action) = action { + execute_action(action, needs_rebuild); } } @@ -494,14 +614,9 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { DispatchMessageW(&msg); } - // Process menu events + // Process menu events - use process_menu_event to avoid deadlocks if let Ok(event) = MenuEvent::receiver().try_recv() { - if let Some(state_mutex) = TRAY_STATE.get() { - let state_guard = state_mutex.lock(); - if let Some(ref state) = *state_guard { - handle_menu_event(&event, state); - } - } + process_menu_event(&event); } if SHOULD_EXIT.load(Ordering::SeqCst) { @@ -522,14 +637,9 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { std::thread::sleep(std::time::Duration::from_millis(100)); } - // Process menu events + // Process menu events - use process_menu_event to avoid deadlocks if let Ok(event) = MenuEvent::receiver().try_recv() { - if let Some(state_mutex) = TRAY_STATE.get() { - let state_guard = state_mutex.lock(); - if let Some(ref state) = *state_guard { - handle_menu_event(&event, state); - } - } + process_menu_event(&event); } if SHOULD_EXIT.load(Ordering::SeqCst) { @@ -553,14 +663,9 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { } }); - // Process menu events + // Process menu events - use process_menu_event to avoid deadlocks if let Ok(event) = MenuEvent::receiver().try_recv() { - if let Some(state_mutex) = TRAY_STATE.get() { - let state_guard = state_mutex.lock(); - if let Some(ref state) = *state_guard { - handle_menu_event(&event, state); - } - } + process_menu_event(&event); } if SHOULD_EXIT.load(Ordering::SeqCst) { diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index cb57f372bb1..7173835fa9c 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -85,46 +85,25 @@ namespace system_tray { case TRAY_ACTION_IMPORT_CONFIG: BOOST_LOG(info) << "Import config requested"sv; - // Config import is handled in Rust for file dialog + // Config import is now handled entirely in Rust break; case TRAY_ACTION_EXPORT_CONFIG: BOOST_LOG(info) << "Export config requested"sv; - // Config export is handled in Rust for file dialog + // Config export is now handled entirely in Rust break; case TRAY_ACTION_RESET_CONFIG: BOOST_LOG(info) << "Reset config requested"sv; - // Reset config handled in C++ for now + // Reset config is now handled entirely in Rust break; case TRAY_ACTION_LANGUAGE_CHINESE: case TRAY_ACTION_LANGUAGE_ENGLISH: - case TRAY_ACTION_LANGUAGE_JAPANESE: { - std::string locale; - switch (action) { - case TRAY_ACTION_LANGUAGE_CHINESE: locale = "zh"; break; - case TRAY_ACTION_LANGUAGE_ENGLISH: locale = "en"; break; - case TRAY_ACTION_LANGUAGE_JAPANESE: locale = "ja"; break; - } - - // Save to config file - try { - auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); - std::stringstream configStream; - vars["tray_locale"] = locale; - for (const auto& [key, value] : vars) { - if (!value.empty() && value != "null") { - configStream << key << " = " << value << std::endl; - } - } - file_handler::write_file(config::sunshine.config_file.c_str(), configStream.str()); - BOOST_LOG(info) << "Tray language setting saved"sv; - } catch (const std::exception& e) { - BOOST_LOG(warning) << "Failed to save tray language: " << e.what(); - } + case TRAY_ACTION_LANGUAGE_JAPANESE: + // Language setting is now saved in Rust, just log here + BOOST_LOG(info) << "Tray language changed (saved by Rust)"sv; break; - } case TRAY_ACTION_STAR_PROJECT: // Handled in Rust (opens URL) @@ -144,16 +123,19 @@ namespace system_tray { case TRAY_ACTION_RESTART: BOOST_LOG(info) << "Restarting from system tray"sv; + tray_exit(); platf::restart(); break; case TRAY_ACTION_QUIT: BOOST_LOG(info) << "Quitting from system tray"sv; + tray_exit(); #ifdef _WIN32 terminate_gui_processes(); if (GetConsoleWindow() == NULL) { lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); - } else { + } + else { lifetime::exit_sunshine(0, true); } #else From 55b6e37c9a0190cc52a34cba0049cd29fbdd059b Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:51:47 +0800 Subject: [PATCH 13/36] =?UTF-8?q?feat:=20=E9=87=8E=E4=BA=BA=E5=8A=9E?= =?UTF-8?q?=E6=B3=95=E5=AE=9E=E7=8E=B0=E6=B7=B1=E8=89=B2=E6=89=98=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/include/rust_tray.h | 19 ++++++ rust_tray/src/dark_mode.rs | 123 ++++++++++++++++++++++++++++++++++ rust_tray/src/lib.rs | 30 +++++++++ 3 files changed, 172 insertions(+) create mode 100644 rust_tray/src/dark_mode.rs diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h index cdd43f92142..6efc748856a 100644 --- a/rust_tray/include/rust_tray.h +++ b/rust_tray/include/rust_tray.h @@ -118,6 +118,25 @@ void tray_set_locale(const char* locale); */ void tray_show_notification(const char* title, const char* text, int icon_type); +/** + * @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/src/dark_mode.rs b/rust_tray/src/dark_mode.rs new file mode 100644 index 00000000000..79999ec6933 --- /dev/null +++ b/rust_tray/src/dark_mode.rs @@ -0,0 +1,123 @@ +//! Dark mode support for Windows context menus +//! +//! This module provides the ability to enable dark mode for context menus +//! (including system tray menus) on Windows 10 1903+ and Windows 11. +//! +//! The implementation uses undocumented Windows APIs from uxtheme.dll: +//! - SetPreferredAppMode (ordinal 135) - Sets the app's preferred dark mode +//! - FlushMenuThemes (ordinal 136) - Refreshes menu theme cache + +#[cfg(target_os = "windows")] +mod windows_impl { + use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; + + /// Preferred app mode values + #[repr(i32)] + #[derive(Debug, Clone, Copy)] + pub enum PreferredAppMode { + /// Use system default (usually light) + Default = 0, + /// Allow dark mode (follow system setting) + AllowDark = 1, + /// Force dark mode + ForceDark = 2, + /// Force light mode + ForceLight = 3, + } + + // Function pointer types for undocumented APIs + type SetPreferredAppModeFn = unsafe extern "system" fn(mode: i32) -> i32; + type FlushMenuThemesFn = unsafe extern "system" fn(); + + /// Set the preferred app mode for context menus + /// + /// This affects the appearance of context menus (including tray menus). + /// Call this before creating any menus. + /// + /// # Arguments + /// * `mode` - The preferred app mode + /// + /// # Returns + /// The previous mode on success, or -1 if the API is not available + pub fn set_preferred_app_mode(mode: PreferredAppMode) -> i32 { + unsafe { + // Load uxtheme.dll + // "uxtheme" in UTF-16 + let dll: [u16; 8] = [0x75, 0x78, 0x74, 0x68, 0x65, 0x6d, 0x65, 0]; + let module = LoadLibraryW(dll.as_ptr()); + + if module.is_null() { + return -1; + } + + // Get SetPreferredAppMode by ordinal 135 + let func = GetProcAddress(module, 135 as *const u8); + + match func { + Some(f) => { + let set_mode: SetPreferredAppModeFn = std::mem::transmute(f); + set_mode(mode as i32) + } + None => -1, + } + } + } + + /// Flush the menu theme cache + /// + /// Call this after changing the app mode to refresh existing menus. + pub fn flush_menu_themes() { + unsafe { + let dll: [u16; 8] = [0x75, 0x78, 0x74, 0x68, 0x65, 0x6d, 0x65, 0]; + let module = LoadLibraryW(dll.as_ptr()); + + if module.is_null() { + return; + } + + // Get FlushMenuThemes by ordinal 136 + let func = GetProcAddress(module, 136 as *const u8); + + if let Some(f) = func { + let flush: FlushMenuThemesFn = std::mem::transmute(f); + flush(); + } + } + } + + /// Enable dark mode for context menus (follow system setting) + /// + /// This is the recommended way to enable dark mode support. + /// The menu will automatically follow the system's dark/light mode setting. + pub fn enable_dark_mode() { + set_preferred_app_mode(PreferredAppMode::AllowDark); + flush_menu_themes(); + } + + /// Force dark mode for context menus + /// + /// This forces menus to use dark mode regardless of system setting. + pub fn force_dark_mode() { + set_preferred_app_mode(PreferredAppMode::ForceDark); + flush_menu_themes(); + } + + /// Force light mode for context menus + pub fn force_light_mode() { + set_preferred_app_mode(PreferredAppMode::ForceLight); + flush_menu_themes(); + } +} + +#[cfg(target_os = "windows")] +pub use windows_impl::*; + +// Stub implementations for non-Windows platforms +#[cfg(not(target_os = "windows"))] +pub fn enable_dark_mode() {} + +#[cfg(not(target_os = "windows"))] +pub fn force_dark_mode() {} + +#[cfg(not(target_os = "windows"))] +pub fn force_light_mode() {} diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 710a1493ce6..907241b2693 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -13,6 +13,7 @@ pub mod i18n; pub mod actions; pub mod config; +pub mod dark_mode; use std::ffi::CStr; use std::os::raw::{c_char, c_int}; @@ -525,6 +526,10 @@ pub unsafe extern "C" fn tray_init_ex( // Register callback register_callback(callback); + // Enable dark mode for context menus (follow system setting) + // This must be called before creating the menu + dark_mode::enable_dark_mode(); + // Initialize global state let _ = TRAY_STATE.get_or_init(|| Mutex::new(None)); @@ -789,6 +794,31 @@ pub unsafe extern "C" fn tray_show_notification( eprintln!("Notification: {} - {}", title_str, text_str); } +// ============================================================================ +// Dark Mode API +// ============================================================================ + +/// 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. +#[no_mangle] +pub extern "C" fn tray_enable_dark_mode() { + dark_mode::enable_dark_mode(); +} + +/// Force dark mode for context menus +#[no_mangle] +pub extern "C" fn tray_force_dark_mode() { + dark_mode::force_dark_mode(); +} + +/// Force light mode for context menus +#[no_mangle] +pub extern "C" fn tray_force_light_mode() { + dark_mode::force_light_mode(); +} + // ============================================================================ // Legacy C API compatibility (for existing C++ code) // ============================================================================ From 9dda020d756c181123bd1d7de41857413687371f Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 17:59:37 +0800 Subject: [PATCH 14/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E4=BB=B6=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/include/rust_tray.h | 2 ++ rust_tray/src/config.rs | 44 +++++++---------------------------- rust_tray/src/lib.rs | 15 ++++++++++++ src/system_tray_rust.cpp | 1 + 4 files changed, 26 insertions(+), 36 deletions(-) diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h index 6efc748856a..80896661ee3 100644 --- a/rust_tray/include/rust_tray.h +++ b/rust_tray/include/rust_tray.h @@ -55,6 +55,7 @@ typedef void (*TrayActionCallback)(uint32_t action); * @param icon_locked Path to locked icon * @param tooltip Tooltip text * @param locale Initial locale (e.g., "zh", "en", "ja") + * @param config_file Path to the Sunshine configuration file (sunshine.conf) * @param callback Callback function for menu actions * @return 0 on success, -1 on error */ @@ -65,6 +66,7 @@ int tray_init_ex( const char* icon_locked, const char* tooltip, const char* locale, + const char* config_file, TrayActionCallback callback ); diff --git a/rust_tray/src/config.rs b/rust_tray/src/config.rs index ab8c4a00cab..a68f5c6f206 100644 --- a/rust_tray/src/config.rs +++ b/rust_tray/src/config.rs @@ -2,12 +2,16 @@ //! //! Provides functionality for reading, writing, importing, exporting, //! and resetting the Sunshine configuration file. +//! +//! The configuration file path is obtained from C++ via the tray_init_ex function, +//! which provides the exact path used by the main Sunshine application. use std::collections::HashMap; use std::fs; use std::path::PathBuf; use crate::i18n::{get_string, StringKey}; +use crate::get_config_file_path_from_cpp; #[cfg(target_os = "windows")] use std::ffi::OsStr; @@ -43,46 +47,14 @@ impl std::fmt::Display for ConfigError { /// Get the Sunshine configuration file path /// -/// On Windows: %PROGRAMDATA%/Sunshine/config/sunshine.conf -/// On Linux: ~/.config/sunshine/sunshine.conf or /etc/sunshine/sunshine.conf -/// On macOS: ~/Library/Application Support/Sunshine/config/sunshine.conf -#[cfg(target_os = "windows")] +/// The path is provided by C++ via tray_init_ex, ensuring consistency +/// with the main Sunshine application's configuration path. pub fn get_config_file_path() -> Option { - std::env::var("PROGRAMDATA") - .ok() - .map(|data| PathBuf::from(data).join("Sunshine").join("config").join("sunshine.conf")) + get_config_file_path_from_cpp() + .map(PathBuf::from) .filter(|p| p.exists()) } -#[cfg(target_os = "linux")] -pub fn get_config_file_path() -> Option { - // Try user config first - if let Some(home) = std::env::var("HOME").ok() { - let user_config = PathBuf::from(home).join(".config/sunshine/sunshine.conf"); - if user_config.exists() { - return Some(user_config); - } - } - // Fallback to system config - let system_config = PathBuf::from("/etc/sunshine/sunshine.conf"); - if system_config.exists() { - return Some(system_config); - } - None -} - -#[cfg(target_os = "macos")] -pub fn get_config_file_path() -> Option { - if let Some(home) = std::env::var("HOME").ok() { - let config = PathBuf::from(home) - .join("Library/Application Support/Sunshine/config/sunshine.conf"); - if config.exists() { - return Some(config); - } - } - None -} - /// Parse configuration file content into a key-value map pub fn parse_config(content: &str) -> HashMap { let mut vars = HashMap::new(); diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 907241b2693..f8b1c273e8d 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -77,6 +77,14 @@ unsafe impl Sync for TrayState {} static TRAY_STATE: OnceCell>> = OnceCell::new(); static SHOULD_EXIT: AtomicBool = AtomicBool::new(false); +/// Config file path storage (set from C++) +static CONFIG_FILE_PATH: OnceCell = OnceCell::new(); + +/// Get the config file path (set from C++) +pub fn get_config_file_path_from_cpp() -> Option<&'static str> { + CONFIG_FILE_PATH.get().map(|s| s.as_str()) +} + /// Icon paths storage static ICON_PATHS: OnceCell = OnceCell::new(); @@ -491,6 +499,7 @@ fn update_menu_texts() { /// * `icon_locked` - Path to locked icon /// * `tooltip` - Tooltip text /// * `locale` - Initial locale (e.g., "zh", "en", "ja") +/// * `config_file` - Path to the Sunshine configuration file (sunshine.conf) /// * `callback` - Callback function for menu actions /// /// # Returns @@ -503,8 +512,14 @@ pub unsafe extern "C" fn tray_init_ex( icon_locked: *const c_char, tooltip: *const c_char, locale: *const c_char, + config_file: *const c_char, callback: ActionCallback, ) -> c_int { + // Store config file path (from C++) + if let Some(cfg_path) = c_str_to_string(config_file) { + let _ = CONFIG_FILE_PATH.set(cfg_path); + } + // 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(); diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index 7173835fa9c..97b9b6d9fd7 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -205,6 +205,7 @@ namespace system_tray { ICON_PATH_LOCKED, tooltip.c_str(), locale.c_str(), + config::sunshine.config_file.c_str(), handle_tray_action ); From c8bdd30aafa1fc88e133446dc892a32caca1edca Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 19:02:11 +0800 Subject: [PATCH 15/36] =?UTF-8?q?fix:=20=E5=88=86=E7=A6=BB=E6=89=98?= =?UTF-8?q?=E7=9B=98=E7=BA=BF=E7=A8=8B=EF=BC=9B=E9=81=BF=E5=85=8D=E5=A4=9A?= =?UTF-8?q?=E6=AC=A1=E8=B0=83=E7=94=A8tray=5Fexit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/system_tray_rust.cpp | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index 97b9b6d9fd7..f523b5c1de6 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -55,9 +55,7 @@ using namespace std::literals; namespace system_tray { static std::atomic tray_initialized = false; - static std::thread tray_thread; - static std::atomic tray_thread_running = false; - static std::atomic tray_thread_should_exit = false; + static std::atomic end_tray_called = false; // Forward declarations static void handle_tray_action(uint32_t action); @@ -123,13 +121,11 @@ namespace system_tray { case TRAY_ACTION_RESTART: BOOST_LOG(info) << "Restarting from system tray"sv; - tray_exit(); platf::restart(); break; case TRAY_ACTION_QUIT: BOOST_LOG(info) << "Quitting from system tray"sv; - tray_exit(); #ifdef _WIN32 terminate_gui_processes(); if (GetConsoleWindow() == NULL) { @@ -227,41 +223,39 @@ namespace system_tray { } 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_exit(); tray_initialized = false; + tray_exit(); BOOST_LOG(info) << "Rust tray shut down"sv; return 0; } int init_tray_threaded() { - if (tray_thread_running.exchange(true)) { - BOOST_LOG(warning) << "Tray thread already running"sv; - return 0; - } - - tray_thread_should_exit = false; + // Reset the end_tray flag for new tray instance + end_tray_called = false; - tray_thread = std::thread([]() { + std::thread tray_thread([]() { if (init_tray() != 0) { - tray_thread_running = false; return; } - while (!tray_thread_should_exit.load()) { - if (tray_loop(1) < 0) { // Blocking with timeout - break; - } - } - - end_tray(); - tray_thread_running = false; + // 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; } From 2d53cd8de321ae1aef0bdecc826b23768562df11 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 3 Dec 2025 20:13:18 +0800 Subject: [PATCH 16/36] =?UTF-8?q?feat:=20DPI=E6=84=9F=E7=9F=A5=E7=9A=84?= =?UTF-8?q?=E6=89=98=E7=9B=98=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/lib.rs | 106 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 7 deletions(-) diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index f8b1c273e8d..126999aaa4e 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -33,9 +33,12 @@ use actions::{MenuAction, trigger_action, register_callback, ActionCallback, ope #[cfg(target_os = "windows")] use windows_sys::Win32::UI::WindowsAndMessaging::{ DispatchMessageW, GetMessageW, PeekMessageW, PostQuitMessage, TranslateMessage, - MSG, WM_QUIT, PM_REMOVE, + MSG, WM_QUIT, PM_REMOVE, WM_DPICHANGED, }; +#[cfg(target_os = "windows")] +use std::sync::atomic::AtomicI32; + /// Tray state #[allow(dead_code)] // Fields are needed for lifetime management struct TrayState { @@ -77,6 +80,15 @@ unsafe impl Sync for TrayState {} 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 +#[cfg(target_os = "windows")] +static CURRENT_ICON_TYPE: AtomicI32 = AtomicI32::new(0); + +/// Cached DPI value to detect DPI changes +#[cfg(target_os = "windows")] +static CACHED_DPI_SIZE: AtomicI32 = AtomicI32::new(0); + /// Config file path storage (set from C++) static CONFIG_FILE_PATH: OnceCell = OnceCell::new(); @@ -103,17 +115,88 @@ unsafe fn c_str_to_string(ptr: *const c_char) -> Option { 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 +#[cfg(target_os = "windows")] +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 +#[cfg(target_os = "windows")] +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 +#[cfg(target_os = "windows")] +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 supports multi-resolution icons - Windows will automatically select -/// the appropriate size based on DPI settings +/// This properly handles DPI scaling by requesting the correct icon size +/// based on SM_CXSMICON/SM_CYSMICON system metrics #[cfg(target_os = "windows")] fn load_icon_from_path(path: &str) -> Option { - // Use native Windows ICO loading (like ExtractIconEx in C++) - // Passing None for size lets Windows choose based on system DPI - match Icon::from_path(path, None) { + // 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 '{}': {}", path, e); + eprintln!("Failed to load icon '{}' with size {:?}: {}", path, size, e); None } } @@ -630,6 +713,11 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { 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); } @@ -728,6 +816,10 @@ pub extern "C" fn tray_exit() { /// * `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 + #[cfg(target_os = "windows")] + CURRENT_ICON_TYPE.store(icon_type, Ordering::SeqCst); + let icon_paths = match ICON_PATHS.get() { Some(p) => p, None => return, From ea5f5a3361119f9a9c3418f37ba3f725aa503ab0 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Thu, 4 Dec 2025 20:15:50 +0800 Subject: [PATCH 17/36] =?UTF-8?q?refactor:=20=E5=88=A0=E9=99=A4=E6=89=98?= =?UTF-8?q?=E7=9B=98=E7=9A=84=E6=B7=B1=E8=89=B2=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/Cargo.toml | 1 - rust_tray/src/dark_mode.rs | 123 ------------------------------------- rust_tray/src/lib.rs | 30 --------- 3 files changed, 154 deletions(-) delete mode 100644 rust_tray/src/dark_mode.rs diff --git a/rust_tray/Cargo.toml b/rust_tray/Cargo.toml index 3f5991e7fdd..72b1b06d8d2 100644 --- a/rust_tray/Cargo.toml +++ b/rust_tray/Cargo.toml @@ -21,7 +21,6 @@ winit = { version = "0.30", default-features = false, features = ["rwh_06"] } windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_UI_WindowsAndMessaging", - "Win32_System_LibraryLoader", "Win32_UI_Shell", "Win32_UI_Controls_Dialogs", ] } diff --git a/rust_tray/src/dark_mode.rs b/rust_tray/src/dark_mode.rs deleted file mode 100644 index 79999ec6933..00000000000 --- a/rust_tray/src/dark_mode.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Dark mode support for Windows context menus -//! -//! This module provides the ability to enable dark mode for context menus -//! (including system tray menus) on Windows 10 1903+ and Windows 11. -//! -//! The implementation uses undocumented Windows APIs from uxtheme.dll: -//! - SetPreferredAppMode (ordinal 135) - Sets the app's preferred dark mode -//! - FlushMenuThemes (ordinal 136) - Refreshes menu theme cache - -#[cfg(target_os = "windows")] -mod windows_impl { - use windows_sys::Win32::System::LibraryLoader::{GetProcAddress, LoadLibraryW}; - - /// Preferred app mode values - #[repr(i32)] - #[derive(Debug, Clone, Copy)] - pub enum PreferredAppMode { - /// Use system default (usually light) - Default = 0, - /// Allow dark mode (follow system setting) - AllowDark = 1, - /// Force dark mode - ForceDark = 2, - /// Force light mode - ForceLight = 3, - } - - // Function pointer types for undocumented APIs - type SetPreferredAppModeFn = unsafe extern "system" fn(mode: i32) -> i32; - type FlushMenuThemesFn = unsafe extern "system" fn(); - - /// Set the preferred app mode for context menus - /// - /// This affects the appearance of context menus (including tray menus). - /// Call this before creating any menus. - /// - /// # Arguments - /// * `mode` - The preferred app mode - /// - /// # Returns - /// The previous mode on success, or -1 if the API is not available - pub fn set_preferred_app_mode(mode: PreferredAppMode) -> i32 { - unsafe { - // Load uxtheme.dll - // "uxtheme" in UTF-16 - let dll: [u16; 8] = [0x75, 0x78, 0x74, 0x68, 0x65, 0x6d, 0x65, 0]; - let module = LoadLibraryW(dll.as_ptr()); - - if module.is_null() { - return -1; - } - - // Get SetPreferredAppMode by ordinal 135 - let func = GetProcAddress(module, 135 as *const u8); - - match func { - Some(f) => { - let set_mode: SetPreferredAppModeFn = std::mem::transmute(f); - set_mode(mode as i32) - } - None => -1, - } - } - } - - /// Flush the menu theme cache - /// - /// Call this after changing the app mode to refresh existing menus. - pub fn flush_menu_themes() { - unsafe { - let dll: [u16; 8] = [0x75, 0x78, 0x74, 0x68, 0x65, 0x6d, 0x65, 0]; - let module = LoadLibraryW(dll.as_ptr()); - - if module.is_null() { - return; - } - - // Get FlushMenuThemes by ordinal 136 - let func = GetProcAddress(module, 136 as *const u8); - - if let Some(f) = func { - let flush: FlushMenuThemesFn = std::mem::transmute(f); - flush(); - } - } - } - - /// Enable dark mode for context menus (follow system setting) - /// - /// This is the recommended way to enable dark mode support. - /// The menu will automatically follow the system's dark/light mode setting. - pub fn enable_dark_mode() { - set_preferred_app_mode(PreferredAppMode::AllowDark); - flush_menu_themes(); - } - - /// Force dark mode for context menus - /// - /// This forces menus to use dark mode regardless of system setting. - pub fn force_dark_mode() { - set_preferred_app_mode(PreferredAppMode::ForceDark); - flush_menu_themes(); - } - - /// Force light mode for context menus - pub fn force_light_mode() { - set_preferred_app_mode(PreferredAppMode::ForceLight); - flush_menu_themes(); - } -} - -#[cfg(target_os = "windows")] -pub use windows_impl::*; - -// Stub implementations for non-Windows platforms -#[cfg(not(target_os = "windows"))] -pub fn enable_dark_mode() {} - -#[cfg(not(target_os = "windows"))] -pub fn force_dark_mode() {} - -#[cfg(not(target_os = "windows"))] -pub fn force_light_mode() {} diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 126999aaa4e..605217f583c 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -13,7 +13,6 @@ pub mod i18n; pub mod actions; pub mod config; -pub mod dark_mode; use std::ffi::CStr; use std::os::raw::{c_char, c_int}; @@ -624,10 +623,6 @@ pub unsafe extern "C" fn tray_init_ex( // Register callback register_callback(callback); - // Enable dark mode for context menus (follow system setting) - // This must be called before creating the menu - dark_mode::enable_dark_mode(); - // Initialize global state let _ = TRAY_STATE.get_or_init(|| Mutex::new(None)); @@ -901,31 +896,6 @@ pub unsafe extern "C" fn tray_show_notification( eprintln!("Notification: {} - {}", title_str, text_str); } -// ============================================================================ -// Dark Mode API -// ============================================================================ - -/// 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. -#[no_mangle] -pub extern "C" fn tray_enable_dark_mode() { - dark_mode::enable_dark_mode(); -} - -/// Force dark mode for context menus -#[no_mangle] -pub extern "C" fn tray_force_dark_mode() { - dark_mode::force_dark_mode(); -} - -/// Force light mode for context menus -#[no_mangle] -pub extern "C" fn tray_force_light_mode() { - dark_mode::force_light_mode(); -} - // ============================================================================ // Legacy C API compatibility (for existing C++ code) // ============================================================================ From 8f96c01713c7d52af984055de46098d1db9536d1 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:32:42 +0800 Subject: [PATCH 18/36] =?UTF-8?q?feat:=20=E8=B7=9F=E9=9A=8Fc++=E6=89=98?= =?UTF-8?q?=E7=9B=98=E7=9A=84=E5=8F=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/include/rust_tray.h | 53 ++++++---- rust_tray/src/actions.rs | 72 +++++++------ rust_tray/src/i18n.rs | 90 ++++++++++++---- rust_tray/src/lib.rs | 192 +++++++++++++++++++++------------- 4 files changed, 263 insertions(+), 144 deletions(-) diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h index 80896661ee3..897b5f536cc 100644 --- a/rust_tray/include/rust_tray.h +++ b/rust_tray/include/rust_tray.h @@ -15,20 +15,27 @@ extern "C" { */ typedef enum { TRAY_ACTION_OPEN_UI = 1, - TRAY_ACTION_TOGGLE_VDD_MONITOR = 2, - TRAY_ACTION_IMPORT_CONFIG = 3, - TRAY_ACTION_EXPORT_CONFIG = 4, - TRAY_ACTION_RESET_CONFIG = 5, - TRAY_ACTION_LANGUAGE_CHINESE = 6, - TRAY_ACTION_LANGUAGE_ENGLISH = 7, - TRAY_ACTION_LANGUAGE_JAPANESE = 8, - TRAY_ACTION_STAR_PROJECT = 9, - TRAY_ACTION_DONATE_YUNDI339 = 10, - TRAY_ACTION_DONATE_QIIN = 11, - TRAY_ACTION_RESET_DISPLAY_DEVICE_CONFIG = 12, - TRAY_ACTION_RESTART = 13, - TRAY_ACTION_QUIT = 14, - TRAY_ACTION_NOTIFICATION_CLICKED = 15, + // VDD submenu actions + TRAY_ACTION_VDD_CREATE = 2, + TRAY_ACTION_VDD_CLOSE = 3, + TRAY_ACTION_VDD_PERSISTENT = 4, + // Config actions + TRAY_ACTION_IMPORT_CONFIG = 5, + TRAY_ACTION_EXPORT_CONFIG = 6, + TRAY_ACTION_RESET_CONFIG = 7, + TRAY_ACTION_CLOSE_APP = 8, + // Language actions + TRAY_ACTION_LANGUAGE_CHINESE = 9, + TRAY_ACTION_LANGUAGE_ENGLISH = 10, + TRAY_ACTION_LANGUAGE_JAPANESE = 11, + TRAY_ACTION_STAR_PROJECT = 12, + // Visit Project actions + TRAY_ACTION_VISIT_PROJECT_SUNSHINE = 13, + TRAY_ACTION_VISIT_PROJECT_MOONLIGHT = 14, + TRAY_ACTION_RESET_DISPLAY_DEVICE_CONFIG = 15, + TRAY_ACTION_RESTART = 16, + TRAY_ACTION_QUIT = 17, + TRAY_ACTION_NOTIFICATION_CLICKED = 18, } TrayAction; /** @@ -95,16 +102,24 @@ void tray_set_icon(int icon_type); void tray_set_tooltip(const char* tooltip); /** - * @brief Update the VDD monitor toggle checkbox state - * @param checked Non-zero to check, zero to uncheck + * @brief Update the VDD create menu item state + * @param checked Non-zero to check (VDD is active), zero to uncheck + * @param enabled Non-zero to enable, zero to disable */ -void tray_set_vdd_checked(int checked); +void tray_set_vdd_create_state(int checked, int enabled); /** - * @brief Set the VDD toggle menu item enabled state + * @brief Update the VDD close menu item state + * @param checked Non-zero to check (VDD is not active), zero to uncheck * @param enabled Non-zero to enable, zero to disable */ -void tray_set_vdd_enabled(int enabled); +void tray_set_vdd_close_state(int checked, int enabled); + +/** + * @brief Update the VDD persistent menu item state + * @param checked Non-zero to check (persistent mode enabled), zero to uncheck + */ +void tray_set_vdd_persistent_state(int checked); /** * @brief Set the current locale diff --git a/rust_tray/src/actions.rs b/rust_tray/src/actions.rs index 98f7f154e35..19fac978eb4 100644 --- a/rust_tray/src/actions.rs +++ b/rust_tray/src/actions.rs @@ -11,20 +11,27 @@ use once_cell::sync::Lazy; #[repr(u32)] pub enum MenuAction { OpenUI = 1, - ToggleVddMonitor = 2, - ImportConfig = 3, - ExportConfig = 4, - ResetConfig = 5, - LanguageChinese = 6, - LanguageEnglish = 7, - LanguageJapanese = 8, - StarProject = 9, - DonateYundi339 = 10, - DonateQiin = 11, - ResetDisplayDeviceConfig = 12, - Restart = 13, - Quit = 14, - NotificationClicked = 15, + // VDD submenu actions + VddCreate = 2, + VddClose = 3, + VddPersistent = 4, + // Config actions + ImportConfig = 5, + ExportConfig = 6, + ResetConfig = 7, + CloseApp = 8, + // Language actions + LanguageChinese = 9, + LanguageEnglish = 10, + LanguageJapanese = 11, + StarProject = 12, + // Visit Project actions + VisitProjectSunshine = 13, + VisitProjectMoonlight = 14, + ResetDisplayDeviceConfig = 15, + Restart = 16, + Quit = 17, + NotificationClicked = 18, } impl TryFrom for MenuAction { @@ -33,20 +40,23 @@ impl TryFrom for MenuAction { fn try_from(value: u32) -> Result { match value { 1 => Ok(MenuAction::OpenUI), - 2 => Ok(MenuAction::ToggleVddMonitor), - 3 => Ok(MenuAction::ImportConfig), - 4 => Ok(MenuAction::ExportConfig), - 5 => Ok(MenuAction::ResetConfig), - 6 => Ok(MenuAction::LanguageChinese), - 7 => Ok(MenuAction::LanguageEnglish), - 8 => Ok(MenuAction::LanguageJapanese), - 9 => Ok(MenuAction::StarProject), - 10 => Ok(MenuAction::DonateYundi339), - 11 => Ok(MenuAction::DonateQiin), - 12 => Ok(MenuAction::ResetDisplayDeviceConfig), - 13 => Ok(MenuAction::Restart), - 14 => Ok(MenuAction::Quit), - 15 => Ok(MenuAction::NotificationClicked), + 2 => Ok(MenuAction::VddCreate), + 3 => Ok(MenuAction::VddClose), + 4 => Ok(MenuAction::VddPersistent), + 5 => Ok(MenuAction::ImportConfig), + 6 => Ok(MenuAction::ExportConfig), + 7 => Ok(MenuAction::ResetConfig), + 8 => Ok(MenuAction::CloseApp), + 9 => Ok(MenuAction::LanguageChinese), + 10 => Ok(MenuAction::LanguageEnglish), + 11 => Ok(MenuAction::LanguageJapanese), + 12 => Ok(MenuAction::StarProject), + 13 => Ok(MenuAction::VisitProjectSunshine), + 14 => Ok(MenuAction::VisitProjectMoonlight), + 15 => Ok(MenuAction::ResetDisplayDeviceConfig), + 16 => Ok(MenuAction::Restart), + 17 => Ok(MenuAction::Quit), + 18 => Ok(MenuAction::NotificationClicked), _ => Err(()), } } @@ -72,9 +82,9 @@ pub fn trigger_action(action: MenuAction) { /// URLs for opening in browser pub mod urls { - pub const GITHUB_PROJECT: &str = "https://github.com/qiin2333/Sunshine-Foundation"; - pub const DONATE_YUNDI339: &str = "https://www.ifdian.net/a/Yundi339"; - pub const DONATE_QIIN: &str = "https://www.ifdian.net/a/qiin2333"; + pub const GITHUB_PROJECT: &str = "https://sunshine-foundation.vercel.app/"; + pub const PROJECT_SUNSHINE: &str = "https://github.com/qiin2333/Sunshine-Foundation"; + pub const PROJECT_MOONLIGHT: &str = "https://github.com/qiin2333/moonlight-vplus"; } /// Open URL in default browser diff --git a/rust_tray/src/i18n.rs b/rust_tray/src/i18n.rs index 3edd5da27f4..e884b4bb160 100644 --- a/rust_tray/src/i18n.rs +++ b/rust_tray/src/i18n.rs @@ -35,20 +35,34 @@ impl From<&str> for Locale { pub enum StringKey { // Menu items OpenSunshine, - VddMonitorToggle, - Configuration, + // VDD submenu + VddBaseDisplay, + VddCreate, + VddClose, + VddPersistent, + VddPersistentConfirmTitle, + VddPersistentConfirmMsg, + // Advanced Settings submenu + AdvancedSettings, ImportConfig, ExportConfig, ResetToDefault, + CloseApp, + CloseAppConfirmTitle, + CloseAppConfirmMsg, + // Language submenu Language, Chinese, English, Japanese, StarProject, - HelpUs, - DeveloperYundi339, - DeveloperQiin, + // Visit Project submenu + VisitProject, + VisitProjectSunshine, + VisitProjectMoonlight, ResetDisplayDeviceConfig, + ResetDisplayConfirmTitle, + ResetDisplayConfirmMsg, Restart, Quit, @@ -101,20 +115,32 @@ static TRANSLATIONS: Lazy> = Lazy::ne // English translations m.insert((Locale::English, StringKey::OpenSunshine), "Open Sunshine"); - m.insert((Locale::English, StringKey::VddMonitorToggle), "VDD Monitor Toggle"); - m.insert((Locale::English, StringKey::Configuration), "Configuration"); + // VDD submenu + m.insert((Locale::English, StringKey::VddBaseDisplay), "Foundation Display"); + m.insert((Locale::English, StringKey::VddCreate), "Create"); + m.insert((Locale::English, StringKey::VddClose), "Close"); + m.insert((Locale::English, StringKey::VddPersistent), "Keep Enabled"); + m.insert((Locale::English, StringKey::VddPersistentConfirmTitle), "Enable Keep VDD Mode"); + m.insert((Locale::English, StringKey::VddPersistentConfirmMsg), "Enabling this mode will keep the virtual display active at all times.\n\nAre you sure you want to enable it?"); + // Advanced Settings submenu + m.insert((Locale::English, StringKey::AdvancedSettings), "Advanced Settings"); m.insert((Locale::English, StringKey::ImportConfig), "Import Config"); m.insert((Locale::English, StringKey::ExportConfig), "Export Config"); m.insert((Locale::English, StringKey::ResetToDefault), "Reset to Default"); + m.insert((Locale::English, StringKey::CloseApp), "Clear Cache"); + m.insert((Locale::English, StringKey::CloseAppConfirmTitle), "Clear Cache"); + m.insert((Locale::English, StringKey::CloseAppConfirmMsg), "This will terminate the current streaming application.\n\nAre you sure you want to continue?"); m.insert((Locale::English, StringKey::Language), "Language"); m.insert((Locale::English, StringKey::Chinese), "中文"); m.insert((Locale::English, StringKey::English), "English"); m.insert((Locale::English, StringKey::Japanese), "日本語"); m.insert((Locale::English, StringKey::StarProject), "Star Project"); - m.insert((Locale::English, StringKey::HelpUs), "Sponsor Us"); - m.insert((Locale::English, StringKey::DeveloperYundi339), "Developer: Yundi339"); - m.insert((Locale::English, StringKey::DeveloperQiin), "Developer: Qiin"); + m.insert((Locale::English, StringKey::VisitProject), "Visit Project"); + m.insert((Locale::English, StringKey::VisitProjectSunshine), "Sunshine-Foundation"); + m.insert((Locale::English, StringKey::VisitProjectMoonlight), "Moonlight-vplus"); m.insert((Locale::English, StringKey::ResetDisplayDeviceConfig), "Reset Display Memory"); + m.insert((Locale::English, StringKey::ResetDisplayConfirmTitle), "Reset Display Configuration"); + m.insert((Locale::English, StringKey::ResetDisplayConfirmMsg), "This will reset all display device configuration.\n\nAre you sure you want to continue?"); m.insert((Locale::English, StringKey::Restart), "Restart"); m.insert((Locale::English, StringKey::Quit), "Quit"); m.insert((Locale::English, StringKey::StreamStarted), "Stream Started"); @@ -155,20 +181,32 @@ static TRANSLATIONS: Lazy> = Lazy::ne // Chinese translations m.insert((Locale::Chinese, StringKey::OpenSunshine), "打开 Sunshine"); - m.insert((Locale::Chinese, StringKey::VddMonitorToggle), "虚拟显示器切换"); - m.insert((Locale::Chinese, StringKey::Configuration), "配置"); + // 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::ImportConfig), "导入配置"); m.insert((Locale::Chinese, StringKey::ExportConfig), "导出配置"); m.insert((Locale::Chinese, StringKey::ResetToDefault), "恢复默认"); + m.insert((Locale::Chinese, StringKey::CloseApp), "清理应用缓存"); + m.insert((Locale::Chinese, StringKey::CloseAppConfirmTitle), "清理应用缓存"); + m.insert((Locale::Chinese, StringKey::CloseAppConfirmMsg), "这将终止当前正在串流的应用程序。\n\n确定要继续吗?"); m.insert((Locale::Chinese, StringKey::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), "Star项目"); - m.insert((Locale::Chinese, StringKey::HelpUs), "赞助我们"); - m.insert((Locale::Chinese, StringKey::DeveloperYundi339), "开发者:Yundi339"); - m.insert((Locale::Chinese, StringKey::DeveloperQiin), "开发者:Qiin"); + m.insert((Locale::Chinese, StringKey::VisitProject), "访问项目"); + m.insert((Locale::Chinese, StringKey::VisitProjectSunshine), "Sunshine-Foundation"); + m.insert((Locale::Chinese, StringKey::VisitProjectMoonlight), "Moonlight-vplus"); m.insert((Locale::Chinese, StringKey::ResetDisplayDeviceConfig), "重置显示器记忆"); + m.insert((Locale::Chinese, StringKey::ResetDisplayConfirmTitle), "重置显示器配置"); + m.insert((Locale::Chinese, StringKey::ResetDisplayConfirmMsg), "这将重置所有显示器设备配置。\n\n确定要继续吗?"); m.insert((Locale::Chinese, StringKey::Restart), "重新启动"); m.insert((Locale::Chinese, StringKey::Quit), "退出"); m.insert((Locale::Chinese, StringKey::StreamStarted), "串流已开始"); @@ -209,20 +247,32 @@ static TRANSLATIONS: Lazy> = Lazy::ne // Japanese translations m.insert((Locale::Japanese, StringKey::OpenSunshine), "Sunshineを開く"); - m.insert((Locale::Japanese, StringKey::VddMonitorToggle), "仮想ディスプレイの切り替え"); - m.insert((Locale::Japanese, StringKey::Configuration), "設定"); + // 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::ImportConfig), "設定をインポート"); m.insert((Locale::Japanese, StringKey::ExportConfig), "設定をエクスポート"); m.insert((Locale::Japanese, StringKey::ResetToDefault), "デフォルトに戻す"); + m.insert((Locale::Japanese, StringKey::CloseApp), "キャッシュをクリア"); + m.insert((Locale::Japanese, StringKey::CloseAppConfirmTitle), "キャッシュをクリア"); + m.insert((Locale::Japanese, StringKey::CloseAppConfirmMsg), "現在ストリーミング中のアプリケーションを終了します。\n\n続行しますか?"); m.insert((Locale::Japanese, StringKey::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::HelpUs), "スポンサー"); - m.insert((Locale::Japanese, StringKey::DeveloperYundi339), "開発者:Yundi339"); - m.insert((Locale::Japanese, StringKey::DeveloperQiin), "開発者:Qiin"); + m.insert((Locale::Japanese, StringKey::VisitProject), "プロジェクトを訪問"); + m.insert((Locale::Japanese, StringKey::VisitProjectSunshine), "Sunshine-Foundation"); + m.insert((Locale::Japanese, StringKey::VisitProjectMoonlight), "Moonlight-vplus"); m.insert((Locale::Japanese, StringKey::ResetDisplayDeviceConfig), "ディスプレイメモリをリセット"); + m.insert((Locale::Japanese, StringKey::ResetDisplayConfirmTitle), "ディスプレイ設定をリセット"); + m.insert((Locale::Japanese, StringKey::ResetDisplayConfirmMsg), "すべてのディスプレイデバイス設定をリセットします。\n\n続行しますか?"); m.insert((Locale::Japanese, StringKey::Restart), "再起動"); m.insert((Locale::Japanese, StringKey::Quit), "終了"); m.insert((Locale::Japanese, StringKey::StreamStarted), "ストリーム開始"); diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 605217f583c..58bf4732aa5 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -21,7 +21,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(not(target_os = "windows"))] use image::ImageReader; -use muda::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu, CheckMenuItem}; +use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu, CheckMenuItem}; use once_cell::sync::OnceCell; use parking_lot::Mutex; use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; @@ -43,30 +43,36 @@ use std::sync::atomic::AtomicI32; struct TrayState { icon: TrayIcon, menu: Menu, - // Menu item IDs for dynamic updates - vdd_toggle_id: MenuId, - // Submenu references for language - config_submenu: Submenu, + // Submenu references + vdd_submenu: Submenu, + advanced_settings_submenu: Submenu, language_submenu: Submenu, - help_submenu: Submenu, + visit_project_submenu: Submenu, // All menu items for rebuilding menu_items: MenuItems, } struct MenuItems { open_sunshine: MenuItem, - vdd_toggle: CheckMenuItem, + // VDD submenu items + vdd_create: CheckMenuItem, + vdd_close: CheckMenuItem, + vdd_persistent: CheckMenuItem, + // Advanced Settings submenu items import_config: MenuItem, export_config: MenuItem, reset_config: MenuItem, + close_app: MenuItem, + #[cfg(target_os = "windows")] + reset_display: MenuItem, + // Language submenu items lang_chinese: MenuItem, lang_english: MenuItem, lang_japanese: MenuItem, + // Other items star_project: MenuItem, - donate_yundi339: MenuItem, - donate_qiin: MenuItem, - #[cfg(target_os = "windows")] - reset_display: MenuItem, + visit_sunshine: MenuItem, + visit_moonlight: MenuItem, restart: MenuItem, quit: MenuItem, } @@ -271,7 +277,7 @@ fn load_icon(icon_str: &str) -> Option { } /// Build the tray menu with current language -fn build_menu() -> (Menu, MenuItems, Submenu, Submenu, Submenu, MenuId) { +fn build_menu() -> (Menu, MenuItems, Submenu, Submenu, Submenu, Submenu) { let menu = Menu::new(); // Open Sunshine @@ -281,24 +287,39 @@ fn build_menu() -> (Menu, MenuItems, Submenu, Submenu, Submenu, MenuId) { // Separator let _ = menu.append(&PredefinedMenuItem::separator()); - // VDD Monitor Toggle (checkbox) - let vdd_toggle = CheckMenuItem::new(get_string(StringKey::VddMonitorToggle), true, false, None); - let vdd_toggle_id = vdd_toggle.id().clone(); - let _ = menu.append(&vdd_toggle); + // VDD submenu (Windows only, but we keep it for structure consistency) + let vdd_create = CheckMenuItem::new(get_string(StringKey::VddCreate), true, false, None); + let vdd_close = CheckMenuItem::new(get_string(StringKey::VddClose), true, false, None); + let vdd_persistent = CheckMenuItem::new(get_string(StringKey::VddPersistent), true, false, None); - // Separator - let _ = menu.append(&PredefinedMenuItem::separator()); + let vdd_submenu = Submenu::new(get_string(StringKey::VddBaseDisplay), true); + let _ = vdd_submenu.append(&vdd_create); + let _ = vdd_submenu.append(&vdd_close); + let _ = vdd_submenu.append(&vdd_persistent); + + #[cfg(target_os = "windows")] + let _ = menu.append(&vdd_submenu); - // Configuration submenu + // Advanced Settings submenu (Windows only) let import_config = MenuItem::new(get_string(StringKey::ImportConfig), true, None); let export_config = MenuItem::new(get_string(StringKey::ExportConfig), true, None); let reset_config = MenuItem::new(get_string(StringKey::ResetToDefault), true, None); + let close_app = MenuItem::new(get_string(StringKey::CloseApp), true, None); + + #[cfg(target_os = "windows")] + let reset_display = MenuItem::new(get_string(StringKey::ResetDisplayDeviceConfig), true, None); - let config_submenu = Submenu::new(get_string(StringKey::Configuration), true); - let _ = config_submenu.append(&import_config); - let _ = config_submenu.append(&export_config); - let _ = config_submenu.append(&reset_config); - let _ = menu.append(&config_submenu); + let advanced_settings_submenu = Submenu::new(get_string(StringKey::AdvancedSettings), true); + let _ = advanced_settings_submenu.append(&import_config); + let _ = advanced_settings_submenu.append(&export_config); + let _ = advanced_settings_submenu.append(&reset_config); + let _ = advanced_settings_submenu.append(&PredefinedMenuItem::separator()); + let _ = advanced_settings_submenu.append(&close_app); + #[cfg(target_os = "windows")] + let _ = advanced_settings_submenu.append(&reset_display); + + #[cfg(target_os = "windows")] + let _ = menu.append(&advanced_settings_submenu); // Separator let _ = menu.append(&PredefinedMenuItem::separator()); @@ -321,26 +342,18 @@ fn build_menu() -> (Menu, MenuItems, Submenu, Submenu, Submenu, MenuId) { let star_project = MenuItem::new(get_string(StringKey::StarProject), true, None); let _ = menu.append(&star_project); - // Help Us submenu - let donate_yundi339 = MenuItem::new(get_string(StringKey::DeveloperYundi339), true, None); - let donate_qiin = MenuItem::new(get_string(StringKey::DeveloperQiin), true, None); + // Visit Project submenu + let visit_sunshine = MenuItem::new(get_string(StringKey::VisitProjectSunshine), true, None); + let visit_moonlight = MenuItem::new(get_string(StringKey::VisitProjectMoonlight), true, None); - let help_submenu = Submenu::new(get_string(StringKey::HelpUs), true); - let _ = help_submenu.append(&donate_yundi339); - let _ = help_submenu.append(&donate_qiin); - let _ = menu.append(&help_submenu); + let visit_project_submenu = Submenu::new(get_string(StringKey::VisitProject), true); + let _ = visit_project_submenu.append(&visit_sunshine); + let _ = visit_project_submenu.append(&visit_moonlight); + let _ = menu.append(&visit_project_submenu); // Separator let _ = menu.append(&PredefinedMenuItem::separator()); - // Windows-specific: Reset Display Device Config - #[cfg(target_os = "windows")] - let reset_display = { - let item = MenuItem::new(get_string(StringKey::ResetDisplayDeviceConfig), true, None); - let _ = menu.append(&item); - item - }; - // Restart let restart = MenuItem::new(get_string(StringKey::Restart), true, None); let _ = menu.append(&restart); @@ -351,23 +364,26 @@ fn build_menu() -> (Menu, MenuItems, Submenu, Submenu, Submenu, MenuId) { let menu_items = MenuItems { open_sunshine, - vdd_toggle, + vdd_create, + vdd_close, + vdd_persistent, import_config, export_config, reset_config, + close_app, + #[cfg(target_os = "windows")] + reset_display, lang_chinese, lang_english, lang_japanese, star_project, - donate_yundi339, - donate_qiin, - #[cfg(target_os = "windows")] - reset_display, + visit_sunshine, + visit_moonlight, restart, quit, }; - (menu, menu_items, config_submenu, language_submenu, help_submenu, vdd_toggle_id) + (menu, menu_items, vdd_submenu, advanced_settings_submenu, language_submenu, visit_project_submenu) } /// Identify which action corresponds to the menu event @@ -377,14 +393,20 @@ fn identify_menu_action(event: &MenuEvent, state: &TrayState) -> (Option (Option { - open_url(urls::DONATE_YUNDI339); + MenuAction::VisitProjectSunshine => { + open_url(urls::PROJECT_SUNSHINE); trigger_action(action); } - MenuAction::DonateQiin => { - open_url(urls::DONATE_QIIN); + MenuAction::VisitProjectMoonlight => { + open_url(urls::PROJECT_MOONLIGHT); trigger_action(action); } _ => { - // For all other actions, just trigger the callback + // For all other actions (VddCreate, VddClose, VddPersistent, CloseApp, ResetDisplayDeviceConfig, Restart, Quit), + // just trigger the callback to let C++ handle them trigger_action(action); } } @@ -544,14 +567,22 @@ fn update_menu_texts() { if let Some(state_mutex) = TRAY_STATE.get() { let mut state_guard = state_mutex.lock(); if let Some(ref mut state) = *state_guard { - // Get the current VDD toggle state before rebuilding - let vdd_checked = state.menu_items.vdd_toggle.is_checked(); + // Get the current VDD states before rebuilding + let vdd_create_checked = state.menu_items.vdd_create.is_checked(); + let vdd_close_checked = state.menu_items.vdd_close.is_checked(); + let vdd_persistent_checked = state.menu_items.vdd_persistent.is_checked(); + let vdd_create_enabled = state.menu_items.vdd_create.is_enabled(); + let vdd_close_enabled = state.menu_items.vdd_close.is_enabled(); // Build a completely new menu with the updated language - let (new_menu, new_menu_items, new_config_submenu, new_language_submenu, new_help_submenu, new_vdd_toggle_id) = build_menu(); + let (new_menu, new_menu_items, new_vdd_submenu, new_advanced_submenu, new_language_submenu, new_visit_submenu) = build_menu(); - // Restore the VDD toggle state - new_menu_items.vdd_toggle.set_checked(vdd_checked); + // Restore the VDD states + new_menu_items.vdd_create.set_checked(vdd_create_checked); + new_menu_items.vdd_close.set_checked(vdd_close_checked); + new_menu_items.vdd_persistent.set_checked(vdd_persistent_checked); + new_menu_items.vdd_create.set_enabled(vdd_create_enabled); + new_menu_items.vdd_close.set_enabled(vdd_close_enabled); // Set the new menu on the tray icon // This properly detaches the old menu subclass and attaches the new one @@ -560,10 +591,10 @@ fn update_menu_texts() { // Update the state with the new menu and items state.menu = new_menu; state.menu_items = new_menu_items; - state.config_submenu = new_config_submenu; + state.vdd_submenu = new_vdd_submenu; + state.advanced_settings_submenu = new_advanced_submenu; state.language_submenu = new_language_submenu; - state.help_submenu = new_help_submenu; - state.vdd_toggle_id = new_vdd_toggle_id; + state.visit_project_submenu = new_visit_submenu; } } } @@ -642,7 +673,7 @@ pub unsafe extern "C" fn tray_init_ex( let tooltip_str = c_str_to_string(tooltip).unwrap_or_else(|| "Sunshine".to_string()); // Build menu - let (menu, menu_items, config_submenu, language_submenu, help_submenu, vdd_toggle_id) = build_menu(); + let (menu, menu_items, vdd_submenu, advanced_settings_submenu, language_submenu, visit_project_submenu) = build_menu(); // Create tray icon let tray_icon = match TrayIconBuilder::new() @@ -662,10 +693,10 @@ pub unsafe extern "C" fn tray_init_ex( let state = TrayState { icon: tray_icon, menu, - vdd_toggle_id, - config_submenu, + vdd_submenu, + advanced_settings_submenu, language_submenu, - help_submenu, + visit_project_submenu, menu_items, }; @@ -851,24 +882,37 @@ pub unsafe extern "C" fn tray_set_tooltip(tooltip: *const c_char) { } } -/// Update the VDD monitor toggle checkbox state +/// Update the VDD create menu item state +#[no_mangle] +pub extern "C" fn tray_set_vdd_create_state(checked: c_int, enabled: c_int) { + if let Some(state_mutex) = TRAY_STATE.get() { + let state_guard = state_mutex.lock(); + if let Some(ref state) = *state_guard { + state.menu_items.vdd_create.set_checked(checked != 0); + state.menu_items.vdd_create.set_enabled(enabled != 0); + } + } +} + +/// Update the VDD close menu item state #[no_mangle] -pub extern "C" fn tray_set_vdd_checked(checked: c_int) { +pub extern "C" fn tray_set_vdd_close_state(checked: c_int, enabled: c_int) { if let Some(state_mutex) = TRAY_STATE.get() { let state_guard = state_mutex.lock(); if let Some(ref state) = *state_guard { - state.menu_items.vdd_toggle.set_checked(checked != 0); + state.menu_items.vdd_close.set_checked(checked != 0); + state.menu_items.vdd_close.set_enabled(enabled != 0); } } } -/// Set the VDD toggle menu item enabled state +/// Update the VDD persistent menu item state #[no_mangle] -pub extern "C" fn tray_set_vdd_enabled(enabled: c_int) { +pub extern "C" fn tray_set_vdd_persistent_state(checked: c_int) { if let Some(state_mutex) = TRAY_STATE.get() { let state_guard = state_mutex.lock(); if let Some(ref state) = *state_guard { - state.menu_items.vdd_toggle.set_enabled(enabled != 0); + state.menu_items.vdd_persistent.set_checked(checked != 0); } } } From 5d810c080715a26b3ab3ebb79bee86825cb4e109 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:50:44 +0800 Subject: [PATCH 19/36] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E6=89=98?= =?UTF-8?q?=E7=9B=98=E9=80=BB=E8=BE=91=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/lib.rs | 432 ++++-------------------------------- rust_tray/src/menu.rs | 276 +++++++++++++++++++++++ rust_tray/src/menu_items.rs | 405 +++++++++++++++++++++++++++++++++ 3 files changed, 726 insertions(+), 387 deletions(-) create mode 100644 rust_tray/src/menu.rs create mode 100644 rust_tray/src/menu_items.rs diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 58bf4732aa5..50d74a067a0 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -13,6 +13,8 @@ pub mod i18n; pub mod actions; pub mod config; +pub mod menu; +pub mod menu_items; use std::ffi::CStr; use std::os::raw::{c_char, c_int}; @@ -21,13 +23,13 @@ use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(not(target_os = "windows"))] use image::ImageReader; -use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu, CheckMenuItem}; +use muda::{Menu, MenuEvent}; use once_cell::sync::OnceCell; use parking_lot::Mutex; use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; -use i18n::{StringKey, get_string, set_locale_str}; -use actions::{MenuAction, trigger_action, register_callback, ActionCallback, open_url, urls}; +use i18n::set_locale_str; +use actions::{register_callback, ActionCallback}; #[cfg(target_os = "windows")] use windows_sys::Win32::UI::WindowsAndMessaging::{ @@ -38,43 +40,11 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ #[cfg(target_os = "windows")] use std::sync::atomic::AtomicI32; -/// Tray state +/// Tray state - simplified to use menu registry #[allow(dead_code)] // Fields are needed for lifetime management struct TrayState { icon: TrayIcon, menu: Menu, - // Submenu references - vdd_submenu: Submenu, - advanced_settings_submenu: Submenu, - language_submenu: Submenu, - visit_project_submenu: Submenu, - // All menu items for rebuilding - menu_items: MenuItems, -} - -struct MenuItems { - open_sunshine: MenuItem, - // VDD submenu items - vdd_create: CheckMenuItem, - vdd_close: CheckMenuItem, - vdd_persistent: CheckMenuItem, - // Advanced Settings submenu items - import_config: MenuItem, - export_config: MenuItem, - reset_config: MenuItem, - close_app: MenuItem, - #[cfg(target_os = "windows")] - reset_display: MenuItem, - // Language submenu items - lang_chinese: MenuItem, - lang_english: MenuItem, - lang_japanese: MenuItem, - // Other items - star_project: MenuItem, - visit_sunshine: MenuItem, - visit_moonlight: MenuItem, - restart: MenuItem, - quit: MenuItem, } // Safety: TrayState is only accessed from the main thread @@ -233,25 +203,6 @@ fn load_icon_from_path(path: &str) -> Option { Icon::from_rgba(rgba, width, height).ok() } -#[cfg(target_os = "linux")] -fn load_icon_by_name(name: &str) -> Option { - let paths = [ - format!("/usr/share/icons/hicolor/256x256/apps/{}.png", name), - format!("/usr/share/icons/hicolor/128x128/apps/{}.png", name), - format!("/usr/share/icons/hicolor/64x64/apps/{}.png", name), - format!("/usr/share/pixmaps/{}.png", name), - ]; - - for path in &paths { - if Path::new(path).exists() { - if let Some(icon) = load_icon_from_path(path) { - return Some(icon); - } - } - } - None -} - /// Load icon /// /// On Windows: expects .ico file path (supports multi-resolution) @@ -263,298 +214,32 @@ fn load_icon(icon_str: &str) -> Option { return load_icon_from_path(icon_str); } - // On Linux, try searching by icon name - #[cfg(target_os = "linux")] - { - return load_icon_by_name(icon_str); - } - - #[cfg(not(target_os = "linux"))] { eprintln!("Icon not found: {}", icon_str); None } } -/// Build the tray menu with current language -fn build_menu() -> (Menu, MenuItems, Submenu, Submenu, Submenu, Submenu) { - let menu = Menu::new(); - - // Open Sunshine - let open_sunshine = MenuItem::new(get_string(StringKey::OpenSunshine), true, None); - let _ = menu.append(&open_sunshine); - - // Separator - let _ = menu.append(&PredefinedMenuItem::separator()); - - // VDD submenu (Windows only, but we keep it for structure consistency) - let vdd_create = CheckMenuItem::new(get_string(StringKey::VddCreate), true, false, None); - let vdd_close = CheckMenuItem::new(get_string(StringKey::VddClose), true, false, None); - let vdd_persistent = CheckMenuItem::new(get_string(StringKey::VddPersistent), true, false, None); - - let vdd_submenu = Submenu::new(get_string(StringKey::VddBaseDisplay), true); - let _ = vdd_submenu.append(&vdd_create); - let _ = vdd_submenu.append(&vdd_close); - let _ = vdd_submenu.append(&vdd_persistent); - - #[cfg(target_os = "windows")] - let _ = menu.append(&vdd_submenu); - - // Advanced Settings submenu (Windows only) - let import_config = MenuItem::new(get_string(StringKey::ImportConfig), true, None); - let export_config = MenuItem::new(get_string(StringKey::ExportConfig), true, None); - let reset_config = MenuItem::new(get_string(StringKey::ResetToDefault), true, None); - let close_app = MenuItem::new(get_string(StringKey::CloseApp), true, None); - - #[cfg(target_os = "windows")] - let reset_display = MenuItem::new(get_string(StringKey::ResetDisplayDeviceConfig), true, None); - - let advanced_settings_submenu = Submenu::new(get_string(StringKey::AdvancedSettings), true); - let _ = advanced_settings_submenu.append(&import_config); - let _ = advanced_settings_submenu.append(&export_config); - let _ = advanced_settings_submenu.append(&reset_config); - let _ = advanced_settings_submenu.append(&PredefinedMenuItem::separator()); - let _ = advanced_settings_submenu.append(&close_app); - #[cfg(target_os = "windows")] - let _ = advanced_settings_submenu.append(&reset_display); - - #[cfg(target_os = "windows")] - let _ = menu.append(&advanced_settings_submenu); - - // Separator - let _ = menu.append(&PredefinedMenuItem::separator()); - - // Language submenu - let lang_chinese = MenuItem::new(get_string(StringKey::Chinese), true, None); - let lang_english = MenuItem::new(get_string(StringKey::English), true, None); - let lang_japanese = MenuItem::new(get_string(StringKey::Japanese), true, None); - - let language_submenu = Submenu::new(get_string(StringKey::Language), true); - let _ = language_submenu.append(&lang_chinese); - let _ = language_submenu.append(&lang_english); - let _ = language_submenu.append(&lang_japanese); - let _ = menu.append(&language_submenu); - - // Separator - let _ = menu.append(&PredefinedMenuItem::separator()); - - // Star Project - let star_project = MenuItem::new(get_string(StringKey::StarProject), true, None); - let _ = menu.append(&star_project); - - // Visit Project submenu - let visit_sunshine = MenuItem::new(get_string(StringKey::VisitProjectSunshine), true, None); - let visit_moonlight = MenuItem::new(get_string(StringKey::VisitProjectMoonlight), true, None); - - let visit_project_submenu = Submenu::new(get_string(StringKey::VisitProject), true); - let _ = visit_project_submenu.append(&visit_sunshine); - let _ = visit_project_submenu.append(&visit_moonlight); - let _ = menu.append(&visit_project_submenu); - - // Separator - let _ = menu.append(&PredefinedMenuItem::separator()); - - // Restart - let restart = MenuItem::new(get_string(StringKey::Restart), true, None); - let _ = menu.append(&restart); - - // Quit - let quit = MenuItem::new(get_string(StringKey::Quit), true, None); - let _ = menu.append(&quit); - - let menu_items = MenuItems { - open_sunshine, - vdd_create, - vdd_close, - vdd_persistent, - import_config, - export_config, - reset_config, - close_app, - #[cfg(target_os = "windows")] - reset_display, - lang_chinese, - lang_english, - lang_japanese, - star_project, - visit_sunshine, - visit_moonlight, - restart, - quit, - }; - - (menu, menu_items, vdd_submenu, advanced_settings_submenu, language_submenu, visit_project_submenu) -} - /// Identify which action corresponds to the menu event -/// Returns the action to perform (if any) and whether menu rebuild is needed -fn identify_menu_action(event: &MenuEvent, state: &TrayState) -> (Option, bool) { - let items = &state.menu_items; - - if event.id == items.open_sunshine.id() { - (Some(MenuAction::OpenUI), false) - } else if event.id == items.vdd_create.id() { - (Some(MenuAction::VddCreate), false) - } else if event.id == items.vdd_close.id() { - (Some(MenuAction::VddClose), false) - } else if event.id == items.vdd_persistent.id() { - (Some(MenuAction::VddPersistent), false) - } else if event.id == items.import_config.id() { - (Some(MenuAction::ImportConfig), false) - } else if event.id == items.export_config.id() { - (Some(MenuAction::ExportConfig), false) - } else if event.id == items.reset_config.id() { - (Some(MenuAction::ResetConfig), false) - } else if event.id == items.close_app.id() { - (Some(MenuAction::CloseApp), false) - } else if event.id == items.lang_chinese.id() { - (Some(MenuAction::LanguageChinese), true) - } else if event.id == items.lang_english.id() { - (Some(MenuAction::LanguageEnglish), true) - } else if event.id == items.lang_japanese.id() { - (Some(MenuAction::LanguageJapanese), true) - } else if event.id == items.star_project.id() { - (Some(MenuAction::StarProject), false) - } else if event.id == items.visit_sunshine.id() { - (Some(MenuAction::VisitProjectSunshine), false) - } else if event.id == items.visit_moonlight.id() { - (Some(MenuAction::VisitProjectMoonlight), false) - } else if event.id == items.restart.id() { - (Some(MenuAction::Restart), false) - } else if event.id == items.quit.id() { - (Some(MenuAction::Quit), false) - } else { - #[cfg(target_os = "windows")] - if event.id == items.reset_display.id() { - return (Some(MenuAction::ResetDisplayDeviceConfig), false); - } - (None, false) - } +/// Returns the item_id (if any) +fn identify_menu_item(event: &MenuEvent) -> Option { + menu::identify_item_id(event) } -/// Execute the identified action -/// This is called AFTER releasing the state lock to avoid deadlocks -fn execute_action(action: MenuAction, needs_menu_rebuild: bool) { - match action { - MenuAction::ImportConfig => { - // Handle config import in Rust - std::thread::spawn(|| { - if let Err(e) = config::import_config() { - match e { - config::ConfigError::DialogCancelled => {} - _ => { - config::show_message_box( - i18n::get_string(i18n::StringKey::ImportErrorTitle), - &format!("{}", e), - true, - ); - } - } - } - }); - trigger_action(action); - } - MenuAction::ExportConfig => { - // Handle config export in Rust - std::thread::spawn(|| { - if let Err(e) = config::export_config() { - match e { - config::ConfigError::DialogCancelled => {} - _ => { - config::show_message_box( - i18n::get_string(i18n::StringKey::ExportErrorTitle), - &format!("{}", e), - true, - ); - } - } - } - }); - trigger_action(action); - } - MenuAction::ResetConfig => { - // Handle config reset in Rust - std::thread::spawn(|| { - if let Err(e) = config::reset_config() { - match e { - config::ConfigError::DialogCancelled => {} - _ => { - config::show_message_box( - i18n::get_string(i18n::StringKey::ResetErrorTitle), - &format!("{}", e), - true, - ); - } - } - } - }); - trigger_action(action); - } - MenuAction::LanguageChinese => { - set_locale_str("zh"); - let _ = config::save_tray_locale("zh"); - trigger_action(action); - if needs_menu_rebuild { - update_menu_texts(); - } - } - MenuAction::LanguageEnglish => { - set_locale_str("en"); - let _ = config::save_tray_locale("en"); - trigger_action(action); - if needs_menu_rebuild { - update_menu_texts(); - } - } - MenuAction::LanguageJapanese => { - set_locale_str("ja"); - let _ = config::save_tray_locale("ja"); - trigger_action(action); - if needs_menu_rebuild { - update_menu_texts(); - } - } - MenuAction::StarProject => { - open_url(urls::GITHUB_PROJECT); - trigger_action(action); - } - MenuAction::VisitProjectSunshine => { - open_url(urls::PROJECT_SUNSHINE); - trigger_action(action); - } - MenuAction::VisitProjectMoonlight => { - open_url(urls::PROJECT_MOONLIGHT); - trigger_action(action); - } - _ => { - // For all other actions (VddCreate, VddClose, VddPersistent, CloseApp, ResetDisplayDeviceConfig, Restart, Quit), - // just trigger the callback to let C++ handle them - trigger_action(action); - } +/// 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 action while holding the lock, -/// then releases the lock before executing to avoid deadlocks +/// Process a menu event - identifies the item and executes its handler fn process_menu_event(event: &MenuEvent) { - let (action, needs_rebuild) = { - // Hold lock only while identifying the action - if let Some(state_mutex) = TRAY_STATE.get() { - let state_guard = state_mutex.lock(); - if let Some(ref state) = *state_guard { - identify_menu_action(event, state) - } else { - (None, false) - } - } else { - (None, false) - } - // Lock is released here - }; - - // Execute action without holding the lock - if let Some(action) = action { - execute_action(action, needs_rebuild); + if let Some(item_id) = identify_menu_item(event) { + execute_action_by_id(&item_id); } } @@ -564,37 +249,27 @@ fn process_menu_event(event: &MenuEvent) { /// 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 { - // Get the current VDD states before rebuilding - let vdd_create_checked = state.menu_items.vdd_create.is_checked(); - let vdd_close_checked = state.menu_items.vdd_close.is_checked(); - let vdd_persistent_checked = state.menu_items.vdd_persistent.is_checked(); - let vdd_create_enabled = state.menu_items.vdd_create.is_enabled(); - let vdd_close_enabled = state.menu_items.vdd_close.is_enabled(); - - // Build a completely new menu with the updated language - let (new_menu, new_menu_items, new_vdd_submenu, new_advanced_submenu, new_language_submenu, new_visit_submenu) = build_menu(); - - // Restore the VDD states - new_menu_items.vdd_create.set_checked(vdd_create_checked); - new_menu_items.vdd_close.set_checked(vdd_close_checked); - new_menu_items.vdd_persistent.set_checked(vdd_persistent_checked); - new_menu_items.vdd_create.set_enabled(vdd_create_enabled); - new_menu_items.vdd_close.set_enabled(vdd_close_enabled); - - // Set the new menu on the tray icon - // This properly detaches the old menu subclass and attaches the new one let _ = state.icon.set_menu(Some(Box::new(new_menu.clone()))); - - // Update the state with the new menu and items state.menu = new_menu; - state.menu_items = new_menu_items; - state.vdd_submenu = new_vdd_submenu; - state.advanced_settings_submenu = new_advanced_submenu; - state.language_submenu = new_language_submenu; - state.visit_project_submenu = new_visit_submenu; } } } @@ -672,8 +347,8 @@ pub unsafe extern "C" fn tray_init_ex( // Get tooltip let tooltip_str = c_str_to_string(tooltip).unwrap_or_else(|| "Sunshine".to_string()); - // Build menu - let (menu, menu_items, vdd_submenu, advanced_settings_submenu, language_submenu, visit_project_submenu) = build_menu(); + // Build menu using the menu module + let menu = menu::rebuild_menu(); // Create tray icon let tray_icon = match TrayIconBuilder::new() @@ -693,11 +368,6 @@ pub unsafe extern "C" fn tray_init_ex( let state = TrayState { icon: tray_icon, menu, - vdd_submenu, - advanced_settings_submenu, - language_submenu, - visit_project_submenu, - menu_items, }; if let Some(state_mutex) = TRAY_STATE.get() { @@ -885,36 +555,24 @@ pub unsafe extern "C" fn tray_set_tooltip(tooltip: *const c_char) { /// Update the VDD create menu item state #[no_mangle] pub extern "C" fn tray_set_vdd_create_state(checked: c_int, enabled: c_int) { - if let Some(state_mutex) = TRAY_STATE.get() { - let state_guard = state_mutex.lock(); - if let Some(ref state) = *state_guard { - state.menu_items.vdd_create.set_checked(checked != 0); - state.menu_items.vdd_create.set_enabled(enabled != 0); - } - } + use menu_items::ids; + menu::set_check_state_by_id(ids::VDD_CREATE, checked != 0); + menu::set_item_enabled_by_id(ids::VDD_CREATE, enabled != 0); } /// Update the VDD close menu item state #[no_mangle] pub extern "C" fn tray_set_vdd_close_state(checked: c_int, enabled: c_int) { - if let Some(state_mutex) = TRAY_STATE.get() { - let state_guard = state_mutex.lock(); - if let Some(ref state) = *state_guard { - state.menu_items.vdd_close.set_checked(checked != 0); - state.menu_items.vdd_close.set_enabled(enabled != 0); - } - } + use menu_items::ids; + menu::set_check_state_by_id(ids::VDD_CLOSE, checked != 0); + menu::set_item_enabled_by_id(ids::VDD_CLOSE, enabled != 0); } /// Update the VDD persistent menu item state #[no_mangle] pub extern "C" fn tray_set_vdd_persistent_state(checked: c_int) { - if let Some(state_mutex) = TRAY_STATE.get() { - let state_guard = state_mutex.lock(); - if let Some(ref state) = *state_guard { - state.menu_items.vdd_persistent.set_checked(checked != 0); - } - } + use menu_items::ids; + menu::set_check_state_by_id(ids::VDD_PERSISTENT, checked != 0); } /// Set the current locale diff --git a/rust_tray/src/menu.rs b/rust_tray/src/menu.rs new file mode 100644 index 00000000000..cc6221eca53 --- /dev/null +++ b/rust_tray/src/menu.rs @@ -0,0 +1,276 @@ +//! 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 +} + +// ============================================================================ +// Backward compatibility functions using item IDs from menu_items::ids +// ============================================================================ + +use crate::actions::MenuAction; + +/// Set check state by MenuAction (for C API compatibility) +pub fn set_check_state(action: MenuAction, checked: bool) { + if let Some(item_id) = action_to_item_id(action) { + set_check_state_by_id(item_id, checked); + } +} + +/// Identify MenuAction from menu event (for lib.rs compatibility) +pub fn identify_action(event: &MenuEvent) -> Option { + identify_item_id(event).and_then(|id| item_id_to_action(&id)) +} + +/// Map MenuAction to item_id +fn action_to_item_id(action: MenuAction) -> Option<&'static str> { + use menu_items::ids::*; + match action { + MenuAction::OpenUI => Some(OPEN_SUNSHINE), + MenuAction::VddCreate => Some(VDD_CREATE), + MenuAction::VddClose => Some(VDD_CLOSE), + MenuAction::VddPersistent => Some(VDD_PERSISTENT), + MenuAction::ImportConfig => Some(IMPORT_CONFIG), + MenuAction::ExportConfig => Some(EXPORT_CONFIG), + MenuAction::ResetConfig => Some(RESET_CONFIG), + MenuAction::CloseApp => Some(CLOSE_APP), + MenuAction::ResetDisplayDeviceConfig => Some(RESET_DISPLAY), + MenuAction::LanguageChinese => Some(LANG_CHINESE), + MenuAction::LanguageEnglish => Some(LANG_ENGLISH), + MenuAction::LanguageJapanese => Some(LANG_JAPANESE), + MenuAction::StarProject => Some(STAR_PROJECT), + MenuAction::VisitProjectSunshine => Some(VISIT_SUNSHINE), + MenuAction::VisitProjectMoonlight => Some(VISIT_MOONLIGHT), + MenuAction::Restart => Some(RESTART), + MenuAction::Quit => Some(QUIT), + // NotificationClicked is not a menu item + MenuAction::NotificationClicked => None, + } +} + +/// Map item_id to MenuAction +fn item_id_to_action(item_id: &str) -> Option { + use menu_items::ids::*; + match item_id { + OPEN_SUNSHINE => Some(MenuAction::OpenUI), + VDD_CREATE => Some(MenuAction::VddCreate), + VDD_CLOSE => Some(MenuAction::VddClose), + VDD_PERSISTENT => Some(MenuAction::VddPersistent), + IMPORT_CONFIG => Some(MenuAction::ImportConfig), + EXPORT_CONFIG => Some(MenuAction::ExportConfig), + RESET_CONFIG => Some(MenuAction::ResetConfig), + CLOSE_APP => Some(MenuAction::CloseApp), + RESET_DISPLAY => Some(MenuAction::ResetDisplayDeviceConfig), + LANG_CHINESE => Some(MenuAction::LanguageChinese), + LANG_ENGLISH => Some(MenuAction::LanguageEnglish), + LANG_JAPANESE => Some(MenuAction::LanguageJapanese), + STAR_PROJECT => Some(MenuAction::StarProject), + VISIT_SUNSHINE => Some(MenuAction::VisitProjectSunshine), + VISIT_MOONLIGHT => Some(MenuAction::VisitProjectMoonlight), + RESTART => Some(MenuAction::Restart), + QUIT => Some(MenuAction::Quit), + _ => None, + } +} + +#[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..65aa76efc32 --- /dev/null +++ b/rust_tray/src/menu_items.rs @@ -0,0 +1,405 @@ +//! 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::{open_url, urls, trigger_action}; +use crate::config; + +/// Menu item handler function type +pub type MenuHandler = fn(); + +/// 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 IMPORT_CONFIG: &str = "import_config"; + pub const EXPORT_CONFIG: &str = "export_config"; + pub const RESET_CONFIG: &str = "reset_config"; + pub const SEP_ADV: &str = "sep_adv"; + 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 open_sunshine() { + // Handled by C++ callback + } + + pub fn vdd_create() { + // Handled by C++ callback + } + + pub fn vdd_close() { + // Handled by C++ callback + } + + pub fn vdd_persistent() { + // Handled by C++ callback + } + + pub fn import_config() { + std::thread::spawn(|| { + if let Err(e) = config::import_config() { + match e { + config::ConfigError::DialogCancelled => {} + _ => { + config::show_message_box( + get_string(StringKey::ImportErrorTitle), + &format!("{}", e), + true, + ); + } + } + } + }); + } + + pub fn export_config() { + std::thread::spawn(|| { + if let Err(e) = config::export_config() { + match e { + config::ConfigError::DialogCancelled => {} + _ => { + config::show_message_box( + get_string(StringKey::ExportErrorTitle), + &format!("{}", e), + true, + ); + } + } + } + }); + } + + pub fn reset_config() { + std::thread::spawn(|| { + if let Err(e) = config::reset_config() { + match e { + config::ConfigError::DialogCancelled => {} + _ => { + config::show_message_box( + get_string(StringKey::ResetErrorTitle), + &format!("{}", e), + true, + ); + } + } + } + }); + } + + pub fn close_app() { + // Handled by C++ callback + } + + pub fn reset_display() { + // Handled by C++ callback + } + + pub fn lang_chinese() { + set_locale_str("zh"); + let _ = config::save_tray_locale("zh"); + } + + pub fn lang_english() { + set_locale_str("en"); + let _ = config::save_tray_locale("en"); + } + + pub fn lang_japanese() { + set_locale_str("ja"); + let _ = config::save_tray_locale("ja"); + } + + pub fn star_project() { + open_url(urls::GITHUB_PROJECT); + } + + pub fn visit_sunshine() { + open_url(urls::PROJECT_SUNSHINE); + } + + pub fn visit_moonlight() { + open_url(urls::PROJECT_MOONLIGHT); + } + + pub fn restart() { + // Handled by C++ callback + } + + pub fn quit() { + // Handled by C++ callback + } +} + +// ============================================================================ +// 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) + .with_handler(handlers::open_sunshine), + + 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) + .with_handler(handlers::vdd_create), + MenuItemInfo::check(VDD_CLOSE, StringKey::VddClose, Some(VDD_SUBMENU), false, 320) + .with_handler(handlers::vdd_close), + MenuItemInfo::check(VDD_PERSISTENT, StringKey::VddPersistent, Some(VDD_SUBMENU), false, 330) + .with_handler(handlers::vdd_persistent), + + // ====== Advanced Settings Submenu ====== + MenuItemInfo::submenu(ADVANCED_SUBMENU, StringKey::AdvancedSettings, None, 400), + MenuItemInfo::action(IMPORT_CONFIG, StringKey::ImportConfig, Some(ADVANCED_SUBMENU), 410) + .with_handler(handlers::import_config), + MenuItemInfo::action(EXPORT_CONFIG, StringKey::ExportConfig, Some(ADVANCED_SUBMENU), 420) + .with_handler(handlers::export_config), + MenuItemInfo::action(RESET_CONFIG, StringKey::ResetToDefault, Some(ADVANCED_SUBMENU), 430) + .with_handler(handlers::reset_config), + MenuItemInfo::separator(SEP_ADV, Some(ADVANCED_SUBMENU), 440), + 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) + .with_handler(handlers::star_project), + + // ====== Visit Project Submenu ====== + MenuItemInfo::submenu(VISIT_SUBMENU, StringKey::VisitProject, None, 900), + MenuItemInfo::action(VISIT_SUNSHINE, StringKey::VisitProjectSunshine, Some(VISIT_SUBMENU), 910) + .with_handler(handlers::visit_sunshine), + MenuItemInfo::action(VISIT_MOONLIGHT, StringKey::VisitProjectMoonlight, Some(VISIT_SUBMENU), 920) + .with_handler(handlers::visit_moonlight), + + MenuItemInfo::separator(SEP_4, None, 1000), + + // ====== Restart & Quit ====== + MenuItemInfo::action(RESTART, StringKey::Restart, None, 1100) + .with_handler(handlers::restart), + 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; + + if let Some(handler) = item.handler { + handler(); + // Also trigger the C++ callback for items that need it + trigger_action_for_id(item_id); + return (true, needs_rebuild); + } + } + + (false, false) +} + +/// Map item ID to MenuAction for C++ callback +fn trigger_action_for_id(item_id: &str) { + use crate::actions::MenuAction; + use ids::*; + + let action = match item_id { + OPEN_SUNSHINE => Some(MenuAction::OpenUI), + VDD_CREATE => Some(MenuAction::VddCreate), + VDD_CLOSE => Some(MenuAction::VddClose), + VDD_PERSISTENT => Some(MenuAction::VddPersistent), + IMPORT_CONFIG => Some(MenuAction::ImportConfig), + EXPORT_CONFIG => Some(MenuAction::ExportConfig), + RESET_CONFIG => Some(MenuAction::ResetConfig), + CLOSE_APP => Some(MenuAction::CloseApp), + LANG_CHINESE => Some(MenuAction::LanguageChinese), + LANG_ENGLISH => Some(MenuAction::LanguageEnglish), + LANG_JAPANESE => Some(MenuAction::LanguageJapanese), + STAR_PROJECT => Some(MenuAction::StarProject), + VISIT_SUNSHINE => Some(MenuAction::VisitProjectSunshine), + VISIT_MOONLIGHT => Some(MenuAction::VisitProjectMoonlight), + RESET_DISPLAY => Some(MenuAction::ResetDisplayDeviceConfig), + RESTART => Some(MenuAction::Restart), + QUIT => Some(MenuAction::Quit), + _ => None, + }; + + if let Some(action) = action { + trigger_action(action); + } +} From 0dbf3b185034cfd68f4bf04b3609ab2513a9e6fa Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 20 Jan 2026 15:57:51 +0800 Subject: [PATCH 20/36] =?UTF-8?q?refactor:=20=E9=A1=B9=E7=9B=AE=E4=BB=85?= =?UTF-8?q?=E9=99=90Windows=E5=B9=B3=E5=8F=B0=EF=BC=8C=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=AF=B9=E5=85=B6=E4=BB=96=E5=B9=B3=E5=8F=B0=E7=9A=84=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/Cargo.toml | 12 +-- rust_tray/src/config.rs | 51 ++---------- rust_tray/src/lib.rs | 167 ++++++++-------------------------------- 3 files changed, 40 insertions(+), 190 deletions(-) diff --git a/rust_tray/Cargo.toml b/rust_tray/Cargo.toml index 72b1b06d8d2..d33a863a20d 100644 --- a/rust_tray/Cargo.toml +++ b/rust_tray/Cargo.toml @@ -3,6 +3,7 @@ name = "sunshine_tray" version = "0.1.0" edition = "2021" build = "build.rs" +description = "Windows-only system tray implementation for Sunshine" [lib] name = "tray" @@ -11,11 +12,11 @@ crate-type = ["staticlib"] [dependencies] tray-icon = "0.19" muda = "0.15" -image = { version = "0.25", default-features = false, features = ["png", "ico"] } 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 = [ @@ -25,15 +26,6 @@ windows-sys = { version = "0.59", features = [ "Win32_UI_Controls_Dialogs", ] } -[target.'cfg(target_os = "linux")'.dependencies] -gtk = "0.18" -glib = "0.20" - -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = "0.5" -objc2-app-kit = { version = "0.2", features = ["NSApplication", "NSRunningApplication"] } -objc2-foundation = { version = "0.2", features = ["NSRunLoop", "NSDate"] } - [build-dependencies] # bindgen = "0.71" # Disabled: requires LLVM/Clang installation diff --git a/rust_tray/src/config.rs b/rust_tray/src/config.rs index a68f5c6f206..bccbf208a4c 100644 --- a/rust_tray/src/config.rs +++ b/rust_tray/src/config.rs @@ -1,4 +1,4 @@ -//! Configuration file operations module +//! Configuration file operations module (Windows only) //! //! Provides functionality for reading, writing, importing, exporting, //! and resetting the Sunshine configuration file. @@ -7,17 +7,14 @@ //! which provides the exact path used by the main Sunshine application. use std::collections::HashMap; +use std::ffi::OsStr; use std::fs; +use std::os::windows::ffi::OsStrExt; use std::path::PathBuf; use crate::i18n::{get_string, StringKey}; use crate::get_config_file_path_from_cpp; -#[cfg(target_os = "windows")] -use std::ffi::OsStr; -#[cfg(target_os = "windows")] -use std::os::windows::ffi::OsStrExt; - /// Result type for config operations pub type ConfigResult = Result; @@ -49,6 +46,7 @@ impl std::fmt::Display for ConfigError { /// /// The path is provided by C++ via tray_init_ex, ensuring consistency /// with the main Sunshine application's configuration path. +/// with the main Sunshine application's configuration path. pub fn get_config_file_path() -> Option { get_config_file_path_from_cpp() .map(PathBuf::from) @@ -120,8 +118,7 @@ pub fn save_config_value(key: &str, value: &str) -> ConfigResult<()> { write_config(&vars) } -/// Show message box (Windows) -#[cfg(target_os = "windows")] +/// Show message box pub fn show_message_box(title: &str, message: &str, is_error: bool) { use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONERROR, MB_ICONINFORMATION}; @@ -147,14 +144,7 @@ pub fn show_message_box(title: &str, message: &str, is_error: bool) { } } -/// Show message box (non-Windows - just log for now) -#[cfg(not(target_os = "windows"))] -pub fn show_message_box(title: &str, message: &str, _is_error: bool) { - eprintln!("[{}] {}", title, message); -} - -/// Show confirmation dialog (Windows) -#[cfg(target_os = "windows")] +/// Show confirmation dialog pub fn show_confirm_dialog(title: &str, message: &str) -> bool { use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_YESNO, MB_ICONQUESTION, IDYES}; @@ -179,16 +169,7 @@ pub fn show_confirm_dialog(title: &str, message: &str) -> bool { } } -/// Show confirmation dialog (non-Windows) -#[cfg(not(target_os = "windows"))] -pub fn show_confirm_dialog(title: &str, message: &str) -> bool { - eprintln!("[{}] {}", title, message); - // On non-Windows, default to yes for now - true -} - -/// Open file dialog for importing config (Windows) -#[cfg(target_os = "windows")] +/// Open file dialog for importing config pub fn open_import_dialog() -> ConfigResult { use windows_sys::Win32::UI::Controls::Dialogs::{ GetOpenFileNameW, OPENFILENAMEW, OFN_EXPLORER, OFN_FILEMUSTEXIST, OFN_PATHMUSTEXIST, @@ -227,8 +208,7 @@ pub fn open_import_dialog() -> ConfigResult { } } -/// Open file dialog for exporting config (Windows) -#[cfg(target_os = "windows")] +/// Open file dialog for exporting config pub fn open_export_dialog() -> ConfigResult { use windows_sys::Win32::UI::Controls::Dialogs::{ GetSaveFileNameW, OPENFILENAMEW, OFN_EXPLORER, OFN_OVERWRITEPROMPT, @@ -282,21 +262,6 @@ pub fn open_export_dialog() -> ConfigResult { } } -/// Open file dialog for importing config (non-Windows placeholder) -#[cfg(not(target_os = "windows"))] -pub fn open_import_dialog() -> ConfigResult { - // For non-Windows, use a simple approach or external crate - eprintln!("File dialog not implemented for this platform"); - Err(ConfigError::NoUserSession) -} - -/// Open file dialog for exporting config (non-Windows placeholder) -#[cfg(not(target_os = "windows"))] -pub fn open_export_dialog() -> ConfigResult { - eprintln!("File dialog not implemented for this platform"); - Err(ConfigError::NoUserSession) -} - /// Import configuration from file pub fn import_config() -> ConfigResult<()> { // Open file dialog diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 50d74a067a0..8da9046b1f3 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -1,14 +1,17 @@ -//! Sunshine Tray - Rust implementation of the system tray +//! 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; @@ -19,26 +22,19 @@ pub mod menu_items; use std::ffi::CStr; use std::os::raw::{c_char, c_int}; use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; -#[cfg(not(target_os = "windows"))] -use image::ImageReader; use muda::{Menu, MenuEvent}; use once_cell::sync::OnceCell; use parking_lot::Mutex; use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; - -use i18n::set_locale_str; -use actions::{register_callback, ActionCallback}; - -#[cfg(target_os = "windows")] use windows_sys::Win32::UI::WindowsAndMessaging::{ DispatchMessageW, GetMessageW, PeekMessageW, PostQuitMessage, TranslateMessage, MSG, WM_QUIT, PM_REMOVE, WM_DPICHANGED, }; -#[cfg(target_os = "windows")] -use std::sync::atomic::AtomicI32; +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 @@ -57,11 +53,9 @@ 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 -#[cfg(target_os = "windows")] static CURRENT_ICON_TYPE: AtomicI32 = AtomicI32::new(0); /// Cached DPI value to detect DPI changes -#[cfg(target_os = "windows")] static CACHED_DPI_SIZE: AtomicI32 = AtomicI32::new(0); /// Config file path storage (set from C++) @@ -92,7 +86,6 @@ unsafe fn c_str_to_string(ptr: *const c_char) -> Option { /// Get the system small icon size (used for notification area icons) /// This size is DPI-aware and matches what Windows expects for tray icons -#[cfg(target_os = "windows")] fn get_system_small_icon_size() -> (u32, u32) { use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON, SM_CYSMICON}; @@ -113,7 +106,6 @@ fn get_system_small_icon_size() -> (u32, u32) { /// Check if DPI has changed since last icon load /// Returns true if DPI changed and icon needs refresh -#[cfg(target_os = "windows")] fn check_dpi_changed() -> bool { use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON}; @@ -131,7 +123,6 @@ fn check_dpi_changed() -> bool { } /// Refresh the current icon with new DPI settings -#[cfg(target_os = "windows")] fn refresh_icon_for_dpi() { let icon_type = CURRENT_ICON_TYPE.load(Ordering::SeqCst); @@ -161,7 +152,6 @@ fn refresh_icon_for_dpi() { /// 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 -#[cfg(target_os = "windows")] 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(); @@ -177,47 +167,14 @@ fn load_icon_from_path(path: &str) -> Option { } } -/// Load icon from file path on non-Windows platforms -/// On Linux/macOS, ICO files are decoded using the image crate -#[cfg(not(target_os = "windows"))] -fn load_icon_from_path(path: &str) -> Option { - let path = Path::new(path); - - let img = match ImageReader::open(path) { - Ok(reader) => match reader.decode() { - Ok(img) => img.into_rgba8(), - Err(e) => { - eprintln!("Failed to decode icon '{}': {}", path.display(), e); - return None; - } - }, - Err(e) => { - eprintln!("Failed to open icon '{}': {}", path.display(), e); - return None; - } - }; - - let (width, height) = img.dimensions(); - let rgba = img.into_raw(); - - Icon::from_rgba(rgba, width, height).ok() -} - -/// Load icon -/// -/// On Windows: expects .ico file path (supports multi-resolution) -/// On Linux: can be either a file path or an icon name (searches system dirs) -/// On macOS: expects file path +/// Load icon from ICO file path fn load_icon(icon_str: &str) -> Option { - // First, try as a direct file path if Path::new(icon_str).exists() { return load_icon_from_path(icon_str); } - { - eprintln!("Icon not found: {}", icon_str); - None - } + eprintln!("Icon not found: {}", icon_str); + None } /// Identify which action corresponds to the menu event @@ -390,99 +347,42 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { return -1; } - #[cfg(target_os = "windows")] - { - 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; - } - } + unsafe { + let mut msg: MSG = std::mem::zeroed(); - if msg.message == WM_QUIT { + if blocking != 0 { + if GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) <= 0 { return -1; } - - // Handle DPI change - refresh icon with new size - if msg.message == WM_DPICHANGED || check_dpi_changed() { - refresh_icon_for_dpi(); + } else { + if PeekMessageW(&mut msg, std::ptr::null_mut(), 0, 0, PM_REMOVE) == 0 { + return 0; } - - 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) { + if msg.message == WM_QUIT { return -1; } - 0 - } - - #[cfg(target_os = "linux")] - { - // GTK event loop - while gtk::events_pending() { - gtk::main_iteration(); - } - - if blocking != 0 { - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - // Process menu events - use process_menu_event to avoid deadlocks - if let Ok(event) = MenuEvent::receiver().try_recv() { - process_menu_event(&event); + // Handle DPI change - refresh icon with new size + if msg.message == WM_DPICHANGED || check_dpi_changed() { + refresh_icon_for_dpi(); } - if SHOULD_EXIT.load(Ordering::SeqCst) { - return -1; - } - - 0 + TranslateMessage(&msg); + DispatchMessageW(&msg); } - #[cfg(target_os = "macos")] - { - use objc2::rc::autoreleasepool; - use objc2_foundation::{NSDate, NSRunLoop}; - - autoreleasepool(|_| { - unsafe { - let run_loop = NSRunLoop::currentRunLoop(); - let interval = if blocking != 0 { 0.1 } else { 0.0 }; - let date = NSDate::dateWithTimeIntervalSinceNow(interval); - run_loop.runUntilDate(&date); - } - }); - - // 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 + // Process menu events - use process_menu_event to avoid deadlocks + if let Ok(event) = MenuEvent::receiver().try_recv() { + process_menu_event(&event); } - #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] - { - -1 + if SHOULD_EXIT.load(Ordering::SeqCst) { + return -1; } + + 0 } /// Exit the tray event loop @@ -490,16 +390,10 @@ pub extern "C" fn tray_loop(blocking: c_int) -> c_int { pub extern "C" fn tray_exit() { SHOULD_EXIT.store(true, Ordering::SeqCst); - #[cfg(target_os = "windows")] unsafe { PostQuitMessage(0); } - #[cfg(target_os = "linux")] - { - gtk::main_quit(); - } - // Clean up state if let Some(state_mutex) = TRAY_STATE.get() { *state_mutex.lock() = None; @@ -513,7 +407,6 @@ pub extern "C" fn tray_exit() { #[no_mangle] pub extern "C" fn tray_set_icon(icon_type: c_int) { // Store current icon type for DPI change refresh - #[cfg(target_os = "windows")] CURRENT_ICON_TYPE.store(icon_type, Ordering::SeqCst); let icon_paths = match ICON_PATHS.get() { From b25b16004b541116c1a2bbb76dc1a4f70549122b Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:17:55 +0800 Subject: [PATCH 21/36] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/config.rs | 122 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/rust_tray/src/config.rs b/rust_tray/src/config.rs index bccbf208a4c..1382d991801 100644 --- a/rust_tray/src/config.rs +++ b/rust_tray/src/config.rs @@ -27,6 +27,12 @@ pub enum ConfigError { NoConfigFound, DialogCancelled, NoUserSession, + InvalidExtension, + SymlinkNotAllowed, + NotRegularFile, + FileTooLarge, + FileEmpty, + InvalidFormat(String), } impl std::fmt::Display for ConfigError { @@ -38,6 +44,12 @@ impl std::fmt::Display for ConfigError { ConfigError::NoConfigFound => write!(f, "No configuration found"), ConfigError::DialogCancelled => write!(f, "Dialog cancelled"), ConfigError::NoUserSession => write!(f, "No active user session"), + ConfigError::InvalidExtension => write!(f, "Invalid file extension (only .conf allowed)"), + ConfigError::SymlinkNotAllowed => write!(f, "Symbolic links are not allowed"), + ConfigError::NotRegularFile => write!(f, "Path is not a regular file"), + ConfigError::FileTooLarge => write!(f, "File exceeds maximum size (1MB)"), + ConfigError::FileEmpty => write!(f, "Configuration file is empty"), + ConfigError::InvalidFormat(msg) => write!(f, "Invalid configuration format: {}", msg), } } } @@ -262,20 +274,128 @@ pub fn open_export_dialog() -> ConfigResult { } } +// ============================================================================ +// Security Validation Functions +// ============================================================================ + +/// Maximum allowed configuration file size (1MB) +const MAX_CONFIG_SIZE: usize = 1024 * 1024; + +/// Validate the file path for security +/// +/// Checks: +/// - File exists +/// - Not a symbolic link +/// - Is a regular file +/// - Has .conf extension +fn validate_file_path(path: &std::path::Path) -> ConfigResult<()> { + // Check if file exists + if !path.exists() { + return Err(ConfigError::ReadFailed("File does not exist".to_string())); + } + + // Check for symbolic link (prevent symlink attacks) + if path.symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + return Err(ConfigError::SymlinkNotAllowed); + } + + // Ensure it's a regular file + if !path.is_file() { + return Err(ConfigError::NotRegularFile); + } + + // Check file extension + if path.extension().map_or(true, |ext| ext != "conf") { + return Err(ConfigError::InvalidExtension); + } + + Ok(()) +} + +/// Validate the configuration file content +/// +/// Checks: +/// - File size within limits +/// - Not empty +/// - Basic format validation (key=value or comment lines) +fn validate_config_content(content: &str) -> ConfigResult<()> { + // Check file size + if content.len() > MAX_CONFIG_SIZE { + return Err(ConfigError::FileTooLarge); + } + + // Check if empty + if content.trim().is_empty() { + return Err(ConfigError::FileEmpty); + } + + // Basic format validation + // Sunshine config format: key = value or # comment or [section] + let mut has_valid_line = false; + for (line_num, line) in content.lines().enumerate() { + let trimmed = line.trim(); + + // Skip empty lines + if trimmed.is_empty() { + continue; + } + + // Allow comment lines + if trimmed.starts_with('#') || trimmed.starts_with(';') { + has_valid_line = true; + continue; + } + + // Allow section headers + if trimmed.starts_with('[') && trimmed.ends_with(']') { + has_valid_line = true; + continue; + } + + // Check for key=value format + if trimmed.contains('=') { + has_valid_line = true; + continue; + } + + // Invalid line found + return Err(ConfigError::InvalidFormat( + format!("Invalid syntax at line {}: {}", line_num + 1, + if trimmed.len() > 50 { &trimmed[..50] } else { trimmed }) + )); + } + + // Must have at least one valid line (could be just comments for a minimal config) + if !has_valid_line { + return Err(ConfigError::InvalidFormat("No valid configuration lines found".to_string())); + } + + Ok(()) +} + /// Import configuration from file pub fn import_config() -> ConfigResult<()> { // Open file dialog let source_path = open_import_dialog()?; + // Security validation: check file path + validate_file_path(&source_path)?; + // Read source file let content = fs::read_to_string(&source_path) .map_err(|e| ConfigError::ReadFailed(e.to_string()))?; + // Security validation: check content + validate_config_content(&content)?; + // Get destination path let dest_path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; // Write to config file - fs::write(&dest_path, content) + fs::write(&dest_path, &content) .map_err(|e| ConfigError::WriteFailed(e.to_string()))?; show_message_box( From b51d02b04e5e77dcfa829327ab9c6217418327a4 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:19:19 +0800 Subject: [PATCH 22/36] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=96=B0?= =?UTF-8?q?=E7=89=88=E6=89=98=E7=9B=98vdd=E7=8A=B6=E6=80=81=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/include/rust_tray.h | 30 ++++++------- rust_tray/src/lib.rs | 45 ++++++++++--------- rust_tray/src/menu.rs | 30 +++++++++++++ rust_tray/src/menu_items.rs | 38 +++++++++++++--- src/system_tray_rust.cpp | 81 ++++++++++++++++++++++++++++++----- 5 files changed, 170 insertions(+), 54 deletions(-) diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h index 897b5f536cc..2c07662c002 100644 --- a/rust_tray/include/rust_tray.h +++ b/rust_tray/include/rust_tray.h @@ -102,24 +102,20 @@ void tray_set_icon(int icon_type); void tray_set_tooltip(const char* tooltip); /** - * @brief Update the VDD create menu item state - * @param checked Non-zero to check (VDD is active), zero to uncheck - * @param enabled Non-zero to enable, zero to disable - */ -void tray_set_vdd_create_state(int checked, int enabled); - -/** - * @brief Update the VDD close menu item state - * @param checked Non-zero to check (VDD is not active), zero to uncheck - * @param enabled Non-zero to enable, zero to disable - */ -void tray_set_vdd_close_state(int checked, int enabled); - -/** - * @brief Update the VDD persistent menu item state - * @param checked Non-zero to check (persistent mode enabled), zero to uncheck + * @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_set_vdd_persistent_state(int checked); +void tray_update_vdd_menu(int can_create, int can_close, int is_persistent, int is_active); /** * @brief Set the current locale diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 8da9046b1f3..53962de08fc 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -445,27 +445,32 @@ pub unsafe extern "C" fn tray_set_tooltip(tooltip: *const c_char) { } } -/// Update the VDD create menu item state -#[no_mangle] -pub extern "C" fn tray_set_vdd_create_state(checked: c_int, enabled: c_int) { - use menu_items::ids; - menu::set_check_state_by_id(ids::VDD_CREATE, checked != 0); - menu::set_item_enabled_by_id(ids::VDD_CREATE, enabled != 0); -} - -/// Update the VDD close menu item state -#[no_mangle] -pub extern "C" fn tray_set_vdd_close_state(checked: c_int, enabled: c_int) { - use menu_items::ids; - menu::set_check_state_by_id(ids::VDD_CLOSE, checked != 0); - menu::set_item_enabled_by_id(ids::VDD_CLOSE, enabled != 0); -} - -/// Update the VDD persistent menu item state +/// 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_set_vdd_persistent_state(checked: c_int) { - use menu_items::ids; - menu::set_check_state_by_id(ids::VDD_PERSISTENT, checked != 0); +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 diff --git a/rust_tray/src/menu.rs b/rust_tray/src/menu.rs index cc6221eca53..d172532327a 100644 --- a/rust_tray/src/menu.rs +++ b/rust_tray/src/menu.rs @@ -264,6 +264,36 @@ fn item_id_to_action(item_id: &str) -> Option { } } +// ============================================================================ +// 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::*; diff --git a/rust_tray/src/menu_items.rs b/rust_tray/src/menu_items.rs index 65aa76efc32..ec4f2a48d5b 100644 --- a/rust_tray/src/menu_items.rs +++ b/rust_tray/src/menu_items.rs @@ -172,15 +172,18 @@ mod handlers { } pub fn vdd_create() { - // Handled by C++ callback + // VDD create/close validation is handled by C++ side (cooldown + state check) + // C++ will only call into Rust after validation passes } pub fn vdd_close() { - // Handled by C++ callback + // VDD create/close validation is handled by C++ side (cooldown + state check) + // C++ will only call into Rust after validation passes } pub fn vdd_persistent() { - // Handled by C++ callback + // VDD persistent toggle - C++ handles the confirmation and config save + // The Rust side only receives the menu click event } pub fn import_config() { @@ -235,11 +238,25 @@ mod handlers { } pub fn close_app() { - // Handled by C++ callback + // Show confirmation dialog before closing app + if !config::show_confirm_dialog( + get_string(StringKey::CloseAppConfirmTitle), + get_string(StringKey::CloseAppConfirmMsg), + ) { + return; + } + // C++ will handle the actual close } pub fn reset_display() { - // Handled by C++ callback + // Show confirmation dialog before resetting display config + if !config::show_confirm_dialog( + get_string(StringKey::ResetDisplayConfirmTitle), + get_string(StringKey::ResetDisplayConfirmMsg), + ) { + return; + } + // C++ will handle the actual reset } pub fn lang_chinese() { @@ -270,11 +287,18 @@ mod handlers { } pub fn restart() { - // Handled by C++ callback + // Handled by C++ callback directly (no confirmation needed) } pub fn quit() { - // Handled by C++ callback + // Show confirmation dialog before quitting + if !config::show_confirm_dialog( + get_string(StringKey::QuitTitle), + get_string(StringKey::QuitMessage), + ) { + return; + } + // C++ will handle the actual quit } } diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index f523b5c1de6..7b35868ee1b 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -56,9 +56,50 @@ 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::session_t::get().get_vdd_id(); + 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 @@ -70,15 +111,31 @@ namespace system_tray { launch_ui(); break; - case TRAY_ACTION_TOGGLE_VDD_MONITOR: - BOOST_LOG(info) << "Toggling display power from system tray"sv; - display_device::session_t::get().toggle_display_power(); - // Disable toggle for 10 seconds - tray_set_vdd_enabled(0); - std::thread([]() { - std::this_thread::sleep_for(10s); - tray_set_vdd_enabled(1); - }).detach(); + case 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(); + } + } + break; + + case 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(); + } + break; + + case TRAY_ACTION_VDD_PERSISTENT: + BOOST_LOG(info) << "Toggling VDD persistent mode"sv; + // Toggle the keep_enabled setting + config::video.vdd_keep_enabled = !config::video.vdd_keep_enabled; + // Save to config file + config::update_config({{"vdd_keep_enabled", config::video.vdd_keep_enabled ? "true" : "false"}}); + // Update menu state + update_vdd_menu_state(); break; case TRAY_ACTION_IMPORT_CONFIG: @@ -211,6 +268,9 @@ namespace system_tray { return -1; } + // Initialize VDD menu state + update_vdd_menu_state(); + BOOST_LOG(info) << "Rust tray initialized successfully"sv; return 0; } @@ -298,7 +358,8 @@ namespace system_tray { void update_tray_vmonitor_checked(int checked) { if (!tray_initialized) return; - tray_set_vdd_checked(checked); + // Use the unified VDD menu update function + update_vdd_menu_state(); } // Stub implementations for compatibility From 84e48e181ca48626ae684e6fe3f192ff2f0d61ce Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:26:10 +0800 Subject: [PATCH 23/36] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E6=97=A7ap?= =?UTF-8?q?i?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/lib.rs | 86 ------------------------------------------- rust_tray/src/menu.rs | 69 ---------------------------------- 2 files changed, 155 deletions(-) diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 53962de08fc..5e06448108f 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -495,89 +495,3 @@ pub unsafe extern "C" fn tray_show_notification( // Log for now - proper notification support needs platform-specific implementation eprintln!("Notification: {} - {}", title_str, text_str); } - -// ============================================================================ -// Legacy C API compatibility (for existing C++ code) -// ============================================================================ - -/// Legacy tray structure (for compatibility) -#[repr(C)] -pub struct tray { - pub icon: *const c_char, - pub tooltip: *const c_char, - pub notification_icon: *const c_char, - pub notification_text: *const c_char, - pub notification_title: *const c_char, - pub notification_cb: Option, - pub menu: *mut tray_menu, - pub iconPathCount: c_int, - pub allIconPaths: [*const c_char; 4], -} - -/// Legacy tray menu structure (for compatibility) -#[repr(C)] -pub struct tray_menu { - pub text: *const c_char, - pub disabled: c_int, - pub checked: c_int, - pub checkbox: c_int, - pub cb: Option, - pub context: *mut std::ffi::c_void, - pub submenu: *mut tray_menu, -} - -/// Legacy tray_init - not recommended, use tray_init_ex instead -#[no_mangle] -pub unsafe extern "C" fn tray_init(_tray: *mut tray) -> c_int { - eprintln!("Warning: tray_init is deprecated, use tray_init_ex instead"); - -1 -} - -/// Legacy tray_update - partially supported -#[no_mangle] -pub unsafe extern "C" fn tray_update(t: *mut tray) { - if t.is_null() { - return; - } - - let tray_ref = &*t; - - // Update icon if changed - if !tray_ref.icon.is_null() { - if let Some(icon_str) = c_str_to_string(tray_ref.icon) { - if let Some(icon) = load_icon(&icon_str) { - 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)); - } - } - } - } - } - - // Update tooltip - if !tray_ref.tooltip.is_null() { - if let Some(tip) = c_str_to_string(tray_ref.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)); - } - } - } - } - - // Handle notifications - if !tray_ref.notification_title.is_null() && !tray_ref.notification_text.is_null() { - let title = c_str_to_string(tray_ref.notification_title).unwrap_or_default(); - let text = c_str_to_string(tray_ref.notification_text).unwrap_or_default(); - if !title.is_empty() || !text.is_empty() { - tray_show_notification( - tray_ref.notification_title, - tray_ref.notification_text, - 0, - ); - } - } -} diff --git a/rust_tray/src/menu.rs b/rust_tray/src/menu.rs index d172532327a..b6cb6ab359a 100644 --- a/rust_tray/src/menu.rs +++ b/rust_tray/src/menu.rs @@ -195,75 +195,6 @@ pub fn rebuild_menu() -> Menu { menu } -// ============================================================================ -// Backward compatibility functions using item IDs from menu_items::ids -// ============================================================================ - -use crate::actions::MenuAction; - -/// Set check state by MenuAction (for C API compatibility) -pub fn set_check_state(action: MenuAction, checked: bool) { - if let Some(item_id) = action_to_item_id(action) { - set_check_state_by_id(item_id, checked); - } -} - -/// Identify MenuAction from menu event (for lib.rs compatibility) -pub fn identify_action(event: &MenuEvent) -> Option { - identify_item_id(event).and_then(|id| item_id_to_action(&id)) -} - -/// Map MenuAction to item_id -fn action_to_item_id(action: MenuAction) -> Option<&'static str> { - use menu_items::ids::*; - match action { - MenuAction::OpenUI => Some(OPEN_SUNSHINE), - MenuAction::VddCreate => Some(VDD_CREATE), - MenuAction::VddClose => Some(VDD_CLOSE), - MenuAction::VddPersistent => Some(VDD_PERSISTENT), - MenuAction::ImportConfig => Some(IMPORT_CONFIG), - MenuAction::ExportConfig => Some(EXPORT_CONFIG), - MenuAction::ResetConfig => Some(RESET_CONFIG), - MenuAction::CloseApp => Some(CLOSE_APP), - MenuAction::ResetDisplayDeviceConfig => Some(RESET_DISPLAY), - MenuAction::LanguageChinese => Some(LANG_CHINESE), - MenuAction::LanguageEnglish => Some(LANG_ENGLISH), - MenuAction::LanguageJapanese => Some(LANG_JAPANESE), - MenuAction::StarProject => Some(STAR_PROJECT), - MenuAction::VisitProjectSunshine => Some(VISIT_SUNSHINE), - MenuAction::VisitProjectMoonlight => Some(VISIT_MOONLIGHT), - MenuAction::Restart => Some(RESTART), - MenuAction::Quit => Some(QUIT), - // NotificationClicked is not a menu item - MenuAction::NotificationClicked => None, - } -} - -/// Map item_id to MenuAction -fn item_id_to_action(item_id: &str) -> Option { - use menu_items::ids::*; - match item_id { - OPEN_SUNSHINE => Some(MenuAction::OpenUI), - VDD_CREATE => Some(MenuAction::VddCreate), - VDD_CLOSE => Some(MenuAction::VddClose), - VDD_PERSISTENT => Some(MenuAction::VddPersistent), - IMPORT_CONFIG => Some(MenuAction::ImportConfig), - EXPORT_CONFIG => Some(MenuAction::ExportConfig), - RESET_CONFIG => Some(MenuAction::ResetConfig), - CLOSE_APP => Some(MenuAction::CloseApp), - RESET_DISPLAY => Some(MenuAction::ResetDisplayDeviceConfig), - LANG_CHINESE => Some(MenuAction::LanguageChinese), - LANG_ENGLISH => Some(MenuAction::LanguageEnglish), - LANG_JAPANESE => Some(MenuAction::LanguageJapanese), - STAR_PROJECT => Some(MenuAction::StarProject), - VISIT_SUNSHINE => Some(MenuAction::VisitProjectSunshine), - VISIT_MOONLIGHT => Some(MenuAction::VisitProjectMoonlight), - RESTART => Some(MenuAction::Restart), - QUIT => Some(MenuAction::Quit), - _ => None, - } -} - // ============================================================================ // VDD Menu State Update // ============================================================================ From 3993895f0d4d469823783eb33c8866b22d44260e Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:32:07 +0800 Subject: [PATCH 24/36] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=80=9A?= =?UTF-8?q?=E7=9F=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/Cargo.toml | 1 + rust_tray/include/rust_tray.h | 17 ++++++++ rust_tray/src/lib.rs | 67 ++++++++++++++++++++++++++++++-- rust_tray/src/notification.rs | 73 +++++++++++++++++++++++++++++++++++ src/system_tray_rust.cpp | 18 ++++++--- 5 files changed, 167 insertions(+), 9 deletions(-) create mode 100644 rust_tray/src/notification.rs diff --git a/rust_tray/Cargo.toml b/rust_tray/Cargo.toml index d33a863a20d..176ab24e9f9 100644 --- a/rust_tray/Cargo.toml +++ b/rust_tray/Cargo.toml @@ -25,6 +25,7 @@ windows-sys = { version = "0.59", features = [ "Win32_UI_Shell", "Win32_UI_Controls_Dialogs", ] } +winrt-notification = "0.5" [build-dependencies] # bindgen = "0.71" # Disabled: requires LLVM/Clang installation diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h index 2c07662c002..834f7751de7 100644 --- a/rust_tray/include/rust_tray.h +++ b/rust_tray/include/rust_tray.h @@ -123,6 +123,16 @@ void tray_update_vdd_menu(int can_create, int can_close, int is_persistent, int */ 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 @@ -131,6 +141,13 @@ void tray_set_locale(const char* locale); */ 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) * diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 5e06448108f..3c3b925a7b9 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -18,6 +18,7 @@ pub mod actions; pub mod config; pub mod menu; pub mod menu_items; +pub mod notification; use std::ffi::CStr; use std::os::raw::{c_char, c_int}; @@ -482,16 +483,74 @@ pub unsafe extern "C" fn tray_set_locale(locale: *const c_char) { } } -/// Show a notification (placeholder - needs platform-specific implementation) +/// 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, + 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(); - // Log for now - proper notification support needs platform-specific implementation - eprintln!("Notification: {} - {}", title_str, text_str); + 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/notification.rs b/rust_tray/src/notification.rs new file mode 100644 index 00000000000..5dcbddb1d6e --- /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_rust.cpp b/src/system_tray_rust.cpp index 7b35868ee1b..4f13cef37bb 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -326,6 +326,9 @@ namespace system_tray { 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) { @@ -335,6 +338,9 @@ namespace system_tray { 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) { @@ -344,16 +350,18 @@ namespace system_tray { 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; - tray_show_notification( - "Sunshine", - ("PIN required for: " + pin_name).c_str(), - TRAY_ICON_TYPE_NORMAL - ); + // Show localized pairing request notification + tray_show_localized_notification(TRAY_NOTIFICATION_PAIRING_REQUEST, pin_name.c_str()); } void update_tray_vmonitor_checked(int checked) { From fbbd666832418bc6276c900ef0d12e5106b5495f Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 20 Jan 2026 16:57:56 +0800 Subject: [PATCH 25/36] =?UTF-8?q?perf:=20=E7=BB=9F=E4=B8=80=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=8E=9F=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/actions.rs | 6 +++--- rust_tray/src/i18n.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/rust_tray/src/actions.rs b/rust_tray/src/actions.rs index 19fac978eb4..4eceaf1c7ef 100644 --- a/rust_tray/src/actions.rs +++ b/rust_tray/src/actions.rs @@ -3,7 +3,7 @@ //! Defines all menu actions and their identifiers. //! C++ side will register callbacks for these actions. -use std::sync::RwLock; +use parking_lot::RwLock; use once_cell::sync::Lazy; /// Menu action identifiers @@ -70,12 +70,12 @@ static ACTION_CALLBACK: Lazy>> = Lazy::new(|| RwLo /// Register the callback for menu actions pub fn register_callback(callback: ActionCallback) { - *ACTION_CALLBACK.write().unwrap() = Some(callback); + *ACTION_CALLBACK.write() = Some(callback); } /// Trigger a menu action pub fn trigger_action(action: MenuAction) { - if let Some(callback) = *ACTION_CALLBACK.read().unwrap() { + if let Some(callback) = *ACTION_CALLBACK.read() { callback(action as u32); } } diff --git a/rust_tray/src/i18n.rs b/rust_tray/src/i18n.rs index e884b4bb160..4542d48d0b6 100644 --- a/rust_tray/src/i18n.rs +++ b/rust_tray/src/i18n.rs @@ -3,7 +3,7 @@ //! Supports Chinese, English, and Japanese translations. use std::collections::HashMap; -use std::sync::RwLock; +use parking_lot::RwLock; use once_cell::sync::Lazy; /// Supported locales @@ -316,12 +316,12 @@ static TRANSLATIONS: Lazy> = Lazy::ne /// Get current locale pub fn get_locale() -> Locale { - *CURRENT_LOCALE.read().unwrap() + *CURRENT_LOCALE.read() } /// Set current locale pub fn set_locale(locale: Locale) { - *CURRENT_LOCALE.write().unwrap() = locale; + *CURRENT_LOCALE.write() = locale; } /// Set locale from string From aeec55356a4f54489268e4036ded4bcc7696e2ca Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:04:20 +0800 Subject: [PATCH 26/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=9E=84?= =?UTF-8?q?=E5=BB=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmake/compile_definitions/common.cmake | 2 + cmake/compile_definitions/linux.cmake | 4 +- cmake/compile_definitions/macos.cmake | 5 +- src/system_tray.h | 73 +++------------------ src/system_tray_rust.cpp | 89 ++++++-------------------- 5 files changed, 36 insertions(+), 137 deletions(-) diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 46088c50127..0178d4ae575 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -119,6 +119,8 @@ set(SUNSHINE_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/move_by_copy.h" "${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" "${CMAKE_SOURCE_DIR}/src/task_pool.h" "${CMAKE_SOURCE_DIR}/src/thread_pool.h" "${CMAKE_SOURCE_DIR}/src/thread_safe.h" diff --git a/cmake/compile_definitions/linux.cmake b/cmake/compile_definitions/linux.cmake index 729e5fe90fd..704da29e536 100644 --- a/cmake/compile_definitions/linux.cmake +++ b/cmake/compile_definitions/linux.cmake @@ -195,9 +195,7 @@ if(${SUNSHINE_ENABLE_TRAY}) include_directories(SYSTEM ${APPINDICATOR_INCLUDE_DIRS} ${LIBNOTIFY_INCLUDE_DIRS}) link_directories(${APPINDICATOR_LIBRARY_DIRS} ${LIBNOTIFY_LIBRARY_DIRS}) - # Rust tray implementation - include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) - list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) + list(APPEND PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_linux.c") list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${APPINDICATOR_LIBRARIES} ${LIBNOTIFY_LIBRARIES}) endif() else() diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index f815c901f01..25529a981d8 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -53,7 +53,6 @@ set(PLATFORM_TARGET_FILES if(SUNSHINE_ENABLE_TRAY) list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${COCOA}) - # Rust tray implementation - include(${CMAKE_MODULE_PATH}/targets/rust_tray.cmake) - list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${RUST_TRAY_LIBRARY} ${RUST_TRAY_PLATFORM_LIBS}) + list(APPEND PLATFORM_TARGET_FILES + "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_darwin.m") endif() 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 index 4f13cef37bb..f3f244561af 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -39,8 +39,10 @@ // 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" @@ -56,21 +58,21 @@ 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::session_t::get().get_vdd_id(); + 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 */ @@ -78,22 +80,22 @@ namespace system_tray { 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; @@ -107,7 +109,6 @@ namespace system_tray { static void handle_tray_action(uint32_t action) { switch (action) { case TRAY_ACTION_OPEN_UI: - BOOST_LOG(debug) << "Opening UI from system tray"sv; launch_ui(); break; @@ -119,7 +120,7 @@ namespace system_tray { } } break; - + case 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) { @@ -127,50 +128,14 @@ namespace system_tray { start_vdd_cooldown(); } break; - + case TRAY_ACTION_VDD_PERSISTENT: BOOST_LOG(info) << "Toggling VDD persistent mode"sv; - // Toggle the keep_enabled setting config::video.vdd_keep_enabled = !config::video.vdd_keep_enabled; - // Save to config file config::update_config({{"vdd_keep_enabled", config::video.vdd_keep_enabled ? "true" : "false"}}); - // Update menu state update_vdd_menu_state(); break; - case TRAY_ACTION_IMPORT_CONFIG: - BOOST_LOG(info) << "Import config requested"sv; - // Config import is now handled entirely in Rust - break; - - case TRAY_ACTION_EXPORT_CONFIG: - BOOST_LOG(info) << "Export config requested"sv; - // Config export is now handled entirely in Rust - break; - - case TRAY_ACTION_RESET_CONFIG: - BOOST_LOG(info) << "Reset config requested"sv; - // Reset config is now handled entirely in Rust - break; - - case TRAY_ACTION_LANGUAGE_CHINESE: - case TRAY_ACTION_LANGUAGE_ENGLISH: - case TRAY_ACTION_LANGUAGE_JAPANESE: - // Language setting is now saved in Rust, just log here - BOOST_LOG(info) << "Tray language changed (saved by Rust)"sv; - break; - - case TRAY_ACTION_STAR_PROJECT: - // Handled in Rust (opens URL) - BOOST_LOG(debug) << "Star project clicked"sv; - break; - - case TRAY_ACTION_DONATE_YUNDI339: - case TRAY_ACTION_DONATE_QIIN: - // Handled in Rust (opens URL) - BOOST_LOG(debug) << "Donation link clicked"sv; - break; - case TRAY_ACTION_RESET_DISPLAY_DEVICE_CONFIG: BOOST_LOG(info) << "Resetting display device config"sv; display_device::session_t::get().reset_persistence(); @@ -197,15 +162,13 @@ namespace system_tray { break; default: - BOOST_LOG(warning) << "Unknown tray action: " << action; + // Other actions are handled entirely in Rust break; } } void terminate_gui_processes() { #ifdef _WIN32 - BOOST_LOG(info) << "Terminating sunshine-gui.exe processes..."sv; - HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snapshot != INVALID_HANDLE_VALUE) { PROCESSENTRY32W pe32; @@ -214,12 +177,9 @@ namespace system_tray { if (Process32FirstW(snapshot, &pe32)) { do { if (wcscmp(pe32.szExeFile, L"sunshine-gui.exe") == 0) { - BOOST_LOG(info) << "Found sunshine-gui.exe (PID: " << pe32.th32ProcessID << "), terminating..."sv; HANDLE process_handle = OpenProcess(PROCESS_TERMINATE, FALSE, pe32.th32ProcessID); if (process_handle != NULL) { - if (TerminateProcess(process_handle, 0)) { - BOOST_LOG(info) << "Successfully terminated sunshine-gui.exe"sv; - } + TerminateProcess(process_handle, 0); CloseHandle(process_handle); } } @@ -271,7 +231,6 @@ namespace system_tray { // Initialize VDD menu state update_vdd_menu_state(); - BOOST_LOG(info) << "Rust tray initialized successfully"sv; return 0; } @@ -295,7 +254,6 @@ namespace system_tray { tray_initialized = false; tray_exit(); - BOOST_LOG(info) << "Rust tray shut down"sv; return 0; } @@ -326,7 +284,7 @@ namespace system_tray { 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()); } @@ -338,7 +296,7 @@ namespace system_tray { 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()); } @@ -350,7 +308,7 @@ namespace system_tray { 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()); @@ -370,15 +328,10 @@ namespace system_tray { update_vdd_menu_state(); } - // Stub implementations for compatibility - std::string get_localized_string(const std::string& key) { - // Localization is handled in Rust - return key; - } - - std::wstring get_localized_wstring(const std::string& key) { - std::string s = get_localized_string(key); - return std::wstring(s.begin(), s.end()); + void update_vdd_menu() { + if (!tray_initialized) return; + // Update VDD menu state (called by vdd_utils) + update_vdd_menu_state(); } } // namespace system_tray From cd0acbd273d947a9b5f1317a5325f001ed7ff6ba Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 23 Jan 2026 01:04:41 +0800 Subject: [PATCH 27/36] =?UTF-8?q?refactor:=20=E6=89=93=E5=BC=80url?= =?UTF-8?q?=E5=92=8C=E9=85=8D=E7=BD=AE=E6=93=8D=E4=BD=9C=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E5=9B=9Ec++=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmake/compile_definitions/common.cmake | 2 + rust_tray/src/config.rs | 359 +---------- rust_tray/src/i18n.rs | 16 - rust_tray/src/menu_items.rs | 47 +- src/config_operations.cpp | 796 +++++++++++++++++++++++++ src/config_operations.h | 41 ++ src/system_tray_rust.cpp | 25 + 7 files changed, 883 insertions(+), 403 deletions(-) create mode 100644 src/config_operations.cpp create mode 100644 src/config_operations.h diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index 0178d4ae575..eecfb2fab9b 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -117,6 +117,8 @@ 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/config_operations.cpp" + "${CMAKE_SOURCE_DIR}/src/config_operations.h" "${CMAKE_SOURCE_DIR}/src/system_tray_rust.cpp" "${CMAKE_SOURCE_DIR}/src/system_tray.h" "${CMAKE_SOURCE_DIR}/src/system_tray_i18n.cpp" diff --git a/rust_tray/src/config.rs b/rust_tray/src/config.rs index 1382d991801..8f053866705 100644 --- a/rust_tray/src/config.rs +++ b/rust_tray/src/config.rs @@ -1,10 +1,11 @@ //! Configuration file operations module (Windows only) //! -//! Provides functionality for reading, writing, importing, exporting, -//! and resetting the Sunshine configuration file. +//! 提供读取、写入配置文件的功能。 //! -//! The configuration file path is obtained from C++ via the tray_init_ex function, -//! which provides the exact path used by the main Sunshine application. +//! 配置文件路径通过 C++ 的 tray_init_ex 函数获取, +//! 确保与主 Sunshine 应用程序使用相同的配置路径。 +//! +//! 注意:导入/导出/重置配置的功能已迁移到 C++ 的 config_operations.cpp 中实现。 use std::collections::HashMap; use std::ffi::OsStr; @@ -12,27 +13,18 @@ use std::fs; use std::os::windows::ffi::OsStrExt; use std::path::PathBuf; -use crate::i18n::{get_string, StringKey}; use crate::get_config_file_path_from_cpp; -/// Result type for config operations +/// 配置操作的结果类型 pub type ConfigResult = Result; -/// Error types for configuration operations +/// 配置操作的错误类型 #[derive(Debug, Clone)] pub enum ConfigError { PathNotFound, ReadFailed(String), WriteFailed(String), - NoConfigFound, DialogCancelled, - NoUserSession, - InvalidExtension, - SymlinkNotAllowed, - NotRegularFile, - FileTooLarge, - FileEmpty, - InvalidFormat(String), } impl std::fmt::Display for ConfigError { @@ -41,43 +33,33 @@ impl std::fmt::Display for ConfigError { ConfigError::PathNotFound => write!(f, "Configuration path not found"), ConfigError::ReadFailed(msg) => write!(f, "Read failed: {}", msg), ConfigError::WriteFailed(msg) => write!(f, "Write failed: {}", msg), - ConfigError::NoConfigFound => write!(f, "No configuration found"), ConfigError::DialogCancelled => write!(f, "Dialog cancelled"), - ConfigError::NoUserSession => write!(f, "No active user session"), - ConfigError::InvalidExtension => write!(f, "Invalid file extension (only .conf allowed)"), - ConfigError::SymlinkNotAllowed => write!(f, "Symbolic links are not allowed"), - ConfigError::NotRegularFile => write!(f, "Path is not a regular file"), - ConfigError::FileTooLarge => write!(f, "File exceeds maximum size (1MB)"), - ConfigError::FileEmpty => write!(f, "Configuration file is empty"), - ConfigError::InvalidFormat(msg) => write!(f, "Invalid configuration format: {}", msg), } } } -/// Get the Sunshine configuration file path +/// 获取 Sunshine 配置文件路径 /// -/// The path is provided by C++ via tray_init_ex, ensuring consistency -/// with the main Sunshine application's configuration path. -/// with the main Sunshine application's configuration path. +/// 路径由 C++ 通过 tray_init_ex 提供,确保与主 Sunshine 应用程序的配置路径一致。 pub fn get_config_file_path() -> Option { get_config_file_path_from_cpp() .map(PathBuf::from) .filter(|p| p.exists()) } -/// Parse configuration file content into a key-value map +/// 将配置文件内容解析为键值对映射 pub fn parse_config(content: &str) -> HashMap { let mut vars = HashMap::new(); for line in content.lines() { let trimmed = line.trim(); - // Skip empty lines and comments + // 跳过空行和注释 if trimmed.is_empty() || trimmed.starts_with('#') { continue; } - // Parse key = value + // 解析 key = value if let Some(pos) = trimmed.find('=') { let key = trimmed[..pos].trim().to_string(); let value = trimmed[pos + 1..].trim().to_string(); @@ -90,7 +72,7 @@ pub fn parse_config(content: &str) -> HashMap { vars } -/// Write configuration map to string +/// 将配置映射序列化为字符串 pub fn serialize_config(vars: &HashMap) -> String { let mut config_str = String::new(); @@ -103,7 +85,7 @@ pub fn serialize_config(vars: &HashMap) -> String { config_str } -/// Read the configuration file +/// 读取配置文件 pub fn read_config() -> ConfigResult> { let path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; @@ -113,7 +95,7 @@ pub fn read_config() -> ConfigResult> { Ok(parse_config(&content)) } -/// Write configuration to file +/// 写入配置到文件 pub fn write_config(vars: &HashMap) -> ConfigResult<()> { let path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; @@ -123,40 +105,14 @@ pub fn write_config(vars: &HashMap) -> ConfigResult<()> { .map_err(|e| ConfigError::WriteFailed(e.to_string())) } -/// Save a single configuration value +/// 保存单个配置值 pub fn save_config_value(key: &str, value: &str) -> ConfigResult<()> { let mut vars = read_config().unwrap_or_default(); vars.insert(key.to_string(), value.to_string()); write_config(&vars) } -/// Show message box -pub fn show_message_box(title: &str, message: &str, is_error: bool) { - use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_OK, MB_ICONERROR, MB_ICONINFORMATION}; - - 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(); - - let icon = if is_error { MB_ICONERROR } else { MB_ICONINFORMATION }; - - unsafe { - MessageBoxW( - std::ptr::null_mut(), - wide_message.as_ptr(), - wide_title.as_ptr(), - MB_OK | icon, - ); - } -} - -/// Show confirmation dialog +/// 显示确认对话框 pub fn show_confirm_dialog(title: &str, message: &str) -> bool { use windows_sys::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_YESNO, MB_ICONQUESTION, IDYES}; @@ -181,286 +137,7 @@ pub fn show_confirm_dialog(title: &str, message: &str) -> bool { } } -/// Open file dialog for importing config -pub fn open_import_dialog() -> ConfigResult { - use windows_sys::Win32::UI::Controls::Dialogs::{ - GetOpenFileNameW, OPENFILENAMEW, OFN_EXPLORER, OFN_FILEMUSTEXIST, OFN_PATHMUSTEXIST, - }; - - let mut file_name: [u16; 260] = [0; 260]; - - // Build filter string: "Config Files (*.conf)\0*.conf\0All Files (*.*)\0*.*\0\0" - let filter_label1 = get_string(StringKey::FileDialogConfigFiles); - let filter_label2 = get_string(StringKey::FileDialogAllFiles); - let filter = format!("{} (*.conf)\0*.conf\0{} (*.*)\0*.*\0\0", filter_label1, filter_label2); - let filter_wide: Vec = filter.encode_utf16().collect(); - - let title = get_string(StringKey::FileDialogSelectImport); - let title_wide: Vec = OsStr::new(title) - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - - let mut ofn: OPENFILENAMEW = unsafe { std::mem::zeroed() }; - ofn.lStructSize = std::mem::size_of::() as u32; - ofn.lpstrFilter = filter_wide.as_ptr(); - ofn.lpstrFile = file_name.as_mut_ptr(); - ofn.nMaxFile = 260; - ofn.lpstrTitle = title_wide.as_ptr(); - ofn.Flags = OFN_EXPLORER | OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST; - - unsafe { - if GetOpenFileNameW(&mut ofn) != 0 { - let len = file_name.iter().position(|&c| c == 0).unwrap_or(260); - let path_str = String::from_utf16_lossy(&file_name[..len]); - Ok(PathBuf::from(path_str)) - } else { - Err(ConfigError::DialogCancelled) - } - } -} - -/// Open file dialog for exporting config -pub fn open_export_dialog() -> ConfigResult { - use windows_sys::Win32::UI::Controls::Dialogs::{ - GetSaveFileNameW, OPENFILENAMEW, OFN_EXPLORER, OFN_OVERWRITEPROMPT, - }; - - let mut file_name: [u16; 260] = [0; 260]; - - // Default filename - let default_name = "sunshine_backup.conf"; - for (i, c) in default_name.encode_utf16().enumerate() { - if i < 259 { - file_name[i] = c; - } - } - - // Build filter string - let filter_label1 = get_string(StringKey::FileDialogConfigFiles); - let filter_label2 = get_string(StringKey::FileDialogAllFiles); - let filter = format!("{} (*.conf)\0*.conf\0{} (*.*)\0*.*\0\0", filter_label1, filter_label2); - let filter_wide: Vec = filter.encode_utf16().collect(); - - let title = get_string(StringKey::FileDialogSaveExport); - let title_wide: Vec = OsStr::new(title) - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - - let mut ofn: OPENFILENAMEW = unsafe { std::mem::zeroed() }; - ofn.lStructSize = std::mem::size_of::() as u32; - ofn.lpstrFilter = filter_wide.as_ptr(); - ofn.lpstrFile = file_name.as_mut_ptr(); - ofn.nMaxFile = 260; - ofn.lpstrTitle = title_wide.as_ptr(); - ofn.Flags = OFN_EXPLORER | OFN_OVERWRITEPROMPT; - - unsafe { - if GetSaveFileNameW(&mut ofn) != 0 { - let len = file_name.iter().position(|&c| c == 0).unwrap_or(260); - let path_str = String::from_utf16_lossy(&file_name[..len]); - let mut path = PathBuf::from(path_str); - - // Ensure .conf extension - if path.extension().map_or(true, |ext| ext != "conf") { - path.set_extension("conf"); - } - - Ok(path) - } else { - Err(ConfigError::DialogCancelled) - } - } -} - -// ============================================================================ -// Security Validation Functions -// ============================================================================ - -/// Maximum allowed configuration file size (1MB) -const MAX_CONFIG_SIZE: usize = 1024 * 1024; - -/// Validate the file path for security -/// -/// Checks: -/// - File exists -/// - Not a symbolic link -/// - Is a regular file -/// - Has .conf extension -fn validate_file_path(path: &std::path::Path) -> ConfigResult<()> { - // Check if file exists - if !path.exists() { - return Err(ConfigError::ReadFailed("File does not exist".to_string())); - } - - // Check for symbolic link (prevent symlink attacks) - if path.symlink_metadata() - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false) - { - return Err(ConfigError::SymlinkNotAllowed); - } - - // Ensure it's a regular file - if !path.is_file() { - return Err(ConfigError::NotRegularFile); - } - - // Check file extension - if path.extension().map_or(true, |ext| ext != "conf") { - return Err(ConfigError::InvalidExtension); - } - - Ok(()) -} - -/// Validate the configuration file content -/// -/// Checks: -/// - File size within limits -/// - Not empty -/// - Basic format validation (key=value or comment lines) -fn validate_config_content(content: &str) -> ConfigResult<()> { - // Check file size - if content.len() > MAX_CONFIG_SIZE { - return Err(ConfigError::FileTooLarge); - } - - // Check if empty - if content.trim().is_empty() { - return Err(ConfigError::FileEmpty); - } - - // Basic format validation - // Sunshine config format: key = value or # comment or [section] - let mut has_valid_line = false; - for (line_num, line) in content.lines().enumerate() { - let trimmed = line.trim(); - - // Skip empty lines - if trimmed.is_empty() { - continue; - } - - // Allow comment lines - if trimmed.starts_with('#') || trimmed.starts_with(';') { - has_valid_line = true; - continue; - } - - // Allow section headers - if trimmed.starts_with('[') && trimmed.ends_with(']') { - has_valid_line = true; - continue; - } - - // Check for key=value format - if trimmed.contains('=') { - has_valid_line = true; - continue; - } - - // Invalid line found - return Err(ConfigError::InvalidFormat( - format!("Invalid syntax at line {}: {}", line_num + 1, - if trimmed.len() > 50 { &trimmed[..50] } else { trimmed }) - )); - } - - // Must have at least one valid line (could be just comments for a minimal config) - if !has_valid_line { - return Err(ConfigError::InvalidFormat("No valid configuration lines found".to_string())); - } - - Ok(()) -} - -/// Import configuration from file -pub fn import_config() -> ConfigResult<()> { - // Open file dialog - let source_path = open_import_dialog()?; - - // Security validation: check file path - validate_file_path(&source_path)?; - - // Read source file - let content = fs::read_to_string(&source_path) - .map_err(|e| ConfigError::ReadFailed(e.to_string()))?; - - // Security validation: check content - validate_config_content(&content)?; - - // Get destination path - let dest_path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; - - // Write to config file - fs::write(&dest_path, &content) - .map_err(|e| ConfigError::WriteFailed(e.to_string()))?; - - show_message_box( - get_string(StringKey::ImportSuccessTitle), - get_string(StringKey::ImportSuccessMsg), - false, - ); - - Ok(()) -} - -/// Export configuration to file -pub fn export_config() -> ConfigResult<()> { - // Get source config path - let source_path = get_config_file_path().ok_or(ConfigError::NoConfigFound)?; - - // Read current config - let content = fs::read_to_string(&source_path) - .map_err(|e| ConfigError::ReadFailed(e.to_string()))?; - - // Open save dialog - let dest_path = open_export_dialog()?; - - // Write to destination - fs::write(&dest_path, content) - .map_err(|e| ConfigError::WriteFailed(e.to_string()))?; - - show_message_box( - get_string(StringKey::ExportSuccessTitle), - get_string(StringKey::ExportSuccessMsg), - false, - ); - - Ok(()) -} - -/// Reset configuration to default -pub fn reset_config() -> ConfigResult<()> { - // Show confirmation dialog - if !show_confirm_dialog( - get_string(StringKey::ResetConfirmTitle), - get_string(StringKey::ResetConfirmMsg), - ) { - return Err(ConfigError::DialogCancelled); - } - - // Get config path - let config_path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; - - // Create empty config (or minimal default) - let default_config = "# Sunshine Configuration\n# Reset to default\n"; - - fs::write(&config_path, default_config) - .map_err(|e| ConfigError::WriteFailed(e.to_string()))?; - - show_message_box( - get_string(StringKey::ResetSuccessTitle), - get_string(StringKey::ResetSuccessMsg), - false, - ); - - Ok(()) -} - -/// Save tray locale to configuration file +/// 保存托盘语言设置到配置文件 pub fn save_tray_locale(locale: &str) -> ConfigResult<()> { save_config_value("tray_locale", locale) } diff --git a/rust_tray/src/i18n.rs b/rust_tray/src/i18n.rs index 4542d48d0b6..6a02eff7e84 100644 --- a/rust_tray/src/i18n.rs +++ b/rust_tray/src/i18n.rs @@ -100,10 +100,6 @@ pub enum StringKey { ResetErrorTitle, ResetErrorMsg, ResetErrorException, - FileDialogSelectImport, - FileDialogSaveExport, - FileDialogConfigFiles, - FileDialogAllFiles, } /// Current locale storage @@ -174,10 +170,6 @@ static TRANSLATIONS: Lazy> = Lazy::ne m.insert((Locale::English, StringKey::ResetErrorTitle), "Reset Error"); m.insert((Locale::English, StringKey::ResetErrorMsg), "Failed to reset configuration file."); m.insert((Locale::English, StringKey::ResetErrorException), "An error occurred while resetting configuration."); - m.insert((Locale::English, StringKey::FileDialogSelectImport), "Select Configuration File to Import"); - m.insert((Locale::English, StringKey::FileDialogSaveExport), "Save Configuration File As"); - m.insert((Locale::English, StringKey::FileDialogConfigFiles), "Configuration Files"); - m.insert((Locale::English, StringKey::FileDialogAllFiles), "All Files"); // Chinese translations m.insert((Locale::Chinese, StringKey::OpenSunshine), "打开 Sunshine"); @@ -240,10 +232,6 @@ static TRANSLATIONS: Lazy> = Lazy::ne m.insert((Locale::Chinese, StringKey::ResetErrorTitle), "重置失败"); m.insert((Locale::Chinese, StringKey::ResetErrorMsg), "无法重置配置文件。"); m.insert((Locale::Chinese, StringKey::ResetErrorException), "重置配置时发生错误。"); - m.insert((Locale::Chinese, StringKey::FileDialogSelectImport), "选择要导入的配置文件"); - m.insert((Locale::Chinese, StringKey::FileDialogSaveExport), "配置文件另存为"); - m.insert((Locale::Chinese, StringKey::FileDialogConfigFiles), "配置文件"); - m.insert((Locale::Chinese, StringKey::FileDialogAllFiles), "所有文件"); // Japanese translations m.insert((Locale::Japanese, StringKey::OpenSunshine), "Sunshineを開く"); @@ -306,10 +294,6 @@ static TRANSLATIONS: Lazy> = Lazy::ne m.insert((Locale::Japanese, StringKey::ResetErrorTitle), "リセット失敗"); m.insert((Locale::Japanese, StringKey::ResetErrorMsg), "設定ファイルをリセットできませんでした。"); m.insert((Locale::Japanese, StringKey::ResetErrorException), "設定のリセット中にエラーが発生しました。"); - m.insert((Locale::Japanese, StringKey::FileDialogSelectImport), "インポートする設定ファイルを選択"); - m.insert((Locale::Japanese, StringKey::FileDialogSaveExport), "設定ファイルに名前を付けて保存"); - m.insert((Locale::Japanese, StringKey::FileDialogConfigFiles), "設定ファイル"); - m.insert((Locale::Japanese, StringKey::FileDialogAllFiles), "すべてのファイル"); m }); diff --git a/rust_tray/src/menu_items.rs b/rust_tray/src/menu_items.rs index ec4f2a48d5b..79064837797 100644 --- a/rust_tray/src/menu_items.rs +++ b/rust_tray/src/menu_items.rs @@ -8,7 +8,7 @@ //! 3. Done! No need to modify any other files. use crate::i18n::{StringKey, get_string, set_locale_str}; -use crate::actions::{open_url, urls, trigger_action}; +use crate::actions::trigger_action; use crate::config; /// Menu item handler function type @@ -187,54 +187,12 @@ mod handlers { } pub fn import_config() { - std::thread::spawn(|| { - if let Err(e) = config::import_config() { - match e { - config::ConfigError::DialogCancelled => {} - _ => { - config::show_message_box( - get_string(StringKey::ImportErrorTitle), - &format!("{}", e), - true, - ); - } - } - } - }); } pub fn export_config() { - std::thread::spawn(|| { - if let Err(e) = config::export_config() { - match e { - config::ConfigError::DialogCancelled => {} - _ => { - config::show_message_box( - get_string(StringKey::ExportErrorTitle), - &format!("{}", e), - true, - ); - } - } - } - }); } pub fn reset_config() { - std::thread::spawn(|| { - if let Err(e) = config::reset_config() { - match e { - config::ConfigError::DialogCancelled => {} - _ => { - config::show_message_box( - get_string(StringKey::ResetErrorTitle), - &format!("{}", e), - true, - ); - } - } - } - }); } pub fn close_app() { @@ -275,15 +233,12 @@ mod handlers { } pub fn star_project() { - open_url(urls::GITHUB_PROJECT); } pub fn visit_sunshine() { - open_url(urls::PROJECT_SUNSHINE); } pub fn visit_moonlight() { - open_url(urls::PROJECT_MOONLIGHT); } pub fn restart() { diff --git a/src/config_operations.cpp b/src/config_operations.cpp new file mode 100644 index 00000000000..6dfe4604c22 --- /dev/null +++ b/src/config_operations.cpp @@ -0,0 +1,796 @@ +/** + * @file src/config_operations.cpp + * @brief 配置文件操作(导入/导出/重置) + * + * 该模块提供安全的配置文件操作,可以从 Rust 托盘和 C++ 代码中调用。 + * + * 注意:由于 Sunshine 以 SYSTEM 用户身份运行,无法访问普通用户的桌面和快速访问位置。 + * 因此我们使用 FOS_HIDEPINNEDPLACES 隐藏快速访问栏,并手动添加常用导航位置。 + */ + +#include "config_operations.h" + +#include +#include +#include +#include + +#if defined(_WIN32) + #define WIN32_LEAN_AND_MEAN + #include + #include + #include + #include + #include + #include +#endif + +#include + +#include "config.h" +#include "file_handler.h" +#include "logging.h" +#include "platform/common.h" +#include "platform/windows/misc.h" +#include "system_tray_i18n.h" + +using namespace std::literals; + +namespace config_operations { + + // ============================================================================ + // 常量和全局变量 + // ============================================================================ + + /** + * @brief 文件对话框打开标志,防止多个对话框同时打开 + */ + static bool s_file_dialog_open = false; + + /** + * @brief 最大配置文件大小限制(1MB) + * + * 这是一个安全限制,防止导入超大的配置文件。 + */ + static constexpr size_t MAX_CONFIG_SIZE = 1024 * 1024; + + // ============================================================================ + // 安全验证函数 + // ============================================================================ + + /** + * @brief 验证文件路径是否安全用于导入配置 + * + * 进行以下安全检查: + * - 文件必须存在 + * - 文件扩展名必须是 .conf + * - 文件不能是符号链接(防止符号链接攻击) + * - 文件必须是普通文件 + * + * @param path 要验证的文件路径 + * @return 如果路径安全返回 true + */ + static bool is_safe_config_path(const std::string &path) { + try { + std::filesystem::path p(path); + + // 检查文件是否存在 + if (!std::filesystem::exists(p)) { + BOOST_LOG(warning) << "[config_ops] 文件不存在: " << path; + return false; + } + + // 获取规范路径(解析所有符号链接) + auto canonical_path = std::filesystem::canonical(p); + + // 检查扩展名 + if (canonical_path.extension() != ".conf") { + BOOST_LOG(warning) << "[config_ops] 无效的文件扩展名: " << canonical_path.extension().string(); + return false; + } + + // 检查是否为符号链接 + if (std::filesystem::is_symlink(p)) { + BOOST_LOG(warning) << "[config_ops] 文件是符号链接,拒绝导入: " << path; + return false; + } + + // 检查是否为普通文件 + if (!std::filesystem::is_regular_file(canonical_path)) { + BOOST_LOG(warning) << "[config_ops] 不是普通文件: " << path; + return false; + } + + return true; + } + catch (const std::exception &e) { + BOOST_LOG(error) << "[config_ops] 路径验证时发生异常: " << e.what(); + return false; + } + } + + /** + * @brief 验证配置文件内容是否安全 + * + * 进行以下检查: + * - 内容大小不能超过 MAX_CONFIG_SIZE + * - 内容不能为空 + * - 内容必须是有效的配置格式(通过尝试解析验证) + * + * @param content 配置文件内容 + * @return 如果内容安全返回 true + */ + static bool is_safe_config_content(const std::string &content) { + // 检查大小限制 + if (content.size() > MAX_CONFIG_SIZE) { + BOOST_LOG(warning) << "[config_ops] 配置文件过大: " << content.size() << " bytes (最大: " << MAX_CONFIG_SIZE << ")"; + return false; + } + + // 检查是否为空 + if (content.empty()) { + BOOST_LOG(warning) << "[config_ops] 配置文件为空"; + return false; + } + + // 尝试解析配置以验证格式 + try { + config::parse_config(content); + return true; + } + catch (const std::exception &e) { + BOOST_LOG(warning) << "[config_ops] 配置文件格式无效: " << e.what(); + return false; + } + } + +#ifdef _WIN32 + // ============================================================================ + // Windows 平台特定函数 + // ============================================================================ + + /** + * @brief 获取当前控制台会话登录用户的令牌 + * + * 由于 Sunshine 以 SYSTEM 用户运行,我们需要获取实际登录用户的令牌 + * 才能访问其桌面等用户文件夹。 + * + * @return 用户令牌句柄,失败返回 NULL。调用者需要调用 CloseHandle 释放。 + */ + static HANDLE get_console_user_token() { + // 获取活动的控制台会话 ID + DWORD session_id = WTSGetActiveConsoleSessionId(); + if (session_id == 0xFFFFFFFF) { + BOOST_LOG(debug) << "[config_ops] 无法获取活动控制台会话"; + return NULL; + } + + HANDLE user_token = NULL; + if (!WTSQueryUserToken(session_id, &user_token)) { + BOOST_LOG(debug) << "[config_ops] 无法获取用户令牌,错误码: " << GetLastError(); + return NULL; + } + + return user_token; + } + + /** + * @brief 为文件对话框添加导航位置 + * + * 由于以 SYSTEM 用户运行,快速访问栏无法正常工作(会尝试访问 SYSTEM 用户的 + * 桌面,但该位置不存在)。我们使用 FOS_HIDEPINNEDPLACES 隐藏快速访问栏, + * 并手动添加有用的导航位置: + * + * 1. 当前登录用户的桌面(如果可以获取) + * 2. 公共桌面 + * 3. 此电脑 + * 4. 所有驱动器 + * 5. 网络 + * + * @param pDialog 文件对话框接口指针 + */ + static void add_dialog_places(IFileDialog *pDialog) { + // 尝试获取当前登录用户的桌面 + HANDLE user_token = get_console_user_token(); + if (user_token != NULL) { + PWSTR user_desktop = NULL; + // 使用用户令牌获取其桌面路径 + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Desktop, KF_FLAG_DEFAULT, user_token, &user_desktop))) { + IShellItem *psiDesktop = NULL; + if (SUCCEEDED(SHCreateItemFromParsingName(user_desktop, NULL, IID_PPV_ARGS(&psiDesktop)))) { + pDialog->AddPlace(psiDesktop, FDAP_TOP); + psiDesktop->Release(); + BOOST_LOG(debug) << "[config_ops] 已添加用户桌面到导航栏"; + } + CoTaskMemFree(user_desktop); + } + + // 获取用户的下载文件夹 + PWSTR user_downloads = NULL; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Downloads, KF_FLAG_DEFAULT, user_token, &user_downloads))) { + IShellItem *psiDownloads = NULL; + if (SUCCEEDED(SHCreateItemFromParsingName(user_downloads, NULL, IID_PPV_ARGS(&psiDownloads)))) { + pDialog->AddPlace(psiDownloads, FDAP_TOP); + psiDownloads->Release(); + BOOST_LOG(debug) << "[config_ops] 已添加用户下载文件夹到导航栏"; + } + CoTaskMemFree(user_downloads); + } + + // 获取用户的文档文件夹 + PWSTR user_documents = NULL; + if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Documents, KF_FLAG_DEFAULT, user_token, &user_documents))) { + IShellItem *psiDocuments = NULL; + if (SUCCEEDED(SHCreateItemFromParsingName(user_documents, NULL, IID_PPV_ARGS(&psiDocuments)))) { + pDialog->AddPlace(psiDocuments, FDAP_TOP); + psiDocuments->Release(); + BOOST_LOG(debug) << "[config_ops] 已添加用户文档文件夹到导航栏"; + } + CoTaskMemFree(user_documents); + } + + CloseHandle(user_token); + } + else { + BOOST_LOG(debug) << "[config_ops] 无法获取用户令牌,将添加公共桌面作为替代"; + + // 如果无法获取用户令牌,添加公共桌面 + IShellItem *psiPublicDesktop = NULL; + if (SUCCEEDED(SHGetKnownFolderItem(FOLDERID_PublicDesktop, KF_FLAG_DEFAULT, NULL, IID_PPV_ARGS(&psiPublicDesktop)))) { + pDialog->AddPlace(psiPublicDesktop, FDAP_TOP); + psiPublicDesktop->Release(); + BOOST_LOG(debug) << "[config_ops] 已添加公共桌面到导航栏"; + } + } + + // 添加"此电脑"到导航栏 + IShellItem *psiComputer = NULL; + if (SUCCEEDED(SHGetKnownFolderItem(FOLDERID_ComputerFolder, KF_FLAG_DEFAULT, NULL, IID_PPV_ARGS(&psiComputer)))) { + pDialog->AddPlace(psiComputer, FDAP_TOP); + psiComputer->Release(); + BOOST_LOG(debug) << "[config_ops] 已添加\"此电脑\"到导航栏"; + } + + // 枚举并添加所有驱动器 + DWORD dwSize = GetLogicalDriveStringsW(0, NULL); + if (dwSize > 0) { + std::vector buffer(dwSize + 1); + if (GetLogicalDriveStringsW(dwSize, buffer.data())) { + for (wchar_t* pDrive = buffer.data(); *pDrive; pDrive += wcslen(pDrive) + 1) { + IShellItem *psiDrive = NULL; + if (SUCCEEDED(SHCreateItemFromParsingName(pDrive, NULL, IID_PPV_ARGS(&psiDrive)))) { + pDialog->AddPlace(psiDrive, FDAP_BOTTOM); + psiDrive->Release(); + } + } + } + } + + // 添加"网络"到导航栏 + IShellItem *psiNetwork = NULL; + if (SUCCEEDED(SHGetKnownFolderItem(FOLDERID_NetworkFolder, KF_FLAG_DEFAULT, NULL, IID_PPV_ARGS(&psiNetwork)))) { + pDialog->AddPlace(psiNetwork, FDAP_BOTTOM); + psiNetwork->Release(); + BOOST_LOG(debug) << "[config_ops] 已添加\"网络\"到导航栏"; + } + } + + /** + * @brief 显示文件打开对话框 + * + * 使用 Windows IFileOpenDialog COM 接口显示现代文件打开对话框。 + * + * @return 用户选择的文件路径(宽字符串),如果取消则返回空字符串 + */ + static std::wstring show_open_file_dialog() { + std::wstring result; + + // 初始化 COM + HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); + bool com_initialized = SUCCEEDED(hr); + + // 创建文件打开对话框 + IFileOpenDialog *pFileOpen = nullptr; + hr = CoCreateInstance(CLSID_FileOpenDialog, NULL, CLSCTX_ALL, + IID_IFileOpenDialog, reinterpret_cast(&pFileOpen)); + + if (FAILED(hr)) { + BOOST_LOG(error) << "[config_ops] 创建文件打开对话框失败,HRESULT: " << std::hex << hr; + if (com_initialized) CoUninitialize(); + return result; + } + + // 设置对话框选项 + // FOS_HIDEPINNEDPLACES: 隐藏快速访问栏(因为以 SYSTEM 用户运行无法访问) + // FOS_FORCEFILESYSTEM: 只允许选择文件系统中的项目 + // FOS_DONTADDTORECENT: 不添加到最近使用列表 + // FOS_NOCHANGEDIR: 不改变当前工作目录 + // FOS_NOVALIDATE: 允许选择不存在的文件名(我们自己验证) + DWORD dwFlags; + pFileOpen->GetOptions(&dwFlags); + pFileOpen->SetOptions(dwFlags | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | FOS_FILEMUSTEXIST | + FOS_DONTADDTORECENT | FOS_NOCHANGEDIR | FOS_HIDEPINNEDPLACES | FOS_NOVALIDATE); + + // 设置文件类型过滤器 + std::wstring config_label = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_CONFIG_FILES)); + std::wstring all_files_label = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_ALL_FILES)); + + COMDLG_FILTERSPEC fileTypes[] = { + { config_label.c_str(), L"*.conf" }, + { all_files_label.c_str(), L"*.*" } + }; + pFileOpen->SetFileTypes(2, fileTypes); + pFileOpen->SetFileTypeIndex(1); // 默认选择 .conf 过滤器 + + // 设置对话框标题 + std::wstring dialog_title = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_SELECT_IMPORT)); + pFileOpen->SetTitle(dialog_title.c_str()); + + // 设置默认文件夹为应用程序数据目录 + IShellItem *psiDefault = NULL; + std::wstring default_path = platf::appdata().wstring(); + if (SUCCEEDED(SHCreateItemFromParsingName(default_path.c_str(), NULL, IID_PPV_ARGS(&psiDefault)))) { + pFileOpen->SetFolder(psiDefault); + psiDefault->Release(); + } + + // 添加导航位置(驱动器、此电脑、网络等) + add_dialog_places(pFileOpen); + + // 显示对话框 + hr = pFileOpen->Show(NULL); + if (SUCCEEDED(hr)) { + // 获取用户选择的文件 + IShellItem *pItem = nullptr; + hr = pFileOpen->GetResult(&pItem); + if (SUCCEEDED(hr)) { + PWSTR pszFilePath = nullptr; + hr = pItem->GetDisplayName(SIGDN_FILESYSPATH, &pszFilePath); + if (SUCCEEDED(hr)) { + result = pszFilePath; + CoTaskMemFree(pszFilePath); + } + pItem->Release(); + } + } + + pFileOpen->Release(); + if (com_initialized) CoUninitialize(); + + return result; + } + + /** + * @brief 显示文件保存对话框 + * + * 使用 Windows IFileSaveDialog COM 接口显示现代文件保存对话框。 + * + * @return 用户选择的保存路径(宽字符串),如果取消则返回空字符串 + */ + static std::wstring show_save_file_dialog() { + std::wstring result; + + // 初始化 COM + HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); + bool com_initialized = SUCCEEDED(hr); + + // 创建文件保存对话框 + IFileSaveDialog *pFileSave = nullptr; + hr = CoCreateInstance(CLSID_FileSaveDialog, NULL, CLSCTX_ALL, + IID_IFileSaveDialog, reinterpret_cast(&pFileSave)); + + if (FAILED(hr)) { + BOOST_LOG(error) << "[config_ops] 创建文件保存对话框失败,HRESULT: " << std::hex << hr; + if (com_initialized) CoUninitialize(); + return result; + } + + // 设置对话框选项 + DWORD dwFlags; + pFileSave->GetOptions(&dwFlags); + pFileSave->SetOptions(dwFlags | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | FOS_OVERWRITEPROMPT | + FOS_DONTADDTORECENT | FOS_NOCHANGEDIR | FOS_HIDEPINNEDPLACES | FOS_NOVALIDATE); + + // 设置文件类型过滤器 + std::wstring config_label = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_CONFIG_FILES)); + std::wstring all_files_label = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_ALL_FILES)); + + COMDLG_FILTERSPEC fileTypes[] = { + { config_label.c_str(), L"*.conf" }, + { all_files_label.c_str(), L"*.*" } + }; + pFileSave->SetFileTypes(2, fileTypes); + pFileSave->SetFileTypeIndex(1); + + // 设置默认扩展名 + pFileSave->SetDefaultExtension(L"conf"); + + // 生成默认文件名(带时间戳) + std::string default_name = "sunshine_config_" + std::to_string(std::time(nullptr)) + ".conf"; + std::wstring wdefault_name(default_name.begin(), default_name.end()); + pFileSave->SetFileName(wdefault_name.c_str()); + + // 设置对话框标题 + std::wstring dialog_title = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_SAVE_EXPORT)); + pFileSave->SetTitle(dialog_title.c_str()); + + // 设置默认文件夹为应用程序数据目录 + IShellItem *psiDefault = NULL; + std::wstring default_path = platf::appdata().wstring(); + if (SUCCEEDED(SHCreateItemFromParsingName(default_path.c_str(), NULL, IID_PPV_ARGS(&psiDefault)))) { + pFileSave->SetFolder(psiDefault); + psiDefault->Release(); + } + + // 添加导航位置 + add_dialog_places(pFileSave); + + // 显示对话框 + hr = pFileSave->Show(NULL); + if (SUCCEEDED(hr)) { + // 获取用户选择的保存路径 + IShellItem *pItem = nullptr; + hr = pFileSave->GetResult(&pItem); + if (SUCCEEDED(hr)) { + PWSTR pszFilePath = nullptr; + hr = pItem->GetDisplayName(SIGDN_FILESYSPATH, &pszFilePath); + if (SUCCEEDED(hr)) { + result = pszFilePath; + CoTaskMemFree(pszFilePath); + } + pItem->Release(); + } + } + + pFileSave->Release(); + if (com_initialized) CoUninitialize(); + + return result; + } + + /** + * @brief 显示消息框 + * + * @param title_key 标题的本地化键 + * @param msg_key 消息的本地化键 + * @param is_error 是否为错误消息(决定图标类型) + */ + static void show_message(const std::string &title_key, const std::string &msg_key, bool is_error) { + std::wstring title = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(title_key)); + std::wstring message = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(msg_key)); + + UINT type = is_error ? (MB_OK | MB_ICONERROR) : (MB_OK | MB_ICONINFORMATION); + MessageBoxW(NULL, message.c_str(), title.c_str(), type); + } + + /** + * @brief 显示带有自定义消息的消息框 + * + * @param title_key 标题的本地化键 + * @param message 自定义消息(宽字符串) + * @param is_error 是否为错误消息 + */ + static void show_message_custom(const std::string &title_key, const std::wstring &message, bool is_error) { + std::wstring title = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(title_key)); + + UINT type = is_error ? (MB_OK | MB_ICONERROR) : (MB_OK | MB_ICONINFORMATION); + MessageBoxW(NULL, message.c_str(), title.c_str(), type); + } + + /** + * @brief 显示确认对话框 + * + * @param title_key 标题的本地化键 + * @param msg_key 消息的本地化键 + * @return 如果用户点击"是"返回 true + */ + static bool show_confirm(const std::string &title_key, const std::string &msg_key) { + std::wstring title = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(title_key)); + std::wstring message = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(msg_key)); + + int result = MessageBoxW(NULL, message.c_str(), title.c_str(), MB_YESNO | MB_ICONQUESTION); + return result == IDYES; + } + + /** + * @brief 显示带有自定义消息的确认对话框 + * + * @param title_key 标题的本地化键 + * @param message 自定义消息(宽字符串) + * @return 如果用户点击"是"返回 true + */ + static bool show_confirm_custom(const std::string &title_key, const std::wstring &message) { + std::wstring title = system_tray_i18n::utf8_to_wstring( + system_tray_i18n::get_localized_string(title_key)); + + int result = MessageBoxW(NULL, message.c_str(), title.c_str(), MB_YESNO | MB_ICONQUESTION); + return result == IDYES; + } +#endif + + // ============================================================================ + // 公共接口函数 + // ============================================================================ + + /** + * @brief 导入配置文件 + * + * 显示文件选择对话框让用户选择要导入的配置文件。 + * 导入前会进行安全验证,并创建当前配置的备份。 + * 导入成功后询问用户是否重启 Sunshine 以应用新配置。 + */ + void import_config() { + BOOST_LOG(info) << "[config_ops] ========== import_config() 被调用 =========="sv; +#ifdef _WIN32 + // 检查是否已有文件对话框打开 + if (s_file_dialog_open) { + BOOST_LOG(warning) << "[config_ops] 已有文件对话框打开,跳过此次调用"; + return; + } + s_file_dialog_open = true; + BOOST_LOG(debug) << "[config_ops] 设置文件对话框标志为 true"sv; + + BOOST_LOG(info) << "[config_ops] 准备显示文件打开对话框..."sv; + + // 显示文件打开对话框 + std::wstring file_path_wide = show_open_file_dialog(); + + // 重置文件对话框标志 + s_file_dialog_open = false; + BOOST_LOG(debug) << "[config_ops] 重置文件对话框标志为 false"sv; + + // 检查用户是否取消 + if (file_path_wide.empty()) { + BOOST_LOG(info) << "[config_ops] 用户取消了文件对话框"sv; + return; + } + + std::string file_path = platf::to_utf8(file_path_wide); + + // 安全验证:检查文件路径 + if (!is_safe_config_path(file_path)) { + BOOST_LOG(error) << "[config_ops] 配置导入被拒绝: 不安全的文件路径: " << file_path; + show_message_custom(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, + L"文件路径不安全或文件类型无效。\n只允许 .conf 文件,不允许符号链接。", true); + return; + } + + try { + // 读取配置文件内容 + std::string config_content = file_handler::read_file(file_path.c_str()); + + // 安全验证:检查配置内容 + if (!is_safe_config_content(config_content)) { + BOOST_LOG(error) << "[config_ops] 配置导入被拒绝: 不安全的内容: " << file_path; + show_message_custom(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, + L"配置文件内容无效、太大或格式错误。\n最大文件大小:1MB", true); + return; + } + + // 备份当前配置(检查是否成功) + std::string backup_path = config::sunshine.config_file + ".backup"; + std::string current_config = file_handler::read_file(config::sunshine.config_file.c_str()); + int backup_result = file_handler::write_file(backup_path.c_str(), current_config); + + if (backup_result != 0) { + BOOST_LOG(error) << "[config_ops] 创建备份失败,中止导入"; + show_message_custom(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, + L"无法创建配置备份,导入操作已中止。", true); + return; + } + + BOOST_LOG(info) << "[config_ops] 配置备份已创建: " << backup_path; + + // 使用临时文件确保原子性写入 + std::string temp_path = config::sunshine.config_file + ".tmp"; + int temp_result = file_handler::write_file(temp_path.c_str(), config_content); + + if (temp_result != 0) { + BOOST_LOG(error) << "[config_ops] 写入临时配置文件失败"; + show_message(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, system_tray_i18n::KEY_IMPORT_ERROR_WRITE, true); + return; + } + + // 原子性替换:重命名临时文件为实际配置文件 + try { + std::filesystem::rename(temp_path, config::sunshine.config_file); + BOOST_LOG(info) << "[config_ops] 配置导入成功: " << file_path; + + // 询问用户是否重启 Sunshine 以应用新配置 + if (show_confirm_custom(system_tray_i18n::KEY_IMPORT_SUCCESS_TITLE, + L"配置导入成功!\n\n是否立即重启 Sunshine 以应用新配置?")) { + BOOST_LOG(info) << "[config_ops] 用户选择重启 Sunshine"sv; + platf::restart(); + } + else { + BOOST_LOG(info) << "[config_ops] 用户选择不重启 Sunshine"sv; + } + } + catch (const std::exception &e) { + BOOST_LOG(error) << "[config_ops] 重命名临时文件失败: " << e.what(); + // 清理临时文件 + std::filesystem::remove(temp_path); + show_message(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, system_tray_i18n::KEY_IMPORT_ERROR_WRITE, true); + } + } + catch (const std::exception &e) { + BOOST_LOG(error) << "[config_ops] 配置导入时发生异常: " << e.what(); + show_message(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, system_tray_i18n::KEY_IMPORT_ERROR_EXCEPTION, true); + } +#else + // 非 Windows 平台的实现(可以后续添加) + BOOST_LOG(info) << "[config_ops] 该平台尚未实现配置导入功能"; +#endif + } + + /** + * @brief 导出配置文件 + * + * 显示文件保存对话框让用户选择导出位置。 + * 导出时会进行基本的安全验证。 + * 使用原子性写入确保文件完整性。 + */ + void export_config() { + BOOST_LOG(info) << "[config_ops] ========== export_config() 被调用 =========="sv; +#ifdef _WIN32 + // 检查是否已有文件对话框打开 + if (s_file_dialog_open) { + BOOST_LOG(warning) << "[config_ops] 已有文件对话框打开,跳过此次调用"; + return; + } + s_file_dialog_open = true; + BOOST_LOG(debug) << "[config_ops] 设置文件对话框标志为 true"sv; + + BOOST_LOG(info) << "[config_ops] 准备显示文件保存对话框..."sv; + + // 显示文件保存对话框 + std::wstring file_path_wide = show_save_file_dialog(); + + // 重置文件对话框标志 + s_file_dialog_open = false; + BOOST_LOG(debug) << "[config_ops] 重置文件对话框标志为 false"sv; + + // 检查用户是否取消 + if (file_path_wide.empty()) { + BOOST_LOG(info) << "[config_ops] 用户取消了文件对话框"sv; + return; + } + + std::string file_path = platf::to_utf8(file_path_wide); + BOOST_LOG(info) << "[config_ops] 用户选择的导出路径: " << file_path; + + // 安全验证:检查输出文件路径(基本检查) + try { + std::filesystem::path p(file_path); + + // 检查文件扩展名 + if (p.extension() != ".conf") { + BOOST_LOG(warning) << "[config_ops] 配置导出被拒绝: 无效的扩展名: " << p.extension().string(); + show_message_custom(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, + L"只允许导出为 .conf 文件。", true); + return; + } + + // 如果文件已存在,检查是否为符号链接 + if (std::filesystem::exists(p) && std::filesystem::is_symlink(p)) { + BOOST_LOG(warning) << "[config_ops] 配置导出被拒绝: 目标是符号链接: " << file_path; + show_message_custom(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, + L"不允许导出到符号链接。", true); + return; + } + } + catch (const std::exception &e) { + BOOST_LOG(error) << "[config_ops] 导出时路径验证错误: " << e.what(); + show_message_custom(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, + L"文件路径无效。", true); + return; + } + + try { + // 读取当前配置 + std::string config_content = file_handler::read_file(config::sunshine.config_file.c_str()); + if (config_content.empty()) { + BOOST_LOG(error) << "[config_ops] 没有可导出的配置"; + show_message(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, system_tray_i18n::KEY_EXPORT_ERROR_NO_CONFIG, true); + return; + } + + // 使用临时文件确保原子性写入 + std::string temp_path = file_path + ".tmp"; + int temp_result = file_handler::write_file(temp_path.c_str(), config_content); + + if (temp_result != 0) { + BOOST_LOG(error) << "[config_ops] 写入临时导出文件失败"; + show_message(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, system_tray_i18n::KEY_EXPORT_ERROR_WRITE, true); + return; + } + + // 原子性替换 + try { + std::filesystem::rename(temp_path, file_path); + BOOST_LOG(info) << "[config_ops] 配置导出成功: " << file_path; + show_message(system_tray_i18n::KEY_EXPORT_SUCCESS_TITLE, system_tray_i18n::KEY_EXPORT_SUCCESS_MSG, false); + } + catch (const std::exception &e) { + BOOST_LOG(error) << "[config_ops] 重命名临时导出文件失败: " << e.what(); + // 清理临时文件 + std::filesystem::remove(temp_path); + show_message(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, system_tray_i18n::KEY_EXPORT_ERROR_WRITE, true); + } + } + catch (const std::exception &e) { + BOOST_LOG(error) << "[config_ops] 配置导出时发生异常: " << e.what(); + show_message(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, system_tray_i18n::KEY_EXPORT_ERROR_EXCEPTION, true); + } +#else + // 非 Windows 平台的实现 + BOOST_LOG(info) << "[config_ops] 该平台尚未实现配置导出功能"; +#endif + } + + /** + * @brief 重置配置为默认值 + * + * 显示确认对话框,如果用户确认则: + * 1. 备份当前配置 + * 2. 写入默认配置 + * 3. 询问是否重启 Sunshine + */ + void reset_config() { + BOOST_LOG(info) << "[config_ops] ========== reset_config() 被调用 =========="sv; +#ifdef _WIN32 + BOOST_LOG(info) << "[config_ops] 准备显示重置确认对话框..."sv; + + // 显示确认对话框 + if (!show_confirm(system_tray_i18n::KEY_RESET_CONFIRM_TITLE, system_tray_i18n::KEY_RESET_CONFIRM_MSG)) { + BOOST_LOG(info) << "[config_ops] 用户取消了配置重置"sv; + return; + } + + BOOST_LOG(info) << "[config_ops] 用户确认重置配置"sv; + + try { + // 先创建备份 + std::string backup_path = config::sunshine.config_file + ".backup"; + std::string current_config = file_handler::read_file(config::sunshine.config_file.c_str()); + file_handler::write_file(backup_path.c_str(), current_config); + BOOST_LOG(info) << "[config_ops] 配置备份已创建: " << backup_path; + + // 写入默认配置 + std::string default_config = "# Sunshine Configuration\n# Reset to default\n"; + if (file_handler::write_file(config::sunshine.config_file.c_str(), default_config) != 0) { + BOOST_LOG(error) << "[config_ops] 重置配置失败"; + show_message(system_tray_i18n::KEY_RESET_ERROR_TITLE, system_tray_i18n::KEY_RESET_ERROR_MSG, true); + return; + } + + BOOST_LOG(info) << "[config_ops] 配置重置成功"; + + // 询问用户是否重启 + if (show_confirm(system_tray_i18n::KEY_RESET_SUCCESS_TITLE, system_tray_i18n::KEY_RESET_SUCCESS_MSG)) { + BOOST_LOG(info) << "[config_ops] 用户选择重启 Sunshine"sv; + platf::restart(); + } + } + catch (const std::exception &e) { + BOOST_LOG(error) << "[config_ops] 配置重置时发生异常: " << e.what(); + show_message(system_tray_i18n::KEY_RESET_ERROR_TITLE, system_tray_i18n::KEY_RESET_ERROR_EXCEPTION, true); + } +#else + // 非 Windows 平台的实现 + BOOST_LOG(info) << "[config_ops] 该平台尚未实现配置重置功能"; +#endif + } + +} // namespace config_operations diff --git a/src/config_operations.h b/src/config_operations.h new file mode 100644 index 00000000000..f941824e7c2 --- /dev/null +++ b/src/config_operations.h @@ -0,0 +1,41 @@ +/** + * @file src/config_operations.h + * @brief 配置文件操作(导入/导出/重置) + * + * 该模块提供安全的配置文件操作,可以从 Rust 托盘和 C++ 代码中调用。 + * 从 system_tray.cpp 迁移而来,保留了完整的功能实现。 + */ +#pragma once + +#include + +namespace config_operations { + + /** + * @brief 从用户选择的文件导入配置 + * + * 打开文件对话框让用户选择 .conf 文件, + * 验证文件,创建备份,然后替换当前配置。 + * 显示适当的成功/错误消息。 + */ + void import_config(); + + /** + * @brief 将当前配置导出到用户选择的文件 + * + * 打开保存文件对话框让用户选择目标位置, + * 然后将当前配置写入该文件。 + * 显示适当的成功/错误消息。 + */ + void export_config(); + + /** + * @brief 将配置重置为默认值 + * + * 显示确认对话框,如果确认则将配置文件 + * 重置为默认值。 + * 显示适当的成功/错误消息。 + */ + void reset_config(); + +} // namespace config_operations diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index f3f244561af..5b3c651d7ad 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -38,6 +38,7 @@ // Local includes #include "config.h" +#include "config_operations.h" #include "confighttp.h" #include "display_device/display_device.h" #include "display_device/session.h" @@ -136,6 +137,30 @@ namespace system_tray { update_vdd_menu_state(); break; + case TRAY_ACTION_IMPORT_CONFIG: + config_operations::import_config(); + break; + + case TRAY_ACTION_EXPORT_CONFIG: + config_operations::export_config(); + break; + + case TRAY_ACTION_RESET_CONFIG: + config_operations::reset_config(); + break; + + case TRAY_ACTION_STAR_PROJECT: + platf::open_url_in_browser("https://sunshine-foundation.vercel.app/"); + break; + + case TRAY_ACTION_VISIT_PROJECT_SUNSHINE: + platf::open_url_in_browser("https://github.com/qiin2333/Sunshine-Foundation"); + break; + + case TRAY_ACTION_VISIT_PROJECT_MOONLIGHT: + platf::open_url_in_browser("https://github.com/qiin2333/moonlight-vplus"); + break; + case TRAY_ACTION_RESET_DISPLAY_DEVICE_CONFIG: BOOST_LOG(info) << "Resetting display device config"sv; display_device::session_t::get().reset_persistence(); From f166c1987ceb6150e5a1ecc2cbb4f1a9014ac8c0 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 23 Jan 2026 07:00:35 +0800 Subject: [PATCH 28/36] =?UTF-8?q?feat:=20=E6=A8=A1=E6=8B=9F=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=99=BB=E5=BD=95=E4=BB=A5=E8=AE=BF=E9=97=AE=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E6=96=87=E4=BB=B6=E5=A4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config_operations.cpp | 303 ++++++++++++-------------------------- 1 file changed, 97 insertions(+), 206 deletions(-) diff --git a/src/config_operations.cpp b/src/config_operations.cpp index 6dfe4604c22..b6b5f861dec 100644 --- a/src/config_operations.cpp +++ b/src/config_operations.cpp @@ -4,8 +4,9 @@ * * 该模块提供安全的配置文件操作,可以从 Rust 托盘和 C++ 代码中调用。 * - * 注意:由于 Sunshine 以 SYSTEM 用户身份运行,无法访问普通用户的桌面和快速访问位置。 - * 因此我们使用 FOS_HIDEPINNEDPLACES 隐藏快速访问栏,并手动添加常用导航位置。 + * 注意:由于 Sunshine 以 SYSTEM 用户身份运行,文件对话框需要特殊处理。 + * 我们使用 ImpersonateLoggedOnUser 模拟登录用户的身份,使文件对话框能够 + * 正确显示用户的快速访问栏和文件夹。 */ #include "config_operations.h" @@ -153,7 +154,7 @@ namespace config_operations { * @brief 获取当前控制台会话登录用户的令牌 * * 由于 Sunshine 以 SYSTEM 用户运行,我们需要获取实际登录用户的令牌 - * 才能访问其桌面等用户文件夹。 + * 才能正确显示用户的文件对话框。 * * @return 用户令牌句柄,失败返回 NULL。调用者需要调用 CloseHandle 释放。 */ @@ -175,141 +176,46 @@ namespace config_operations { } /** - * @brief 为文件对话框添加导航位置 + * @brief 在登录用户上下文中显示文件对话框的实际实现 * - * 由于以 SYSTEM 用户运行,快速访问栏无法正常工作(会尝试访问 SYSTEM 用户的 - * 桌面,但该位置不存在)。我们使用 FOS_HIDEPINNEDPLACES 隐藏快速访问栏, - * 并手动添加有用的导航位置: + * 这是内部实现函数,在已经模拟用户身份后调用。 * - * 1. 当前登录用户的桌面(如果可以获取) - * 2. 公共桌面 - * 3. 此电脑 - * 4. 所有驱动器 - * 5. 网络 - * - * @param pDialog 文件对话框接口指针 + * @param is_save 是否为保存对话框 + * @return 用户选择的文件路径 */ - static void add_dialog_places(IFileDialog *pDialog) { - // 尝试获取当前登录用户的桌面 - HANDLE user_token = get_console_user_token(); - if (user_token != NULL) { - PWSTR user_desktop = NULL; - // 使用用户令牌获取其桌面路径 - if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Desktop, KF_FLAG_DEFAULT, user_token, &user_desktop))) { - IShellItem *psiDesktop = NULL; - if (SUCCEEDED(SHCreateItemFromParsingName(user_desktop, NULL, IID_PPV_ARGS(&psiDesktop)))) { - pDialog->AddPlace(psiDesktop, FDAP_TOP); - psiDesktop->Release(); - BOOST_LOG(debug) << "[config_ops] 已添加用户桌面到导航栏"; - } - CoTaskMemFree(user_desktop); - } - - // 获取用户的下载文件夹 - PWSTR user_downloads = NULL; - if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Downloads, KF_FLAG_DEFAULT, user_token, &user_downloads))) { - IShellItem *psiDownloads = NULL; - if (SUCCEEDED(SHCreateItemFromParsingName(user_downloads, NULL, IID_PPV_ARGS(&psiDownloads)))) { - pDialog->AddPlace(psiDownloads, FDAP_TOP); - psiDownloads->Release(); - BOOST_LOG(debug) << "[config_ops] 已添加用户下载文件夹到导航栏"; - } - CoTaskMemFree(user_downloads); - } - - // 获取用户的文档文件夹 - PWSTR user_documents = NULL; - if (SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Documents, KF_FLAG_DEFAULT, user_token, &user_documents))) { - IShellItem *psiDocuments = NULL; - if (SUCCEEDED(SHCreateItemFromParsingName(user_documents, NULL, IID_PPV_ARGS(&psiDocuments)))) { - pDialog->AddPlace(psiDocuments, FDAP_TOP); - psiDocuments->Release(); - BOOST_LOG(debug) << "[config_ops] 已添加用户文档文件夹到导航栏"; - } - CoTaskMemFree(user_documents); - } - - CloseHandle(user_token); - } - else { - BOOST_LOG(debug) << "[config_ops] 无法获取用户令牌,将添加公共桌面作为替代"; - - // 如果无法获取用户令牌,添加公共桌面 - IShellItem *psiPublicDesktop = NULL; - if (SUCCEEDED(SHGetKnownFolderItem(FOLDERID_PublicDesktop, KF_FLAG_DEFAULT, NULL, IID_PPV_ARGS(&psiPublicDesktop)))) { - pDialog->AddPlace(psiPublicDesktop, FDAP_TOP); - psiPublicDesktop->Release(); - BOOST_LOG(debug) << "[config_ops] 已添加公共桌面到导航栏"; - } - } - - // 添加"此电脑"到导航栏 - IShellItem *psiComputer = NULL; - if (SUCCEEDED(SHGetKnownFolderItem(FOLDERID_ComputerFolder, KF_FLAG_DEFAULT, NULL, IID_PPV_ARGS(&psiComputer)))) { - pDialog->AddPlace(psiComputer, FDAP_TOP); - psiComputer->Release(); - BOOST_LOG(debug) << "[config_ops] 已添加\"此电脑\"到导航栏"; - } - - // 枚举并添加所有驱动器 - DWORD dwSize = GetLogicalDriveStringsW(0, NULL); - if (dwSize > 0) { - std::vector buffer(dwSize + 1); - if (GetLogicalDriveStringsW(dwSize, buffer.data())) { - for (wchar_t* pDrive = buffer.data(); *pDrive; pDrive += wcslen(pDrive) + 1) { - IShellItem *psiDrive = NULL; - if (SUCCEEDED(SHCreateItemFromParsingName(pDrive, NULL, IID_PPV_ARGS(&psiDrive)))) { - pDialog->AddPlace(psiDrive, FDAP_BOTTOM); - psiDrive->Release(); - } - } - } - } - - // 添加"网络"到导航栏 - IShellItem *psiNetwork = NULL; - if (SUCCEEDED(SHGetKnownFolderItem(FOLDERID_NetworkFolder, KF_FLAG_DEFAULT, NULL, IID_PPV_ARGS(&psiNetwork)))) { - pDialog->AddPlace(psiNetwork, FDAP_BOTTOM); - psiNetwork->Release(); - BOOST_LOG(debug) << "[config_ops] 已添加\"网络\"到导航栏"; - } - } - - /** - * @brief 显示文件打开对话框 - * - * 使用 Windows IFileOpenDialog COM 接口显示现代文件打开对话框。 - * - * @return 用户选择的文件路径(宽字符串),如果取消则返回空字符串 - */ - static std::wstring show_open_file_dialog() { + static std::wstring show_file_dialog_impl(bool is_save) { std::wstring result; // 初始化 COM HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); bool com_initialized = SUCCEEDED(hr); - // 创建文件打开对话框 - IFileOpenDialog *pFileOpen = nullptr; - hr = CoCreateInstance(CLSID_FileOpenDialog, NULL, CLSCTX_ALL, - IID_IFileOpenDialog, reinterpret_cast(&pFileOpen)); + // 创建文件对话框 + IFileDialog *pDialog = nullptr; + hr = CoCreateInstance(is_save ? CLSID_FileSaveDialog : CLSID_FileOpenDialog, + NULL, CLSCTX_ALL, + is_save ? IID_IFileSaveDialog : IID_IFileOpenDialog, + reinterpret_cast(&pDialog)); if (FAILED(hr)) { - BOOST_LOG(error) << "[config_ops] 创建文件打开对话框失败,HRESULT: " << std::hex << hr; + BOOST_LOG(error) << "[config_ops] 创建文件对话框失败,HRESULT: " << std::hex << hr; if (com_initialized) CoUninitialize(); return result; } // 设置对话框选项 - // FOS_HIDEPINNEDPLACES: 隐藏快速访问栏(因为以 SYSTEM 用户运行无法访问) // FOS_FORCEFILESYSTEM: 只允许选择文件系统中的项目 // FOS_DONTADDTORECENT: 不添加到最近使用列表 // FOS_NOCHANGEDIR: 不改变当前工作目录 // FOS_NOVALIDATE: 允许选择不存在的文件名(我们自己验证) + // + // 注意:不使用 FOS_HIDEPINNEDPLACES,因为我们通过 ImpersonateLoggedOnUser + // 模拟登录用户身份,快速访问栏会正确显示用户的文件夹位置。 DWORD dwFlags; - pFileOpen->GetOptions(&dwFlags); - pFileOpen->SetOptions(dwFlags | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | FOS_FILEMUSTEXIST | - FOS_DONTADDTORECENT | FOS_NOCHANGEDIR | FOS_HIDEPINNEDPLACES | FOS_NOVALIDATE); + pDialog->GetOptions(&dwFlags); + DWORD extra_flags = FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | FOS_DONTADDTORECENT | FOS_NOCHANGEDIR | FOS_NOVALIDATE; + extra_flags |= is_save ? FOS_OVERWRITEPROMPT : FOS_FILEMUSTEXIST; + pDialog->SetOptions(dwFlags | extra_flags); // 设置文件类型过滤器 std::wstring config_label = system_tray_i18n::utf8_to_wstring( @@ -321,31 +227,40 @@ namespace config_operations { { config_label.c_str(), L"*.conf" }, { all_files_label.c_str(), L"*.*" } }; - pFileOpen->SetFileTypes(2, fileTypes); - pFileOpen->SetFileTypeIndex(1); // 默认选择 .conf 过滤器 + pDialog->SetFileTypes(2, fileTypes); + pDialog->SetFileTypeIndex(1); // 设置对话框标题 std::wstring dialog_title = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_SELECT_IMPORT)); - pFileOpen->SetTitle(dialog_title.c_str()); + system_tray_i18n::get_localized_string(is_save ? system_tray_i18n::KEY_FILE_DIALOG_SAVE_EXPORT + : system_tray_i18n::KEY_FILE_DIALOG_SELECT_IMPORT)); + pDialog->SetTitle(dialog_title.c_str()); + + // 保存对话框特有设置 + if (is_save) { + IFileSaveDialog *pFileSave = static_cast(pDialog); + pFileSave->SetDefaultExtension(L"conf"); + + // 生成默认文件名(带时间戳) + std::string default_name = "sunshine_config_" + std::to_string(std::time(nullptr)) + ".conf"; + std::wstring wdefault_name(default_name.begin(), default_name.end()); + pFileSave->SetFileName(wdefault_name.c_str()); + } // 设置默认文件夹为应用程序数据目录 IShellItem *psiDefault = NULL; std::wstring default_path = platf::appdata().wstring(); if (SUCCEEDED(SHCreateItemFromParsingName(default_path.c_str(), NULL, IID_PPV_ARGS(&psiDefault)))) { - pFileOpen->SetFolder(psiDefault); + pDialog->SetFolder(psiDefault); psiDefault->Release(); } - // 添加导航位置(驱动器、此电脑、网络等) - add_dialog_places(pFileOpen); - // 显示对话框 - hr = pFileOpen->Show(NULL); + hr = pDialog->Show(NULL); if (SUCCEEDED(hr)) { // 获取用户选择的文件 IShellItem *pItem = nullptr; - hr = pFileOpen->GetResult(&pItem); + hr = pDialog->GetResult(&pItem); if (SUCCEEDED(hr)) { PWSTR pszFilePath = nullptr; hr = pItem->GetDisplayName(SIGDN_FILESYSPATH, &pszFilePath); @@ -357,103 +272,79 @@ namespace config_operations { } } - pFileOpen->Release(); + pDialog->Release(); if (com_initialized) CoUninitialize(); return result; } /** - * @brief 显示文件保存对话框 + * @brief 在登录用户上下文中显示文件对话框 * - * 使用 Windows IFileSaveDialog COM 接口显示现代文件保存对话框。 + * 由于 Sunshine 以 SYSTEM 用户运行,文件对话框的快速访问栏会尝试访问 + * SYSTEM 用户的配置。此函数通过 ImpersonateLoggedOnUser 临时模拟 + * 登录用户的身份,使文件对话框能够正确显示用户的快速访问栏。 * - * @return 用户选择的保存路径(宽字符串),如果取消则返回空字符串 + * @param is_save 是否为保存对话框 + * @return 用户选择的文件路径(宽字符串),如果取消则返回空字符串 */ - static std::wstring show_save_file_dialog() { + static std::wstring show_file_dialog_as_user(bool is_save) { std::wstring result; + bool impersonating = false; - // 初始化 COM - HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); - bool com_initialized = SUCCEEDED(hr); - - // 创建文件保存对话框 - IFileSaveDialog *pFileSave = nullptr; - hr = CoCreateInstance(CLSID_FileSaveDialog, NULL, CLSCTX_ALL, - IID_IFileSaveDialog, reinterpret_cast(&pFileSave)); - - if (FAILED(hr)) { - BOOST_LOG(error) << "[config_ops] 创建文件保存对话框失败,HRESULT: " << std::hex << hr; - if (com_initialized) CoUninitialize(); - return result; + // 尝试获取登录用户的令牌 + HANDLE user_token = get_console_user_token(); + if (user_token != NULL) { + // 模拟登录用户身份 + if (ImpersonateLoggedOnUser(user_token)) { + BOOST_LOG(debug) << "[config_ops] 成功模拟登录用户身份"; + impersonating = true; + } + else { + BOOST_LOG(warning) << "[config_ops] 模拟用户身份失败,错误码: " << GetLastError(); + } + CloseHandle(user_token); } - - // 设置对话框选项 - DWORD dwFlags; - pFileSave->GetOptions(&dwFlags); - pFileSave->SetOptions(dwFlags | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | FOS_OVERWRITEPROMPT | - FOS_DONTADDTORECENT | FOS_NOCHANGEDIR | FOS_HIDEPINNEDPLACES | FOS_NOVALIDATE); - - // 设置文件类型过滤器 - std::wstring config_label = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_CONFIG_FILES)); - std::wstring all_files_label = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_ALL_FILES)); - - COMDLG_FILTERSPEC fileTypes[] = { - { config_label.c_str(), L"*.conf" }, - { all_files_label.c_str(), L"*.*" } - }; - pFileSave->SetFileTypes(2, fileTypes); - pFileSave->SetFileTypeIndex(1); - - // 设置默认扩展名 - pFileSave->SetDefaultExtension(L"conf"); - - // 生成默认文件名(带时间戳) - std::string default_name = "sunshine_config_" + std::to_string(std::time(nullptr)) + ".conf"; - std::wstring wdefault_name(default_name.begin(), default_name.end()); - pFileSave->SetFileName(wdefault_name.c_str()); - - // 设置对话框标题 - std::wstring dialog_title = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_SAVE_EXPORT)); - pFileSave->SetTitle(dialog_title.c_str()); - - // 设置默认文件夹为应用程序数据目录 - IShellItem *psiDefault = NULL; - std::wstring default_path = platf::appdata().wstring(); - if (SUCCEEDED(SHCreateItemFromParsingName(default_path.c_str(), NULL, IID_PPV_ARGS(&psiDefault)))) { - pFileSave->SetFolder(psiDefault); - psiDefault->Release(); + else { + BOOST_LOG(warning) << "[config_ops] 无法获取登录用户令牌,将以 SYSTEM 身份显示对话框"; } - - // 添加导航位置 - add_dialog_places(pFileSave); - - // 显示对话框 - hr = pFileSave->Show(NULL); - if (SUCCEEDED(hr)) { - // 获取用户选择的保存路径 - IShellItem *pItem = nullptr; - hr = pFileSave->GetResult(&pItem); - if (SUCCEEDED(hr)) { - PWSTR pszFilePath = nullptr; - hr = pItem->GetDisplayName(SIGDN_FILESYSPATH, &pszFilePath); - if (SUCCEEDED(hr)) { - result = pszFilePath; - CoTaskMemFree(pszFilePath); - } - pItem->Release(); - } + + // 在用户上下文中初始化 COM 和显示对话框 + result = show_file_dialog_impl(is_save); + + // 恢复 SYSTEM 身份 + if (impersonating) { + RevertToSelf(); + BOOST_LOG(debug) << "[config_ops] 已恢复 SYSTEM 身份"; } - - pFileSave->Release(); - if (com_initialized) CoUninitialize(); return result; } + /** + * @brief 显示文件打开对话框 + * + * 使用 Windows IFileOpenDialog COM 接口显示现代文件打开对话框。 + * 通过模拟登录用户身份,确保快速访问栏正确显示用户的文件夹位置。 + * + * @return 用户选择的文件路径(宽字符串),如果取消则返回空字符串 + */ + static std::wstring show_open_file_dialog() { + return show_file_dialog_as_user(false); + } + + /** + * @brief 显示文件保存对话框 + * + * 使用 Windows IFileSaveDialog COM 接口显示现代文件保存对话框。 + * 通过模拟登录用户身份,确保快速访问栏正确显示用户的文件夹位置。 + * + * @return 用户选择的保存路径(宽字符串),如果取消则返回空字符串 + */ + static std::wstring show_save_file_dialog() { + return show_file_dialog_as_user(true); + } + /** * @brief 显示消息框 * From 8bff078022c743590dcd9963ebb0d7373a13ea2b Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 23 Jan 2026 07:57:41 +0800 Subject: [PATCH 29/36] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmake/compile_definitions/common.cmake | 2 - rust_tray/include/rust_tray.h | 5 +- rust_tray/src/actions.rs | 9 +- rust_tray/src/config.rs | 2 - rust_tray/src/menu_items.rs | 23 - src/config_operations.cpp | 687 ------------------------- src/config_operations.h | 41 -- src/system_tray_rust.cpp | 13 - 8 files changed, 3 insertions(+), 779 deletions(-) delete mode 100644 src/config_operations.cpp delete mode 100644 src/config_operations.h diff --git a/cmake/compile_definitions/common.cmake b/cmake/compile_definitions/common.cmake index eecfb2fab9b..0178d4ae575 100644 --- a/cmake/compile_definitions/common.cmake +++ b/cmake/compile_definitions/common.cmake @@ -117,8 +117,6 @@ 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/config_operations.cpp" - "${CMAKE_SOURCE_DIR}/src/config_operations.h" "${CMAKE_SOURCE_DIR}/src/system_tray_rust.cpp" "${CMAKE_SOURCE_DIR}/src/system_tray.h" "${CMAKE_SOURCE_DIR}/src/system_tray_i18n.cpp" diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h index 834f7751de7..91e6d612be3 100644 --- a/rust_tray/include/rust_tray.h +++ b/rust_tray/include/rust_tray.h @@ -19,10 +19,7 @@ typedef enum { TRAY_ACTION_VDD_CREATE = 2, TRAY_ACTION_VDD_CLOSE = 3, TRAY_ACTION_VDD_PERSISTENT = 4, - // Config actions - TRAY_ACTION_IMPORT_CONFIG = 5, - TRAY_ACTION_EXPORT_CONFIG = 6, - TRAY_ACTION_RESET_CONFIG = 7, + // Reserved: 5, 6, 7 (removed import/export/reset config) TRAY_ACTION_CLOSE_APP = 8, // Language actions TRAY_ACTION_LANGUAGE_CHINESE = 9, diff --git a/rust_tray/src/actions.rs b/rust_tray/src/actions.rs index 4eceaf1c7ef..d8564a0d808 100644 --- a/rust_tray/src/actions.rs +++ b/rust_tray/src/actions.rs @@ -15,10 +15,7 @@ pub enum MenuAction { VddCreate = 2, VddClose = 3, VddPersistent = 4, - // Config actions - ImportConfig = 5, - ExportConfig = 6, - ResetConfig = 7, + // Reserved: 5, 6, 7 (removed import/export/reset config) CloseApp = 8, // Language actions LanguageChinese = 9, @@ -43,9 +40,7 @@ impl TryFrom for MenuAction { 2 => Ok(MenuAction::VddCreate), 3 => Ok(MenuAction::VddClose), 4 => Ok(MenuAction::VddPersistent), - 5 => Ok(MenuAction::ImportConfig), - 6 => Ok(MenuAction::ExportConfig), - 7 => Ok(MenuAction::ResetConfig), + // 5, 6, 7 reserved (removed import/export/reset config) 8 => Ok(MenuAction::CloseApp), 9 => Ok(MenuAction::LanguageChinese), 10 => Ok(MenuAction::LanguageEnglish), diff --git a/rust_tray/src/config.rs b/rust_tray/src/config.rs index 8f053866705..42bee90981a 100644 --- a/rust_tray/src/config.rs +++ b/rust_tray/src/config.rs @@ -4,8 +4,6 @@ //! //! 配置文件路径通过 C++ 的 tray_init_ex 函数获取, //! 确保与主 Sunshine 应用程序使用相同的配置路径。 -//! -//! 注意:导入/导出/重置配置的功能已迁移到 C++ 的 config_operations.cpp 中实现。 use std::collections::HashMap; use std::ffi::OsStr; diff --git a/rust_tray/src/menu_items.rs b/rust_tray/src/menu_items.rs index 79064837797..2b31ea6ee8d 100644 --- a/rust_tray/src/menu_items.rs +++ b/rust_tray/src/menu_items.rs @@ -141,10 +141,6 @@ pub mod ids { // Advanced Settings submenu pub const ADVANCED_SUBMENU: &str = "advanced_submenu"; - pub const IMPORT_CONFIG: &str = "import_config"; - pub const EXPORT_CONFIG: &str = "export_config"; - pub const RESET_CONFIG: &str = "reset_config"; - pub const SEP_ADV: &str = "sep_adv"; pub const CLOSE_APP: &str = "close_app"; pub const RESET_DISPLAY: &str = "reset_display"; @@ -186,15 +182,6 @@ mod handlers { // The Rust side only receives the menu click event } - pub fn import_config() { - } - - pub fn export_config() { - } - - pub fn reset_config() { - } - pub fn close_app() { // Show confirmation dialog before closing app if !config::show_confirm_dialog( @@ -284,13 +271,6 @@ pub fn get_all_items() -> Vec { // ====== Advanced Settings Submenu ====== MenuItemInfo::submenu(ADVANCED_SUBMENU, StringKey::AdvancedSettings, None, 400), - MenuItemInfo::action(IMPORT_CONFIG, StringKey::ImportConfig, Some(ADVANCED_SUBMENU), 410) - .with_handler(handlers::import_config), - MenuItemInfo::action(EXPORT_CONFIG, StringKey::ExportConfig, Some(ADVANCED_SUBMENU), 420) - .with_handler(handlers::export_config), - MenuItemInfo::action(RESET_CONFIG, StringKey::ResetToDefault, Some(ADVANCED_SUBMENU), 430) - .with_handler(handlers::reset_config), - MenuItemInfo::separator(SEP_ADV, Some(ADVANCED_SUBMENU), 440), 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) @@ -362,9 +342,6 @@ fn trigger_action_for_id(item_id: &str) { VDD_CREATE => Some(MenuAction::VddCreate), VDD_CLOSE => Some(MenuAction::VddClose), VDD_PERSISTENT => Some(MenuAction::VddPersistent), - IMPORT_CONFIG => Some(MenuAction::ImportConfig), - EXPORT_CONFIG => Some(MenuAction::ExportConfig), - RESET_CONFIG => Some(MenuAction::ResetConfig), CLOSE_APP => Some(MenuAction::CloseApp), LANG_CHINESE => Some(MenuAction::LanguageChinese), LANG_ENGLISH => Some(MenuAction::LanguageEnglish), diff --git a/src/config_operations.cpp b/src/config_operations.cpp deleted file mode 100644 index b6b5f861dec..00000000000 --- a/src/config_operations.cpp +++ /dev/null @@ -1,687 +0,0 @@ -/** - * @file src/config_operations.cpp - * @brief 配置文件操作(导入/导出/重置) - * - * 该模块提供安全的配置文件操作,可以从 Rust 托盘和 C++ 代码中调用。 - * - * 注意:由于 Sunshine 以 SYSTEM 用户身份运行,文件对话框需要特殊处理。 - * 我们使用 ImpersonateLoggedOnUser 模拟登录用户的身份,使文件对话框能够 - * 正确显示用户的快速访问栏和文件夹。 - */ - -#include "config_operations.h" - -#include -#include -#include -#include - -#if defined(_WIN32) - #define WIN32_LEAN_AND_MEAN - #include - #include - #include - #include - #include - #include -#endif - -#include - -#include "config.h" -#include "file_handler.h" -#include "logging.h" -#include "platform/common.h" -#include "platform/windows/misc.h" -#include "system_tray_i18n.h" - -using namespace std::literals; - -namespace config_operations { - - // ============================================================================ - // 常量和全局变量 - // ============================================================================ - - /** - * @brief 文件对话框打开标志,防止多个对话框同时打开 - */ - static bool s_file_dialog_open = false; - - /** - * @brief 最大配置文件大小限制(1MB) - * - * 这是一个安全限制,防止导入超大的配置文件。 - */ - static constexpr size_t MAX_CONFIG_SIZE = 1024 * 1024; - - // ============================================================================ - // 安全验证函数 - // ============================================================================ - - /** - * @brief 验证文件路径是否安全用于导入配置 - * - * 进行以下安全检查: - * - 文件必须存在 - * - 文件扩展名必须是 .conf - * - 文件不能是符号链接(防止符号链接攻击) - * - 文件必须是普通文件 - * - * @param path 要验证的文件路径 - * @return 如果路径安全返回 true - */ - static bool is_safe_config_path(const std::string &path) { - try { - std::filesystem::path p(path); - - // 检查文件是否存在 - if (!std::filesystem::exists(p)) { - BOOST_LOG(warning) << "[config_ops] 文件不存在: " << path; - return false; - } - - // 获取规范路径(解析所有符号链接) - auto canonical_path = std::filesystem::canonical(p); - - // 检查扩展名 - if (canonical_path.extension() != ".conf") { - BOOST_LOG(warning) << "[config_ops] 无效的文件扩展名: " << canonical_path.extension().string(); - return false; - } - - // 检查是否为符号链接 - if (std::filesystem::is_symlink(p)) { - BOOST_LOG(warning) << "[config_ops] 文件是符号链接,拒绝导入: " << path; - return false; - } - - // 检查是否为普通文件 - if (!std::filesystem::is_regular_file(canonical_path)) { - BOOST_LOG(warning) << "[config_ops] 不是普通文件: " << path; - return false; - } - - return true; - } - catch (const std::exception &e) { - BOOST_LOG(error) << "[config_ops] 路径验证时发生异常: " << e.what(); - return false; - } - } - - /** - * @brief 验证配置文件内容是否安全 - * - * 进行以下检查: - * - 内容大小不能超过 MAX_CONFIG_SIZE - * - 内容不能为空 - * - 内容必须是有效的配置格式(通过尝试解析验证) - * - * @param content 配置文件内容 - * @return 如果内容安全返回 true - */ - static bool is_safe_config_content(const std::string &content) { - // 检查大小限制 - if (content.size() > MAX_CONFIG_SIZE) { - BOOST_LOG(warning) << "[config_ops] 配置文件过大: " << content.size() << " bytes (最大: " << MAX_CONFIG_SIZE << ")"; - return false; - } - - // 检查是否为空 - if (content.empty()) { - BOOST_LOG(warning) << "[config_ops] 配置文件为空"; - return false; - } - - // 尝试解析配置以验证格式 - try { - config::parse_config(content); - return true; - } - catch (const std::exception &e) { - BOOST_LOG(warning) << "[config_ops] 配置文件格式无效: " << e.what(); - return false; - } - } - -#ifdef _WIN32 - // ============================================================================ - // Windows 平台特定函数 - // ============================================================================ - - /** - * @brief 获取当前控制台会话登录用户的令牌 - * - * 由于 Sunshine 以 SYSTEM 用户运行,我们需要获取实际登录用户的令牌 - * 才能正确显示用户的文件对话框。 - * - * @return 用户令牌句柄,失败返回 NULL。调用者需要调用 CloseHandle 释放。 - */ - static HANDLE get_console_user_token() { - // 获取活动的控制台会话 ID - DWORD session_id = WTSGetActiveConsoleSessionId(); - if (session_id == 0xFFFFFFFF) { - BOOST_LOG(debug) << "[config_ops] 无法获取活动控制台会话"; - return NULL; - } - - HANDLE user_token = NULL; - if (!WTSQueryUserToken(session_id, &user_token)) { - BOOST_LOG(debug) << "[config_ops] 无法获取用户令牌,错误码: " << GetLastError(); - return NULL; - } - - return user_token; - } - - /** - * @brief 在登录用户上下文中显示文件对话框的实际实现 - * - * 这是内部实现函数,在已经模拟用户身份后调用。 - * - * @param is_save 是否为保存对话框 - * @return 用户选择的文件路径 - */ - static std::wstring show_file_dialog_impl(bool is_save) { - std::wstring result; - - // 初始化 COM - HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); - bool com_initialized = SUCCEEDED(hr); - - // 创建文件对话框 - IFileDialog *pDialog = nullptr; - hr = CoCreateInstance(is_save ? CLSID_FileSaveDialog : CLSID_FileOpenDialog, - NULL, CLSCTX_ALL, - is_save ? IID_IFileSaveDialog : IID_IFileOpenDialog, - reinterpret_cast(&pDialog)); - - if (FAILED(hr)) { - BOOST_LOG(error) << "[config_ops] 创建文件对话框失败,HRESULT: " << std::hex << hr; - if (com_initialized) CoUninitialize(); - return result; - } - - // 设置对话框选项 - // FOS_FORCEFILESYSTEM: 只允许选择文件系统中的项目 - // FOS_DONTADDTORECENT: 不添加到最近使用列表 - // FOS_NOCHANGEDIR: 不改变当前工作目录 - // FOS_NOVALIDATE: 允许选择不存在的文件名(我们自己验证) - // - // 注意:不使用 FOS_HIDEPINNEDPLACES,因为我们通过 ImpersonateLoggedOnUser - // 模拟登录用户身份,快速访问栏会正确显示用户的文件夹位置。 - DWORD dwFlags; - pDialog->GetOptions(&dwFlags); - DWORD extra_flags = FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | FOS_DONTADDTORECENT | FOS_NOCHANGEDIR | FOS_NOVALIDATE; - extra_flags |= is_save ? FOS_OVERWRITEPROMPT : FOS_FILEMUSTEXIST; - pDialog->SetOptions(dwFlags | extra_flags); - - // 设置文件类型过滤器 - std::wstring config_label = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_CONFIG_FILES)); - std::wstring all_files_label = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(system_tray_i18n::KEY_FILE_DIALOG_ALL_FILES)); - - COMDLG_FILTERSPEC fileTypes[] = { - { config_label.c_str(), L"*.conf" }, - { all_files_label.c_str(), L"*.*" } - }; - pDialog->SetFileTypes(2, fileTypes); - pDialog->SetFileTypeIndex(1); - - // 设置对话框标题 - std::wstring dialog_title = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(is_save ? system_tray_i18n::KEY_FILE_DIALOG_SAVE_EXPORT - : system_tray_i18n::KEY_FILE_DIALOG_SELECT_IMPORT)); - pDialog->SetTitle(dialog_title.c_str()); - - // 保存对话框特有设置 - if (is_save) { - IFileSaveDialog *pFileSave = static_cast(pDialog); - pFileSave->SetDefaultExtension(L"conf"); - - // 生成默认文件名(带时间戳) - std::string default_name = "sunshine_config_" + std::to_string(std::time(nullptr)) + ".conf"; - std::wstring wdefault_name(default_name.begin(), default_name.end()); - pFileSave->SetFileName(wdefault_name.c_str()); - } - - // 设置默认文件夹为应用程序数据目录 - IShellItem *psiDefault = NULL; - std::wstring default_path = platf::appdata().wstring(); - if (SUCCEEDED(SHCreateItemFromParsingName(default_path.c_str(), NULL, IID_PPV_ARGS(&psiDefault)))) { - pDialog->SetFolder(psiDefault); - psiDefault->Release(); - } - - // 显示对话框 - hr = pDialog->Show(NULL); - if (SUCCEEDED(hr)) { - // 获取用户选择的文件 - IShellItem *pItem = nullptr; - hr = pDialog->GetResult(&pItem); - if (SUCCEEDED(hr)) { - PWSTR pszFilePath = nullptr; - hr = pItem->GetDisplayName(SIGDN_FILESYSPATH, &pszFilePath); - if (SUCCEEDED(hr)) { - result = pszFilePath; - CoTaskMemFree(pszFilePath); - } - pItem->Release(); - } - } - - pDialog->Release(); - if (com_initialized) CoUninitialize(); - - return result; - } - - /** - * @brief 在登录用户上下文中显示文件对话框 - * - * 由于 Sunshine 以 SYSTEM 用户运行,文件对话框的快速访问栏会尝试访问 - * SYSTEM 用户的配置。此函数通过 ImpersonateLoggedOnUser 临时模拟 - * 登录用户的身份,使文件对话框能够正确显示用户的快速访问栏。 - * - * @param is_save 是否为保存对话框 - * @return 用户选择的文件路径(宽字符串),如果取消则返回空字符串 - */ - static std::wstring show_file_dialog_as_user(bool is_save) { - std::wstring result; - bool impersonating = false; - - // 尝试获取登录用户的令牌 - HANDLE user_token = get_console_user_token(); - if (user_token != NULL) { - // 模拟登录用户身份 - if (ImpersonateLoggedOnUser(user_token)) { - BOOST_LOG(debug) << "[config_ops] 成功模拟登录用户身份"; - impersonating = true; - } - else { - BOOST_LOG(warning) << "[config_ops] 模拟用户身份失败,错误码: " << GetLastError(); - } - CloseHandle(user_token); - } - else { - BOOST_LOG(warning) << "[config_ops] 无法获取登录用户令牌,将以 SYSTEM 身份显示对话框"; - } - - // 在用户上下文中初始化 COM 和显示对话框 - result = show_file_dialog_impl(is_save); - - // 恢复 SYSTEM 身份 - if (impersonating) { - RevertToSelf(); - BOOST_LOG(debug) << "[config_ops] 已恢复 SYSTEM 身份"; - } - - return result; - } - - /** - * @brief 显示文件打开对话框 - * - * 使用 Windows IFileOpenDialog COM 接口显示现代文件打开对话框。 - * 通过模拟登录用户身份,确保快速访问栏正确显示用户的文件夹位置。 - * - * @return 用户选择的文件路径(宽字符串),如果取消则返回空字符串 - */ - static std::wstring show_open_file_dialog() { - return show_file_dialog_as_user(false); - } - - /** - * @brief 显示文件保存对话框 - * - * 使用 Windows IFileSaveDialog COM 接口显示现代文件保存对话框。 - * 通过模拟登录用户身份,确保快速访问栏正确显示用户的文件夹位置。 - * - * @return 用户选择的保存路径(宽字符串),如果取消则返回空字符串 - */ - static std::wstring show_save_file_dialog() { - return show_file_dialog_as_user(true); - } - - /** - * @brief 显示消息框 - * - * @param title_key 标题的本地化键 - * @param msg_key 消息的本地化键 - * @param is_error 是否为错误消息(决定图标类型) - */ - static void show_message(const std::string &title_key, const std::string &msg_key, bool is_error) { - std::wstring title = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(title_key)); - std::wstring message = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(msg_key)); - - UINT type = is_error ? (MB_OK | MB_ICONERROR) : (MB_OK | MB_ICONINFORMATION); - MessageBoxW(NULL, message.c_str(), title.c_str(), type); - } - - /** - * @brief 显示带有自定义消息的消息框 - * - * @param title_key 标题的本地化键 - * @param message 自定义消息(宽字符串) - * @param is_error 是否为错误消息 - */ - static void show_message_custom(const std::string &title_key, const std::wstring &message, bool is_error) { - std::wstring title = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(title_key)); - - UINT type = is_error ? (MB_OK | MB_ICONERROR) : (MB_OK | MB_ICONINFORMATION); - MessageBoxW(NULL, message.c_str(), title.c_str(), type); - } - - /** - * @brief 显示确认对话框 - * - * @param title_key 标题的本地化键 - * @param msg_key 消息的本地化键 - * @return 如果用户点击"是"返回 true - */ - static bool show_confirm(const std::string &title_key, const std::string &msg_key) { - std::wstring title = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(title_key)); - std::wstring message = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(msg_key)); - - int result = MessageBoxW(NULL, message.c_str(), title.c_str(), MB_YESNO | MB_ICONQUESTION); - return result == IDYES; - } - - /** - * @brief 显示带有自定义消息的确认对话框 - * - * @param title_key 标题的本地化键 - * @param message 自定义消息(宽字符串) - * @return 如果用户点击"是"返回 true - */ - static bool show_confirm_custom(const std::string &title_key, const std::wstring &message) { - std::wstring title = system_tray_i18n::utf8_to_wstring( - system_tray_i18n::get_localized_string(title_key)); - - int result = MessageBoxW(NULL, message.c_str(), title.c_str(), MB_YESNO | MB_ICONQUESTION); - return result == IDYES; - } -#endif - - // ============================================================================ - // 公共接口函数 - // ============================================================================ - - /** - * @brief 导入配置文件 - * - * 显示文件选择对话框让用户选择要导入的配置文件。 - * 导入前会进行安全验证,并创建当前配置的备份。 - * 导入成功后询问用户是否重启 Sunshine 以应用新配置。 - */ - void import_config() { - BOOST_LOG(info) << "[config_ops] ========== import_config() 被调用 =========="sv; -#ifdef _WIN32 - // 检查是否已有文件对话框打开 - if (s_file_dialog_open) { - BOOST_LOG(warning) << "[config_ops] 已有文件对话框打开,跳过此次调用"; - return; - } - s_file_dialog_open = true; - BOOST_LOG(debug) << "[config_ops] 设置文件对话框标志为 true"sv; - - BOOST_LOG(info) << "[config_ops] 准备显示文件打开对话框..."sv; - - // 显示文件打开对话框 - std::wstring file_path_wide = show_open_file_dialog(); - - // 重置文件对话框标志 - s_file_dialog_open = false; - BOOST_LOG(debug) << "[config_ops] 重置文件对话框标志为 false"sv; - - // 检查用户是否取消 - if (file_path_wide.empty()) { - BOOST_LOG(info) << "[config_ops] 用户取消了文件对话框"sv; - return; - } - - std::string file_path = platf::to_utf8(file_path_wide); - - // 安全验证:检查文件路径 - if (!is_safe_config_path(file_path)) { - BOOST_LOG(error) << "[config_ops] 配置导入被拒绝: 不安全的文件路径: " << file_path; - show_message_custom(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, - L"文件路径不安全或文件类型无效。\n只允许 .conf 文件,不允许符号链接。", true); - return; - } - - try { - // 读取配置文件内容 - std::string config_content = file_handler::read_file(file_path.c_str()); - - // 安全验证:检查配置内容 - if (!is_safe_config_content(config_content)) { - BOOST_LOG(error) << "[config_ops] 配置导入被拒绝: 不安全的内容: " << file_path; - show_message_custom(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, - L"配置文件内容无效、太大或格式错误。\n最大文件大小:1MB", true); - return; - } - - // 备份当前配置(检查是否成功) - std::string backup_path = config::sunshine.config_file + ".backup"; - std::string current_config = file_handler::read_file(config::sunshine.config_file.c_str()); - int backup_result = file_handler::write_file(backup_path.c_str(), current_config); - - if (backup_result != 0) { - BOOST_LOG(error) << "[config_ops] 创建备份失败,中止导入"; - show_message_custom(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, - L"无法创建配置备份,导入操作已中止。", true); - return; - } - - BOOST_LOG(info) << "[config_ops] 配置备份已创建: " << backup_path; - - // 使用临时文件确保原子性写入 - std::string temp_path = config::sunshine.config_file + ".tmp"; - int temp_result = file_handler::write_file(temp_path.c_str(), config_content); - - if (temp_result != 0) { - BOOST_LOG(error) << "[config_ops] 写入临时配置文件失败"; - show_message(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, system_tray_i18n::KEY_IMPORT_ERROR_WRITE, true); - return; - } - - // 原子性替换:重命名临时文件为实际配置文件 - try { - std::filesystem::rename(temp_path, config::sunshine.config_file); - BOOST_LOG(info) << "[config_ops] 配置导入成功: " << file_path; - - // 询问用户是否重启 Sunshine 以应用新配置 - if (show_confirm_custom(system_tray_i18n::KEY_IMPORT_SUCCESS_TITLE, - L"配置导入成功!\n\n是否立即重启 Sunshine 以应用新配置?")) { - BOOST_LOG(info) << "[config_ops] 用户选择重启 Sunshine"sv; - platf::restart(); - } - else { - BOOST_LOG(info) << "[config_ops] 用户选择不重启 Sunshine"sv; - } - } - catch (const std::exception &e) { - BOOST_LOG(error) << "[config_ops] 重命名临时文件失败: " << e.what(); - // 清理临时文件 - std::filesystem::remove(temp_path); - show_message(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, system_tray_i18n::KEY_IMPORT_ERROR_WRITE, true); - } - } - catch (const std::exception &e) { - BOOST_LOG(error) << "[config_ops] 配置导入时发生异常: " << e.what(); - show_message(system_tray_i18n::KEY_IMPORT_ERROR_TITLE, system_tray_i18n::KEY_IMPORT_ERROR_EXCEPTION, true); - } -#else - // 非 Windows 平台的实现(可以后续添加) - BOOST_LOG(info) << "[config_ops] 该平台尚未实现配置导入功能"; -#endif - } - - /** - * @brief 导出配置文件 - * - * 显示文件保存对话框让用户选择导出位置。 - * 导出时会进行基本的安全验证。 - * 使用原子性写入确保文件完整性。 - */ - void export_config() { - BOOST_LOG(info) << "[config_ops] ========== export_config() 被调用 =========="sv; -#ifdef _WIN32 - // 检查是否已有文件对话框打开 - if (s_file_dialog_open) { - BOOST_LOG(warning) << "[config_ops] 已有文件对话框打开,跳过此次调用"; - return; - } - s_file_dialog_open = true; - BOOST_LOG(debug) << "[config_ops] 设置文件对话框标志为 true"sv; - - BOOST_LOG(info) << "[config_ops] 准备显示文件保存对话框..."sv; - - // 显示文件保存对话框 - std::wstring file_path_wide = show_save_file_dialog(); - - // 重置文件对话框标志 - s_file_dialog_open = false; - BOOST_LOG(debug) << "[config_ops] 重置文件对话框标志为 false"sv; - - // 检查用户是否取消 - if (file_path_wide.empty()) { - BOOST_LOG(info) << "[config_ops] 用户取消了文件对话框"sv; - return; - } - - std::string file_path = platf::to_utf8(file_path_wide); - BOOST_LOG(info) << "[config_ops] 用户选择的导出路径: " << file_path; - - // 安全验证:检查输出文件路径(基本检查) - try { - std::filesystem::path p(file_path); - - // 检查文件扩展名 - if (p.extension() != ".conf") { - BOOST_LOG(warning) << "[config_ops] 配置导出被拒绝: 无效的扩展名: " << p.extension().string(); - show_message_custom(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, - L"只允许导出为 .conf 文件。", true); - return; - } - - // 如果文件已存在,检查是否为符号链接 - if (std::filesystem::exists(p) && std::filesystem::is_symlink(p)) { - BOOST_LOG(warning) << "[config_ops] 配置导出被拒绝: 目标是符号链接: " << file_path; - show_message_custom(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, - L"不允许导出到符号链接。", true); - return; - } - } - catch (const std::exception &e) { - BOOST_LOG(error) << "[config_ops] 导出时路径验证错误: " << e.what(); - show_message_custom(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, - L"文件路径无效。", true); - return; - } - - try { - // 读取当前配置 - std::string config_content = file_handler::read_file(config::sunshine.config_file.c_str()); - if (config_content.empty()) { - BOOST_LOG(error) << "[config_ops] 没有可导出的配置"; - show_message(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, system_tray_i18n::KEY_EXPORT_ERROR_NO_CONFIG, true); - return; - } - - // 使用临时文件确保原子性写入 - std::string temp_path = file_path + ".tmp"; - int temp_result = file_handler::write_file(temp_path.c_str(), config_content); - - if (temp_result != 0) { - BOOST_LOG(error) << "[config_ops] 写入临时导出文件失败"; - show_message(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, system_tray_i18n::KEY_EXPORT_ERROR_WRITE, true); - return; - } - - // 原子性替换 - try { - std::filesystem::rename(temp_path, file_path); - BOOST_LOG(info) << "[config_ops] 配置导出成功: " << file_path; - show_message(system_tray_i18n::KEY_EXPORT_SUCCESS_TITLE, system_tray_i18n::KEY_EXPORT_SUCCESS_MSG, false); - } - catch (const std::exception &e) { - BOOST_LOG(error) << "[config_ops] 重命名临时导出文件失败: " << e.what(); - // 清理临时文件 - std::filesystem::remove(temp_path); - show_message(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, system_tray_i18n::KEY_EXPORT_ERROR_WRITE, true); - } - } - catch (const std::exception &e) { - BOOST_LOG(error) << "[config_ops] 配置导出时发生异常: " << e.what(); - show_message(system_tray_i18n::KEY_EXPORT_ERROR_TITLE, system_tray_i18n::KEY_EXPORT_ERROR_EXCEPTION, true); - } -#else - // 非 Windows 平台的实现 - BOOST_LOG(info) << "[config_ops] 该平台尚未实现配置导出功能"; -#endif - } - - /** - * @brief 重置配置为默认值 - * - * 显示确认对话框,如果用户确认则: - * 1. 备份当前配置 - * 2. 写入默认配置 - * 3. 询问是否重启 Sunshine - */ - void reset_config() { - BOOST_LOG(info) << "[config_ops] ========== reset_config() 被调用 =========="sv; -#ifdef _WIN32 - BOOST_LOG(info) << "[config_ops] 准备显示重置确认对话框..."sv; - - // 显示确认对话框 - if (!show_confirm(system_tray_i18n::KEY_RESET_CONFIRM_TITLE, system_tray_i18n::KEY_RESET_CONFIRM_MSG)) { - BOOST_LOG(info) << "[config_ops] 用户取消了配置重置"sv; - return; - } - - BOOST_LOG(info) << "[config_ops] 用户确认重置配置"sv; - - try { - // 先创建备份 - std::string backup_path = config::sunshine.config_file + ".backup"; - std::string current_config = file_handler::read_file(config::sunshine.config_file.c_str()); - file_handler::write_file(backup_path.c_str(), current_config); - BOOST_LOG(info) << "[config_ops] 配置备份已创建: " << backup_path; - - // 写入默认配置 - std::string default_config = "# Sunshine Configuration\n# Reset to default\n"; - if (file_handler::write_file(config::sunshine.config_file.c_str(), default_config) != 0) { - BOOST_LOG(error) << "[config_ops] 重置配置失败"; - show_message(system_tray_i18n::KEY_RESET_ERROR_TITLE, system_tray_i18n::KEY_RESET_ERROR_MSG, true); - return; - } - - BOOST_LOG(info) << "[config_ops] 配置重置成功"; - - // 询问用户是否重启 - if (show_confirm(system_tray_i18n::KEY_RESET_SUCCESS_TITLE, system_tray_i18n::KEY_RESET_SUCCESS_MSG)) { - BOOST_LOG(info) << "[config_ops] 用户选择重启 Sunshine"sv; - platf::restart(); - } - } - catch (const std::exception &e) { - BOOST_LOG(error) << "[config_ops] 配置重置时发生异常: " << e.what(); - show_message(system_tray_i18n::KEY_RESET_ERROR_TITLE, system_tray_i18n::KEY_RESET_ERROR_EXCEPTION, true); - } -#else - // 非 Windows 平台的实现 - BOOST_LOG(info) << "[config_ops] 该平台尚未实现配置重置功能"; -#endif - } - -} // namespace config_operations diff --git a/src/config_operations.h b/src/config_operations.h deleted file mode 100644 index f941824e7c2..00000000000 --- a/src/config_operations.h +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @file src/config_operations.h - * @brief 配置文件操作(导入/导出/重置) - * - * 该模块提供安全的配置文件操作,可以从 Rust 托盘和 C++ 代码中调用。 - * 从 system_tray.cpp 迁移而来,保留了完整的功能实现。 - */ -#pragma once - -#include - -namespace config_operations { - - /** - * @brief 从用户选择的文件导入配置 - * - * 打开文件对话框让用户选择 .conf 文件, - * 验证文件,创建备份,然后替换当前配置。 - * 显示适当的成功/错误消息。 - */ - void import_config(); - - /** - * @brief 将当前配置导出到用户选择的文件 - * - * 打开保存文件对话框让用户选择目标位置, - * 然后将当前配置写入该文件。 - * 显示适当的成功/错误消息。 - */ - void export_config(); - - /** - * @brief 将配置重置为默认值 - * - * 显示确认对话框,如果确认则将配置文件 - * 重置为默认值。 - * 显示适当的成功/错误消息。 - */ - void reset_config(); - -} // namespace config_operations diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index 5b3c651d7ad..4f18524c02e 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -38,7 +38,6 @@ // Local includes #include "config.h" -#include "config_operations.h" #include "confighttp.h" #include "display_device/display_device.h" #include "display_device/session.h" @@ -137,18 +136,6 @@ namespace system_tray { update_vdd_menu_state(); break; - case TRAY_ACTION_IMPORT_CONFIG: - config_operations::import_config(); - break; - - case TRAY_ACTION_EXPORT_CONFIG: - config_operations::export_config(); - break; - - case TRAY_ACTION_RESET_CONFIG: - config_operations::reset_config(); - break; - case TRAY_ACTION_STAR_PROJECT: platf::open_url_in_browser("https://sunshine-foundation.vercel.app/"); break; From 67ae4a0764d8535866dbb598aa4592ee35ce5573 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:28:59 +0800 Subject: [PATCH 30/36] =?UTF-8?q?perf:=20=E6=B8=85=E7=90=86=E6=9C=AA?= =?UTF-8?q?=E4=BD=BF=E7=94=A8=E7=9A=84=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/actions.rs | 45 ---------------------- rust_tray/src/menu_items.rs | 74 +++++++++---------------------------- 2 files changed, 17 insertions(+), 102 deletions(-) diff --git a/rust_tray/src/actions.rs b/rust_tray/src/actions.rs index d8564a0d808..e3cca149312 100644 --- a/rust_tray/src/actions.rs +++ b/rust_tray/src/actions.rs @@ -74,48 +74,3 @@ pub fn trigger_action(action: MenuAction) { callback(action as u32); } } - -/// URLs for opening in browser -pub mod urls { - pub const GITHUB_PROJECT: &str = "https://sunshine-foundation.vercel.app/"; - pub const PROJECT_SUNSHINE: &str = "https://github.com/qiin2333/Sunshine-Foundation"; - pub const PROJECT_MOONLIGHT: &str = "https://github.com/qiin2333/moonlight-vplus"; -} - -/// Open URL in default browser -#[cfg(target_os = "windows")] -pub fn open_url(url: &str) { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - use std::ptr::null_mut; - - let wide_url: Vec = OsStr::new(url) - .encode_wide() - .chain(std::iter::once(0)) - .collect(); - - unsafe { - windows_sys::Win32::UI::Shell::ShellExecuteW( - null_mut(), - ['o' as u16, 'p' as u16, 'e' as u16, 'n' as u16, 0].as_ptr(), - wide_url.as_ptr(), - null_mut(), - null_mut(), - windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL as i32, - ); - } -} - -#[cfg(target_os = "linux")] -pub fn open_url(url: &str) { - let _ = std::process::Command::new("xdg-open") - .arg(url) - .spawn(); -} - -#[cfg(target_os = "macos")] -pub fn open_url(url: &str) { - let _ = std::process::Command::new("open") - .arg(url) - .spawn(); -} diff --git a/rust_tray/src/menu_items.rs b/rust_tray/src/menu_items.rs index 2b31ea6ee8d..cfe5447cc22 100644 --- a/rust_tray/src/menu_items.rs +++ b/rust_tray/src/menu_items.rs @@ -163,47 +163,27 @@ pub mod ids { mod handlers { use super::*; - pub fn open_sunshine() { - // Handled by C++ callback - } - - pub fn vdd_create() { - // VDD create/close validation is handled by C++ side (cooldown + state check) - // C++ will only call into Rust after validation passes - } - - pub fn vdd_close() { - // VDD create/close validation is handled by C++ side (cooldown + state check) - // C++ will only call into Rust after validation passes - } - - pub fn vdd_persistent() { - // VDD persistent toggle - C++ handles the confirmation and config save - // The Rust side only receives the menu click event - } - + /// 关闭应用前显示确认对话框 pub fn close_app() { - // Show confirmation dialog before closing app if !config::show_confirm_dialog( get_string(StringKey::CloseAppConfirmTitle), get_string(StringKey::CloseAppConfirmMsg), ) { return; } - // C++ will handle the actual close } + /// 重置显示配置前显示确认对话框 pub fn reset_display() { - // Show confirmation dialog before resetting display config if !config::show_confirm_dialog( get_string(StringKey::ResetDisplayConfirmTitle), get_string(StringKey::ResetDisplayConfirmMsg), ) { return; } - // C++ will handle the actual reset } + /// 切换语言并保存设置 pub fn lang_chinese() { set_locale_str("zh"); let _ = config::save_tray_locale("zh"); @@ -219,28 +199,14 @@ mod handlers { let _ = config::save_tray_locale("ja"); } - pub fn star_project() { - } - - pub fn visit_sunshine() { - } - - pub fn visit_moonlight() { - } - - pub fn restart() { - // Handled by C++ callback directly (no confirmation needed) - } - + /// 退出前显示确认对话框 pub fn quit() { - // Show confirmation dialog before quitting if !config::show_confirm_dialog( get_string(StringKey::QuitTitle), get_string(StringKey::QuitMessage), ) { return; } - // C++ will handle the actual quit } } @@ -255,19 +221,15 @@ pub fn get_all_items() -> Vec { vec![ // ====== Top Level Items ====== - MenuItemInfo::action(OPEN_SUNSHINE, StringKey::OpenSunshine, None, 100) - .with_handler(handlers::open_sunshine), + 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) - .with_handler(handlers::vdd_create), - MenuItemInfo::check(VDD_CLOSE, StringKey::VddClose, Some(VDD_SUBMENU), false, 320) - .with_handler(handlers::vdd_close), - MenuItemInfo::check(VDD_PERSISTENT, StringKey::VddPersistent, Some(VDD_SUBMENU), false, 330) - .with_handler(handlers::vdd_persistent), + 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), @@ -293,21 +255,17 @@ pub fn get_all_items() -> Vec { MenuItemInfo::separator(SEP_3, None, 700), // ====== Star Project ====== - MenuItemInfo::action(STAR_PROJECT, StringKey::StarProject, None, 800) - .with_handler(handlers::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) - .with_handler(handlers::visit_sunshine), - MenuItemInfo::action(VISIT_MOONLIGHT, StringKey::VisitProjectMoonlight, Some(VISIT_SUBMENU), 920) - .with_handler(handlers::visit_moonlight), + 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) - .with_handler(handlers::restart), + MenuItemInfo::action(RESTART, StringKey::Restart, None, 1100), MenuItemInfo::action(QUIT, StringKey::Quit, None, 1200) .with_handler(handlers::quit), ] @@ -321,12 +279,14 @@ pub fn execute_handler(item_id: &str) -> (bool, bool) { 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.) if let Some(handler) = item.handler { handler(); - // Also trigger the C++ callback for items that need it - trigger_action_for_id(item_id); - return (true, needs_rebuild); } + + // Always trigger C++ callback for action items + trigger_action_for_id(item_id); + return (true, needs_rebuild); } (false, false) From 43a8f6ed14805d6901ba0c3e0fbab77f1d5493b3 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:30:48 +0800 Subject: [PATCH 31/36] =?UTF-8?q?perf:=20=E4=BD=BF=E7=94=A8=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E4=B8=B2id=E6=9B=BF=E4=BB=A3=E6=95=B4=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/include/rust_tray.h | 46 ++++++------- rust_tray/src/actions.rs | 68 +++---------------- rust_tray/src/menu_items.rs | 27 +------- src/system_tray_rust.cpp | 122 ++++++++++++++++------------------ 4 files changed, 89 insertions(+), 174 deletions(-) diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h index 91e6d612be3..20479629075 100644 --- a/rust_tray/include/rust_tray.h +++ b/rust_tray/include/rust_tray.h @@ -11,29 +11,25 @@ extern "C" { #endif /** - * @brief Menu action identifiers (must match Rust MenuAction enum) - */ -typedef enum { - TRAY_ACTION_OPEN_UI = 1, - // VDD submenu actions - TRAY_ACTION_VDD_CREATE = 2, - TRAY_ACTION_VDD_CLOSE = 3, - TRAY_ACTION_VDD_PERSISTENT = 4, - // Reserved: 5, 6, 7 (removed import/export/reset config) - TRAY_ACTION_CLOSE_APP = 8, - // Language actions - TRAY_ACTION_LANGUAGE_CHINESE = 9, - TRAY_ACTION_LANGUAGE_ENGLISH = 10, - TRAY_ACTION_LANGUAGE_JAPANESE = 11, - TRAY_ACTION_STAR_PROJECT = 12, - // Visit Project actions - TRAY_ACTION_VISIT_PROJECT_SUNSHINE = 13, - TRAY_ACTION_VISIT_PROJECT_MOONLIGHT = 14, - TRAY_ACTION_RESET_DISPLAY_DEVICE_CONFIG = 15, - TRAY_ACTION_RESTART = 16, - TRAY_ACTION_QUIT = 17, - TRAY_ACTION_NOTIFICATION_CLICKED = 18, -} TrayAction; + * @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 @@ -47,9 +43,9 @@ typedef enum { /** * @brief Callback function type for menu actions - * @param action The action identifier + * @param action_id The action identifier string (null-terminated) */ -typedef void (*TrayActionCallback)(uint32_t action); +typedef void (*TrayActionCallback)(const char* action_id); /** * @brief Initialize the tray with extended options diff --git a/rust_tray/src/actions.rs b/rust_tray/src/actions.rs index e3cca149312..8773d652631 100644 --- a/rust_tray/src/actions.rs +++ b/rust_tray/src/actions.rs @@ -1,64 +1,14 @@ //! Menu actions module //! -//! Defines all menu actions and their identifiers. -//! C++ side will register callbacks for these actions. +//! 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; -/// Menu action identifiers -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[repr(u32)] -pub enum MenuAction { - OpenUI = 1, - // VDD submenu actions - VddCreate = 2, - VddClose = 3, - VddPersistent = 4, - // Reserved: 5, 6, 7 (removed import/export/reset config) - CloseApp = 8, - // Language actions - LanguageChinese = 9, - LanguageEnglish = 10, - LanguageJapanese = 11, - StarProject = 12, - // Visit Project actions - VisitProjectSunshine = 13, - VisitProjectMoonlight = 14, - ResetDisplayDeviceConfig = 15, - Restart = 16, - Quit = 17, - NotificationClicked = 18, -} - -impl TryFrom for MenuAction { - type Error = (); - - fn try_from(value: u32) -> Result { - match value { - 1 => Ok(MenuAction::OpenUI), - 2 => Ok(MenuAction::VddCreate), - 3 => Ok(MenuAction::VddClose), - 4 => Ok(MenuAction::VddPersistent), - // 5, 6, 7 reserved (removed import/export/reset config) - 8 => Ok(MenuAction::CloseApp), - 9 => Ok(MenuAction::LanguageChinese), - 10 => Ok(MenuAction::LanguageEnglish), - 11 => Ok(MenuAction::LanguageJapanese), - 12 => Ok(MenuAction::StarProject), - 13 => Ok(MenuAction::VisitProjectSunshine), - 14 => Ok(MenuAction::VisitProjectMoonlight), - 15 => Ok(MenuAction::ResetDisplayDeviceConfig), - 16 => Ok(MenuAction::Restart), - 17 => Ok(MenuAction::Quit), - 18 => Ok(MenuAction::NotificationClicked), - _ => Err(()), - } - } -} - -/// Callback function type for menu actions -pub type ActionCallback = extern "C" fn(action: u32); +/// 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)); @@ -68,9 +18,11 @@ pub fn register_callback(callback: ActionCallback) { *ACTION_CALLBACK.write() = Some(callback); } -/// Trigger a menu action -pub fn trigger_action(action: MenuAction) { +/// Trigger a menu action by string ID +pub fn trigger_action(action_id: &str) { if let Some(callback) = *ACTION_CALLBACK.read() { - callback(action as u32); + if let Ok(c_str) = CString::new(action_id) { + callback(c_str.as_ptr()); + } } } diff --git a/rust_tray/src/menu_items.rs b/rust_tray/src/menu_items.rs index cfe5447cc22..683ce2af4f7 100644 --- a/rust_tray/src/menu_items.rs +++ b/rust_tray/src/menu_items.rs @@ -292,30 +292,7 @@ pub fn execute_handler(item_id: &str) -> (bool, bool) { (false, false) } -/// Map item ID to MenuAction for C++ callback +/// Trigger C++ callback with action ID fn trigger_action_for_id(item_id: &str) { - use crate::actions::MenuAction; - use ids::*; - - let action = match item_id { - OPEN_SUNSHINE => Some(MenuAction::OpenUI), - VDD_CREATE => Some(MenuAction::VddCreate), - VDD_CLOSE => Some(MenuAction::VddClose), - VDD_PERSISTENT => Some(MenuAction::VddPersistent), - CLOSE_APP => Some(MenuAction::CloseApp), - LANG_CHINESE => Some(MenuAction::LanguageChinese), - LANG_ENGLISH => Some(MenuAction::LanguageEnglish), - LANG_JAPANESE => Some(MenuAction::LanguageJapanese), - STAR_PROJECT => Some(MenuAction::StarProject), - VISIT_SUNSHINE => Some(MenuAction::VisitProjectSunshine), - VISIT_MOONLIGHT => Some(MenuAction::VisitProjectMoonlight), - RESET_DISPLAY => Some(MenuAction::ResetDisplayDeviceConfig), - RESTART => Some(MenuAction::Restart), - QUIT => Some(MenuAction::Quit), - _ => None, - }; - - if let Some(action) = action { - trigger_action(action); - } + trigger_action(item_id); } diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index 4f18524c02e..40c4545e415 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -104,79 +104,69 @@ namespace system_tray { } /** - * @brief Handle tray actions from Rust + * @brief Handle tray actions from Rust (string-based) */ - static void handle_tray_action(uint32_t action) { - switch (action) { - case TRAY_ACTION_OPEN_UI: - launch_ui(); - break; - - case 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(); - } - } - break; - - case 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(); + 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(); } - break; - - case 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(); - break; - - case TRAY_ACTION_STAR_PROJECT: - platf::open_url_in_browser("https://sunshine-foundation.vercel.app/"); - break; - - case TRAY_ACTION_VISIT_PROJECT_SUNSHINE: - platf::open_url_in_browser("https://github.com/qiin2333/Sunshine-Foundation"); - break; - - case TRAY_ACTION_VISIT_PROJECT_MOONLIGHT: - platf::open_url_in_browser("https://github.com/qiin2333/moonlight-vplus"); - break; - - case TRAY_ACTION_RESET_DISPLAY_DEVICE_CONFIG: - BOOST_LOG(info) << "Resetting display device config"sv; - display_device::session_t::get().reset_persistence(); - break; - - case TRAY_ACTION_RESTART: - BOOST_LOG(info) << "Restarting from system tray"sv; - platf::restart(); - break; - - case TRAY_ACTION_QUIT: - BOOST_LOG(info) << "Quitting from system tray"sv; + } + } + 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_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); - } + 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); + lifetime::exit_sunshine(0, true); #endif - break; - - default: - // Other actions are handled entirely in Rust - break; } + // Other actions (language, close_app) are handled entirely in Rust } void terminate_gui_processes() { From 8f5eb5d7f9ae8a7e34b48a1c480bd697e852f32b Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:37:35 +0800 Subject: [PATCH 32/36] =?UTF-8?q?perf:=20rust=E7=AB=AF=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E6=93=8D=E4=BD=9Cconfig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/include/rust_tray.h | 2 - rust_tray/src/config.rs | 172 ---------------------------------- rust_tray/src/dialogs.rs | 31 ++++++ rust_tray/src/lib.rs | 17 +--- rust_tray/src/menu_items.rs | 13 +-- src/system_tray_rust.cpp | 10 +- 6 files changed, 46 insertions(+), 199 deletions(-) delete mode 100644 rust_tray/src/config.rs create mode 100644 rust_tray/src/dialogs.rs diff --git a/rust_tray/include/rust_tray.h b/rust_tray/include/rust_tray.h index 20479629075..4eea26c030d 100644 --- a/rust_tray/include/rust_tray.h +++ b/rust_tray/include/rust_tray.h @@ -55,7 +55,6 @@ typedef void (*TrayActionCallback)(const char* action_id); * @param icon_locked Path to locked icon * @param tooltip Tooltip text * @param locale Initial locale (e.g., "zh", "en", "ja") - * @param config_file Path to the Sunshine configuration file (sunshine.conf) * @param callback Callback function for menu actions * @return 0 on success, -1 on error */ @@ -66,7 +65,6 @@ int tray_init_ex( const char* icon_locked, const char* tooltip, const char* locale, - const char* config_file, TrayActionCallback callback ); diff --git a/rust_tray/src/config.rs b/rust_tray/src/config.rs deleted file mode 100644 index 42bee90981a..00000000000 --- a/rust_tray/src/config.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! Configuration file operations module (Windows only) -//! -//! 提供读取、写入配置文件的功能。 -//! -//! 配置文件路径通过 C++ 的 tray_init_ex 函数获取, -//! 确保与主 Sunshine 应用程序使用相同的配置路径。 - -use std::collections::HashMap; -use std::ffi::OsStr; -use std::fs; -use std::os::windows::ffi::OsStrExt; -use std::path::PathBuf; - -use crate::get_config_file_path_from_cpp; - -/// 配置操作的结果类型 -pub type ConfigResult = Result; - -/// 配置操作的错误类型 -#[derive(Debug, Clone)] -pub enum ConfigError { - PathNotFound, - ReadFailed(String), - WriteFailed(String), - DialogCancelled, -} - -impl std::fmt::Display for ConfigError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ConfigError::PathNotFound => write!(f, "Configuration path not found"), - ConfigError::ReadFailed(msg) => write!(f, "Read failed: {}", msg), - ConfigError::WriteFailed(msg) => write!(f, "Write failed: {}", msg), - ConfigError::DialogCancelled => write!(f, "Dialog cancelled"), - } - } -} - -/// 获取 Sunshine 配置文件路径 -/// -/// 路径由 C++ 通过 tray_init_ex 提供,确保与主 Sunshine 应用程序的配置路径一致。 -pub fn get_config_file_path() -> Option { - get_config_file_path_from_cpp() - .map(PathBuf::from) - .filter(|p| p.exists()) -} - -/// 将配置文件内容解析为键值对映射 -pub fn parse_config(content: &str) -> HashMap { - let mut vars = HashMap::new(); - - for line in content.lines() { - let trimmed = line.trim(); - - // 跳过空行和注释 - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - // 解析 key = value - if let Some(pos) = trimmed.find('=') { - let key = trimmed[..pos].trim().to_string(); - let value = trimmed[pos + 1..].trim().to_string(); - if !key.is_empty() { - vars.insert(key, value); - } - } - } - - vars -} - -/// 将配置映射序列化为字符串 -pub fn serialize_config(vars: &HashMap) -> String { - let mut config_str = String::new(); - - for (key, value) in vars { - if !value.is_empty() && value != "null" { - config_str.push_str(&format!("{} = {}\n", key, value)); - } - } - - config_str -} - -/// 读取配置文件 -pub fn read_config() -> ConfigResult> { - let path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; - - let content = fs::read_to_string(&path) - .map_err(|e| ConfigError::ReadFailed(e.to_string()))?; - - Ok(parse_config(&content)) -} - -/// 写入配置到文件 -pub fn write_config(vars: &HashMap) -> ConfigResult<()> { - let path = get_config_file_path().ok_or(ConfigError::PathNotFound)?; - - let content = serialize_config(vars); - - fs::write(&path, content) - .map_err(|e| ConfigError::WriteFailed(e.to_string())) -} - -/// 保存单个配置值 -pub fn save_config_value(key: &str, value: &str) -> ConfigResult<()> { - let mut vars = read_config().unwrap_or_default(); - vars.insert(key.to_string(), value.to_string()); - write_config(&vars) -} - -/// 显示确认对话框 -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 - } -} - -/// 保存托盘语言设置到配置文件 -pub fn save_tray_locale(locale: &str) -> ConfigResult<()> { - save_config_value("tray_locale", locale) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_config() { - let content = r#" -# Comment line -key1 = value1 -key2 = value2 - -key3=value3 -"#; - let vars = parse_config(content); - assert_eq!(vars.get("key1"), Some(&"value1".to_string())); - assert_eq!(vars.get("key2"), Some(&"value2".to_string())); - assert_eq!(vars.get("key3"), Some(&"value3".to_string())); - } - - #[test] - fn test_serialize_config() { - let mut vars = HashMap::new(); - vars.insert("key1".to_string(), "value1".to_string()); - vars.insert("key2".to_string(), "value2".to_string()); - - let result = serialize_config(&vars); - assert!(result.contains("key1 = value1")); - assert!(result.contains("key2 = value2")); - } -} 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/lib.rs b/rust_tray/src/lib.rs index 3c3b925a7b9..1c40ca9d379 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -15,7 +15,7 @@ pub mod i18n; pub mod actions; -pub mod config; +pub mod dialogs; pub mod menu; pub mod menu_items; pub mod notification; @@ -59,14 +59,6 @@ static CURRENT_ICON_TYPE: AtomicI32 = AtomicI32::new(0); /// Cached DPI value to detect DPI changes static CACHED_DPI_SIZE: AtomicI32 = AtomicI32::new(0); -/// Config file path storage (set from C++) -static CONFIG_FILE_PATH: OnceCell = OnceCell::new(); - -/// Get the config file path (set from C++) -pub fn get_config_file_path_from_cpp() -> Option<&'static str> { - CONFIG_FILE_PATH.get().map(|s| s.as_str()) -} - /// Icon paths storage static ICON_PATHS: OnceCell = OnceCell::new(); @@ -245,7 +237,6 @@ fn update_menu_texts() { /// * `icon_locked` - Path to locked icon /// * `tooltip` - Tooltip text /// * `locale` - Initial locale (e.g., "zh", "en", "ja") -/// * `config_file` - Path to the Sunshine configuration file (sunshine.conf) /// * `callback` - Callback function for menu actions /// /// # Returns @@ -258,14 +249,8 @@ pub unsafe extern "C" fn tray_init_ex( icon_locked: *const c_char, tooltip: *const c_char, locale: *const c_char, - config_file: *const c_char, callback: ActionCallback, ) -> c_int { - // Store config file path (from C++) - if let Some(cfg_path) = c_str_to_string(config_file) { - let _ = CONFIG_FILE_PATH.set(cfg_path); - } - // 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(); diff --git a/rust_tray/src/menu_items.rs b/rust_tray/src/menu_items.rs index 683ce2af4f7..9e458b4c9f4 100644 --- a/rust_tray/src/menu_items.rs +++ b/rust_tray/src/menu_items.rs @@ -9,7 +9,7 @@ use crate::i18n::{StringKey, get_string, set_locale_str}; use crate::actions::trigger_action; -use crate::config; +use crate::dialogs; /// Menu item handler function type pub type MenuHandler = fn(); @@ -165,7 +165,7 @@ mod handlers { /// 关闭应用前显示确认对话框 pub fn close_app() { - if !config::show_confirm_dialog( + if !dialogs::show_confirm_dialog( get_string(StringKey::CloseAppConfirmTitle), get_string(StringKey::CloseAppConfirmMsg), ) { @@ -175,7 +175,7 @@ mod handlers { /// 重置显示配置前显示确认对话框 pub fn reset_display() { - if !config::show_confirm_dialog( + if !dialogs::show_confirm_dialog( get_string(StringKey::ResetDisplayConfirmTitle), get_string(StringKey::ResetDisplayConfirmMsg), ) { @@ -183,25 +183,22 @@ mod handlers { } } - /// 切换语言并保存设置 + /// 切换语言(只更新 UI,配置保存由 C++ 处理) pub fn lang_chinese() { set_locale_str("zh"); - let _ = config::save_tray_locale("zh"); } pub fn lang_english() { set_locale_str("en"); - let _ = config::save_tray_locale("en"); } pub fn lang_japanese() { set_locale_str("ja"); - let _ = config::save_tray_locale("ja"); } /// 退出前显示确认对话框 pub fn quit() { - if !config::show_confirm_dialog( + if !dialogs::show_confirm_dialog( get_string(StringKey::QuitTitle), get_string(StringKey::QuitMessage), ) { diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index 40c4545e415..dcd46ec9fc8 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -135,6 +135,15 @@ namespace system_tray { 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_STAR_PROJECT) { platf::open_url_in_browser("https://sunshine-foundation.vercel.app/"); } @@ -220,7 +229,6 @@ namespace system_tray { ICON_PATH_LOCKED, tooltip.c_str(), locale.c_str(), - config::sunshine.config_file.c_str(), handle_tray_action ); From d0c2871f8f5806189122c05cce2f3eae7d521933 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:16:50 +0800 Subject: [PATCH 33/36] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DHandler=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E5=80=BC=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/menu_items.rs | 43 +++++++++++++++++++------------------ src/system_tray_rust.cpp | 5 +++++ 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/rust_tray/src/menu_items.rs b/rust_tray/src/menu_items.rs index 9e458b4c9f4..1b1d4041dc4 100644 --- a/rust_tray/src/menu_items.rs +++ b/rust_tray/src/menu_items.rs @@ -12,7 +12,8 @@ use crate::actions::trigger_action; use crate::dialogs; /// Menu item handler function type -pub type MenuHandler = fn(); +/// Returns true if action should proceed, false to cancel +pub type MenuHandler = fn() -> bool; /// Menu item type #[derive(Clone, Copy, PartialEq)] @@ -164,46 +165,43 @@ mod handlers { use super::*; /// 关闭应用前显示确认对话框 - pub fn close_app() { - if !dialogs::show_confirm_dialog( + pub fn close_app() -> bool { + dialogs::show_confirm_dialog( get_string(StringKey::CloseAppConfirmTitle), get_string(StringKey::CloseAppConfirmMsg), - ) { - return; - } + ) } /// 重置显示配置前显示确认对话框 - pub fn reset_display() { - if !dialogs::show_confirm_dialog( + pub fn reset_display() -> bool { + dialogs::show_confirm_dialog( get_string(StringKey::ResetDisplayConfirmTitle), get_string(StringKey::ResetDisplayConfirmMsg), - ) { - return; - } + ) } /// 切换语言(只更新 UI,配置保存由 C++ 处理) - pub fn lang_chinese() { + pub fn lang_chinese() -> bool { set_locale_str("zh"); + true } - pub fn lang_english() { + pub fn lang_english() -> bool { set_locale_str("en"); + true } - pub fn lang_japanese() { + pub fn lang_japanese() -> bool { set_locale_str("ja"); + true } /// 退出前显示确认对话框 - pub fn quit() { - if !dialogs::show_confirm_dialog( + pub fn quit() -> bool { + dialogs::show_confirm_dialog( get_string(StringKey::QuitTitle), get_string(StringKey::QuitMessage), - ) { - return; - } + ) } } @@ -277,11 +275,14 @@ pub fn execute_handler(item_id: &str) -> (bool, bool) { 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 { - handler(); + if !handler() { + return (true, false); // Handled but cancelled, no rebuild + } } - // Always trigger C++ callback for action items + // Trigger C++ callback for action items trigger_action_for_id(item_id); return (true, needs_rebuild); } diff --git a/src/system_tray_rust.cpp b/src/system_tray_rust.cpp index dcd46ec9fc8..2da1c930740 100644 --- a/src/system_tray_rust.cpp +++ b/src/system_tray_rust.cpp @@ -46,6 +46,7 @@ #include "file_handler.h" #include "logging.h" #include "platform/common.h" +#include "process.h" #include "system_tray.h" #include "version.h" @@ -144,6 +145,10 @@ namespace system_tray { 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/"); } From d9039866be82bae765298b7171d29fb7ad154669 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:30:58 +0800 Subject: [PATCH 34/36] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=E7=BF=BB?= =?UTF-8?q?=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/i18n.rs | 176 ++++++++++-------------------------------- 1 file changed, 42 insertions(+), 134 deletions(-) diff --git a/rust_tray/src/i18n.rs b/rust_tray/src/i18n.rs index 6a02eff7e84..31d4116d214 100644 --- a/rust_tray/src/i18n.rs +++ b/rust_tray/src/i18n.rs @@ -44,9 +44,6 @@ pub enum StringKey { VddPersistentConfirmMsg, // Advanced Settings submenu AdvancedSettings, - ImportConfig, - ExportConfig, - ResetToDefault, CloseApp, CloseAppConfirmTitle, CloseAppConfirmMsg, @@ -80,26 +77,6 @@ pub enum StringKey { QuitTitle, QuitMessage, ErrorTitle, - ErrorNoUserSession, - ImportSuccessTitle, - ImportSuccessMsg, - ImportErrorTitle, - ImportErrorWrite, - ImportErrorRead, - ImportErrorException, - ExportSuccessTitle, - ExportSuccessMsg, - ExportErrorTitle, - ExportErrorWrite, - ExportErrorNoConfig, - ExportErrorException, - ResetConfirmTitle, - ResetConfirmMsg, - ResetSuccessTitle, - ResetSuccessMsg, - ResetErrorTitle, - ResetErrorMsg, - ResetErrorException, } /// Current locale storage @@ -110,33 +87,30 @@ static TRANSLATIONS: Lazy> = Lazy::ne let mut m = HashMap::new(); // English translations - m.insert((Locale::English, StringKey::OpenSunshine), "Open Sunshine"); + 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"); - m.insert((Locale::English, StringKey::VddClose), "Close"); + 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), "Enable Keep VDD Mode"); - m.insert((Locale::English, StringKey::VddPersistentConfirmMsg), "Enabling this mode will keep the virtual display active at all times.\n\nAre you sure you want to enable it?"); + 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::ImportConfig), "Import Config"); - m.insert((Locale::English, StringKey::ExportConfig), "Export Config"); - m.insert((Locale::English, StringKey::ResetToDefault), "Reset to Default"); m.insert((Locale::English, StringKey::CloseApp), "Clear Cache"); m.insert((Locale::English, StringKey::CloseAppConfirmTitle), "Clear Cache"); - m.insert((Locale::English, StringKey::CloseAppConfirmMsg), "This will terminate the current streaming application.\n\nAre you sure you want to continue?"); + 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), "中文"); m.insert((Locale::English, StringKey::English), "English"); m.insert((Locale::English, StringKey::Japanese), "日本語"); - m.insert((Locale::English, StringKey::StarProject), "Star Project"); + m.insert((Locale::English, StringKey::StarProject), "Visit Website"); m.insert((Locale::English, StringKey::VisitProject), "Visit Project"); - m.insert((Locale::English, StringKey::VisitProjectSunshine), "Sunshine-Foundation"); - m.insert((Locale::English, StringKey::VisitProjectMoonlight), "Moonlight-vplus"); - m.insert((Locale::English, StringKey::ResetDisplayDeviceConfig), "Reset Display Memory"); - m.insert((Locale::English, StringKey::ResetDisplayConfirmTitle), "Reset Display Configuration"); - m.insert((Locale::English, StringKey::ResetDisplayConfirmMsg), "This will reset all display device configuration.\n\nAre you sure you want to continue?"); + 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"); @@ -150,55 +124,32 @@ static TRANSLATIONS: Lazy> = Lazy::ne 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"); - m.insert((Locale::English, StringKey::ErrorNoUserSession), "Cannot open file dialog: No active user session found."); - m.insert((Locale::English, StringKey::ImportSuccessTitle), "Import Success"); - m.insert((Locale::English, StringKey::ImportSuccessMsg), "Configuration imported successfully!\nPlease restart Sunshine to apply changes."); - m.insert((Locale::English, StringKey::ImportErrorTitle), "Import Error"); - m.insert((Locale::English, StringKey::ImportErrorWrite), "Failed to import configuration file."); - m.insert((Locale::English, StringKey::ImportErrorRead), "Failed to read the selected configuration file."); - m.insert((Locale::English, StringKey::ImportErrorException), "An error occurred while importing configuration."); - m.insert((Locale::English, StringKey::ExportSuccessTitle), "Export Success"); - m.insert((Locale::English, StringKey::ExportSuccessMsg), "Configuration exported successfully!"); - m.insert((Locale::English, StringKey::ExportErrorTitle), "Export Error"); - m.insert((Locale::English, StringKey::ExportErrorWrite), "Failed to export configuration file."); - m.insert((Locale::English, StringKey::ExportErrorNoConfig), "No configuration found to export."); - m.insert((Locale::English, StringKey::ExportErrorException), "An error occurred while exporting configuration."); - m.insert((Locale::English, StringKey::ResetConfirmTitle), "Reset Configuration"); - m.insert((Locale::English, StringKey::ResetConfirmMsg), "This will reset all configuration to default values.\nThis action cannot be undone.\n\nDo you want to continue?"); - m.insert((Locale::English, StringKey::ResetSuccessTitle), "Reset Success"); - m.insert((Locale::English, StringKey::ResetSuccessMsg), "Configuration has been reset to default values.\nPlease restart Sunshine to apply changes."); - m.insert((Locale::English, StringKey::ResetErrorTitle), "Reset Error"); - m.insert((Locale::English, StringKey::ResetErrorMsg), "Failed to reset configuration file."); - m.insert((Locale::English, StringKey::ResetErrorException), "An error occurred while resetting configuration."); // Chinese translations - m.insert((Locale::Chinese, StringKey::OpenSunshine), "打开 Sunshine"); + 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::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确定要启用吗?"); + 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::ImportConfig), "导入配置"); - m.insert((Locale::Chinese, StringKey::ExportConfig), "导出配置"); - m.insert((Locale::Chinese, StringKey::ResetToDefault), "恢复默认"); - m.insert((Locale::Chinese, StringKey::CloseApp), "清理应用缓存"); - m.insert((Locale::Chinese, StringKey::CloseAppConfirmTitle), "清理应用缓存"); - m.insert((Locale::Chinese, StringKey::CloseAppConfirmMsg), "这将终止当前正在串流的应用程序。\n\n确定要继续吗?"); + m.insert((Locale::Chinese, StringKey::CloseApp), "清理缓存"); + m.insert((Locale::Chinese, StringKey::CloseAppConfirmTitle), "清理缓存"); + m.insert((Locale::Chinese, StringKey::CloseAppConfirmMsg), "此操作将会清理串流状态,可能会终止串流应用,并清理相关进程和状态。是否继续?"); m.insert((Locale::Chinese, StringKey::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), "Star项目"); - m.insert((Locale::Chinese, StringKey::VisitProject), "访问项目"); - m.insert((Locale::Chinese, StringKey::VisitProjectSunshine), "Sunshine-Foundation"); - m.insert((Locale::Chinese, StringKey::VisitProjectMoonlight), "Moonlight-vplus"); - m.insert((Locale::Chinese, StringKey::ResetDisplayDeviceConfig), "重置显示器记忆"); - m.insert((Locale::Chinese, StringKey::ResetDisplayConfirmTitle), "重置显示器配置"); - m.insert((Locale::Chinese, StringKey::ResetDisplayConfirmMsg), "这将重置所有显示器设备配置。\n\n确定要继续吗?"); + 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), "串流已开始"); @@ -212,55 +163,32 @@ static TRANSLATIONS: Lazy> = Lazy::ne m.insert((Locale::Chinese, StringKey::QuitTitle), "真的要退出吗"); m.insert((Locale::Chinese, StringKey::QuitMessage), "你不能退出!\n那么想退吗? 真拿你没办法呢, 继续点一下吧~\n\n这将同时关闭Sunshine GUI应用程序。"); m.insert((Locale::Chinese, StringKey::ErrorTitle), "错误"); - m.insert((Locale::Chinese, StringKey::ErrorNoUserSession), "无法打开文件对话框:未找到活动的用户会话。"); - m.insert((Locale::Chinese, StringKey::ImportSuccessTitle), "导入成功"); - m.insert((Locale::Chinese, StringKey::ImportSuccessMsg), "配置已成功导入!\n请重新启动 Sunshine 以应用更改。"); - m.insert((Locale::Chinese, StringKey::ImportErrorTitle), "导入失败"); - m.insert((Locale::Chinese, StringKey::ImportErrorWrite), "无法写入配置文件。"); - m.insert((Locale::Chinese, StringKey::ImportErrorRead), "无法读取所选的配置文件。"); - m.insert((Locale::Chinese, StringKey::ImportErrorException), "导入配置时发生错误。"); - m.insert((Locale::Chinese, StringKey::ExportSuccessTitle), "导出成功"); - m.insert((Locale::Chinese, StringKey::ExportSuccessMsg), "配置已成功导出!"); - m.insert((Locale::Chinese, StringKey::ExportErrorTitle), "导出失败"); - m.insert((Locale::Chinese, StringKey::ExportErrorWrite), "无法导出配置文件。"); - m.insert((Locale::Chinese, StringKey::ExportErrorNoConfig), "未找到可导出的配置。"); - m.insert((Locale::Chinese, StringKey::ExportErrorException), "导出配置时发生错误。"); - m.insert((Locale::Chinese, StringKey::ResetConfirmTitle), "重置配置"); - m.insert((Locale::Chinese, StringKey::ResetConfirmMsg), "这将把所有配置重置为默认值。\n此操作无法撤销。\n\n确定要继续吗?"); - m.insert((Locale::Chinese, StringKey::ResetSuccessTitle), "重置成功"); - m.insert((Locale::Chinese, StringKey::ResetSuccessMsg), "配置已重置为默认值。\n请重新启动 Sunshine 以应用更改。"); - m.insert((Locale::Chinese, StringKey::ResetErrorTitle), "重置失败"); - m.insert((Locale::Chinese, StringKey::ResetErrorMsg), "无法重置配置文件。"); - m.insert((Locale::Chinese, StringKey::ResetErrorException), "重置配置时发生错误。"); // Japanese translations - m.insert((Locale::Japanese, StringKey::OpenSunshine), "Sunshineを開く"); + 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有効にしますか?"); + 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::ImportConfig), "設定をインポート"); - m.insert((Locale::Japanese, StringKey::ExportConfig), "設定をエクスポート"); - m.insert((Locale::Japanese, StringKey::ResetToDefault), "デフォルトに戻す"); m.insert((Locale::Japanese, StringKey::CloseApp), "キャッシュをクリア"); m.insert((Locale::Japanese, StringKey::CloseAppConfirmTitle), "キャッシュをクリア"); - m.insert((Locale::Japanese, StringKey::CloseAppConfirmMsg), "現在ストリーミング中のアプリケーションを終了します。\n\n続行しますか?"); + m.insert((Locale::Japanese, StringKey::CloseAppConfirmMsg), "この操作はストリーミング状態をクリアし、ストリーミングアプリケーションを終了する可能性があり、関連するプロセスと状態をクリーンアップします。続行しますか?"); m.insert((Locale::Japanese, StringKey::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-Foundation"); - m.insert((Locale::Japanese, StringKey::VisitProjectMoonlight), "Moonlight-vplus"); - m.insert((Locale::Japanese, StringKey::ResetDisplayDeviceConfig), "ディスプレイメモリをリセット"); - m.insert((Locale::Japanese, StringKey::ResetDisplayConfirmTitle), "ディスプレイ設定をリセット"); - m.insert((Locale::Japanese, StringKey::ResetDisplayConfirmMsg), "すべてのディスプレイデバイス設定をリセットします。\n\n続行しますか?"); + 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), "ストリーム開始"); @@ -274,26 +202,6 @@ static TRANSLATIONS: Lazy> = Lazy::ne m.insert((Locale::Japanese, StringKey::QuitTitle), "本当に終了しますか?"); m.insert((Locale::Japanese, StringKey::QuitMessage), "終了できません!\n本当に終了したいですか?\n\nこれによりSunshine GUIアプリケーションも閉じられます。"); m.insert((Locale::Japanese, StringKey::ErrorTitle), "エラー"); - m.insert((Locale::Japanese, StringKey::ErrorNoUserSession), "ファイルダイアログを開けません:アクティブなユーザーセッションが見つかりません。"); - m.insert((Locale::Japanese, StringKey::ImportSuccessTitle), "インポート成功"); - m.insert((Locale::Japanese, StringKey::ImportSuccessMsg), "設定のインポートに成功しました!\n変更を適用するにはSunshineを再起動してください。"); - m.insert((Locale::Japanese, StringKey::ImportErrorTitle), "インポート失敗"); - m.insert((Locale::Japanese, StringKey::ImportErrorWrite), "設定ファイルを書き込めませんでした。"); - m.insert((Locale::Japanese, StringKey::ImportErrorRead), "選択した設定ファイルを読み取れませんでした。"); - m.insert((Locale::Japanese, StringKey::ImportErrorException), "設定のインポート中にエラーが発生しました。"); - m.insert((Locale::Japanese, StringKey::ExportSuccessTitle), "エクスポート成功"); - m.insert((Locale::Japanese, StringKey::ExportSuccessMsg), "設定のエクスポートに成功しました!"); - m.insert((Locale::Japanese, StringKey::ExportErrorTitle), "エクスポート失敗"); - m.insert((Locale::Japanese, StringKey::ExportErrorWrite), "設定ファイルをエクスポートできませんでした。"); - m.insert((Locale::Japanese, StringKey::ExportErrorNoConfig), "エクスポートする設定が見つかりません。"); - m.insert((Locale::Japanese, StringKey::ExportErrorException), "設定のエクスポート中にエラーが発生しました。"); - m.insert((Locale::Japanese, StringKey::ResetConfirmTitle), "設定のリセット"); - m.insert((Locale::Japanese, StringKey::ResetConfirmMsg), "すべての設定をデフォルト値にリセットします。\nこの操作は元に戻せません。\n\n続行しますか?"); - m.insert((Locale::Japanese, StringKey::ResetSuccessTitle), "リセット成功"); - m.insert((Locale::Japanese, StringKey::ResetSuccessMsg), "設定をデフォルト値にリセットしました。\n変更を適用するにはSunshineを再起動してください。"); - m.insert((Locale::Japanese, StringKey::ResetErrorTitle), "リセット失敗"); - m.insert((Locale::Japanese, StringKey::ResetErrorMsg), "設定ファイルをリセットできませんでした。"); - m.insert((Locale::Japanese, StringKey::ResetErrorException), "設定のリセット中にエラーが発生しました。"); m }); From 0206257ad3e63f981e22c8cd0ab22d3770e22469 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 24 Jan 2026 23:47:25 +0800 Subject: [PATCH 35/36] =?UTF-8?q?style:=20=E7=A7=BB=E9=99=A4=E8=A1=8C?= =?UTF-8?q?=E5=B0=BE=E7=A9=BA=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/i18n.rs | 18 +++++----- rust_tray/src/lib.rs | 68 +++++++++++++++++------------------ rust_tray/src/menu.rs | 32 ++++++++--------- rust_tray/src/menu_items.rs | 14 ++++---- rust_tray/src/notification.rs | 2 +- 5 files changed, 67 insertions(+), 67 deletions(-) diff --git a/rust_tray/src/i18n.rs b/rust_tray/src/i18n.rs index 31d4116d214..d2f4a100e26 100644 --- a/rust_tray/src/i18n.rs +++ b/rust_tray/src/i18n.rs @@ -1,5 +1,5 @@ //! Internationalization (i18n) module for tray icon -//! +//! //! Supports Chinese, English, and Japanese translations. use std::collections::HashMap; @@ -62,7 +62,7 @@ pub enum StringKey { ResetDisplayConfirmMsg, Restart, Quit, - + // Notifications StreamStarted, StreamingStartedFor, @@ -72,7 +72,7 @@ pub enum StringKey { ApplicationStoppedMsg, IncomingPairingRequest, ClickToCompletePairing, - + // Dialog messages QuitTitle, QuitMessage, @@ -85,7 +85,7 @@ static CURRENT_LOCALE: Lazy> = Lazy::new(|| RwLock::new(Locale::E /// Translation tables static TRANSLATIONS: Lazy> = Lazy::new(|| { let mut m = HashMap::new(); - + // English translations m.insert((Locale::English, StringKey::OpenSunshine), "Open GUI"); // VDD submenu @@ -224,17 +224,17 @@ pub fn set_locale_str(locale_str: &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 "" } @@ -262,10 +262,10 @@ mod tests { 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を開く"); } diff --git a/rust_tray/src/lib.rs b/rust_tray/src/lib.rs index 1c40ca9d379..9e9ff561570 100644 --- a/rust_tray/src/lib.rs +++ b/rust_tray/src/lib.rs @@ -30,7 +30,7 @@ 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, + DispatchMessageW, GetMessageW, PeekMessageW, PostQuitMessage, TranslateMessage, MSG, WM_QUIT, PM_REMOVE, WM_DPICHANGED, }; @@ -180,7 +180,7 @@ fn identify_menu_item(event: &MenuEvent) -> Option { /// 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(); } @@ -194,26 +194,26 @@ fn process_menu_event(event: &MenuEvent) { } /// 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(); @@ -229,7 +229,7 @@ fn update_menu_texts() { // ============================================================================ /// Initialize the tray with icon paths -/// +/// /// # Arguments /// * `icon_normal` - Path to normal icon /// * `icon_playing` - Path to playing icon @@ -238,7 +238,7 @@ fn update_menu_texts() { /// * `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] @@ -256,28 +256,28 @@ pub unsafe extern "C" fn tray_init_ex( 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, @@ -286,13 +286,13 @@ pub unsafe extern "C" fn tray_init_ex( 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) @@ -306,25 +306,25 @@ pub unsafe extern "C" fn tray_init_ex( 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] @@ -387,7 +387,7 @@ pub extern "C" fn tray_exit() { } /// Set the tray icon -/// +/// /// # Arguments /// * `icon_type` - 0=normal, 1=playing, 2=pausing, 3=locked #[no_mangle] @@ -399,7 +399,7 @@ pub extern "C" fn tray_set_icon(icon_type: c_int) { Some(p) => p, None => return, }; - + let icon_path = match icon_type { 0 => &icon_paths.normal, 1 => &icon_paths.playing, @@ -407,7 +407,7 @@ pub extern "C" fn tray_set_icon(icon_type: c_int) { 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(); @@ -432,16 +432,16 @@ pub unsafe extern "C" fn tray_set_tooltip(tooltip: *const c_char) { } /// 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 +/// * `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] @@ -469,7 +469,7 @@ pub unsafe extern "C" fn tray_set_locale(locale: *const c_char) { } /// Show a Windows toast notification -/// +/// /// # Arguments /// * `title` - Notification title (UTF-8 string) /// * `text` - Notification body text (UTF-8 string) @@ -482,7 +482,7 @@ pub unsafe extern "C" fn tray_show_notification( ) { 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); } @@ -498,7 +498,7 @@ pub enum NotificationType { } /// 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) @@ -508,7 +508,7 @@ pub unsafe extern "C" fn tray_show_localized_notification( 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 @@ -536,6 +536,6 @@ pub unsafe extern "C" fn tray_show_localized_notification( }, _ => return, }; - + notification::show_notification(&title, &text, icon); } diff --git a/rust_tray/src/menu.rs b/rust_tray/src/menu.rs index b6cb6ab359a..31694e00083 100644 --- a/rust_tray/src/menu.rs +++ b/rust_tray/src/menu.rs @@ -23,7 +23,7 @@ pub struct MenuIdEntry { // Thread-local storage for actual muda items (not thread-safe) thread_local! { - static CREATED_ITEMS: std::cell::RefCell> = + static CREATED_ITEMS: std::cell::RefCell> = std::cell::RefCell::new(HashMap::new()); } @@ -35,7 +35,7 @@ enum CreatedItem { /// 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>> = +static MENU_ID_REGISTRY: Lazy>> = Lazy::new(|| RwLock::new(HashMap::new())); /// Clear the registries @@ -111,17 +111,17 @@ pub fn identify_item_id(event: &MenuEvent) -> Option { 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 { @@ -133,7 +133,7 @@ pub fn rebuild_menu() -> Menu { } } } - + // Create all other items for info in &sorted_items { match info.kind { @@ -162,7 +162,7 @@ pub fn rebuild_menu() -> Menu { } } } - + // Second pass: add items to their parent submenus for info in &sorted_items { if let Some(parent_id) = info.parent { @@ -177,7 +177,7 @@ pub fn rebuild_menu() -> Menu { } } } - + // Third pass: build main menu with top-level items let menu = Menu::new(); for info in &sorted_items { @@ -191,7 +191,7 @@ pub fn rebuild_menu() -> Menu { } } } - + menu } @@ -200,9 +200,9 @@ pub fn rebuild_menu() -> Menu { // ============================================================================ /// 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 @@ -210,17 +210,17 @@ pub fn rebuild_menu() -> Menu { /// * `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); } @@ -228,7 +228,7 @@ pub fn update_vdd_menu_state(can_create: bool, can_close: bool, is_persistent: b #[cfg(test)] mod tests { use super::*; - + #[test] fn test_get_all_items() { let items = menu_items::get_all_items(); diff --git a/rust_tray/src/menu_items.rs b/rust_tray/src/menu_items.rs index 1b1d4041dc4..fc22b61553f 100644 --- a/rust_tray/src/menu_items.rs +++ b/rust_tray/src/menu_items.rs @@ -1,7 +1,7 @@ //! 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) @@ -213,11 +213,11 @@ mod handlers { /// 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 ====== @@ -270,10 +270,10 @@ pub fn get_all_items() -> Vec { /// 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 { @@ -281,12 +281,12 @@ pub fn execute_handler(item_id: &str) -> (bool, bool) { 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) } diff --git a/rust_tray/src/notification.rs b/rust_tray/src/notification.rs index 5dcbddb1d6e..f1a475c1c91 100644 --- a/rust_tray/src/notification.rs +++ b/rust_tray/src/notification.rs @@ -5,7 +5,7 @@ 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. From 3af156f158c697b269754996e6bb4ee8f53051fa Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sun, 25 Jan 2026 21:30:40 +0800 Subject: [PATCH 36/36] =?UTF-8?q?chore:=20=E6=9B=B4=E6=94=B9i18n=E6=96=87?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rust_tray/src/i18n.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/rust_tray/src/i18n.rs b/rust_tray/src/i18n.rs index d2f4a100e26..2a33f12a746 100644 --- a/rust_tray/src/i18n.rs +++ b/rust_tray/src/i18n.rs @@ -101,9 +101,9 @@ static TRANSLATIONS: Lazy> = Lazy::ne 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), "中文"); + m.insert((Locale::English, StringKey::Chinese), "中文 (Chinese)"); m.insert((Locale::English, StringKey::English), "English"); - m.insert((Locale::English, StringKey::Japanese), "日本語"); + 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"); @@ -139,10 +139,10 @@ static TRANSLATIONS: Lazy> = Lazy::ne m.insert((Locale::Chinese, StringKey::CloseApp), "清理缓存"); m.insert((Locale::Chinese, StringKey::CloseAppConfirmTitle), "清理缓存"); m.insert((Locale::Chinese, StringKey::CloseAppConfirmMsg), "此操作将会清理串流状态,可能会终止串流应用,并清理相关进程和状态。是否继续?"); - m.insert((Locale::Chinese, StringKey::Language), "语言"); + 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::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"); @@ -178,9 +178,9 @@ static TRANSLATIONS: Lazy> = Lazy::ne m.insert((Locale::Japanese, StringKey::CloseApp), "キャッシュをクリア"); m.insert((Locale::Japanese, StringKey::CloseAppConfirmTitle), "キャッシュをクリア"); m.insert((Locale::Japanese, StringKey::CloseAppConfirmMsg), "この操作はストリーミング状態をクリアし、ストリーミングアプリケーションを終了する可能性があり、関連するプロセスと状態をクリーンアップします。続行しますか?"); - m.insert((Locale::Japanese, StringKey::Language), "言語"); - m.insert((Locale::Japanese, StringKey::Chinese), "中文"); - m.insert((Locale::Japanese, StringKey::English), "English"); + 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), "プロジェクトアドレスを訪問");