feat(app): 支持设置 .md 默认打开方式(docs/32)

三条链路:①tauri.conf bundle.fileAssociations 注册 md/markdown,
新版安装包重装后出现在「打开方式」候选;②文件菜单「设为 .md
默认打开方式…」:Rust 命令经 reg.exe 写 HKCU 的 ProgID
NewMarkdown.md/OpenWithProgids/SupportedTypes(免管理员、指向
当前 exe),再打开系统默认应用设置让用户确认一次(Windows 不许
程序直接抢默认);③双击打开:单实例插件转发第二实例参数到待
打开队列+聚焦主窗口,冷启动读 env args,共用 md_files_from_args
过滤;前端 take_pending_open_files 取走逐个打开,只由 main 窗口
消费防子窗口抢文件。命令数 31→33 对账,参数过滤纯函数已抽 crate
实测,vitest 154+2、构建通过;Windows 构建与双击全流程待真机验。
This commit is contained in:
Muse
2026-11-15 13:57:31 +08:00
parent 492051cef0
commit c300b4c8db
10 changed files with 710 additions and 4 deletions

463
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"

View File

@@ -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<String> {
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<String, String> {
tauri::async_runtime::spawn_blocking(register_file_association_impl)
.await
.map_err(|e| format!("注册任务失败:{e}"))?
}
#[cfg(not(windows))]
fn register_file_association_impl() -> Result<String, String> {
Err("文件关联设置仅支持 Windows。".into())
}
#[cfg(windows)]
fn register_file_association_impl() -> Result<String, String> {
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<String> = 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,

View File

@@ -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<rusqlite::Connection>);
/// 待打开文件队列(docs/32):启动参数与第二实例转发的 .md 文件先存这里,
/// 前端就绪后经 take_pending_open_files 命令取走打开。
pub struct PendingOpen(pub Mutex<Vec<String>>);
/// 从命令行参数里挑出要打开的 Markdown 文件:跳过开关项,只收扩展名
/// .md/.markdown 且确实存在的文件。
pub fn md_files_from_args<I: IntoIterator<Item = String>>(args: I) -> Vec<String> {
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<rusqlite::Connection, String> {
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<rusqlite::Connection, String>
#[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::<PendingOpen>() {
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,

View File

@@ -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"}
{"$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"}