547 lines
17 KiB
Rust
547 lines
17 KiB
Rust
use rand::RngCore;
|
|
use serde::Serialize;
|
|
use serde_json::Value;
|
|
use std::sync::Mutex;
|
|
use std::{
|
|
fs,
|
|
fs::OpenOptions,
|
|
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>,
|
|
access_token: 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 access_token = ensure_access_token(&review_root)?;
|
|
|
|
let expected_version = read_expected_version(&tool_root);
|
|
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root, &access_token)
|
|
{
|
|
let session_id = ensure_session_from_path(&url, epub_path.as_deref(), &access_token)?;
|
|
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,
|
|
access_token,
|
|
});
|
|
}
|
|
|
|
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(),
|
|
"--access-token".to_string(),
|
|
access_token.clone(),
|
|
"--daemon".to_string(),
|
|
],
|
|
Some(&tool_root),
|
|
)?;
|
|
|
|
let launched_url = extract_url(&output.stdout)
|
|
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root, &access_token))
|
|
.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(), &access_token)?;
|
|
|
|
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,
|
|
access_token,
|
|
})
|
|
}
|
|
|
|
fn ensure_access_token(review_root: &Path) -> Result<String, String> {
|
|
let token_path = review_root.join(".access-token");
|
|
if let Ok(existing) = fs::read_to_string(&token_path) {
|
|
let token = existing.trim();
|
|
if !token.is_empty() {
|
|
return Ok(token.to_string());
|
|
}
|
|
}
|
|
|
|
let mut bytes = [0_u8; 32];
|
|
rand::thread_rng().fill_bytes(&mut bytes);
|
|
let token = bytes
|
|
.iter()
|
|
.map(|byte| format!("{byte:02x}"))
|
|
.collect::<String>();
|
|
let mut options = OpenOptions::new();
|
|
options.write(true).create_new(true);
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
options.mode(0o600);
|
|
}
|
|
let create_result = options
|
|
.open(&token_path)
|
|
.and_then(|mut file| file.write_all(token.as_bytes()));
|
|
match create_result {
|
|
Ok(()) => Ok(token),
|
|
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
fs::read_to_string(&token_path)
|
|
.map(|value| value.trim().to_string())
|
|
.map_err(|read_err| {
|
|
format!("Failed to read review sidecar access token: {read_err}")
|
|
})
|
|
.and_then(|value| {
|
|
if value.is_empty() {
|
|
Err("Review sidecar access token file is empty.".to_string())
|
|
} else {
|
|
Ok(value)
|
|
}
|
|
})
|
|
}
|
|
Err(err) => Err(format!(
|
|
"Failed to create review sidecar access token: {err}"
|
|
)),
|
|
}
|
|
}
|
|
|
|
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,
|
|
access_token: &str,
|
|
) -> 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", access_token) 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, access_token: &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\nAuthorization: Bearer {access_token}\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, access_token: &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\nAuthorization: Bearer {access_token}\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>,
|
|
access_token: &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, access_token)?;
|
|
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,
|
|
redact_command_args(args).join(" "),
|
|
output.stdout,
|
|
output.stderr
|
|
))
|
|
}
|
|
}
|
|
|
|
fn redact_command_args(args: &[String]) -> Vec<String> {
|
|
let mut redact_next = false;
|
|
args.iter()
|
|
.map(|arg| {
|
|
if redact_next {
|
|
redact_next = false;
|
|
return "[REDACTED]".to_string();
|
|
}
|
|
if arg == "--access-token" {
|
|
redact_next = true;
|
|
}
|
|
arg.clone()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
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)
|
|
}
|