Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a06b6183d7 |
@@ -1,4 +1,4 @@
|
|||||||
import { spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -46,6 +46,8 @@ const venvPython = () =>
|
|||||||
? path.join(venvRoot(), 'Scripts', 'python.exe')
|
? path.join(venvRoot(), 'Scripts', 'python.exe')
|
||||||
: path.join(venvRoot(), 'bin', 'python');
|
: path.join(venvRoot(), 'bin', 'python');
|
||||||
|
|
||||||
|
const venvPythonw = () => path.join(venvRoot(), 'Scripts', 'pythonw.exe');
|
||||||
|
|
||||||
const run = (
|
const run = (
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
@@ -138,6 +140,43 @@ const findRunningEditor = async () => {
|
|||||||
return '';
|
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 resolvePythonCommand = (): PythonCommand => {
|
||||||
const candidates: PythonCommand[] =
|
const candidates: PythonCommand[] =
|
||||||
process.platform === 'win32'
|
process.platform === 'win32'
|
||||||
@@ -253,30 +292,10 @@ async function launchEditor(): Promise<LaunchResponse> {
|
|||||||
ensureDependencies();
|
ensureDependencies();
|
||||||
fs.mkdirSync(reviewRoot(), { recursive: true });
|
fs.mkdirSync(reviewRoot(), { recursive: true });
|
||||||
|
|
||||||
const launch = run(
|
launchDetachedEditor(serverPath);
|
||||||
venvPython(),
|
const launchedUrl = normalizeLoopbackUrl(await waitForRunningEditor());
|
||||||
[
|
|
||||||
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()),
|
|
||||||
);
|
|
||||||
if (!launchedUrl) {
|
if (!launchedUrl) {
|
||||||
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
|
throw new Error('审校器已启动但未返回访问地址,请稍后重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -169,16 +169,21 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
|||||||
async (book: Book, mode: 'review' | 'translate') => {
|
async (book: Book, mode: 'review' | 'translate') => {
|
||||||
const available = await makeBookAvailable(book);
|
const available = await makeBookAvailable(book);
|
||||||
if (!available) return;
|
if (!available) return;
|
||||||
const epubPath = await appService?.resolveNativeBookFilePath(book);
|
const epubPath = appService?.isDesktopApp
|
||||||
if (!epubPath) {
|
? await appService.resolveNativeBookFilePath(book)
|
||||||
|
: null;
|
||||||
|
if (appService?.isDesktopApp && !epubPath) {
|
||||||
eventDispatcher.dispatch('toast', {
|
eventDispatcher.dispatch('toast', {
|
||||||
message: _('Book file is not available locally'),
|
message: _('Book file is not available locally'),
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sessionStorage.setItem('reviewEditorLaunchContext', JSON.stringify({ mode, epubPath }));
|
sessionStorage.setItem(
|
||||||
const params = new URLSearchParams({ mode });
|
'reviewEditorLaunchContext',
|
||||||
|
JSON.stringify(epubPath ? { mode, epubPath } : { mode, bookHash: book.hash }),
|
||||||
|
);
|
||||||
|
const params = new URLSearchParams(epubPath ? { mode } : { mode, book_hash: book.hash });
|
||||||
navigateToReviewEditor(router, params);
|
navigateToReviewEditor(router, params);
|
||||||
},
|
},
|
||||||
[_, appService, makeBookAvailable, router],
|
[_, appService, makeBookAvailable, router],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React from 'react';
|
|||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { useEnv } from '@/context/EnvContext';
|
import { useEnv } from '@/context/EnvContext';
|
||||||
import { useTranslation } from '@/hooks/useTranslation';
|
import { useTranslation } from '@/hooks/useTranslation';
|
||||||
|
import { isWebAppPlatform } from '@/services/environment';
|
||||||
import { useBookDataStore } from '@/store/bookDataStore';
|
import { useBookDataStore } from '@/store/bookDataStore';
|
||||||
import { useReaderStore } from '@/store/readerStore';
|
import { useReaderStore } from '@/store/readerStore';
|
||||||
import { useReviewModeStore } from '@/store/reviewModeStore';
|
import { useReviewModeStore } from '@/store/reviewModeStore';
|
||||||
@@ -31,8 +32,10 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
|
|||||||
|
|
||||||
const enabled = !!reviewState?.enabled;
|
const enabled = !!reviewState?.enabled;
|
||||||
const loading = !!reviewState?.loading;
|
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 () => {
|
const handleToggleReviewMode = async () => {
|
||||||
if (appService?.isMobile) {
|
if (appService?.isMobile) {
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ import {
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
import type { Dispatch, ReactNode, SetStateAction } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useEnv } from '@/context/EnvContext';
|
||||||
import { isTauriAppPlatform } from '@/services/environment';
|
import { isTauriAppPlatform } from '@/services/environment';
|
||||||
|
import { useLibraryStore } from '@/store/libraryStore';
|
||||||
import { openExternalUrl } from '@/utils/open';
|
import { openExternalUrl } from '@/utils/open';
|
||||||
|
|
||||||
type LaunchState = 'idle' | 'launching' | 'ready' | 'failed';
|
type LaunchState = 'idle' | 'launching' | 'ready' | 'failed';
|
||||||
@@ -39,6 +41,7 @@ type LaunchResponse = {
|
|||||||
type LaunchContext = {
|
type LaunchContext = {
|
||||||
mode?: LaunchMode;
|
mode?: LaunchMode;
|
||||||
epubPath?: string;
|
epubPath?: string;
|
||||||
|
bookHash?: string;
|
||||||
sessionId?: 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> {
|
async function sidecarApi<T>(baseUrl: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const url = new URL(path, baseUrl);
|
const url = new URL(path, baseUrl);
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
|
||||||
const headers =
|
const headers =
|
||||||
options.body === undefined
|
options.body === undefined || isFormData
|
||||||
? options.headers
|
? options.headers
|
||||||
: {
|
: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -366,6 +370,15 @@ async function sidecarApi<T>(baseUrl: string, path: string, options: RequestInit
|
|||||||
return data as T;
|
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) =>
|
const openSessionBody = (sessionId: string, activate = true) =>
|
||||||
JSON.stringify({ session_id: sessionId, activate });
|
JSON.stringify({ session_id: sessionId, activate });
|
||||||
|
|
||||||
@@ -384,6 +397,9 @@ function readLaunchContextFromBrowser(): LaunchContext {
|
|||||||
if (params.has('epub_path')) {
|
if (params.has('epub_path')) {
|
||||||
context.epubPath = params.get('epub_path') || undefined;
|
context.epubPath = params.get('epub_path') || undefined;
|
||||||
}
|
}
|
||||||
|
if (params.has('book_hash')) {
|
||||||
|
context.bookHash = params.get('book_hash') || undefined;
|
||||||
|
}
|
||||||
if (params.has('session_id')) {
|
if (params.has('session_id')) {
|
||||||
context.sessionId = params.get('session_id') || undefined;
|
context.sessionId = params.get('session_id') || undefined;
|
||||||
}
|
}
|
||||||
@@ -1563,6 +1579,7 @@ function EmptyFeatureState({
|
|||||||
|
|
||||||
export default function ReviewEditorLauncher() {
|
export default function ReviewEditorLauncher() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { appService } = useEnv();
|
||||||
const [state, setState] = useState<LaunchState>('idle');
|
const [state, setState] = useState<LaunchState>('idle');
|
||||||
const [payload, setPayload] = useState<LaunchResponse | null>(null);
|
const [payload, setPayload] = useState<LaunchResponse | null>(null);
|
||||||
const [activeMode, setActiveMode] = useState<LaunchMode>('review');
|
const [activeMode, setActiveMode] = useState<LaunchMode>('review');
|
||||||
@@ -1584,6 +1601,23 @@ export default function ReviewEditorLauncher() {
|
|||||||
[activeMode, payload?.url, router],
|
[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 () => {
|
const launch = useCallback(async () => {
|
||||||
setState('launching');
|
setState('launching');
|
||||||
setPayload(null);
|
setPayload(null);
|
||||||
@@ -1616,6 +1650,10 @@ export default function ReviewEditorLauncher() {
|
|||||||
);
|
);
|
||||||
nextSessionId = createdSession.id || null;
|
nextSessionId = createdSession.id || null;
|
||||||
}
|
}
|
||||||
|
if (!nextSessionId && context.bookHash) {
|
||||||
|
const createdSession = await createSessionFromBookHash(data.url, context.bookHash);
|
||||||
|
nextSessionId = createdSession.id || null;
|
||||||
|
}
|
||||||
if (!nextSessionId) {
|
if (!nextSessionId) {
|
||||||
const activeSession = await sidecarApi<SessionPayload>(data.url, '/api/session');
|
const activeSession = await sidecarApi<SessionPayload>(data.url, '/api/session');
|
||||||
nextSessionId = activeSession.has_session ? activeSession.id || null : null;
|
nextSessionId = activeSession.has_session ? activeSession.id || null : null;
|
||||||
@@ -1638,13 +1676,15 @@ export default function ReviewEditorLauncher() {
|
|||||||
});
|
});
|
||||||
setState('failed');
|
setState('failed');
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [createSessionFromBookHash, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (didAutoLaunchRef.current) return;
|
if (didAutoLaunchRef.current) return;
|
||||||
|
const context = readLaunchContextFromBrowser();
|
||||||
|
if (context.bookHash && !appService) return;
|
||||||
didAutoLaunchRef.current = true;
|
didAutoLaunchRef.current = true;
|
||||||
void launch();
|
void launch();
|
||||||
}, [launch]);
|
}, [appService, launch]);
|
||||||
|
|
||||||
const switchMode = (mode: LaunchMode) => {
|
const switchMode = (mode: LaunchMode) => {
|
||||||
setActiveMode(mode);
|
setActiveMode(mode);
|
||||||
|
|||||||
@@ -148,8 +148,9 @@ export async function sidecarApi<T>(
|
|||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
url.searchParams.set('session_id', sessionId);
|
url.searchParams.set('session_id', sessionId);
|
||||||
}
|
}
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
|
||||||
const headers =
|
const headers =
|
||||||
options.body === undefined
|
options.body === undefined || isFormData
|
||||||
? options.headers
|
? options.headers
|
||||||
: {
|
: {
|
||||||
'Content-Type': 'application/json',
|
'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(
|
export async function openReviewSession(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -202,10 +220,12 @@ export async function launchInlineReviewEditor(
|
|||||||
if (!appService) throw new Error('Readest 服务尚未初始化');
|
if (!appService) throw new Error('Readest 服务尚未初始化');
|
||||||
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
|
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
|
||||||
|
|
||||||
const epubPath = await appService.resolveNativeBookFilePath(book);
|
const epubPath = isTauriAppPlatform() ? await appService.resolveNativeBookFilePath(book) : null;
|
||||||
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
|
if (isTauriAppPlatform() && !epubPath) {
|
||||||
|
throw new Error('无法定位当前 EPUB 的本机文件路径');
|
||||||
|
}
|
||||||
|
|
||||||
const launch = isTauriAppPlatform()
|
const launch = epubPath
|
||||||
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
|
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
|
||||||
epubPath,
|
epubPath,
|
||||||
})
|
})
|
||||||
@@ -222,7 +242,9 @@ export async function launchInlineReviewEditor(
|
|||||||
|
|
||||||
let sessionId = launch.sessionId || null;
|
let sessionId = launch.sessionId || null;
|
||||||
if (!sessionId) {
|
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;
|
sessionId = createdSession.id || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4078,7 +4078,11 @@ def run_daemon(args: argparse.Namespace) -> int:
|
|||||||
creationflags = 0
|
creationflags = 0
|
||||||
popen_kwargs: dict[str, Any] = {}
|
popen_kwargs: dict[str, Any] = {}
|
||||||
if os.name == "nt":
|
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:
|
else:
|
||||||
popen_kwargs["start_new_session"] = True
|
popen_kwargs["start_new_session"] = True
|
||||||
with log_path.open("a", encoding="utf-8") as log:
|
with log_path.open("a", encoding="utf-8") as log:
|
||||||
|
|||||||
Reference in New Issue
Block a user