diff --git a/docs/32-md文件关联.md b/docs/32-md文件关联.md new file mode 100644 index 0000000..49fa7e5 --- /dev/null +++ b/docs/32-md文件关联.md @@ -0,0 +1,48 @@ +# docs/32 .md 文件关联(默认打开方式) + +2026-11-15 用户要求:「支持设置 md 格式的打开方式」。目标:在资源 +管理器双击 .md 能用本程序打开,且程序内能发起设置。三条链路: + +## 1. 安装器注册(tauri.conf.json) + +`bundle.fileAssociations` 增加 md/markdown(name=NewMarkdown, +role=Editor)。NSIS/MSI 安装时写入关联注册信息,本程序出现在 +「打开方式」候选里,用户在打开方式对话框勾「始终」或在系统设置 +里选一次即设为默认。注意:需用新版安装包**重装一次**才生效。 + +## 2. 程序内发起设置(文件菜单) + +「文件 → 设为 .md 默认打开方式…」(仅桌面模式):Rust 命令 +`register_file_association` 用 reg.exe 写当前用户注册表(免管理员): +ProgID `NewMarkdown.md`(类型名、打开命令 `"" "%1"`、图标)、 +.md/.markdown 的 OpenWithProgids、Applications 的 SupportedTypes 与 +打开命令——便携版/未走安装器的场景也能注册,且指向当前 exe 路径。 +写完后打开 `ms-settings:defaultapps` 并弹窗说明:**Windows 不允许 +程序直接抢占默认**(UserChoice 带哈希校验),最后一步必须由用户 +在设置里把 .md 选为 new-markdown,只需一次。 + +## 3. 双击真正打开文件 + +- 单实例插件(tauri-plugin-single-instance,须最先注册):程序已 + 运行时双击第二个文件不再起新进程,第二实例的参数经回调转入 + 待打开队列并发 `pending-open-files` 事件,同时聚焦主窗口。 +- 冷启动:setup 里读 `std::env::args`,与第二实例共用纯函数 + `md_files_from_args` 过滤(跳过开关项、只收存在的 .md/.markdown), + 存入 `PendingOpen` 队列。 +- 前端:新命令 `take_pending_open_files` 取走队列并逐个 openPath。 + 主窗口在启动流程末尾拉一次(冷启动),并监听事件随时拉(热转 + 发)。**只由 label=main 的窗口消费**:子窗口(?file= 打开的)跑 + 同一套 App,不拦会把文件抢到子窗口里。 + +命令总数 31 → 33。 + +## 验证 + +- 参数过滤纯函数抽入独立 crate 实测:开关项/.txt/不存在的文件被 + 排除,.md 与 .markdown 存在文件入选。 +- rustfmt 语法解析通过;命令数对账 33=33;tsc 0;vitest 全过 + (InfoModal 新增 1 例);生产构建通过。 +- 沙箱验不了(待真机):Windows 完整构建(含单实例插件与 + cfg(windows) 的 reg.exe 分支)、安装包关联注册实效、双击冷启动 + 打开、已运行双击第二个文件转发到主窗口、菜单项注册后在系统 + 设置里选默认全流程。 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b55d7c4..7fcce34 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -59,6 +59,137 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + [[package]] name = "atk" version = "0.18.2" @@ -175,6 +306,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "9.1.1" @@ -382,6 +526,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "cookie" version = "0.18.2" @@ -785,6 +938,33 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -802,6 +982,36 @@ dependencies = [ "typeid", ] +[[package]] +name = "errno" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35d6e78f4cebb90e51d543db8fe41528851d6deeef6ef21ebd4bfdc1e784776" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -937,6 +1147,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1314,6 +1537,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1867,6 +2096,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" @@ -2040,6 +2275,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-single-instance", "trash", ] @@ -2295,6 +2531,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2320,6 +2566,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2461,6 +2713,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2493,6 +2756,20 @@ dependencies = [ "miniz_oxide 0.8.9", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2920,6 +3197,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.46" @@ -3276,6 +3566,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3742,6 +4042,22 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726b6da0dd5bd1344f84f4108a622c07d01923baacb5c803a605189672c5352f" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.12.2" @@ -3841,6 +4157,19 @@ dependencies = [ "toml 1.2.1+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -4121,9 +4450,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4192,6 +4533,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unicode-ident" version = "1.0.27" @@ -4980,6 +5332,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.5", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.10", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.5", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.8.63" @@ -5071,3 +5493,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.5", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.10", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.10", + "winnow 1.0.5", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b0bee22..f345ee7 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" +tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" encoding_rs = "0.8" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index f05c7d6..bf1cb36 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -941,6 +941,76 @@ pub async fn s3_upload_image( .map_err(|e| format!("上传任务失败:{e}"))? } +/// 取走待打开文件队列(docs/32):启动参数/第二实例双击转来的 .md 文件。 +#[tauri::command] +pub fn take_pending_open_files(state: tauri::State<'_, crate::PendingOpen>) -> Vec { + match state.0.lock() { + Ok(mut queue) => std::mem::take(&mut *queue), + Err(_) => Vec::new(), + } +} + +/// 把本程序注册为 .md/.markdown 的候选打开方式(docs/32),并打开 Windows +/// 「默认应用」设置页让用户确认。Windows 不允许程序直接抢占默认(UserChoice +/// 带校验),应用能做的是注册候选 + 引导用户点一次。 +#[tauri::command] +pub async fn register_file_association() -> Result { + tauri::async_runtime::spawn_blocking(register_file_association_impl) + .await + .map_err(|e| format!("注册任务失败:{e}"))? +} + +#[cfg(not(windows))] +fn register_file_association_impl() -> Result { + Err("文件关联设置仅支持 Windows。".into()) +} + +#[cfg(windows)] +fn register_file_association_impl() -> Result { + use std::os::windows::process::CommandExt; + use std::process::Command; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + let exe = std::env::current_exe().map_err(|e| format!("无法定位程序位置:{e}"))?; + let exe_s = exe.to_string_lossy().to_string(); + let exe_name = exe + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "new-markdown.exe".into()); + let prog_id = "NewMarkdown.md"; + let open_cmd = format!("\"{exe_s}\" \"%1\""); + let mut failures: Vec = Vec::new(); + let mut reg = |args: &[&str]| { + match Command::new("reg.exe") + .args(args) + .creation_flags(CREATE_NO_WINDOW) + .status() + { + Ok(st) if st.success() => {} + Ok(st) => failures.push(format!("reg {:?} 退出码 {}", args.get(1), st.code().unwrap_or(-1))), + Err(e) => failures.push(format!("reg 执行失败:{e}")), + } + }; + // ProgID:类型名、打开命令、图标 + reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}"), "/ve", "/d", "Markdown 文档", "/f"]); + reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]); + reg(&["add", &format!(r"HKCU\Software\Classes\{prog_id}\DefaultIcon"), "/ve", "/d", &format!("\"{exe_s}\",0"), "/f"]); + for ext in [".md", ".markdown"] { + // 扩展名的候选列表 + 应用的 SupportedTypes(出现在「打开方式」里) + reg(&["add", &format!(r"HKCU\Software\Classes\{ext}\OpenWithProgids"), "/v", prog_id, "/t", "REG_SZ", "/d", "", "/f"]); + reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\SupportedTypes"), "/v", ext, "/t", "REG_SZ", "/d", "", "/f"]); + } + reg(&["add", &format!(r"HKCU\Software\Classes\Applications\{exe_name}\shell\open\command"), "/ve", "/d", &open_cmd, "/f"]); + if !failures.is_empty() { + return Err(format!("注册表写入失败:{}", failures.join(";"))); + } + // 打开系统「默认应用」设置,用户在那里把 .md 选成本程序(只需一次) + let _ = Command::new("explorer.exe") + .arg("ms-settings:defaultapps") + .creation_flags(CREATE_NO_WINDOW) + .spawn(); + Ok("已把 new-markdown 注册为 .md / .markdown 的候选打开方式,并打开了 Windows「默认应用」设置。请在设置里找到 .md,把默认应用选为 new-markdown(Windows 要求手动确认一次)。".into()) +} + #[tauri::command] pub async fn s3_upload_base64( config: crate::s3::S3Config, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 58a06d5..a2b09a1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,11 +4,28 @@ mod search; mod storage; use std::sync::Mutex; -use tauri::Manager; +use tauri::{Emitter, Manager}; /// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。 pub struct AppDb(pub Mutex); +/// 待打开文件队列(docs/32):启动参数与第二实例转发的 .md 文件先存这里, +/// 前端就绪后经 take_pending_open_files 命令取走打开。 +pub struct PendingOpen(pub Mutex>); + +/// 从命令行参数里挑出要打开的 Markdown 文件:跳过开关项,只收扩展名 +/// .md/.markdown 且确实存在的文件。 +pub fn md_files_from_args>(args: I) -> Vec { + args.into_iter() + .filter(|a| !a.starts_with('-')) + .filter(|a| { + let lower = a.to_lowercase(); + (lower.ends_with(".md") || lower.ends_with(".markdown")) + && std::path::Path::new(a).is_file() + }) + .collect() +} + /// 打开数据库并建表:WAL 降低并发写冲突,busy_timeout 让多开实例的写操作排队而非直接报错。 fn open_and_init(path: &std::path::Path) -> Result { let conn = rusqlite::Connection::open(path).map_err(|e| e.to_string())?; @@ -22,6 +39,23 @@ fn open_and_init(path: &std::path::Path) -> Result #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + // 单实例必须最先注册:双击第二个 .md 时不再起新进程,把文件转给 + // 已运行的窗口打开(docs/32)。 + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + let files = md_files_from_args(argv); + if !files.is_empty() { + if let Some(state) = app.try_state::() { + if let Ok(mut queue) = state.0.lock() { + queue.extend(files); + } + } + let _ = app.emit("pending-open-files", ()); + } + if let Some(w) = app.get_webview_window("main") { + let _ = w.unminimize(); + let _ = w.set_focus(); + } + })) .plugin(tauri_plugin_dialog::init()) .setup(|app| { let data_dir = app.path().app_data_dir().expect("app data dir"); @@ -50,6 +84,9 @@ pub fn run() { } }; app.manage(AppDb(Mutex::new(conn))); + // 冷启动参数(双击 .md 打开本程序时,文件路径在这里) + let initial = md_files_from_args(std::env::args().skip(1)); + app.manage(PendingOpen(Mutex::new(initial))); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -70,6 +107,8 @@ pub fn run() { commands::s3_upload_image, commands::s3_upload_base64, commands::s3_test_connection, + commands::take_pending_open_files, + commands::register_file_association, commands::delete_to_trash, commands::db_recent_list, commands::db_recent_add, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 057e181..c353a05 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1 +1 @@ -{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","windows":{"nsis":{"languages":["SimpChinese"],"displayLanguageSelector":false},"wix":{"language":"zh-CN"}},"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"} \ No newline at end of file +{"$schema":"https://schema.tauri.app/config/2","app":{"security":{"csp":null},"windows":[{"center":true,"decorations":false,"height":820,"label":"main","minHeight":600,"minWidth":920,"resizable":true,"title":"new-markdown","width":1280,"dragDropEnabled":false}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","windows":{"nsis":{"languages":["SimpChinese"],"displayLanguageSelector":false},"wix":{"language":"zh-CN"}},"icon":["icons/32x32.png","icons/128x128.png","icons/128x128@2x.png","icons/icon.ico","icons/icon.png"],"shortDescription":"Windows Markdown 所见即所得编辑器","targets":["nsis","msi"],"fileAssociations":[{"ext":["md","markdown"],"name":"NewMarkdown","description":"Markdown 文档","role":"Editor"}]},"identifier":"app.newmarkdown.desktop","productName":"new-markdown","version":"0.1.0"} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 6f70efd..add71ac 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ const SourceEditor = lazy(() => import('./components/SourceEditor')) import { offsetInMarkdown, type CursorAnchor } from './editor/cursorSync' import { insertPlainText, insertLinkText } from './editor/paste' import { isImeComposing } from './utils/ime' -import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal, ImageLinkModal } from './components/Modals' +import { ConfirmSaveModal, AboutModal, ShortcutsModal, LinkModal, ErrorModal , EncodingConvertModal, ImageSizeModal, ImageLinkModal, InfoModal } from './components/Modals' import { tabsReducer, createTab, activeTab, isDirty, type DocTab, type TabsState } from './stores/tabs' import { countWords, countChars, extractOutline, baseName, isPathInside, type OutlineItem } from './utils/text' import { callUndo, callRedo, focusEditor, type CursorInfo } from './editor/editor' @@ -29,7 +29,7 @@ import { pickImageFile, pickAnyFile, pickAndReadWebImage, copyToAssets, saveBytesToAssets, fileToBase64, statFile, createTextFile, createDirPath, renamePath, deleteToTrash, revealInExplorer, type DirEntry, type RecoveryEntry, pickSaveHtmlFile, pickImportFile, readFileBase64, readBinaryFile, pickAndReadWebImport, - gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, type GitStatusInfo, + gitStatus, gitInitRepo, gitSetRemote, gitSyncNow, s3UploadImage, s3UploadBase64, takePendingOpenFiles, registerFileAssociation, type GitStatusInfo, } from './services/platform' import * as store from './services/store' import { recoveryKey, planRecovery } from './utils/recovery' @@ -708,11 +708,47 @@ export default function App() { // 审计修复:这段原先写在恢复记录的提前 return 之后,无恢复记录时永远执行不到。 const startupFile = new URLSearchParams(window.location.search).get('file') if (!cancelled && startupFile) void openPath(startupFile) + // 双击 .md 冷启动:文件路径经启动参数进 Rust 的待打开队列(docs/32)。 + // 只由主窗口消费——子窗口(?file= 打开的)也在跑同一套启动流程, + // 不拦会把队列里的文件抢去自己窗口打开。 + if (!cancelled && tauriMode) { + try { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + if (getCurrentWindow().label === 'main') { + const pending = await takePendingOpenFiles() + for (const p of pending) { if (!cancelled) await openPath(p) } + } + } catch { /* 队列不可用时不影响正常启动 */ } + } })() return () => { cancelled = true } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + // 第二个文件双击转来时(单实例转发):Rust 发事件,这里取队列打开(docs/32) + useEffect(() => { + if (!tauriMode) return + let unlisten: (() => void) | undefined + let cancelled = false + void (async () => { + try { + const { getCurrentWindow } = await import('@tauri-apps/api/window') + if (getCurrentWindow().label !== 'main') return // 只由主窗口消费(理由同冷启动) + const { listen } = await import('@tauri-apps/api/event') + const un = await listen('pending-open-files', async () => { + try { + const pending = await takePendingOpenFiles() + for (const p of pending) await openPath(p) + } catch { /* 同上 */ } + }) + if (cancelled) un() + else unlisten = un + } catch { /* 事件不可用时退化为仅冷启动打开 */ } + })() + return () => { cancelled = true; unlisten?.() } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tauriMode]) + useEffect(() => { if (workspaceRoot) { store.addWorkspace(workspaceRoot) @@ -1090,6 +1126,8 @@ export default function App() { /** 右键图片的「超链接」目标(图片模式链接弹窗)与「调整大小」目标(docs/31) */ const [imageLinkTarget, setImageLinkTarget] = useState<{ tabId: string; el: HTMLElement; src: string; href: string } | null>(null) const [imageSizeTarget, setImageSizeTarget] = useState<{ tabId: string; el: HTMLElement; width: string | null } | null>(null) + /** 文件关联注册后的引导文案(docs/32) */ + const [assocMsg, setAssocMsg] = useState(null) const openEditorMenu = (e: React.MouseEvent) => { const target = e.target as HTMLElement const inEditor = sourceMode @@ -1586,6 +1624,8 @@ export default function App() { { divider: true }, { label: '上传文档图片到 S3…', disabled: !tab || sourceMode, onClick: () => void runBatchImageUpload() }, { label: 'S3 上传设置…', onClick: () => setS3SettingsOpen(true) }, + { divider: true }, + { label: '设为 .md 默认打开方式…', disabled: !tauriMode, onClick: () => { void (async () => { try { setAssocMsg(await registerFileAssociation()) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } })() } }, ], }, { @@ -2091,6 +2131,7 @@ export default function App() { }} /> )} + {assocMsg && setAssocMsg(null)} />} {imageSizeTarget && ( void }) { + return ( + +

{title}

+

{message}

+
+
+ ) +} + export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) { return ( diff --git a/src/services/platform.ts b/src/services/platform.ts index 5d9dcc1..675c8de 100644 --- a/src/services/platform.ts +++ b/src/services/platform.ts @@ -366,6 +366,16 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f return invoke('s3_upload_image', { config, filePath, fileName }) } +/** 取走待打开文件队列:启动参数/双击第二个文件转来的 .md 路径(docs/32)。 */ +export async function takePendingOpenFiles(): Promise { + return invoke('take_pending_open_files') +} + +/** 注册 .md/.markdown 文件关联并打开系统默认应用设置,返回引导文案(docs/32)。 */ +export async function registerFileAssociation(): Promise { + return invoke('register_file_association') +} + /** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */ export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise { return invoke('s3_upload_base64', { config, base64, fileName }) diff --git a/tests/info-modal.test.tsx b/tests/info-modal.test.tsx new file mode 100644 index 0000000..af46b1e --- /dev/null +++ b/tests/info-modal.test.tsx @@ -0,0 +1,24 @@ +// 通用信息弹窗(docs/32 文件关联引导用):标题/正文渲染,点「知道了」关闭。 +import { describe, it, expect, vi } from 'vitest' +import { createRoot } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { InfoModal } from '../src/components/Modals' + +describe('InfoModal', () => { + it('渲染标题与正文,点知道了回调关闭', async () => { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const onClose = vi.fn() + await act(async () => { + root.render() + }) + expect(container.textContent).toContain('文件关联') + expect(container.textContent).toContain('已注册为候选打开方式。') + const btn = [...container.querySelectorAll('button')].find((b) => b.textContent === '知道了')! + await act(async () => { btn.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) + expect(onClose).toHaveBeenCalledTimes(1) + await act(async () => { root.unmount() }) + container.remove() + }) +})