feat: 添加 OpenNexus 认证 Core 与 Stronghold 基础能力

This commit is contained in:
2026-09-08 12:23:20 +08:00
parent f4aeeef49b
commit 4c79e940d2
59 changed files with 4242 additions and 102 deletions
+15 -5
View File
@@ -2,7 +2,7 @@ import type { ApiError, ErrorResponse } from '@/contracts'
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' : '')
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? import.meta.env.VITE_API_BASE ?? ''
interface DesktopCoreResponse {
status: number
@@ -82,12 +82,22 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
try {
if (isDesktop()) {
const parsed = new URL(url)
const parsed = new URL(url, 'http://localhost')
let bodyBase64: string | undefined
if (rest.body instanceof Blob) {
if (rest.body.size > 64 * 1024 * 1024) throw new ApiErrorClass('CORE_REQUEST_TOO_LARGE', '上传文件超过 64 MiB')
const bytes = new Uint8Array(await rest.body.arrayBuffer())
const parts: string[] = []
for (let offset = 0; offset < bytes.length; offset += 16384) parts.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)))
bodyBase64 = btoa(parts.join(''))
}
const response = await hostInvoke<DesktopCoreResponse>('core_request', {
method: rest.method ?? 'GET',
path: `${parsed.pathname}${parsed.search}`,
body: typeof rest.body === 'string' ? JSON.parse(rest.body) : undefined,
authorization: token ? `Bearer ${token}` : undefined,
bodyBase64,
contentType: new Headers(reqHeaders).get('Content-Type'),
idempotencyKey: new Headers(reqHeaders).get('Idempotency-Key') ?? undefined,
})
if (response.status >= 200 && response.status < 300) {
if (response.status === 204) return undefined as T
@@ -136,8 +146,8 @@ async function request<T>(path: string, options: RequestOptions = {}): Promise<T
}
export const apiClient = {
postBinary<T>(path: string, body: Blob) {
return request<T>(path, { method: 'POST', body, headers: { 'Content-Type': 'application/zip' } })
postBinary<T>(path: string, body: Blob, headers: Record<string, string> = { 'Content-Type': 'application/zip' }) {
return request<T>(path, { method: 'POST', body, headers })
},
get<T>(path: string, options?: Omit<RequestOptions, 'method'>) {
return request<T>(path, { ...options, method: 'GET' })
+5
View File
@@ -1,5 +1,6 @@
import { apiClient, resolveApiUrl } from './apiClient'
import { t } from '@/i18n'
import { isDesktop } from './platform/desktop'
export interface Segment { segment_id: string; start_time: number; end_time: number; text: string; speaker: string | null; language?: string }
export interface MediaJob {
@@ -24,6 +25,10 @@ export const mediaService = {
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
async upload(file: File, idempotencyKey?: string) {
if (isDesktop()) return apiClient.postBinary<{ attachment_id: string }>(
`/api/media/attachments?filename=${encodeURIComponent(file.name)}`, file,
{'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})},
)
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
})
@@ -0,0 +1,38 @@
// @vitest-environment happy-dom
import { beforeEach, expect, it, vi } from 'vitest'
const { invoke, channels } = vi.hoisted(() => ({ invoke: vi.fn(), channels: [] as { onmessage: (message: unknown) => void }[] }))
vi.mock('./desktop', () => ({ hostInvoke: invoke }))
vi.mock('@tauri-apps/api/core', () => ({ Channel: class { onmessage = (_message: unknown) => {}; constructor() { channels.push(this) } } }))
import { coreStream } from './coreStream'
beforeEach(() => { channels.length = 0; invoke.mockReset(); invoke.mockResolvedValue(undefined) })
it('preserves UTF-8 byte fragments and cursor without exposing authorization', async () => {
const pending = coreStream('/api/events', { method: 'GET', headers: { 'Last-Event-ID': '42', Authorization: 'must-not-forward' } })
channels[0]!.onmessage({ kind: 'headers', status: 200 })
const response = await pending
channels[0]!.onmessage({ kind: 'chunk', data: '5A==' })
channels[0]!.onmessage({ kind: 'chunk', data: 'uK0=' })
channels[0]!.onmessage({ kind: 'done' })
expect(await response.text()).toBe('中')
expect(invoke.mock.calls[0]![1]).toMatchObject({ lastEventId: '42' })
expect(JSON.stringify(invoke.mock.calls)).not.toContain('must-not-forward')
})
it('cancels a native request even when abort arrives before start acknowledgement', async () => {
let acknowledge!: () => void
invoke.mockImplementationOnce(() => new Promise<void>(resolve => { acknowledge = resolve }))
const abort = new AbortController()
const pending = coreStream('/api/events', { signal: abort.signal })
abort.abort()
await expect(pending).rejects.toMatchObject({ name: 'AbortError' })
acknowledge()
await vi.waitFor(() => expect(invoke).toHaveBeenCalledWith('core_stream_cancel', expect.anything()))
})
it('propagates native failure after headers to the response reader', async () => {
const pending = coreStream('/api/events', {})
channels[0]!.onmessage({ kind: 'headers', status: 200 })
const response = await pending
channels[0]!.onmessage({ kind: 'error', code: 'CORE_RESPONSE_ERROR' })
await expect(response.text()).rejects.toThrow('CORE_RESPONSE_ERROR')
})
@@ -0,0 +1,52 @@
import { Channel } from '@tauri-apps/api/core'
import { hostInvoke } from './desktop'
type Message = { kind: 'headers'; status: number } | { kind: 'chunk'; data: string }
| { kind: 'done' } | { kind: 'error'; code: string }
/** Native session credentials stay in Rust; this channel carries response bytes only. */
export function coreStream(path: string, init: RequestInit): Promise<Response> {
return new Promise((resolve, reject) => {
const requestId = crypto.randomUUID()
let ended = false
let started = false
let controller: ReadableStreamDefaultController<Uint8Array>
const cancelHost = () => hostInvoke('core_stream_cancel', { requestId }).catch(() => {})
const cleanup = () => init.signal?.removeEventListener('abort', abort)
const fail = (error: Error) => {
if (ended) return
ended = true
cleanup()
controller.error(error)
reject(error)
if (started) void cancelHost()
}
const abort = () => fail(new DOMException('Request aborted', 'AbortError'))
const stream = new ReadableStream<Uint8Array>({
start(value) { controller = value },
cancel() { ended = true; cleanup(); if (started) void cancelHost() },
})
const channel = new Channel<Message>()
channel.onmessage = message => {
if (ended) return
if (message.kind === 'headers') resolve(new Response(stream, { status: message.status, headers: { 'Content-Type': 'text/event-stream' } }))
if (message.kind === 'chunk') {
const bytes = Uint8Array.from(atob(message.data), c => c.charCodeAt(0))
controller.enqueue(bytes)
// Bound queued data if a consumer stops reading without cancelling.
if ((controller.desiredSize ?? 0) < -4096) fail(new Error('CORE_STREAM_BACKPRESSURE'))
}
if (message.kind === 'error') fail(new Error(message.code))
if (message.kind === 'done') { ended = true; cleanup(); controller.close() }
}
init.signal?.addEventListener('abort', abort, { once: true })
if (init.signal?.aborted) { abort(); return }
let body: unknown
try { body = typeof init.body === 'string' ? JSON.parse(init.body) : undefined }
catch { fail(new Error('CORE_BODY_INVALID')); return }
void hostInvoke('core_stream', {
requestId, path, method: init.method ?? 'GET', body,
lastEventId: new Headers(init.headers).get('Last-Event-ID') ?? undefined, channel,
}).then(() => { started = true; if (ended) void cancelHost() }).catch(fail)
})
}
+5 -2
View File
@@ -1,4 +1,6 @@
import { resolveApiUrl } from './apiClient'
import { isDesktop } from './platform/desktop'
import { coreStream } from './platform/coreStream'
export type SseEventHandler = (
event: string,
@@ -47,12 +49,13 @@ export class SseClient {
headers['Last-Event-ID'] = lastEventId
}
const resp = await fetch(resolveApiUrl(url), {
const init: RequestInit = {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: this.controller.signal,
})
}
const resp = isDesktop() ? await coreStream(url, init) : await fetch(resolveApiUrl(url), init)
if (!resp.ok || !resp.body) {
throw new Error(`SSE connection failed: ${resp.status}`)