diff --git a/README.md b/README.md index 06037da..cc9b0ac 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,53 @@ Topdo 是一个“**常驻、低打扰、键盘优先**”的任务悬浮窗: --- +## 2.0 宠物模式(阶段一) + +- 新增两种窗口形态:`面板态` / `猫咪态` +- 默认形态切换快捷键:`⌥T`(可在设置页自定义) +- 猫咪等级随今日完成进度变化:Sleeping / Awake / Happy / Crowned +- 支持角标显示未完成数量、可选动画开关、位置记忆 + +> 说明:阶段一先上线猫咪态,不含顶部吸附灵动岛能力。 + +### 猫咪状态切换规则 + +- 今天完成第 `1` 个任务:`awake` +- 今天完成第 `3` 个任务:`happy` +- 当天全部任务都已完成:`crowned` +- 当天未完成任何任务:`sleeping` +- 判定口径:仅统计 `completed_at` 落在今天、且当前状态仍为“已完成”的任务 +- 同一天内小猫保持当天已达成的最高状态,不会因为新增任务或回退任务立刻降级 +- 次日首次打开应用后,今日进度会自动重置 +- 瞬态动画: + - 新增任务:`new_task`(0.5 秒) + - 首次达到当天全部完成:`celebrate`(2 秒) + +### 今日进度激励 + +- 飞书模式下,完成任务后小猫会立即更新状态,不必等待下一轮同步 +- 小猫更偏向“正向反馈”,强调今天完成了多少,而不是总库存完成率 +- 对于任务少的用户,完成 1 个任务也能立刻看到从 `sleeping` 到 `awake` 的变化 + +### 宠物素材规范(阶段一) + +- 文件名固定:`sleeping.svg`、`awake.svg`、`happy.svg`、`crowned.svg` +- 资源策略:仅加载 SVG(不再回退 PNG),避免白底素材误渲染 +- 推荐规格:透明背景、像素风主体居中 +- 若素材缺失或加载失败,应用会自动降级为透明占位渲染,避免出现白色方块 + +--- + +## 唤起快捷键冲突处理 + +如果 `⌘⇧T` 与浏览器或输入法冲突,可在设置页「全局唤起快捷键」中修改。 + +- 推荐组合:`Cmd+Alt+T`、`Cmd+Shift+Y` +- 规则:必须包含至少一个修饰键(Cmd/Alt/Ctrl/Shift) +- 避免与应用内保留快捷键冲突:`⌘N`、`⌘K`、`⌘1~⌘4`、`⌘⇧R`、`⌘⇧L` + +--- + ## 飞书同步配置 在设置页填写以下字段: @@ -108,6 +155,8 @@ Topdo 是一个“**常驻、低打扰、键盘优先**”的任务悬浮窗: - 应用与目标多维表格在同一租户 - 应用版本已发布可用 +如果报错较长,设置页已支持“复制错误详情”,可直接贴给开发者排查。 + --- ## 开发者指南 @@ -133,6 +182,9 @@ pnpm install pnpm tauri dev ``` +> macOS 透明窗口依赖 `src-tauri/tauri.conf.json` 中的 `app.macOSPrivateApi: true`。 +> 这会启用 macOS private API,因此不适合 App Store 分发。 + ### 常用命令 ```bash @@ -153,7 +205,7 @@ pnpm release:mac 产物路径: - `.app`:`src-tauri/target/universal-apple-darwin/release/bundle/macos/Topdo.app` -- `.dmg`:`src-tauri/target/universal-apple-darwin/release/bundle/dmg/Topdo_1.0.0_universal.dmg` +- `.dmg`:`src-tauri/target/universal-apple-darwin/release/bundle/dmg/Topdo_1.0.1_universal.dmg` --- diff --git a/package.json b/package.json index b225d99..5562e5c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "task-float", "private": true, - "version": "0.1.0", + "version": "1.0.1", "type": "module", "scripts": { "dev": "vite", @@ -13,6 +13,7 @@ "dependencies": { "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-autostart": "^2.5.1", + "@tauri-apps/plugin-shell": "^2.0.0", "pinia": "^2.1.7", "vue": "^3.4.31" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd3c55..c847068 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@tauri-apps/plugin-autostart': specifier: ^2.5.1 version: 2.5.1 + '@tauri-apps/plugin-shell': + specifier: ^2.0.0 + version: 2.3.5 pinia: specifier: ^2.1.7 version: 2.3.1(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3)) @@ -455,6 +458,9 @@ packages: '@tauri-apps/plugin-autostart@2.5.1': resolution: {integrity: sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w==} + '@tauri-apps/plugin-shell@2.3.5': + resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -1198,6 +1204,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tauri-apps/plugin-shell@2.3.5': + dependencies: + '@tauri-apps/api': 2.10.1 + '@types/estree@1.0.8': {} '@types/node@22.19.17': diff --git a/scripts/build_universal_dmg.sh b/scripts/build_universal_dmg.sh index 4a16f42..4e2cda1 100755 --- a/scripts/build_universal_dmg.sh +++ b/scripts/build_universal_dmg.sh @@ -5,15 +5,15 @@ ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" PRODUCT="Topdo" -VERSION="1.0.0" +VERSION="1.0.1" APP_NAME="$PRODUCT.app" NOTICE_FILE="$ROOT_DIR/docs/01_安装说明.txt" echo "[1/3] 确保 Rust 双目标已安装..." rustup target add x86_64-apple-darwin >/dev/null -echo "[2/3] 构建 Universal app + dmg..." -pnpm tauri build --target universal-apple-darwin --bundles app,dmg +echo "[2/3] 构建 Universal app..." +pnpm tauri build --target universal-apple-darwin --bundles app BUNDLE_BASE="$ROOT_DIR/src-tauri/target/universal-apple-darwin/release/bundle" DMG_DIR="$BUNDLE_BASE/dmg" @@ -23,15 +23,12 @@ VOL_ICON="$DMG_DIR/icon.icns" APP_PATH="$MACOS_DIR/$APP_NAME" OUTPUT_DMG="$DMG_DIR/${PRODUCT}_${VERSION}_universal.dmg" TEMP_DMG="$DMG_DIR/${PRODUCT}_${VERSION}_universal_with_notice.dmg" +SRC_FOLDER="$MACOS_DIR" if [ ! -d "$APP_PATH" ]; then echo "[错误] 未找到 app: $APP_PATH" exit 1 fi -if [ ! -f "$OUTPUT_DMG" ]; then - echo "[错误] 未找到 dmg: $OUTPUT_DMG" - exit 1 -fi if [ ! -f "$BUNDLE_DMG_SH" ]; then echo "[错误] 未找到 bundle_dmg.sh: $BUNDLE_DMG_SH" exit 1 @@ -43,6 +40,7 @@ fi echo "[3/3] 重新打包 DMG(注入安装说明)..." rm -f "$TEMP_DMG" +rm -f "$OUTPUT_DMG" "$BUNDLE_DMG_SH" \ --volname "$PRODUCT" \ --icon "$APP_NAME" 180 220 \ @@ -52,7 +50,7 @@ rm -f "$TEMP_DMG" --add-file "01_安装说明.txt" "$NOTICE_FILE" 330 320 \ --volicon "$VOL_ICON" \ "$TEMP_DMG" \ - "$APP_PATH" + "$SRC_FOLDER" mv -f "$TEMP_DMG" "$OUTPUT_DMG" echo "[完成] 发布包已生成" diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3d9b36b..fbad035 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1895,6 +1895,25 @@ dependencies = [ "serde", ] +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2370,8 +2389,38 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.1", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.1", + "objc2", "objc2-foundation", ] @@ -2399,6 +2448,41 @@ dependencies = [ "objc2-io-surface", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -2422,6 +2506,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.11.1", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2487,6 +2572,18 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "open" +version = "5.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "openssl" version = "0.10.77" @@ -2537,6 +2634,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "pango" version = "0.18.3" @@ -2585,6 +2692,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3748,12 +3867,44 @@ dependencies = [ "digest", ] +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -4175,6 +4326,18 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-nspanel" +version = "2.1.0" +source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2.1#a3122e894383aa068ec5365a42994e3ac94ba1b6" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-foundation", + "pastey", + "tauri", +] + [[package]] name = "tauri-plugin" version = "2.5.4" @@ -4221,6 +4384,27 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -4619,7 +4803,7 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "topdo" -version = "0.1.0" +version = "0.1.1" dependencies = [ "aes-gcm", "base64 0.22.1", @@ -4633,8 +4817,10 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-nspanel", "tauri-plugin-autostart", "tauri-plugin-global-shortcut", + "tauri-plugin-shell", "tokio", "uuid", "whoami", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index a87494a..cbcb5d2 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "topdo" -version = "0.1.0" +version = "0.1.1" description = "A macOS floating window app for today's tasks" authors = ["you"] license = "" @@ -20,9 +20,11 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["full"] } -tauri = { version = "2", features = ["tray-icon"] } +tauri = { version = "2", features = ["macos-private-api", "tray-icon"] } +tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2.1" } tauri-plugin-global-shortcut = "2" tauri-plugin-autostart = "2" +tauri-plugin-shell = "2" dirs = "5" rusqlite = { version = "0.31", features = ["bundled"] } uuid = { version = "1", features = ["v4"] } diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 986bab4..d7a96bf 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "shell:allow-open" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6c53622..287e806 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,24 +12,57 @@ use std::collections::HashMap; use std::{ fs, path::{Path, PathBuf}, + str::FromStr, sync::Mutex, }; use tauri::{ + Emitter, menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, LogicalSize, Manager, Size, State, WebviewWindow, + ActivationPolicy, AppHandle, LogicalSize, Manager, Size, State, WebviewWindow, +}; +use tauri_plugin_global_shortcut::GlobalShortcutExt; +#[cfg(target_os = "macos")] +use tauri_nspanel::{ + tauri_panel, CollectionBehavior, PanelLevel, StyleMask, + WebviewWindowExt as NsPanelExt, }; const MAIN_WINDOW_LABEL: &str = "main"; const NORMAL_WIDTH: f64 = 320.0; const NORMAL_HEIGHT: f64 = 500.0; -const MINI_WIDTH: f64 = 96.0; -const MINI_HEIGHT: f64 = 44.0; +const MINI_WIDTH: f64 = 80.0; +const MINI_HEIGHT: f64 = 80.0; const CONFIG_FILE_NAME: &str = "config.json"; const DB_FILE_NAME: &str = "tasks.db"; const TASK_ORDER_FILE_NAME: &str = "task_order.json"; const WINDOW_SIZE_FILE_NAME: &str = "window_size.json"; const TOKEN_SALT: &str = "topdo-salt-2026"; +const DEFAULT_TOGGLE_SHORTCUT: &str = "Cmd+Shift+T"; +const DEFAULT_TOGGLE_MODE_SHORTCUT: &str = "Alt+T"; + +#[cfg(target_os = "macos")] +const KCG_NORMAL_WINDOW_LEVEL_KEY: i32 = 4; +#[cfg(target_os = "macos")] +const KCG_STATUS_WINDOW_LEVEL_KEY: i32 = 9; +#[cfg(target_os = "macos")] +const NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_APPLICATIONS: u64 = 1 << 18; + +#[cfg(target_os = "macos")] +#[link(name = "CoreGraphics", kind = "framework")] +unsafe extern "C" { + fn CGWindowLevelForKey(key: i32) -> i32; +} + +#[cfg(target_os = "macos")] +tauri_panel! { + panel!(TopdoPanel { + config: { + can_become_key_window: true, + can_become_main_window: false + } + }) +} const CREATE_TASKS_TABLE_SQL: &str = r#" CREATE TABLE IF NOT EXISTS tasks ( @@ -73,6 +106,12 @@ struct WindowStatePayload { always_on_top: bool, } +#[derive(Debug, Serialize, Clone)] +struct WindowModeChangedPayload { + mode: String, + mini_mode: bool, +} + #[derive(Debug, Serialize, Deserialize, Default, Clone)] struct FeishuConfig { #[serde(default)] @@ -89,12 +128,62 @@ struct FeishuConfig { collaborator_email: String, } +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +struct ShortcutConfig { + #[serde(default)] + toggle_window: String, + #[serde(default)] + toggle_mode: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct PetPositionConfig { + x: f64, + y: f64, +} + +impl Default for PetPositionConfig { + fn default() -> Self { + Self { x: 0.0, y: 0.0 } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct PetConfig { + #[serde(default = "default_pet_enabled")] + enabled: bool, + #[serde(default = "default_pet_show_badge")] + show_badge: bool, + #[serde(default = "default_pet_animations")] + animations: bool, + #[serde(default)] + cat_position: PetPositionConfig, + #[serde(default = "default_pet_window_mode")] + window_mode: String, +} + +impl Default for PetConfig { + fn default() -> Self { + Self { + enabled: default_pet_enabled(), + show_badge: default_pet_show_badge(), + animations: default_pet_animations(), + cat_position: PetPositionConfig::default(), + window_mode: default_pet_window_mode(), + } + } +} + #[derive(Debug, Serialize, Deserialize, Default, Clone)] struct AppConfig { #[serde(default)] mode: String, #[serde(default)] feishu: FeishuConfig, + #[serde(default)] + shortcut: ShortcutConfig, + #[serde(default)] + pet: PetConfig, #[serde(default = "default_sync_interval")] sync_interval: i64, #[serde(default)] @@ -130,6 +219,14 @@ struct ConfigPayload { #[derive(Debug, Default)] struct ConfigIoLock; +#[derive(Debug, Default)] +struct GlobalShortcutState { + toggle_window: Option, + toggle_window_text: String, + toggle_mode: Option, + toggle_mode_text: String, +} + #[derive(Debug, Default)] struct TokenManager { app_id: String, @@ -205,6 +302,38 @@ struct CreateTaskResult { synced: bool, } +#[derive(Debug, Serialize)] +struct ShortcutConfigPayload { + toggle_window: String, +} + +#[derive(Debug, Serialize)] +struct ModeShortcutConfigPayload { + toggle_mode: String, +} + +#[derive(Debug, Serialize)] +struct SetShortcutConfigResult { + success: bool, + message: String, + applied: Option, +} + +#[derive(Debug, Serialize)] +struct PetPositionPayload { + x: f64, + y: f64, +} + +#[derive(Debug, Serialize)] +struct PetSettingsPayload { + enabled: bool, + show_badge: bool, + animations: bool, + cat_position: PetPositionPayload, + window_mode: String, +} + #[derive(Debug, Serialize, Deserialize)] struct WindowSizePayload { width: f64, @@ -276,6 +405,22 @@ fn default_sync_interval() -> i64 { 30 } +fn default_pet_enabled() -> bool { + true +} + +fn default_pet_show_badge() -> bool { + true +} + +fn default_pet_animations() -> bool { + true +} + +fn default_pet_window_mode() -> String { + "panel".to_string() +} + fn normalize_app_mode(mode: &str) -> String { if mode.trim() == "feishu" { "feishu".to_string() @@ -294,6 +439,14 @@ fn default_sync_interval_seconds(value: i64) -> i64 { } } +fn normalize_window_mode(value: &str) -> String { + if value.trim() == "cat" { + "cat".to_string() + } else { + "panel".to_string() + } +} + fn normalize_task(mut task: Task) -> Task { if task.id.trim().is_empty() { task.id = task.record_id.clone(); @@ -472,7 +625,120 @@ fn apply_mini_mode(window: &WebviewWindow) -> tauri::Result<()> { window.set_min_size(Some(mini_size))?; window.set_max_size(Some(mini_size))?; window.set_size(mini_size)?; - window.set_always_on_top(true)?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn macos_window_level_for_key(key: i32) -> i32 { + unsafe { CGWindowLevelForKey(key) } +} + +fn should_include_native_traits(reason: &str) -> bool { + if reason == "setup" { + return true; + } + !cfg!(debug_assertions) +} + +fn apply_window_traits_safe(window: &WebviewWindow, pinned: bool, reason: &str) -> Result<(), String> { + eprintln!("[Rust] apply_window_traits_safe start: reason={reason}, pinned={pinned}"); + window + .set_always_on_top(pinned) + .map_err(|err| format!("set always_on_top failed: {err}"))?; + window + .set_visible_on_all_workspaces(pinned) + .map_err(|err| format!("set visible on all workspaces failed: {err}"))?; + eprintln!( + "[Rust] apply_window_traits_safe visible_on_all_workspaces set: reason={reason}, pinned={pinned}" + ); + eprintln!("[Rust] apply_window_traits_safe done: reason={reason}, pinned={pinned}"); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn apply_window_traits_native( + window: &WebviewWindow, + pinned: bool, + reason: &str, +) -> Result<(), String> { + use cocoa::{ + appkit::{NSColor, NSWindow, NSWindowCollectionBehavior}, + base::{id, nil, NO}, + }; + + eprintln!("[Rust] apply_window_traits_native start: reason={reason}, pinned={pinned}"); + + let ns_window_ptr = window + .ns_window() + .map_err(|err| format!("ns_window unavailable: {err}"))?; + let ns_window: id = ns_window_ptr as id; + + unsafe { + ns_window.setBackgroundColor_(NSColor::clearColor(nil)); + ns_window.setOpaque_(NO); + ns_window.setHasShadow_(NO); + let mut behavior = ns_window.collectionBehavior(); + let original_behavior = behavior; + let tracked_mask = NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces.bits() + | NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary.bits() + | NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary.bits() + | NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_APPLICATIONS; + let next_bits = if pinned { + behavior.bits() | tracked_mask + } else { + behavior.bits() & !tracked_mask + }; + behavior = NSWindowCollectionBehavior::from_bits_retain(next_bits); + ns_window.setCollectionBehavior_(behavior); + let level = if pinned { + macos_window_level_for_key(KCG_STATUS_WINDOW_LEVEL_KEY) as _ + } else { + macos_window_level_for_key(KCG_NORMAL_WINDOW_LEVEL_KEY) as _ + }; + ns_window.setLevel_(level); + eprintln!( + "[Rust] apply_window_traits_native macos state: reason={reason}, pinned={pinned}, behavior_before=0x{:x}, behavior_after=0x{:x}, level={}, has_can_join_all_spaces={}, has_stationary={}, has_full_screen_auxiliary={}, has_can_join_all_applications={}", + original_behavior.bits(), + behavior.bits(), + level, + (behavior.bits() & NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces.bits()) != 0, + (behavior.bits() & NSWindowCollectionBehavior::NSWindowCollectionBehaviorStationary.bits()) != 0, + (behavior.bits() & NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary.bits()) != 0, + (behavior.bits() & NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_APPLICATIONS) != 0 + ); + } + + eprintln!("[Rust] apply_window_traits_native done: reason={reason}, pinned={pinned}"); + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn apply_window_traits_native( + _window: &WebviewWindow, + _pinned: bool, + _reason: &str, +) -> Result<(), String> { + Ok(()) +} + +fn apply_window_traits( + window: &WebviewWindow, + pinned: bool, + include_native: bool, + reason: &str, +) -> Result<(), String> { + eprintln!( + "[Rust] apply_window_traits start: reason={reason}, pinned={pinned}, include_native={include_native}" + ); + apply_window_traits_safe(window, pinned, reason)?; + if include_native { + apply_window_traits_native(window, pinned, reason)?; + } else { + eprintln!("[Rust] apply_window_traits native deferred: reason={reason}, pinned={pinned}"); + } + eprintln!( + "[Rust] apply_window_traits done: reason={reason}, pinned={pinned}, include_native={include_native}" + ); Ok(()) } @@ -481,9 +747,23 @@ fn toggle_window_visibility(app: &AppHandle) -> tauri::Result<()> { if window.is_visible().unwrap_or(false) { window.hide()?; } else { + let (mini_mode, pinned) = if let Ok(state) = app.state::>().lock() { + (state.mini_mode, state.always_on_top) + } else { + (false, true) + }; + let _ = apply_window_traits(&window, pinned, false, "toggle_window_visibility:pre-show"); window.unminimize()?; window.show()?; window.set_focus()?; + let _ = apply_window_traits(&window, pinned, true, "toggle_window_visibility:post-show"); + let _ = app.emit( + "window-mode-changed", + WindowModeChangedPayload { + mode: if mini_mode { "cat" } else { "panel" }.to_string(), + mini_mode, + }, + ); } } Ok(()) @@ -585,6 +865,13 @@ fn normalize_loaded_config(mut cfg: AppConfig) -> AppConfig { if cfg.created_at.trim().is_empty() { cfg.created_at = now_iso(); } + if cfg.shortcut.toggle_window.trim().is_empty() { + cfg.shortcut.toggle_window = DEFAULT_TOGGLE_SHORTCUT.to_string(); + } + if cfg.shortcut.toggle_mode.trim().is_empty() { + cfg.shortcut.toggle_mode = DEFAULT_TOGGLE_MODE_SHORTCUT.to_string(); + } + cfg.pet.window_mode = normalize_window_mode(&cfg.pet.window_mode); cfg } @@ -592,6 +879,11 @@ fn default_app_config() -> AppConfig { AppConfig { mode: String::new(), feishu: FeishuConfig::default(), + shortcut: ShortcutConfig { + toggle_window: DEFAULT_TOGGLE_SHORTCUT.to_string(), + toggle_mode: DEFAULT_TOGGLE_MODE_SHORTCUT.to_string(), + }, + pet: PetConfig::default(), sync_interval: 30, created_at: now_iso(), app_mode: String::new(), @@ -628,6 +920,11 @@ fn load_app_config_from_file(app: &AppHandle) -> Result { folder_token: String::new(), collaborator_email: String::new(), }, + shortcut: ShortcutConfig { + toggle_window: DEFAULT_TOGGLE_SHORTCUT.to_string(), + toggle_mode: DEFAULT_TOGGLE_MODE_SHORTCUT.to_string(), + }, + pet: PetConfig::default(), sync_interval: default_sync_interval_seconds(legacy.sync_interval_seconds), created_at: now_iso(), ..default_app_config() @@ -1719,26 +2016,17 @@ fn init_tray(app: &AppHandle) -> Result<(), Box> { .on_menu_event(move |_tray, event| match event.id.as_ref() { "show" => { if let Some(window) = app_for_menu.get_webview_window(MAIN_WINDOW_LABEL) { - let _ = apply_normal_mode(&window); - let _ = window.set_always_on_top(true); + let _ = set_window_mode_internal(&app_for_menu, "panel"); let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); - if let Ok(mut state) = app_for_menu.state::>().lock() { - state.mini_mode = false; - state.always_on_top = true; - } } } "mini" => { if let Some(window) = app_for_menu.get_webview_window(MAIN_WINDOW_LABEL) { - let _ = apply_mini_mode(&window); + let _ = set_window_mode_internal(&app_for_menu, "cat"); let _ = window.unminimize(); let _ = window.show(); - if let Ok(mut state) = app_for_menu.state::>().lock() { - state.mini_mode = true; - state.always_on_top = true; - } } } "quit" => { @@ -1765,24 +2053,283 @@ fn init_tray(app: &AppHandle) -> Result<(), Box> { Ok(()) } +fn shortcut_conflict_label(shortcut: &tauri_plugin_global_shortcut::Shortcut) -> Option<&'static str> { + const RESERVED: [(&str, &str); 9] = [ + ("Cmd+N", "新建任务"), + ("Cmd+,", "打开设置"), + ("Cmd+K", "快捷键面板"), + ("Cmd+1", "筛选:待办"), + ("Cmd+2", "筛选:进行中"), + ("Cmd+3", "筛选:已完成"), + ("Cmd+4", "筛选:全部"), + ("Cmd+Shift+R", "飞书手动同步"), + ("Cmd+Shift+L", "主题切换"), + ]; + + for (value, label) in RESERVED { + if let Ok(reserved) = tauri_plugin_global_shortcut::Shortcut::from_str(value) { + if reserved.id() == shortcut.id() { + return Some(label); + } + } + } + None +} + +fn format_shortcut(shortcut: &tauri_plugin_global_shortcut::Shortcut) -> String { + let mut parts: Vec = Vec::new(); + if shortcut.mods.contains(tauri_plugin_global_shortcut::Modifiers::SUPER) { + parts.push("Cmd".to_string()); + } + if shortcut.mods.contains(tauri_plugin_global_shortcut::Modifiers::CONTROL) { + parts.push("Ctrl".to_string()); + } + if shortcut.mods.contains(tauri_plugin_global_shortcut::Modifiers::ALT) { + parts.push("Alt".to_string()); + } + if shortcut.mods.contains(tauri_plugin_global_shortcut::Modifiers::SHIFT) { + parts.push("Shift".to_string()); + } + + let key_part = shortcut + .key + .to_string() + .strip_prefix("Key") + .map_or_else(|| shortcut.key.to_string(), |v| v.to_string()); + parts.push(key_part); + parts.join("+") +} + +fn parse_shortcut_input(raw: &str) -> Result<(tauri_plugin_global_shortcut::Shortcut, String), String> { + let normalized = raw + .trim() + .replace('⌘', "Cmd") + .replace('⌥', "Alt") + .replace('⌃', "Ctrl") + .replace('⇧', "Shift"); + if normalized.is_empty() { + return Err("快捷键不能为空".to_string()); + } + + let shortcut = tauri_plugin_global_shortcut::Shortcut::from_str(&normalized) + .map_err(|_| "快捷键格式无效。示例:Cmd+Shift+T".to_string())?; + if shortcut.mods.is_empty() { + return Err("快捷键必须包含至少一个修饰键(Cmd/Alt/Ctrl/Shift)".to_string()); + } + if let Some(label) = shortcut_conflict_label(&shortcut) { + return Err(format!("快捷键冲突:该组合已用于“{}”", label)); + } + + Ok((shortcut.clone(), format_shortcut(&shortcut))) +} + +fn register_shortcut_with_rollback( + manager: &tauri_plugin_global_shortcut::GlobalShortcut, + old: Option, + new_shortcut: tauri_plugin_global_shortcut::Shortcut, +) -> Result<(), String> { + if let Some(prev) = old.clone() { + let _ = manager.unregister(prev); + } + if let Err(err) = manager.register(new_shortcut.clone()) { + if let Some(prev) = old { + let _ = manager.register(prev); + } + return Err(format!("注册快捷键失败:{err}")); + } + Ok(()) +} + +fn register_toggle_shortcut( + app: &AppHandle, + shortcut: tauri_plugin_global_shortcut::Shortcut, + label: String, +) -> Result<(), String> { + let manager = app.global_shortcut(); + let state = app.state::>(); + + let old = { + let guard = state + .lock() + .map_err(|_| "failed to lock shortcut state".to_string())?; + guard.toggle_window.clone() + }; + + register_shortcut_with_rollback(&manager, old, shortcut.clone())?; + + let mut guard = state + .lock() + .map_err(|_| "failed to lock shortcut state".to_string())?; + guard.toggle_window = Some(shortcut); + guard.toggle_window_text = label; + Ok(()) +} + +fn register_mode_shortcut( + app: &AppHandle, + shortcut: tauri_plugin_global_shortcut::Shortcut, + label: String, +) -> Result<(), String> { + let manager = app.global_shortcut(); + let state = app.state::>(); + let old = { + let guard = state + .lock() + .map_err(|_| "failed to lock shortcut state".to_string())?; + guard.toggle_mode.clone() + }; + + register_shortcut_with_rollback(&manager, old, shortcut.clone())?; + + let mut guard = state + .lock() + .map_err(|_| "failed to lock shortcut state".to_string())?; + guard.toggle_mode = Some(shortcut); + guard.toggle_mode_text = label; + Ok(()) +} + +fn set_window_mode_internal(app: &AppHandle, mode: &str) -> Result<(), String> { + let normalized_mode = normalize_window_mode(mode); + let window = get_main_window(app)?; + let ui_state = app.state::>(); + let mut state = ui_state + .lock() + .map_err(|_| "failed to lock ui state".to_string())?; + + if normalized_mode == "cat" { + apply_mini_mode(&window).map_err(|err| err.to_string())?; + state.mini_mode = true; + } else { + apply_normal_mode(&window).map_err(|err| err.to_string())?; + state.mini_mode = false; + } + apply_window_traits( + &window, + state.always_on_top, + should_include_native_traits("set_window_mode_internal"), + "set_window_mode_internal", + )?; + + let payload = WindowModeChangedPayload { + mode: normalized_mode.clone(), + mini_mode: state.mini_mode, + }; + drop(state); + let _ = app.emit("window-mode-changed", payload); + let mut cfg = load_app_config_from_file(app)?; + cfg.pet.window_mode = normalized_mode; + save_app_config_to_file(app, &cfg)?; + Ok(()) +} + fn init_global_shortcut(app: &AppHandle) -> Result<(), Box> { - use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState}; + use tauri_plugin_global_shortcut::ShortcutState; let app_for_handler = app.clone(); - let shortcut = Shortcut::new(Some(Modifiers::SUPER | Modifiers::SHIFT), Code::KeyT); - let shortcut_for_handler = shortcut.clone(); app.plugin( tauri_plugin_global_shortcut::Builder::new() .with_handler(move |_app, current_shortcut, event| { - if current_shortcut == &shortcut_for_handler && event.state() == ShortcutState::Pressed { - let _ = toggle_window_visibility(&app_for_handler); + if event.state() == ShortcutState::Pressed { + if let Ok(state) = app_for_handler.state::>().lock() { + let shortcut_id = current_shortcut.id(); + if state + .toggle_window + .as_ref() + .map(|shortcut| shortcut.id() == shortcut_id) + .unwrap_or(false) + { + let _ = toggle_window_visibility(&app_for_handler); + return; + } + if state + .toggle_mode + .as_ref() + .map(|shortcut| shortcut.id() == shortcut_id) + .unwrap_or(false) + { + let current_mode = if let Ok(ui_state) = app_for_handler.state::>().lock() { + if ui_state.mini_mode { "cat" } else { "panel" } + } else { + "panel" + }; + let next_mode = if current_mode == "cat" { "panel" } else { "cat" }; + let _ = set_window_mode_internal(&app_for_handler, next_mode); + } + } } }) .build(), )?; - app.global_shortcut().register(shortcut)?; + let cfg = load_app_config_from_file(app).unwrap_or_else(|_| default_app_config()); + let raw_shortcut = cfg.shortcut.toggle_window.trim(); + let shortcut_text = if raw_shortcut.is_empty() { + DEFAULT_TOGGLE_SHORTCUT + } else { + raw_shortcut + }; + + match parse_shortcut_input(shortcut_text) { + Ok((shortcut, label)) => { + if let Err(err) = register_toggle_shortcut(app, shortcut, label.clone()) { + eprintln!("[Rust] register configured shortcut failed: {}", err); + let (fallback_shortcut, fallback_label) = + parse_shortcut_input(DEFAULT_TOGGLE_SHORTCUT).expect("default shortcut should be valid"); + register_toggle_shortcut(app, fallback_shortcut, fallback_label) + .map_err(Box::::from)?; + } + } + Err(err) => { + eprintln!("[Rust] parse configured shortcut failed: {}", err); + let (fallback_shortcut, fallback_label) = + parse_shortcut_input(DEFAULT_TOGGLE_SHORTCUT).expect("default shortcut should be valid"); + register_toggle_shortcut(app, fallback_shortcut, fallback_label) + .map_err(Box::::from)?; + } + } + + let raw_mode_shortcut = cfg.shortcut.toggle_mode.trim(); + let mode_shortcut_text = if raw_mode_shortcut.is_empty() { + DEFAULT_TOGGLE_MODE_SHORTCUT + } else { + raw_mode_shortcut + }; + match parse_shortcut_input(mode_shortcut_text) { + Ok((shortcut, label)) => { + if let Ok(lock) = app.state::>().lock() { + if lock + .toggle_window + .as_ref() + .map(|v| v.id() == shortcut.id()) + .unwrap_or(false) + { + let fallback = parse_shortcut_input(DEFAULT_TOGGLE_MODE_SHORTCUT) + .expect("default mode shortcut should be valid"); + register_mode_shortcut(app, fallback.0, fallback.1) + .map_err(Box::::from)?; + return Ok(()); + } + } + if let Err(err) = register_mode_shortcut(app, shortcut, label.clone()) { + eprintln!("[Rust] register configured mode shortcut failed: {}", err); + let (fallback_shortcut, fallback_label) = + parse_shortcut_input(DEFAULT_TOGGLE_MODE_SHORTCUT) + .expect("default mode shortcut should be valid"); + register_mode_shortcut(app, fallback_shortcut, fallback_label) + .map_err(Box::::from)?; + } + } + Err(err) => { + eprintln!("[Rust] parse configured mode shortcut failed: {}", err); + let (fallback_shortcut, fallback_label) = parse_shortcut_input(DEFAULT_TOGGLE_MODE_SHORTCUT) + .expect("default mode shortcut should be valid"); + register_mode_shortcut(app, fallback_shortcut, fallback_label) + .map_err(Box::::from)?; + } + } + Ok(()) } @@ -1813,6 +2360,35 @@ fn get_window_state(state: State<'_, Mutex>) -> Result Result<(), String> { + let labels = [MAIN_WINDOW_LABEL, "panel", "cat"]; + let mut applied = false; + + for label in labels { + if let Some(window) = app_handle.get_webview_window(label) { + apply_window_traits(&window, visible, true, "set_window_visible_on_all_spaces")?; + applied = true; + } + } + + if applied { + Ok(()) + } else { + Err("no target window found".to_string()) + } +} + +#[tauri::command] +fn reapply_window_traits(app: AppHandle, state: State<'_, Mutex>) -> Result<(), String> { + let pinned = state + .lock() + .map_err(|_| "failed to lock ui state".to_string())? + .always_on_top; + let window = get_main_window(&app)?; + apply_window_traits(&window, pinned, true, "reapply_window_traits") +} + #[tauri::command] fn toggle_always_on_top( app: AppHandle, @@ -1824,38 +2400,26 @@ fn toggle_always_on_top( .map_err(|_| "failed to lock ui state".to_string())?; state.always_on_top = !state.always_on_top; - window - .set_always_on_top(state.always_on_top) - .map_err(|err| err.to_string())?; + apply_window_traits( + &window, + state.always_on_top, + should_include_native_traits("toggle_always_on_top"), + "toggle_always_on_top", + )?; Ok(state.always_on_top) } #[tauri::command] fn enter_mini_mode(app: AppHandle, state: State<'_, Mutex>) -> Result<(), String> { - let window = get_main_window(&app)?; - apply_mini_mode(&window).map_err(|err| err.to_string())?; - - let mut state = state - .lock() - .map_err(|_| "failed to lock ui state".to_string())?; - state.mini_mode = true; - state.always_on_top = true; - - Ok(()) + let _ = state; + set_window_mode_internal(&app, "cat") } #[tauri::command] fn restore_normal_mode(app: AppHandle, state: State<'_, Mutex>) -> Result<(), String> { - let window = get_main_window(&app)?; - apply_normal_mode(&window).map_err(|err| err.to_string())?; - - let mut state = state - .lock() - .map_err(|_| "failed to lock ui state".to_string())?; - state.mini_mode = false; - - Ok(()) + let _ = state; + set_window_mode_internal(&app, "panel") } #[tauri::command] @@ -1965,6 +2529,212 @@ fn load_config(app: AppHandle) -> Result { }) } +#[tauri::command] +fn get_shortcut_config( + app: AppHandle, + state: State<'_, Mutex>, +) -> Result { + let from_runtime = state + .lock() + .map_err(|_| "failed to lock shortcut state".to_string())? + .toggle_window_text + .trim() + .to_string(); + + if !from_runtime.is_empty() { + return Ok(ShortcutConfigPayload { + toggle_window: from_runtime, + }); + } + + let cfg = load_app_config_from_file(&app)?; + let raw = cfg.shortcut.toggle_window.trim(); + let value = if raw.is_empty() { + DEFAULT_TOGGLE_SHORTCUT.to_string() + } else { + raw.to_string() + }; + + Ok(ShortcutConfigPayload { + toggle_window: value, + }) +} + +#[tauri::command] +fn set_shortcut_config( + app: AppHandle, + toggle_window: String, +) -> Result { + let previous = { + let state = app.state::>(); + let guard = state + .lock() + .map_err(|_| "failed to lock shortcut state".to_string())?; + ( + guard.toggle_window.clone(), + guard.toggle_window_text.clone(), + guard.toggle_mode.clone(), + ) + }; + + let (shortcut, label) = parse_shortcut_input(&toggle_window)?; + if previous + .2 + .as_ref() + .map(|v| v.id() == shortcut.id()) + .unwrap_or(false) + { + return Err("快捷键冲突:与“形态切换快捷键”重复".to_string()); + } + register_toggle_shortcut(&app, shortcut, label.clone())?; + + let mut cfg = load_app_config_from_file(&app)?; + cfg.shortcut.toggle_window = label.clone(); + if let Err(err) = save_app_config_to_file(&app, &cfg) { + if let Some(prev_shortcut) = previous.0 { + let rollback_label = if previous.1.trim().is_empty() { + format_shortcut(&prev_shortcut) + } else { + previous.1 + }; + let _ = register_toggle_shortcut(&app, prev_shortcut, rollback_label); + } + return Err(err); + } + + Ok(SetShortcutConfigResult { + success: true, + message: "快捷键已更新".to_string(), + applied: Some(label), + }) +} + +#[tauri::command] +fn get_mode_shortcut_config( + app: AppHandle, + state: State<'_, Mutex>, +) -> Result { + let from_runtime = state + .lock() + .map_err(|_| "failed to lock shortcut state".to_string())? + .toggle_mode_text + .trim() + .to_string(); + if !from_runtime.is_empty() { + return Ok(ModeShortcutConfigPayload { + toggle_mode: from_runtime, + }); + } + + let cfg = load_app_config_from_file(&app)?; + let raw = cfg.shortcut.toggle_mode.trim(); + let value = if raw.is_empty() { + DEFAULT_TOGGLE_MODE_SHORTCUT.to_string() + } else { + raw.to_string() + }; + Ok(ModeShortcutConfigPayload { + toggle_mode: value, + }) +} + +#[tauri::command] +fn set_mode_shortcut_config( + app: AppHandle, + toggle_mode: String, +) -> Result { + let previous = { + let state = app.state::>(); + let guard = state + .lock() + .map_err(|_| "failed to lock shortcut state".to_string())?; + ( + guard.toggle_mode.clone(), + guard.toggle_mode_text.clone(), + guard.toggle_window.clone(), + ) + }; + + let (shortcut, label) = parse_shortcut_input(&toggle_mode)?; + if previous + .2 + .as_ref() + .map(|v| v.id() == shortcut.id()) + .unwrap_or(false) + { + return Err("快捷键冲突:与“唤起窗口快捷键”重复".to_string()); + } + + register_mode_shortcut(&app, shortcut, label.clone())?; + + let mut cfg = load_app_config_from_file(&app)?; + cfg.shortcut.toggle_mode = label.clone(); + if let Err(err) = save_app_config_to_file(&app, &cfg) { + if let Some(prev_shortcut) = previous.0 { + let rollback_label = if previous.1.trim().is_empty() { + format_shortcut(&prev_shortcut) + } else { + previous.1 + }; + let _ = register_mode_shortcut(&app, prev_shortcut, rollback_label); + } + return Err(err); + } + + Ok(SetShortcutConfigResult { + success: true, + message: "形态切换快捷键已更新".to_string(), + applied: Some(label), + }) +} + +#[tauri::command] +fn set_window_mode(app: AppHandle, mode: String) -> Result<(), String> { + set_window_mode_internal(&app, &mode) +} + +#[tauri::command] +fn get_pet_settings(app: AppHandle) -> Result { + let cfg = load_app_config_from_file(&app)?; + Ok(PetSettingsPayload { + enabled: cfg.pet.enabled, + show_badge: cfg.pet.show_badge, + animations: cfg.pet.animations, + cat_position: PetPositionPayload { + x: cfg.pet.cat_position.x, + y: cfg.pet.cat_position.y, + }, + window_mode: normalize_window_mode(&cfg.pet.window_mode), + }) +} + +#[tauri::command] +fn save_pet_settings( + app: AppHandle, + enabled: bool, + show_badge: bool, + animations: bool, + cat_x: Option, + cat_y: Option, + window_mode: Option, +) -> Result<(), String> { + let mut cfg = load_app_config_from_file(&app)?; + cfg.pet.enabled = enabled; + cfg.pet.show_badge = show_badge; + cfg.pet.animations = animations; + if let Some(x) = cat_x { + cfg.pet.cat_position.x = x; + } + if let Some(y) = cat_y { + cfg.pet.cat_position.y = y; + } + if let Some(mode) = window_mode { + cfg.pet.window_mode = normalize_window_mode(&mode); + } + save_app_config_to_file(&app, &cfg)?; + Ok(()) +} + #[tauri::command] fn get_app_mode(app: AppHandle) -> Result { let path = config_file_path(&app)?; @@ -2401,6 +3171,8 @@ async fn sync_tasks( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin(tauri_nspanel::init()) + .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, None, @@ -2410,25 +3182,43 @@ pub fn run() { always_on_top: true, })) .manage(Mutex::new(ConfigIoLock)) + .manage(Mutex::new(GlobalShortcutState::default())) .manage(tokio::sync::RwLock::new(TokenManager::default())) .manage(tokio::sync::Mutex::new(())) .setup(|app| { #[cfg(target_os = "macos")] { - use cocoa::{ - appkit::{NSColor, NSWindow}, - base::{id, nil, NO}, - }; - - if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - if let Ok(ns_window_ptr) = window.ns_window() { - let ns_window: id = ns_window_ptr as id; - unsafe { - ns_window.setBackgroundColor_(NSColor::clearColor(nil)); - ns_window.setOpaque_(NO); - ns_window.setHasShadow_(NO); - } - } + app.set_activation_policy(ActivationPolicy::Accessory); + } + + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { + let pinned = app + .state::>() + .lock() + .map(|state| state.always_on_top) + .unwrap_or(true); + let _ = apply_window_traits(&window, pinned, should_include_native_traits("setup"), "setup"); + + #[cfg(target_os = "macos")] + { + let panel = window + .to_panel::() + .map_err(|err| -> Box { Box::new(err) })?; + panel.set_style_mask(StyleMask::empty().borderless().nonactivating_panel().into()); + panel.set_collection_behavior( + CollectionBehavior::new() + .can_join_all_spaces() + .stationary() + .full_screen_auxiliary() + .into(), + ); + panel.set_level(PanelLevel::Custom(25).value()); + panel.set_floating_panel(true); + panel.set_hides_on_deactivate(false); + println!( + "[Rust] Window '{}' converted to NSPanel for fullscreen Space visibility", + MAIN_WINDOW_LABEL + ); } } @@ -2442,12 +3232,21 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ check_feishu_api_client, get_window_state, + set_window_visible_on_all_spaces, + reapply_window_traits, toggle_always_on_top, enter_mini_mode, restore_normal_mode, hide_window_to_tray, save_config, load_config, + get_shortcut_config, + set_shortcut_config, + get_mode_shortcut_config, + set_mode_shortcut_config, + set_window_mode, + get_pet_settings, + save_pet_settings, get_app_mode, set_app_mode, save_task_order, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e5fb323..5c10de3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Topdo", - "version": "1.0.0", + "version": "1.0.1", "identifier": "com.topdo", "build": { "beforeDevCommand": "pnpm dev", @@ -10,6 +10,7 @@ "frontendDist": "../dist" }, "app": { + "macOSPrivateApi": true, "windows": [ { "title": "Topdo", diff --git a/src/App.vue b/src/App.vue index 4f036d1..9d31da7 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,17 +1,13 @@