diff --git a/apps/readest-app/e2e/fixtures/books.ts b/apps/readest-app/e2e/fixtures/books.ts
index 992bdb81..67fb23c0 100644
--- a/apps/readest-app/e2e/fixtures/books.ts
+++ b/apps/readest-app/e2e/fixtures/books.ts
@@ -15,3 +15,5 @@ export const SAMPLE_EPUB = path.join(
fixturesDir,
'../../src/__tests__/fixtures/data/sample-alice.epub',
);
+
+export const REVIEW_EPUB = path.join(fixturesDir, 'books/readest-review-e2e.epub');
diff --git a/apps/readest-app/e2e/fixtures/create-review-epub.mjs b/apps/readest-app/e2e/fixtures/create-review-epub.mjs
new file mode 100644
index 00000000..bf80273d
--- /dev/null
+++ b/apps/readest-app/e2e/fixtures/create-review-epub.mjs
@@ -0,0 +1,31 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { zipSync, strToU8 } from 'fflate';
+
+const target = process.argv[2];
+if (!target) throw new Error('output path is required');
+
+const files = {
+ mimetype: strToU8('application/epub+zip'),
+ 'META-INF/container.xml': strToU8(`
+
+
+`),
+ 'OEBPS/content.opf': strToU8(`
+
+
+ urn:readest:review-e2e
+ Readest Review E2Ezh-CN
+
+
+
+`),
+ 'OEBPS/chapter.xhtml': strToU8(`
+
Chapter
+これは校正テスト用の原文です。
+这是网页审校测试译文。
+`),
+};
+
+fs.mkdirSync(path.dirname(target), { recursive: true });
+fs.writeFileSync(target, zipSync(files, { level: 0 }));
diff --git a/apps/readest-app/e2e/tests/review-mode.spec.ts b/apps/readest-app/e2e/tests/review-mode.spec.ts
new file mode 100644
index 00000000..7302d5f2
--- /dev/null
+++ b/apps/readest-app/e2e/tests/review-mode.spec.ts
@@ -0,0 +1,60 @@
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { expect, test } from '../fixtures/base';
+import { REVIEW_EPUB } from '../fixtures/books';
+
+const fixtureDir = path.dirname(fileURLToPath(import.meta.url));
+
+test.beforeAll(() => {
+ execFileSync(
+ process.execPath,
+ [path.join(fixtureDir, '../fixtures/create-review-epub.mjs'), REVIEW_EPUB],
+ { stdio: 'inherit' },
+ );
+});
+
+test.afterAll(() => {
+ fs.rmSync(REVIEW_EPUB, { force: true });
+});
+
+test.describe('Inline review mode', () => {
+ test('opens the local sidecar, saves an edit, and resumes the same session', async ({
+ openBook,
+ page,
+ }) => {
+ test.skip(process.env['READEST_REVIEW_E2E'] !== '1', 'requires the local Python sidecar');
+ const reader = await openBook(REVIEW_EPUB);
+ await reader.revealHeader();
+
+ const toggle = page.getByRole('button', { name: 'Review Mode' });
+ await expect(toggle).toBeVisible();
+ await toggle.click();
+
+ const panel = page.getByRole('group', { name: '审校' });
+ await expect(panel).toBeVisible({ timeout: 120_000 });
+ const editor = panel.locator('textarea').first();
+ await expect(editor).toHaveValue('这是网页审校测试译文。');
+ await editor.fill('这是已保存的网页审校测试译文。');
+ await panel.getByRole('button', { name: '保存', exact: true }).click();
+ await expect(panel.getByRole('status')).toContainText('已保存');
+
+ await panel.getByRole('button', { name: 'Close' }).click();
+ await expect(panel).toBeHidden();
+ await reader.revealHeader();
+ await page.getByRole('button', { name: 'Disable Review Mode' }).click();
+ await reader.revealHeader();
+ await page.getByRole('button', { name: 'Review Mode' }).click();
+
+ await expect(panel).toBeVisible({ timeout: 120_000 });
+ await expect(panel.locator('textarea').first()).toHaveValue(
+ '这是已保存的网页审校测试译文。',
+ );
+
+ const downloadPromise = page.waitForEvent('download');
+ await panel.getByRole('button', { name: '导出 EPUB' }).click();
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toMatch(/\.epub$/i);
+ });
+});
diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml
index f8702d11..e3f29c0e 100644
--- a/apps/readest-app/src-tauri/Cargo.toml
+++ b/apps/readest-app/src-tauri/Cargo.toml
@@ -112,9 +112,9 @@ sentry = { version = "0.42", default-features = false, features = [
"rustls",
] }
tauri-plugin-sentry = "0.5"
+rand = "0.8"
[target."cfg(target_os = \"macos\")".dependencies]
-rand = "0.8"
cocoa = "0.25"
objc = "0.2.7"
objc-foundation = "0.1.1"
diff --git a/apps/readest-app/src-tauri/src/review_editor.rs b/apps/readest-app/src-tauri/src/review_editor.rs
index b769cf2b..4305b178 100644
--- a/apps/readest-app/src-tauri/src/review_editor.rs
+++ b/apps/readest-app/src-tauri/src/review_editor.rs
@@ -1,8 +1,10 @@
+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},
@@ -24,6 +26,7 @@ pub struct ReviewEditorLaunchResponse {
review_root: String,
version: String,
session_id: Option,
+ access_token: String,
}
struct CommandOutput {
@@ -76,10 +79,12 @@ fn launch_epub_review_editor_sync(
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) {
- let session_id = ensure_session_from_path(&url, epub_path.as_deref())?;
+ 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,
@@ -87,6 +92,7 @@ fn launch_epub_review_editor_sync(
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
+ access_token,
});
}
@@ -111,13 +117,15 @@ fn launch_epub_review_editor_sync(
"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))
+ .or_else(|| find_running_editor(expected_version.as_deref(), &review_root, &access_token))
.ok_or_else(|| {
format!(
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
@@ -126,7 +134,7 @@ fn launch_epub_review_editor_sync(
})?;
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())?;
+ let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref(), &access_token)?;
Ok(ReviewEditorLaunchResponse {
ok: true,
@@ -135,9 +143,57 @@ fn launch_epub_review_editor_sync(
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 {
+ 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::();
+ 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 {
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
let path = PathBuf::from(raw);
@@ -292,12 +348,16 @@ fn read_expected_version(tool_root: &Path) -> Option {
Some(rest[..rest.find('"')?].to_string())
}
-fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> Option {
+fn find_running_editor(
+ expected_version: Option<&str>,
+ review_root: &Path,
+ access_token: &str,
+) -> Option {
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 {
+ let Some(payload) = request_json(port, "/api/version", access_token) else {
continue;
};
let Some(version) = payload.get("version").and_then(Value::as_str) else {
@@ -322,17 +382,19 @@ fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> Op
None
}
-fn request_json(port: u16, path: &str) -> Option {
+fn request_json(port: u16, path: &str, access_token: &str) -> Option {
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");
+ 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) -> Result {
+fn post_json(url: &str, path: &str, body: &str, access_token: &str) -> Result {
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))
@@ -340,7 +402,7 @@ fn post_json(url: &str, path: &str, body: &str) -> Result {
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{}",
+ "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
);
@@ -392,12 +454,16 @@ fn parse_loopback_port(url: &str) -> Result {
.map_err(|err| format!("无法解析审校器端口:{port_text} ({err})"))
}
-fn ensure_session_from_path(url: &str, epub_path: Option<&str>) -> Result