diff --git a/Dockerfile b/Dockerfile index b1f86b3..de7f2ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ cp /app/target-cache/release/lxc-stream-server /app/lxc-stream-server FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libasound2 libssl3 && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl libasound2 libssl3 && rm -rf /var/lib/apt/lists/* WORKDIR /app # The upstream client logs full upstream HTTP responses at INFO, which can include # account metadata and short-lived connection tokens. Keep application lifecycle diff --git a/README.md b/README.md index 6f9cae5..cfda4da 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,138 @@ -# 洛星瓷直播弹幕姬 +# 洛星瓷直播组件服务 -运行前复制带注释的 `config.toml.example` 为 `config.toml`,填入 CookieCloud、设置数据库和访问密钥,然后执行: +这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 + +直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在 `vendor/blivedm` 固定了保留原始 JSON 的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃。 + +## 后端结构 + +`src/main.rs` 只负责配置、组装、监听和优雅退出,可复用核心由 `src/lib.rs` 导出: + +- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs` 的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。 +- `components.rs` 注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。 +- `realtime.rs` 按 component UUID 建立独立广播通道,路由时同时校验 owner 与 source;副作用 handler 不依赖 OBS WebSocket 是否在线。 +- `auth.rs`、`repository.rs` 和 PostgreSQL RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs` 从服务端会话推导 owner,不接受客户端自报 owner ID。 + +## 部署 + +先复制配置并生成独立密钥: ```sh cp config.toml.example config.toml +openssl rand -base64 32 # 填入 security.data_encryption_key +openssl rand -hex 32 # 可作为一次性 admin.password +openssl rand -hex 32 # 填入兼容字段 admin.session_secret docker compose up --build -d ``` -应用配置遵循 `blivedm_rs` 的 TOML/`--config` 形式;Compose 会以只读方式将 `config.toml` 挂载到容器并传入 `--config /app/config.toml`。配置段及字段说明直接写在 [config.toml.example](config.toml.example) 的注释中。 +Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config` 读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719` 直接暴露到公网。 -应用容器使用 host 网络并监听 `9719`。它通过 `[database].url` 连接 PostgreSQL,仅用于持久化 OBS 弹幕姬设置。应用会自动创建所需表;`[connection].room_id`、CookieCloud Key/UUID 与密码均为必填配置。 +容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将 `config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置 `APP_UID` 与 `APP_GID`。 -服务端已迁移为 Rust/Axum,并使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 发布的 `blivedm` crate 建立 Bilibili 认证弹幕连接;不再使用 Node 服务端或 `@laplace.live/ws`。为确保 UID、礼物价格和上游事件 ID 不会被库的简化消息结构丢弃,项目在 `vendor/blivedm` 固定了一个仅保留原始 JSON 的小补丁。应用从 CookieCloud 获取 Bilibili Cookie(必须包含 `SESSDATA`),用于获取认证 UID、直播间网关与 WebSocket 鉴权包。 +`[database].url` 指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。 -## OBS 弹幕姬 +`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP Secret 与每个用户的 CookieCloud Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。 -镜像会在构建阶段编译 `apps/overlay`,最终由 Rust/Axum **同域托管**: +## 首次初始化与登录 -- `/obs?token=你的_OBS_访问令牌`:透明背景的 OBS 浏览器源。它没有固定画布,按浏览器源的实际宽高自动在窄侧栏、常规卡片和低高度模式之间切换。 -- `/control`:管理员控制台。使用 `admin.password` 登录后可改标题、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效、每卡粒子数量与速度、低性能模式和礼物高亮阈值;保存会实时广播给已打开的 OBS 源。 +首次部署且数据库中尚无账户时,打开: -控制台的“复制 OBS 地址”会生成含只读令牌的完整地址。该令牌仅能订阅 `/ws`,不能访问管理或写入接口。常用 OBS 浏览器源尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始;可按实际版面自由拖拽缩放,不会出现 1920×1080 的固定画布留白。 +```text +https://danmaku.luoxingci.com/control/setup +``` -每张消息卡片都会从六套花纹组合中稳定选取一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景;连续的新卡会主动避开相同款式,礼物连击更新则保持原样式不跳动。透明消息墙本身不铺设全局装饰背景。粒子数量与速度可在控制台调整;窄尺寸会自动减少粒子,低性能模式会关闭动态粒子。花边 SVG 已本地打包,来源和公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。 +填写系统管理员用户名和 `config.toml` 中的 `admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。 -后端会在启动时、之后每十分钟调用 Bilibili 的无需 Cookie 礼物面板接口缓存礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失目录条目会安全降级为直播事件自带的名称与价格。 +`admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof: -直播弹幕中的普通混排表情和整条大表情会作为安全的文字/图片分段通过 WebSocket 推送。消息自带的图片地址优先;服务还会使用 CookieCloud 的 `SESSDATA` 刷新直播间表情目录,在消息只提供表情唯一标识时补齐图片。图片加载失败时前端会退回原始表情文字。 +- 它不会保存为账户密码,也不能用于日常登录。 +- 第一个账户创建后,bootstrap 接口永久拒绝再次初始化。 +- 所有账户都是 passwordless 账户,以“用户名 + TOTP”登录;丢失验证器时可使用一次性恢复码。 +- 会话使用随机令牌,数据库只保存摘要,可在服务端到期或撤销。 -刷新间隔和请求超时可通过 `[gifts]` 调整。`[overlay]` 与 `[overlay.events]` 是某个直播间第一次运行时的展示默认值;在 `/control` 保存后,数据库中的设置优先,因此更新 TOML 不会覆盖管理员已经调好的 OBS 样式。若希望重新采用 TOML 默认值,可删除该房间在 `overlay_settings` 表中的记录后重启。 +系统管理员可在 `/control/invitations` 创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili `room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。 + +每个用户在 `/control/` 配置自己的 CookieCloud 同步 UUID/Key 和密码,地址必须位于部署管理员配置的 `security.cookiecloud_allowed_hosts` 白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili `SESSDATA`。 + +### 旧单用户配置迁移 + +以下 TOML 段现在只用于第一次系统管理员初始化时的一次性兼容迁移: + +- `[connection].room_id`:绑定首个系统管理员的房间。 +- `[cookiecloud]`:导入首个系统管理员的用户级加密凭据。 +- `[obs].access_token`:导入首个默认弹幕组件的旧 OBS 令牌。 +- `[overlay]` 与 `[overlay.events]`:迁移旧 `overlay_settings`,没有旧记录时作为首个组件的回退值。 + +迁移完成后,运行时以 PostgreSQL 中的用户、直播源、组件和凭据为准。以后修改这些旧段不会改变现有账户,也不会成为其他受邀用户的默认值;新用户使用邀请中绑定的房间,并在控制台填写自己的 CookieCloud。 + +## 必须使用 HTTPS + +公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持 `security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写: + +```nginx +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name danmaku.luoxingci.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl; + http2 on; + server_name danmaku.luoxingci.com; + + ssl_certificate /etc/letsencrypt/live/danmaku.luoxingci.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/danmaku.luoxingci.com/privkey.pem; + + location / { + proxy_pass http://127.0.0.1:9719; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } +} +``` + +只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置 `secure_cookies = false`。不要用该选项把登录页面直接发布到公网。 + +## 控制台 PWA + +通过 HTTPS 打开 `/control/` 后,受支持的浏览器会在控制台顶部显示“安装到设备”。`/control` 会由服务端永久重定向到这个规范地址。安装后的应用使用独立窗口,并继续采用“用户名 + TOTP”登录;登录、注册与首次初始化在 PWA 内分别使用 `/control/login`、`/control/register` 和 `/control/setup`。原有 `/login`、`/register`、`/setup` 地址仍兼容普通浏览器书签。 + +PWA 只控制 `/control/`,不会控制、缓存或刷新 `/obs/*` 浏览器源。离线缓存仅包含 React 应用外壳、本地图标和构建后带哈希的静态资源;账户、TOTP、邀请码、CookieCloud、组件设置、OBS 令牌、直播事件和所有 `/api/*` 响应始终在线直连且由服务端返回 `Cache-Control: no-store`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。 + +部署新版本后,已打开的控制台会显示“更新可用”。更新不会自动接管或刷新页面,必须由用户点击确认;确认前请先保存设置以及只显示一次的邀请码、恢复码或刚轮换的 OBS 令牌。 + +## 组件与 OBS 地址 + +登录 `/control/` 后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是: + +```text +https://danmaku.luoxingci.com/obs/#token= +``` + +`publicId` 是组件 UUID。`#token=...` 位于 URL fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向 `/api/v1/components//stream` 完成认证。令牌只带 `events:subscribe` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。 + +旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。 + +OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。 + +## 弹幕姬展示 + +每张消息卡片会从六套花纹组合中稳定选择一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景。连续的新卡避免使用相同款式,礼物连击更新保持原样式;透明消息墙本身不铺设全局装饰背景。粒子数量与速度、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效强度、低性能模式和礼物高亮阈值均可按组件在控制台调整,并实时推送给对应 OBS 源。 + +花边 SVG 已本地打包,来源及公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。 + +后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过 `[gifts]` 调整。 + +普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的 `SESSDATA` 刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过 `[emoticons]` 调整。 diff --git a/apps/overlay/index.html b/apps/overlay/index.html index 71a27a5..45d4ac8 100644 --- a/apps/overlay/index.html +++ b/apps/overlay/index.html @@ -1 +1,21 @@ -洛星瓷弹幕猪
+ + + + + + + + + + + + + + + 洛星瓷直播云台 + + +
+ + + diff --git a/apps/overlay/public/control/manifest.webmanifest b/apps/overlay/public/control/manifest.webmanifest new file mode 100644 index 0000000..d0ef263 --- /dev/null +++ b/apps/overlay/public/control/manifest.webmanifest @@ -0,0 +1,35 @@ +{ + "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" + } + ] +} diff --git a/apps/overlay/public/pwa/apple-touch-icon.png b/apps/overlay/public/pwa/apple-touch-icon.png new file mode 100644 index 0000000..a7495a4 Binary files /dev/null and b/apps/overlay/public/pwa/apple-touch-icon.png differ diff --git a/apps/overlay/public/pwa/icon-192.png b/apps/overlay/public/pwa/icon-192.png new file mode 100644 index 0000000..e6adbae Binary files /dev/null and b/apps/overlay/public/pwa/icon-192.png differ diff --git a/apps/overlay/public/pwa/icon-512.png b/apps/overlay/public/pwa/icon-512.png new file mode 100644 index 0000000..ff6b41c Binary files /dev/null and b/apps/overlay/public/pwa/icon-512.png differ diff --git a/apps/overlay/public/pwa/icon-maskable-512.png b/apps/overlay/public/pwa/icon-maskable-512.png new file mode 100644 index 0000000..184d94d Binary files /dev/null and b/apps/overlay/public/pwa/icon-maskable-512.png differ diff --git a/apps/overlay/public/pwa/icon-maskable.svg b/apps/overlay/public/pwa/icon-maskable.svg new file mode 100644 index 0000000..8d52c22 --- /dev/null +++ b/apps/overlay/public/pwa/icon-maskable.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/overlay/public/pwa/icon.svg b/apps/overlay/public/pwa/icon.svg new file mode 100644 index 0000000..269ca69 --- /dev/null +++ b/apps/overlay/public/pwa/icon.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/overlay/pwa/control-sw.js b/apps/overlay/pwa/control-sw.js new file mode 100644 index 0000000..99a5099 --- /dev/null +++ b/apps/overlay/pwa/control-sw.js @@ -0,0 +1,102 @@ +/* __PWA_BUILD_ID__ is replaced by the Vite build before this file is emitted. */ +const buildId = '__PWA_BUILD_ID__' +const cacheName = `lxc-control-v2-shell-${buildId}` +const shellPath = '/control/' +const shellRoutes = [ + '/control/', + '/control/invitations', + '/control/login', + '/control/register', + '/control/setup', +] +const stableAssets = [ + '/control/manifest.webmanifest', + '/pwa/icon-192.png', + '/pwa/icon-512.png', + '/pwa/icon-maskable-512.png', + '/pwa/apple-touch-icon.png', +] + +async function precacheShell() { + const cache = await caches.open(cacheName) + const response = await fetch(shellPath, { cache: 'reload', credentials: 'same-origin' }) + if (!response.ok) throw new Error(`Cannot precache control shell: ${response.status}`) + + const html = await response.clone().text() + await Promise.all(shellRoutes.map(route => cache.put(route, response.clone()))) + const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)] + .map(match => match[1]) + await cache.addAll([...new Set([...stableAssets, ...generatedAssets])]) +} + +self.addEventListener('install', event => { + event.waitUntil(precacheShell()) +}) + +self.addEventListener('activate', event => { + event.waitUntil((async () => { + const names = await caches.keys() + await Promise.all(names + .filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName) + .map(name => caches.delete(name))) + await self.clients.claim() + })()) +}) + +self.addEventListener('message', event => { + if (event.data?.type === 'SKIP_WAITING') event.waitUntil(self.skipWaiting()) +}) + +function isControlShell(pathname) { + return pathname.startsWith('/control/') +} + +function isStaticAsset(pathname) { + return pathname.startsWith('/assets/') + || pathname.startsWith('/pwa/') + || pathname === '/control/manifest.webmanifest' +} + +async function navigationResponse(request) { + const cache = await caches.open(cacheName) + const pathname = new URL(request.url).pathname + try { + const response = await fetch(request) + if (shellRoutes.includes(pathname) + && response.ok + && response.headers.get('content-type')?.includes('text/html')) { + await cache.put(pathname, response.clone()) + } + return response + } catch { + return (await cache.match(request, { ignoreSearch: true })) + || (await cache.match(shellPath)) + || Response.error() + } +} + +async function staticResponse(request) { + const cached = await caches.match(request, { ignoreSearch: false }) + if (cached) return cached + const response = await fetch(request) + if (response.ok) { + const cache = await caches.open(cacheName) + await cache.put(request, response.clone()) + } + return response +} + +self.addEventListener('fetch', event => { + const request = event.request + if (request.method !== 'GET') return + const url = new URL(request.url) + if (url.origin !== self.location.origin) return + + // Authentication, component data, WebSockets and OBS are outside this + // worker's scope and are never cached. Keep the path check as defence in depth. + if (request.mode === 'navigate' && isControlShell(url.pathname)) { + event.respondWith(navigationResponse(request)) + return + } + if (isStaticAsset(url.pathname)) event.respondWith(staticResponse(request)) +}) diff --git a/apps/overlay/src/api.ts b/apps/overlay/src/api.ts new file mode 100644 index 0000000..c568932 --- /dev/null +++ b/apps/overlay/src/api.ts @@ -0,0 +1,225 @@ +import type { + AuthUser, + ComponentSummary, + CookieCloudSource, + Invitation, + OverlaySettings, + Session, + TotpEnrollment, +} from './types' + +export class ApiError extends Error { + readonly status: number + readonly code?: string + readonly fieldErrors?: Record + + constructor(status: number, message: string, code?: string, fieldErrors?: Record) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + this.fieldErrors = fieldErrors + } +} + +async function parseResponse(response: Response): Promise { + if (response.status === 204) return undefined + const contentType = response.headers.get('content-type') ?? '' + if (contentType.includes('application/json')) return response.json() + const text = await response.text() + return text ? { message: text } : undefined +} + +export async function api(path: string, init: RequestInit = {}): Promise { + const method = (init.method ?? 'GET').toUpperCase() + if (!navigator.onLine && !['GET', 'HEAD'].includes(method)) { + throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', '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') + + const response = await fetch(path, { + ...init, + headers, + credentials: 'same-origin', + }) + const payload = await parseResponse(response) + if (!response.ok) { + if (response.status === 401 && !path.startsWith('/api/v1/auth/')) { + window.dispatchEvent(new Event('lxc:session-expired')) + } + const error = payload && typeof payload === 'object' ? payload as Record : {} + throw new ApiError( + response.status, + String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`), + typeof error.code === 'string' ? error.code : undefined, + error.fieldErrors && typeof error.fieldErrors === 'object' + ? error.fieldErrors as Record + : undefined, + ) + } + return payload as T +} + +export function json(method: string, body?: unknown): RequestInit { + return { + method, + body: body === undefined ? undefined : JSON.stringify(body), + } +} + +function object(value: unknown): Record { + return value != null && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {} +} + +export function normalizeSession(value: unknown): Session { + const root = object(value) + const candidate = root.user + ?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null) + const raw = object(candidate) + const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string' + const user: AuthUser | null = hasUser ? { + id: String(raw.id ?? ''), + username: String(raw.username ?? ''), + roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined, + displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined, + role: typeof raw.role === 'string' ? raw.role : 'user', + totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined, + } : null + return { user, setupRequired: root.setupRequired === true } +} + +export function normalizeEnrollment(value: unknown): TotpEnrollment { + const root = object(value) + const totp = object(root.totp ?? root.enrollment) + const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id + return { + enrollmentToken: String(enrollmentToken ?? ''), + qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined, + qrDataUrl: typeof totp.qrDataUrl === 'string' + ? totp.qrDataUrl + : typeof totp.qrCodeDataUrl === 'string' + ? totp.qrCodeDataUrl + : typeof root.qrDataUrl === 'string' + ? root.qrDataUrl + : undefined, + otpauthUri: typeof totp.otpauthUri === 'string' + ? totp.otpauthUri + : typeof root.otpauthUri === 'string' + ? root.otpauthUri + : undefined, + manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''), + expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined, + } +} + +export function normalizeRecoveryCodes(value: unknown): string[] { + const root = object(value) + const codes = root.recoveryCodes ?? object(root.recovery).codes + return Array.isArray(codes) ? codes.filter((code): code is string => typeof code === 'string') : [] +} + +export function normalizeComponents(value: unknown): ComponentSummary[] { + const root = object(value) + const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : [] + return list.map((entry) => { + const item = object(entry) + return { + id: String(item.id ?? ''), + publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''), + kind: String(item.kind ?? item.type ?? 'danmaku'), + name: String(item.name ?? '弹幕姬'), + enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined, + settings: item.settings as OverlaySettings | undefined, + updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined, + } + }).filter(item => item.id) +} + +export function normalizeSettings(value: unknown): OverlaySettings { + const root = object(value) + return (root.settings ?? value) as OverlaySettings +} + +export function normalizeSource(value: unknown): CookieCloudSource { + const root = object(value) + const source = object(root.source ?? root) + const cookieCloud = object(source.cookieCloud ?? source.cookiecloud ?? root.cookieCloud ?? root.cookiecloud) + const status = object(source.status) + return { + roomId: String(source.roomId ?? root.roomId ?? ''), + cookieCloud: { + host: String(cookieCloud.host ?? ''), + key: '', + keyConfigured: cookieCloud.keyConfigured === true + || (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0), + passwordConfigured: cookieCloud.passwordConfigured === true + || cookieCloud.configured === true + || (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0), + }, + connected: typeof source.connected === 'boolean' + ? source.connected + : typeof status.connected === 'boolean' + ? status.connected + : undefined, + detail: typeof source.detail === 'string' + ? source.detail + : typeof status.detail === 'string' + ? status.detail + : undefined, + updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined, + } +} + +export function normalizeInvitations(value: unknown): Invitation[] { + const root = object(value) + const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : [] + return list.map((entry) => { + const item = object(entry) + return { + id: String(item.id ?? ''), + code: typeof item.code === 'string' ? item.code : undefined, + codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined, + roomId: String(item.roomId ?? ''), + createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined, + createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined, + expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined, + consumedAt: typeof item.consumedAt === 'string' || item.consumedAt === null ? item.consumedAt : undefined, + revokedAt: typeof item.revokedAt === 'string' || item.revokedAt === null ? item.revokedAt : undefined, + } + }).filter(item => item.id) +} + +export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string { + return error instanceof Error && error.message ? error.message : fallback +} + +export async function copyToClipboard(text: string): Promise { + if (window.isSecureContext && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Fall through to the compatibility path used by OBS' embedded browser. + } + } + const input = document.createElement('textarea') + input.value = text + input.readOnly = true + input.style.position = 'fixed' + input.style.left = '-9999px' + input.style.opacity = '0' + document.body.appendChild(input) + input.focus() + input.select() + let copied = false + try { + copied = document.execCommand('copy') + } finally { + input.remove() + } + return copied +} diff --git a/apps/overlay/src/auth.tsx b/apps/overlay/src/auth.tsx new file mode 100644 index 0000000..27f2b4f --- /dev/null +++ b/apps/overlay/src/auth.tsx @@ -0,0 +1,358 @@ +import { useMemo, useState } from 'react' +import type { FormEvent, ReactNode } from 'react' +import { + api, + copyToClipboard, + errorMessage, + json, + normalizeEnrollment, + normalizeRecoveryCodes, +} from './api' +import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa' +import type { TotpEnrollment } from './types' + +function AuthShell({ eyebrow, title, children, footer }: { + eyebrow: string + title: string + children: ReactNode + footer?: ReactNode +}) { + return ( +
+
+ + +

{eyebrow}

+

{title}

+ {children} + {footer &&
{footer}
} +
+
+ ) +} + +function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) { + const source = useMemo(() => { + if (enrollment.qrDataUrl) return enrollment.qrDataUrl + if (enrollment.qrSvg) return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}` + return undefined + }, [enrollment.qrDataUrl, enrollment.qrSvg]) + + return ( +
+
+ {source + ? TOTP 验证器绑定二维码 + : 二维码暂不可用,请使用右侧密钥手工添加。} +
+
+

绑定动态验证器

+
    +
  1. 使用 1Password、Aegis、Microsoft Authenticator 等应用扫描二维码。
  2. +
  3. 若无法扫码,选择“输入设置密钥”。
  4. +
  5. 输入应用中出现的 6 位动态码完成绑定。
  6. +
+ +
+
+ ) +} + +function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () => void }) { + const [copied, setCopied] = useState(false) + const text = codes.join('\n') + const download = () => { + const blob = new Blob([ + '洛星瓷直播组件恢复码\n', + '每个恢复码只能使用一次,请离线妥善保存。\n\n', + text, + '\n', + ], { type: 'text/plain;charset=utf-8' }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = 'luoxingci-recovery-codes.txt' + anchor.click() + URL.revokeObjectURL(url) + } + + return ( + +

手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。

+ {codes.length > 0 + ?
{codes.map(code => {code})}
+ :
服务端没有返回恢复码,请先联系管理员确认恢复策略。
} +
+ {codes.length > 0 && ( + <> + + + + )} + +
+
+ ) +} + +export function LoginPage({ onAuthenticated, setupRequired }: { + onAuthenticated: () => Promise + setupRequired: boolean +}) { + 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), + ) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setBusy(true) + setError('') + try { + await api('/api/v1/auth/login', json('POST', { username: username.trim(), code: totpCode })) + setTotpCode('') + await onAuthenticated() + location.assign('/control/') + } catch (reason) { + setError(errorMessage(reason, '用户名或动态验证码不正确')) + } finally { + setBusy(false) + } + } + + return ( + + {setupRequired + ? <>首次部署?创建系统管理员 + : <>持有邀请码?注册新账户} +

+ )} + > +

这是无密码账户。输入用户名与验证器中的动态验证码即可登录。

+
+ + + + {error &&
{error}
} + +
+
+ ) +} + +export function EnrollmentPage({ mode, onAuthenticated }: { + mode: 'setup' | 'register' + onAuthenticated: () => Promise +}) { + const inviteFromFragment = new URLSearchParams(location.hash.slice(1)).get('invite') ?? '' + const [inviteCode, setInviteCode] = useState(inviteFromFragment) + const [username, setUsername] = useState('') + const [bootstrapPassword, setBootstrapPassword] = useState('') + const [enrollment, setEnrollment] = useState() + const [totpCode, setTotpCode] = useState('') + const [recoveryCodes, setRecoveryCodes] = useState() + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + const isSetup = mode === 'setup' + usePwaUpdateBlocker( + 'totp-enrollment', + enrollment || recoveryCodes + ? '完成 TOTP 绑定并保存一次性恢复码' + : '完成或清空正在填写的注册表单', + busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes), + ) + + const start = async (event: FormEvent) => { + event.preventDefault() + setBusy(true) + setError('') + try { + const payload = await api( + `/api/v1/auth/${isSetup ? 'setup' : 'register'}/start`, + json('POST', { + ...(isSetup ? {} : { inviteCode: inviteCode.trim() }), + username: username.trim(), + ...(isSetup ? { bootstrapPassword } : {}), + }), + ) + const next = normalizeEnrollment(payload) + if (!next.enrollmentToken) throw new Error('服务端未返回注册流程标识') + setEnrollment(next) + setBootstrapPassword('') + if (!isSetup) { + setInviteCode('') + history.replaceState(null, '', authRoute('register')) + } + } catch (reason) { + setError(errorMessage(reason, '无法开始安全注册流程')) + } finally { + setBusy(false) + } + } + + const confirm = async (event: FormEvent) => { + event.preventDefault() + if (!enrollment) return + setBusy(true) + setError('') + try { + const payload = await api( + `/api/v1/auth/${isSetup ? 'setup' : 'register'}/confirm`, + json('POST', { enrollmentToken: enrollment.enrollmentToken, code: totpCode }), + ) + setTotpCode('') + // Drop QR/manual-key material from React state as soon as enrollment is + // committed; only the one-time recovery codes remain on screen. + setEnrollment(undefined) + setRecoveryCodes(normalizeRecoveryCodes(payload)) + } catch (reason) { + setError(errorMessage(reason, '动态验证码无效或注册流程已过期')) + } finally { + setBusy(false) + } + } + + const finish = async () => { + await onAuthenticated() + location.assign('/control/') + } + + if (recoveryCodes) return void finish()} /> + + if (enrollment) { + return ( + + +
+ + {error &&
{error}
} + +
+
+ ) + } + + return ( + 已有账户?返回登录

} + > +

+ {isSetup + ? '首位账户将拥有邀请码管理权限。旧管理员口令只授权这一次初始化,不会成为账户密码。' + : '邀请码只用于注册;这是无密码账户,创建后每次登录都必须验证 TOTP。'} +

+
+ {!isSetup && ( + + )} + + {isSetup && ( + + )} + {error &&
{error}
} + +
+
+ ) +} diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css index 907ac44..7d32e34 100644 --- a/apps/overlay/src/control.css +++ b/apps/overlay/src/control.css @@ -250,22 +250,6 @@ body, display: none; } -.wall header { - max-width: 100%; - padding: 9px 14px; - font-size: var(--font-title, 25px); -} - -.wall header span { - min-width: 0; - overflow: visible; - overflow-wrap: anywhere; - text-overflow: clip; - white-space: normal; -} - -.wall:not(.awake) header, -.wall header span, .copy, .copy b, .copy span, @@ -533,3 +517,1034 @@ body, @media (max-width: 720px) { .preview-viewport { max-height: 560px; } } + +/* Multi-user application shell ------------------------------------------------ */ + +:root { + color-scheme: dark; + --app-bg: #041219; + --app-panel: rgba(7, 32, 42, .88); + --app-panel-strong: rgba(7, 39, 48, .96); + --app-line: rgba(110, 224, 205, .22); + --app-line-strong: rgba(117, 244, 218, .48); + --app-text: #dcfff7; + --app-muted: #83b8b0; + --app-accent: #67e6c5; + --app-accent-deep: #1c766c; + --app-danger: #ff9aa8; +} + +button, +input, +select { + font: inherit; +} + +.jade-panel { + position: relative; + border: 1px solid var(--app-line); + background: + radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, .08), transparent 48%), + linear-gradient(135deg, rgba(8, 39, 50, .94), rgba(5, 25, 35, .91)); + box-shadow: + inset 0 1px rgba(210, 255, 245, .055), + 0 18px 48px rgba(0, 8, 15, .22); + backdrop-filter: blur(18px); +} + +.jade-panel::after { + content: ""; + position: absolute; + inset: 5px; + z-index: 0; + border: 1px solid rgba(122, 237, 216, .06); + border-radius: inherit; + pointer-events: none; +} + +.jade-panel > * { + position: relative; + z-index: 1; +} + +.eyebrow { + margin: 0 0 6px; + color: #6bd9c1; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 11px; + font-weight: 700; + letter-spacing: .19em; + text-transform: uppercase; +} + +.auth-page, +.route-loading { + min-height: 100%; + padding: clamp(20px, 6vw, 72px); + display: grid; + place-items: center; + overflow: auto; + background: + radial-gradient(circle at 12% 12%, rgba(50, 145, 139, .2), transparent 30%), + radial-gradient(circle at 86% 82%, rgba(67, 105, 143, .18), transparent 33%), + linear-gradient(145deg, #04131b, #082b31 55%, #041119); + color: var(--app-text); +} + +.route-loading { + color: #93cfc4; + letter-spacing: .12em; +} + +.auth-card { + width: min(100%, 760px); + padding: clamp(26px, 5vw, 52px); + border-radius: 24px; +} + +.auth-card h1 { + margin: 0 0 8px; + color: #dffff7; + font-size: clamp(28px, 5vw, 44px); + font-weight: 600; + letter-spacing: .04em; +} + +.auth-card footer { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid rgba(109, 212, 196, .14); +} + +.auth-card footer p { + margin: 0; +} + +.auth-page a, +.dashboard-shell a { + color: #82efda; + text-underline-offset: 3px; +} + +.auth-mark { + position: absolute !important; + top: 24px; + right: 28px; + width: 54px; + height: 54px; + display: grid; + place-items: center; + border: 1px solid rgba(124, 239, 216, .32); + border-radius: 50%; + color: rgba(190, 255, 241, .8); + background: radial-gradient(circle, rgba(62, 173, 155, .23), transparent 72%); + box-shadow: 0 0 30px rgba(80, 216, 191, .14); +} + +.auth-lead { + margin: 8px 0 26px; + color: var(--app-muted); + line-height: 1.7; +} + +.stack-form, +.field-grid { + display: grid; + gap: 17px; +} + +.stack-form label, +.field-grid label, +.secret-address { + display: grid; + gap: 7px; + color: #bde9df; + font-size: 14px; +} + +.stack-form label small, +.field-grid label small { + color: #719f99; + line-height: 1.45; +} + +.stack-form input, +.field-grid input, +.field-grid select, +.secret-address input, +.one-time-secret input { + width: 100%; + min-height: 44px; + padding: 10px 12px; + border: 1px solid rgba(75, 157, 151, .55); + border-radius: 10px; + outline: none; + color: #edfffb; + background: rgba(3, 18, 26, .82); + transition: border-color .18s ease, box-shadow .18s ease; +} + +.stack-form input:focus, +.field-grid input:focus, +.field-grid select:focus, +.secret-address input:focus, +.one-time-secret input:focus { + border-color: #6ee8ce; + box-shadow: 0 0 0 3px rgba(92, 225, 198, .12); +} + +.otp-input { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; + font-size: 22px !important; + font-weight: 700; + letter-spacing: .35em; + text-align: center; +} + +.auth-page button, +.dashboard-shell button, +.button-link { + min-height: 40px; + padding: 9px 15px; + border: 1px solid transparent; + border-radius: 9px; + color: #05241f; + background: linear-gradient(135deg, #86f2d8, #50cdb2); + box-shadow: 0 5px 18px rgba(40, 168, 145, .16); + font-weight: 700; + text-align: center; + text-decoration: none; + cursor: pointer; + transition: filter .16s ease, transform .16s ease, border-color .16s ease; +} + +.auth-page button:hover:not(:disabled), +.dashboard-shell button:hover:not(:disabled), +.button-link:hover { + filter: brightness(1.08); + transform: translateY(-1px); +} + +.auth-page button:disabled, +.dashboard-shell button:disabled { + cursor: wait; + filter: saturate(.45); + opacity: .65; +} + +.auth-page button.secondary, +.dashboard-shell button.secondary, +.dashboard-shell .ghost-button { + border-color: rgba(91, 189, 176, .26); + color: #cffff4; + background: rgba(16, 67, 76, .62); + box-shadow: none; +} + +.dashboard-shell button.danger { + border-color: rgba(255, 138, 156, .3); + color: #ffd7dd; + background: rgba(102, 32, 48, .58); + box-shadow: none; +} + +.dashboard-shell button.small { + min-height: 30px; + padding: 5px 10px; + font-size: 12px; +} + +.text-button { + min-height: 30px !important; + padding: 4px 9px !important; + box-shadow: none !important; +} + +.auth-page .inline-link { + width: max-content; + min-height: 0; + padding: 0; + border: 0; + color: #79d7c5; + background: transparent; + box-shadow: none; + font-weight: 500; +} + +.recovery-input { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; + letter-spacing: .08em; +} + +.notice { + padding: 11px 13px; + border: 1px solid rgba(121, 222, 205, .24); + border-radius: 10px; + color: #c9f8ee; + background: rgba(17, 73, 76, .42); + line-height: 1.45; +} + +.notice.error { + border-color: rgba(255, 133, 151, .36); + color: #ffd6dc; + background: rgba(103, 31, 47, .45); +} + +.notice.warning { + border-color: rgba(255, 213, 127, .34); + color: #ffe4aa; + background: rgba(93, 67, 24, .4); +} + +.notice.success { + border-color: rgba(105, 239, 196, .34); + color: #baffea; +} + +.totp-enrollment { + margin: 26px 0; + display: grid; + grid-template-columns: minmax(180px, 240px) 1fr; + gap: clamp(20px, 5vw, 42px); + align-items: center; +} + +.totp-qr { + aspect-ratio: 1; + padding: 12px; + display: grid; + place-items: center; + border-radius: 16px; + color: #47736e; + background: #f4fffc; + text-align: center; +} + +.totp-qr img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.totp-copy h2 { + margin: 0 0 10px; +} + +.totp-copy ol { + margin: 0 0 18px; + padding-left: 20px; + color: #91c1b9; + line-height: 1.65; +} + +.secret-row { + min-width: 0; + display: flex; + gap: 8px; + align-items: center; +} + +.secret-row code { + min-width: 0; + padding: 8px 10px; + overflow-wrap: anywhere; + border: 1px solid rgba(106, 216, 199, .2); + border-radius: 8px; + color: #e8fff9; + background: rgba(0, 12, 19, .66); +} + +.compact-form { + max-width: 420px; + margin-inline: auto; +} + +.recovery-grid { + margin: 22px 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 9px; +} + +.recovery-grid code { + padding: 11px; + border: 1px solid rgba(104, 219, 200, .2); + border-radius: 8px; + color: #e8fff9; + background: rgba(0, 13, 20, .7); + text-align: center; + letter-spacing: .08em; +} + +.form-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.form-actions.align-end { + justify-content: flex-end; +} + +.not-found { + text-align: center; +} + +/* Dashboard */ + +.dashboard-shell { + min-height: 100%; + overflow: auto; + color: var(--app-text); + background: + radial-gradient(circle at 88% 8%, rgba(30, 119, 114, .17), transparent 28%), + radial-gradient(circle at 8% 80%, rgba(58, 83, 126, .12), transparent 32%), + var(--app-bg); +} + +.dashboard-topbar { + position: sticky; + top: 0; + z-index: 20; + min-height: 72px; + padding: 10px clamp(16px, 4vw, 48px); + display: grid; + grid-template-columns: minmax(210px, 1fr) auto minmax(210px, 1fr); + gap: 20px; + align-items: center; + border-bottom: 1px solid rgba(84, 178, 166, .16); + background: rgba(3, 18, 26, .84); + backdrop-filter: blur(18px); +} + +.brand { + width: max-content; + display: flex; + gap: 11px; + align-items: center; + color: #dffff8 !important; + text-decoration: none; +} + +.brand > span { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border: 1px solid rgba(111, 232, 210, .35); + border-radius: 50%; + background: rgba(34, 116, 107, .22); +} + +.brand div, +.account-menu div { + display: grid; + gap: 2px; +} + +.brand small, +.account-menu small { + color: #689b94; + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 9px; + letter-spacing: .12em; +} + +.dashboard-topbar nav { + display: flex; + gap: 5px; + padding: 4px; + border: 1px solid rgba(89, 181, 169, .14); + border-radius: 11px; + background: rgba(3, 20, 28, .54); +} + +.dashboard-topbar nav a { + padding: 8px 14px; + border-radius: 8px; + color: #8ebdb6; + text-decoration: none; +} + +.dashboard-topbar nav a.active { + color: #e4fff9; + background: rgba(49, 132, 120, .35); +} + +.account-menu { + display: flex; + justify-content: flex-end; + gap: 12px; + align-items: center; + text-align: right; +} + +.pwa-controls { + display: flex; + gap: 7px; + align-items: center; +} + +.account-menu .pwa-controls { + 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; + border-color: rgba(95, 215, 193, .28); + color: #cffff4; + background: rgba(16, 67, 76, .72); + box-shadow: none; + font-size: 12px; +} + +.pwa-state { + min-height: 30px; + padding: 5px 10px; + display: inline-flex; + gap: 6px; + align-items: center; + border: 1px solid rgba(255, 202, 112, .3); + border-radius: 999px; + color: #ffe2a7; + background: rgba(83, 55, 20, .48); + font-family: ui-sans-serif, system-ui, sans-serif; + font-size: 12px; + white-space: nowrap; +} + +.pwa-state i { + width: 7px; + height: 7px; + border-radius: 50%; + background: #ffca70; + box-shadow: 0 0 9px rgba(255, 202, 112, .68); +} + +.dashboard-shell .pwa-action { + min-height: 30px; + padding: 5px 10px; + border-color: rgba(95, 215, 193, .28); + color: #cffff4; + background: rgba(16, 67, 76, .72); + box-shadow: none; + font-size: 12px; + white-space: nowrap; +} + +.dashboard-shell .pwa-action.update { + border-color: rgba(255, 222, 143, .4); + color: #ffe8b7; + background: rgba(91, 65, 25, .58); + animation: pwa-update-glow 2.2s ease-in-out infinite; +} + +@keyframes pwa-update-glow { + 50% { box-shadow: 0 0 15px rgba(255, 215, 123, .16); } +} + +@media (display-mode: standalone) { + .dashboard-topbar { + padding-top: max(10px, env(safe-area-inset-top)); + padding-right: max(clamp(16px, 4vw, 48px), env(safe-area-inset-right)); + padding-left: max(clamp(16px, 4vw, 48px), env(safe-area-inset-left)); + } +} + +.dashboard-grid { + width: min(100%, 1580px); + margin: 0 auto; + padding: clamp(18px, 3vw, 38px); + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: clamp(18px, 3vw, 30px); + align-items: start; +} + +.component-sidebar { + position: sticky; + top: 98px; + min-height: 400px; + padding: 20px 13px; + border-radius: 17px; +} + +.component-sidebar-heading { + padding: 0 8px 13px; +} + +.component-sidebar h2, +.panel-header h2 { + margin: 0; + color: #dffff8; + font-size: 20px; + font-weight: 600; +} + +.component-list { + display: grid; + gap: 7px; +} + +.component-list button { + width: 100%; + min-height: 64px; + padding: 9px 10px; + display: grid; + grid-template-columns: 38px minmax(0, 1fr) 8px; + gap: 10px; + align-items: center; + border-color: transparent; + color: #beeae2; + background: transparent; + box-shadow: none; + text-align: left; +} + +.component-list button.selected { + border-color: rgba(91, 211, 190, .23); + background: linear-gradient(110deg, rgba(38, 126, 114, .35), rgba(17, 69, 76, .3)); +} + +.component-list button > span:nth-child(2) { + min-width: 0; + display: grid; + gap: 3px; +} + +.component-list button b, +.component-list button small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.component-list button small { + color: #719e98; + font-weight: 400; +} + +.component-icon { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border: 1px solid rgba(106, 225, 203, .2); + border-radius: 10px; + color: #a8f0df; + background: rgba(45, 135, 121, .2); +} + +.component-list i { + width: 7px; + height: 7px; + border-radius: 50%; + background: #536d6d; +} + +.component-list i.enabled { + background: #72ebc9; + box-shadow: 0 0 9px rgba(86, 232, 197, .7); +} + +.future-components { + margin: 18px 8px 0; + padding-top: 15px; + display: grid; + gap: 4px; + border-top: 1px solid rgba(94, 187, 173, .12); + color: #567d78; +} + +.dashboard-content { + min-width: 0; + display: grid; + gap: 20px; +} + +.page-heading { + min-height: 64px; + display: flex; + justify-content: space-between; + gap: 20px; + align-items: center; +} + +.page-heading h1 { + margin: 0; + color: #e2fff9; + font-size: clamp(27px, 4vw, 40px); + font-weight: 600; +} + +.status-chip { + width: max-content; + padding: 5px 9px; + border: 1px solid rgba(112, 197, 186, .2); + border-radius: 99px; + color: #91bcb6; + background: rgba(17, 60, 66, .44); + font-size: 12px; + white-space: nowrap; +} + +.status-chip.online { + border-color: rgba(99, 236, 199, .3); + color: #99f3d8; + background: rgba(31, 105, 87, .35); +} + +.status-chip.offline { + border-color: rgba(240, 151, 159, .25); + color: #e9aeb4; + background: rgba(91, 39, 48, .34); +} + +.dashboard-panel { + padding: clamp(18px, 3vw, 28px); + border-radius: 17px; +} + +.panel-header { + margin-bottom: 22px; + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; +} + +.panel-header p, +.source-detail { + margin: 6px 0 0; + color: #78aaa3; + line-height: 1.55; +} + +.slider-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 19px 30px; +} + +.slider-grid label { + display: grid; + gap: 9px; + color: #a9dad1; +} + +.slider-grid label > span { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.slider-grid output { + color: #8aebd5; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.slider-grid input[type="range"] { + width: 100%; + accent-color: #63ddc1; +} + +.toggle-grid { + margin: 25px 0; + padding: 14px; + display: flex; + flex-wrap: wrap; + gap: 9px; + border: 1px solid rgba(89, 179, 167, .18); + border-radius: 12px; +} + +.toggle-grid legend { + padding: 0 7px; + color: #77a9a2; +} + +.toggle-grid label { + margin: 0; + display: flex; + gap: 7px; + align-items: center; + color: #bae6de; +} + +.toggle-grid input { + accent-color: #5ee0c1; +} + +.two-columns { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.span-all { + grid-column: 1 / -1; +} + +.field-grid > .notice { + grid-column: 1 / -1; +} + +.preview-panel { + overflow: hidden; +} + +.preview-panel .preset-buttons { + margin-top: 0; +} + +.preview-panel .preset-buttons button { + color: #d2f8f0; +} + +.preview-panel .preview-viewport { + border-radius: 10px; + background: #021017; +} + +.preview-panel .preview-frame { + max-width: none; + min-width: 280px; + min-height: 240px; +} + +.token-summary { + margin-bottom: 19px; + display: flex; + flex-wrap: wrap; + gap: 14px 30px; +} + +.token-summary > div { + display: grid; + gap: 4px; +} + +.token-summary small { + color: #719e97; +} + +.token-summary code { + color: #b7eade; + overflow-wrap: anywhere; +} + +.secret-address { + margin: 16px 0; +} + +.secret-address input, +.one-time-secret input { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; +} + +.empty-state { + padding: 28px 12px; + color: #709d97; + text-align: center; +} + +.empty-state p { + margin: 7px 0 0; +} + +.loading-panel { + min-height: 180px; + padding: 30px; + display: grid; + place-items: center; + border-radius: 17px; + color: #79b2aa; +} + +.admin-content { + width: min(1120px, 100%); + margin: 0 auto; + padding: clamp(20px, 4vw, 46px); + display: grid; + gap: 22px; +} + +.one-time-secret { + margin-top: 22px; + padding: 17px; + display: grid; + gap: 11px; + border: 1px solid rgba(255, 215, 127, .28); + border-radius: 12px; + background: rgba(86, 64, 24, .24); +} + +.one-time-secret > b { + color: #ffe3a7; +} + +.one-time-secret > code { + color: #fff3d1; + font-size: 17px; + overflow-wrap: anywhere; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + padding: 12px 10px; + border-bottom: 1px solid rgba(85, 169, 158, .12); + color: #a9d3cc; + text-align: left; + white-space: nowrap; +} + +th { + color: #719e97; + font-size: 12px; + font-weight: 500; +} + +td:last-child { + text-align: right; +} + +.obs-configuration-error { + padding: 10px 12px; + border: 1px solid rgba(255, 143, 157, .42); + border-radius: 10px; + color: #ffe1e5; + background: rgba(72, 24, 37, .82); + font-size: clamp(13px, 3vw, 17px); +} + +@media (max-width: 980px) { + .dashboard-topbar { + grid-template-columns: 1fr auto; + } + + .dashboard-topbar nav { + grid-row: 2; + grid-column: 1 / -1; + justify-self: center; + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .component-sidebar { + position: static; + min-height: 0; + } + + .component-list { + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + } + + .future-components { + display: none; + } +} + +@media (max-width: 680px) { + .auth-page { + padding: 12px; + } + + .auth-card { + padding: 25px 18px; + border-radius: 17px; + } + + .auth-mark { + display: none; + } + + .totp-enrollment, + .slider-grid, + .two-columns { + grid-template-columns: 1fr; + } + + .totp-qr { + width: min(230px, 100%); + justify-self: center; + } + + .recovery-grid { + grid-template-columns: 1fr; + } + + .dashboard-topbar { + position: static; + grid-template-columns: 1fr; + } + + .dashboard-topbar nav, + .dashboard-topbar .account-menu { + grid-row: auto; + grid-column: auto; + justify-self: stretch; + } + + .dashboard-topbar nav { + justify-content: center; + } + + .account-menu { + justify-content: space-between; + flex-wrap: wrap; + text-align: left; + } + + .account-menu .pwa-controls { + order: 3; + width: 100%; + } + + .dashboard-grid { + padding: 12px; + } + + .component-list { + grid-template-columns: 1fr; + } + + .panel-header, + .page-heading { + align-items: flex-start; + flex-direction: column; + } + + .token-summary { + display: grid; + } +} + +@media (prefers-reduced-motion: reduce) { + .auth-page *, + .dashboard-shell * { + scroll-behavior: auto !important; + transition: none !important; + } + + .dashboard-shell .pwa-action.update { + animation: none !important; + } +} diff --git a/apps/overlay/src/control.tsx b/apps/overlay/src/control.tsx new file mode 100644 index 0000000..145eaf1 --- /dev/null +++ b/apps/overlay/src/control.tsx @@ -0,0 +1,784 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { FormEvent, ReactNode } from 'react' +import { + ApiError, + api, + copyToClipboard, + errorMessage, + json, + normalizeComponents, + normalizeInvitations, + normalizeSettings, + normalizeSource, +} from './api' +import { Overlay } from './overlay' +import { PwaControls, usePwaUpdateBlocker } from './pwa' +import { defaultOverlaySettings } from './types' +import type { + AuthUser, + ComponentSummary, + CookieCloudSource, + Invitation, + OverlaySettings, +} from './types' + +const previewPresets = [ + { label: '窄侧栏', width: 360, height: 600 }, + { label: '竖屏', width: 440, height: 760 }, + { label: '高清竖栏', width: 600, height: 1080 }, + { label: '横向条', width: 720, height: 320 }, +] + +function isDanmakuKind(kind: string): boolean { + return kind === 'danmaku_overlay' || kind === 'danmaku' +} + +type Flash = { kind: 'success' | 'error'; text: string } | undefined + +function Panel({ title, description, aside, children, className = '' }: { + title: string + description?: string + aside?: ReactNode + children: ReactNode + className?: string +}) { + return ( +
+
+
+

{title}

+ {description &&

{description}

} +
+ {aside} +
+ {children} +
+ ) +} + +function FlashMessage({ flash }: { flash: Flash }) { + if (!flash) return null + return
{flash.text}
+} + +function ControlLayout({ user, active, onLogout, children }: { + user: AuthUser + active: 'components' | 'invitations' + onLogout: () => Promise + children: ReactNode +}) { + const isAdmin = user.role === 'system_admin' + return ( +
+
+ + +
洛星瓷直播云台OBS COMPONENT STUDIO
+
+ +
+ +
{user.displayName || user.username}{isAdmin ? '系统管理员' : '用户'}
+ +
+
+ {children} +
+ ) +} + +function SettingsEditor({ settings, onChange, onSave, saving }: { + settings: OverlaySettings + onChange: (settings: OverlaySettings) => void + onSave: () => Promise + saving: boolean +}) { + const edit = (key: K, value: OverlaySettings[K]) => { + onChange({ ...settings, [key]: value }) + } + const eventToggles: Array<[keyof OverlaySettings, string]> = [ + ['showDanmaku', '弹幕'], + ['showEnter', '进房'], + ['showGift', '礼物'], + ['showSuperchat', '醒目留言'], + ['showGuard', '舰长'], + ['showLike', '点赞'], + ['showShare', '分享'], + ] + + return ( +
+
+ + + + + + + +
+ +
+ 显示事件 + {eventToggles.map(([key, label]) => ( + + ))} + +
+ +
+ + +
+
+ +
+
+ ) +} + +function OverlayPreview({ settings }: { settings: OverlaySettings }) { + const [preset, setPreset] = useState(previewPresets[1]) + return ( + +
+ {previewPresets.map(size => ( + + ))} +
+
+
+ +
+
+
+ ) +} + +function SourceEditor({ source, onSaved }: { + source: CookieCloudSource + onSaved: (source: CookieCloudSource) => void +}) { + const [roomId, setRoomId] = useState(source.roomId) + const [host, setHost] = useState(source.cookieCloud.host) + const [key, setKey] = useState(source.cookieCloud.key) + const [password, setPassword] = useState('') + const [busy, setBusy] = useState(false) + const [flash, setFlash] = useState() + const sourceDirty = roomId !== source.roomId + || host.trim() !== source.cookieCloud.host + || key !== source.cookieCloud.key + || Boolean(password) + usePwaUpdateBlocker('live-source', '保存或还原直播源与 CookieCloud 设置', busy || sourceDirty) + + useEffect(() => { + setRoomId(source.roomId) + setHost(source.cookieCloud.host) + setKey(source.cookieCloud.key) + setPassword('') + }, [source]) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setBusy(true) + setFlash(undefined) + try { + const payload = await api('/api/v1/source', json('PUT', { + roomId: roomId.trim(), + cookieCloud: { + host: host.trim(), + key: key.trim(), + ...(password ? { password } : {}), + }, + })) + const next = normalizeSource(payload) + onSaved(next) + setPassword('') + setFlash({ kind: 'success', text: '直播源已保存,连接会使用新的隔离配置。' }) + } catch (reason) { + setFlash({ kind: 'error', text: errorMessage(reason, '直播源保存失败') }) + } finally { + setBusy(false) + } + } + + return ( + + {source.connected ? '已连接' : '未连接'} + + )} + > + {source.detail &&

{source.detail}

} +
+ + + + + +
+ +
+ +
+ ) +} + +type TokenState = { + publicId: string + configured: boolean + updatedAt?: string + address?: string +} + +function tokenState(value: unknown, fallbackPublicId: string): TokenState { + const root = value && typeof value === 'object' ? value as Record : {} + const token = typeof root.token === 'string' ? root.token : undefined + const publicId = String(root.publicId ?? fallbackPublicId) + let address = typeof root.url === 'string' + ? root.url + : typeof root.path === 'string' + ? root.path + : undefined + if (address) address = new URL(address, location.origin).toString() + if (!address && token) { + const url = new URL(`/obs/${encodeURIComponent(publicId)}`, location.origin) + url.hash = new URLSearchParams({ token }).toString() + address = url.toString() + } + return { + publicId, + configured: root.configured === true || root.hasToken === true || root.tokenConfigured === true || Boolean(token || address), + updatedAt: typeof root.updatedAt === 'string' ? root.updatedAt : undefined, + address, + } +} + +function ObsAccessPanel({ component }: { component: ComponentSummary }) { + const [state, setState] = useState({ publicId: component.publicId, configured: false }) + const [busy, setBusy] = useState(false) + const [flash, setFlash] = useState() + usePwaUpdateBlocker( + `obs-token:${component.id}`, + '复制并妥善保存本次生成的 OBS 地址', + busy || Boolean(state.address), + ) + + useEffect(() => { + let cancelled = false + setState({ publicId: component.publicId, configured: false }) + setFlash(undefined) + api(`/api/v1/components/${encodeURIComponent(component.id)}/token`) + .then(payload => { if (!cancelled) setState(tokenState(payload, component.publicId)) }) + .catch(reason => { + if (cancelled) return + setState({ publicId: component.publicId, configured: false }) + setFlash({ kind: 'error', text: errorMessage(reason, '无法读取当前组件的 OBS 令牌状态') }) + }) + return () => { cancelled = true } + }, [component.id, component.publicId]) + + const rotate = async () => { + if (state.configured && !window.confirm('轮换后,所有使用旧地址的 OBS 浏览器源会立即失效。确定继续吗?')) return + setBusy(true) + setFlash(undefined) + try { + const payload = await api(`/api/v1/components/${encodeURIComponent(component.id)}/token`, json('POST')) + const next = tokenState(payload, component.publicId) + setState(next) + setFlash({ kind: 'success', text: '新 OBS 令牌已生成。请立即复制,离开本页后不会再次显示明文令牌。' }) + } catch (reason) { + setFlash({ kind: 'error', text: errorMessage(reason, '无法轮换 OBS 令牌') }) + } finally { + setBusy(false) + } + } + + const copy = async () => { + if (!state.address) return + const copied = await copyToClipboard(state.address) + setFlash(copied + ? { kind: 'success', text: 'OBS 浏览器源地址已复制。' } + : { kind: 'error', text: '浏览器阻止了复制,请手工选择下方地址。' }) + } + + return ( + +
+
+ 访问状态 + {state.configured ? '已生成令牌' : '尚未生成'} +
+
+ 组件公开标识 + {state.publicId} +
+ {state.updatedAt &&
最近轮换{formatDate(state.updatedAt)}
} +
+ + {state.address && ( + + )} +
+ + {state.address && } +
+
+ ) +} + +function TestEvents({ componentId }: { componentId: string }) { + const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift'>('danmaku') + const [uid, setUid] = useState('test-viewer') + const [name, setName] = useState('测试观众') + const [text, setText] = useState('今天也要闪闪发光!') + const [giftName, setGiftName] = useState('小花花') + const [quantity, setQuantity] = useState(1) + const [battery, setBattery] = useState(100) + const [flash, setFlash] = useState() + const [busy, setBusy] = useState(false) + + const submit = async (event: FormEvent) => { + event.preventDefault() + setBusy(true) + setFlash(undefined) + try { + await api(`/api/v1/components/${encodeURIComponent(componentId)}/test-events`, json('POST', { + kind, + uid, + name, + ...(kind === 'danmaku' ? { text } : {}), + ...(kind === 'gift' ? { giftName, quantity, battery } : {}), + })) + setFlash({ kind: 'success', text: '测试事件已发送到当前组件。' }) + } catch (reason) { + setFlash({ kind: 'error', text: errorMessage(reason, '测试事件发送失败') }) + } finally { + setBusy(false) + } + } + + return ( + +
+ + + + {kind === 'danmaku' && } + {kind === 'gift' && ( + <> + + + + + )} + +
+ +
+ ) +} + +function ComponentList({ components, selectedId, onSelect }: { + components: ComponentSummary[] + selectedId?: string + onSelect: (component: ComponentSummary) => void +}) { + return ( + + ) +} + +export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise }) { + const [components, setComponents] = useState([]) + const [selectedId, setSelectedId] = useState() + const [settings, setSettings] = useState() + const [source, setSource] = useState() + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [flash, setFlash] = useState() + const selectedIdRef = useRef(undefined) + const settingsRequestRef = useRef(0) + const savedSettingsRef = useRef(undefined) + + const selected = useMemo(() => components.find(component => component.id === selectedId), [components, selectedId]) + const settingsDirty = Boolean(settings) + && JSON.stringify(settings) !== savedSettingsRef.current + usePwaUpdateBlocker('component-settings', '保存或还原当前组件设置', saving || settingsDirty) + + const loadComponentSettings = useCallback(async (component: ComponentSummary) => { + const requestId = ++settingsRequestRef.current + setSaving(false) + setSettings(undefined) + savedSettingsRef.current = undefined + setFlash(undefined) + try { + const payload = await api(`/api/v1/components/${encodeURIComponent(component.id)}/settings`) + if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return + const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) } + savedSettingsRef.current = JSON.stringify(next) + setSettings(next) + } catch (reason) { + if (requestId !== settingsRequestRef.current || selectedIdRef.current !== component.id) return + setFlash({ kind: 'error', text: errorMessage(reason, '无法读取组件设置') }) + } + }, []) + + useEffect(() => { + let cancelled = false + const load = async () => { + setLoading(true) + try { + const [componentPayload, sourcePayload] = await Promise.all([ + api('/api/v1/components'), + api('/api/v1/source'), + ]) + if (cancelled) return + const nextComponents = normalizeComponents(componentPayload) + setComponents(nextComponents) + setSource(normalizeSource(sourcePayload)) + const requested = new URLSearchParams(location.search).get('component') + const first = nextComponents.find(component => component.id === requested) + ?? nextComponents.find(component => isDanmakuKind(component.kind)) + ?? nextComponents[0] + if (first) { + selectedIdRef.current = first.id + setSelectedId(first.id) + await loadComponentSettings(first) + } + } catch (reason) { + if (!cancelled) setFlash({ kind: 'error', text: errorMessage(reason, '控制台数据加载失败') }) + } finally { + if (!cancelled) setLoading(false) + } + } + void load() + return () => { + cancelled = true + settingsRequestRef.current += 1 + } + }, [loadComponentSettings]) + + const choose = (component: ComponentSummary) => { + selectedIdRef.current = component.id + setSelectedId(component.id) + const url = new URL(location.href) + url.searchParams.set('component', component.id) + history.replaceState(null, '', url) + void loadComponentSettings(component) + } + + const saveSettings = async () => { + if (!selected || !settings) return + const componentId = selected.id + const requestId = settingsRequestRef.current + setSaving(true) + setFlash(undefined) + try { + const payload = await api( + `/api/v1/components/${encodeURIComponent(componentId)}/settings`, + json('PUT', settings), + ) + if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return + const next = { ...defaultOverlaySettings, ...normalizeSettings(payload) } + savedSettingsRef.current = JSON.stringify(next) + setSettings(next) + setFlash({ kind: 'success', text: '组件设置已保存,并实时同步到已连接的 OBS。' }) + } catch (reason) { + if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return + setFlash({ kind: 'error', text: errorMessage(reason, '组件设置保存失败') }) + } finally { + if (requestId === settingsRequestRef.current && selectedIdRef.current === componentId) setSaving(false) + } + } + + return ( + +
+ +
+ {loading &&
正在展开云台…
} + + {!loading && selected && ( + <> +
+

{selected.kind.toUpperCase()}

{selected.name}

+ + {selected.enabled === false ? '已停用' : '运行中'} + +
+ {isDanmakuKind(selected.kind) && settings + ? ( + <> + + + + + + ) + :
该组件类型的设置编辑器尚未安装。
} + + + + )} + {!loading && !selected &&
当前账户还没有可配置的组件。
} + {source && } +
+
+
+ ) +} + +function formatDate(value?: string): string { + if (!value) return '永久' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(date) +} + +function invitationStatus(invitation: Invitation): { label: string; className: string } { + if (invitation.revokedAt) return { label: '已撤销', className: 'offline' } + if (invitation.expiresAt && new Date(invitation.expiresAt).getTime() <= Date.now()) return { label: '已过期', className: 'offline' } + if (invitation.consumedAt) return { label: '已使用', className: 'offline' } + return { label: '可使用', className: 'online' } +} + +export function InvitationsPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise }) { + const [invitations, setInvitations] = useState([]) + const [roomId, setRoomId] = useState('') + const [expiresInHours, setExpiresInHours] = useState(24) + const [newCode, setNewCode] = useState('') + const [busy, setBusy] = useState(false) + const [flash, setFlash] = useState() + usePwaUpdateBlocker('invitation-code', '复制并妥善保存本次生成的一次性邀请码', busy || Boolean(newCode)) + + const load = useCallback(async () => { + const payload = await api('/api/v1/invitations') + setInvitations(normalizeInvitations(payload)) + }, []) + + useEffect(() => { + void load().catch(reason => setFlash({ kind: 'error', text: errorMessage(reason, '邀请码读取失败') })) + }, [load]) + + const create = async (event: FormEvent) => { + event.preventDefault() + setBusy(true) + setFlash(undefined) + setNewCode('') + try { + const payload = await api('/api/v1/invitations', json('POST', { roomId: roomId.trim(), expiresInHours })) + const root = payload && typeof payload === 'object' ? payload as Record : {} + const invitation = root.invitation && typeof root.invitation === 'object' + ? root.invitation as Record + : root + const code = String(invitation.code ?? root.code ?? '') + setNewCode(code) + setFlash({ kind: 'success', text: '邀请码已创建。明文只显示这一次。' }) + await load() + } catch (reason) { + setFlash({ kind: 'error', text: errorMessage(reason, '邀请码创建失败') }) + } finally { + setBusy(false) + } + } + + const revoke = async (invitation: Invitation) => { + if (!window.confirm('确定撤销这个邀请码吗?尚未完成的注册会立即失效。')) return + setFlash(undefined) + try { + await api(`/api/v1/invitations/${encodeURIComponent(invitation.id)}`, json('DELETE')) + setFlash({ kind: 'success', text: '邀请码已撤销。' }) + await load() + } catch (reason) { + setFlash({ kind: 'error', text: errorMessage(reason, '邀请码撤销失败') }) + } + } + + const registerAddress = useMemo(() => { + if (!newCode) return '' + const url = new URL('/control/register', location.origin) + url.hash = new URLSearchParams({ invite: newCode }).toString() + return url.toString() + }, [newCode]) + + return ( + +
+
+

SYSTEM ADMIN

邀请码管理

+ 仅系统管理员 +
+ + +
+ + +
+
+ {newCode && ( +
+ 仅显示一次 + {newCode} + event.currentTarget.select()} /> +
+ + +
+
+ )} +
+ +
+ + + + {invitations.map(invitation => { + const status = invitationStatus(invitation) + return ( + + + + + + + + + ) + })} + {invitations.length === 0 && } + +
直播间邀请码前缀创建时间有效期状态
{invitation.roomId}{invitation.codePrefix || '—'}{formatDate(invitation.createdAt)}{formatDate(invitation.expiresAt)}{status.label}{status.className === 'online' && }
尚未创建邀请码。
+
+
+
+
+ ) +} + +export function ForbiddenPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise }) { + return ( + +

邀请码管理仅对系统管理员开放。

返回我的组件
+
+ ) +} + +export function isUnauthorized(error: unknown): boolean { + return error instanceof ApiError && error.status === 401 +} diff --git a/apps/overlay/src/main.tsx b/apps/overlay/src/main.tsx index 90ee944..d582dae 100644 --- a/apps/overlay/src/main.tsx +++ b/apps/overlay/src/main.tsx @@ -1,68 +1,149 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' +import { ApiError, api, errorMessage, json, normalizeSession } from './api' +import { EnrollmentPage, LoginPage } from './auth' +import { ComponentsPage, ForbiddenPage, InvitationsPage } from './control' +import { Overlay, tokenFromFragment } from './overlay' +import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa' +import type { Session } from './types' import './style.css' import './control.css' -type Settings = { title:string; fontScale:number; showDanmaku:boolean; showEnter:boolean; showGift:boolean; showSuperchat:boolean; showGuard:boolean; showLike:boolean; showShare:boolean; maxVisible:number; collapseAfterSeconds:number; unfoldDurationMs:number; motionIntensity:number; particleCount:number; particleSpeed:number; lowPerformanceMode:boolean; highValueThreshold:number; featuredValueThreshold:number } -type Envelope = { id:string; type:string; payload:any } -type Item = Envelope & { key:string; received:number; decorVariant:number } -type DanmakuSegment = { type:'text'; text:string } | { type:'emoticon'; text:string; unique?:string; url:string; width?:number; height?:number; isDynamic?:boolean; standalone?:boolean } -const defaults: Settings = { title:'洛星瓷专用弹幕猪!', fontScale:140, showDanmaku:true, showEnter:true, showGift:true, showSuperchat:true, showGuard:true, showLike:false, showShare:false, maxVisible:5, collapseAfterSeconds:12, unfoldDurationMs:1000, motionIntensity:70, particleCount:8, particleSpeed:100, lowPerformanceMode:false, highValueThreshold:10000, featuredValueThreshold:100000 } -const cardParticles=['star','floret','star','star','floret','star','floret','star','star','floret','star','floret'] as const -const decorVariantCount=6 +function Redirect({ to }: { to: string }) { + useEffect(() => { + location.replace(to) + }, [to]) + return
正在前往云台…
+} -function stableHash(value:string) { let hash=2166136261; for(let index=0;index>>0 } -function chooseDecorVariant(seed:string,previous?:number) { const hash=stableHash(seed); const base=hash%decorVariantCount; if(previous===undefined||base!==previous)return base; return (base+1+((hash>>>8)%(decorVariantCount-1)))%decorVariantCount } -function CardDecor({count,variant}:{count:number;variant:number}) { const visible=Math.min(cardParticles.length,Math.max(0,Math.round(count||0))); const normalized=((variant%decorVariantCount)+decorVariantCount)%decorVariantCount; return } +function NotFoundPage() { + return ( +
+
+ +

404 · LOST IN THE CLOUDS

+

这里没有组件

+

地址可能已经失效,或者组件已被所属用户删除。

+ 返回控制台 +
+
+ ) +} -function wsUrl() { const p = new URLSearchParams(location.search); const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; return `${protocol}//${location.host}/ws?token=${encodeURIComponent(p.get('token') || '')}` } -function enabled(type:string, s:Settings) { return (type==='live.danmaku'&&s.showDanmaku)||(type==='live.enter'&&s.showEnter)||(type.startsWith('live.gift')&&s.showGift)||(type==='live.superchat'&&s.showSuperchat)||(type==='live.guard.buy'&&s.showGuard)||(type==='live.like'&&s.showLike)||(type==='live.share'&&s.showShare) } -function useEvents(disabled=false) { - const [settings,setSettings]=useState(defaults); const [items,setItems]=useState([]); const [connected,setConnected]=useState(false); const settingsRef=useRef(settings) - useEffect(()=>{settingsRef.current=settings},[settings]) - useEffect(()=>{ if(disabled)return; let dead=false; let socket:WebSocket|undefined; let timer=0 - const open=()=>{ socket=new WebSocket(wsUrl()); socket.onopen=()=>setConnected(true); socket.onclose=()=>{setConnected(false); if(!dead) timer=window.setTimeout(open,1500)}; socket.onmessage=e=>{ try { const x:Envelope=JSON.parse(e.data); if(x.type==='overlay.settings.snapshot'||x.type==='overlay.settings.updated'){setSettings(x.payload.settings);return} setItems(old=>{ const current=settingsRef.current; if(!enabled(x.type,current))return old; const combo=x.type==='live.gift.combo'&&x.payload.comboId; const key=combo?`combo:${combo}`:x.id; const existing=old.find(v=>v.key===key); const decorVariant=existing?.decorVariant??chooseDecorVariant(`${x.type}:${key}`,old[0]?.decorVariant); const next=[{...x,key,received:Date.now(),decorVariant},...old.filter(v=>v.key!==key)].slice(0,current.maxVisible); return next }) }catch{} } } - open(); return()=>{dead=true;window.clearTimeout(timer);socket?.close()} - },[disabled]) - useEffect(()=>{setItems(items=>items.slice(0,settings.maxVisible))},[settings.maxVisible]) - return {settings,items,connected,setItems} +function App() { + const [session, setSession] = useState() + const [loadError, setLoadError] = useState('') + const [online, setOnline] = useState(navigator.onLine) + const path = location.pathname.replace(/\/+$/, '') || '/' + + const refreshSession = useCallback(async () => { + setLoadError('') + try { + const payload = await api('/api/v1/auth/me') + setSession(normalizeSession(payload)) + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + setSession({ user: null, setupRequired: false }) + return + } + setLoadError(errorMessage(error, '无法连接认证服务')) + } + }, []) + + useEffect(() => { + void refreshSession() + }, [refreshSession]) + + useEffect(() => { + const wentOnline = () => { + setOnline(true) + if (loadError) void refreshSession() + } + const wentOffline = () => setOnline(false) + window.addEventListener('online', wentOnline) + window.addEventListener('offline', wentOffline) + return () => { + window.removeEventListener('online', wentOnline) + window.removeEventListener('offline', wentOffline) + } + }, [loadError, refreshSession]) + + useEffect(() => { + const expired = () => { + setSession({ user: null, setupRequired: false }) + if (location.pathname.startsWith('/control')) location.assign('/control/login') + } + window.addEventListener('lxc:session-expired', expired) + return () => window.removeEventListener('lxc:session-expired', expired) + }, []) + + if (loadError) { + const offline = !online + return ( +
+
+ +

{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}

+

{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}

+ {offline &&

应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。

} +
{loadError}
+ +
+
+ ) + } + if (!session) return
正在验证安全会话…
+ + const logout = async () => { + try { + await api('/api/v1/auth/logout', json('POST')) + } finally { + setSession({ user: null, setupRequired: false }) + location.assign(authRoute('login')) + } + } + + if (path === '/') return + if (path === '/login' || path === '/control/login') { + if (session.user) return + return + } + if (path === '/setup' || path === '/control/setup') { + if (session.user) return + if (!session.setupRequired) return + return + } + if (path === '/register' || path === '/control/register') { + if (session.user) return + return + } + if (path === '/control') { + if (location.pathname === '/control') return + if (!session.user) return + return + } + if (path === '/control/invitations') { + if (!session.user) return + if (session.user.role !== 'system_admin') return + return + } + return } -function giftTier(item:Item,s:Settings){const price=item.payload?.gift?.totalPrice||0;return price>=s.featuredValueThreshold?'featured':price>=s.highValueThreshold?'high':'normal'} -const eventRenderers:Recordstring>={ - 'live.enter':()=> '踏入了云台','live.superchat':p=>p.message, - 'live.guard.buy':p=>`开通 ${p.guardName||'舰长'}`,'live.like':()=> '点亮了一颗星','live.share':()=> '分享了直播间' + +const obsMatch = location.pathname.match(/^\/obs\/([^/]+)\/?$/) +const root = createRoot(document.getElementById('root')!) +if (obsMatch) { + // A short-lived migration only: remove the root-scoped worker from early + // development builds so it cannot keep controlling an OBS browser source. + cleanupLegacyPwa() + let publicId = '' + try { + publicId = decodeURIComponent(obsMatch[1]) + } catch { + publicId = '' + } + root.render() +} else { + initializePwa() + root.render() } -function DanmakuEmoticon({segment}:{segment:Extract}) { const [failed,setFailed]=useState(false); if(failed)return <>{segment.text}; return {segment.text}setFailed(true)}/> } -function DanmakuBody({payload}:{payload:any}) { const segments=Array.isArray(payload.segments)?payload.segments as DanmakuSegment[]:undefined; if(!segments?.length)return <>{payload.text||''}; return <>{segments.map((segment,index)=>segment.type==='emoticon'&&segment.url?:{segment.text})} } -function Card({item,settings,expanded}:{item:Item;settings:Settings;expanded:boolean}) { const p=item.payload||{}; const v=p.viewer||{}; const gift=p.gift; const isDanmaku=item.type==='live.danmaku'; const tier=gift?giftTier(item,settings):''; const body=gift?`献上 ${gift.name} ×${p.quantity||1}`:isDanmaku?:eventRenderers[item.type]?.(p)||'送来了一份互动'; - return
- - {gift&&
{gift.animationUrl||gift.imageUrl?{const image=e.currentTarget;if(gift.imageUrl&&!image.src.endsWith(gift.imageUrl))image.src=gift.imageUrl;else image.style.display='none'}}/>:✦}
} -
{v.name||'直播间观众'}{body}{gift?.priceCny>0&&¥ {Number(gift.priceCny).toFixed(2)}}
{tier==='featured'&&
✦ ✧ ✦
} -
} -function Overlay({preview=false,previewSettings}:{preview?:boolean;previewSettings?:Settings}) { const root=useRef(null); const events=useEvents(preview); const {items,connected,setItems}=events; const settings=previewSettings||events.settings; const [shape,setShape]=useState('standard'); const [expandedKey,setExpandedKey]=useState(); const fontFactor=settings.fontScale/100 - useEffect(()=>{if(!root.current)return;const ob=new ResizeObserver(([entry])=>{const {width,height}=entry.contentRect;setShape(width<380?'narrow':height<420?'short':'standard')});ob.observe(root.current);return()=>ob.disconnect()},[]) - useEffect(()=>{if(preview&&!items.length)setItems([{id:'text-preview',key:'text-preview',received:Date.now(),decorVariant:0,type:'live.danmaku',payload:{viewer:{name:'青玉观众'},text:'今天也要闪闪发光!'}},{id:'gift-preview',key:'gift-preview',received:Date.now()-1000,decorVariant:3,type:'live.gift',payload:{viewer:{name:'星光旅人'},quantity:1,gift:{name:'甜蜜告白',totalPrice:12000,priceCny:12,imageUrl:'',animationUrl:''}}}])},[preview,items.length,setItems]) - useEffect(()=>{const newest=items[0];if(!newest){setExpandedKey(undefined);return}setExpandedKey(newest.key);const densityFactor=shape==='short'?.6:1;const timer=window.setTimeout(()=>setExpandedKey(key=>key===newest.key?undefined:key),settings.collapseAfterSeconds*1000*densityFactor);return()=>window.clearTimeout(timer)},[items[0]?.key,items[0]?.received,settings.collapseAfterSeconds,shape]) - return
{settings.title}
{items.map(item=>)}
} -function api(url:string, init?:RequestInit){return fetch(url,{credentials:'same-origin',headers:{'content-type':'application/json',...(init?.headers||{})},...init})} -async function copyToClipboard(text:string){ - if(window.isSecureContext&&navigator.clipboard?.writeText){try{await navigator.clipboard.writeText(text);return true}catch{}} - const input=document.createElement('textarea');input.value=text;input.readOnly=true;input.style.position='fixed';input.style.left='-9999px';input.style.opacity='0';document.body.appendChild(input);input.focus();input.select() - let copied=false;try{copied=document.execCommand('copy')}finally{input.remove()} - return copied -} -const previewPresets=[{label:'窄侧栏',width:360,height:600},{label:'竖屏',width:440,height:760},{label:'高清竖栏',width:600,height:1080},{label:'横向条',width:720,height:320}] -function Control(){ - const [password,setPassword]=useState(''); const [settings,setSettings]=useState(); const [error,setError]=useState(''); const [message,setMessage]=useState(''); const [obsAddress,setObsAddress]=useState(''); const [previewSize,setPreviewSize]=useState(previewPresets[1]) - const load=useCallback(async()=>{const r=await api('/api/admin/overlay-settings');if(!r.ok)throw new Error(r.status===401?'请输入管理员密码':'无法读取设置');setSettings(await r.json())},[]) - useEffect(()=>{load().catch(()=>{})},[load]) - const login=async(e:React.FormEvent)=>{e.preventDefault();const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({password})});if(!r.ok){setError('密码不正确');return}setError('');await load()} - const save=async()=>{if(!settings)return;const r=await api('/api/admin/overlay-settings',{method:'PUT',body:JSON.stringify(settings)});if(!r.ok)setError('保存失败');else setSettings((await r.json()).settings)} - const copy=async()=>{setMessage('');const r=await api('/api/admin/obs-url');if(!r.ok){setError('无法获取 OBS 地址,请重新登录');return}const {path}=await r.json();const address=new URL(path,location.origin).toString();setObsAddress(address);if(await copyToClipboard(address)){setError('');setMessage('OBS 地址已复制到剪贴板')}else{setError('浏览器阻止了自动复制,请在下方地址框中手动复制')}} - if(!settings)return

弹幕猪控制台

setPassword(e.target.value)}/>{error&&

{error}

}
- const edit=(key:keyof Settings,value:any)=>setSettings({...settings,[key]:value}) - const labels={showDanmaku:'弹幕',showEnter:'进房',showGift:'礼物',showSuperchat:'醒目留言',showGuard:'舰长',showLike:'点赞',showShare:'分享',lowPerformanceMode:'低性能模式'} - return

青玉弹幕姬

改动会立即同步到所有 OBS 浏览器源。

{(Object.keys(labels) as (keyof typeof labels)[]).map(k=>)}
{obsAddress&&}{message&&

{message}

}{error&&

{error}

}

自适应预览

选择常用尺寸后仍可拖拽预览框右下角;OBS 中也可使用任意宽高。

{previewPresets.map(size=>)}
-} -const isControl=location.pathname.startsWith('/control');createRoot(document.getElementById('root')!).render(isControl?:); diff --git a/apps/overlay/src/overlay.tsx b/apps/overlay/src/overlay.tsx new file mode 100644 index 0000000..3381e21 --- /dev/null +++ b/apps/overlay/src/overlay.tsx @@ -0,0 +1,383 @@ +import { useEffect, useRef, useState } from 'react' +import type { Dispatch, SetStateAction } from 'react' +import { defaultOverlaySettings } from './types' +import type { OverlaySettings } from './types' + +type Envelope = { + id: string + type: string + payload: LivePayload & { + code?: string + settings?: Partial + } +} + +type LivePayload = { + viewer?: { uid?: string; name?: string } + text?: string + message?: string + guardName?: string + quantity?: number + comboId?: string + segments?: DanmakuSegment[] + gift?: { + name?: string + totalPrice?: number + priceCny?: number + imageUrl?: string + animationUrl?: string + } +} + +type Item = Envelope & { + key: string + received: number + decorVariant: number +} + +type DanmakuSegment = + | { type: 'text'; text: string } + | { + type: 'emoticon' + text: string + unique?: string + url: string + width?: number + height?: number + isDynamic?: boolean + standalone?: boolean + } + +type OverlayProps = { + preview?: boolean + previewSettings?: OverlaySettings + publicId?: string + accessToken?: string +} + +const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const +const decorVariantCount = 6 + +function stableHash(value: string) { + let hash = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 16777619) + } + return hash >>> 0 +} + +function chooseDecorVariant(seed: string, previous?: number) { + const hash = stableHash(seed) + const base = hash % decorVariantCount + if (previous === undefined || base !== previous) return base + return (base + 1 + ((hash >>> 8) % (decorVariantCount - 1))) % decorVariantCount +} + +function CardDecor({ count, variant }: { count: number; variant: number }) { + const visible = Math.min(cardParticles.length, Math.max(0, Math.round(count || 0))) + const normalized = ((variant % decorVariantCount) + decorVariantCount) % decorVariantCount + return ( + + ) +} + +function streamUrl(publicId: string) { + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${location.host}/api/v1/components/${encodeURIComponent(publicId)}/stream` +} + +function enabled(type: string, settings: OverlaySettings) { + return (type === 'live.danmaku' && settings.showDanmaku) + || (type === 'live.enter' && settings.showEnter) + || (type.startsWith('live.gift') && settings.showGift) + || (type === 'live.superchat' && settings.showSuperchat) + || (type === 'live.guard.buy' && settings.showGuard) + || (type === 'live.like' && settings.showLike) + || (type === 'live.share' && settings.showShare) +} + +function parseSettings(value: unknown): OverlaySettings { + if (!value || typeof value !== 'object') return defaultOverlaySettings + return { ...defaultOverlaySettings, ...value as Partial } +} + +function useEvents(disabled: boolean, publicId?: string, accessToken?: string): { + settings: OverlaySettings + items: Item[] + setItems: Dispatch> + connection: 'idle' | 'connecting' | 'connected' | 'denied' +} { + const [settings, setSettings] = useState(defaultOverlaySettings) + const [items, setItems] = useState([]) + const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle') + const settingsRef = useRef(settings) + + useEffect(() => { + settingsRef.current = settings + }, [settings]) + + useEffect(() => { + if (disabled || !publicId || !accessToken) { + setConnection('idle') + return + } + + let dead = false + let socket: WebSocket | undefined + let timer = 0 + let retries = 0 + + const open = () => { + setConnection('connecting') + socket = new WebSocket(streamUrl(publicId)) + socket.onopen = () => { + retries = 0 + socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken })) + } + socket.onclose = (event) => { + if (dead) return + if (event.code === 1008 || event.code === 4401 || event.code === 4403) { + setConnection('denied') + return + } + setConnection('connecting') + const delay = Math.min(12_000, 1200 * 2 ** Math.min(retries, 3)) + retries += 1 + timer = window.setTimeout(open, delay) + } + socket.onmessage = event => { + try { + const envelope = JSON.parse(event.data) as Envelope + if (envelope.type === 'authenticated' || envelope.type === 'stream.authenticated') { + setConnection('connected') + return + } + if (envelope.type === 'error' && envelope.payload?.code === 'UNAUTHORIZED') { + setConnection('denied') + socket?.close(1008, 'Unauthorized') + return + } + setConnection('connected') + if (envelope.type === 'overlay.settings.snapshot' || envelope.type === 'overlay.settings.updated') { + setSettings(parseSettings(envelope.payload?.settings)) + return + } + setItems(old => { + const current = settingsRef.current + if (!enabled(envelope.type, current)) return old + const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId + const key = combo ? `combo:${combo}` : envelope.id + const existing = old.find(item => item.key === key) + const decorVariant = existing?.decorVariant + ?? chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant) + return [{ ...envelope, key, received: Date.now(), decorVariant }, ...old.filter(item => item.key !== key)] + .slice(0, current.maxVisible) + }) + } catch { + // A malformed upstream event must not break a long-running OBS source. + } + } + } + + open() + return () => { + dead = true + window.clearTimeout(timer) + socket?.close() + } + }, [accessToken, disabled, publicId]) + + useEffect(() => { + setItems(current => current.slice(0, settings.maxVisible)) + }, [settings.maxVisible]) + + return { settings, items, setItems, connection } +} + +function giftTier(item: Item, settings: OverlaySettings) { + const price = item.payload?.gift?.totalPrice || 0 + return price >= settings.featuredValueThreshold + ? 'featured' + : price >= settings.highValueThreshold + ? 'high' + : 'normal' +} + +const eventRenderers: Record string> = { + 'live.enter': () => '踏入了云台', + 'live.superchat': payload => payload.message || '', + 'live.guard.buy': payload => `开通 ${payload.guardName || '舰长'}`, + 'live.like': () => '点亮了一颗星', + 'live.share': () => '分享了直播间', +} + +function DanmakuEmoticon({ segment }: { segment: Extract }) { + const [failed, setFailed] = useState(false) + if (failed) return <>{segment.text} + return ( + {segment.text} setFailed(true)} + /> + ) +} + +function DanmakuBody({ payload }: { payload: LivePayload }) { + const segments = Array.isArray(payload.segments) ? payload.segments as DanmakuSegment[] : undefined + if (!segments?.length) return <>{payload.text || ''} + return <>{segments.map((segment, index) => segment.type === 'emoticon' && segment.url + ? + : {segment.text})} +} + +function Card({ item, settings, expanded }: { item: Item; settings: OverlaySettings; expanded: boolean }) { + const payload = item.payload || {} + const viewer = payload.viewer || {} + const gift = payload.gift + const isDanmaku = item.type === 'live.danmaku' + const tier = gift ? giftTier(item, settings) : '' + const body = gift + ? `献上 ${gift.name || '礼物'} ×${payload.quantity || 1}` + : isDanmaku + ? + : eventRenderers[item.type]?.(payload) || '送来了一份互动' + + return ( +
+ + {gift && ( +
+ {gift.animationUrl || gift.imageUrl + ? ( + {gift.name { + const image = event.currentTarget + if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl + else image.style.display = 'none' + }} + /> + ) + : ✦} +
+ )} +
+ {viewer.name || '直播间观众'} + {body} + {typeof gift?.priceCny === 'number' && gift.priceCny > 0 && ¥ {gift.priceCny.toFixed(2)}} +
+ {tier === 'featured' &&
✦ ✧ ✦
} +
+ ) +} + +export function Overlay({ preview = false, previewSettings, publicId, accessToken }: OverlayProps) { + const root = useRef(null) + const events = useEvents(preview, publicId, accessToken) + const { items, setItems } = events + const settings = previewSettings || events.settings + const [shape, setShape] = useState('standard') + const [expandedKey, setExpandedKey] = useState() + const fontFactor = settings.fontScale / 100 + + useEffect(() => { + if (!root.current) return + const observer = new ResizeObserver(([entry]) => { + const { width, height } = entry.contentRect + setShape(width < 380 ? 'narrow' : height < 420 ? 'short' : 'standard') + }) + observer.observe(root.current) + return () => observer.disconnect() + }, []) + + useEffect(() => { + if (preview && !items.length) { + setItems([ + { + id: 'text-preview', + key: 'text-preview', + received: Date.now(), + decorVariant: 0, + type: 'live.danmaku', + payload: { viewer: { name: '青玉观众' }, text: '今天也要闪闪发光!' }, + }, + { + id: 'gift-preview', + key: 'gift-preview', + received: Date.now() - 1000, + decorVariant: 3, + type: 'live.gift', + payload: { + viewer: { name: '星光旅人' }, + quantity: 1, + gift: { name: '甜蜜告白', totalPrice: 12_000, priceCny: 12, imageUrl: '', animationUrl: '' }, + }, + }, + ]) + } + }, [items.length, preview, setItems]) + + useEffect(() => { + const newest = items[0] + if (!newest) { + setExpandedKey(undefined) + return + } + setExpandedKey(newest.key) + const densityFactor = shape === 'short' ? 0.6 : 1 + const timer = window.setTimeout( + () => setExpandedKey(key => key === newest.key ? undefined : key), + settings.collapseAfterSeconds * 1000 * densityFactor, + ) + return () => window.clearTimeout(timer) + }, [items, settings.collapseAfterSeconds, shape]) + + const missingAccess = !preview && (!publicId || !accessToken) + return ( +
+
+ {missingAccess &&
OBS 地址不完整,请从控制台重新复制。
} + {!missingAccess && events.connection === 'denied' &&
OBS 访问令牌已失效。
} +
+ {items.map(item => ( + + ))} +
+
+
+ ) +} + +export function tokenFromFragment(): string { + const hash = location.hash.startsWith('#') ? location.hash.slice(1) : location.hash + return new URLSearchParams(hash).get('token') ?? '' +} diff --git a/apps/overlay/src/pwa.tsx b/apps/overlay/src/pwa.tsx new file mode 100644 index 0000000..85cc537 --- /dev/null +++ b/apps/overlay/src/pwa.tsx @@ -0,0 +1,214 @@ +import { useEffect, useSyncExternalStore } from 'react' + +interface InstallChoice { + outcome: 'accepted' | 'dismissed' + platform: string +} + +interface BeforeInstallPromptEvent extends Event { + readonly platforms: string[] + readonly userChoice: Promise + prompt(): Promise +} + +interface PwaSnapshot { + online: boolean + standalone: boolean + installPrompt?: BeforeInstallPromptEvent + registration?: ServiceWorkerRegistration + waitingWorker?: ServiceWorker +} + +const listeners = new Set<() => void>() +const updateBlockers = new Map() +let snapshot: PwaSnapshot = { + online: navigator.onLine, + standalone: isStandalone(), +} +let initialized = false +let reloadForUpdate = false + +function isStandalone(): boolean { + const iosNavigator = navigator as Navigator & { standalone?: boolean } + return window.matchMedia('(display-mode: standalone)').matches || iosNavigator.standalone === true +} + +function emit(patch: Partial) { + snapshot = { ...snapshot, ...patch } + listeners.forEach(listener => listener()) +} + +function subscribe(listener: () => void) { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function getSnapshot() { + return snapshot +} + +function addManifest() { + if (document.head.querySelector('link[rel="manifest"]')) return + const manifest = document.createElement('link') + manifest.rel = 'manifest' + manifest.href = '/control/manifest.webmanifest' + document.head.append(manifest) +} + +function observeRegistration(registration: ServiceWorkerRegistration) { + emit({ + registration, + waitingWorker: registration.waiting && navigator.serviceWorker.controller + ? registration.waiting + : snapshot.waitingWorker, + }) + + registration.addEventListener('updatefound', () => { + const worker = registration.installing + if (!worker) return + worker.addEventListener('statechange', () => { + if (worker.state === 'installed' && navigator.serviceWorker.controller) { + emit({ waitingWorker: worker }) + } + }) + }) +} + +async function removeLegacyRootWorker() { + const registrations = await navigator.serviceWorker.getRegistrations() + await Promise.all(registrations.map(async registration => { + const scopePath = new URL(registration.scope).pathname + const workers = [registration.installing, registration.waiting, registration.active] + const isLegacy = scopePath === '/' + && workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js') + if (isLegacy) await registration.unregister() + })) + + const cacheNames = await caches.keys() + await Promise.all(cacheNames + .filter(name => name.startsWith('lxc-control-shell-')) + .map(name => caches.delete(name))) +} + +export function cleanupLegacyPwa() { + if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return + void removeLegacyRootWorker().catch(error => { + console.warn('旧版 PWA 清理失败', error) + }) +} + +async function registerWorker() { + try { + await removeLegacyRootWorker() + const registration = await navigator.serviceWorker.register( + `/control/sw.js?v=${encodeURIComponent(__PWA_BUILD_ID__)}`, + { scope: '/control/', updateViaCache: 'none' }, + ) + observeRegistration(registration) + } catch (error) { + // A failed PWA registration must never stop the online control console. + console.warn('控制台 PWA 注册失败', error) + } +} + +export function initializePwa() { + if (initialized || location.pathname === '/obs' || location.pathname.startsWith('/obs/')) return + initialized = true + addManifest() + + const displayMode = window.matchMedia('(display-mode: standalone)') + const updateConnection = () => emit({ online: navigator.onLine }) + const updateDisplayMode = () => emit({ standalone: isStandalone() }) + window.addEventListener('online', updateConnection) + window.addEventListener('offline', updateConnection) + const modernListener = (displayMode as unknown as { + addEventListener?: (type: 'change', listener: () => void) => void + }).addEventListener + if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode) + else (displayMode as unknown as { addListener?: (listener: () => void) => void }) + .addListener?.call(displayMode, updateDisplayMode) + + window.addEventListener('beforeinstallprompt', event => { + event.preventDefault() + emit({ installPrompt: event as BeforeInstallPromptEvent }) + }) + window.addEventListener('appinstalled', () => emit({ installPrompt: undefined, standalone: true })) + + if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return + + navigator.serviceWorker.addEventListener('controllerchange', () => { + if (!reloadForUpdate) return + reloadForUpdate = false + location.reload() + }) + + const start = () => void registerWorker() + if (document.readyState === 'complete') start() + else window.addEventListener('load', start, { once: true }) + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && navigator.onLine) { + void snapshot.registration?.update() + } + }) + window.setInterval(() => { + if (navigator.onLine) void snapshot.registration?.update() + }, 60 * 60 * 1000) +} + +export function authRoute(name: 'login' | 'register' | 'setup'): string { + const inControlScope = location.pathname === '/control' || location.pathname.startsWith('/control/') + return inControlScope ? `/control/${name}` : `/${name}` +} + +export function usePwaUpdateBlocker(key: string, reason: string, active: boolean) { + useEffect(() => { + if (active) updateBlockers.set(key, reason) + else updateBlockers.delete(key) + return () => { updateBlockers.delete(key) } + }, [active, key, reason]) +} + +async function requestInstall() { + const prompt = snapshot.installPrompt + if (!prompt) return + await prompt.prompt() + await prompt.userChoice + emit({ installPrompt: undefined, standalone: isStandalone() }) +} + +function applyUpdate() { + const worker = snapshot.waitingWorker + if (!worker) return + const reasons = [...new Set(updateBlockers.values())] + if (reasons.length > 0) { + window.alert(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`) + return + } + const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?') + if (!confirmed) return + reloadForUpdate = true + worker.postMessage({ type: 'SKIP_WAITING' }) +} + +export function PwaControls() { + const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const canInstall = Boolean(state.installPrompt) && !state.standalone + if (state.online && !state.waitingWorker && !canInstall) return null + + return ( +
+ {!state.online &&