forked from akai/readest
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a06b6183d7 | |||
| 43ce37c61b | |||
| 545c56966f | |||
| 2d97de18c1 |
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user