feat: Tauri 壳与 Rust 命令层(文件读写/编码探测/资源落盘)

This commit is contained in:
Muse
2026-11-13 21:19:23 +08:00
parent cbe641e21a
commit ac2c29df8f
13 changed files with 4834 additions and 0 deletions

4569
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

19
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "new-markdown"
version = "0.1.0"
description = "new-markdown — Windows Markdown 所见即所得编辑器"
edition = "2021"
[lib]
name = "new_markdown_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
encoding_rs = "0.8"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1 @@
{"$schema":"../gen/schemas/desktop-schema.json","description":"主窗口默认权限:窗口控制(自定义标题栏)与文件对话框","identifier":"default","permissions":["core:default","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-unmaximize","core:window:allow-close","core:window:allow-is-maximized","core:window:allow-start-dragging","dialog:default"],"windows":["main"]}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 283 B

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

219
src-tauri/src/commands.rs Normal file
View File

@@ -0,0 +1,219 @@
//! 文件系统命令层:前端一切磁盘读写只走这里。
//! 设计约束(架构文档 §3):
//! - 读入探测编码(UTF-8 / UTF-8 BOM / UTF-16 / GBK),内部统一为 UTF-8 字符串;
//! - 写入用「同目录临时文件 + 原子替换」,任何失败都不破坏原文件;
//! - 正文永不经 SQLite,.md 文件是唯一事实源。
use serde::Serialize;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileContent {
pub content: String,
/// 探测到的源编码:UTF-8 / UTF-8 BOM / UTF-16LE / UTF-16BE / GBK
pub encoding: String,
/// 换行符:CRLF / LF
pub line_ending: String,
pub had_bom: bool,
}
#[tauri::command]
pub fn read_text_file(path: String) -> Result<FileContent, String> {
let bytes = fs::read(&path).map_err(|e| format!("读取文件失败:{e}"))?;
let (content, encoding, had_bom) = decode_bytes(&bytes);
let line_ending = if content.contains("\r\n") { "CRLF" } else { "LF" }.to_string();
Ok(FileContent { content, encoding, line_ending, had_bom })
}
fn decode_bytes(bytes: &[u8]) -> (String, String, bool) {
if let Some(rest) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) {
return (String::from_utf8_lossy(rest).into_owned(), "UTF-8".into(), true);
}
if bytes.starts_with(&[0xFF, 0xFE]) {
let (text, _, _) = encoding_rs::UTF_16LE.decode(bytes);
return (text.into_owned(), "UTF-16LE".into(), true);
}
if bytes.starts_with(&[0xFE, 0xFF]) {
let (text, _, _) = encoding_rs::UTF_16BE.decode(bytes);
return (text.into_owned(), "UTF-16BE".into(), true);
}
match std::str::from_utf8(bytes) {
Ok(s) => (s.to_string(), "UTF-8".into(), false),
Err(_) => {
let (text, _, _) = encoding_rs::GBK.decode(bytes);
(text.into_owned(), "GBK".into(), false)
}
}
}
/// 原子写:先写同目录临时文件并落盘,再替换目标。`keep_bom` 为真时补回 UTF-8 BOM。
#[tauri::command]
pub fn write_text_file(path: String, content: String, keep_bom: bool) -> Result<(), String> {
let target = PathBuf::from(&path);
let dir = target
.parent()
.ok_or_else(|| "目标路径没有父目录".to_string())?
.to_path_buf();
let file_name = target
.file_name()
.ok_or_else(|| "目标路径没有文件名".to_string())?
.to_string_lossy()
.into_owned();
let tmp = dir.join(format!(".{file_name}.tmp-{}", std::process::id()));
let mut data: Vec<u8> = Vec::with_capacity(content.len() + 3);
if keep_bom {
data.extend_from_slice(&[0xEF, 0xBB, 0xBF]);
}
data.extend_from_slice(content.as_bytes());
let write_result = (|| -> std::io::Result<()> {
let mut f = fs::File::create(&tmp)?;
f.write_all(&data)?;
f.sync_all()?;
Ok(())
})();
if let Err(e) = write_result {
let _ = fs::remove_file(&tmp);
return Err(format!("写入临时文件失败:{e}"));
}
fs::rename(&tmp, &target).map_err(|e| {
let _ = fs::remove_file(&tmp);
format!("替换文件失败:{e}")
})?;
Ok(())
}
/// 资源目录:<文档目录>/<文档主名>.assets/,不存在则创建。返回目录路径。
fn assets_dir(doc_path: &str) -> Result<PathBuf, String> {
let doc = PathBuf::from(doc_path);
let dir = doc.parent().ok_or_else(|| "文档路径没有父目录".to_string())?.to_path_buf();
let stem = doc
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "assets".to_string());
let assets = dir.join(format!("{stem}.assets"));
fs::create_dir_all(&assets).map_err(|e| format!("创建资源目录失败:{e}"))?;
Ok(assets)
}
/// 文件名清洗:只保留基名,替换 Windows 非法字符。
fn sanitize_file_name(name: &str) -> String {
let base = Path::new(name)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string());
let cleaned: String = base
.chars()
.map(|c| if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') { '_' } else { c })
.collect();
if cleaned.trim().is_empty() { "file".to_string() } else { cleaned }
}
/// 在资源目录里找一个不重名的目标路径(重名追加 -1、-2…)。
fn dedupe_path(dir: &Path, file_name: &str) -> PathBuf {
let candidate = dir.join(file_name);
if !candidate.exists() {
return candidate;
}
let stem = Path::new(file_name)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let ext = Path::new(file_name)
.extension()
.map(|e| format!(".{}", e.to_string_lossy()))
.unwrap_or_default();
for i in 1..1000 {
let next = dir.join(format!("{stem}-{i}{ext}"));
if !next.exists() {
return next;
}
}
candidate
}
fn relative_assets_path(doc_path: &str, target: &Path) -> String {
let stem = PathBuf::from(doc_path)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "assets".to_string());
let name = target
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
format!("{stem}.assets/{name}")
}
/// 把外部文件复制进文档资源目录,返回相对文档的路径(正斜杠,可直接写进 Markdown)。
#[tauri::command]
pub fn copy_to_assets(source: String, doc_path: String) -> Result<String, String> {
let src = PathBuf::from(&source);
let name = sanitize_file_name(&src.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "file".into()));
let dir = assets_dir(&doc_path)?;
let target = dedupe_path(&dir, &name);
fs::copy(&src, &target).map_err(|e| format!("复制资源文件失败:{e}"))?;
Ok(relative_assets_path(&doc_path, &target))
}
/// 把字节内容(如粘贴的图片)写入文档资源目录,返回相对路径。
#[tauri::command]
pub fn save_bytes_to_assets(doc_path: String, file_name: String, bytes: Vec<u8>) -> Result<String, String> {
let name = sanitize_file_name(&file_name);
let dir = assets_dir(&doc_path)?;
let target = dedupe_path(&dir, &name);
fs::write(&target, &bytes).map_err(|e| format!("写入资源文件失败:{e}"))?;
Ok(relative_assets_path(&doc_path, &target))
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DirEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
}
/// 列出一层目录:文件夹在前、同类按名称(不区分大小写)排序;
/// 文件只收 Markdown/文本类(.md/.markdown/.txt),隐藏项(点开头)跳过。
#[tauri::command]
pub fn list_dir(path: String) -> Result<Vec<DirEntry>, String> {
let dir = Path::new(&path);
let read = fs::read_dir(dir).map_err(|e| format!("读取目录失败:{e}"))?;
let mut entries: Vec<DirEntry> = Vec::new();
for item in read {
let item = match item {
Ok(i) => i,
Err(_) => continue,
};
let name = item.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let is_dir = item.file_type().map(|t| t.is_dir()).unwrap_or(false);
if !is_dir {
let ext = item
.path()
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
if !matches!(ext.as_str(), "md" | "markdown" | "txt") {
continue;
}
}
entries.push(DirEntry {
name,
path: item.path().to_string_lossy().into_owned(),
is_dir,
});
}
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}

16
src-tauri/src/lib.rs Normal file
View File

@@ -0,0 +1,16 @@
mod commands;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
commands::read_text_file,
commands::write_text_file,
commands::list_dir,
commands::copy_to_assets,
commands::save_bytes_to_assets,
])
.run(tauri::generate_context!())
.expect("error while running new-markdown");
}

6
src-tauri/src/main.rs Normal file
View File

@@ -0,0 +1,6 @@
// Windows 发布版不弹控制台窗口
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
new_markdown_lib::run()
}

View File

@@ -0,0 +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}]},"build":{"beforeBuildCommand":"npm run build","beforeDevCommand":"npm run dev","devUrl":"http://localhost:1420","frontendDist":"../dist"},"bundle":{"active":true,"category":"Productivity","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"}