Initial commit: DeskFloat 桌面透明浮动工具栏

Tauri 2 + React:任务列表、快捷方式、番茄钟、系统托盘、钉子穿透模式等。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wjl
2026-05-26 20:01:43 +08:00
commit 8e9d108995
103 changed files with 12278 additions and 0 deletions
+5580
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "desk-top-float"
version = "0.1.0"
description = "桌面透明浮动工具栏"
authors = ["you"]
edition = "2021"
[lib]
name = "desk_top_float_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-shell = "2"
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-notification = "2"
tauri-plugin-global-shortcut = "2"
tauri-plugin-autostart = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
thiserror = "2"
[target."cfg(windows)".dependencies]
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+32
View File
@@ -0,0 +1,32 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "默认窗口能力:允许前端调用基础窗口/文件/通知/快捷键/打开 URL 等接口",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-set-always-on-top",
"core:window:allow-set-size",
"core:window:allow-set-position",
"core:window:allow-set-decorations",
"core:window:allow-start-dragging",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-close",
"core:window:allow-minimize",
"core:window:allow-set-ignore-cursor-events",
"core:webview:default",
"core:app:default",
"core:path:default",
"core:event:default",
"shell:default",
"opener:default",
"opener:allow-open-url",
"fs:default",
"notification:default",
"notification:allow-notify",
"global-shortcut:default",
"autostart:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

+114
View File
@@ -0,0 +1,114 @@
use crate::pin_mode;
use crate::runner::{self, Shortcut};
use crate::storage;
use tauri::{AppHandle, Manager, PhysicalSize};
use tauri_plugin_notification::NotificationExt;
/// 统一的命令返回错误类型(serde 友好)
#[derive(Debug, thiserror::Error)]
pub enum CmdError {
#[error("{0}")]
Anyhow(String),
}
impl From<anyhow::Error> for CmdError {
fn from(e: anyhow::Error) -> Self {
CmdError::Anyhow(format!("{e:#}"))
}
}
impl serde::Serialize for CmdError {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
type CmdResult<T> = Result<T, CmdError>;
/// 读取整份 JSON 数据返回给前端
#[tauri::command]
pub fn load_state(app: AppHandle) -> CmdResult<String> {
Ok(storage::read_state(&app)?)
}
/// 前端整体保存 JSON
#[tauri::command]
pub fn save_state(app: AppHandle, json: String) -> CmdResult<()> {
storage::write_state(&app, &json)?;
Ok(())
}
/// 执行一个快捷方式(app/cmd/powershellurl 由前端 opener 处理)
#[tauri::command]
pub fn run_shortcut(shortcut: Shortcut) -> CmdResult<()> {
runner::execute(&shortcut)?;
Ok(())
}
/// 切换主窗口的"始终置顶"
#[tauri::command]
pub fn set_always_on_top(app: AppHandle, on: bool) -> CmdResult<()> {
if let Some(win) = app.get_webview_window("main") {
win.set_always_on_top(on)
.map_err(|e| CmdError::Anyhow(e.to_string()))?;
}
Ok(())
}
/// 调整主窗口大小(折叠/展开抽屉时使用)
#[tauri::command]
pub fn set_window_size(app: AppHandle, width: u32, height: u32) -> CmdResult<()> {
if let Some(win) = app.get_webview_window("main") {
win.set_size(PhysicalSize::new(width, height))
.map_err(|e| CmdError::Anyhow(e.to_string()))?;
}
Ok(())
}
/// 在文件资源管理器中打开数据目录
#[tauri::command]
pub fn open_data_dir(app: AppHandle) -> CmdResult<String> {
let path = storage::data_path(&app)?;
let dir = path
.parent()
.ok_or_else(|| CmdError::Anyhow("no parent dir".into()))?;
// 用 Windows explorer 打开目录
#[cfg(target_os = "windows")]
{
use std::process::Command;
Command::new("explorer.exe")
.arg(dir)
.spawn()
.map_err(|e| CmdError::Anyhow(e.to_string()))?;
}
Ok(dir.display().to_string())
}
/// 钉子模式:开启后窗口默认点击穿透,仅钉子按钮热区可点击
#[tauri::command]
pub fn set_pin_mode(app: AppHandle, on: bool) -> CmdResult<()> {
pin_mode::set_pin_mode(&app, on).map_err(CmdError::Anyhow)?;
Ok(())
}
/// 更新钉子按钮在窗口内的热区(供轮询判断何时恢复接收鼠标)
#[tauri::command]
pub fn update_pin_hotzone(x: f64, y: f64, width: f64, height: f64, active: bool) -> CmdResult<()> {
pin_mode::set_hotzone(x, y, width, height, active);
Ok(())
}
/// 发送系统通知(番茄钟阶段切换提示)
#[tauri::command]
pub fn notify_user(app: AppHandle, title: String, body: String) -> CmdResult<()> {
app.notification()
.builder()
.title(&title)
.body(&body)
.show()
.map_err(|e| CmdError::Anyhow(e.to_string()))?;
Ok(())
}
+58
View File
@@ -0,0 +1,58 @@
mod storage;
mod runner;
mod commands;
mod tray;
mod pin_mode;
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher;
/// Tauri 应用入口:注册插件、初始化窗口属性、暴露所有 invoke 命令
pub fn run() {
tauri::Builder::default()
// 文件读写(用于 JSON 持久化)
.plugin(tauri_plugin_fs::init())
// 用系统默认浏览器打开 URL
.plugin(tauri_plugin_opener::init())
// 执行系统命令 / 启动应用
.plugin(tauri_plugin_shell::init())
// 系统通知(番茄钟提醒)
.plugin(tauri_plugin_notification::init())
// 全局快捷键
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
// 开机自启
.plugin(tauri_plugin_autostart::init(
MacosLauncher::LaunchAgent,
None,
))
.setup(|app| {
// 启动后保证数据文件就绪
if let Err(e) = storage::ensure_data_file(&app.handle()) {
eprintln!("[DeskFloat] init data file failed: {e:?}");
}
// 主窗口在 Windows 上的额外微调:取消阴影、保持半透明
if let Some(win) = app.get_webview_window("main") {
let _ = win.set_always_on_top(true);
let _ = win.set_skip_taskbar(true);
}
// 系统托盘:右键「退出」才真正结束进程
tray::setup_tray(app.handle())?;
Ok(())
})
.on_window_event(|window, event| {
tray::on_window_event(window, event);
})
.invoke_handler(tauri::generate_handler![
commands::load_state,
commands::save_state,
commands::run_shortcut,
commands::set_always_on_top,
commands::set_window_size,
commands::open_data_dir,
commands::notify_user,
commands::set_pin_mode,
commands::update_pin_hotzone,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// 防止 Windows release 构建时弹出 cmd 窗口
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
desk_top_float_lib::run()
}
+128
View File
@@ -0,0 +1,128 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use std::time::Duration;
use tauri::{AppHandle, Manager};
/// 钉子按钮在窗口内的热区(逻辑像素,相对窗口客户区左上角)
#[derive(Clone, Copy, Default)]
struct Hotzone {
x: f64,
y: f64,
w: f64,
h: f64,
active: bool,
}
static HOTZONE: Mutex<Hotzone> = Mutex::new(Hotzone {
x: 0.0,
y: 0.0,
w: 0.0,
h: 0.0,
active: false,
});
static POLLING: AtomicBool = AtomicBool::new(false);
/// 前端布局变化时更新钉子按钮热区坐标
pub fn set_hotzone(x: f64, y: f64, width: f64, height: f64, active: bool) {
if let Ok(mut hz) = HOTZONE.lock() {
*hz = Hotzone {
x,
y,
w: width,
h: height,
active,
};
}
}
/// 开启/关闭钉子模式;开启时后台轮询鼠标位置,仅在热区内恢复窗口接收点击
pub fn set_pin_mode(app: &AppHandle, on: bool) -> Result<(), String> {
if on {
if let Some(win) = app.get_webview_window("main") {
// 默认先穿透,等热区上报后再由轮询精确切换
win.set_ignore_cursor_events(true)
.map_err(|e| e.to_string())?;
}
start_polling(app.clone());
} else {
stop_polling();
if let Ok(mut hz) = HOTZONE.lock() {
hz.active = false;
}
if let Some(win) = app.get_webview_window("main") {
win.set_ignore_cursor_events(false)
.map_err(|e| e.to_string())?;
}
}
Ok(())
}
fn start_polling(app: AppHandle) {
if POLLING.swap(true, Ordering::SeqCst) {
return;
}
std::thread::spawn(move || {
while POLLING.load(Ordering::SeqCst) {
poll_once(&app);
std::thread::sleep(Duration::from_millis(32));
}
});
}
fn stop_polling() {
POLLING.store(false, Ordering::SeqCst);
}
fn poll_once(app: &AppHandle) {
let hz = match HOTZONE.lock() {
Ok(h) => *h,
Err(_) => return,
};
let Some(win) = app.get_webview_window("main") else {
return;
};
if !hz.active || hz.w <= 0.0 || hz.h <= 0.0 {
let _ = win.set_ignore_cursor_events(true);
return;
}
let (cursor_x, cursor_y) = match cursor_screen_position() {
Some(p) => p,
None => return,
};
let Some(win_pos) = win.outer_position().ok() else {
return;
};
// 热区为前端 getBoundingClientRect 的逻辑像素;光标与窗口位置为物理像素
let scale = win.scale_factor().unwrap_or(1.0);
let local_x = (cursor_x - win_pos.x as f64) / scale;
let local_y = (cursor_y - win_pos.y as f64) / scale;
let in_hotzone = local_x >= hz.x
&& local_x <= hz.x + hz.w
&& local_y >= hz.y
&& local_y <= hz.y + hz.h;
let _ = win.set_ignore_cursor_events(!in_hotzone);
}
#[cfg(target_os = "windows")]
fn cursor_screen_position() -> Option<(f64, f64)> {
use windows::Win32::Foundation::POINT;
use windows::Win32::UI::WindowsAndMessaging::GetCursorPos;
let mut pt = POINT::default();
unsafe {
GetCursorPos(&mut pt).ok()?;
}
Some((pt.x as f64, pt.y as f64))
}
#[cfg(not(target_os = "windows"))]
fn cursor_screen_position() -> Option<(f64, f64)> {
None
}
+129
View File
@@ -0,0 +1,129 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::process::Command;
/// 前端传过来的"快捷方式"定义,与 JSON 中的 shortcut 项保持一致
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Shortcut {
pub id: String,
pub label: String,
#[serde(default)]
pub icon: String,
/// "url" | "app" | "cmd" | "powershell"
#[serde(rename = "type")]
pub kind: String,
pub target: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub cwd: Option<String>,
/// 是否静默执行(仅对 cmd/powershell 生效):
/// - false(默认):弹出控制台窗口,用户能看到输出,适合调试或交互命令(pause/read)
/// - true:隐藏控制台,适合后台脚本
#[serde(default)]
pub silent: bool,
}
/// 根据 type 分发执行:
/// - url:交给前端用 opener 打开(这里直接返回标记,让前端处理)
/// - app:直接以独立进程方式启动可执行文件
/// - cmd:通过 cmd.exe /C 执行
/// - powershell:通过 powershell.exe -NoProfile -Command 执行
pub fn execute(shortcut: &Shortcut) -> Result<()> {
match shortcut.kind.as_str() {
"url" => {
// url 类型在前端通过 opener 插件处理;保留这里以便扩展(例如内置浏览器)
anyhow::bail!("url shortcuts should be handled by frontend opener");
}
"app" => spawn_app(shortcut),
"cmd" => spawn_cmd(shortcut),
"powershell" => spawn_powershell(shortcut),
other => anyhow::bail!("unsupported shortcut type: {other}"),
}
}
fn spawn_app(s: &Shortcut) -> Result<()> {
let mut cmd = Command::new(&s.target);
if !s.args.is_empty() {
cmd.args(&s.args);
}
apply_cwd(&mut cmd, &s.cwd);
// 启动可执行文件本身不弹出控制台
hide_console(&mut cmd);
cmd.spawn()
.with_context(|| format!("failed to spawn app: {}", s.target))?;
Ok(())
}
/// 用 `cmd.exe /C` 执行一行命令。默认显式打开一个新控制台窗口让用户能看到输出。
fn spawn_cmd(s: &Shortcut) -> Result<()> {
let mut cmd = Command::new("cmd.exe");
// /K 保持窗口不关闭,便于用户看完输出;silent 模式用 /C 静默
if s.silent {
cmd.arg("/C").arg(&s.target);
hide_console(&mut cmd);
} else {
// 用 start "title" cmd /K 在新窗口里运行,这样窗口不会因为父进程退出而立即销毁
// 这里直接走 cmd /K 的方式,CREATE_NEW_CONSOLE 标志可见
cmd.arg("/C")
.arg("start")
.arg("") // title 占位
.arg("cmd.exe")
.arg("/K")
.arg(&s.target);
new_console(&mut cmd);
}
apply_cwd(&mut cmd, &s.cwd);
cmd.spawn()
.with_context(|| format!("failed to spawn cmd: {}", s.target))?;
Ok(())
}
/// 用 powershell.exe 执行一段脚本。
fn spawn_powershell(s: &Shortcut) -> Result<()> {
let mut cmd = Command::new("powershell.exe");
cmd.arg("-NoProfile");
if s.silent {
cmd.arg("-WindowStyle").arg("Hidden");
cmd.arg("-Command").arg(&s.target);
hide_console(&mut cmd);
} else {
// -NoExit 让窗口在命令执行完后保留,方便查看输出
cmd.arg("-NoExit").arg("-Command").arg(&s.target);
new_console(&mut cmd);
}
apply_cwd(&mut cmd, &s.cwd);
cmd.spawn()
.with_context(|| format!("failed to spawn powershell: {}", s.target))?;
Ok(())
}
fn apply_cwd(cmd: &mut Command, cwd: &Option<String>) {
if let Some(c) = cwd {
if !c.trim().is_empty() {
cmd.current_dir(c);
}
}
}
/// Windows 下隐藏子进程的控制台窗口
#[cfg(target_os = "windows")]
fn hide_console(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW = 0x08000000
cmd.creation_flags(0x0800_0000);
}
#[cfg(not(target_os = "windows"))]
fn hide_console(_cmd: &mut Command) {}
/// Windows 下显式给子进程开一个新的可见控制台窗口
#[cfg(target_os = "windows")]
fn new_console(cmd: &mut Command) {
use std::os::windows::process::CommandExt;
// CREATE_NEW_CONSOLE = 0x00000010
cmd.creation_flags(0x0000_0010);
}
#[cfg(not(target_os = "windows"))]
fn new_console(_cmd: &mut Command) {}
+110
View File
@@ -0,0 +1,110 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use tauri::{AppHandle, Manager};
const DATA_FILE: &str = "data.json";
/// 应用全量持久化数据(JSON 根对象)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppState {
#[serde(default)]
pub tasks: serde_json::Value,
#[serde(default)]
pub shortcuts: serde_json::Value,
#[serde(default)]
pub pomodoro: serde_json::Value,
#[serde(default)]
pub settings: serde_json::Value,
}
impl Default for AppState {
fn default() -> Self {
AppState {
tasks: serde_json::json!([]),
shortcuts: serde_json::json!([
{
"id": "demo-1",
"label": "GitHub",
"icon": "github",
"type": "url",
"target": "https://github.com",
"args": []
},
{
"id": "demo-2",
"label": "记事本",
"icon": "file-text",
"type": "app",
"target": "notepad.exe",
"args": []
}
]),
pomodoro: serde_json::json!({
"workMin": 25,
"breakMin": 5,
"soundOn": true
}),
settings: serde_json::json!({
"alwaysOnTop": true,
"opacity": 0.85,
"globalHotkeys": {
"newTask": "Ctrl+Alt+T",
"togglePomodoro": "Ctrl+Alt+P",
"showHide": "Ctrl+Alt+`"
},
"autoStart": false
}),
}
}
}
/// 获取数据文件完整路径,若目录不存在则创建
pub fn data_path(app: &AppHandle) -> Result<PathBuf> {
let dir = app
.path()
.app_data_dir()
.context("failed to resolve app data dir")?;
fs::create_dir_all(&dir).with_context(|| format!("create dir {}", dir.display()))?;
Ok(dir.join(DATA_FILE))
}
/// 启动时调用:若 data.json 不存在则写入默认值
pub fn ensure_data_file(app: &AppHandle) -> Result<()> {
let path = data_path(app)?;
if !path.exists() {
let default = AppState::default();
let json = serde_json::to_string_pretty(&default)?;
fs::write(&path, json).with_context(|| format!("write default {}", path.display()))?;
}
Ok(())
}
/// 读取 JSON 字符串(前端直接解析)。若文件损坏则返回默认值。
pub fn read_state(app: &AppHandle) -> Result<String> {
let path = data_path(app)?;
if !path.exists() {
ensure_data_file(app)?;
}
let content = fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
// 校验一下结构,失败则用默认覆盖
if serde_json::from_str::<AppState>(&content).is_err() {
let default = AppState::default();
let json = serde_json::to_string_pretty(&default)?;
fs::write(&path, &json)?;
return Ok(json);
}
Ok(content)
}
/// 写入 JSON 字符串(前端整体保存)
pub fn write_state(app: &AppHandle, json: &str) -> Result<()> {
let path = data_path(app)?;
// 简单校验,避免写入非法 JSON
let _: serde_json::Value =
serde_json::from_str(json).context("invalid JSON when saving state")?;
fs::write(&path, json).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
+82
View File
@@ -0,0 +1,82 @@
use tauri::{
menu::{Menu, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
Manager, Runtime,
};
/// 初始化 Windows 系统托盘(通知区域):
/// - 右键菜单:显示 / 隐藏 / 退出
/// - 左键单击:切换工具栏显示/隐藏
pub fn setup_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "显示工具栏", true, None::<&str>)?;
let hide = MenuItem::with_id(app, "hide", "隐藏工具栏", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?;
let icon = app
.default_window_icon()
.cloned()
.ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::NotFound, "default window icon missing")
})?;
let _tray = TrayIconBuilder::with_id("main-tray")
.icon(icon)
.tooltip("DeskFloat")
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"show" => show_main_window(app),
"hide" => hide_main_window(app),
"quit" => {
app.exit(0);
}
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
toggle_main_window(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
/// 关闭窗口请求时改为隐藏到托盘,避免误关导致进程仍在后台却无入口
pub fn on_window_event(window: &tauri::Window, event: &tauri::WindowEvent) {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
}
fn show_main_window<R: Runtime>(app: &tauri::AppHandle<R>) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
fn hide_main_window<R: Runtime>(app: &tauri::AppHandle<R>) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.hide();
}
}
fn toggle_main_window<R: Runtime>(app: &tauri::AppHandle<R>) {
if let Some(win) = app.get_webview_window("main") {
let visible = win.is_visible().unwrap_or(true);
if visible {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "DeskFloat",
"version": "0.1.0",
"identifier": "com.deskfloat.app",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"withGlobalTauri": false,
"windows": [
{
"label": "main",
"title": "DeskFloat",
"width": 520,
"height": 64,
"minWidth": 320,
"minHeight": 56,
"decorations": false,
"transparent": true,
"alwaysOnTop": true,
"skipTaskbar": true,
"resizable": true,
"shadow": false,
"center": false,
"x": 200,
"y": 80,
"visible": true,
"fullscreen": false,
"dragDropEnabled": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": ["msi", "nsis"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Productivity",
"shortDescription": "桌面透明浮动工具栏",
"longDescription": "包含任务列表、自定义快捷按钮、番茄钟的桌面浮动工具栏"
}
}