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

48
docs/32-md文件关联.md Normal file
View File

@@ -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`(类型名、打开命令 `"<exe>" "%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 分支)、安装包关联注册实效、双击冷启动
打开、已运行双击第二个文件转发到主窗口、菜单项注册后在系统
设置里选默认全流程。

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

View File

@@ -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<string | null>(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 && <InfoModal title="文件关联" message={assocMsg} onClose={() => setAssocMsg(null)} />}
{imageSizeTarget && (
<ImageSizeModal
current={imageSizeTarget.width}

View File

@@ -260,6 +260,16 @@ export function ImageLinkModal({ src, linkHref, onSubmit, onCancel }: {
)
}
export function InfoModal({ title, message, onClose }: { title: string; message: string; onClose: () => void }) {
return (
<Shell onCancel={onClose}>
<h3>{title}</h3>
<p>{message}</p>
<div className="modal-actions"><button className="primary" onClick={onClose}>知道了</button></div>
</Shell>
)
}
export function ErrorModal({ message, onClose }: { message: string; onClose: () => void }) {
return (
<Shell onCancel={onClose}>

View File

@@ -366,6 +366,16 @@ export async function s3UploadImage(config: S3ConfigPayload, filePath: string, f
return invoke<string>('s3_upload_image', { config, filePath, fileName })
}
/** 取走待打开文件队列:启动参数/双击第二个文件转来的 .md 路径(docs/32)。 */
export async function takePendingOpenFiles(): Promise<string[]> {
return invoke<string[]>('take_pending_open_files')
}
/** 注册 .md/.markdown 文件关联并打开系统默认应用设置,返回引导文案(docs/32)。 */
export async function registerFileAssociation(): Promise<string> {
return invoke<string>('register_file_association')
}
/** 上传文档内嵌的 base64 图片(已剥 data: 前缀的纯 base64),返回回填地址。 */
export async function s3UploadBase64(config: S3ConfigPayload, base64: string, fileName: string): Promise<string> {
return invoke<string>('s3_upload_base64', { config, base64, fileName })

24
tests/info-modal.test.tsx Normal file
View File

@@ -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(<InfoModal title="文件关联" message="已注册为候选打开方式。" onClose={onClose} />)
})
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()
})
})