add account localization and switchable rooms

Centralize control and OBS copy in a shared TOML catalog, persist the selected locale per account, and broadcast language changes to component streams. Allow account owners to atomically switch their Bilibili room and restart the shared listener without changing component URLs.
This commit is contained in:
2026-07-18 23:28:05 -07:00
parent 53ae90ec13
commit f79852d8e6
35 changed files with 1946 additions and 472 deletions
+7
View File
@@ -27,6 +27,7 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域
| `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 |
| `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 |
| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker |
| `src/i18n.tsx` | TOML 语言资源、浏览器回退和运行时切换 |
| `src/types.ts` | sanitized API view model 与 overlay settings |
| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 |
@@ -45,6 +46,12 @@ manifest、start URL 与 Service Worker scope 均为 `/control/`。worker 使用
HTML 和 cache-first build assets;`/api/*`、`/obs/*`、WebSocket 和用户数据永不缓存。每次 Vite
build 把同一个 build ID 注入浏览器 bundle 与 worker,发现更新后等待用户确认激活。
## 语言资源
`../../resources/i18n.toml` 是控制台、OBS 组件、API 错误和 PWA
metadata 的唯一文案来源。Vite 在构建时解析并注入它,Rust 则嵌入同一文件来验证允许保存的 locale。增加文案键时必须为每个 locale 提供值;增加语言时同时增加完整
`[locales."<code>".messages]` 段。业务命令如“点歌”是 Bilibili 输入协议,不属于 UI 翻译。
## 格式化与构建
```bash
+5 -5
View File
@@ -1,18 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<html lang="__DEFAULT_LANGUAGE__">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="theme-color" content="#0a302f" />
<meta name="description" content="洛星瓷直播组件与 OBS 浏览器源控制台" />
<meta name="application-name" content="洛星瓷直播云台" />
<meta name="description" content="__APP_DESCRIPTION__" />
<meta name="application-name" content="__APP_NAME__" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="直播云台" />
<meta name="apple-mobile-web-app-title" content="__APP_SHORT_NAME__" />
<link rel="icon" type="image/svg+xml" href="/pwa/icon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/pwa/apple-touch-icon.png" />
<title>洛星瓷直播云台</title>
<title>__APP_NAME__</title>
</head>
<body>
<div id="root"></div>
+14
View File
@@ -16,6 +16,7 @@
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2",
"prettier": "3.9.5",
"smol-toml": "1.4.2",
"typescript": "5.9.2",
"vite": "7.3.6"
}
@@ -1682,6 +1683,19 @@
"semver": "bin/semver.js"
}
},
"node_modules/smol-toml": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz",
"integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 18"
},
"funding": {
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@types/react-dom": "19.1.7",
"@vitejs/plugin-react": "5.0.2",
"prettier": "3.9.5",
"smol-toml": "1.4.2",
"typescript": "5.9.2",
"vite": "7.3.6"
}
@@ -1,35 +0,0 @@
{
"id": "/control",
"name": "洛星瓷直播云台",
"short_name": "直播云台",
"description": "洛星瓷直播组件与 OBS 浏览器源控制台",
"lang": "zh-CN",
"dir": "auto",
"start_url": "/control/?source=pwa",
"scope": "/control/",
"display": "standalone",
"orientation": "any",
"background_color": "#03121a",
"theme_color": "#0a302f",
"categories": ["utilities", "productivity"],
"icons": [
{
"src": "/pwa/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/pwa/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+30 -6
View File
@@ -21,22 +21,26 @@ import type {
} from './types'
import { normalizeThemeId } from './themes'
import { defaultOverlaySettings, defaultSongRequestSettings } from './types'
import { currentLanguage, hasTranslation, translate } from './i18n'
export class ApiError extends Error {
readonly status: number
readonly code?: string
readonly messageKey?: string
readonly fieldErrors?: Record<string, string>
constructor(
status: number,
message: string,
code?: string,
messageKey?: string,
fieldErrors?: Record<string, string>,
) {
super(message)
this.name = 'ApiError'
this.status = status
this.code = code
this.messageKey = messageKey
this.fieldErrors = fieldErrors
}
}
@@ -52,12 +56,13 @@ async function parseResponse(response: Response): Promise<unknown> {
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const method = (init.method ?? 'GET').toUpperCase()
if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) {
throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline')
throw new ApiError(0, translate('api.offline'), 'offline', 'api.error.offline')
}
const headers = new Headers(init.headers)
if (init.body != null && !headers.has('content-type'))
headers.set('content-type', 'application/json')
headers.set('accept', 'application/json')
headers.set('accept-language', currentLanguage())
const response = await fetch(path, {
...init,
@@ -70,10 +75,24 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
window.dispatchEvent(new Event('lxc:session-expired'))
}
const error = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
const code = typeof error.code === 'string' ? error.code : undefined
const messageKey =
typeof error.messageKey === 'string'
? error.messageKey
: typeof error.message_key === 'string'
? error.message_key
: code
? `api.error.${code}`
: undefined
const localized = messageKey && hasTranslation(messageKey) ? translate(messageKey) : undefined
throw new ApiError(
response.status,
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`),
typeof error.code === 'string' ? error.code : undefined,
localized ??
String(
error.message ?? error.error ?? translate('api.http_error', { status: response.status }),
),
code,
messageKey,
error.fieldErrors && typeof error.fieldErrors === 'object'
? (error.fieldErrors as Record<string, string>)
: undefined,
@@ -166,7 +185,7 @@ export function normalizeComponents(value: unknown): ComponentSummary[] {
id: String(item.id ?? ''),
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
kind: String(item.kind ?? item.type ?? 'danmaku'),
name: String(item.name ?? '弹幕姬'),
name: String(item.name ?? translate('components.danmaku_type')),
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
settings: item.settings as OverlaySettings | undefined,
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
@@ -204,7 +223,10 @@ export function normalizeSongRequestItem(value: unknown): SongRequestItem | unde
return {
id: item.id,
title: item.title,
requester: { uid: String(requester.uid ?? ''), name: String(requester.name ?? '直播间观众') },
requester: {
uid: String(requester.uid ?? ''),
name: String(requester.name ?? translate('common.viewer')),
},
status: status as SongRequestItem['status'],
queuePosition: Number(item.queuePosition ?? 0),
requestedAt: String(item.requestedAt ?? ''),
@@ -308,7 +330,9 @@ export function normalizeInvitations(value: unknown): Invitation[] {
.filter(item => item.id)
}
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string {
export function errorMessage(error: unknown, fallback = translate('common.failed')): string {
if (error instanceof ApiError && error.messageKey && hasTranslation(error.messageKey))
return translate(error.messageKey)
return error instanceof Error && error.message ? error.message : fallback
}
+62 -58
View File
@@ -17,6 +17,7 @@ import {
normalizeRecoveryCodes,
} from './api'
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
import { LanguageSelect, useI18n } from './i18n'
import type { TotpEnrollment } from './types'
function AuthShell({
@@ -30,12 +31,16 @@ function AuthShell({
children: ReactNode
footer?: ReactNode
}) {
const { t } = useI18n()
return (
<main className="auth-page">
<section className="auth-card jade-panel">
<PwaControls />
<div className="auth-toolbar">
<LanguageSelect />
<PwaControls />
</div>
<div className="auth-mark" aria-hidden="true">
星
{t('common.star')}
</div>
<p className="eyebrow">{eyebrow}</p>
<h1>{title}</h1>
@@ -47,6 +52,7 @@ function AuthShell({
}
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
const { t } = useI18n()
const source = useMemo(() => {
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
if (enrollment.qrSvg)
@@ -58,29 +64,29 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
<div className="totp-enrollment">
<div className="totp-qr">
{source ? (
<img src={source} alt="TOTP 验证器绑定二维码" />
<img src={source} alt={t('auth.totp_qr_alt')} />
) : (
<span>二维码暂不可用,请使用右侧密钥手工添加。</span>
<span>{t('auth.qr_unavailable')}</span>
)}
</div>
<div className="totp-copy">
<h2>绑定动态验证器</h2>
<h2>{t('auth.bind_authenticator')}</h2>
<ol>
<li>使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。</li>
<li>若无法扫码,选择“输入设置密钥”。</li>
<li>输入应用中出现的 6 位动态码完成绑定。</li>
<li>{t('auth.totp_step_scan')}</li>
<li>{t('auth.totp_step_manual')}</li>
<li>{t('auth.totp_step_confirm')}</li>
</ol>
<label>
手工设置密钥
{t('auth.manual_key')}
<div className="secret-row">
<code>{enrollment.manualKey || '未提供'}</code>
<code>{enrollment.manualKey || t('common.not_provided')}</code>
{enrollment.manualKey && (
<button
type="button"
className="text-button"
onClick={() => void copyToClipboard(enrollment.manualKey)}
>
复制
{t('common.copy')}
</button>
)}
</div>
@@ -91,11 +97,12 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
}
function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) {
const { t } = useI18n()
const [copied, setCopied] = useState(false)
const text = codes.join('\n')
const download = () => {
const blob = new Blob(
['洛星瓷直播组件恢复码\n', '每个恢复码只能使用一次,请离线妥善保存。\n\n', text, '\n'],
[`${t('auth.recovery_file_title')}\n`, `${t('auth.recovery_file_warning')}\n\n`, text, '\n'],
{ type: 'text/plain;charset=utf-8' },
)
const url = URL.createObjectURL(blob)
@@ -107,10 +114,8 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
}
return (
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
<p className="auth-lead">
手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。
</p>
<AuthShell eyebrow={t('auth.security_complete')} title={t('auth.save_recovery_codes')}>
<p className="auth-lead">{t('auth.recovery_description')}</p>
{codes.length > 0 ? (
<div className="recovery-grid">
{codes.map(code => (
@@ -118,7 +123,7 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
))}
</div>
) : (
<div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>
<div className="notice warning">{t('auth.no_recovery_codes')}</div>
)}
<div className="form-actions">
{codes.length > 0 && (
@@ -128,15 +133,15 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
className="secondary"
onClick={() => void copyToClipboard(text).then(setCopied)}
>
{copied ? '已复制' : '复制全部'}
{copied ? t('common.copied') : t('auth.copy_all')}
</button>
<button type="button" className="secondary" onClick={download}>
下载文本
{t('auth.download_text')}
</button>
</>
)}
<button type="button" onClick={onContinue}>
我已妥善保存
{t('auth.recovery_saved')}
</button>
</div>
</AuthShell>
@@ -150,16 +155,13 @@ export function LoginPage({
onAuthenticated: () => Promise<void>
setupRequired: boolean
}) {
const { t } = useI18n()
const [username, setUsername] = useState('')
const [totpCode, setTotpCode] = useState('')
const [useRecoveryCode, setUseRecoveryCode] = useState(false)
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
usePwaUpdateBlocker(
'login-form',
'完成或清空正在填写的登录表单',
busy || Boolean(username || totpCode),
)
usePwaUpdateBlocker('login-form', t('auth.login_blocker'), busy || Boolean(username || totpCode))
const submit = async (event: FormEvent) => {
event.preventDefault()
@@ -171,7 +173,7 @@ export function LoginPage({
await onAuthenticated()
location.assign('/control/')
} catch (reason) {
setError(errorMessage(reason, '用户名或动态验证码不正确'))
setError(errorMessage(reason, t('auth.login_failed')))
} finally {
setBusy(false)
}
@@ -179,26 +181,27 @@ export function LoginPage({
return (
<AuthShell
eyebrow="洛星瓷直播组件"
title="回到你的云台"
eyebrow={t('auth.product_eyebrow')}
title={t('auth.login_title')}
footer={
<p>
{setupRequired ? (
<>
首次部署?<a href={authRoute('setup')}>创建系统管理员</a>
{t('auth.first_deployment')} <a href={authRoute('setup')}>{t('auth.create_admin')}</a>
</>
) : (
<>
持有邀请码?<a href={authRoute('register')}>注册新账户</a>
{t('auth.have_invitation')}{' '}
<a href={authRoute('register')}>{t('auth.register_account')}</a>
</>
)}
</p>
}
>
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
<p className="auth-lead">{t('auth.passwordless_description')}</p>
<form className="stack-form" onSubmit={submit}>
<label>
用户名
{t('auth.username')}
<input
autoFocus
required
@@ -208,7 +211,7 @@ export function LoginPage({
/>
</label>
<label>
{useRecoveryCode ? '账户恢复码' : '6 位动态验证码'}
{useRecoveryCode ? t('auth.recovery_code') : t('auth.totp_code')}
<input
required
className={useRecoveryCode ? 'recovery-input' : 'otp-input'}
@@ -216,7 +219,7 @@ export function LoginPage({
autoComplete={useRecoveryCode ? 'off' : 'one-time-code'}
pattern={useRecoveryCode ? undefined : '[0-9]{6}'}
maxLength={useRecoveryCode ? 64 : 6}
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
placeholder={useRecoveryCode ? t('auth.recovery_placeholder') : '000000'}
value={totpCode}
onChange={event =>
setTotpCode(
@@ -235,14 +238,14 @@ export function LoginPage({
setTotpCode('')
}}
>
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
{useRecoveryCode ? t('auth.use_totp') : t('auth.use_recovery')}
</button>
{error && (
<div className="notice error" role="alert">
{error}
</div>
)}
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
<button disabled={busy}>{busy ? t('auth.verifying') : t('auth.login')}</button>
</form>
</AuthShell>
)
@@ -255,6 +258,7 @@ export function EnrollmentPage({
mode: 'setup' | 'register'
onAuthenticated: () => Promise<void>
}) {
const { language, t } = useI18n()
const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? ''
const [inviteCode, setInviteCode] = useState(inviteFromFragment)
const [username, setUsername] = useState('')
@@ -267,9 +271,7 @@ export function EnrollmentPage({
const isSetup = mode === 'setup'
usePwaUpdateBlocker(
'totp-enrollment',
enrollment || recoveryCodes
? '完成 TOTP 绑定并保存一次性恢复码'
: '完成或清空正在填写的注册表单',
enrollment || recoveryCodes ? t('auth.enrollment_blocker') : t('auth.registration_blocker'),
busy ||
Boolean(
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
@@ -290,7 +292,7 @@ export function EnrollmentPage({
}),
)
const next = normalizeEnrollment(payload)
if (!next.enrollmentToken) throw new Error('服务端未返回注册流程标识')
if (!next.enrollmentToken) throw new Error(t('auth.registration_missing_id'))
setEnrollment(next)
setBootstrapPassword('')
if (!isSetup) {
@@ -298,7 +300,7 @@ export function EnrollmentPage({
history.replaceState(null, '', authRoute('register'))
}
} catch (reason) {
setError(errorMessage(reason, '无法开始安全注册流程'))
setError(errorMessage(reason, t('auth.registration_start_failed')))
} finally {
setBusy(false)
}
@@ -312,7 +314,11 @@ export function EnrollmentPage({
try {
const payload = await api<unknown>(
`/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`,
json('POST', { enrollmentToken: enrollment.enrollmentToken, code: totpCode }),
json('POST', {
enrollmentToken: enrollment.enrollmentToken,
code: totpCode,
language,
}),
)
setTotpCode('')
// Drop QR/manual-key material from React state as soon as enrollment is
@@ -320,7 +326,7 @@ export function EnrollmentPage({
setEnrollment(undefined)
setRecoveryCodes(normalizeRecoveryCodes(payload))
} catch (reason) {
setError(errorMessage(reason, '动态验证码无效或注册流程已过期'))
setError(errorMessage(reason, t('auth.registration_confirm_failed')))
} finally {
setBusy(false)
}
@@ -336,13 +342,13 @@ export function EnrollmentPage({
if (enrollment) {
return (
<AuthShell
eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'}
title="强制绑定 TOTP"
eyebrow={isSetup ? t('auth.setup_step_two') : t('auth.register_step_two')}
title={t('auth.require_totp')}
>
<TotpQr enrollment={enrollment} />
<form className="stack-form compact-form" onSubmit={confirm}>
<label>
验证器中的 6 位动态码
{t('auth.authenticator_code')}
<input
required
autoFocus
@@ -362,7 +368,7 @@ export function EnrollmentPage({
</div>
)}
<button disabled={busy || totpCode.length !== 6}>
{busy ? '正在确认…' : '确认绑定并创建账户'}
{busy ? t('auth.confirming') : t('auth.confirm_create')}
</button>
</form>
</AuthShell>
@@ -371,23 +377,21 @@ export function EnrollmentPage({
return (
<AuthShell
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
title={isSetup ? '创建系统管理员' : '创建你的账户'}
eyebrow={isSetup ? t('auth.setup_only') : t('auth.invited_only')}
title={isSetup ? t('auth.create_admin') : t('auth.create_your_account')}
footer={
<p>
已有账户?<a href={authRoute('login')}>返回登录</a>
{t('auth.already_account')} <a href={authRoute('login')}>{t('auth.back_to_login')}</a>
</p>
}
>
<p className="auth-lead">
{isSetup
? '首位账户将拥有邀请码管理权限。旧管理员口令只授权这一次初始化,不会成为账户密码。'
: '邀请码只用于注册;这是无密码账户,创建后每次登录都必须验证 TOTP。'}
{isSetup ? t('auth.setup_description') : t('auth.register_description')}
</p>
<form className="stack-form" onSubmit={start}>
{!isSetup && (
<label>
邀请码
{t('auth.invitation')}
<input
required
autoFocus={!inviteFromFragment}
@@ -398,7 +402,7 @@ export function EnrollmentPage({
</label>
)}
<label>
用户名
{t('auth.username')}
<input
required
autoFocus={isSetup || Boolean(inviteFromFragment)}
@@ -411,7 +415,7 @@ export function EnrollmentPage({
</label>
{isSetup && (
<label>
一次性初始化口令
{t('auth.bootstrap_password')}
<input
required
type="password"
@@ -419,7 +423,7 @@ export function EnrollmentPage({
value={bootstrapPassword}
onChange={event => setBootstrapPassword(event.target.value)}
/>
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
<small>{t('auth.bootstrap_help')}</small>
</label>
)}
{error && (
@@ -427,7 +431,7 @@ export function EnrollmentPage({
{error}
</div>
)}
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
<button disabled={busy}>{busy ? t('auth.preparing_totp') : t('auth.next_bind')}</button>
</form>
</AuthShell>
)
+32 -5
View File
@@ -1226,12 +1226,44 @@ select {
.account-menu {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12px;
align-items: center;
text-align: right;
}
.language-select {
display: grid;
gap: 3px;
min-width: 112px;
color: var(--app-muted);
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 10px;
text-align: left;
}
.language-select select {
min-height: 32px;
padding: 5px 28px 5px 9px;
border: 1px solid rgba(95, 215, 193, 0.24);
border-radius: 8px;
color: #dffff7;
background: rgba(4, 30, 38, 0.9);
}
.auth-toolbar {
margin: -6px 64px 18px 0;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: flex-end;
}
.auth-toolbar .pwa-controls {
min-height: 32px;
}
.pwa-controls {
display: flex;
gap: 7px;
@@ -1242,11 +1274,6 @@ select {
justify-content: flex-end;
}
.auth-card > .pwa-controls {
margin: -4px 64px 18px 0;
justify-content: flex-start;
}
.auth-page .pwa-action {
min-height: 34px;
padding: 6px 11px;
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
/** Runtime localization backed exclusively by `resources/i18n.toml`. */
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
type Messages = Record<string, string>
type LocaleDefinition = { name: string; messages: Messages }
type Parameters = Record<string, string | number>
const resource = __I18N_RESOURCE__
const storageKey = 'lxc-stream-language'
const localeEntries = Object.entries(resource.locales)
function supported(value: string | null | undefined): boolean {
return Boolean(value && resource.locales[value])
}
function browserLanguage(): string {
let saved: string | null = null
try {
saved = localStorage.getItem(storageKey)
} catch {
// Some embedded browsers disable storage. Locale selection still works
// for the current document and authenticated accounts remain authoritative.
}
if (saved && supported(saved)) return saved
for (const candidate of navigator.languages) {
if (supported(candidate)) return candidate
const prefix = candidate.split('-')[0].toLowerCase()
const match = localeEntries.find(([code]) => code.split('-')[0].toLowerCase() === prefix)
if (match) return match[0]
}
return resource.default_language
}
let activeLanguage = browserLanguage()
function format(message: string, parameters?: Parameters): string {
if (!parameters) return message
return message.replace(/\{([a-zA-Z0-9_]+)\}/g, (placeholder, key: string) =>
Object.hasOwn(parameters, key) ? String(parameters[key]) : placeholder,
)
}
export function translate(key: string, parameters?: Parameters, language = activeLanguage): string {
const locale = resource.locales[language] ?? resource.locales[resource.default_language]
const fallback = resource.locales[resource.default_language]
return format(locale?.messages[key] ?? fallback?.messages[key] ?? key, parameters)
}
export function currentLanguage(): string {
return activeLanguage
}
export function hasTranslation(key: string): boolean {
return localeEntries.some(([, locale]) => Object.hasOwn(locale.messages, key))
}
type I18nContextValue = {
language: string
locales: Array<{ code: string; name: string }>
setLanguage: (language: string) => void
t: (key: string, parameters?: Parameters) => string
}
const I18nContext = createContext<I18nContextValue | undefined>(undefined)
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState(activeLanguage)
const setLanguage = useCallback((next: string) => {
if (!supported(next)) return
activeLanguage = next
try {
localStorage.setItem(storageKey, next)
} catch {
// Storage is a pre-login convenience, not a requirement for switching.
}
document.documentElement.lang = next
setLanguageState(next)
}, [])
useEffect(() => {
document.documentElement.lang = language
document.title = translate('pwa.manifest.name', undefined, language)
const localizedMetadata: Array<[string, string]> = [
['meta[name="description"]', 'pwa.manifest.description'],
['meta[name="application-name"]', 'pwa.manifest.name'],
['meta[name="apple-mobile-web-app-title"]', 'pwa.manifest.short_name'],
]
for (const [selector, key] of localizedMetadata) {
document
.querySelector<HTMLMetaElement>(selector)
?.setAttribute('content', translate(key, undefined, language))
}
}, [language])
const value = useMemo<I18nContextValue>(
() => ({
language,
locales: localeEntries.map(([code, locale]) => ({
code,
name: (locale as LocaleDefinition).name,
})),
setLanguage,
t: (key, parameters) => translate(key, parameters, language),
}),
[language, setLanguage],
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n(): I18nContextValue {
const context = useContext(I18nContext)
if (!context) throw new Error('I18nProvider is missing')
return context
}
export function LanguageSelect({
onChange,
className = '',
}: {
onChange?: (language: string) => void | Promise<void>
className?: string
}) {
const { language, locales, setLanguage, t } = useI18n()
return (
<label className={`language-select ${className}`.trim()}>
<span>{t('language.label')}</span>
<select
value={language}
onChange={event => {
const next = event.target.value
setLanguage(next)
void onChange?.(next)
}}
>
{locales.map(locale => (
<option key={locale.code} value={locale.code}>
{locale.name}
</option>
))}
</select>
</label>
)
}
+46 -23
View File
@@ -21,6 +21,7 @@ import { Overlay, tokenFromFragment } from './overlay'
import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa'
import { SongRequestOverlay } from './songOverlay'
import { useComponentStream } from './stream'
import { I18nProvider, translate, useI18n } from './i18n'
import type { Session } from './types'
import './style.css'
import './control.css'
@@ -28,35 +29,41 @@ import './song.css'
function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) {
const stream = useComponentStream(false, publicId, accessToken)
const { setLanguage, t } = useI18n()
useEffect(() => {
if (stream.language) setLanguage(stream.language)
}, [setLanguage, stream.language])
if (!publicId || !accessToken)
return <main className="obs-status">OBS 地址缺少组件标识或访问令牌</main>
return <main className="obs-status">{t('main.obs_missing_credentials')}</main>
if (stream.connection === 'denied')
return <main className="obs-status">OBS 访问令牌无效或已经轮换</main>
return <main className="obs-status">{t('main.obs_invalid_token')}</main>
if (stream.componentKind === 'song_request') return <SongRequestOverlay stream={stream} />
if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku')
return <Overlay stream={stream} />
return <main className="obs-status pending">正在连接组件…</main>
return <main className="obs-status pending">{t('main.obs_connecting')}</main>
}
function Redirect({ to }: { to: string }) {
const { t } = useI18n()
useEffect(() => {
location.replace(to)
}, [to])
return <main className="route-loading">正在前往云台…</main>
return <main className="route-loading">{t('main.redirecting')}</main>
}
function NotFoundPage() {
const { t } = useI18n()
return (
<main className="auth-page">
<section className="auth-card jade-panel not-found">
<div className="auth-mark" aria-hidden="true">
云
{t('common.cloud')}
</div>
<p className="eyebrow">404 · LOST IN THE CLOUDS</p>
<h1>这里没有组件</h1>
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p>
<p className="eyebrow">{t('main.not_found_eyebrow')}</p>
<h1>{t('main.not_found_title')}</h1>
<p className="auth-lead">{t('main.not_found_description')}</p>
<a className="button-link" href="/control/">
返回控制台
{t('main.back_to_console')}
</a>
</section>
</main>
@@ -64,6 +71,7 @@ function NotFoundPage() {
}
function App() {
const { setLanguage, t } = useI18n()
const [session, setSession] = useState<Session>()
const [loadError, setLoadError] = useState('')
const [online, setOnline] = useState(navigator.onLine)
@@ -73,15 +81,24 @@ function App() {
setLoadError('')
try {
const payload = await api<unknown>('/api/v1/auth/me')
setSession(normalizeSession(payload))
const next = normalizeSession(payload)
setSession(next)
if (next.user) {
try {
const preferences = await api<{ language?: string }>('/api/v1/account/preferences')
if (preferences.language) setLanguage(preferences.language)
} catch {
// The local pre-login choice remains usable if preferences cannot load.
}
}
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
setSession({ user: null, setupRequired: false })
return
}
setLoadError(errorMessage(error, '无法连接认证服务'))
setLoadError(errorMessage(error, translate('main.auth_unavailable')))
}
}, [])
}, [setLanguage])
useEffect(() => {
void refreshSession()
@@ -116,22 +133,20 @@ function App() {
<main className="auth-page">
<section className="auth-card jade-panel">
<PwaControls />
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
{offline && (
<p className="auth-lead">
应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。
</p>
)}
<p className="eyebrow">
{t(offline ? 'main.offline_eyebrow' : 'main.connection_error_eyebrow')}
</p>
<h1>{offline ? t('main.offline_title') : t('main.unavailable_title')}</h1>
{offline && <p className="auth-lead">{t('main.offline_description')}</p>}
<div className="notice error">{loadError}</div>
<button type="button" disabled={offline} onClick={() => void refreshSession()}>
重新连接
{t('main.retry')}
</button>
</section>
</main>
)
}
if (!session) return <main className="route-loading">正在验证安全会话…</main>
if (!session) return <main className="route-loading">{t('main.verifying_session')}</main>
const logout = async () => {
try {
@@ -197,8 +212,16 @@ if (obsMatch) {
} catch {
publicId = ''
}
root.render(<ObsComponent publicId={publicId} accessToken={tokenFromFragment()} />)
root.render(
<I18nProvider>
<ObsComponent publicId={publicId} accessToken={tokenFromFragment()} />
</I18nProvider>,
)
} else {
initializePwa()
root.render(<App />)
root.render(
<I18nProvider>
<App />
</I18nProvider>,
)
}
+22 -13
View File
@@ -10,6 +10,7 @@
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import type { ComponentStream } from './stream'
import { translate, useI18n } from './i18n'
import { getOverlayTheme, normalizeThemeId, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes'
import { defaultOverlaySettings } from './types'
@@ -195,11 +196,12 @@ function giftTier(item: Item, settings: OverlaySettings) {
}
const eventRenderers: Record<string, (payload: LivePayload) => string> = {
'live.enter': () => '踏入了云台',
'live.enter': () => translate('overlay.enter'),
'live.superchat': payload => payload.message || '',
'live.guard.buy': payload => `开通 ${payload.guardName || '舰长'}`,
'live.like': () => '点亮了一颗星',
'live.share': () => '分享了直播间',
'live.guard.buy': payload =>
translate('overlay.guard', { guard: payload.guardName || translate('common.guard') }),
'live.like': () => translate('overlay.like'),
'live.share': () => translate('overlay.share'),
}
function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type: 'emoticon' }> }) {
@@ -258,11 +260,14 @@ function Card({
const isDanmaku = item.type === 'live.danmaku'
const tier = gift ? giftTier(item, settings) : ''
const body = gift ? (
`献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
translate('overlay.gift', {
gift: gift.name || translate('common.gift'),
quantity: payload.quantity || 1,
})
) : isDanmaku ? (
<DanmakuBody payload={payload} />
) : (
eventRenderers[item.type]?.(payload) || '送来了一份互动'
eventRenderers[item.type]?.(payload) || translate('overlay.interaction')
)
return (
@@ -275,7 +280,7 @@ function Card({
{gift.animationUrl || gift.imageUrl ? (
<img
src={gift.animationUrl || gift.imageUrl}
alt={gift.name || '礼物'}
alt={gift.name || translate('common.gift')}
onError={event => {
const image = event.currentTarget
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
@@ -288,7 +293,7 @@ function Card({
</div>
)}
<div className="copy">
<b>{viewer.name || '直播间观众'}</b>
<b>{viewer.name || translate('common.viewer')}</b>
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && (
<em>¥ {gift.priceCny.toFixed(2)}</em>
@@ -300,6 +305,7 @@ function Card({
}
export function Overlay({ preview = false, previewSettings, stream }: OverlayProps) {
const { language } = useI18n()
const root = useRef<HTMLDivElement>(null)
const events = useEvents(preview, stream)
const { items, setItems } = events
@@ -320,7 +326,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
}, [])
useEffect(() => {
if (preview && !items.length) {
if (preview) {
setItems([
{
id: 'text-preview',
@@ -328,7 +334,10 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
received: Date.now(),
decorVariant: 0,
type: 'live.danmaku',
payload: { viewer: { name: '青玉观众' }, text: '今天也要闪闪发光!' },
payload: {
viewer: { name: translate('overlay.preview_viewer') },
text: translate('overlay.preview_message'),
},
},
{
id: 'gift-preview',
@@ -337,10 +346,10 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
decorVariant: 3,
type: 'live.gift',
payload: {
viewer: { name: '星光旅人' },
viewer: { name: translate('overlay.preview_gift_viewer') },
quantity: 1,
gift: {
name: '甜蜜告白',
name: translate('overlay.preview_gift'),
totalPrice: 12_000,
priceCny: 12,
imageUrl: '',
@@ -350,7 +359,7 @@ export function Overlay({ preview = false, previewSettings, stream }: OverlayPro
},
])
}
}, [items.length, preview, setItems])
}, [items.length, language, preview, setItems])
useEffect(() => {
const newest = items[0]
+9 -9
View File
@@ -8,6 +8,7 @@
* one-time-secret or dirty-form blocker has cleared.
*/
import { useEffect, useSyncExternalStore } from 'react'
import { translate, useI18n } from './i18n'
interface InstallChoice {
outcome: 'accepted' | 'dismissed'
@@ -108,7 +109,7 @@ async function removeLegacyRootWorker() {
export function cleanupLegacyPwa() {
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
void removeLegacyRootWorker().catch(error => {
console.warn('旧版 PWA 清理失败', error)
console.warn('Legacy PWA cleanup failed', error)
})
}
@@ -122,7 +123,7 @@ async function registerWorker() {
observeRegistration(registration)
} catch (error) {
// A failed PWA registration must never stop the online control console.
console.warn('控制台 PWA 注册失败', error)
console.warn('Console PWA registration failed', error)
}
}
@@ -211,19 +212,18 @@ function applyUpdate() {
const reasons = [...new Set(updateBlockers.values())]
if (reasons.length > 0) {
window.alert(
`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`,
translate('pwa.blocked', { reasons: reasons.map(reason => `• ${reason}`).join('\n') }),
)
return
}
const confirmed = window.confirm(
'更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?',
)
const confirmed = window.confirm(translate('pwa.confirm_update'))
if (!confirmed) return
reloadForUpdate = true
worker.postMessage({ type: 'SKIP_WAITING' })
}
export function PwaControls() {
const { t } = useI18n()
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
const canInstall = Boolean(state.installPrompt) && !state.standalone
if (state.online && !state.waitingWorker && !canInstall) return null
@@ -233,17 +233,17 @@ export function PwaControls() {
{!state.online && (
<span className="pwa-state offline">
<i aria-hidden="true" />
离线
{t('pwa.offline')}
</span>
)}
{state.waitingWorker && (
<button type="button" className="pwa-action update" onClick={applyUpdate}>
更新可用
{t('pwa.update')}
</button>
)}
{canInstall && (
<button type="button" className="pwa-action install" onClick={() => void requestInstall()}>
安装到设备
{t('pwa.install')}
</button>
)}
</div>
+62 -40
View File
@@ -2,6 +2,7 @@
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties, RefObject } from 'react'
import { normalizeSongRequestItem, normalizeSongRequestSettings } from './api'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { getOverlayTheme, themeCssVariables } from './themes'
import type { OverlayThemeDefinition } from './themes'
@@ -23,32 +24,33 @@ type SnapshotDraft = {
items: SongRequestItem[]
}
const previewCurrent: SongRequestItem = {
id: 'preview-current',
title: '星河入梦',
requester: { uid: '10001', name: '青玉观众' },
status: 'current',
queuePosition: 0,
requestedAt: new Date().toISOString(),
startedAt: new Date().toISOString(),
averageScore: 4.8,
ratingCount: 26,
}
const previewQueue: SongRequestItem[] = ['晚风告白', '月下花笺', '云海来信', '落星成诗'].map(
(title, index) => ({
id: `preview-${index}`,
title,
requester: {
uid: String(10002 + index),
name: ['星光旅人', '花间客', '小瓷片', '月桂'][index],
function previewData(): { current: SongRequestItem; queued: SongRequestItem[] } {
const now = new Date().toISOString()
const titles = translate('song.overlay.preview_titles').split('|')
const viewers = translate('song.overlay.preview_viewers').split('|')
return {
current: {
id: 'preview-current',
title: translate('song.overlay.preview_title'),
requester: { uid: '10001', name: translate('song.overlay.preview_viewer') },
status: 'current',
queuePosition: 0,
requestedAt: now,
startedAt: now,
averageScore: 4.8,
ratingCount: 26,
},
status: 'queued',
queuePosition: index + 1,
requestedAt: new Date().toISOString(),
ratingCount: 0,
}),
)
queued: titles.map((title, index) => ({
id: `preview-${index}`,
title,
requester: { uid: String(10002 + index), name: viewers[index] ?? viewers[0] },
status: 'queued',
queuePosition: index + 1,
requestedAt: now,
ratingCount: 0,
})),
}
}
function payloadRecord(value: unknown): Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value)
@@ -62,13 +64,14 @@ function ordered(items: SongRequestItem[]): SongRequestItem[] {
.sort((left, right) => left.queuePosition - right.queuePosition)
}
function useSongQueue(preview: boolean, stream?: ComponentStream) {
function useSongQueue(preview: boolean, language: string, stream?: ComponentStream) {
const initialPreview = previewData()
const [settings, setSettings] = useState(defaultSongRequestSettings)
const [queue, setQueue] = useState<QueueState>(() => ({
initialized: preview,
revision: 0,
current: preview ? previewCurrent : undefined,
queued: preview ? previewQueue : [],
current: preview ? initialPreview.current : undefined,
queued: preview ? initialPreview.queued : [],
}))
const queueRef = useRef(queue)
const draftRef = useRef<SnapshotDraft | undefined>(undefined)
@@ -78,6 +81,19 @@ function useSongQueue(preview: boolean, stream?: ComponentStream) {
queueRef.current = queue
}, [queue])
useEffect(() => {
if (!preview) return
const translated = previewData()
const next = {
initialized: true,
revision: 0,
current: translated.current,
queued: translated.queued,
}
queueRef.current = next
setQueue(next)
}, [language, preview])
useEffect(() => {
if (preview || !stream) return
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
@@ -179,8 +195,11 @@ function useSongQueue(preview: boolean, stream?: ComponentStream) {
}
function score(item?: SongRequestItem) {
if (!item?.ratingCount || item.averageScore == null) return '尚无评分'
return `${item.averageScore.toFixed(1)} ★ · ${item.ratingCount} 人`
if (!item?.ratingCount || item.averageScore == null) return translate('song.no_rating')
return translate('song.overlay.score', {
score: item.averageScore.toFixed(1),
count: item.ratingCount,
})
}
/** Theme-owned stars and florets used inside the compact current-song card. */
@@ -254,7 +273,8 @@ export function SongRequestOverlay({
previewSettings?: SongRequestSettings
stream?: ComponentStream
}) {
const { settings: remoteSettings, queue } = useSongQueue(preview, stream)
const { language } = useI18n()
const { settings: remoteSettings, queue } = useSongQueue(preview, language, stream)
const settings = previewSettings ?? remoteSettings
const theme = getOverlayTheme(settings.themeId)
const root = useRef<HTMLElement>(null)
@@ -294,32 +314,34 @@ export function SongRequestOverlay({
>
<section
className="song-current"
aria-label="当前歌曲"
aria-label={translate('song.overlay.current_aria')}
key={queue.current?.id ?? 'empty-current'}
>
<SongParticles theme={theme} />
<div className="song-current-mark" aria-hidden="true">
唱
{translate('song.overlay.sing_mark')}
</div>
{queue.current ? (
<div className="song-current-copy">
<small>正在演唱 · {queue.current.requester.name} 点歌</small>
<small>
{translate('song.overlay.now_singing', { viewer: queue.current.requester.name })}
</small>
<strong>{queue.current.title}</strong>
</div>
) : (
<div className="song-current-copy empty">
<small>等待弹幕点歌</small>
<strong>发送「点歌 歌名」加入队列</strong>
<small>{translate('song.overlay.waiting')}</small>
<strong>{translate('song.overlay.request_help')}</strong>
</div>
)}
<div className="song-score">
<b>{score(queue.current)}</b>
<small>发送「打分 1-5」</small>
<small>{translate('song.overlay.rate_help')}</small>
</div>
</section>
<section className="song-queue" aria-label="待唱队列">
<section className="song-queue" aria-label={translate('song.overlay.queue_aria')}>
<header>
<span>待唱歌单</span>
<span>{translate('song.overlay.queue_title')}</span>
<b>{queue.queued.length}</b>
</header>
<div className="song-queue-viewport" ref={viewport}>
@@ -336,7 +358,7 @@ export function SongRequestOverlay({
</article>
))}
{queue.queued.length === 0 && (
<div className="song-queue-empty">下一首,会由谁来点呢?</div>
<div className="song-queue-empty">{translate('song.overlay.queue_empty')}</div>
)}
</div>
</div>
+10 -1
View File
@@ -14,6 +14,7 @@ export type StreamMessage = {
export type ComponentStream = {
componentKind?: string
language?: string
connection: 'idle' | 'connecting' | 'connected' | 'denied'
/**
* Ordered frames retained long enough for React consumers to drain them.
@@ -50,6 +51,7 @@ export function useComponentStream(
accessToken?: string,
): ComponentStream {
const [componentKind, setComponentKind] = useState<string>()
const [language, setLanguage] = useState<string>()
const [connection, setConnection] = useState<ComponentStream['connection']>('idle')
const [messages, setMessages] = useState<readonly StreamMessage[]>([])
const socketRef = useRef<WebSocket | undefined>(undefined)
@@ -62,6 +64,7 @@ export function useComponentStream(
useEffect(() => {
if (disabled || !publicId || !accessToken) {
setComponentKind(undefined)
setLanguage(undefined)
setConnection('idle')
setMessages([])
return
@@ -101,12 +104,18 @@ export function useComponentStream(
try {
const envelope = JSON.parse(event.data) as ComponentEnvelope & {
componentKind?: string
language?: string
}
if (envelope.type === 'authenticated' || envelope.type === 'stream.authenticated') {
setComponentKind(envelope.componentKind)
setLanguage(envelope.language)
setConnection('connected')
return
}
if (envelope.type === 'component.language.updated') {
setLanguage(String(envelope.payload?.language ?? ''))
return
}
if (envelope.type === 'error' && envelope.payload?.code === 'UNAUTHORIZED') {
setConnection('denied')
candidate.close(1008, 'Unauthorized')
@@ -133,5 +142,5 @@ export function useComponentStream(
}
}, [accessToken, disabled, publicId])
return { componentKind, connection, messages, resync }
return { componentKind, language, connection, messages, resync }
}
+2 -2
View File
@@ -7,8 +7,8 @@ const vine = '/assets/floral-vine.svg'
/** The original ancient-style glass renderer, captured as the first theme. */
export const jadeScrollTheme: OverlayThemeDefinition = {
id: 'jade-scroll',
name: '青玉花卷',
description: '暗蓝青玉玻璃、古风花纹、星花粒子与横向卷轴展开。',
nameKey: 'theme.jade_scroll.name',
descriptionKey: 'theme.jade_scroll.description',
className: 'theme-jade-scroll',
palette: {
text: '#e7fff9',
+2 -2
View File
@@ -13,8 +13,8 @@ export type ThemeParticle = 'star' | 'floret'
*/
export type OverlayThemeDefinition = {
id: OverlayThemeId
name: string
description: string
nameKey: string
descriptionKey: string
className: string
palette: {
text: string
+4
View File
@@ -1,3 +1,7 @@
/// <reference types="vite/client" />
declare const __PWA_BUILD_ID__: string
declare const __I18N_RESOURCE__: {
default_language: string
locales: Record<string, { name: string; messages: Record<string, string> }>
}
+47
View File
@@ -8,27 +8,74 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { readFileSync } from 'node:fs'
import { parse } from 'smol-toml'
// A new URL makes the browser check and stage every deployed service worker.
// The waiting worker is still activated explicitly by the user in the console.
const pwaBuildId = Date.now().toString(36)
const serviceWorkerSource = readFileSync(new URL('./pwa/control-sw.js', import.meta.url), 'utf8')
const i18nResource = parse(
readFileSync(new URL('../../resources/i18n.toml', import.meta.url), 'utf8'),
) as unknown as {
default_language: string
locales: Record<string, { messages: Record<string, string> }>
}
const defaultLanguage = i18nResource.default_language
const defaultMessages = i18nResource.locales[defaultLanguage].messages
const manifest = {
id: '/control',
name: defaultMessages['pwa.manifest.name'],
short_name: defaultMessages['pwa.manifest.short_name'],
description: defaultMessages['pwa.manifest.description'],
lang: defaultLanguage,
dir: 'auto',
start_url: '/control/?source=pwa',
scope: '/control/',
display: 'standalone',
orientation: 'any',
background_color: '#03121a',
theme_color: '#0a302f',
categories: ['utilities', 'productivity'],
icons: [
{ src: '/pwa/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
{ src: '/pwa/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
{
src: '/pwa/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
}
export default defineConfig({
plugins: [
react(),
{
name: 'control-pwa-assets',
transformIndexHtml(html) {
return html
.replaceAll('__DEFAULT_LANGUAGE__', defaultLanguage)
.replaceAll('__APP_NAME__', defaultMessages['pwa.manifest.name'])
.replaceAll('__APP_SHORT_NAME__', defaultMessages['pwa.manifest.short_name'])
.replaceAll('__APP_DESCRIPTION__', defaultMessages['pwa.manifest.description'])
},
generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'control/sw.js',
source: serviceWorkerSource.replaceAll('__PWA_BUILD_ID__', pwaBuildId),
})
this.emitFile({
type: 'asset',
fileName: 'control/manifest.webmanifest',
source: JSON.stringify(manifest, null, 2),
})
},
},
],
define: {
__PWA_BUILD_ID__: JSON.stringify(pwaBuildId),
__I18N_RESOURCE__: JSON.stringify(i18nResource),
},
})
+1
View File
@@ -15,6 +15,7 @@ crate 导出。
| `db` | PostgreSQL pool、迁移和 RLS tenant context |
| `domain` | provider-independent event 与 WebSocket envelope |
| `http_api` | REST/WS、会话、权限、same-origin、静态资源与安全响应头 |
| `i18n` | 嵌入共享 TOML 语言目录并验证 locale 与键完整性 |
| `live` | provider trait、Bilibili adapter 与 source supervisor |
| `overlay` | 弹幕姬设置及礼物/表情目录 |
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
@@ -0,0 +1,31 @@
-- Treat an invitation room as the account's initial room rather than an
-- immutable lifetime binding. Room IDs remain unique across accounts so two
-- listeners cannot accidentally claim the same upstream room.
DROP TRIGGER IF EXISTS users_room_id_immutable ON users;
DROP FUNCTION IF EXISTS prevent_user_room_id_change();
CREATE OR REPLACE FUNCTION enforce_live_source_account_room()
RETURNS trigger
LANGUAGE plpgsql
SET search_path = pg_catalog, public
AS $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM public.users
WHERE id = NEW.owner_user_id AND room_id = NEW.room_id
) THEN
RAISE EXCEPTION 'live source room_id must equal its owning account room_id';
END IF;
IF TG_OP = 'UPDATE'
AND NEW.owner_user_id IS DISTINCT FROM OLD.owner_user_id THEN
RAISE EXCEPTION 'live source ownership is immutable';
END IF;
RETURN NEW;
END;
$$;
COMMENT ON COLUMN users.room_id IS
'Current account-level Bilibili room; initialized by invitation and user-switchable.';
COMMENT ON COLUMN live_sources.room_id IS
'Current room of the owning account; updated atomically with users.room_id.';
@@ -0,0 +1,23 @@
-- Account language preference. The browser keeps a local pre-login choice;
-- authenticated saves become authoritative for every control session and OBS
-- component owned by the account.
CREATE TABLE IF NOT EXISTS account_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
-- Supported locale codes are validated against resources/i18n.toml by the
-- repository. Keeping the column open avoids a schema migration per locale.
language TEXT NOT NULL DEFAULT 'zh-CN',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO account_preferences(user_id)
SELECT id FROM users
ON CONFLICT(user_id) DO NOTHING;
ALTER TABLE account_preferences ENABLE ROW LEVEL SECURITY;
ALTER TABLE account_preferences FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS account_preferences_owner ON account_preferences;
CREATE POLICY account_preferences_owner ON account_preferences
USING (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID)
WITH CHECK (user_id = NULLIF(current_setting('app.user_id', true), '')::UUID);
+8
View File
@@ -283,6 +283,14 @@ async fn migrate(db: &Db) -> Result<(), String> {
6_i32,
include_str!("../migrations/006_account_live_source.sql"),
),
(
7_i32,
include_str!("../migrations/007_switchable_account_room.sql"),
),
(
8_i32,
include_str!("../migrations/008_account_language.sql"),
),
] {
let applied = transaction
.query_one(
+134 -26
View File
@@ -19,13 +19,14 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use tokio_postgres::Transaction;
use tokio_postgres::{Transaction, error::SqlState};
use totp_rs::{Algorithm, Secret, TOTP};
use uuid::Uuid;
use crate::{
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
db::{Db, DbError},
i18n,
overlay::OverlaySettings,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
};
@@ -479,7 +480,11 @@ impl AuthService {
&self,
enrollment_token: &str,
totp_code: &str,
language: &str,
) -> Result<RegistrationComplete, AuthError> {
if !i18n::is_supported(language) {
return Err(AuthError::InvalidInput("language is not supported"));
}
let digest = token_digest(enrollment_token.trim());
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
@@ -598,6 +603,12 @@ impl AuthService {
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
let session = insert_session(&transaction, user_id, self.session_ttl).await?;
Db::set_tenant(&transaction, user_id).await?;
transaction
.execute(
"INSERT INTO account_preferences(user_id,language) VALUES($1,$2)",
&[&user_id, &language],
)
.await?;
let default_source_id = Uuid::new_v4();
transaction
.execute(
@@ -909,6 +920,100 @@ impl AuthService {
user_id: Uuid,
credentials: &CookieCloudCredentials,
) -> Result<(), AuthError> {
let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?;
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
ensure_active_user(&transaction, user_id).await?;
Db::set_tenant(&transaction, user_id).await?;
upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?;
insert_audit(
&transaction,
Some(user_id),
"cookiecloud.credentials.updated",
"user",
Some(user_id.to_string()),
json!({"host":host}),
)
.await?;
transaction.commit().await?;
Ok(())
}
/// Atomically changes the account's current room and encrypted CookieCloud
/// source. The live source keeps its stable ID, allowing the supervisor to
/// cancel the old room connection and start the replacement without
/// changing any component or OBS token.
pub async fn set_account_live_source(
&self,
user_id: Uuid,
room_id: &str,
credentials: &CookieCloudCredentials,
) -> Result<(), AuthError> {
let room_id = room_id.trim();
validate_room_id(room_id)?;
let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?;
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
ensure_active_user(&transaction, user_id).await?;
let old_room_id: String = transaction
.query_one("SELECT room_id FROM users WHERE id=$1", &[&user_id])
.await?
.get(0);
let room_in_use: bool = transaction
.query_one(
"SELECT EXISTS(SELECT 1 FROM users WHERE room_id=$1 AND id<>$2)",
&[&room_id, &user_id],
)
.await?
.get(0);
if room_in_use {
return Err(AuthError::RoomUnavailable);
}
Db::set_tenant(&transaction, user_id).await?;
transaction
.execute(
"UPDATE users SET room_id=$1,updated_at=now() WHERE id=$2 AND status='active'",
&[&room_id, &user_id],
)
.await
.map_err(map_room_write_error)?;
let changed = transaction
.execute(
"UPDATE live_sources SET room_id=$1,updated_at=now() \
WHERE owner_user_id=$2",
&[&room_id, &user_id],
)
.await
.map_err(map_room_write_error)?;
if changed != 1 {
return Err(AuthError::DatabaseInvariant(
"account must own exactly one live source",
));
}
upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?;
insert_audit(
&transaction,
Some(user_id),
"account.live_source.updated",
"user",
Some(user_id.to_string()),
json!({
"host": host,
"oldRoomId": old_room_id,
"newRoomId": room_id,
"roomChanged": old_room_id != room_id,
}),
)
.await?;
transaction.commit().await?;
Ok(())
}
fn encrypt_cookiecloud_credentials(
&self,
user_id: Uuid,
credentials: &CookieCloudCredentials,
) -> Result<(String, EncryptedSecret), AuthError> {
let host = normalize_cookiecloud_host(&credentials.host)
.map_err(|_| AuthError::InvalidInput("CookieCloud host is invalid"))?;
if credentials.secrets.key.trim().is_empty() || credentials.secrets.password.is_empty() {
@@ -924,31 +1029,7 @@ impl AuthService {
let encrypted = self
.cipher
.encrypt(&plaintext, format!("cookiecloud:{user_id}").as_bytes())?;
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
ensure_active_user(&transaction, user_id).await?;
Db::set_tenant(&transaction, user_id).await?;
transaction
.execute(
"INSERT INTO cookiecloud_credentials \
(user_id,host,secrets_ciphertext,secrets_nonce) VALUES($1,$2,$3,$4) \
ON CONFLICT(user_id) DO UPDATE SET host=EXCLUDED.host,\
secrets_ciphertext=EXCLUDED.secrets_ciphertext,\
secrets_nonce=EXCLUDED.secrets_nonce,updated_at=now()",
&[&user_id, &host, &encrypted.ciphertext, &encrypted.nonce],
)
.await?;
insert_audit(
&transaction,
Some(user_id),
"cookiecloud.credentials.updated",
"user",
Some(user_id.to_string()),
json!({"host":host}),
)
.await?;
transaction.commit().await?;
Ok(())
Ok((host, encrypted))
}
pub async fn get_cookiecloud_credentials(
@@ -1274,6 +1355,33 @@ impl AuthService {
}
}
async fn upsert_cookiecloud_credentials(
transaction: &Transaction<'_>,
user_id: Uuid,
host: &str,
encrypted: &EncryptedSecret,
) -> Result<(), AuthError> {
transaction
.execute(
"INSERT INTO cookiecloud_credentials \
(user_id,host,secrets_ciphertext,secrets_nonce) VALUES($1,$2,$3,$4) \
ON CONFLICT(user_id) DO UPDATE SET host=EXCLUDED.host,\
secrets_ciphertext=EXCLUDED.secrets_ciphertext,\
secrets_nonce=EXCLUDED.secrets_nonce,updated_at=now()",
&[&user_id, &host, &encrypted.ciphertext, &encrypted.nonce],
)
.await?;
Ok(())
}
fn map_room_write_error(error: tokio_postgres::Error) -> AuthError {
if error.code() == Some(&SqlState::UNIQUE_VIOLATION) {
AuthError::RoomUnavailable
} else {
error.into()
}
}
#[derive(Clone)]
struct SecretCipher {
cipher: Arc<XChaCha20Poly1305>,
+82 -11
View File
@@ -68,6 +68,10 @@ pub fn router(state: AppState) -> Router {
"/api/v1/account/live-source/reconnect",
post(reconnect_source),
)
.route(
"/api/v1/account/preferences",
get(get_account_preferences).put(put_account_preferences),
)
.route(
"/api/v1/components",
get(list_components).post(create_component),
@@ -275,6 +279,7 @@ async fn register_start(
struct EnrollmentConfirmRequest {
enrollment_token: String,
code: String,
language: Option<String>,
}
async fn enrollment_confirm(
@@ -284,9 +289,20 @@ async fn enrollment_confirm(
) -> Result<Response, ApiError> {
same_origin(&state, &headers)?;
consume_enrollment_budget(&state, &headers).await?;
let requested_language = body
.language
.unwrap_or_else(|| crate::i18n::default_language().to_owned());
let language = requested_language.trim();
if !crate::i18n::is_supported(language) {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"invalid_input",
"Language is not supported",
));
}
let result = state
.auth
.registration_confirm(&body.enrollment_token, &body.code)
.registration_confirm(&body.enrollment_token, &body.code, language)
.await?;
if result.user.role == UserRole::SystemAdmin {
if let Err(error) = state
@@ -520,13 +536,7 @@ async fn put_source(
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
if body.room_id.trim() != session.user.room_id {
return Err(ApiError::new(
StatusCode::BAD_REQUEST,
"room_is_immutable",
"The room bound by the invitation cannot be changed",
));
}
let room_id = body.room_id.trim().to_owned();
let existing = state
.auth
.get_cookiecloud_credentials(session.user.id)
@@ -577,7 +587,7 @@ async fn put_source(
})?;
state
.auth
.set_cookiecloud_credentials(session.user.id, &credentials)
.set_account_live_source(session.user.id, &room_id, &credentials)
.await?;
state
.restart_user_source(session.user.id)
@@ -599,6 +609,50 @@ async fn reconnect_source(
Ok(Json(json!({"ok":true})))
}
async fn get_account_preferences(
State(state): State<AppState>,
headers: HeaderMap,
) -> Result<Json<Value>, ApiError> {
let session = require_session(&state, &headers).await?;
let language = state.repository.account_language(session.user.id).await?;
Ok(Json(json!({"language":language})))
}
#[derive(Deserialize)]
struct PutAccountPreferences {
language: String,
}
async fn put_account_preferences(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<PutAccountPreferences>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
let language = state
.repository
.set_account_language(session.user.id, body.language.trim())
.await?;
let room_id = state.repository.room_id(session.user.id).await?;
for view in state.repository.list_components(session.user.id).await? {
let component = state
.repository
.get_component(session.user.id, view.id)
.await?;
state.hub.publish(
component.id,
Arc::new(ComponentMessage::new(
&component,
&room_id,
"component.language.updated",
json!({"language":language}),
)),
);
}
Ok(Json(json!({"language":language})))
}
async fn list_components(
State(state): State<AppState>,
headers: HeaderMap,
@@ -1016,9 +1070,20 @@ async fn component_ws(mut socket: WebSocket, state: AppState, component_id: Uuid
}
};
let mut receiver = state.hub.subscribe(component_id);
let language = match state
.repository
.account_language(identity.owner_user_id)
.await
{
Ok(language) => language,
Err(_) => {
close_ws(&mut socket, "Component is unavailable").await;
return;
}
};
if socket
.send(Message::Text(
json!({"version":COMPONENT_PROTOCOL_VERSION,"type":"authenticated","componentId":component_id,"componentKind":component.kind})
json!({"version":COMPONENT_PROTOCOL_VERSION,"type":"authenticated","componentId":component_id,"componentKind":component.kind,"language":language})
.to_string()
.into(),
))
@@ -1296,9 +1361,11 @@ fn constant_time_secret_eq(provided: &str, expected: &str) -> bool {
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ErrorBody {
error: &'static str,
code: &'static str,
message_key: String,
message: String,
}
@@ -1345,6 +1412,7 @@ impl IntoResponse for ApiError {
Json(ErrorBody {
error: self.code,
code: self.code,
message_key: format!("api.error.{}", self.code),
message: self.message,
}),
)
@@ -1380,11 +1448,14 @@ impl From<AuthError> for ApiError {
"enrollment_unavailable",
error.to_string(),
),
AuthError::AccountUnavailable | AuthError::RoomUnavailable => Self::new(
AuthError::AccountUnavailable => Self::new(
StatusCode::CONFLICT,
"account_unavailable",
error.to_string(),
),
AuthError::RoomUnavailable => {
Self::new(StatusCode::CONFLICT, "room_unavailable", error.to_string())
}
AuthError::InvalidTotp
| AuthError::TotpReplay
| AuthError::InvalidCredentials
+65
View File
@@ -0,0 +1,65 @@
//! Shared language-resource validation.
//!
//! Rust embeds the same TOML file that Vite compiles into the browser bundle.
//! Backend persistence accepts only locale codes declared by that resource, so
//! the database and deployed frontend cannot drift into unsupported values.
use std::{collections::HashMap, sync::OnceLock};
use serde::Deserialize;
const RESOURCE: &str = include_str!("../../../resources/i18n.toml");
#[derive(Deserialize)]
struct Catalog {
default_language: String,
locales: HashMap<String, Locale>,
}
#[derive(Deserialize)]
struct Locale {
#[allow(dead_code)]
name: String,
messages: HashMap<String, String>,
}
fn catalog() -> &'static Catalog {
static CATALOG: OnceLock<Catalog> = OnceLock::new();
CATALOG.get_or_init(|| {
let catalog: Catalog = toml::from_str(RESOURCE).expect("resources/i18n.toml must be valid");
let default = catalog
.locales
.get(&catalog.default_language)
.expect("default language must be declared");
for (code, locale) in &catalog.locales {
for key in default.messages.keys() {
assert!(
locale.messages.contains_key(key),
"locale {code} is missing translation key {key}"
);
}
}
catalog
})
}
pub fn default_language() -> &'static str {
&catalog().default_language
}
pub fn is_supported(language: &str) -> bool {
catalog().locales.contains_key(language)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shared_catalog_is_complete_and_declares_supported_languages() {
assert_eq!(default_language(), "zh-CN");
assert!(is_supported("zh-CN"));
assert!(is_supported("en-US"));
assert!(!is_supported("unknown"));
}
}
+1
View File
@@ -13,6 +13,7 @@ pub mod credentials;
pub mod db;
pub mod domain;
pub mod http_api;
pub mod i18n;
pub mod live;
pub mod overlay;
pub mod rate_limit;
+39
View File
@@ -15,6 +15,7 @@ use uuid::Uuid;
use crate::{
components::{ComponentInstance, ComponentRegistry},
db::{ComponentRecord, Db, DbError},
i18n,
realtime::InMemoryComponentStore,
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
};
@@ -300,6 +301,44 @@ impl TenantRepository {
Ok(row.get(0))
}
pub async fn account_language(&self, owner_id: Uuid) -> Result<String, RepositoryError> {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?;
let language = transaction
.query_opt(
"SELECT language FROM account_preferences WHERE user_id=$1",
&[&owner_id],
)
.await?
.map(|row| row.get(0))
.unwrap_or_else(|| i18n::default_language().to_owned());
transaction.commit().await?;
Ok(language)
}
pub async fn set_account_language(
&self,
owner_id: Uuid,
language: &str,
) -> Result<String, RepositoryError> {
if !i18n::is_supported(language) {
return Err(RepositoryError::Invalid("language is not supported".into()));
}
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, owner_id).await?;
transaction
.execute(
"INSERT INTO account_preferences(user_id,language) VALUES($1,$2) \
ON CONFLICT(user_id) DO UPDATE SET language=EXCLUDED.language,updated_at=now()",
&[&owner_id, &language],
)
.await?;
transaction.commit().await?;
Ok(language.to_owned())
}
pub async fn token_summary(
&self,
owner_id: Uuid,