feat: 添加 OpenNexus 认证 Core 与 Stronghold 基础能力
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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}`)
|
||||
|
||||
Reference in New Issue
Block a user