mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +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 { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { api, rawRecordDiagnosticEvent } from './client'
|
import {
|
||||||
|
api,
|
||||||
|
getDefaultBaseUrl,
|
||||||
|
rawRecordDiagnosticEvent,
|
||||||
|
setAuthToken,
|
||||||
|
setBaseUrl,
|
||||||
|
} from './client'
|
||||||
|
|
||||||
describe('api diagnostics reporting', () => {
|
describe('api diagnostics reporting', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
setAuthToken(null)
|
||||||
|
setBaseUrl(getDefaultBaseUrl())
|
||||||
vi.restoreAllMocks()
|
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 () => {
|
it('reports non-diagnostics API failures without request bodies', async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
fetchMock
|
fetchMock
|
||||||
@ -31,6 +71,27 @@ describe('api diagnostics reporting', () => {
|
|||||||
expect(JSON.stringify(body)).not.toContain('sk-should-not-report')
|
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 () => {
|
it('does not recursively report diagnostics endpoint failures', async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||||
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'diagnostics down' }), {
|
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'
|
const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456'
|
||||||
|
|
||||||
let baseUrl = DEFAULT_BASE_URL
|
let baseUrl = DEFAULT_BASE_URL
|
||||||
|
let authToken: string | null = null
|
||||||
const DIAGNOSTICS_PATH = '/api/diagnostics/events'
|
const DIAGNOSTICS_PATH = '/api/diagnostics/events'
|
||||||
|
|
||||||
function getErrorMessage(status: number, body: unknown) {
|
function getErrorMessage(status: number, body: unknown) {
|
||||||
@ -30,6 +31,15 @@ export function getBaseUrl() {
|
|||||||
return baseUrl
|
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() {
|
export function getDefaultBaseUrl() {
|
||||||
return DEFAULT_BASE_URL
|
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> {
|
async function request<T>(method: string, path: string, body?: unknown, options?: { timeout?: number }): Promise<T> {
|
||||||
const url = `${baseUrl}${path}`
|
const url = `${baseUrl}${path}`
|
||||||
const headers: Record<string, string> = {
|
const headers = buildHeaders()
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeoutMs = options?.timeout ?? 30_000
|
const timeoutMs = options?.timeout ?? 30_000
|
||||||
@ -88,12 +96,12 @@ function reportApiFailure(method: string, path: string, error: unknown) {
|
|||||||
method,
|
method,
|
||||||
path,
|
path,
|
||||||
errorName: error instanceof Error ? error.name : typeof error,
|
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) {
|
if (error instanceof ApiError) {
|
||||||
details.status = error.status
|
details.status = error.status
|
||||||
details.response = error.body
|
details.response = sanitizeDiagnosticValue(error.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
void rawRecordDiagnosticEvent({
|
void rawRecordDiagnosticEvent({
|
||||||
@ -113,11 +121,43 @@ export function rawRecordDiagnosticEvent(event: {
|
|||||||
}) {
|
}) {
|
||||||
return fetch(`${baseUrl}${DIAGNOSTICS_PATH}`, {
|
return fetch(`${baseUrl}${DIAGNOSTICS_PATH}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: buildHeaders(),
|
||||||
body: JSON.stringify(event),
|
body: JSON.stringify(event),
|
||||||
}).catch(() => undefined)
|
}).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 = {
|
export const api = {
|
||||||
get: <T>(path: string, options?: { timeout?: number }) => request<T>('GET', path, undefined, options),
|
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),
|
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'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
vi.mock('./client', () => ({
|
const clientMocks = vi.hoisted(() => ({
|
||||||
getBaseUrl: () => 'http://127.0.0.1:3456',
|
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)
|
type SocketHandler = (() => void) | ((event: { data: string }) => void)
|
||||||
|
|
||||||
@ -53,6 +59,8 @@ describe('wsManager reconnect buffering', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
|
clientMocks.baseUrl = 'http://127.0.0.1:3456'
|
||||||
|
clientMocks.authToken = null
|
||||||
FakeWebSocket.instances = []
|
FakeWebSocket.instances = []
|
||||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
|
||||||
wsManager.disconnectAll()
|
wsManager.disconnectAll()
|
||||||
@ -89,4 +97,21 @@ describe('wsManager reconnect buffering', () => {
|
|||||||
JSON.stringify({ type: 'user_message', content: 'queued while offline' }),
|
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 type { ClientMessage, ServerMessage } from '../types/chat'
|
||||||
import { getBaseUrl } from './client'
|
import { getAuthToken, getBaseUrl } from './client'
|
||||||
|
|
||||||
type MessageHandler = (msg: ServerMessage) => void
|
type MessageHandler = (msg: ServerMessage) => void
|
||||||
|
|
||||||
@ -39,8 +39,7 @@ class WebSocketManager {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const wsUrl = getBaseUrl().replace(/^http/, 'ws')
|
const ws = new WebSocket(buildSessionWebSocketUrl(sessionId))
|
||||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
|
||||||
|
|
||||||
const conn: Connection = {
|
const conn: Connection = {
|
||||||
ws,
|
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()
|
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 '@testing-library/jest-dom'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
initializeDesktopServerUrl: vi.fn(),
|
initializeDesktopServerUrl: vi.fn(),
|
||||||
|
isTauriRuntime: false,
|
||||||
fetchAll: vi.fn(),
|
fetchAll: vi.fn(),
|
||||||
restoreTabs: vi.fn(),
|
restoreTabs: vi.fn(),
|
||||||
connectToSession: vi.fn(),
|
connectToSession: vi.fn(),
|
||||||
@ -15,7 +16,9 @@ const mocks = vi.hoisted(() => ({
|
|||||||
|
|
||||||
vi.mock('../../lib/desktopRuntime', () => ({
|
vi.mock('../../lib/desktopRuntime', () => ({
|
||||||
initializeDesktopServerUrl: mocks.initializeDesktopServerUrl,
|
initializeDesktopServerUrl: mocks.initializeDesktopServerUrl,
|
||||||
isTauriRuntime: () => false,
|
isTauriRuntime: () => mocks.isTauriRuntime,
|
||||||
|
isH5ConnectionRequiredError: (error: unknown) =>
|
||||||
|
error instanceof Error && error.name === 'H5ConnectionRequiredError',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../stores/settingsStore', () => ({
|
vi.mock('../../stores/settingsStore', () => ({
|
||||||
@ -68,6 +71,16 @@ vi.mock('./TabBar', () => ({
|
|||||||
TabBar: () => <nav>tabs loaded</nav>,
|
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', () => ({
|
vi.mock('../shared/Toast', () => ({
|
||||||
ToastContainer: () => null,
|
ToastContainer: () => null,
|
||||||
}))
|
}))
|
||||||
@ -81,6 +94,7 @@ import { AppShell } from './AppShell'
|
|||||||
describe('AppShell boot flow', () => {
|
describe('AppShell boot flow', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mocks.isTauriRuntime = false
|
||||||
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
|
mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456')
|
||||||
mocks.fetchAll.mockResolvedValue(undefined)
|
mocks.fetchAll.mockResolvedValue(undefined)
|
||||||
mocks.restoreTabs.mockResolvedValue(undefined)
|
mocks.restoreTabs.mockResolvedValue(undefined)
|
||||||
@ -139,4 +153,54 @@ describe('AppShell boot flow', () => {
|
|||||||
expect(mocks.connectToSession).toHaveBeenCalledWith('session-1')
|
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 { useSettingsStore } from '../../stores/settingsStore'
|
||||||
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
import { useUIStore, type SettingsTab } from '../../stores/uiStore'
|
||||||
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts'
|
||||||
import { initializeDesktopServerUrl, isTauriRuntime } from '../../lib/desktopRuntime'
|
import {
|
||||||
|
H5ConnectionRequiredError,
|
||||||
|
initializeDesktopServerUrl,
|
||||||
|
isH5ConnectionRequiredError,
|
||||||
|
isTauriRuntime,
|
||||||
|
} from '../../lib/desktopRuntime'
|
||||||
import { TabBar } from './TabBar'
|
import { TabBar } from './TabBar'
|
||||||
import { StartupErrorView } from './StartupErrorView'
|
import { StartupErrorView } from './StartupErrorView'
|
||||||
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { H5ConnectionView } from './H5ConnectionView'
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
const fetchSettings = useSettingsStore((s) => s.fetchAll)
|
||||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
const sidebarOpen = useUIStore((s) => s.sidebarOpen)
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
const [startupError, setStartupError] = useState<string | null>(null)
|
const [startupError, setStartupError] = useState<string | null>(null)
|
||||||
|
const [h5StartupError, setH5StartupError] = useState<H5ConnectionRequiredError | null>(null)
|
||||||
|
const [bootstrapNonce, setBootstrapNonce] = useState(0)
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
|
const tauriRuntime = isTauriRuntime()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setReady(false)
|
||||||
|
setStartupError(null)
|
||||||
|
setH5StartupError(null)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await initializeDesktopServerUrl()
|
await initializeDesktopServerUrl()
|
||||||
await fetchSettings()
|
await fetchSettings()
|
||||||
@ -43,7 +58,13 @@ export function AppShell() {
|
|||||||
})().catch(() => {})
|
})().catch(() => {})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled) {
|
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)
|
setReady(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -54,11 +75,11 @@ export function AppShell() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [fetchSettings])
|
}, [bootstrapNonce, fetchSettings, tauriRuntime])
|
||||||
|
|
||||||
// Listen for macOS native menu navigation events (About / Settings)
|
// Listen for macOS native menu navigation events (About / Settings)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauriRuntime()) return
|
if (!tauriRuntime) return
|
||||||
let unlisten: (() => void) | undefined
|
let unlisten: (() => void) | undefined
|
||||||
import('@tauri-apps/api/event')
|
import('@tauri-apps/api/event')
|
||||||
.then(({ listen }) =>
|
.then(({ listen }) =>
|
||||||
@ -77,6 +98,16 @@ export function AppShell() {
|
|||||||
|
|
||||||
useKeyboardShortcuts()
|
useKeyboardShortcuts()
|
||||||
|
|
||||||
|
if (!tauriRuntime && h5StartupError) {
|
||||||
|
return (
|
||||||
|
<H5ConnectionView
|
||||||
|
initialServerUrl={h5StartupError.serverUrl}
|
||||||
|
error={h5StartupError.message}
|
||||||
|
onConnected={() => setBootstrapNonce((value) => value + 1)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (startupError) {
|
if (startupError) {
|
||||||
return <StartupErrorView error={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() {
|
export function isTauriRuntime() {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false
|
||||||
return '__TAURI_INTERNALS__' in window || '__TAURI__' in window
|
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() {
|
export async function initializeDesktopServerUrl() {
|
||||||
const fallbackUrl = getDefaultBaseUrl()
|
const fallbackUrl = getDefaultBaseUrl()
|
||||||
const queryUrl =
|
|
||||||
typeof window !== 'undefined'
|
|
||||||
? new URLSearchParams(window.location.search).get('serverUrl')
|
|
||||||
: null
|
|
||||||
const requestedUrl = queryUrl?.trim() || fallbackUrl
|
|
||||||
|
|
||||||
if (!isTauriRuntime()) {
|
if (!isTauriRuntime()) {
|
||||||
setBaseUrl(requestedUrl)
|
return initializeBrowserServerUrl(fallbackUrl)
|
||||||
await waitForHealth(requestedUrl)
|
|
||||||
return requestedUrl
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { invoke } = await import('@tauri-apps/api/core')
|
const { invoke } = await import('@tauri-apps/api/core')
|
||||||
const serverUrl = await invoke<string>('get_server_url')
|
const serverUrl = await invoke<string>('get_server_url')
|
||||||
setBaseUrl(serverUrl)
|
setBaseUrl(serverUrl)
|
||||||
|
setAuthToken(null)
|
||||||
await waitForHealth(serverUrl)
|
await waitForHealth(serverUrl)
|
||||||
return serverUrl
|
return serverUrl
|
||||||
} catch (error) {
|
} 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) {
|
async function waitForHealth(serverUrl: string) {
|
||||||
let lastError: unknown
|
let lastError: unknown
|
||||||
|
|
||||||
@ -54,7 +177,55 @@ async function waitForHealth(serverUrl: string) {
|
|||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
lastError instanceof Error
|
lastError instanceof Error
|
||||||
? `Local server healthcheck failed: ${lastError.message}`
|
? `Server healthcheck failed: ${lastError.message}`
|
||||||
: 'Local server healthcheck failed',
|
: '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