4 Commits

Author SHA1 Message Date
Codex a06b6183d7 Fix web dev review editor launch
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
2026-07-10 00:42:10 +08:00
an 43ce37c61b Merge pull request 'Codex/bilingual epub filter' (#1) from codex/bilingual-epub-filter into main
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Reviewed-on: #1
Reviewed-by: akai <1085932718@qq.com>
2026-07-09 13:30:40 +00:00
an 545c56966f Merge branch 'main' into codex/bilingual-epub-filter
Android E2E (CDP) / android-e2e (pull_request) Waiting to run
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
2026-07-09 13:30:13 +00:00
an 2d97de18c1 Merge pull request 'Readest native inline review mode' (#3) from codex/readest-inline-review-mode into main
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Publish Docker image / merge (push) Has been cancelled
Reviewed-on: #3
2026-07-09 13:29:24 +00:00
6 changed files with 131 additions and 38 deletions
@@ -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<LaunchResponse> {
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 {
@@ -169,16 +169,21 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
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],
@@ -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<ReviewModeTogglerProps> = ({ 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) {
@@ -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<T>(baseUrl: string, path: string, options: RequestInit = {}): Promise<T> {
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<T>(baseUrl: string, path: string, options: RequestInit
return data as T;
}
async function uploadSessionFromFile(baseUrl: string, file: File): Promise<SessionSummary> {
const formData = new FormData();
formData.set('epub', file, file.name || 'book.epub');
return sidecarApi<SessionSummary>(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<LaunchState>('idle');
const [payload, setPayload] = useState<LaunchResponse | null>(null);
const [activeMode, setActiveMode] = useState<LaunchMode>('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<SessionPayload>(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);
@@ -148,8 +148,9 @@ export async function sidecarApi<T>(
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<ReviewSessionSummary> {
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<ReviewEditorLaunchResponse>('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;
}
@@ -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: