Initial commit: DeskFloat 桌面透明浮动工具栏
Tauri 2 + React:任务列表、快捷方式、番茄钟、系统托盘、钉子穿透模式等。 Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 736 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 332 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 411 KiB |
@@ -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/powershell;url 由前端 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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// 防止 Windows release 构建时弹出 cmd 窗口
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
desk_top_float_lib::run()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": "包含任务列表、自定义快捷按钮、番茄钟的桌面浮动工具栏"
|
||||
}
|
||||
}
|
||||