From 27c48cba965300c2c4b2683e5f140a4f11e54f01 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Tue, 8 Sep 2026 00:15:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/Export开发说明.md | 2 + frontend/src-tauri/Cargo.lock | 1 + frontend/src-tauri/Cargo.toml | 3 +- frontend/src-tauri/src/main.rs | 49 ++++++++++++++++++- .../src/services/apiClient.desktop.spec.ts | 38 ++++++++++++++ frontend/src/services/apiClient.ts | 25 +++++++++- 6 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 frontend/src/services/apiClient.desktop.spec.ts diff --git a/docs/development/Export开发说明.md b/docs/development/Export开发说明.md index b83baf7..7712cb6 100644 --- a/docs/development/Export开发说明.md +++ b/docs/development/Export开发说明.md @@ -6,6 +6,8 @@ Export Service 把笔记或未保存的 Markdown 文本渲染为可下载的 HTML 文件。采用与 Benchmark 一致的「创建即返回 queued、后台 asyncio.Task 执行」的内存模型,产物带 24h 过期时间,过期后不可下载。导出是轮询式(无 SSE 事件流),客户端通过 `GET /api/exports/{job_id}` 轮询状态,完成后走 `GET /api/exports/{job_id}/file` 下载。 +桌面端的 `core_request` 保持浏览器 Fetch 语义:JSON 响应以 UTF-8 文本传递,HTML、PDF、DOCX 等下载响应以 Base64 穿过 Tauri IPC,并在前端还原为带正确 `Content-Type` 的 `Response`。Host 将单次响应限制为 64 MiB,防止异常产物耗尽 WebView 内存。 + ## 模块布局 ```text diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 0ce041d..054415f 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -2298,6 +2298,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" name = "notesagent-desktop" version = "0.3.0-alpha.1" dependencies = [ + "base64 0.22.1", "fs2", "reqwest 0.12.28", "rfd", diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 3975553..ed27245 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -14,7 +14,7 @@ required-features = ["desktop"] [features] default = [] -desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest"] +desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:base64"] [dependencies] serde = { version = "1", features = ["derive"] } @@ -27,6 +27,7 @@ fs2 = "0.4" tauri = { version = "2", optional = true, features = ["tray-icon"] } rfd = { version = "0.15", optional = true } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true } +base64 = { version = "0.22", optional = true } [build-dependencies] tauri-build = { version = "2", optional = true , features = [] } diff --git a/frontend/src-tauri/src/main.rs b/frontend/src-tauri/src/main.rs index 77dfaa3..d087c2d 100644 --- a/frontend/src-tauri/src/main.rs +++ b/frontend/src-tauri/src/main.rs @@ -2,6 +2,7 @@ //! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。 +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; use notesagent_host::recent::{RecentVault, RecentVaultStore}; use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace}; use std::path::Path; @@ -47,6 +48,21 @@ struct CoreResponse { status: u16, content_type: String, body: String, + #[serde(skip_serializing_if = "Option::is_none")] + body_base64: Option, +} + +// 后端常规导出上限为 20 MiB;为 PDF 和未来的二进制接口保留余量,同时限制 IPC 内存占用。 +const MAX_CORE_RESPONSE_BYTES: usize = 64 * 1024 * 1024; + +fn is_json_content_type(content_type: &str) -> bool { + let media_type = content_type + .split(';') + .next() + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + media_type == "application/json" || media_type.ends_with("+json") } fn core_url(path: &str) -> Result { @@ -61,7 +77,7 @@ fn core_url(path: &str) -> Result { #[cfg(test)] mod core_proxy_tests { - use super::core_url; + use super::{core_url, is_json_content_type}; #[test] fn only_allows_expected_loopback_paths() { @@ -79,6 +95,17 @@ mod core_proxy_tests { assert!(csp.contains("'wasm-unsafe-eval'")); assert!(!csp.split_whitespace().any(|token| token == "'unsafe-eval'")); } + + #[test] + fn only_json_media_types_use_text_ipc_payloads() { + assert!(is_json_content_type("application/json; charset=utf-8")); + assert!(is_json_content_type("application/problem+json")); + assert!(!is_json_content_type("text/html; charset=utf-8")); + assert!(!is_json_content_type("application/pdf")); + assert!(!is_json_content_type( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + )); + } } /// 预览版只代理固定回环地址,避免 WebView CORS 与任意地址转发。 @@ -120,11 +147,29 @@ async fn core_request( .and_then(|value| value.to_str().ok()) .unwrap_or("") .to_owned(); - let body = response.text().await.map_err(|_| "CORE_RESPONSE_ERROR")?; + if response + .content_length() + .is_some_and(|length| length > MAX_CORE_RESPONSE_BYTES as u64) + { + return Err("CORE_RESPONSE_TOO_LARGE".into()); + } + let bytes = response.bytes().await.map_err(|_| "CORE_RESPONSE_ERROR")?; + if bytes.len() > MAX_CORE_RESPONSE_BYTES { + return Err("CORE_RESPONSE_TOO_LARGE".into()); + } + let (body, body_base64) = if is_json_content_type(&content_type) { + ( + String::from_utf8(bytes.to_vec()).map_err(|_| "CORE_RESPONSE_ERROR")?, + None, + ) + } else { + (String::new(), Some(BASE64_STANDARD.encode(&bytes))) + }; Ok(CoreResponse { status, content_type, body, + body_base64, }) } diff --git a/frontend/src/services/apiClient.desktop.spec.ts b/frontend/src/services/apiClient.desktop.spec.ts new file mode 100644 index 0000000..36ec5cb --- /dev/null +++ b/frontend/src/services/apiClient.desktop.spec.ts @@ -0,0 +1,38 @@ +// @vitest-environment happy-dom +import { beforeEach, expect, it, vi } from 'vitest' + +const { hostInvoke } = vi.hoisted(() => ({ hostInvoke: vi.fn() })) +vi.mock('./platform/desktop', () => ({ isDesktop: () => true, hostInvoke })) + +import apiClient from './apiClient' + +beforeEach(() => hostInvoke.mockReset()) + +it('restores binary desktop responses as browser-compatible response objects', async () => { + hostInvoke.mockResolvedValue({ + status: 200, + content_type: 'application/pdf', + body: '', + body_base64: 'JVBERi0xLjc=', + }) + + const response = await apiClient.get('/api/exports/job/file') + + expect(response).toBeInstanceOf(Response) + expect(response.headers.get('content-type')).toBe('application/pdf') + expect([...new Uint8Array(await response.arrayBuffer())]).toEqual([37, 80, 68, 70, 45, 49, 46, 55]) +}) + +it('keeps downloadable text responses wrapped in a response object', async () => { + hostInvoke.mockResolvedValue({ + status: 200, + content_type: 'text/html; charset=utf-8', + body: '', + body_base64: 'PGgxPuWvvOWHujwvaDE+', + }) + + const response = await apiClient.get('/api/exports/job/file') + + expect(response).toBeInstanceOf(Response) + expect(await response.text()).toBe('

导出

') +}) diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index 7d97f61..f91ac00 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -4,7 +4,26 @@ import { hostInvoke, isDesktop } from './platform/desktop' // 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。 const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? (isDesktop() ? 'http://127.0.0.1:8000' : '') -interface DesktopCoreResponse { status: number; content_type: string; body: string } +interface DesktopCoreResponse { + status: number + content_type: string + body: string + body_base64?: string +} + +function responseFromDesktopCore(response: DesktopCoreResponse): Response { + let body: BodyInit = response.body + if (response.body_base64 !== undefined) { + const binary = atob(response.body_base64) + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + body = bytes.buffer + } + return new Response(body, { + status: response.status, + headers: response.content_type ? { 'Content-Type': response.content_type } : undefined, + }) +} export function resolveApiUrl(path: string): string { if (/^https?:\/\//i.test(path)) return path @@ -72,7 +91,9 @@ async function request(path: string, options: RequestOptions = {}): Promise= 200 && response.status < 300) { if (response.status === 204) return undefined as T - return (response.content_type.includes('application/json') ? JSON.parse(response.body) : response.body) as T + return (response.content_type.includes('application/json') + ? JSON.parse(response.body) + : responseFromDesktopCore(response)) as T } let error: ErrorResponse | null = null try { error = JSON.parse(response.body) as ErrorResponse } catch { /* 非 JSON 错误 */ }