diff --git a/capacitor.config.ts b/capacitor.config.ts index cc5b546d..6c21ff8f 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -40,14 +40,12 @@ const config: CapacitorConfig = { allowMixedContent: false, }, plugins: { - // Patch window.fetch + XMLHttpRequest to route through Swift's - // URLSession. The WKWebView's origin is `capacitor://localhost`, - // which CORS-blocks calls to the build-time configured HTTPS API unless - // the server explicitly allows that origin. Routing through the native layer sidesteps - // CORS entirely — the request reaches the API the same way curl - // would, no browser preflight involved. + // Do not patch window.fetch globally. LingxiLoop JSON requests explicitly + // use CapacitorHttp (src/api/transport.ts), while presigned File/Blob PUTs + // stay in the WebView fetch implementation and never cross the JS/native + // bridge as a duplicated binary payload. CapacitorHttp: { - enabled: true, + enabled: false, }, SplashScreen: { // Hide as soon as the WebView is ready — the launch storyboard diff --git a/docker-compose.production.yml b/docker-compose.production.yml index d63a1bd7..8d1850a4 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -139,6 +139,15 @@ services: redis: condition: service_healthy + # Deployment-only reconciler. When R2 is configured, deploy-production.sh + # runs this before the application cutover and the script reads the bucket + # policy back, failing the deployment if mobile WebView PUT origins are absent. + r2-cors: + <<: *lingxiloop-runtime + profiles: [tools] + restart: "no" + command: ["node", "server/scripts/r2-cors.mjs"] + lingxiloop: <<: *lingxiloop-runtime restart: unless-stopped diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 34d5ecac..73faabb8 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -25,5 +25,13 @@ OS, and verifies `/api/meta`, dependency health, authenticated channel access and the release version. Rollback restores both pre-cutover backups and the previous digest manifest. It never re-enables a retired runtime. +When all four core `R2_*` secrets are configured, the production deployment +also reconciles the bucket CORS policy before application cutover. The +deployment image applies the policy and reads it back, requiring presigned +`PUT` permission for the production web origin plus Electron, iOS +(`capacitor://localhost`), and Android (`https://localhost`) renderer origins. +Partial R2 configuration or a failed readback aborts the deployment. Operators +can add comma-separated origins with `R2_CORS_EXTRA_ORIGINS` in `.env.secrets`. + Desktop artifacts contain only the renderer and Electron shell. Package verification rejects server/runtime source and environment files. diff --git a/docs/mobile-upload-device-smoke.md b/docs/mobile-upload-device-smoke.md new file mode 100644 index 00000000..b28a3808 --- /dev/null +++ b/docs/mobile-upload-device-smoke.md @@ -0,0 +1,40 @@ +# Mobile presigned-upload acceptance + +This smoke test exercises the released mobile data path that unit tests cannot: + +```text +authenticated app -> /uploads/capabilities -> /uploads/presign + -> WebView fetch(File) PUT -> R2 +``` + +It must run against a deployment where `presignSupported` is true. The +production deployment reconciles and reads back the target bucket CORS policy +before cutover; a device run then proves the WebView origin and binary path. + +## Build the instrumented app + +The acceptance panel is opt-in and excluded from ordinary startup: + +```sh +VITE_MOBILE_UPLOAD_SMOKE=1 npm run mobile:sync +``` + +Open the iOS or Android project, install it on a physical device, sign in, and +select a representative 24 MiB file in the panel at the bottom of the app. This +stays just below LingxiLoop's 25 MiB attachment ceiling while exercising the +largest supported upload path. Keep Xcode Instruments (Allocations) or Android Studio Memory Profiler +recording from before file selection until at least ten seconds after upload. + +Run once on each platform and attach the panel's `[mobile-upload-smoke] RESULT` +object plus the profiler screenshot/export to the pull request. Acceptance +requires: + +- `status: passed`, an R2 object key, and no CORS/preflight error; +- the app remains responsive and is not terminated by the OS; +- peak process memory does not show a second file-sized allocation attributable + to a JavaScript/native HTTP bridge copy; +- a normal build without `VITE_MOBILE_UPLOAD_SMOKE=1` shows no smoke panel. + +Record device model, OS version, build commit, file size, elapsed time, baseline +memory, peak memory, and post-upload memory. JavaScript heap fields may be +`null` on iOS; the platform profiler is authoritative for peak process memory. diff --git a/electron/authNonce.cjs b/electron/authNonce.cjs new file mode 100644 index 00000000..9bb1143e --- /dev/null +++ b/electron/authNonce.cjs @@ -0,0 +1,35 @@ +/* eslint-env node */ + +function createAuthNonceGuard({ randomBytes, timingSafeEqual, now = Date.now, ttlMs }) { + let armedNonce = null + let armedExpiry = 0 + + return { + arm() { + armedNonce = randomBytes(16).toString('hex') + armedExpiry = now() + ttlMs + return armedNonce + }, + consume(nonce) { + if (!armedNonce) return false + if (now() > armedExpiry) { + armedNonce = null + armedExpiry = 0 + return false + } + if (typeof nonce !== 'string' || nonce.length !== armedNonce.length) return false + let matches = false + try { + matches = timingSafeEqual(Buffer.from(nonce), Buffer.from(armedNonce)) + } catch { + return false + } + if (!matches) return false + armedNonce = null + armedExpiry = 0 + return true + }, + } +} + +module.exports = { createAuthNonceGuard } diff --git a/electron/main.cjs b/electron/main.cjs index 9b3835ae..fbf55840 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -6,6 +6,7 @@ const http = require('node:http') const crypto = require('node:crypto') const { pathToFileURL } = require('node:url') const autoUpdater = require('./autoUpdater.cjs') +const { createAuthNonceGuard } = require('./authNonce.cjs') const isDev = !app.isPackaged const DEV_URL = process.env.ELECTRON_RENDERER_URL || 'http://localhost:5180' @@ -410,33 +411,24 @@ const AUTH_DONE_HTML = ` // process finds no armed nonce and is (correctly) dropped — they just sign in // again from the now-running app. That rare edge is the accepted cost of not // persisting a bearer-handoff credential to disk. -let armedAuthNonce = null -let armedAuthExpiry = 0 const AUTH_NONCE_TTL_MS = 10 * 60 * 1000 +const authNonceGuard = createAuthNonceGuard({ + randomBytes: crypto.randomBytes, + timingSafeEqual: crypto.timingSafeEqual, + ttlMs: AUTH_NONCE_TTL_MS, +}) /** Arm for one sign-in and return a fresh nonce the renderer appends to the * OAuth return URL. Supersedes any previous unused nonce. */ function armAuthHandoff() { - armedAuthNonce = crypto.randomBytes(16).toString('hex') - armedAuthExpiry = Date.now() + AUTH_NONCE_TTL_MS - return armedAuthNonce + return authNonceGuard.arm() } /** Validate + single-use-consume an inbound nonce. Constant-time compare so a - * mismatch leaks nothing; clears the armed nonce on any check so a token can - * be accepted at most once. */ + * mismatch leaks nothing; invalid callbacks preserve the current attempt, + * while a match or expiry clears it. */ function consumeAuthNonce(nonce) { - const armed = armedAuthNonce - const expiry = armedAuthExpiry - armedAuthNonce = null - armedAuthExpiry = 0 - if (!armed || Date.now() > expiry) return false - if (typeof nonce !== 'string' || nonce.length !== armed.length) return false - try { - return crypto.timingSafeEqual(Buffer.from(nonce), Buffer.from(armed)) - } catch { - return false - } + return authNonceGuard.consume(nonce) } /** Pull token + companyId + nonce out of a `lingxiloop://auth#token=…` URL. The OS diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index 1081b621..f378cd74 100755 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -25,6 +25,25 @@ for secret in OPEN_NOTEBOOK_PASSWORD OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_ exit 2 fi done + +r2_present=0 +r2_missing="" +for secret in R2_ENDPOINT R2_BUCKET R2_ACCESS_KEY_ID R2_SECRET_ACCESS_KEY; do + if grep -Eq "^${secret}=.+" .env.secrets; then + r2_present=$((r2_present + 1)) + else + r2_missing="${r2_missing} ${secret}" + fi +done +if [ "$r2_present" -gt 0 ] && [ "$r2_present" -lt 4 ]; then + echo "Incomplete R2 production configuration; missing:${r2_missing}" >&2 + exit 2 +fi +if [ "$r2_present" -eq 4 ]; then + r2_configured=true +else + r2_configured=false +fi if ! grep -Eq '^OPEN_NOTEBOOK_CHAT_MODEL=.+' .env.secrets && { ! grep -Eq '^OPEN_NOTEBOOK_STRATEGY_MODEL=.+' .env.secrets || ! grep -Eq '^OPEN_NOTEBOOK_ANSWER_MODEL=.+' .env.secrets || @@ -54,6 +73,15 @@ compose() { docker compose --env-file "$active_env" --env-file .env.secrets -f "$compose_file" "$@" } +configure_r2_cors() { + if [ "$r2_configured" = "true" ]; then + echo "Applying and verifying R2 CORS policy" + compose --profile tools run --rm --no-deps r2-cors + else + echo "R2 is not configured; skipping bucket CORS reconciliation" + fi +} + verify() { expected_sha="$(sed -n 's/^LINGXILOOP_COMMIT_SHA=//p' "$active_env")" expected_version="$(sed -n 's/^LINGXILOOP_VERSION=//p' "$active_env")" @@ -85,6 +113,7 @@ rollback() { } if ! compose pull || + ! configure_r2_cors || ! compose --profile tools run --rm migrate || ! compose up -d --remove-orphans || ! verify || diff --git a/server/scripts/r2-cors-policy.mjs b/server/scripts/r2-cors-policy.mjs new file mode 100644 index 00000000..85cdd31b --- /dev/null +++ b/server/scripts/r2-cors-policy.mjs @@ -0,0 +1,49 @@ +export const DEFAULT_R2_CORS_ORIGINS = [ + 'http://localhost:5173', + 'http://localhost:5180', + 'app://lingxiloop', + 'capacitor://localhost', + 'https://localhost', +] + +export function uniqueOrigins(extraOrigins = []) { + return [...new Set([ + ...DEFAULT_R2_CORS_ORIGINS, + ...extraOrigins.map((origin) => origin.trim().replace(/\/+$/, '')).filter(Boolean), + ])] +} + +export function buildR2CorsRules(origins) { + return [ + { + AllowedOrigins: origins, + AllowedMethods: ['PUT', 'GET', 'HEAD'], + AllowedHeaders: ['*'], + ExposeHeaders: ['ETag'], + MaxAgeSeconds: 3600, + }, + ] +} + +function ruleAllowsHeader(rule, header) { + const allowed = (rule.AllowedHeaders ?? []).map((value) => value.toLowerCase()) + return allowed.includes('*') || allowed.includes(header.toLowerCase()) +} + +/** Return actionable errors when a read-back policy cannot serve presigned PUTs. */ +export function validateR2CorsRules(rules, requiredOrigins) { + const errors = [] + for (const origin of requiredOrigins) { + const compatible = (rules ?? []).some((rule) => + (rule.AllowedOrigins ?? []).includes(origin) && + (rule.AllowedMethods ?? []).some((method) => method.toUpperCase() === 'PUT') && + ruleAllowsHeader(rule, 'content-type')) + if (!compatible) errors.push(`missing presigned PUT permission for origin ${origin}`) + } + return errors +} + +export function assertR2CorsRules(rules, requiredOrigins) { + const errors = validateR2CorsRules(rules, requiredOrigins) + if (errors.length > 0) throw new Error(`R2 CORS readback verification failed: ${errors.join('; ')}`) +} diff --git a/server/scripts/r2-cors.mjs b/server/scripts/r2-cors.mjs index dfa6c3dd..5d6ab294 100644 --- a/server/scripts/r2-cors.mjs +++ b/server/scripts/r2-cors.mjs @@ -29,26 +29,36 @@ * Inspect the current policy without changing anything: * * node server/scripts/r2-cors.mjs --print + * + * Verify that the current policy supports every required origin: + * + * node server/scripts/r2-cors.mjs --check */ import 'dotenv/config' import { S3Client, PutBucketCorsCommand, GetBucketCorsCommand } from '@aws-sdk/client-s3' +import { + assertR2CorsRules, + buildR2CorsRules, + uniqueOrigins, +} from './r2-cors-policy.mjs' // Origins that must be allowed to PUT directly to R2. Keep these in sync // with how each surface loads its renderer: // - http://localhost:5173 → browser Vite dev (vite.config.ts `port`) // - http://localhost:5180 → Electron dev renderer (electron/main.cjs DEV_URL) // - app://lingxiloop → packaged Electron (main.cjs loadURL app://lingxiloop/...) +// - capacitor://localhost → iOS Capacitor WebView +// - https://localhost → Android Capacitor WebView // Extra origins (prod web, alternate ports, …) come from CLI args. -const DEFAULT_ORIGINS = [ - 'http://localhost:5173', - 'http://localhost:5180', - 'app://lingxiloop', -] - const cliArgs = process.argv.slice(2) const printOnly = cliArgs.includes('--print') -const extraOrigins = cliArgs.filter((a) => !a.startsWith('--')) -const origins = [...new Set([...DEFAULT_ORIGINS, ...extraOrigins])] +const checkOnly = cliArgs.includes('--check') +const environmentOrigins = [ + process.env.LINGXILOOP_PUBLIC_ORIGIN ?? '', + ...(process.env.R2_CORS_EXTRA_ORIGINS ?? '').split(','), +] +const extraOrigins = [...environmentOrigins, ...cliArgs.filter((a) => !a.startsWith('--'))] +const origins = uniqueOrigins(extraOrigins) const { R2_ENDPOINT, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY } = process.env const missing = [ @@ -70,42 +80,44 @@ const client = new S3Client({ credentials: { accessKeyId: R2_ACCESS_KEY_ID, secretAccessKey: R2_SECRET_ACCESS_KEY }, }) -async function printCurrent(label) { +async function readCurrent(label) { try { const cur = await client.send(new GetBucketCorsCommand({ Bucket: R2_BUCKET })) - console.log(`[r2-cors] ${label}:`, JSON.stringify(cur.CORSRules ?? [], null, 2)) + const rules = cur.CORSRules ?? [] + console.log(`[r2-cors] ${label}:`, JSON.stringify(rules, null, 2)) + return rules } catch (e) { // R2 returns NoSuchCORSConfiguration when nothing is set yet. - if (e?.name === 'NoSuchCORSConfiguration') console.log(`[r2-cors] ${label}: (none set)`) + if (e?.name === 'NoSuchCORSConfiguration') { + console.log(`[r2-cors] ${label}: (none set)`) + return [] + } else throw e } } if (printOnly) { - await printCurrent(`current CORS for ${R2_BUCKET}`) + await readCurrent(`current CORS for ${R2_BUCKET}`) + process.exit(0) +} + +if (checkOnly) { + const rules = await readCurrent(`current CORS for ${R2_BUCKET}`) + assertR2CorsRules(rules, origins) + console.log(`[r2-cors] verified presigned PUT policy for ${origins.length} origins`) process.exit(0) } await client.send(new PutBucketCorsCommand({ Bucket: R2_BUCKET, CORSConfiguration: { - CORSRules: [ - { - AllowedOrigins: origins, - // PUT is what the browser upload does. GET/HEAD are harmless and - // cover any future cross-origin presigned read. - AllowedMethods: ['PUT', 'GET', 'HEAD'], - // "*" so the preflight's Access-Control-Request-Headers (Content-Type, - // and anything we add later) always passes. - AllowedHeaders: ['*'], - ExposeHeaders: ['ETag'], - MaxAgeSeconds: 3600, - }, - ], + CORSRules: buildR2CorsRules(origins), }, })) console.log(`[r2-cors] ✅ applied to bucket "${R2_BUCKET}" for origins:`) for (const o of origins) console.log(` - ${o}`) -await printCurrent('readback') +const readback = await readCurrent('readback') +assertR2CorsRules(readback, origins) +console.log(`[r2-cors] verified presigned PUT policy for ${origins.length} origins`) console.log('[r2-cors] done — no server restart needed; just retry the upload.') diff --git a/server/src/__tests__/r2-cors-deployment.test.ts b/server/src/__tests__/r2-cors-deployment.test.ts new file mode 100644 index 00000000..25b09c35 --- /dev/null +++ b/server/src/__tests__/r2-cors-deployment.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +// @ts-expect-error The production helper is plain ESM so Node can execute it directly. +import * as r2CorsPolicy from '../../scripts/r2-cors-policy.mjs' + +const { + assertR2CorsRules, + buildR2CorsRules, + DEFAULT_R2_CORS_ORIGINS, + uniqueOrigins, + validateR2CorsRules, +} = r2CorsPolicy + +test('R2 policy covers iOS and Android presigned PUT preflights', () => { + const origins = uniqueOrigins(['https://loop.example.com/']) + const rules = buildR2CorsRules(origins) + + assert.ok(origins.includes('capacitor://localhost')) + assert.ok(origins.includes('https://localhost')) + assert.ok(origins.includes('https://loop.example.com')) + assert.deepEqual(validateR2CorsRules(rules, origins), []) + assert.doesNotThrow(() => assertR2CorsRules(rules, origins)) +}) + +test('R2 readback validation rejects a policy without mobile PUT access', () => { + const webOnly = buildR2CorsRules(['https://loop.example.com']) + const errors = validateR2CorsRules(webOnly, DEFAULT_R2_CORS_ORIGINS) + + assert.ok(errors.some((error: string) => error.includes('capacitor://localhost'))) + assert.ok(errors.some((error: string) => error.includes('https://localhost'))) + assert.throws( + () => assertR2CorsRules(webOnly, DEFAULT_R2_CORS_ORIGINS), + /R2 CORS readback verification failed/, + ) +}) + +test('production deployment applies and verifies R2 CORS before cutover', () => { + const compose = readFileSync(new URL('../../../docker-compose.production.yml', import.meta.url), 'utf8') + const deploy = readFileSync(new URL('../../../scripts/deploy-production.sh', import.meta.url), 'utf8') + const cors = readFileSync(new URL('../../scripts/r2-cors.mjs', import.meta.url), 'utf8') + + assert.match(compose, /\n {2}r2-cors:\n[\s\S]*command: \["node", "server\/scripts\/r2-cors\.mjs"\]/) + assert.match(deploy, /! configure_r2_cors \|\|\n\s+! compose --profile tools run --rm migrate/) + assert.match(deploy, /compose --profile tools run --rm --no-deps r2-cors/) + assert.match(cors, /assertR2CorsRules\(readback, origins\)/) +}) diff --git a/src/App.tsx b/src/App.tsx index 83bee936..c721eac3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,4 @@ -import { useEffect, useState } from 'react' -import { AdminApp } from '@/admin/AdminApp' +import { lazy, Suspense, useEffect, useState } from 'react' import { consumeSuspendedFragment, SuspendedScreen } from '@/admin/SuspendedScreen' import { consumeWaitlistFragment, WaitlistConfirmedScreen } from '@/admin/WaitlistConfirmedScreen' import { api } from '@/api/client' @@ -12,14 +11,10 @@ import { InviteAcceptScreen, } from '@/components/InviteAcceptScreen' import { NotificationToasts } from '@/components/NotificationToasts' -import { NotificationWindow } from '@/components/NotificationWindow' import { UpdateBanner, UpdaterDialog } from '@/components/UpdaterDialog' -import { DesktopApp } from '@/desktop/DesktopApp' import { seedMockIm } from '@/dev/mockIm' import { isMockImDevelopment } from '@/lib/devMode' -import { isNotificationWindow } from '@/lib/runtime' import { useIsMobile } from '@/lib/utils' -import { MobileApp } from '@/mobile/MobileApp' import { useApp } from '@/stores/app' import { useAuth } from '@/stores/auth' import { bootConversations, isMuted, useConversations } from '@/stores/conversations' @@ -27,7 +22,14 @@ import { bootMessagesStream, useMessages } from '@/stores/messages' import { bootParticipants } from '@/stores/participants' import { usePrefs } from '@/stores/preferences' import { bootWhispers, useWhispers } from '@/stores/whispers' -import '@/admin/admin.css' + +const AdminApp = lazy(() => import('@/admin/AdminApp').then((module) => ({ default: module.AdminApp }))) +const DesktopApp = lazy(() => import('@/desktop/DesktopApp').then((module) => ({ default: module.DesktopApp }))) +const MobileApp = lazy(() => import('@/mobile/MobileApp').then((module) => ({ default: module.MobileApp }))) + +function SurfaceFallback() { + return
Loading…
+} /** True iff this browser tab is for the admin panel. An optional `admin.*` * hostname or the `/admin` path prefix triggers it. We check both so dev can hit @@ -95,7 +97,9 @@ function AuthedApp({ mockMode = false }: { mockMode?: boolean }) { return ( <> - {isMobile ? : } + }> + {isMobile ? : } + {/* In-app message toasts (window-blur / different-convo only) — rendered at the AuthedApp level so they share auth context and unmount cleanly on sign-out. */} @@ -107,12 +111,6 @@ function AuthedApp({ mockMode = false }: { mockMode?: boolean }) { } export function App() { - // The Electron notification BrowserWindow loads this same React bundle - // with a `#notifications` hash. Bypass everything else (auth, stores, - // routing) and just render the toast stack — it receives payloads over - // IPC from the main window. - if (isNotificationWindow) return - // Local Vite development opens straight into a deterministic IM workspace. // `?api=1` remains available for explicitly testing the real auth/API stack. if (isMockImDevelopment() && !isAdminContext()) { @@ -178,7 +176,7 @@ export function App() { return ( - + }> ) diff --git a/src/admin/AdminApp.tsx b/src/admin/AdminApp.tsx index 934a3e81..c785b53c 100644 --- a/src/admin/AdminApp.tsx +++ b/src/admin/AdminApp.tsx @@ -15,6 +15,7 @@ * the basePath is `/admin`. */ import { useEffect, useState } from 'react' +import './admin.css' import { CloudLogo } from '@/components/Avatar' import { useAuth } from '@/stores/auth' import { type AdminStats, adminApi } from './api' diff --git a/src/admin/SuspendedScreen.tsx b/src/admin/SuspendedScreen.tsx index 547a063b..9b47100b 100644 --- a/src/admin/SuspendedScreen.tsx +++ b/src/admin/SuspendedScreen.tsx @@ -14,6 +14,7 @@ * the next time they try to sign in. */ import { useState } from 'react' +import './auth-state.css' interface CarriedSuspension { email: string | null; reason: string | null } @@ -56,7 +57,7 @@ export function SuspendedScreen({ email, reason }: { email: string | null; reaso 如果您认为这是一个错误,请回复我们发送给您的最新消息,或者 联系您的工作区所有者。 - diff --git a/src/admin/WaitlistConfirmedScreen.tsx b/src/admin/WaitlistConfirmedScreen.tsx index f126fea6..de3bdbf8 100644 --- a/src/admin/WaitlistConfirmedScreen.tsx +++ b/src/admin/WaitlistConfirmedScreen.tsx @@ -8,6 +8,7 @@ */ import { useState } from 'react' import { GetDesktopAppLink } from '@/components/GetDesktopAppLink' +import './auth-state.css' interface CarriedWaitlist { email: string | null } @@ -51,7 +52,7 @@ export function WaitlistConfirmedScreen({ email }: { email: string | null }) {
想要抢占先机吗? — 一旦获得批准,您只需在工作区中单击一下即可。
- diff --git a/src/admin/admin.css b/src/admin/admin.css index daf3ca5a..18c77951 100644 --- a/src/admin/admin.css +++ b/src/admin/admin.css @@ -325,28 +325,6 @@ } .admin-switch.is-on .admin-switch-thumb { transform: translateX(20px); } -/* ===== Waitlist confirm screen (renderer-side, not panel) ===== */ -.lingxiloop-waitlist-screen { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 24px; - background: var(--paper); -} -.lingxiloop-waitlist-card { - max-width: 460px; - text-align: center; - background: var(--cloud); - padding: 40px 32px; - border-radius: 16px; - border: 1px solid var(--ink-100); -} -.lingxiloop-waitlist-emoji { font-size: 48px; margin-bottom: 16px; } -.lingxiloop-waitlist-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; } -.lingxiloop-waitlist-sub { font-size: 14px; color: var(--ink-500); line-height: 1.6; } -.lingxiloop-waitlist-email { font-weight: 600; color: var(--ink-900); } - /* ===== Mobile topbar (hidden on desktop) ===== */ .admin-topbar { display: none; } .admin-nav-scrim { display: none; } @@ -510,31 +488,6 @@ .admin-detail-grid { grid-template-columns: 1fr; } } -/* ===== Suspended screen — admin-supplied reason callout ===== */ -.lingxiloop-suspended-reason { - text-align: left; - background: var(--paper); - border: 1px solid var(--ink-100); - border-radius: 10px; - padding: 12px 14px; - margin: 4px 0 0; -} -.lingxiloop-suspended-reason-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--ink-500); - margin-bottom: 4px; -} -.lingxiloop-suspended-reason-body { - font-size: 14px; - color: var(--ink-900); - line-height: 1.5; - white-space: pre-wrap; - word-break: break-word; -} - /* ===================================================================== * Observability page — admin-only, sub2api spend by business purpose. * Visual goals: stylish, refined, rich. Anchored by a gradient Spend diff --git a/src/admin/api.ts b/src/admin/api.ts index c52fbd91..c8330381 100644 --- a/src/admin/api.ts +++ b/src/admin/api.ts @@ -9,6 +9,7 @@ * is what authenticates admin calls. */ import { getAuthToken, useAuth } from '@/stores/auth' +import { lingxiApiFetch, mergeRequestHeaders } from '@/api/transport' function origin(): string { if (typeof localStorage !== 'undefined') { @@ -24,9 +25,9 @@ async function http(path: string, init?: RequestInit): Promise { const headers: Record = { 'content-type': 'application/json' } const token = getAuthToken() if (token) headers.authorization = `Bearer ${token}` - const res = await fetch(`${origin()}/api/admin${path}`, { - headers: { ...headers, ...(init?.headers ?? {}) }, + const res = await lingxiApiFetch(`${origin()}/api/admin${path}`, { ...init, + headers: mergeRequestHeaders(headers, init?.headers), }) if (res.status === 401) { useAuth.getState().clear() diff --git a/src/admin/auth-state.css b/src/admin/auth-state.css new file mode 100644 index 00000000..af51e33e --- /dev/null +++ b/src/admin/auth-state.css @@ -0,0 +1,67 @@ +.lingxiloop-waitlist-screen { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: var(--paper); +} + +.lingxiloop-waitlist-card { + max-width: 460px; + text-align: center; + background: var(--cloud); + padding: 40px 32px; + border-radius: 16px; + border: 1px solid var(--ink-100); +} + +.lingxiloop-waitlist-emoji { font-size: 48px; margin-bottom: 16px; } +.lingxiloop-waitlist-title { font-size: 22px; font-weight: 700; margin-bottom: 8px; } +.lingxiloop-waitlist-sub { font-size: 14px; color: var(--ink-500); line-height: 1.6; } +.lingxiloop-waitlist-email { font-weight: 600; color: var(--ink-900); } + +.lingxiloop-auth-state-button { + border-radius: 8px; + padding: 7px 14px; + border: 1px solid var(--ink-200); + background: transparent; + color: var(--ink-700); + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.12s, color 0.12s; +} + +.lingxiloop-auth-state-button:hover { background: var(--ink-100); } + +.lingxiloop-suspended-reason { + text-align: left; + background: var(--paper); + border: 1px solid var(--ink-100); + border-radius: 10px; + padding: 12px 14px; + margin: 4px 0 0; +} + +.lingxiloop-suspended-reason-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-500); + margin-bottom: 4px; +} + +.lingxiloop-suspended-reason-body { + font-size: 14px; + color: var(--ink-900); + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +@media (max-width: 900px) { + .lingxiloop-auth-state-button { padding: 8px 14px; } +} diff --git a/src/api/client.ts b/src/api/client.ts index 258b92dc..89f8f626 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -2,6 +2,7 @@ import { getActiveCompanyId, getAuthToken, useAuth } from '@/stores/auth' import { getWorkspaceSession } from '@/lib/workspaceSession' import { isMockImDevelopment } from '@/lib/devMode' import { normalizeCourseContract } from './courseContract' +import { lingxiApiFetch, mergeRequestHeaders, putPresignedFile } from './transport' export { normalizeCourseContract } from './courseContract' import type { AgentCapability, @@ -112,9 +113,9 @@ export async function http(path: string, init?: RequestInit): Promise { const workspace = getWorkspaceSession() if (workspace && workspace.companyId === company) headers['x-project-id'] = workspace.projectId if (getDevModeEnabled()) headers['x-lingxiloop-dev-mode'] = '1' - const res = await fetch(`${API}${path}`, { - headers: { ...headers, ...(init?.headers ?? {}) }, + const res = await lingxiApiFetch(`${API}${path}`, { ...init, + headers: mergeRequestHeaders(headers, init?.headers), }) // Auto-clear session on 401 so the AuthGate boots back to the login screen. if (res.status === 401 && !path.startsWith('/auth/')) { @@ -936,7 +937,7 @@ export const api = { const signed = await http<{ id: string; uploadUrl: string; mime: string; size: number }>(`/conversations/${encodeURIComponent(conversationId)}/sources/upload/presign`, { method: 'POST', body: JSON.stringify({ name: file.name, mime, size: file.size }), }) - const response = await fetch(signed.uploadUrl, { method: 'PUT', headers: { 'Content-Type': mime }, body: file }) + const response = await putPresignedFile(signed.uploadUrl, file, mime) if (!response.ok) throw new Error(`source upload failed: ${response.status}`) await http(`/conversations/${encodeURIComponent(conversationId)}/sources/${encodeURIComponent(signed.id)}/complete-upload`, { method: 'POST' }) return @@ -1366,7 +1367,7 @@ export const api = { const company = getActiveCompanyId() if (company) headers['x-company-id'] = company if (getDevModeEnabled()) headers['x-lingxiloop-dev-mode'] = '1' - const res = await fetch(`${API}/email/${encodeURIComponent(messageId)}/html`, { headers }) + const res = await lingxiApiFetch(`${API}/email/${encodeURIComponent(messageId)}/html`, { headers }) if (res.status === 204) return null if (!res.ok) { const text = await res.text().catch(() => '') @@ -1421,11 +1422,7 @@ export const api = { }) // Step 2 — PUT the raw bytes directly to R2. No auth header; the // presigned URL carries everything the bucket needs. - const r = await fetch(signed.uploadUrl, { - method: 'PUT', - headers: { 'Content-Type': mime }, - body: file, - }) + const r = await putPresignedFile(signed.uploadUrl, file, mime) if (!r.ok) { const text = await r.text().catch(() => '') throw new Error(`R2 PUT failed: ${r.status} ${text.slice(0, 200)}`) @@ -1787,7 +1784,7 @@ class WsClient { // / referrer headers). The ticket is consumed atomically server-side. let ticket: string try { - const r = await fetch(`${API}/auth/ws-ticket`, { + const r = await lingxiApiFetch(`${API}/auth/ws-ticket`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, }) diff --git a/src/api/transport.test.ts b/src/api/transport.test.ts new file mode 100644 index 00000000..67f47cc6 --- /dev/null +++ b/src/api/transport.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { lingxiApiFetch, mergeRequestHeaders, putPresignedFile } from './transport' + +test('caller headers override matching defaults without dropping auth or tenant context', () => { + const headers = mergeRequestHeaders({ + authorization: 'Bearer token', + 'content-type': 'application/json', + 'x-company-id': 'co-1', + 'x-project-id': 'project-1', + }, new Headers([ + ['Content-Type', 'text/plain'], + ['x-custom-header', 'custom'], + ])) + + assert.equal(headers.get('authorization'), 'Bearer token') + assert.equal(headers.get('x-company-id'), 'co-1') + assert.equal(headers.get('x-project-id'), 'project-1') + assert.equal(headers.get('content-type'), 'text/plain') + assert.equal(headers.get('x-custom-header'), 'custom') +}) + +test('native Lingxi API transport uses explicit CapacitorHttp semantics', async () => { + let options: unknown + const response = await lingxiApiFetch('https://loop.example/api/test', { + method: 'POST', + headers: { authorization: 'Bearer token' }, + body: '{"ok":true}', + }, { + native: true, + nativeRequest: async (value) => { + options = value + return { status: 201, data: { accepted: true }, headers: { 'content-type': 'application/json' }, url: value.url } + }, + }) + + assert.deepEqual(options, { + url: 'https://loop.example/api/test', + method: 'POST', + headers: { authorization: 'Bearer token' }, + data: '{"ok":true}', + responseType: 'text', + }) + assert.equal(response.status, 201) + assert.deepEqual(await response.json(), { accepted: true }) +}) + +test('native Lingxi API transport preserves bodyless response statuses', async () => { + const response = await lingxiApiFetch('https://loop.example/api/empty', {}, { + native: true, + nativeRequest: async (value) => ({ + status: 204, + data: '', + headers: {}, + url: value.url, + }), + }) + assert.equal(response.status, 204) + assert.equal(await response.text(), '') +}) + +test('presigned uploads keep the File body on the browser fetch path', async () => { + const file = new File(['payload'], 'notes.txt', { type: 'text/plain' }) + let input: Parameters | null = null + const response = await putPresignedFile('https://r2.example/signed', file, 'text/plain', async (...args) => { + input = args + return new Response(null, { status: 200 }) + }) + assert.equal(response.status, 200) + assert.ok(input) + assert.equal(input[0], 'https://r2.example/signed') + assert.equal((input[1] as RequestInit).body, file) +}) diff --git a/src/api/transport.ts b/src/api/transport.ts new file mode 100644 index 00000000..4542f448 --- /dev/null +++ b/src/api/transport.ts @@ -0,0 +1,74 @@ +import type { HttpOptions, HttpResponse } from '@capacitor/core' +import { isCapacitorNative } from '@/lib/runtime' + +type NativeRequest = (options: HttpOptions) => Promise + +/** Merge caller headers without discarding unrelated auth/tenant defaults. */ +export function mergeRequestHeaders( + defaults: HeadersInit, + overrides?: HeadersInit, +): Headers { + const merged = new Headers(defaults) + new Headers(overrides).forEach((value, key) => { + merged.set(key, value) + }) + return merged +} + +async function defaultNativeRequest(options: HttpOptions): Promise { + const { CapacitorHttp } = await import('@capacitor/core') + return CapacitorHttp.request(options) +} + +function responseBody(data: unknown, status: number): BodyInit | null { + if (status === 204 || status === 205 || status === 304) return null + if (data == null) return null + if (typeof data === 'string') return data + return JSON.stringify(data) +} + +/** + * Fetch a LingxiLoop API URL. Native shells call CapacitorHttp explicitly so + * ordinary JSON traffic bypasses CORS without globally patching window.fetch. + * Presigned Blob uploads intentionally do not use this transport. + */ +export async function lingxiApiFetch( + url: string, + init: RequestInit = {}, + runtime: { native?: boolean; nativeRequest?: NativeRequest } = {}, +): Promise { + const native = runtime.native ?? isCapacitorNative + if (!native) return fetch(url, init) + if (init.signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError') + if (init.body != null && typeof init.body !== 'string') { + throw new TypeError('Native LingxiLoop API requests require a serialized string body') + } + + const headers = Object.fromEntries(new Headers(init.headers).entries()) + const request = runtime.nativeRequest ?? defaultNativeRequest + const result = await request({ + url, + method: init.method ?? 'GET', + headers, + data: init.body ?? undefined, + responseType: 'text', + }) + return new Response(responseBody(result.data, result.status), { + status: result.status, + headers: result.headers, + }) +} + +/** Raw Blob PUT used only for presigned object-storage uploads. */ +export function putPresignedFile( + uploadUrl: string, + file: File, + mime: string, + browserFetch: typeof fetch = fetch, +): Promise { + return browserFetch(uploadUrl, { + method: 'PUT', + headers: { 'Content-Type': mime }, + body: file, + }) +} diff --git a/src/components/AuthScreen.tsx b/src/components/AuthScreen.tsx index 89ace031..a817bed9 100644 --- a/src/components/AuthScreen.tsx +++ b/src/components/AuthScreen.tsx @@ -13,13 +13,7 @@ */ import { useState, useEffect } from 'react' import { api, getServerOrigin } from '@/api/client' -import { isElectron } from '@/lib/runtime' -import { - armNativeOAuthHandoff, - handleNativeOAuthCallback, - isNativePlatform, - runOAuth, -} from '@/lib/native' +import { isCapacitorNative, isElectron } from '@/lib/runtime' import { CloudLogo } from './Avatar' import { WindowDragStrip } from './WindowDragStrip' @@ -94,7 +88,7 @@ export function AuthScreen() { })() return } - if (isNativePlatform()) { + if (isCapacitorNative) { // iOS / Android: run the OAuth flow inside ASWebAuthenticationSession // (our WebAuthPlugin). It hands the final lingxiloop://auth#... callback // straight back to us — no SFSafariViewController, no broken 302 @@ -105,10 +99,15 @@ export function AuthScreen() { setBusy(null) return } - const nonce = armNativeOAuthHandoff() - const ret = encodeURIComponent(`lingxiloop://auth?n=${encodeURIComponent(nonce)}`) void (async () => { try { + const { + armNativeOAuthHandoff, + handleNativeOAuthCallback, + runOAuth, + } = await import('@/lib/native') + const nonce = armNativeOAuthHandoff() + const ret = encodeURIComponent(`lingxiloop://auth?n=${encodeURIComponent(nonce)}`) const callbackUrl = await runOAuth({ url: `${origin}/api/auth/start/${provider}?return=${ret}`, callbackScheme: 'lingxiloop', diff --git a/src/dev/mobileUploadSmoke.ts b/src/dev/mobileUploadSmoke.ts new file mode 100644 index 00000000..0c6107e4 --- /dev/null +++ b/src/dev/mobileUploadSmoke.ts @@ -0,0 +1,89 @@ +import { api } from '@/api/client' + +interface MemoryPerformance extends Performance { + memory?: { usedJSHeapSize?: number } +} + +function usedJsHeapBytes(): number | null { + return (performance as MemoryPerformance).memory?.usedJSHeapSize ?? null +} + +function formatBytes(bytes: number | null): string { + if (bytes == null) return 'unavailable' + return `${(bytes / 1024 / 1024).toFixed(1)} MiB` +} + +/** + * Install an opt-in, on-device upload acceptance panel. This module is only + * bundled when VITE_MOBILE_UPLOAD_SMOKE=1 and is never on the normal startup + * path. Select a real large file so the measurement includes the exact + * presign + WebView Blob PUT path used by chat attachments. + */ +export function installMobileUploadSmoke(): void { + const panel = document.createElement('aside') + panel.setAttribute('data-mobile-upload-smoke', '') + panel.style.cssText = [ + 'position:fixed', 'inset:auto 12px 12px 12px', 'z-index:2147483647', + 'padding:12px', 'border-radius:10px', 'background:#111827', 'color:white', + 'font:12px/1.4 ui-monospace,monospace', 'box-shadow:0 8px 30px #0008', + ].join(';') + + const title = document.createElement('strong') + title.textContent = 'Mobile upload smoke (choose a 24 MiB file)' + const input = document.createElement('input') + input.type = 'file' + input.style.cssText = 'display:block;margin:8px 0;color:white' + const run = document.createElement('button') + run.type = 'button' + run.textContent = 'Run presigned upload' + run.disabled = true + const output = document.createElement('pre') + output.style.cssText = 'margin:8px 0 0;white-space:pre-wrap;max-height:160px;overflow:auto' + output.textContent = 'Authenticate first, then choose a representative large file.' + + input.addEventListener('change', () => { + run.disabled = !input.files?.[0] + }) + run.addEventListener('click', () => { + const file = input.files?.[0] + if (!file) return + if (file.size < 20 * 1024 * 1024 || file.size > 25 * 1024 * 1024) { + output.textContent = 'Choose a file between 20 MiB and the 25 MiB product limit.' + return + } + run.disabled = true + const startedAt = performance.now() + const heapBefore = usedJsHeapBytes() + output.textContent = `Uploading ${file.name} (${formatBytes(file.size)})\u2026` + void api.uploadFile(file).then((attachment) => { + const heapAfter = usedJsHeapBytes() + const result = { + status: 'passed', + platform: window.Capacitor?.getPlatform?.() ?? 'unknown', + bytes: file.size, + durationMs: Math.round(performance.now() - startedAt), + jsHeapBeforeBytes: heapBefore, + jsHeapAfterBytes: heapAfter, + jsHeapDeltaBytes: heapBefore == null || heapAfter == null ? null : heapAfter - heapBefore, + uploadedKey: attachment.key ?? null, + } + output.textContent = JSON.stringify(result, null, 2) + console.info('[mobile-upload-smoke] RESULT', result) + }).catch((error: unknown) => { + const result = { + status: 'failed', + platform: window.Capacitor?.getPlatform?.() ?? 'unknown', + bytes: file.size, + durationMs: Math.round(performance.now() - startedAt), + error: error instanceof Error ? error.message : String(error), + } + output.textContent = JSON.stringify(result, null, 2) + console.error('[mobile-upload-smoke] RESULT', result) + }).finally(() => { + run.disabled = false + }) + }) + + panel.append(title, input, run, output) + document.body.appendChild(panel) +} diff --git a/src/lib/appEntryBoundaries.test.ts b/src/lib/appEntryBoundaries.test.ts new file mode 100644 index 00000000..54b306d6 --- /dev/null +++ b/src/lib/appEntryBoundaries.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +test('shared entry keeps notification, native, analytics, and app surfaces behind dynamic boundaries', () => { + const main = readFileSync(new URL('../main.tsx', import.meta.url), 'utf8') + const app = readFileSync(new URL('../App.tsx', import.meta.url), 'utf8') + const suspended = readFileSync(new URL('../admin/SuspendedScreen.tsx', import.meta.url), 'utf8') + const waitlist = readFileSync(new URL('../admin/WaitlistConfirmedScreen.tsx', import.meta.url), 'utf8') + + assert.match(main, /import\('\.\/components\/NotificationWindow'\)/) + assert.match(main, /import\('\.\/lib\/native'\)/) + assert.doesNotMatch(main, /await bootNative\(\)/) + assert.match(main, /void import\('\.\/lib\/native'\)\.then/) + assert.match(main, /import\('\.\/observability-entry'\)/) + assert.match(main, /VITE_MOBILE_UPLOAD_SMOKE === '1'/) + assert.match(main, /import\('\.\/dev\/mobileUploadSmoke'\)/) + assert.match(main, /import\('\.\/App'\)/) + assert.match(app, /lazy\(\(\) => import\('@\/admin\/AdminApp'\)/) + assert.match(app, /lazy\(\(\) => import\('@\/desktop\/DesktopApp'\)/) + assert.match(app, /lazy\(\(\) => import\('@\/mobile\/MobileApp'\)/) + assert.match(suspended, /import '\.\/auth-state\.css'/) + assert.match(waitlist, /import '\.\/auth-state\.css'/) +}) diff --git a/src/lib/avatarCache.ts b/src/lib/avatarCache.ts index e5763a4d..8523a262 100644 --- a/src/lib/avatarCache.ts +++ b/src/lib/avatarCache.ts @@ -18,7 +18,7 @@ * swap to the new objectUrl first. */ import { useCallback, useEffect, useRef, useState } from 'react' -import { isNativePlatform } from './native' +import { isCapacitorNative } from './runtime' interface Entry { /** The remote URL we fetched FROM — used to invalidate when the URL @@ -161,21 +161,20 @@ export function useAvatarImg(cachedSrc: string | null): { * silently never fire inside transformed/virtualized scroll containers * (the row count is already bounded by virtualization, so eager is * cheap there); browsers keep the lazy win. */ -export const AVATAR_IMG_LOADING: 'eager' | 'lazy' = isNativePlatform() ? 'eager' : 'lazy' +export const AVATAR_IMG_LOADING: 'eager' | 'lazy' = isCapacitorNative ? 'eager' : 'lazy' export function useCachedAvatarSrc( participantId: string, url: string | null | undefined, ): string | null { // On native (iOS/Android) skip the fetch→blob cache entirely and hand the - // raw CDN URL straight to . Two reasons: (1) with CapacitorHttp enabled - // every `fetch()` is proxied through the slow JS↔native bridge (and stampedes - // when many avatars mount at once), whereas loads go through the - // WebView's native image pipeline — fast and HTTP-cached; (2) it sidesteps - // the cache-stampede of N components fetching the same URL before it's cached. + // raw CDN URL straight to . This keeps avatar loads in the WebView's + // native image pipeline (HTTP-cached and free of extra Blob copies), avoids + // a cache stampede when many avatars mount, and keeps CDN reads separate + // from the JSON-oriented native API transport. // Invalidation still works: a regenerated avatar gets a new URL, so the prop // changes and reloads. - const native = isNativePlatform() + const native = isCapacitorNative const initial = (() => { if (!url) return null diff --git a/src/lib/electronAuthNonce.test.ts b/src/lib/electronAuthNonce.test.ts new file mode 100644 index 00000000..2782e2d7 --- /dev/null +++ b/src/lib/electronAuthNonce.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +import test from 'node:test' + +const require = createRequire(import.meta.url) +const { createAuthNonceGuard } = require('../../electron/authNonce.cjs') as { + createAuthNonceGuard: (options: { + randomBytes: () => Buffer + timingSafeEqual: (left: Buffer, right: Buffer) => boolean + now: () => number + ttlMs: number + }) => { arm: () => string; consume: (nonce: unknown) => boolean } +} + +test('invalid Electron callbacks preserve the active nonce until a valid match', () => { + const guard = createAuthNonceGuard({ + randomBytes: () => Buffer.from('00112233445566778899aabbccddeeff', 'hex'), + timingSafeEqual: (left, right) => left.equals(right), + now: () => 1_000, + ttlMs: 10_000, + }) + const nonce = guard.arm() + assert.equal(guard.consume(null), false) + assert.equal(guard.consume('short'), false) + assert.equal(guard.consume('ffffffffffffffffffffffffffffffff'), false) + assert.equal(guard.consume(nonce), true) + assert.equal(guard.consume(nonce), false, 'a successful match remains single-use') +}) + +test('expired Electron nonce is cleared', () => { + let now = 1_000 + const guard = createAuthNonceGuard({ + randomBytes: () => Buffer.alloc(16, 1), + timingSafeEqual: (left, right) => left.equals(right), + now: () => now, + ttlMs: 100, + }) + const nonce = guard.arm() + now = 1_101 + assert.equal(guard.consume(nonce), false) + assert.equal(guard.consume(nonce), false) +}) diff --git a/src/lib/im/wukong.ts b/src/lib/im/wukong.ts index 9f70e73d..21be1f0c 100644 --- a/src/lib/im/wukong.ts +++ b/src/lib/im/wukong.ts @@ -1,5 +1,6 @@ import WKSDK, { MessageContent, type WKEvent, type Message as WKMessage } from 'wukongimjssdk' import { getServerOrigin } from '@/api/client' +import { lingxiApiFetch } from '@/api/transport' import { getActiveCompanyId, getAuthToken } from '@/stores/auth' import { isEmptyHistoryDetail, isInternalAgentStatus } from './historyErrors' @@ -125,7 +126,7 @@ export class LingxiImClient { async connect(): Promise { if (this.started && this.sdk.connectManager.connected()) return - const response = await fetch(`${getServerOrigin()}/api/im/bootstrap`, { headers: authHeaders() }) + const response = await lingxiApiFetch(`${getServerOrigin()}/api/im/bootstrap`, { headers: authHeaders() }) if (!response.ok) throw new Error(`IM bootstrap failed: ${response.status}`) const bootstrap = await response.json() as Bootstrap this.sdk.config.uid = bootstrap.uid @@ -152,7 +153,7 @@ export class LingxiImClient { } async history(channelId: string, limit = 80): Promise { - const response = await fetch(`${getServerOrigin()}/api/im/channels/${encodeURIComponent(channelId)}/messages?limit=${limit}`, { headers: authHeaders() }) + const response = await lingxiApiFetch(`${getServerOrigin()}/api/im/channels/${encodeURIComponent(channelId)}/messages?limit=${limit}`, { headers: authHeaders() }) if (!response.ok) { const detail = await response.text().catch(() => '') // Older API deployments can forward WuKongIM's empty-channel result as @@ -169,7 +170,7 @@ export class LingxiImClient { } async send(channelId: string, payload: LingxiMessageV1, channelType = 2): Promise { - const response = await fetch(`${getServerOrigin()}/api/im/channels/${encodeURIComponent(channelId)}/messages/accept`, { + const response = await lingxiApiFetch(`${getServerOrigin()}/api/im/channels/${encodeURIComponent(channelId)}/messages/accept`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ clientNonce: payload.clientMsgNo, payload, channelType }), }) if (!response.ok) { @@ -181,7 +182,7 @@ export class LingxiImClient { } async sendStatus(clientNonce: string): Promise<{ status: string; echo?: ImEnvelope; error?: string }> { - const response = await fetch(`${getServerOrigin()}/api/im/sends/${encodeURIComponent(clientNonce)}`, { headers: authHeaders() }) + const response = await lingxiApiFetch(`${getServerOrigin()}/api/im/sends/${encodeURIComponent(clientNonce)}`, { headers: authHeaders() }) if (response.status === 404) return { status: 'missing' } if (!response.ok) throw new Error(`IM send recovery failed: ${response.status}`) return await response.json() as { status: string; echo?: ImEnvelope; error?: string } diff --git a/src/lib/native.ts b/src/lib/native.ts index be0cddb1..7fb595b9 100644 --- a/src/lib/native.ts +++ b/src/lib/native.ts @@ -97,12 +97,7 @@ export function nativePlatform(): string | null { let booted = false -export async function bootNative(): Promise { - if (booted) return - booted = true - if (!isNativePlatform()) return - - // Status bar — DARK glyphs on a light app background. +async function configureNativeChrome(): Promise { try { await StatusBar.setStyle({ style: Style.Light }) if (nativePlatform() === 'android') { @@ -112,6 +107,12 @@ export async function bootNative(): Promise { } catch (err) { console.warn('[native] status bar setup failed', err) } +} + +export function bootNative(): void { + if (booted) return + booted = true + if (!isNativePlatform()) return // Splash — hide once React has mounted past the launch image. try { @@ -167,6 +168,10 @@ export async function bootNative(): Promise { } catch (err) { console.warn('[native] appUrlOpen listener failed', err) } + + // Status-bar bridge latency is non-critical for the product shell. Start it + // only after event listeners are armed, and never block first paint on it. + void configureNativeChrome() } /** diff --git a/src/main.tsx b/src/main.tsx index e55b411c..181dff03 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,24 +1,45 @@ -import { StrictMode } from 'react' +import { StrictMode, type ComponentType } from 'react' import { createRoot } from 'react-dom/client' -import { App } from './App' -import { ConditionalPostHogProvider } from './components/ConditionalPostHogProvider' -import { PostHogAppTracker } from './components/PostHogAppTracker' -import { initObservability } from './lib/observability' -import { isElectron } from './lib/runtime' -import { bootNative, isNativePlatform } from './lib/native' +import { isCapacitorNative, isElectron, isNotificationWindow } from './lib/runtime' import './styles/globals.css' -if (isElectron) document.body.classList.add('electron') -if (isNativePlatform()) document.body.classList.add('native', `native-${typeof window !== 'undefined' && (window as { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.() || ''}`) +const root = createRoot(document.getElementById('root')!) -void initObservability() -void bootNative() +function render(Component: ComponentType) { + root.render() +} -createRoot(document.getElementById('root')!).render( - - - - - - , -) +async function boot() { + if (isNotificationWindow) { + const { NotificationWindow } = await import('./components/NotificationWindow') + render(NotificationWindow) + return + } + + if (isElectron) document.body.classList.add('electron') + if (isCapacitorNative) { + const platform = window.Capacitor?.getPlatform?.() || '' + document.body.classList.add('native', `native-${platform}`) + // Start native bridge setup in parallel with the product shell. Status-bar + // and listener registration must never delay App import or first paint. + void import('./lib/native').then(({ bootNative }) => bootNative()) + } + + const { App } = await import('./App') + render(App) + + if (isCapacitorNative && import.meta.env.VITE_MOBILE_UPLOAD_SMOKE === '1') { + void import('./dev/mobileUploadSmoke').then(({ installMobileUploadSmoke }) => { + installMobileUploadSmoke() + }) + } + + // Analytics is non-critical: load it after the product shell has painted. + if (import.meta.env.VITE_PUBLIC_POSTHOG_KEY) { + const start = () => { void import('./observability-entry').then(({ mountObservability }) => mountObservability()) } + if (typeof window.requestIdleCallback === 'function') window.requestIdleCallback(start) + else globalThis.setTimeout(start, 0) + } +} + +void boot() diff --git a/src/observability-entry.tsx b/src/observability-entry.tsx new file mode 100644 index 00000000..edc8bcf7 --- /dev/null +++ b/src/observability-entry.tsx @@ -0,0 +1,14 @@ +import { createRoot } from 'react-dom/client' +import { ConditionalPostHogProvider } from './components/ConditionalPostHogProvider' +import { PostHogAppTracker } from './components/PostHogAppTracker' + +export function mountObservability(): void { + const host = document.createElement('div') + host.hidden = true + document.body.appendChild(host) + createRoot(host).render( + + + , + ) +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 8a5edf5f..dd0c9e79 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,7 @@ interface ImportMetaEnv { readonly VITE_LINGXILOOP_DEV_API_TARGET?: string readonly VITE_PUBLIC_POSTHOG_KEY?: string readonly VITE_PUBLIC_POSTHOG_HOST?: string + readonly VITE_MOBILE_UPLOAD_SMOKE?: string } interface ImportMeta {