fix(desktop): 修复导出文件下载
This commit is contained in:
@@ -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` 下载。
|
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
|
```text
|
||||||
|
|||||||
Generated
+1
@@ -2298,6 +2298,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
|||||||
name = "notesagent-desktop"
|
name = "notesagent-desktop"
|
||||||
version = "0.3.0-alpha.1"
|
version = "0.3.0-alpha.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"fs2",
|
"fs2",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
"rfd",
|
"rfd",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ required-features = ["desktop"]
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = []
|
default = []
|
||||||
desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest"]
|
desktop = ["dep:tauri", "dep:tauri-build", "dep:rfd", "dep:reqwest", "dep:base64"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
@@ -27,6 +27,7 @@ fs2 = "0.4"
|
|||||||
tauri = { version = "2", optional = true, features = ["tray-icon"] }
|
tauri = { version = "2", optional = true, features = ["tray-icon"] }
|
||||||
rfd = { version = "0.15", optional = true }
|
rfd = { version = "0.15", optional = true }
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
||||||
|
base64 = { version = "0.22", optional = true }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
tauri-build = { version = "2", optional = true , features = [] }
|
tauri-build = { version = "2", optional = true , features = [] }
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
//! 预览 Host 只开放本地文件命令;未接通的 AI / 同步 / 凭据能力明确返回不可用。
|
||||||
|
|
||||||
|
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||||
use notesagent_host::recent::{RecentVault, RecentVaultStore};
|
use notesagent_host::recent::{RecentVault, RecentVaultStore};
|
||||||
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
|
use notesagent_host::workspace::{portable_path_string, Document, Entry, Workspace};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -47,6 +48,21 @@ struct CoreResponse {
|
|||||||
status: u16,
|
status: u16,
|
||||||
content_type: String,
|
content_type: String,
|
||||||
body: String,
|
body: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
body_base64: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端常规导出上限为 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<String, String> {
|
fn core_url(path: &str) -> Result<String, String> {
|
||||||
@@ -61,7 +77,7 @@ fn core_url(path: &str) -> Result<String, String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod core_proxy_tests {
|
mod core_proxy_tests {
|
||||||
use super::core_url;
|
use super::{core_url, is_json_content_type};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_allows_expected_loopback_paths() {
|
fn only_allows_expected_loopback_paths() {
|
||||||
@@ -79,6 +95,17 @@ mod core_proxy_tests {
|
|||||||
assert!(csp.contains("'wasm-unsafe-eval'"));
|
assert!(csp.contains("'wasm-unsafe-eval'"));
|
||||||
assert!(!csp.split_whitespace().any(|token| token == "'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 与任意地址转发。
|
/// 预览版只代理固定回环地址,避免 WebView CORS 与任意地址转发。
|
||||||
@@ -120,11 +147,29 @@ async fn core_request(
|
|||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.unwrap_or("")
|
.unwrap_or("")
|
||||||
.to_owned();
|
.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 {
|
Ok(CoreResponse {
|
||||||
status,
|
status,
|
||||||
content_type,
|
content_type,
|
||||||
body,
|
body,
|
||||||
|
body_base64,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<Response>('/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<Response>('/api/exports/job/file')
|
||||||
|
|
||||||
|
expect(response).toBeInstanceOf(Response)
|
||||||
|
expect(await response.text()).toBe('<h1>导出</h1>')
|
||||||
|
})
|
||||||
@@ -4,7 +4,26 @@ import { hostInvoke, isDesktop } from './platform/desktop'
|
|||||||
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
|
// 所有 HTTP 请求都经过此边界,以统一地址、请求追踪和错误契约。
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? (isDesktop() ? 'http://127.0.0.1:8000' : '')
|
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 {
|
export function resolveApiUrl(path: string): string {
|
||||||
if (/^https?:\/\//i.test(path)) return path
|
if (/^https?:\/\//i.test(path)) return path
|
||||||
@@ -72,7 +91,9 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
|
|||||||
})
|
})
|
||||||
if (response.status >= 200 && response.status < 300) {
|
if (response.status >= 200 && response.status < 300) {
|
||||||
if (response.status === 204) return undefined as T
|
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
|
let error: ErrorResponse | null = null
|
||||||
try { error = JSON.parse(response.body) as ErrorResponse } catch { /* 非 JSON 错误 */ }
|
try { error = JSON.parse(response.body) as ErrorResponse } catch { /* 非 JSON 错误 */ }
|
||||||
|
|||||||
Reference in New Issue
Block a user