From da756eed471394165adf4e5062f4b2e88bdad98d Mon Sep 17 00:00:00 2001 From: akai Date: Fri, 10 Jul 2026 12:37:13 +0800 Subject: [PATCH] feat: secure inline review sidecar --- apps/readest-app/e2e/fixtures/books.ts | 2 + .../e2e/fixtures/create-review-epub.mjs | 31 +++++ .../readest-app/e2e/tests/review-mode.spec.ts | 60 ++++++++++ apps/readest-app/src-tauri/Cargo.toml | 2 +- .../src-tauri/src/review_editor.rs | 108 +++++++++++++++--- .../components/ReviewModeToggler.test.tsx | 71 ++++++++++++ .../services/reviewEditorService.test.ts | 39 ++++++- .../src/app/api/review-editor/launch/route.ts | 44 ++++++- .../reader/components/ReviewModeToggler.tsx | 7 +- .../src/app/reader/components/ReviewPanel.tsx | 17 +-- .../src/services/reviewEditorService.ts | 80 +++++++++---- apps/readest-app/src/store/reviewModeStore.ts | 2 + .../tools/epub-review-editor/MAINTENANCE.md | 9 +- .../tools/epub-review-editor/README.md | 9 +- .../tools/epub-review-editor/server.py | 72 ++++++++++-- .../test_inline_review_session_scope.py | 87 ++++++++++++-- .../tools/epub-review-editor/version.py | 2 +- 17 files changed, 556 insertions(+), 86 deletions(-) create mode 100644 apps/readest-app/e2e/fixtures/create-review-epub.mjs create mode 100644 apps/readest-app/e2e/tests/review-mode.spec.ts create mode 100644 apps/readest-app/src/__tests__/components/ReviewModeToggler.test.tsx 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, String> { +fn ensure_session_from_path( + url: &str, + epub_path: Option<&str>, + access_token: &str, +) -> Result, 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)?; + let payload = post_json(url, "/api/session/from-path", &body, access_token)?; Ok(payload .get("id") .and_then(Value::as_str) @@ -416,13 +482,29 @@ fn run_command( Err(format!( "命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}", program, - args.join(" "), + redact_command_args(args).join(" "), output.stdout, output.stderr )) } } +fn redact_command_args(args: &[String]) -> Vec { + 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], diff --git a/apps/readest-app/src/__tests__/components/ReviewModeToggler.test.tsx b/apps/readest-app/src/__tests__/components/ReviewModeToggler.test.tsx new file mode 100644 index 00000000..c0cc317b --- /dev/null +++ b/apps/readest-app/src/__tests__/components/ReviewModeToggler.test.tsx @@ -0,0 +1,71 @@ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const h = vi.hoisted(() => ({ + ask: vi.fn(), + setBookEnabled: vi.fn(), + reviewStore: { + books: { + 'book-a': { enabled: true, loading: false, hasUnsavedEdit: true }, + }, + setActiveBookKey: vi.fn(), + setPanelVisible: vi.fn(), + setBookLoading: vi.fn(), + setBookError: vi.fn(), + setBookEnabled: vi.fn(), + setBookData: vi.fn(), + }, +})); + +vi.mock('@/context/EnvContext', () => ({ + useEnv: () => ({ appService: { isDesktopApp: true, isMobile: false, ask: h.ask } }), +})); +vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (value: string) => value })); +vi.mock('@/store/bookDataStore', () => ({ + useBookDataStore: (selector: (state: { getBookData: () => unknown }) => unknown) => + selector({ getBookData: () => ({ book: { format: 'EPUB' } }) }), +})); +vi.mock('@/store/readerStore', () => ({ + useReaderStore: () => ({ setHoveredBookKey: vi.fn() }), +})); +vi.mock('@/store/reviewModeStore', () => { + const useReviewModeStore = (selector?: (state: typeof h.reviewStore) => unknown) => + selector ? selector(h.reviewStore) : h.reviewStore; + Object.assign(useReviewModeStore, { getState: () => h.reviewStore }); + return { useReviewModeStore }; +}); +vi.mock('@/services/reviewEditorService', () => ({ + canLaunchInlineReviewEditor: () => true, + launchInlineReviewEditor: vi.fn(), + loadInlineReviewData: vi.fn(), +})); +vi.mock('@/utils/event', () => ({ eventDispatcher: { dispatch: vi.fn() } })); + +import ReviewModeToggler from '@/app/reader/components/ReviewModeToggler'; + +describe('ReviewModeToggler unsaved edit guard', () => { + beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + h.reviewStore.setBookEnabled = h.setBookEnabled; + }); + + test('keeps review mode enabled when discarding an unsaved edit is cancelled', async () => { + h.ask.mockResolvedValue(false); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' })); + + await waitFor(() => expect(h.ask).toHaveBeenCalledOnce()); + expect(h.setBookEnabled).not.toHaveBeenCalled(); + }); + + test('disables review mode after confirming the unsaved edit warning', async () => { + h.ask.mockResolvedValue(true); + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' })); + + await waitFor(() => expect(h.setBookEnabled).toHaveBeenCalledWith('book-a', false)); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts b/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts index 5ee74670..45b35c9e 100644 --- a/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts +++ b/apps/readest-app/src/__tests__/services/reviewEditorService.test.ts @@ -75,7 +75,11 @@ describe('reviewEditorService', () => { const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => { const url = input.toString(); if (url === '/api/review-editor/launch') { - return Response.json({ ok: true, url: 'http://127.0.0.1:5177' }); + return Response.json({ + ok: true, + url: 'http://127.0.0.1:5177', + accessToken: 'sidecar-token', + }); } return Response.json({ id: 'session-web' }); }); @@ -99,6 +103,9 @@ describe('reviewEditorService', () => { | undefined; expect(uploadCall?.[0]).toBe('http://127.0.0.1:5177/api/upload'); expect(uploadCall?.[1]?.body).toBeInstanceOf(FormData); + expect(uploadCall?.[1]?.headers).toMatchObject({ + Authorization: 'Bearer sidecar-token', + }); expect((uploadCall?.[1]?.body as FormData).get('activate')).toBe('false'); expect((uploadCall?.[1]?.body as FormData).get('book_key')).toBe('book-hash'); }); @@ -110,11 +117,25 @@ describe('reviewEditorService', () => { vi.spyOn(document, 'createElement').mockReturnValue(anchor as never); vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node); + const fetchMock = vi.fn(async () => + new Response(new Blob(['epub-bytes'], { type: 'application/epub+zip' })), + ); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:reviewed-epub'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined); + await downloadReviewedEpub( 'http://127.0.0.1:5177/downloads/export/reviewed.epub?session_id=session-a', + 'sidecar-token', ); - expect(anchor.href).toContain('/downloads/export/reviewed.epub'); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/downloads/export/reviewed.epub'), + expect.objectContaining({ + headers: { Authorization: 'Bearer sidecar-token' }, + }), + ); + expect(anchor.href).toBe('blob:reviewed-epub'); expect(anchor.download).toBe('reviewed.epub'); expect(click).toHaveBeenCalledOnce(); expect(remove).toHaveBeenCalledOnce(); @@ -173,11 +194,23 @@ describe('reviewEditorService', () => { test('adds session_id to sidecar API calls without mutating endpoint paths', async () => { const fetchMock = stubJsonFetch(); - await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a'); + await sidecarApi( + 'http://127.0.0.1:5177', + '/api/rows', + {}, + 'session-a', + 'sidecar-token', + ); const url = new URL(firstFetchUrl(fetchMock)); expect(url.pathname).toBe('/api/rows'); expect(url.searchParams.get('session_id')).toBe('session-a'); + const firstCall = fetchMock.mock.calls[0] as + | [string | URL | Request, RequestInit | undefined] + | undefined; + expect(firstCall?.[1]?.headers).toMatchObject({ + Authorization: 'Bearer sidecar-token', + }); }); test('row operations are scoped to the current review session', async () => { diff --git a/apps/readest-app/src/app/api/review-editor/launch/route.ts b/apps/readest-app/src/app/api/review-editor/launch/route.ts index 1fcbb906..9d4df31a 100644 --- a/apps/readest-app/src/app/api/review-editor/launch/route.ts +++ b/apps/readest-app/src/app/api/review-editor/launch/route.ts @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; @@ -27,6 +28,7 @@ type LaunchResponse = { reused?: boolean; reviewRoot?: string; version?: string; + accessToken?: string; error?: string; }; @@ -39,6 +41,28 @@ const appRoot = () => process.cwd(); const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor'); const reviewRoot = () => path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions')); +const accessTokenPath = () => path.join(reviewRoot(), '.access-token'); + +const ensureAccessToken = () => { + fs.mkdirSync(reviewRoot(), { recursive: true }); + try { + const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim(); + if (existing) return existing; + } catch { + // Create the token below. + } + const token = crypto.randomBytes(32).toString('hex'); + try { + fs.writeFileSync(accessTokenPath(), token, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); + return token; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim(); + if (existing) return existing; + } + throw error; + } +}; const venvRoot = () => path.join(toolRoot(), '.venv'); const venvPython = () => @@ -91,7 +115,11 @@ const isLoopbackHost = (host: string | null) => { const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:'); -const requestJson = (port: number, pathname: string): Promise | null> => +const requestJson = ( + port: number, + pathname: string, + accessToken: string, +): Promise | null> => new Promise((resolve) => { const req = http.get( { @@ -99,6 +127,7 @@ const requestJson = (port: number, pathname: string): Promise { let body = ''; @@ -126,11 +155,11 @@ const requestJson = (port: number, pathname: string): Promise resolve(null)); }); -const findRunningEditor = async () => { +const findRunningEditor = async (accessToken: string) => { const expectedVersion = readExpectedVersion(); const expectedReviewRoot = path.resolve(reviewRoot()); for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) { - const payload = await requestJson(port, '/api/version'); + const payload = await requestJson(port, '/api/version', accessToken); if (!payload) continue; const runningReviewRoot = typeof payload['review_root'] === 'string' ? path.resolve(payload['review_root']) : ''; @@ -243,7 +272,8 @@ async function launchEditor(): Promise { }; } - const runningUrl = await findRunningEditor(); + const accessToken = ensureAccessToken(); + const runningUrl = await findRunningEditor(accessToken); if (runningUrl) { return { ok: true, @@ -251,6 +281,7 @@ async function launchEditor(): Promise { reused: true, reviewRoot: reviewRoot(), version: readExpectedVersion(), + accessToken, }; } @@ -269,6 +300,8 @@ async function launchEditor(): Promise { '127.0.0.1', '--port', String(DEFAULT_PORT), + '--access-token', + accessToken, '--daemon', ], { cwd: bundledToolRoot, timeout: 45_000 }, @@ -278,7 +311,7 @@ async function launchEditor(): Promise { } const launchedUrl = normalizeLoopbackUrl( - /https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()), + /https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor(accessToken)), ); if (!launchedUrl) { throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`); @@ -290,6 +323,7 @@ async function launchEditor(): Promise { reused: false, reviewRoot: reviewRoot(), version: readExpectedVersion(), + accessToken, }; } catch (error) { return { diff --git a/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx index 7bea2bdc..4245f88c 100644 --- a/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx +++ b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx @@ -80,9 +80,14 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => { try { const launch = await launchInlineReviewEditor(appService, bookData.book); - const data = await loadInlineReviewData(launch.url, launch.sessionId); + const data = await loadInlineReviewData( + launch.url, + launch.sessionId, + launch.accessToken, + ); setBookData(bookKey, { baseUrl: launch.url, + accessToken: launch.accessToken, sessionId: launch.sessionId, reviewRoot: launch.reviewRoot, version: launch.version, diff --git a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx index d8426f50..de97afc2 100644 --- a/apps/readest-app/src/app/reader/components/ReviewPanel.tsx +++ b/apps/readest-app/src/app/reader/components/ReviewPanel.tsx @@ -25,7 +25,6 @@ import { useReaderStore } from '@/store/readerStore'; import { useReviewModeStore } from '@/store/reviewModeStore'; import { useThemeStore } from '@/store/themeStore'; import { getPanelTopInset } from '@/utils/insets'; -import { openExternalUrl } from '@/utils/open'; import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag'; import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize'; import { @@ -762,8 +761,8 @@ const ReviewPanel: React.FC = () => { ? new URL(payload.download_url, baseUrl).toString() : ''; setExportInfo(downloadUrl || payload.output || ''); - if (downloadUrl && !appService?.isDesktopApp) { - await downloadReviewedEpub(downloadUrl); + if (downloadUrl) { + await downloadReviewedEpub(downloadUrl, bookState.accessToken); } setMessage(`已导出:${payload.output || downloadUrl}`); } catch (error) { @@ -983,17 +982,9 @@ const ReviewPanel: React.FC = () => {