feat: Tauri 壳与 Rust 命令层(文件读写/编码探测/资源落盘)
This commit is contained in:
4569
src-tauri/Cargo.lock
generated
Normal file
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
19
src-tauri/Cargo.toml
Normal 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
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
1
src-tauri/capabilities/default.json
Normal file
1
src-tauri/capabilities/default.json
Normal 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
BIN
src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 934 B |
BIN
src-tauri/icons/128x128@2x.png
Normal file
BIN
src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
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
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
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
219
src-tauri/src/commands.rs
Normal 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
16
src-tauri/src/lib.rs
Normal 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
6
src-tauri/src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
// Windows 发布版不弹控制台窗口
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
new_markdown_lib::run()
|
||||||
|
}
|
||||||
1
src-tauri/tauri.conf.json
Normal file
1
src-tauri/tauri.conf.json
Normal 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"}
|
||||||
Reference in New Issue
Block a user