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 b6c37d23..c0fb8410 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,4 @@ -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; @@ -46,6 +46,8 @@ const venvPython = () => ? path.join(venvRoot(), 'Scripts', 'python.exe') : path.join(venvRoot(), 'bin', 'python'); +const venvPythonw = () => path.join(venvRoot(), 'Scripts', 'pythonw.exe'); + const run = ( command: string, args: string[], @@ -138,6 +140,43 @@ const findRunningEditor = async () => { return ''; }; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const waitForRunningEditor = async (timeoutMs = 45_000) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const url = await findRunningEditor(); + if (url) return url; + await sleep(500); + } + return ''; +}; + +const launchDetachedEditor = (serverPath: string) => { + const command = + process.platform === 'win32' && fs.existsSync(venvPythonw()) ? venvPythonw() : venvPython(); + const child = spawn( + command, + [ + serverPath, + '--review-root', + reviewRoot(), + '--host', + '127.0.0.1', + '--port', + String(DEFAULT_PORT), + '--no-browser', + ], + { + cwd: toolRoot(), + detached: true, + stdio: 'ignore', + windowsHide: true, + }, + ); + child.unref(); +}; + const resolvePythonCommand = (): PythonCommand => { const candidates: PythonCommand[] = process.platform === 'win32' @@ -253,30 +292,10 @@ async function launchEditor(): Promise { ensureDependencies(); fs.mkdirSync(reviewRoot(), { recursive: true }); - const launch = run( - venvPython(), - [ - serverPath, - '--review-root', - reviewRoot(), - '--host', - '127.0.0.1', - '--port', - String(DEFAULT_PORT), - '--daemon', - '--no-browser', - ], - { cwd: bundledToolRoot, timeout: 45_000 }, - ); - if (!launch.ok) { - throw new Error(`启动审校器失败:${launch.stderr || launch.error || launch.stdout}`); - } - - const launchedUrl = normalizeLoopbackUrl( - /https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()), - ); + launchDetachedEditor(serverPath); + const launchedUrl = normalizeLoopbackUrl(await waitForRunningEditor()); if (!launchedUrl) { - throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`); + throw new Error('审校器已启动但未返回访问地址,请稍后重试'); } return { diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 4f2f652b..7281dd79 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -169,16 +169,21 @@ const BookshelfItem: React.FC = ({ async (book: Book, mode: 'review' | 'translate') => { const available = await makeBookAvailable(book); if (!available) return; - const epubPath = await appService?.resolveNativeBookFilePath(book); - if (!epubPath) { + const epubPath = appService?.isDesktopApp + ? await appService.resolveNativeBookFilePath(book) + : null; + if (appService?.isDesktopApp && !epubPath) { eventDispatcher.dispatch('toast', { message: _('Book file is not available locally'), type: 'warning', }); return; } - sessionStorage.setItem('reviewEditorLaunchContext', JSON.stringify({ mode, epubPath })); - const params = new URLSearchParams({ mode }); + sessionStorage.setItem( + 'reviewEditorLaunchContext', + JSON.stringify(epubPath ? { mode, epubPath } : { mode, bookHash: book.hash }), + ); + const params = new URLSearchParams(epubPath ? { mode } : { mode, book_hash: book.hash }); navigateToReviewEditor(router, params); }, [_, appService, makeBookAvailable, router], diff --git a/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx index 5dc594fc..858ac66e 100644 --- a/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx +++ b/apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx @@ -4,6 +4,7 @@ import React from 'react'; import Button from '@/components/Button'; import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; +import { isWebAppPlatform } from '@/services/environment'; import { useBookDataStore } from '@/store/bookDataStore'; import { useReaderStore } from '@/store/readerStore'; import { useReviewModeStore } from '@/store/reviewModeStore'; @@ -31,8 +32,10 @@ const ReviewModeToggler: React.FC = ({ bookKey }) => { const enabled = !!reviewState?.enabled; const loading = !!reviewState?.loading; + const canLaunchReviewEditor = + !!appService?.isDesktopApp || (isWebAppPlatform() && process.env['NODE_ENV'] === 'development'); - if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null; + if (!canLaunchReviewEditor || bookData?.book?.format !== 'EPUB') return null; const handleToggleReviewMode = async () => { if (appService?.isMobile) { diff --git a/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx b/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx index e440b0c5..0b32d249 100644 --- a/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx +++ b/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx @@ -20,7 +20,9 @@ import { import { useRouter } from 'next/navigation'; import type { Dispatch, ReactNode, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEnv } from '@/context/EnvContext'; import { isTauriAppPlatform } from '@/services/environment'; +import { useLibraryStore } from '@/store/libraryStore'; import { openExternalUrl } from '@/utils/open'; type LaunchState = 'idle' | 'launching' | 'ready' | 'failed'; @@ -39,6 +41,7 @@ type LaunchResponse = { type LaunchContext = { mode?: LaunchMode; epubPath?: string; + bookHash?: string; sessionId?: string; }; @@ -348,8 +351,9 @@ const getSessionTitle = (session: SessionSummary | SessionPayload | null) => async function sidecarApi(baseUrl: string, path: string, options: RequestInit = {}): Promise { const url = new URL(path, baseUrl); + const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData; const headers = - options.body === undefined + options.body === undefined || isFormData ? options.headers : { 'Content-Type': 'application/json', @@ -366,6 +370,15 @@ async function sidecarApi(baseUrl: string, path: string, options: RequestInit return data as T; } +async function uploadSessionFromFile(baseUrl: string, file: File): Promise { + const formData = new FormData(); + formData.set('epub', file, file.name || 'book.epub'); + return sidecarApi(baseUrl, '/api/upload', { + method: 'POST', + body: formData, + }); +} + const openSessionBody = (sessionId: string, activate = true) => JSON.stringify({ session_id: sessionId, activate }); @@ -384,6 +397,9 @@ function readLaunchContextFromBrowser(): LaunchContext { if (params.has('epub_path')) { context.epubPath = params.get('epub_path') || undefined; } + if (params.has('book_hash')) { + context.bookHash = params.get('book_hash') || undefined; + } if (params.has('session_id')) { context.sessionId = params.get('session_id') || undefined; } @@ -1563,6 +1579,7 @@ function EmptyFeatureState({ export default function ReviewEditorLauncher() { const router = useRouter(); + const { appService } = useEnv(); const [state, setState] = useState('idle'); const [payload, setPayload] = useState(null); const [activeMode, setActiveMode] = useState('review'); @@ -1584,6 +1601,23 @@ export default function ReviewEditorLauncher() { [activeMode, payload?.url, router], ); + const createSessionFromBookHash = useCallback( + async (baseUrl: string, bookHash: string) => { + if (!appService) throw new Error('Readest 服务尚未初始化'); + let book = useLibraryStore.getState().getBookByHash(bookHash); + if (!book) { + const library = await appService.loadLibraryBooks(); + useLibraryStore.getState().setLibrary(library); + book = library.find((item) => item.hash === bookHash); + } + if (!book) throw new Error('无法在书库中找到当前 EPUB'); + if (book.format !== 'EPUB') throw new Error('审校器目前只支持 EPUB 书籍'); + const { file } = await appService.loadBookContent(book); + return uploadSessionFromFile(baseUrl, file); + }, + [appService], + ); + const launch = useCallback(async () => { setState('launching'); setPayload(null); @@ -1616,6 +1650,10 @@ export default function ReviewEditorLauncher() { ); nextSessionId = createdSession.id || null; } + if (!nextSessionId && context.bookHash) { + const createdSession = await createSessionFromBookHash(data.url, context.bookHash); + nextSessionId = createdSession.id || null; + } if (!nextSessionId) { const activeSession = await sidecarApi(data.url, '/api/session'); nextSessionId = activeSession.has_session ? activeSession.id || null : null; @@ -1638,13 +1676,15 @@ export default function ReviewEditorLauncher() { }); setState('failed'); } - }, [router]); + }, [createSessionFromBookHash, router]); useEffect(() => { if (didAutoLaunchRef.current) return; + const context = readLaunchContextFromBrowser(); + if (context.bookHash && !appService) return; didAutoLaunchRef.current = true; void launch(); - }, [launch]); + }, [appService, launch]); const switchMode = (mode: LaunchMode) => { setActiveMode(mode); diff --git a/apps/readest-app/src/services/reviewEditorService.ts b/apps/readest-app/src/services/reviewEditorService.ts index ff2047b9..d19396a3 100644 --- a/apps/readest-app/src/services/reviewEditorService.ts +++ b/apps/readest-app/src/services/reviewEditorService.ts @@ -148,8 +148,9 @@ export async function sidecarApi( if (sessionId) { url.searchParams.set('session_id', sessionId); } + const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData; const headers = - options.body === undefined + options.body === undefined || isFormData ? options.headers : { 'Content-Type': 'application/json', @@ -181,6 +182,23 @@ export async function createReviewSessionFromPath( }); } +export async function uploadReviewSession( + baseUrl: string, + file: File, + options: { reset?: boolean } = {}, +): Promise { + const formData = new FormData(); + formData.set('epub', file, file.name || 'book.epub'); + if (options.reset) { + formData.set('reset', '1'); + } + + return sidecarApi(baseUrl, '/api/upload', { + method: 'POST', + body: formData, + }); +} + export async function openReviewSession( baseUrl: string, sessionId: string, @@ -202,10 +220,12 @@ export async function launchInlineReviewEditor( if (!appService) throw new Error('Readest 服务尚未初始化'); if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍'); - const epubPath = await appService.resolveNativeBookFilePath(book); - if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径'); + const epubPath = isTauriAppPlatform() ? await appService.resolveNativeBookFilePath(book) : null; + if (isTauriAppPlatform() && !epubPath) { + throw new Error('无法定位当前 EPUB 的本机文件路径'); + } - const launch = isTauriAppPlatform() + const launch = epubPath ? await invoke('launch_epub_review_editor', { epubPath, }) @@ -222,7 +242,9 @@ export async function launchInlineReviewEditor( let sessionId = launch.sessionId || null; if (!sessionId) { - const createdSession = await createReviewSessionFromPath(launch.url, epubPath); + const createdSession = epubPath + ? await createReviewSessionFromPath(launch.url, epubPath) + : await uploadReviewSession(launch.url, (await appService.loadBookContent(book)).file); sessionId = createdSession.id || null; } diff --git a/apps/readest-app/tools/epub-review-editor/server.py b/apps/readest-app/tools/epub-review-editor/server.py index 91de7cdc..09391e51 100644 --- a/apps/readest-app/tools/epub-review-editor/server.py +++ b/apps/readest-app/tools/epub-review-editor/server.py @@ -4078,7 +4078,11 @@ def run_daemon(args: argparse.Namespace) -> int: creationflags = 0 popen_kwargs: dict[str, Any] = {} if os.name == "nt": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS + creationflags = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.DETACHED_PROCESS + | subprocess.CREATE_NO_WINDOW + ) else: popen_kwargs["start_new_session"] = True with log_path.open("a", encoding="utf-8") as log: