mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Enable authenticated browser bootstrap for remote H5 access
Browser-mode startup now preserves the desktop localhost path while prompting for H5 credentials when a non-loopback backend requires verification. The API client and websocket layer share a central token source so REST and session sockets authenticate consistently without leaking the token into diagnostics. Constraint: Preserve the Tauri startup path and default localhost browser behavior Rejected: Reuse the generic startup error view for H5 auth failures | it blocks the credential-entry recovery path Directive: Non-loopback browser startup is the only path that should require saved H5 verification without re-checking the desktop bootstrap contract Confidence: high Scope-risk: moderate Tested: cd desktop && bunx vitest run src/api/client.test.ts src/api/websocket.test.ts src/components/layout/AppShell.test.tsx Tested: cd desktop && bun run lint Tested: git diff --check -- desktop/src/api/client.ts desktop/src/api/websocket.ts desktop/src/lib/desktopRuntime.ts desktop/src/components/layout/H5ConnectionView.tsx desktop/src/components/layout/AppShell.tsx desktop/src/api/client.test.ts desktop/src/api/websocket.test.ts desktop/src/components/layout/AppShell.test.tsx Not-tested: Live remote H5 browser session against a running server
This commit is contained in:
parent
4865b4f206
commit
ba1807541a
@ -1,11 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api, rawRecordDiagnosticEvent } from './client'
|
||||
import {
|
||||
api,
|
||||
getDefaultBaseUrl,
|
||||
rawRecordDiagnosticEvent,
|
||||
setAuthToken,
|
||||
setBaseUrl,
|
||||
} from './client'
|
||||
|
||||
describe('api diagnostics reporting', () => {
|
||||
afterEach(() => {
|
||||
setAuthToken(null)
|
||||
setBaseUrl(getDefaultBaseUrl())
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('does not send Authorization for default local requests', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
await api.get('/api/status')
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!
|
||||
expect((init as RequestInit).headers).toMatchObject({
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
expect((init as RequestInit & { headers?: Record<string, string> }).headers?.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('adds Authorization when an H5 token is configured', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
setAuthToken('h5_x')
|
||||
await api.get('/api/status')
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!
|
||||
expect((init as RequestInit & { headers?: Record<string, string> }).headers).toMatchObject({
|
||||
Authorization: 'Bearer h5_x',
|
||||
})
|
||||
})
|
||||
|
||||
it('reports non-diagnostics API failures without request bodies', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock
|
||||
@ -31,6 +71,27 @@ describe('api diagnostics reporting', () => {
|
||||
expect(JSON.stringify(body)).not.toContain('sk-should-not-report')
|
||||
})
|
||||
|
||||
it('does not leak the H5 token in diagnostics payloads', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Unauthorized' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}))
|
||||
|
||||
setAuthToken('h5_super_secret')
|
||||
|
||||
await expect(api.get('/api/status')).rejects.toThrow('Unauthorized')
|
||||
|
||||
const [, diagnosticInit] = fetchMock.mock.calls[1]!
|
||||
const body = JSON.parse(String((diagnosticInit as RequestInit).body))
|
||||
expect(JSON.stringify(body)).not.toContain('h5_super_secret')
|
||||
})
|
||||
|
||||
it('does not recursively report diagnostics endpoint failures', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'diagnostics down' }), {
|
||||
|
||||
@ -8,6 +8,7 @@ const ENV_BASE_URL =
|
||||
const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
|
||||
|
||||
let baseUrl = DEFAULT_BASE_URL
|
||||
let authToken: string | null = null
|
||||
const DIAGNOSTICS_PATH = '/api/diagnostics/events'
|
||||
|
||||
function getErrorMessage(status: number, body: unknown) {
|
||||
@ -30,6 +31,15 @@ export function getBaseUrl() {
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
export function setAuthToken(token: string | null) {
|
||||
const trimmed = token?.trim() ?? ''
|
||||
authToken = trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
export function getAuthToken() {
|
||||
return authToken
|
||||
}
|
||||
|
||||
export function getDefaultBaseUrl() {
|
||||
return DEFAULT_BASE_URL
|
||||
}
|
||||
@ -46,9 +56,7 @@ export class ApiError extends Error {
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown, options?: { timeout?: number }): Promise<T> {
|
||||
const url = `${baseUrl}${path}`
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
const headers = buildHeaders()
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutMs = options?.timeout ?? 30_000
|
||||
@ -88,12 +96,12 @@ function reportApiFailure(method: string, path: string, error: unknown) {
|
||||
method,
|
||||
path,
|
||||
errorName: error instanceof Error ? error.name : typeof error,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
message: sanitizeDiagnosticValue(error instanceof Error ? error.message : String(error)),
|
||||
}
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
details.status = error.status
|
||||
details.response = error.body
|
||||
details.response = sanitizeDiagnosticValue(error.body)
|
||||
}
|
||||
|
||||
void rawRecordDiagnosticEvent({
|
||||
@ -113,11 +121,43 @@ export function rawRecordDiagnosticEvent(event: {
|
||||
}) {
|
||||
return fetch(`${baseUrl}${DIAGNOSTICS_PATH}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: buildHeaders(),
|
||||
body: JSON.stringify(event),
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (authToken) {
|
||||
headers.Authorization = `Bearer ${authToken}`
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
function sanitizeDiagnosticValue(value: unknown): unknown {
|
||||
if (!authToken) return value
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value.split(authToken).join('[redacted]')
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => sanitizeDiagnosticValue(entry))
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, entry]) => [key, sanitizeDiagnosticValue(entry)]),
|
||||
)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, options?: { timeout?: number }) => request<T>('GET', path, undefined, options),
|
||||
post: <T>(path: string, body?: unknown, options?: { timeout?: number }) => request<T>('POST', path, body, options),
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('./client', () => ({
|
||||
getBaseUrl: () => 'http://127.0.0.1:3456',
|
||||
const clientMocks = vi.hoisted(() => ({
|
||||
baseUrl: 'http://127.0.0.1:3456',
|
||||
authToken: null as string | null,
|
||||
}))
|
||||
|
||||
import { wsManager } from './websocket'
|
||||
vi.mock('./client', () => ({
|
||||
getBaseUrl: () => clientMocks.baseUrl,
|
||||
getAuthToken: () => clientMocks.authToken,
|
||||
}))
|
||||
|
||||
import { buildSessionWebSocketUrl, wsManager } from './websocket'
|
||||
|
||||
type SocketHandler = (() => void) | ((event: { data: string }) => void)
|
||||
|
||||
@ -53,6 +59,8 @@ describe('wsManager reconnect buffering', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
clientMocks.baseUrl = 'http://127.0.0.1:3456'
|
||||
clientMocks.authToken = null
|
||||
FakeWebSocket.instances = []
|
||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||
wsManager.disconnectAll()
|
||||
@ -89,4 +97,21 @@ describe('wsManager reconnect buffering', () => {
|
||||
JSON.stringify({ type: 'user_message', content: 'queued while offline' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('builds websocket URLs from http and encodes token query params', () => {
|
||||
clientMocks.baseUrl = 'http://10.0.0.2:3456'
|
||||
clientMocks.authToken = 'h5 token/with?chars'
|
||||
|
||||
expect(buildSessionWebSocketUrl('session-reconnect')).toBe(
|
||||
'ws://10.0.0.2:3456/ws/session-reconnect?token=h5+token%2Fwith%3Fchars',
|
||||
)
|
||||
})
|
||||
|
||||
it('upgrades https backends to wss', () => {
|
||||
clientMocks.baseUrl = 'https://remote.example.com'
|
||||
|
||||
expect(buildSessionWebSocketUrl('secure-session')).toBe(
|
||||
'wss://remote.example.com/ws/secure-session',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ClientMessage, ServerMessage } from '../types/chat'
|
||||
import { getBaseUrl } from './client'
|
||||
import { getAuthToken, getBaseUrl } from './client'
|
||||
|
||||
type MessageHandler = (msg: ServerMessage) => void
|
||||
|
||||
@ -39,8 +39,7 @@ class WebSocketManager {
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = getBaseUrl().replace(/^http/, 'ws')
|
||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||
const ws = new WebSocket(buildSessionWebSocketUrl(sessionId))
|
||||
|
||||
const conn: Connection = {
|
||||
ws,
|
||||
@ -178,4 +177,19 @@ class WebSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSessionWebSocketUrl(sessionId: string) {
|
||||
const url = new URL(getBaseUrl())
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
url.pathname = `/ws/${encodeURIComponent(sessionId)}`
|
||||
|
||||
const token = getAuthToken()
|
||||
if (token) {
|
||||
url.searchParams.set('token', token)
|
||||
} else {
|
||||
url.searchParams.delete('token')
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export const wsManager = new WebSocketManager()
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
initializeDesktopServerUrl: vi.fn(),
|
||||
isTauriRuntime: false,
|
||||
fetchAll: vi.fn(),
|
||||
restoreTabs: vi.fn(),
|
||||
connectToSession: vi.fn(),
|
||||
@ -15,7 +16,9 @@ const mocks = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../lib/desktopRuntime', () => ({
|
||||
initializeDesktopServerUrl: mocks.initializeDesktopServerUrl,
|
||||
isTauriRuntime: () => false,
|
||||
isTauriRuntime: () => mocks.isTauriRuntime,
|
||||
isH5ConnectionRequiredError: (error: unknown) =>
|
||||
error instanceof Error && error.name === 'H5ConnectionRequiredError',
|
||||
}))
|
||||
|
||||
vi.mock('../../stores/settingsStore', () => ({
|
||||
@ -68,6 +71,16 @@ vi.mock('./TabBar', () => ({
|
||||
TabBar: () => <nav>tabs loaded</nav>,
|
||||
}))
|
||||
|
||||
vi.mock('./H5ConnectionView', () => ({
|
||||
H5ConnectionView: ({ error, onConnected }: { error?: string | null; onConnected: () => void }) => (
|
||||
<div>
|
||||
<div>h5 connection view</div>
|
||||
<div>{error}</div>
|
||||
<button type="button" onClick={onConnected}>retry h5 bootstrap</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../shared/Toast', () => ({
|
||||
ToastContainer: () => null,
|
||||
}))
|
||||
@ -81,6 +94,7 @@ import { AppShell } from './AppShell'
|
||||
describe('AppShell boot flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.isTauriRuntime = false
|
||||
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
|
||||
mocks.fetchAll.mockResolvedValue(undefined)
|
||||
mocks.restoreTabs.mockResolvedValue(undefined)
|
||||
@ -139,4 +153,54 @@ describe('AppShell boot flow', () => {
|
||||
expect(mocks.connectToSession).toHaveBeenCalledWith('session-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the H5 connection view in browser mode when startup needs H5 auth', async () => {
|
||||
mocks.initializeDesktopServerUrl.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Enter your H5 token to continue.'), {
|
||||
name: 'H5ConnectionRequiredError',
|
||||
serverUrl: 'https://remote.example.com',
|
||||
}),
|
||||
)
|
||||
|
||||
render(<AppShell />)
|
||||
|
||||
expect(await screen.findByText('h5 connection view')).toBeInTheDocument()
|
||||
expect(screen.getByText('Enter your H5 token to continue.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('retries bootstrap after a successful H5 connection', async () => {
|
||||
mocks.initializeDesktopServerUrl
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('The saved H5 token is no longer valid.'), {
|
||||
name: 'H5ConnectionRequiredError',
|
||||
serverUrl: 'https://remote.example.com',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce('https://remote.example.com')
|
||||
|
||||
render(<AppShell />)
|
||||
|
||||
expect(await screen.findByText('h5 connection view')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'retry h5 bootstrap' }))
|
||||
|
||||
await screen.findByText('sidebar loaded')
|
||||
expect(mocks.initializeDesktopServerUrl).toHaveBeenCalledTimes(2)
|
||||
expect(mocks.fetchAll).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the Tauri startup error path unchanged', async () => {
|
||||
mocks.isTauriRuntime = true
|
||||
mocks.initializeDesktopServerUrl.mockRejectedValueOnce(
|
||||
Object.assign(new Error('desktop server startup failed'), {
|
||||
name: 'H5ConnectionRequiredError',
|
||||
serverUrl: 'https://remote.example.com',
|
||||
}),
|
||||
)
|
||||
|
||||
render(<AppShell />)
|
||||
|
||||
expect(await screen.findByText('app.serverFailed')).toBeInTheDocument()
|
||||
expect(screen.queryByText('h5 connection view')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -6,24 +6,39 @@ import { UpdateChecker } from '../shared/UpdateChecker'
|
||||
import { useSettingsStore } from '../../stores/settingsStore'
|
||||
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
||||
import { initializeDesktopServerUrl, isTauriRuntime } from '../../lib/desktopRuntime'
|
||||
import {
|
||||
H5ConnectionRequiredError,
|
||||
initializeDesktopServerUrl,
|
||||
isH5ConnectionRequiredError,
|
||||
isTauriRuntime,
|
||||
} from '../../lib/desktopRuntime'
|
||||
import { TabBar } from './TabBar'
|
||||
import { StartupErrorView } from './StartupErrorView'
|
||||
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
||||
import { useChatStore } from '../../stores/chatStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { H5ConnectionView } from './H5ConnectionView'
|
||||
|
||||
export function AppShell() {
|
||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [startupError, setStartupError] = useState<string | null>(null)
|
||||
const [h5StartupError, setH5StartupError] = useState<H5ConnectionRequiredError | null>(null)
|
||||
const [bootstrapNonce, setBootstrapNonce] = useState(0)
|
||||
const t = useTranslation()
|
||||
const tauriRuntime = isTauriRuntime()
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const bootstrap = async () => {
|
||||
if (!cancelled) {
|
||||
setReady(false)
|
||||
setStartupError(null)
|
||||
setH5StartupError(null)
|
||||
}
|
||||
|
||||
try {
|
||||
await initializeDesktopServerUrl()
|
||||
await fetchSettings()
|
||||
@ -43,7 +58,13 @@ export function AppShell() {
|
||||
})().catch(() => {})
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setStartupError(error instanceof Error ? error.message : String(error))
|
||||
if (!tauriRuntime && isH5ConnectionRequiredError(error)) {
|
||||
setH5StartupError(error)
|
||||
setStartupError(null)
|
||||
} else {
|
||||
setStartupError(error instanceof Error ? error.message : String(error))
|
||||
setH5StartupError(null)
|
||||
}
|
||||
setReady(false)
|
||||
}
|
||||
}
|
||||
@ -54,11 +75,11 @@ export function AppShell() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [fetchSettings])
|
||||
}, [bootstrapNonce, fetchSettings, tauriRuntime])
|
||||
|
||||
// Listen for macOS native menu navigation events (About / Settings)
|
||||
useEffect(() => {
|
||||
if (!isTauriRuntime()) return
|
||||
if (!tauriRuntime) return
|
||||
let unlisten: (() => void) | undefined
|
||||
import('@tauri-apps/api/event')
|
||||
.then(({ listen }) =>
|
||||
@ -77,6 +98,16 @@ export function AppShell() {
|
||||
|
||||
useKeyboardShortcuts()
|
||||
|
||||
if (!tauriRuntime && h5StartupError) {
|
||||
return (
|
||||
<H5ConnectionView
|
||||
initialServerUrl={h5StartupError.serverUrl}
|
||||
error={h5StartupError.message}
|
||||
onConnected={() => setBootstrapNonce((value) => value + 1)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (startupError) {
|
||||
return <StartupErrorView error={startupError} />
|
||||
}
|
||||
|
||||
84
desktop/src/components/layout/H5ConnectionView.tsx
Normal file
84
desktop/src/components/layout/H5ConnectionView.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import type { FormEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { saveAndVerifyH5Connection } from '../../lib/desktopRuntime'
|
||||
import { Button } from '../shared/Button'
|
||||
import { Input } from '../shared/Input'
|
||||
|
||||
type H5ConnectionViewProps = {
|
||||
initialServerUrl?: string | null
|
||||
error?: string | null
|
||||
onConnected: () => void
|
||||
}
|
||||
|
||||
export function H5ConnectionView({
|
||||
initialServerUrl,
|
||||
error: initialError,
|
||||
onConnected,
|
||||
}: H5ConnectionViewProps) {
|
||||
const [serverUrl, setServerUrl] = useState(initialServerUrl ?? '')
|
||||
const [token, setToken] = useState('')
|
||||
const [error, setError] = useState(initialError ?? '')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await saveAndVerifyH5Connection(serverUrl, token)
|
||||
onConnected()
|
||||
} catch (submitError) {
|
||||
setError(
|
||||
submitError instanceof Error ? submitError.message : 'Unable to connect to the H5 server.',
|
||||
)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-[var(--color-surface)] px-6">
|
||||
<section className="w-full max-w-md rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-6 shadow-[var(--shadow-md)]">
|
||||
<div className="mb-5">
|
||||
<h1 className="text-lg font-semibold text-[var(--color-text-primary)]">
|
||||
Connect to H5 Access
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">
|
||||
Enter the server URL and H5 access token from the desktop app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<Input
|
||||
label="Server URL"
|
||||
placeholder="https://chat.example.com"
|
||||
value={serverUrl}
|
||||
onChange={(event) => setServerUrl(event.target.value)}
|
||||
autoComplete="url"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="H5 Token"
|
||||
type="password"
|
||||
placeholder="h5_..."
|
||||
value={token}
|
||||
onChange={(event) => setToken(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-[var(--color-error)]/30 bg-[var(--color-error)]/8 px-3 py-2 text-sm text-[var(--color-error)]">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" size="lg" className="w-full" loading={submitting}>
|
||||
Connect
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,28 +1,115 @@
|
||||
import { getDefaultBaseUrl, setBaseUrl } from '../api/client'
|
||||
import { api, getDefaultBaseUrl, setAuthToken, setBaseUrl } from '../api/client'
|
||||
|
||||
export const H5_SERVER_URL_STORAGE_KEY = 'cc-haha-h5-server-url'
|
||||
export const H5_TOKEN_STORAGE_KEY = 'cc-haha-h5-token'
|
||||
|
||||
type H5ConnectionFailureReason = 'missing-token' | 'invalid-token' | 'verify-failed'
|
||||
|
||||
export type StoredH5Connection = {
|
||||
serverUrl: string | null
|
||||
token: string | null
|
||||
}
|
||||
|
||||
export class H5ConnectionRequiredError extends Error {
|
||||
readonly serverUrl: string
|
||||
readonly reason: H5ConnectionFailureReason
|
||||
|
||||
constructor(message: string, serverUrl: string, reason: H5ConnectionFailureReason) {
|
||||
super(message)
|
||||
this.name = 'H5ConnectionRequiredError'
|
||||
this.serverUrl = serverUrl
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
export function isTauriRuntime() {
|
||||
if (typeof window === 'undefined') return false
|
||||
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
|
||||
}
|
||||
|
||||
export function isBrowserH5Runtime() {
|
||||
return typeof window !== 'undefined' && !isTauriRuntime()
|
||||
}
|
||||
|
||||
export function readStoredH5Connection(): StoredH5Connection {
|
||||
if (typeof window === 'undefined') {
|
||||
return { serverUrl: null, token: null }
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
serverUrl: normalizeServerUrl(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)),
|
||||
token: normalizeToken(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)),
|
||||
}
|
||||
} catch {
|
||||
return { serverUrl: null, token: null }
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredH5Connection() {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.removeItem(H5_SERVER_URL_STORAGE_KEY)
|
||||
window.localStorage.removeItem(H5_TOKEN_STORAGE_KEY)
|
||||
} catch {
|
||||
// Ignore storage failures
|
||||
}
|
||||
}
|
||||
|
||||
setAuthToken(null)
|
||||
}
|
||||
|
||||
export async function saveAndVerifyH5Connection(serverUrl: string, token: string) {
|
||||
const normalizedServerUrl = normalizeServerUrl(serverUrl)
|
||||
const normalizedToken = normalizeToken(token)
|
||||
|
||||
if (!normalizedServerUrl) {
|
||||
throw new Error('Enter a valid server URL.')
|
||||
}
|
||||
|
||||
if (!normalizedToken) {
|
||||
throw new Error('Enter your H5 access token.')
|
||||
}
|
||||
|
||||
setBaseUrl(normalizedServerUrl)
|
||||
setAuthToken(normalizedToken)
|
||||
|
||||
try {
|
||||
await waitForHealth(normalizedServerUrl)
|
||||
await verifyH5Access()
|
||||
} catch (error) {
|
||||
setAuthToken(null)
|
||||
throw normalizeH5VerificationError(error, normalizedServerUrl)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, normalizedServerUrl)
|
||||
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, normalizedToken)
|
||||
} catch {
|
||||
// Ignore storage failures after a successful verification.
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedServerUrl
|
||||
}
|
||||
|
||||
export function isH5ConnectionRequiredError(error: unknown): error is H5ConnectionRequiredError {
|
||||
return error instanceof H5ConnectionRequiredError
|
||||
}
|
||||
|
||||
export async function initializeDesktopServerUrl() {
|
||||
const fallbackUrl = getDefaultBaseUrl()
|
||||
const queryUrl =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('serverUrl')
|
||||
: null
|
||||
const requestedUrl = queryUrl?.trim() || fallbackUrl
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
setBaseUrl(requestedUrl)
|
||||
await waitForHealth(requestedUrl)
|
||||
return requestedUrl
|
||||
return initializeBrowserServerUrl(fallbackUrl)
|
||||
}
|
||||
|
||||
try {
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
const serverUrl = await invoke<string>('get_server_url')
|
||||
setBaseUrl(serverUrl)
|
||||
setAuthToken(null)
|
||||
await waitForHealth(serverUrl)
|
||||
return serverUrl
|
||||
} catch (error) {
|
||||
@ -33,6 +120,42 @@ export async function initializeDesktopServerUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeBrowserServerUrl(fallbackUrl: string) {
|
||||
const queryUrl =
|
||||
typeof window !== 'undefined'
|
||||
? new URLSearchParams(window.location.search).get('serverUrl')
|
||||
: null
|
||||
const stored = readStoredH5Connection()
|
||||
const requestedUrl = normalizeServerUrl(queryUrl) ?? stored.serverUrl ?? fallbackUrl
|
||||
const token = stored.token
|
||||
|
||||
setBaseUrl(requestedUrl)
|
||||
setAuthToken(token)
|
||||
await waitForHealth(requestedUrl)
|
||||
|
||||
if (!requiresH5Auth(requestedUrl)) {
|
||||
return requestedUrl
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
clearStoredH5Connection()
|
||||
throw new H5ConnectionRequiredError(
|
||||
'Enter your H5 token to continue.',
|
||||
requestedUrl,
|
||||
'missing-token',
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyH5Access()
|
||||
} catch (error) {
|
||||
clearStoredH5Connection()
|
||||
throw normalizeH5VerificationError(error, requestedUrl)
|
||||
}
|
||||
|
||||
return requestedUrl
|
||||
}
|
||||
|
||||
async function waitForHealth(serverUrl: string) {
|
||||
let lastError: unknown
|
||||
|
||||
@ -54,7 +177,55 @@ async function waitForHealth(serverUrl: string) {
|
||||
|
||||
throw new Error(
|
||||
lastError instanceof Error
|
||||
? `Local server healthcheck failed: ${lastError.message}`
|
||||
: 'Local server healthcheck failed',
|
||||
? `Server healthcheck failed: ${lastError.message}`
|
||||
: 'Server healthcheck failed',
|
||||
)
|
||||
}
|
||||
|
||||
async function verifyH5Access() {
|
||||
await api.post<{ ok: true }>('/api/h5-access/verify')
|
||||
}
|
||||
|
||||
function normalizeServerUrl(value: string | null | undefined) {
|
||||
const trimmed = value?.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
try {
|
||||
return new URL(trimmed).toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToken(value: string | null | undefined) {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
function requiresH5Auth(serverUrl: string) {
|
||||
try {
|
||||
const hostname = new URL(serverUrl).hostname
|
||||
return hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeH5VerificationError(error: unknown, serverUrl: string) {
|
||||
if (error instanceof H5ConnectionRequiredError) {
|
||||
return error
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.startsWith('Server healthcheck failed')) {
|
||||
return new Error(`Unable to reach ${serverUrl}. Check the server URL or network access.`)
|
||||
}
|
||||
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Unable to verify the H5 access token.'
|
||||
const unauthorized = message.includes('401') || message.toLowerCase().includes('unauthorized')
|
||||
return new H5ConnectionRequiredError(
|
||||
unauthorized ? 'The saved H5 token is no longer valid.' : 'Unable to verify the H5 access token.',
|
||||
serverUrl,
|
||||
unauthorized ? 'invalid-token' : 'verify-failed',
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user