Files
readest/apps/readest-app/src-tauri/src/review_editor.rs
T
2026-07-09 14:15:36 +08:00

466 lines
15 KiB
Rust

use serde::Serialize;
use serde_json::Value;
use std::sync::Mutex;
use std::{
fs,
io::{Read, Write},
net::{SocketAddr, TcpStream},
path::{Path, PathBuf},
process::Command,
time::Duration,
};
use tauri::{AppHandle, Manager};
const DEFAULT_PORT: u16 = 5177;
const PORT_SCAN_LIMIT: u16 = 100;
static LAUNCH_LOCK: Mutex<()> = Mutex::new(());
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReviewEditorLaunchResponse {
ok: bool,
url: String,
reused: bool,
review_root: String,
version: String,
session_id: Option<String>,
}
struct CommandOutput {
stdout: String,
stderr: String,
}
struct PythonCommand {
program: String,
args: Vec<String>,
}
#[tauri::command]
pub async fn launch_epub_review_editor(
app: AppHandle,
epub_path: Option<String>,
) -> Result<ReviewEditorLaunchResponse, String> {
tauri::async_runtime::spawn_blocking(move || launch_epub_review_editor_sync(&app, epub_path))
.await
.map_err(|err| err.to_string())?
}
fn launch_epub_review_editor_sync(
app: &AppHandle,
epub_path: Option<String>,
) -> Result<ReviewEditorLaunchResponse, String> {
let _launch_guard = LAUNCH_LOCK
.lock()
.map_err(|_| "审校器启动锁已损坏。请重启 Readest 后重试。".to_string())?;
let tool_root = resolve_tool_root(app)?;
let server_path = tool_root.join("server.py");
if !server_path.exists() {
return Err(format!(
"没有找到审校器后端:{}",
server_path.to_string_lossy()
));
}
let review_root = std::env::var("READEST_REVIEW_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| {
app.path()
.app_data_dir()
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.join("epub_review_sessions")
});
fs::create_dir_all(&review_root).map_err(|err| {
format!(
"创建审校数据目录失败:{} ({err})",
review_root.to_string_lossy()
)
})?;
let expected_version = read_expected_version(&tool_root);
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root) {
let session_id = ensure_session_from_path(&url, epub_path.as_deref())?;
return Ok(ReviewEditorLaunchResponse {
ok: true,
url,
reused: true,
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
});
}
let runtime_root = app
.path()
.app_data_dir()
.unwrap_or_else(|_| review_root.clone())
.join("epub-review-editor-runtime");
let venv_root = runtime_root.join(".venv");
let venv_python = venv_python_path(&venv_root);
ensure_venv(&venv_python, &venv_root)?;
ensure_dependencies(&venv_python, &tool_root)?;
let output = run_command(
&venv_python.to_string_lossy(),
&[
server_path.to_string_lossy().to_string(),
"--review-root".to_string(),
review_root.to_string_lossy().to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
DEFAULT_PORT.to_string(),
"--daemon".to_string(),
"--no-browser".to_string(),
],
Some(&tool_root),
)?;
let launched_url = extract_url(&output.stdout)
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root))
.ok_or_else(|| {
format!(
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
output.stdout, output.stderr
)
})?;
let launched_url = launched_url.replace("http://localhost:", "http://127.0.0.1:");
let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref())?;
Ok(ReviewEditorLaunchResponse {
ok: true,
url: launched_url,
reused: false,
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
})
}
fn resolve_tool_root(app: &AppHandle) -> Result<PathBuf, String> {
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
let path = PathBuf::from(raw);
if path.join("server.py").exists() {
return Ok(path);
}
}
if let Ok(resource_dir) = app.path().resource_dir() {
for candidate in [
resource_dir.join("tools").join("epub-review-editor"),
resource_dir
.join("apps")
.join("readest-app")
.join("tools")
.join("epub-review-editor"),
] {
if candidate.join("server.py").exists() {
return Ok(candidate);
}
}
}
#[cfg(debug_assertions)]
if let Ok(current) = std::env::current_dir() {
for ancestor in current.ancestors() {
for candidate in [
ancestor.join("tools").join("epub-review-editor"),
ancestor
.join("apps")
.join("readest-app")
.join("tools")
.join("epub-review-editor"),
] {
if candidate.join("server.py").exists() {
return Ok(candidate);
}
}
}
}
Err("没有找到已迁移的 EPUB 审校器资源目录。".to_string())
}
fn ensure_venv(venv_python: &Path, venv_root: &Path) -> Result<(), String> {
if venv_python.exists() {
return Ok(());
}
if let Some(parent) = venv_root.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"创建审校器运行目录失败:{} ({err})",
parent.to_string_lossy()
)
})?;
}
let python = resolve_python_command()?;
let mut args = python.args;
args.extend([
"-m".to_string(),
"venv".to_string(),
venv_root.to_string_lossy().to_string(),
]);
run_command(&python.program, &args, None)?;
Ok(())
}
fn ensure_dependencies(venv_python: &Path, tool_root: &Path) -> Result<(), String> {
let check = run_command_allow_failure(
&venv_python.to_string_lossy(),
&[
"-c".to_string(),
"import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('flask') else 1)"
.to_string(),
],
Some(tool_root),
)?;
if check.0 {
return Ok(());
}
let requirements = tool_root.join("requirements.txt");
run_command(
&venv_python.to_string_lossy(),
&[
"-m".to_string(),
"pip".to_string(),
"install".to_string(),
"-r".to_string(),
requirements.to_string_lossy().to_string(),
],
Some(tool_root),
)?;
Ok(())
}
fn venv_python_path(venv_root: &Path) -> PathBuf {
if cfg!(windows) {
venv_root.join("Scripts").join("python.exe")
} else {
venv_root.join("bin").join("python")
}
}
fn resolve_python_command() -> Result<PythonCommand, String> {
let candidates: Vec<PythonCommand> = if cfg!(windows) {
vec![
PythonCommand {
program: "py".to_string(),
args: vec!["-3".to_string()],
},
PythonCommand {
program: "python".to_string(),
args: vec![],
},
PythonCommand {
program: "python3".to_string(),
args: vec![],
},
]
} else {
vec![
PythonCommand {
program: "python3".to_string(),
args: vec![],
},
PythonCommand {
program: "python".to_string(),
args: vec![],
},
]
};
for candidate in candidates {
let mut args = candidate.args.clone();
args.push("--version".to_string());
if run_command_allow_failure(&candidate.program, &args, None)
.map(|(ok, _)| ok)
.unwrap_or(false)
{
return Ok(candidate);
}
}
Err("没有找到 Python 3。请先安装 Python 3,然后重新打开 EPUB 审校器。".to_string())
}
fn read_expected_version(tool_root: &Path) -> Option<String> {
let text = fs::read_to_string(tool_root.join("version.py")).ok()?;
let marker = "version = \"";
let start = text.find(marker)? + marker.len();
let rest = &text[start..];
Some(rest[..rest.find('"')?].to_string())
}
fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> Option<String> {
let expected_root = review_root
.canonicalize()
.unwrap_or_else(|_| review_root.to_path_buf());
for port in DEFAULT_PORT..(DEFAULT_PORT + PORT_SCAN_LIMIT) {
let Some(payload) = request_json(port, "/api/version") else {
continue;
};
let Some(version) = payload.get("version").and_then(Value::as_str) else {
continue;
};
if expected_version.map_or(true, |expected| expected == version) {
let running_root = payload
.get("review_root")
.and_then(Value::as_str)
.map(PathBuf::from);
let running_root_matches = running_root
.as_ref()
.and_then(|path| path.canonicalize().ok())
.map(|path| path == expected_root)
.unwrap_or(false);
if !running_root_matches {
continue;
}
return Some(format!("http://127.0.0.1:{port}"));
}
}
None
}
fn request_json(port: u16, path: &str) -> Option<Value> {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250)).ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(800)));
let request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n");
stream.write_all(request.as_bytes()).ok()?;
read_http_json_response(stream)
}
fn post_json(url: &str, path: &str, body: &str) -> Result<Value, String> {
let port = parse_loopback_port(url)?;
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3))
.map_err(|err| format!("连接审校器失败:{err}"))?;
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
let request = format!(
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(request.as_bytes())
.map_err(|err| format!("写入审校器请求失败:{err}"))?;
read_http_json_response_result(stream)
}
fn read_http_json_response(mut stream: TcpStream) -> Option<Value> {
let mut response = String::new();
stream.read_to_string(&mut response).ok()?;
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
return None;
}
let body = response.split("\r\n\r\n").nth(1)?;
serde_json::from_str(body).ok()
}
fn read_http_json_response_result(mut stream: TcpStream) -> Result<Value, String> {
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|err| format!("读取审校器响应失败:{err}"))?;
let status_line = response.lines().next().unwrap_or_default().to_string();
let body = response.split("\r\n\r\n").nth(1).unwrap_or("");
let payload = serde_json::from_str::<Value>(body).unwrap_or(Value::Null);
if status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200") {
if payload.is_null() {
return Err("审校器返回了空响应。".to_string());
}
return Ok(payload);
}
let message = payload
.get("error")
.and_then(Value::as_str)
.unwrap_or(status_line.as_str());
Err(message.to_string())
}
fn parse_loopback_port(url: &str) -> Result<u16, String> {
let rest = url
.strip_prefix("http://127.0.0.1:")
.or_else(|| url.strip_prefix("http://localhost:"))
.ok_or_else(|| format!("审校器地址不是本机 HTTP 地址:{url}"))?;
let port_text = rest.split('/').next().unwrap_or(rest);
port_text
.parse::<u16>()
.map_err(|err| format!("无法解析审校器端口:{port_text} ({err})"))
}
fn ensure_session_from_path(url: &str, epub_path: Option<&str>) -> Result<Option<String>, String> {
let Some(raw_path) = epub_path.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string();
let payload = post_json(url, "/api/session/from-path", &body)?;
Ok(payload
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned))
}
fn run_command(
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Result<CommandOutput, String> {
let (ok, output) = run_command_allow_failure(program, args, cwd)?;
if ok {
Ok(output)
} else {
Err(format!(
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
program,
args.join(" "),
output.stdout,
output.stderr
))
}
}
fn run_command_allow_failure(
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Result<(bool, CommandOutput), String> {
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
command.stdin(std::process::Stdio::null());
hide_command_window(&mut command);
let output = command
.output()
.map_err(|err| format!("无法执行命令 {program}: {err}"))?;
Ok((
output.status.success(),
CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
},
))
}
#[cfg(windows)]
fn hide_command_window(command: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn hide_command_window(_command: &mut Command) {}
fn extract_url(text: &str) -> Option<String> {
text.split_whitespace()
.find(|part| part.starts_with("http://") || part.starts_with("https://"))
.map(ToOwned::to_owned)
}