diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3111c9d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{ts,tsx,js,json,css,html,md,yaml,yml}] +indent_style = space +indent_size = 2 + +[*.{rs,toml}] +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..79ad020 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,8 @@ +.git/ +**/dist/ +**/node_modules/ +**/target/ +vendor/ +config.toml +*.png +*.svg diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e667257 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "arrowParens": "avoid", + "endOfLine": "lf", + "printWidth": 100, + "proseWrap": "always", + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all", + "useTabs": false +} diff --git a/README.md b/README.md index cfda4da..11a8fa6 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,36 @@ # 洛星瓷直播组件服务 -这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 +这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` +弹幕姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示、点歌姬等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 -直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在 `vendor/blivedm` 固定了保留原始 JSON 的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃。 +直播连接使用 [`blivedm_rs`](https://github.com/isomoes/blivedm_rs) 的 `blivedm` crate。项目在 +`vendor/blivedm` 固定了保留原始 JSON 的小补丁,避免 UID、礼物价格和上游事件 ID 被简化消息结构丢弃。 + +## 文档索引 + +- [总体架构与事件流](docs/architecture.md) +- [Rust 后端模块](apps/server-rust/README.md) +- [React 控制台、OBS 与 PWA](apps/overlay/README.md) +- [组件开发指南](docs/components/README.md) +- [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md) +- [WebSocket 实时协议](docs/protocol.md) +- [租户、Secret 与部署安全](docs/security.md) +- [完整配置注释](config.toml.example) ## 后端结构 `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。 +- `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。 ## 部署 @@ -25,13 +44,43 @@ openssl rand -hex 32 # 填入兼容字段 admin.session_secret docker compose up --build -d ``` -Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config` 读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719` 直接暴露到公网。 +Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--config` +读取它。容器使用 host 网络,默认只在 `127.0.0.1:9719` 监听,供同机 Nginx 访问;不要把 `9719` +直接暴露到公网。 -容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将 `config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置 `APP_UID` 与 `APP_GID`。 +容器默认以 `1000:1000` 非 root 身份、只读根文件系统运行,并丢弃全部 Linux capabilities。请将 +`config.toml` 设为 `chmod 600`,并确保容器用户可读;如宿主机用户不是 `1000:1000`,启动前设置 +`APP_UID` 与 `APP_GID`。 -`[database].url` 指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。 +`[database].url` +指向 PostgreSQL。应用启动时自动执行版本化迁移,数据库保存账户、一次性邀请码、TOTP 注册状态、可撤销会话、恢复码摘要、用户直播源、组件设置、OBS 令牌摘要和审计记录。租户表同时使用 owner 复合外键与 PostgreSQL +RLS 约束,HTTP API 也始终从当前会话取得 owner,客户端不能自行指定其他用户。 -`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP Secret 与每个用户的 CookieCloud Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。 +`security.data_encryption_key` 必须是独立生成并妥善备份的 32 字节 Base64 密钥。TOTP +Secret 与每个用户的 CookieCloud +Key/密码会在写入 PostgreSQL 前用它加密;丢失或擅自更换该密钥会导致已有账户和 CookieCloud 凭据无法解密。 + +## 开发与代码质量 + +Rust 使用仓库内 `rustfmt.toml`,前端和文档使用固定版本 Prettier。推荐提交前执行: + +```sh +cargo fmt --manifest-path apps/server-rust/Cargo.toml +cargo clippy --manifest-path apps/server-rust/Cargo.toml --all-targets --no-deps -- -D warnings +cargo test --manifest-path apps/server-rust/Cargo.toml --all-targets + +npm --prefix apps/overlay run format +npm --prefix apps/overlay run format:check +npm --prefix apps/overlay run docs:check +npm --prefix apps/overlay run build + +docker compose config --quiet +git diff --check +``` + +`.editorconfig` 统一换行、缩进和文件末尾规则;`.prettierignore` 与 Docker/Git +ignore 会排除依赖、构建产物、第三方 vendor、PNG/SVG 和包含真实 Secret 的 `config.toml`。不要对 +`vendor/blivedm` 做无关的批量风格改写,以便继续审查上游补丁。 ## 首次初始化与登录 @@ -41,7 +90,8 @@ Compose 将 `config.toml` 只读挂载到 `/app/config.toml`,应用通过 `--c https://danmaku.luoxingci.com/control/setup ``` -填写系统管理员用户名和 `config.toml` 中的 `admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。 +填写系统管理员用户名和 `config.toml` 中的 +`admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。 `admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof: @@ -50,9 +100,14 @@ https://danmaku.luoxingci.com/control/setup - 所有账户都是 passwordless 账户,以“用户名 + TOTP”登录;丢失验证器时可使用一次性恢复码。 - 会话使用随机令牌,数据库只保存摘要,可在服务端到期或撤销。 -系统管理员可在 `/control/invitations` 创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili `room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。 +系统管理员可在 `/control/invitations` +创建和撤销邀请码。每个邀请码只能使用一次,并在创建时固定绑定一个尚未占用的 Bilibili +`room_id`;注册者不能修改该房间,账户创建后房间绑定也不可更改。受邀用户打开注册链接,选择用户名、扫描自己的 TOTP 二维码并确认动态码即可完成注册。普通用户不能创建邀请码。 -每个用户在 `/control/` 配置自己的 CookieCloud 同步 UUID/Key 和密码,地址必须位于部署管理员配置的 `security.cookiecloud_allowed_hosts` 白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili `SESSDATA`。 +每个用户在 `/control/` 配置自己的 CookieCloud 同步 UUID/Key 和密码,地址必须位于部署管理员配置的 +`security.cookiecloud_allowed_hosts` +白名单。服务禁止 HTTP 重定向并安全编码 Key 路径,避免用户凭据导致服务端任意请求。服务会先验证凭据,再将敏感字段按用户独立加密保存;Cookie、Key 和明文密码不会返回浏览器,也不会与其他账户共享。CookieCloud 中需要存在 Bilibili +`SESSDATA`。 ### 旧单用户配置迁移 @@ -67,7 +122,8 @@ https://danmaku.luoxingci.com/control/setup ## 必须使用 HTTPS -公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持 `security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写: +公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持 +`security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写: ```nginx map $http_upgrade $connection_upgrade { @@ -103,36 +159,54 @@ server { } ``` -只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置 `secure_cookies = false`。不要用该选项把登录页面直接发布到公网。 +只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置 +`secure_cookies = false`。不要用该选项把登录页面直接发布到公网。 ## 控制台 PWA -通过 HTTPS 打开 `/control/` 后,受支持的浏览器会在控制台顶部显示“安装到设备”。`/control` 会由服务端永久重定向到这个规范地址。安装后的应用使用独立窗口,并继续采用“用户名 + TOTP”登录;登录、注册与首次初始化在 PWA 内分别使用 `/control/login`、`/control/register` 和 `/control/setup`。原有 `/login`、`/register`、`/setup` 地址仍兼容普通浏览器书签。 +通过 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`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。 +PWA 只控制 `/control/`,不会控制、缓存或刷新 `/obs/*` +浏览器源。离线缓存仅包含 React 应用外壳、本地图标和构建后带哈希的静态资源;账户、TOTP、邀请码、CookieCloud、组件设置、OBS 令牌、直播事件和所有 +`/api/*` 响应始终在线直连且由服务端返回 +`Cache-Control: no-store`。离线时写操作会在浏览器端直接拒绝,不会排队或在恢复网络后重放。 部署新版本后,已打开的控制台会显示“更新可用”。更新不会自动接管或刷新页面,必须由用户点击确认;确认前请先保存设置以及只显示一次的邀请码、恢复码或刚轮换的 OBS 令牌。 ## 组件与 OBS 地址 -登录 `/control/` 后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 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` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。 +`publicId` 是组件 UUID。`#token=...` 位于 URL +fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向 +`/api/v1/components//stream` 完成认证。令牌只带 `events:subscribe` +权限,不能调用管理或写入接口;轮换后旧令牌立即失效。 -旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。 +旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer +token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。 -OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从 `360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。 +OBS 页面保持透明根背景,不使用固定 1920×1080 画布,并根据浏览器源的实际宽高自动适配。常用尺寸可从 +`360×600`、`440×760` 或 `600×1080` 开始,也可以在 OBS 中自由拖拽缩放。 ## 弹幕姬展示 每张消息卡片会从六套花纹组合中稳定选择一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景。连续的新卡避免使用相同款式,礼物连击更新保持原样式;透明消息墙本身不铺设全局装饰背景。粒子数量与速度、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效强度、低性能模式和礼物高亮阈值均可按组件在控制台调整,并实时推送给对应 OBS 源。 -花边 SVG 已本地打包,来源及公版/CC0 许可记录在 `apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。 +花边 SVG 已本地打包,来源及公版/CC0 许可记录在 +`apps/overlay/public/assets/NOTICE.md`,OBS 运行时不会访问素材站点。 -后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过 `[gifts]` 调整。 +后端会为每个活动直播源缓存 Bilibili 礼物图片、GIF、币种和价格。目录刷新失败时保留上一次成功缓存;缺失条目会降级为直播事件自带的名称与价格。刷新间隔和请求超时可通过 +`[gifts]` 调整。 -普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的 `SESSDATA` 刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过 `[emoticons]` 调整。 +普通混排表情和整条大表情会作为安全的文字/图片分段推送。消息自带图片地址优先;服务还会使用该用户 CookieCloud 中的 +`SESSDATA` +刷新对应直播间的表情目录,在消息只提供唯一标识时补齐图片。加载失败时前端退回原始表情文字,刷新参数可通过 +`[emoticons]` 调整。 diff --git a/apps/overlay/README.md b/apps/overlay/README.md new file mode 100644 index 0000000..2de215e --- /dev/null +++ b/apps/overlay/README.md @@ -0,0 +1,61 @@ +# React 控制台与 OBS 前端 + +Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域托管。生产容器不包含 Node。 + +## 路由 + +| 路由 | 权限 | 作用 | +| ---------------------- | --------------- | ------------------------------------ | +| `/control/` | 登录用户 | 直播源、组件、测试、设置和 OBS token | +| `/control/invitations` | system admin | 创建/撤销绑定房间的邀请码 | +| `/control/login` | 匿名 | 用户名 + TOTP/恢复码登录 | +| `/control/register` | 匿名受邀用户 | 邀请码注册与 TOTP enrollment | +| `/control/setup` | 首次部署 | 创建唯一 system admin | +| `/obs/:publicId` | component token | 透明 OBS 浏览器源 | + +`main.tsx` 在初始化控制台前先识别 OBS 路由,因此 OBS 不会注册 PWA 或请求账户 session。 + +## 文件职责 + +| 文件 | 职责 | +| ------------------- | ---------------------------------------------------- | +| `src/api.ts` | same-origin fetch、错误模型和兼容性 normalizer | +| `src/auth.tsx` | passwordless login、TOTP QR 与恢复码 | +| `src/control.tsx` | tenant component studio 和 system-admin 邀请码页面 | +| `src/overlay.tsx` | WebSocket、消息队列、礼物/表情和 OBS 自适应渲染 | +| `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker | +| `src/types.ts` | sanitized API view model 与 overlay settings | +| `pwa/control-sw.js` | `/control/` 静态壳层的缓存策略 | + +## Secret 与状态 + +- TOTP Secret、恢复码、邀请码和新 OBS 地址只保存在当前 React state。 +- API normalizer 不把未知对象直接传播到组件。 +- API mutation 离线时立即失败,不进入 Background Sync。 +- PWA 更新在 dirty form 或一次性 secret 可见时被阻止。 +- OBS token 从 URL fragment 读取,只在 WebSocket 第一帧发送。 +- OBS 收到坏 JSON、坏图片或未知事件时局部降级,不让浏览器源崩溃。 + +## PWA + +manifest、start URL 与 Service Worker scope 均为 `/control/`。worker 使用 network-first +HTML 和 cache-first build assets;`/api/*`、`/obs/*`、WebSocket 和用户数据永不缓存。每次 Vite +build 把同一个 build ID 注入浏览器 bundle 与 worker,发现更新后等待用户确认激活。 + +## 格式化与构建 + +```bash +npm run format +npm run format:check +npm run docs:check +npm run build +``` + +`npm run format` +使用仓库根目录的 Prettier 配置,同时格式化 TypeScript、TSX、CSS、HTML、JSON、Markdown、Compose +YAML 和项目文档。 + +组件协议与弹幕姬行为分别见: + +- [实时协议](../../docs/protocol.md) +- [弹幕姬组件](../../docs/components/danmaku-overlay.md) diff --git a/apps/overlay/package-lock.json b/apps/overlay/package-lock.json index 56c49b6..9e1a799 100644 --- a/apps/overlay/package-lock.json +++ b/apps/overlay/package-lock.json @@ -15,6 +15,7 @@ "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", + "prettier": "3.9.5", "typescript": "5.9.2", "vite": "7.3.6" } @@ -1573,6 +1574,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/react": { "version": "19.1.1", "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", diff --git a/apps/overlay/package.json b/apps/overlay/package.json index f742dcd..f94a6b8 100644 --- a/apps/overlay/package.json +++ b/apps/overlay/package.json @@ -2,7 +2,23 @@ "name": "lxc-stream-overlay", "private": true, "version": "0.1.0", - "scripts": { "build": "tsc -b && vite build", "dev": "vite" }, - "dependencies": { "react": "19.1.1", "react-dom": "19.1.1" }, - "devDependencies": { "@types/react": "19.1.10", "@types/react-dom": "19.1.7", "@vitejs/plugin-react": "5.0.2", "typescript": "5.9.2", "vite": "7.3.6" } + "scripts": { + "build": "tsc -b && vite build", + "dev": "vite", + "docs:check": "node scripts/check-doc-links.mjs ../..", + "format": "prettier --ignore-path ../../.prettierignore --write . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml", + "format:check": "prettier --ignore-path ../../.prettierignore --check . ../server-rust/README.md ../../README.md ../../docs ../../compose.yaml" + }, + "dependencies": { + "react": "19.1.1", + "react-dom": "19.1.1" + }, + "devDependencies": { + "@types/react": "19.1.10", + "@types/react-dom": "19.1.7", + "@vitejs/plugin-react": "5.0.2", + "prettier": "3.9.5", + "typescript": "5.9.2", + "vite": "7.3.6" + } } diff --git a/apps/overlay/public/assets/NOTICE.md b/apps/overlay/public/assets/NOTICE.md index df83647..0a984d8 100644 --- a/apps/overlay/public/assets/NOTICE.md +++ b/apps/overlay/public/assets/NOTICE.md @@ -7,8 +7,7 @@ - License: CC0 1.0 / public domain - Retrieved: 2026-07-15 -`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by -Paul Bürck”. +`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by Paul Bürck”. - Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg - Original artist: Paul Bürck, 1899 @@ -16,8 +15,7 @@ Paul Bürck”. - License: original public domain; SVG adaptation CC0 1.0 - Retrieved: 2026-07-15 -`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by -Paul Bürck”. +`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by Paul Bürck”. - Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg - Original artist: Paul Bürck, 1899 @@ -25,5 +23,5 @@ Paul Bürck”. - License: original public domain; SVG adaptation CC0 1.0 - Retrieved: 2026-07-15 -The overlay uses these SVGs as recolorable CSS masks inside message cards and -bundles them locally, so OBS never needs to load the source websites. +The overlay uses these SVGs as recolorable CSS masks inside message cards and bundles them locally, +so OBS never needs to load the source websites. diff --git a/apps/overlay/pwa/control-sw.js b/apps/overlay/pwa/control-sw.js index 99a5099..ad6bb5e 100644 --- a/apps/overlay/pwa/control-sw.js +++ b/apps/overlay/pwa/control-sw.js @@ -1,4 +1,11 @@ -/* __PWA_BUILD_ID__ is replaced by the Vite build before this file is emitted. */ +/** + * Network policy for the `/control/` application shell. + * + * `__PWA_BUILD_ID__` is replaced by Vite before emission. Navigations use a + * network-first strategy so fresh HTML can reference the newest hashed bundle; + * known static assets use cache-first. The worker never handles API, WebSocket + * or OBS routes because they are outside both this fetch policy and its scope. + */ const buildId = '__PWA_BUILD_ID__' const cacheName = `lxc-control-v2-shell-${buildId}` const shellPath = '/control/' @@ -24,8 +31,9 @@ async function precacheShell() { 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]) + const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)].map( + match => match[1], + ) await cache.addAll([...new Set([...stableAssets, ...generatedAssets])]) } @@ -34,13 +42,17 @@ self.addEventListener('install', event => { }) 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() - })()) + 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 => { @@ -52,9 +64,11 @@ function isControlShell(pathname) { } function isStaticAsset(pathname) { - return pathname.startsWith('/assets/') - || pathname.startsWith('/pwa/') - || pathname === '/control/manifest.webmanifest' + return ( + pathname.startsWith('/assets/') || + pathname.startsWith('/pwa/') || + pathname === '/control/manifest.webmanifest' + ) } async function navigationResponse(request) { @@ -62,16 +76,20 @@ async function navigationResponse(request) { 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')) { + 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() + return ( + (await cache.match(request, { ignoreSearch: true })) || + (await cache.match(shellPath)) || + Response.error() + ) } } diff --git a/apps/overlay/scripts/check-doc-links.mjs b/apps/overlay/scripts/check-doc-links.mjs new file mode 100644 index 0000000..b3e76c6 --- /dev/null +++ b/apps/overlay/scripts/check-doc-links.mjs @@ -0,0 +1,56 @@ +/** + * Verify repository-local Markdown links without making network requests. + * + * External URLs are intentionally skipped: CI should not fail because an + * upstream website is temporarily unavailable. Local documentation links are + * deterministic and catch renamed component guides or incorrect relative paths. + */ +import { existsSync, readdirSync, readFileSync } from 'node:fs' +import { dirname, extname, resolve } from 'node:path' + +const root = resolve(process.argv[2] ?? new URL('../../..', import.meta.url).pathname) +const ignoredDirectories = new Set([ + '.agents', + '.codex', + '.git', + 'dist', + 'node_modules', + 'target', + 'vendor', +]) +const markdownFiles = [] + +function collect(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue + const path = resolve(directory, entry.name) + if (entry.isDirectory()) collect(path) + else if (extname(entry.name).toLowerCase() === '.md') markdownFiles.push(path) + } +} + +collect(root) + +const failures = [] +const markdownLink = /\[[^\]]*\]\(([^)]+)\)/g +for (const file of markdownFiles) { + const source = readFileSync(file, 'utf8') + for (const match of source.matchAll(markdownLink)) { + const rawTarget = match[1].trim().replace(/^<|>$/g, '') + if (!rawTarget || rawTarget.startsWith('#') || /^[a-z][a-z+.-]*:/i.test(rawTarget)) continue + + // The project does not currently use titled local links. Splitting here + // still handles the conventional `(path "title")` form if one is added. + const target = decodeURI(rawTarget.split(/\s+["']/)[0].split('#')[0]) + if (!target) continue + const destination = resolve(dirname(file), target) + if (!existsSync(destination)) failures.push(`${file}: ${rawTarget}`) + } +} + +if (failures.length > 0) { + console.error(`Broken local documentation links:\n${failures.join('\n')}`) + process.exitCode = 1 +} else { + console.log(`Checked ${markdownFiles.length} Markdown files; local links are valid.`) +} diff --git a/apps/overlay/src/api.ts b/apps/overlay/src/api.ts index c568932..b396b9a 100644 --- a/apps/overlay/src/api.ts +++ b/apps/overlay/src/api.ts @@ -1,3 +1,12 @@ +/** + * Same-origin API client and defensive wire-format normalizers. + * + * The backend is authoritative and may evolve response wrappers independently + * from a deployed frontend. Normalizers accept those compatible wrappers while + * producing strict UI models. Mutations are refused while offline and are never + * queued for background replay, which avoids applying an old user's action + * after logout or tenant switching. + */ import type { AuthUser, ComponentSummary, @@ -13,7 +22,12 @@ export class ApiError extends Error { readonly code?: string readonly fieldErrors?: Record - constructor(status: number, message: string, code?: string, fieldErrors?: Record) { + constructor( + status: number, + message: string, + code?: string, + fieldErrors?: Record, + ) { super(message) this.name = 'ApiError' this.status = status @@ -36,7 +50,8 @@ export async function api(path: string, init: RequestInit = {}): Promise { 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') + if (init.body != null && !headers.has('content-type')) + headers.set('content-type', 'application/json') headers.set('accept', 'application/json') const response = await fetch(path, { @@ -49,13 +64,13 @@ export async function api(path: string, init: RequestInit = {}): Promise { 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 : {} + 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 + ? (error.fieldErrors as Record) : undefined, ) } @@ -71,24 +86,29 @@ export function json(method: string, body?: unknown): RequestInit { function object(value: unknown): Record { return value != null && typeof value === 'object' && !Array.isArray(value) - ? value as Record + ? (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 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 + 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 } } @@ -98,19 +118,26 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment { 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 + 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, - 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, } @@ -119,24 +146,28 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment { 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') : [] + 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) + 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 { @@ -147,50 +178,68 @@ export function normalizeSettings(value: unknown): 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 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), + 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, + 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) + 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 { diff --git a/apps/overlay/src/auth.tsx b/apps/overlay/src/auth.tsx index 27f2b4f..f31d9b6 100644 --- a/apps/overlay/src/auth.tsx +++ b/apps/overlay/src/auth.tsx @@ -1,3 +1,11 @@ +/** + * Passwordless login and one-time TOTP enrollment screens. + * + * QR material, manual keys and recovery codes exist only in React memory and + * are dropped as soon as their enrollment phase completes. PWA activation is + * blocked while those values or partially completed forms are visible so an + * update cannot erase information that the server will not reveal again. + */ import { useMemo, useState } from 'react' import type { FormEvent, ReactNode } from 'react' import { @@ -11,7 +19,12 @@ import { import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa' import type { TotpEnrollment } from './types' -function AuthShell({ eyebrow, title, children, footer }: { +function AuthShell({ + eyebrow, + title, + children, + footer, +}: { eyebrow: string title: string children: ReactNode @@ -21,7 +34,9 @@ function AuthShell({ eyebrow, title, children, footer }: {
- +

{eyebrow}

{title}

{children} @@ -34,16 +49,19 @@ function AuthShell({ eyebrow, title, children, 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)}` + if (enrollment.qrSvg) + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(enrollment.qrSvg)}` return undefined }, [enrollment.qrDataUrl, enrollment.qrSvg]) return (
- {source - ? TOTP 验证器绑定二维码 - : 二维码暂不可用,请使用右侧密钥手工添加。} + {source ? ( + TOTP 验证器绑定二维码 + ) : ( + 二维码暂不可用,请使用右侧密钥手工添加。 + )}

绑定动态验证器

@@ -57,7 +75,11 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
{enrollment.manualKey || '未提供'} {enrollment.manualKey && ( - )} @@ -72,12 +94,10 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () 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 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 @@ -88,26 +108,45 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: () return ( -

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

- {codes.length > 0 - ?
{codes.map(code => {code})}
- :
服务端没有返回恢复码,请先联系管理员确认恢复策略。
} +

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

+ {codes.length > 0 ? ( +
+ {codes.map(code => ( + {code} + ))} +
+ ) : ( +
服务端没有返回恢复码,请先联系管理员确认恢复策略。
+ )}
{codes.length > 0 && ( <> - - + )} - +
) } -export function LoginPage({ onAuthenticated, setupRequired }: { +export function LoginPage({ + onAuthenticated, + setupRequired, +}: { onAuthenticated: () => Promise setupRequired: boolean }) { @@ -142,13 +181,19 @@ export function LoginPage({ onAuthenticated, setupRequired }: { - {setupRequired - ? <>首次部署?创建系统管理员 - : <>持有邀请码?注册新账户} + {setupRequired ? ( + <> + 首次部署?创建系统管理员 + + ) : ( + <> + 持有邀请码?注册新账户 + + )}

- )} + } >

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

@@ -173,9 +218,13 @@ export function LoginPage({ onAuthenticated, setupRequired }: { maxLength={useRecoveryCode ? 64 : 6} placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'} value={totpCode} - onChange={event => setTotpCode(useRecoveryCode - ? event.target.value.trimStart().slice(0, 64) - : event.target.value.replace(/\D/g, '').slice(0, 6))} + onChange={event => + setTotpCode( + useRecoveryCode + ? event.target.value.trimStart().slice(0, 64) + : event.target.value.replace(/\D/g, '').slice(0, 6), + ) + } /> - {error &&
{error}
} + {error && ( +
+ {error} +
+ )}
) } -export function EnrollmentPage({ mode, onAuthenticated }: { +export function EnrollmentPage({ + mode, + onAuthenticated, +}: { mode: 'setup' | 'register' onAuthenticated: () => Promise }) { @@ -214,7 +270,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: { enrollment || recoveryCodes ? '完成 TOTP 绑定并保存一次性恢复码' : '完成或清空正在填写的注册表单', - busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes), + busy || + Boolean( + inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes, + ), ) const start = async (event: FormEvent) => { @@ -276,7 +335,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: { if (enrollment) { return ( - +
- {error &&
{error}
} - + {error && ( +
+ {error} +
+ )} +
) @@ -305,7 +373,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: { 已有账户?返回登录

} + footer={ +

+ 已有账户?返回登录 +

+ } >

{isSetup @@ -350,7 +422,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: { 填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。 )} - {error &&

{error}
} + {error && ( +
+ {error} +
+ )}
diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css index 7d32e34..fab3205 100644 --- a/apps/overlay/src/control.css +++ b/apps/overlay/src/control.css @@ -7,7 +7,9 @@ html, body, #root, .copy { - font-family: "Noto Serif SC", "Microsoft YaHei", "Noto Color Emoji", "Segoe UI Emoji", "Apple Color Emoji", serif; + font-family: + 'Noto Serif SC', 'Microsoft YaHei', 'Noto Color Emoji', 'Segoe UI Emoji', 'Apple Color Emoji', + serif; } .wall { @@ -19,19 +21,33 @@ body, } .card-decor { - --decor-primary-image: url("/assets/floral-divider.svg"); + --decor-primary-image: url('/assets/floral-divider.svg'); --decor-primary-position: center 44%; --decor-primary-size: 92% auto; --decor-primary-transform: none; - --decor-primary-opacity: .11; - --decor-primary-color: linear-gradient(90deg, #e7d0f4 4%, #75e6cc 35%, #d5fff1 50%, #6adcc6 68%, #f4bfd4 96%); - --decor-secondary-image: url("/assets/floral-cluster.svg"); + --decor-primary-opacity: 0.11; + --decor-primary-color: linear-gradient( + 90deg, + #e7d0f4 4%, + #75e6cc 35%, + #d5fff1 50%, + #6adcc6 68%, + #f4bfd4 96% + ); + --decor-secondary-image: url('/assets/floral-cluster.svg'); --decor-secondary-position: right -10px bottom -22px; --decor-secondary-size: 34% auto; --decor-secondary-transform: none; - --decor-secondary-opacity: .065; + --decor-secondary-opacity: 0.065; --decor-secondary-color: linear-gradient(135deg, #e9c7f1, #79ead3 62%, #fff0bc); - --decor-surface: radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, .09), transparent 58%), linear-gradient(90deg, rgba(230, 203, 241, .04), transparent 18% 82%, rgba(244, 190, 214, .04)); + --decor-surface: + radial-gradient(ellipse at 50% 0, rgba(180, 255, 237, 0.09), transparent 58%), + linear-gradient( + 90deg, + rgba(230, 203, 241, 0.04), + transparent 18% 82%, + rgba(244, 190, 214, 0.04) + ); position: absolute; inset: 0; z-index: 0; @@ -43,7 +59,7 @@ body, .card-decor::before, .card-decor::after { - content: ""; + content: ''; position: absolute; inset: 0; z-index: 1; @@ -59,7 +75,7 @@ body, opacity: var(--decor-primary-opacity); transform: var(--decor-primary-transform); transform-origin: center; - filter: drop-shadow(0 0 6px rgba(105, 255, 224, .28)); + filter: drop-shadow(0 0 6px rgba(105, 255, 224, 0.28)); } .card-decor::after { @@ -78,12 +94,12 @@ body, position: absolute; inset: 2px; z-index: 0; - border: 1px solid rgba(157, 255, 232, .14); + border: 1px solid rgba(157, 255, 232, 0.14); border-radius: inherit; background: var(--decor-surface); box-shadow: - inset 0 1px rgba(225, 255, 247, .1), - inset 0 -1px rgba(82, 224, 198, .08); + inset 0 1px rgba(225, 255, 247, 0.1), + inset 0 -1px rgba(82, 224, 198, 0.08); } .decor-v1 { @@ -94,67 +110,82 @@ body, --decor-secondary-position: right -12px bottom -20px; --decor-secondary-size: 31% auto; --decor-secondary-transform: rotate(180deg); - --decor-secondary-opacity: .075; + --decor-secondary-opacity: 0.075; --decor-secondary-color: linear-gradient(145deg, #ffe9aa, #83e9d2 62%, #efd0f5); - --decor-surface: radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, .08), transparent 56%), linear-gradient(90deg, rgba(112, 232, 208, .045), transparent 34% 76%, rgba(236, 200, 243, .04)); + --decor-surface: + radial-gradient(ellipse at 52% 100%, rgba(255, 226, 163, 0.08), transparent 56%), + linear-gradient( + 90deg, + rgba(112, 232, 208, 0.045), + transparent 34% 76%, + rgba(236, 200, 243, 0.04) + ); } .decor-v2 { - --decor-primary-image: url("/assets/floral-vine.svg"); + --decor-primary-image: url('/assets/floral-vine.svg'); --decor-primary-position: left -12px bottom -13px; --decor-primary-size: 73% auto; - --decor-primary-opacity: .105; + --decor-primary-opacity: 0.105; --decor-primary-color: linear-gradient(110deg, #83ead5, #d8fff3 54%, #cbb9e9); - --decor-secondary-image: url("/assets/floral-divider.svg"); + --decor-secondary-image: url('/assets/floral-divider.svg'); --decor-secondary-position: right -22px top -18px; --decor-secondary-size: 62% auto; - --decor-secondary-opacity: .055; + --decor-secondary-opacity: 0.055; --decor-secondary-color: linear-gradient(90deg, #f5c9dc, #8be9d7 68%, #fff1bd); - --decor-surface: radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, .09), transparent 54%), linear-gradient(105deg, rgba(217, 198, 241, .045), transparent 58%); + --decor-surface: + radial-gradient(ellipse at 0 74%, rgba(105, 231, 206, 0.09), transparent 54%), + linear-gradient(105deg, rgba(217, 198, 241, 0.045), transparent 58%); } .decor-v3 { - --decor-primary-image: url("/assets/floral-vine.svg"); + --decor-primary-image: url('/assets/floral-vine.svg'); --decor-primary-position: left -15px bottom -15px; --decor-primary-size: 77% auto; --decor-primary-transform: scaleX(-1); - --decor-primary-opacity: .115; + --decor-primary-opacity: 0.115; --decor-primary-color: linear-gradient(100deg, #f2bfd7, #8aead8 48%, #d6fff0); --decor-secondary-position: right -12px bottom -20px; --decor-secondary-size: 32% auto; - --decor-secondary-opacity: .07; + --decor-secondary-opacity: 0.07; --decor-secondary-color: linear-gradient(145deg, #d9c1ed, #6fe0c9 58%, #ffe8ad); - --decor-surface: radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, .075), transparent 54%), linear-gradient(270deg, rgba(103, 228, 204, .05), transparent 64%); + --decor-surface: + radial-gradient(ellipse at 100% 24%, rgba(235, 190, 218, 0.075), transparent 54%), + linear-gradient(270deg, rgba(103, 228, 204, 0.05), transparent 64%); } .decor-v4 { - --decor-primary-image: url("/assets/floral-cluster.svg"); + --decor-primary-image: url('/assets/floral-cluster.svg'); --decor-primary-position: left -18px bottom -28px; --decor-primary-size: 45% auto; --decor-primary-transform: rotate(-5deg); - --decor-primary-opacity: .09; + --decor-primary-opacity: 0.09; --decor-primary-color: linear-gradient(135deg, #9cecd8, #fff0bd 57%, #edc5e4); --decor-secondary-position: left -15px bottom -25px; --decor-secondary-size: 37% auto; --decor-secondary-transform: rotate(180deg); - --decor-secondary-opacity: .065; + --decor-secondary-opacity: 0.065; --decor-secondary-color: linear-gradient(145deg, #dec4ee, #72dfc7 66%, #fff0b6); - --decor-surface: radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, .085), transparent 47%), radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, .06), transparent 44%); + --decor-surface: + radial-gradient(ellipse at 18% 100%, rgba(110, 232, 207, 0.085), transparent 47%), + radial-gradient(ellipse at 84% 0, rgba(239, 199, 223, 0.06), transparent 44%); } .decor-v5 { - --decor-primary-image: url("/assets/floral-vine.svg"); + --decor-primary-image: url('/assets/floral-vine.svg'); --decor-primary-position: center top -17px; --decor-primary-size: 90% auto; --decor-primary-transform: scaleY(-1); - --decor-primary-opacity: .095; + --decor-primary-opacity: 0.095; --decor-primary-color: linear-gradient(90deg, #d9c4ed, #82e5d2 44%, #f8e7b5 78%, #efc2d8); - --decor-secondary-image: url("/assets/floral-divider.svg"); + --decor-secondary-image: url('/assets/floral-divider.svg'); --decor-secondary-position: left 18% top -15px; --decor-secondary-size: 55% auto; - --decor-secondary-opacity: .06; + --decor-secondary-opacity: 0.06; --decor-secondary-color: linear-gradient(90deg, #fff0bc, #86e7d5 64%, #e4c6f0); - --decor-surface: radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, .065), transparent 56%), linear-gradient(90deg, rgba(232, 199, 239, .04), transparent 52%, rgba(255, 229, 168, .035)); + --decor-surface: + radial-gradient(ellipse at 50% 50%, rgba(207, 247, 236, 0.065), transparent 56%), + linear-gradient(90deg, rgba(232, 199, 239, 0.04), transparent 52%, rgba(255, 229, 168, 0.035)); } .card::before { @@ -173,8 +204,8 @@ body, position: absolute; inset: 0; z-index: 2; - opacity: .5; - transition: opacity .3s ease; + opacity: 0.5; + transition: opacity 0.3s ease; } .decor-v1 .card-particle-layer, @@ -208,7 +239,7 @@ body, .card-particle.star { background: linear-gradient(135deg, #fff7ca, #b7fff0 58%, #f5d4ff); clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%); - filter: drop-shadow(0 0 4px rgba(190, 255, 240, .9)); + filter: drop-shadow(0 0 4px rgba(190, 255, 240, 0.9)); } .card-particle.floret { @@ -219,30 +250,99 @@ body, radial-gradient(circle at 50% 83%, #ffd7e5 0 19%, transparent 22%), radial-gradient(circle at 18% 50%, #ffd7e5 0 19%, transparent 22%), radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%); - filter: drop-shadow(0 0 4px rgba(255, 202, 226, .72)); + filter: drop-shadow(0 0 4px rgba(255, 202, 226, 0.72)); } -.card-particle:nth-child(1) { left: 2%; top: 14%; animation-delay: -.35s; } -.card-particle:nth-child(2) { right: 3%; top: 16%; --particle-size: 10px; animation-delay: -1.15s; } -.card-particle:nth-child(3) { left: 4%; bottom: 13%; --particle-size: 7px; animation-delay: -2.4s; } -.card-particle:nth-child(4) { right: 2%; bottom: 15%; --particle-size: 8px; animation-delay: -.75s; } -.card-particle:nth-child(5) { left: 22%; top: 5%; --particle-size: 9px; animation-delay: -3.15s; } -.card-particle:nth-child(6) { right: 24%; top: 8%; --particle-size: 6px; animation-delay: -1.65s; } -.card-particle:nth-child(7) { left: 47%; bottom: 4%; --particle-size: 8px; animation-delay: -2.75s; } -.card-particle:nth-child(8) { right: 43%; top: 4%; --particle-size: 7px; animation-delay: -.15s; } -.card-particle:nth-child(9) { left: 34%; top: 46%; --particle-size: 6px; animation-delay: -1.95s; } -.card-particle:nth-child(10) { right: 32%; bottom: 30%; --particle-size: 9px; animation-delay: -3.55s; } -.card-particle:nth-child(11) { left: 12%; top: 48%; --particle-size: 7px; animation-delay: -.95s; } -.card-particle:nth-child(12) { right: 13%; top: 52%; --particle-size: 6px; animation-delay: -2.2s; } +.card-particle:nth-child(1) { + left: 2%; + top: 14%; + animation-delay: -0.35s; +} +.card-particle:nth-child(2) { + right: 3%; + top: 16%; + --particle-size: 10px; + animation-delay: -1.15s; +} +.card-particle:nth-child(3) { + left: 4%; + bottom: 13%; + --particle-size: 7px; + animation-delay: -2.4s; +} +.card-particle:nth-child(4) { + right: 2%; + bottom: 15%; + --particle-size: 8px; + animation-delay: -0.75s; +} +.card-particle:nth-child(5) { + left: 22%; + top: 5%; + --particle-size: 9px; + animation-delay: -3.15s; +} +.card-particle:nth-child(6) { + right: 24%; + top: 8%; + --particle-size: 6px; + animation-delay: -1.65s; +} +.card-particle:nth-child(7) { + left: 47%; + bottom: 4%; + --particle-size: 8px; + animation-delay: -2.75s; +} +.card-particle:nth-child(8) { + right: 43%; + top: 4%; + --particle-size: 7px; + animation-delay: -0.15s; +} +.card-particle:nth-child(9) { + left: 34%; + top: 46%; + --particle-size: 6px; + animation-delay: -1.95s; +} +.card-particle:nth-child(10) { + right: 32%; + bottom: 30%; + --particle-size: 9px; + animation-delay: -3.55s; +} +.card-particle:nth-child(11) { + left: 12%; + top: 48%; + --particle-size: 7px; + animation-delay: -0.95s; +} +.card-particle:nth-child(12) { + right: 13%; + top: 52%; + --particle-size: 6px; + animation-delay: -2.2s; +} @keyframes card-sparkle { - 0%, 100% { opacity: .04; transform: translate3d(0, 4px, 0) rotate(0) scale(.45); } - 38% { opacity: .82; transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08); } - 68% { opacity: .22; transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(.7); } + 0%, + 100% { + opacity: 0.04; + transform: translate3d(0, 4px, 0) rotate(0) scale(0.45); + } + 38% { + opacity: 0.82; + transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08); + } + 68% { + opacity: 0.22; + transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(0.7); + } } -.narrow .card-particle-layer > :nth-child(n+9), -.short .card-particle-layer > :nth-child(n+7) { +.narrow .card-particle-layer > :nth-child(n + 9), +.short .card-particle-layer > :nth-child(n + 7) { display: none; } @@ -279,9 +379,9 @@ body, width: auto; height: 1.4em; max-width: 6em; - margin-inline: .08em; + margin-inline: 0.08em; object-fit: contain; - vertical-align: -.32em; + vertical-align: -0.32em; opacity: 1; filter: none; } @@ -292,7 +392,7 @@ body, height: auto; max-width: 100%; max-height: 4.8em; - margin: .12em 0; + margin: 0.12em 0; vertical-align: top; } @@ -300,8 +400,8 @@ body, display: inline-block; max-width: 5em; max-height: 2.2em; - margin: 0 .1em; - vertical-align: -.55em; + margin: 0 0.1em; + vertical-align: -0.55em; } .copy b, @@ -316,46 +416,52 @@ body, .card.danmaku { isolation: isolate; transform-origin: center center; - transition: min-height .24s ease, padding .24s ease, border-radius .24s ease, background .24s ease; + transition: + min-height 0.24s ease, + padding 0.24s ease, + border-radius 0.24s ease, + background 0.24s ease; } .card.danmaku.expanded { min-height: 92px; padding: 14px; - border-color: rgba(133, 255, 232, .58); + border-color: rgba(133, 255, 232, 0.58); background: - linear-gradient(90deg, rgba(91, 224, 199, .13), transparent 14% 86%, rgba(91, 224, 199, .13)), - linear-gradient(115deg, rgba(4, 43, 59, .94), rgba(9, 82, 81, .78)); + linear-gradient(90deg, rgba(91, 224, 199, 0.13), transparent 14% 86%, rgba(91, 224, 199, 0.13)), + linear-gradient(115deg, rgba(4, 43, 59, 0.94), rgba(9, 82, 81, 0.78)); box-shadow: - inset 12px 0 18px -15px rgba(142, 255, 230, .95), - inset -12px 0 18px -15px rgba(142, 255, 230, .95), - 0 10px 28px rgba(0, 15, 25, .32); - animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both; + inset 12px 0 18px -15px rgba(142, 255, 230, 0.95), + inset -12px 0 18px -15px rgba(142, 255, 230, 0.95), + 0 10px 28px rgba(0, 15, 25, 0.32); + animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95) both; } .card.danmaku.expanded::after { - content: ""; + content: ''; position: absolute; inset-block: 4px; inset-inline: -1px; z-index: 2; pointer-events: none; - border-inline: 4px solid rgba(123, 238, 214, .72); + border-inline: 4px solid rgba(123, 238, 214, 0.72); border-radius: 11px; - background: - linear-gradient(90deg, - rgba(201, 255, 240, .32) 0, - rgba(63, 180, 165, .26) 5px, - transparent 5px, - transparent calc(100% - 5px), - rgba(63, 180, 165, .26) calc(100% - 5px), - rgba(201, 255, 240, .32) 100%); + background: linear-gradient( + 90deg, + rgba(201, 255, 240, 0.32) 0, + rgba(63, 180, 165, 0.26) 5px, + transparent 5px, + transparent calc(100% - 5px), + rgba(63, 180, 165, 0.26) calc(100% - 5px), + rgba(201, 255, 240, 0.32) 100% + ); box-shadow: - inset 6px 0 7px -7px rgba(216, 255, 246, .72), - inset -6px 0 7px -7px rgba(216, 255, 246, .72), - -2px 0 0 rgba(7, 46, 53, .72), - 2px 0 0 rgba(7, 46, 53, .72); - animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both; + inset 6px 0 7px -7px rgba(216, 255, 246, 0.72), + inset -6px 0 7px -7px rgba(216, 255, 246, 0.72), + -2px 0 0 rgba(7, 46, 53, 0.72), + 2px 0 0 rgba(7, 46, 53, 0.72); + animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95) + both; } .card.danmaku.expanded .copy { @@ -370,8 +476,8 @@ body, .card.danmaku.expanded .copy b { color: #f0fffb; - font-size: .78em; - letter-spacing: .06em; + font-size: 0.78em; + letter-spacing: 0.06em; } .card.danmaku.expanded .copy span { @@ -386,7 +492,7 @@ body, min-height: 38px; padding: 5px 10px; border-radius: 10px; - background: linear-gradient(115deg, rgba(4, 34, 49, .72), rgba(8, 69, 72, .48)); + background: linear-gradient(115deg, rgba(4, 34, 49, 0.72), rgba(8, 69, 72, 0.48)); } .card.danmaku.compact .copy { @@ -413,7 +519,7 @@ body, white-space: normal; } -.short .cards .card:nth-child(n+4) { +.short .cards .card:nth-child(n + 4) { display: flex; } @@ -428,7 +534,7 @@ body, @keyframes scroll-rails-open { from { - transform: scaleX(.025); + transform: scaleX(0.025); } to { transform: scaleX(1); @@ -436,11 +542,17 @@ body, } @keyframes arrive { - from { transform: translateX(38px) scale(.96); } - to { transform: none; } + from { + transform: translateX(38px) scale(0.96); + } + to { + transform: none; + } } -.overlay.narrow { padding: 6px; } +.overlay.narrow { + padding: 6px; +} .narrow .card.danmaku.expanded { min-height: 82px; @@ -478,11 +590,11 @@ body, .preset-buttons small { font-weight: normal; - opacity: .72; + opacity: 0.72; } .preset-buttons .active { - box-shadow: 0 0 0 2px rgba(137, 255, 226, .28); + box-shadow: 0 0 0 2px rgba(137, 255, 226, 0.28); } .control .obs-address { @@ -503,7 +615,7 @@ body, max-height: 75vh; overflow: auto; margin-top: 14px; - border: 1px solid rgba(89, 183, 173, .25); + border: 1px solid rgba(89, 183, 173, 0.25); background: #04131b; } @@ -515,7 +627,9 @@ body, } @media (max-width: 720px) { - .preview-viewport { max-height: 560px; } + .preview-viewport { + max-height: 560px; + } } /* Multi-user application shell ------------------------------------------------ */ @@ -523,10 +637,10 @@ body, :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-panel: rgba(7, 32, 42, 0.88); + --app-panel-strong: rgba(7, 39, 48, 0.96); + --app-line: rgba(110, 224, 205, 0.22); + --app-line-strong: rgba(117, 244, 218, 0.48); --app-text: #dcfff7; --app-muted: #83b8b0; --app-accent: #67e6c5; @@ -544,20 +658,20 @@ select { 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)); + radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, 0.08), transparent 48%), + linear-gradient(135deg, rgba(8, 39, 50, 0.94), rgba(5, 25, 35, 0.91)); box-shadow: - inset 0 1px rgba(210, 255, 245, .055), - 0 18px 48px rgba(0, 8, 15, .22); + inset 0 1px rgba(210, 255, 245, 0.055), + 0 18px 48px rgba(0, 8, 15, 0.22); backdrop-filter: blur(18px); } .jade-panel::after { - content: ""; + content: ''; position: absolute; inset: 5px; z-index: 0; - border: 1px solid rgba(122, 237, 216, .06); + border: 1px solid rgba(122, 237, 216, 0.06); border-radius: inherit; pointer-events: none; } @@ -573,7 +687,7 @@ select { font-family: ui-sans-serif, system-ui, sans-serif; font-size: 11px; font-weight: 700; - letter-spacing: .19em; + letter-spacing: 0.19em; text-transform: uppercase; } @@ -585,15 +699,15 @@ select { 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%), + radial-gradient(circle at 12% 12%, rgba(50, 145, 139, 0.2), transparent 30%), + radial-gradient(circle at 86% 82%, rgba(67, 105, 143, 0.18), transparent 33%), linear-gradient(145deg, #04131b, #082b31 55%, #041119); color: var(--app-text); } .route-loading { color: #93cfc4; - letter-spacing: .12em; + letter-spacing: 0.12em; } .auth-card { @@ -607,13 +721,13 @@ select { color: #dffff7; font-size: clamp(28px, 5vw, 44px); font-weight: 600; - letter-spacing: .04em; + letter-spacing: 0.04em; } .auth-card footer { margin-top: 24px; padding-top: 16px; - border-top: 1px solid rgba(109, 212, 196, .14); + border-top: 1px solid rgba(109, 212, 196, 0.14); } .auth-card footer p { @@ -634,11 +748,11 @@ select { height: 54px; display: grid; place-items: center; - border: 1px solid rgba(124, 239, 216, .32); + border: 1px solid rgba(124, 239, 216, 0.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); + color: rgba(190, 255, 241, 0.8); + background: radial-gradient(circle, rgba(62, 173, 155, 0.23), transparent 72%); + box-shadow: 0 0 30px rgba(80, 216, 191, 0.14); } .auth-lead { @@ -676,12 +790,14 @@ select { width: 100%; min-height: 44px; padding: 10px 12px; - border: 1px solid rgba(75, 157, 151, .55); + border: 1px solid rgba(75, 157, 151, 0.55); border-radius: 10px; outline: none; color: #edfffb; - background: rgba(3, 18, 26, .82); - transition: border-color .18s ease, box-shadow .18s ease; + background: rgba(3, 18, 26, 0.82); + transition: + border-color 0.18s ease, + box-shadow 0.18s ease; } .stack-form input:focus, @@ -690,14 +806,14 @@ select { .secret-address input:focus, .one-time-secret input:focus { border-color: #6ee8ce; - box-shadow: 0 0 0 3px rgba(92, 225, 198, .12); + box-shadow: 0 0 0 3px rgba(92, 225, 198, 0.12); } .otp-input { font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; font-size: 22px !important; font-weight: 700; - letter-spacing: .35em; + letter-spacing: 0.35em; text-align: center; } @@ -710,12 +826,15 @@ select { border-radius: 9px; color: #05241f; background: linear-gradient(135deg, #86f2d8, #50cdb2); - box-shadow: 0 5px 18px rgba(40, 168, 145, .16); + box-shadow: 0 5px 18px rgba(40, 168, 145, 0.16); font-weight: 700; text-align: center; text-decoration: none; cursor: pointer; - transition: filter .16s ease, transform .16s ease, border-color .16s ease; + transition: + filter 0.16s ease, + transform 0.16s ease, + border-color 0.16s ease; } .auth-page button:hover:not(:disabled), @@ -728,23 +847,23 @@ select { .auth-page button:disabled, .dashboard-shell button:disabled { cursor: wait; - filter: saturate(.45); - opacity: .65; + filter: saturate(0.45); + opacity: 0.65; } .auth-page button.secondary, .dashboard-shell button.secondary, .dashboard-shell .ghost-button { - border-color: rgba(91, 189, 176, .26); + border-color: rgba(91, 189, 176, 0.26); color: #cffff4; - background: rgba(16, 67, 76, .62); + background: rgba(16, 67, 76, 0.62); box-shadow: none; } .dashboard-shell button.danger { - border-color: rgba(255, 138, 156, .3); + border-color: rgba(255, 138, 156, 0.3); color: #ffd7dd; - background: rgba(102, 32, 48, .58); + background: rgba(102, 32, 48, 0.58); box-shadow: none; } @@ -773,32 +892,32 @@ select { .recovery-input { font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important; - letter-spacing: .08em; + letter-spacing: 0.08em; } .notice { padding: 11px 13px; - border: 1px solid rgba(121, 222, 205, .24); + border: 1px solid rgba(121, 222, 205, 0.24); border-radius: 10px; color: #c9f8ee; - background: rgba(17, 73, 76, .42); + background: rgba(17, 73, 76, 0.42); line-height: 1.45; } .notice.error { - border-color: rgba(255, 133, 151, .36); + border-color: rgba(255, 133, 151, 0.36); color: #ffd6dc; - background: rgba(103, 31, 47, .45); + background: rgba(103, 31, 47, 0.45); } .notice.warning { - border-color: rgba(255, 213, 127, .34); + border-color: rgba(255, 213, 127, 0.34); color: #ffe4aa; - background: rgba(93, 67, 24, .4); + background: rgba(93, 67, 24, 0.4); } .notice.success { - border-color: rgba(105, 239, 196, .34); + border-color: rgba(105, 239, 196, 0.34); color: #baffea; } @@ -849,10 +968,10 @@ select { min-width: 0; padding: 8px 10px; overflow-wrap: anywhere; - border: 1px solid rgba(106, 216, 199, .2); + border: 1px solid rgba(106, 216, 199, 0.2); border-radius: 8px; color: #e8fff9; - background: rgba(0, 12, 19, .66); + background: rgba(0, 12, 19, 0.66); } .compact-form { @@ -869,12 +988,12 @@ select { .recovery-grid code { padding: 11px; - border: 1px solid rgba(104, 219, 200, .2); + border: 1px solid rgba(104, 219, 200, 0.2); border-radius: 8px; color: #e8fff9; - background: rgba(0, 13, 20, .7); + background: rgba(0, 13, 20, 0.7); text-align: center; - letter-spacing: .08em; + letter-spacing: 0.08em; } .form-actions { @@ -899,9 +1018,8 @@ select { 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); + radial-gradient(circle at 88% 8%, rgba(30, 119, 114, 0.17), transparent 28%), + radial-gradient(circle at 8% 80%, rgba(58, 83, 126, 0.12), transparent 32%), var(--app-bg); } .dashboard-topbar { @@ -914,8 +1032,8 @@ select { 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); + border-bottom: 1px solid rgba(84, 178, 166, 0.16); + background: rgba(3, 18, 26, 0.84); backdrop-filter: blur(18px); } @@ -933,9 +1051,9 @@ select { height: 38px; display: grid; place-items: center; - border: 1px solid rgba(111, 232, 210, .35); + border: 1px solid rgba(111, 232, 210, 0.35); border-radius: 50%; - background: rgba(34, 116, 107, .22); + background: rgba(34, 116, 107, 0.22); } .brand div, @@ -949,16 +1067,16 @@ select { color: #689b94; font-family: ui-sans-serif, system-ui, sans-serif; font-size: 9px; - letter-spacing: .12em; + letter-spacing: 0.12em; } .dashboard-topbar nav { display: flex; gap: 5px; padding: 4px; - border: 1px solid rgba(89, 181, 169, .14); + border: 1px solid rgba(89, 181, 169, 0.14); border-radius: 11px; - background: rgba(3, 20, 28, .54); + background: rgba(3, 20, 28, 0.54); } .dashboard-topbar nav a { @@ -970,7 +1088,7 @@ select { .dashboard-topbar nav a.active { color: #e4fff9; - background: rgba(49, 132, 120, .35); + background: rgba(49, 132, 120, 0.35); } .account-menu { @@ -999,9 +1117,9 @@ select { .auth-page .pwa-action { min-height: 34px; padding: 6px 11px; - border-color: rgba(95, 215, 193, .28); + border-color: rgba(95, 215, 193, 0.28); color: #cffff4; - background: rgba(16, 67, 76, .72); + background: rgba(16, 67, 76, 0.72); box-shadow: none; font-size: 12px; } @@ -1012,10 +1130,10 @@ select { display: inline-flex; gap: 6px; align-items: center; - border: 1px solid rgba(255, 202, 112, .3); + border: 1px solid rgba(255, 202, 112, 0.3); border-radius: 999px; color: #ffe2a7; - background: rgba(83, 55, 20, .48); + background: rgba(83, 55, 20, 0.48); font-family: ui-sans-serif, system-ui, sans-serif; font-size: 12px; white-space: nowrap; @@ -1026,29 +1144,31 @@ select { height: 7px; border-radius: 50%; background: #ffca70; - box-shadow: 0 0 9px rgba(255, 202, 112, .68); + box-shadow: 0 0 9px rgba(255, 202, 112, 0.68); } .dashboard-shell .pwa-action { min-height: 30px; padding: 5px 10px; - border-color: rgba(95, 215, 193, .28); + border-color: rgba(95, 215, 193, 0.28); color: #cffff4; - background: rgba(16, 67, 76, .72); + background: rgba(16, 67, 76, 0.72); box-shadow: none; font-size: 12px; white-space: nowrap; } .dashboard-shell .pwa-action.update { - border-color: rgba(255, 222, 143, .4); + border-color: rgba(255, 222, 143, 0.4); color: #ffe8b7; - background: rgba(91, 65, 25, .58); + background: rgba(91, 65, 25, 0.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); } + 50% { + box-shadow: 0 0 15px rgba(255, 215, 123, 0.16); + } } @media (display-mode: standalone) { @@ -1110,8 +1230,8 @@ select { } .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)); + border-color: rgba(91, 211, 190, 0.23); + background: linear-gradient(110deg, rgba(38, 126, 114, 0.35), rgba(17, 69, 76, 0.3)); } .component-list button > span:nth-child(2) { @@ -1137,10 +1257,10 @@ select { height: 38px; display: grid; place-items: center; - border: 1px solid rgba(106, 225, 203, .2); + border: 1px solid rgba(106, 225, 203, 0.2); border-radius: 10px; color: #a8f0df; - background: rgba(45, 135, 121, .2); + background: rgba(45, 135, 121, 0.2); } .component-list i { @@ -1152,7 +1272,7 @@ select { .component-list i.enabled { background: #72ebc9; - box-shadow: 0 0 9px rgba(86, 232, 197, .7); + box-shadow: 0 0 9px rgba(86, 232, 197, 0.7); } .future-components { @@ -1160,7 +1280,7 @@ select { padding-top: 15px; display: grid; gap: 4px; - border-top: 1px solid rgba(94, 187, 173, .12); + border-top: 1px solid rgba(94, 187, 173, 0.12); color: #567d78; } @@ -1188,24 +1308,24 @@ select { .status-chip { width: max-content; padding: 5px 9px; - border: 1px solid rgba(112, 197, 186, .2); + border: 1px solid rgba(112, 197, 186, 0.2); border-radius: 99px; color: #91bcb6; - background: rgba(17, 60, 66, .44); + background: rgba(17, 60, 66, 0.44); font-size: 12px; white-space: nowrap; } .status-chip.online { - border-color: rgba(99, 236, 199, .3); + border-color: rgba(99, 236, 199, 0.3); color: #99f3d8; - background: rgba(31, 105, 87, .35); + background: rgba(31, 105, 87, 0.35); } .status-chip.offline { - border-color: rgba(240, 151, 159, .25); + border-color: rgba(240, 151, 159, 0.25); color: #e9aeb4; - background: rgba(91, 39, 48, .34); + background: rgba(91, 39, 48, 0.34); } .dashboard-panel { @@ -1251,7 +1371,7 @@ select { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } -.slider-grid input[type="range"] { +.slider-grid input[type='range'] { width: 100%; accent-color: #63ddc1; } @@ -1262,7 +1382,7 @@ select { display: flex; flex-wrap: wrap; gap: 9px; - border: 1px solid rgba(89, 179, 167, .18); + border: 1px solid rgba(89, 179, 167, 0.18); border-radius: 12px; } @@ -1381,9 +1501,9 @@ select { padding: 17px; display: grid; gap: 11px; - border: 1px solid rgba(255, 215, 127, .28); + border: 1px solid rgba(255, 215, 127, 0.28); border-radius: 12px; - background: rgba(86, 64, 24, .24); + background: rgba(86, 64, 24, 0.24); } .one-time-secret > b { @@ -1408,7 +1528,7 @@ table { th, td { padding: 12px 10px; - border-bottom: 1px solid rgba(85, 169, 158, .12); + border-bottom: 1px solid rgba(85, 169, 158, 0.12); color: #a9d3cc; text-align: left; white-space: nowrap; @@ -1426,10 +1546,10 @@ td:last-child { .obs-configuration-error { padding: 10px 12px; - border: 1px solid rgba(255, 143, 157, .42); + border: 1px solid rgba(255, 143, 157, 0.42); border-radius: 10px; color: #ffe1e5; - background: rgba(72, 24, 37, .82); + background: rgba(72, 24, 37, 0.82); font-size: clamp(13px, 3vw, 17px); } diff --git a/apps/overlay/src/control.tsx b/apps/overlay/src/control.tsx index 145eaf1..44ba961 100644 --- a/apps/overlay/src/control.tsx +++ b/apps/overlay/src/control.tsx @@ -1,3 +1,11 @@ +/** + * Authenticated component studio. + * + * This module composes tenant-scoped component settings, CookieCloud source + * configuration, isolated test events, OBS token rotation and system-admin + * invitations. One-time secrets remain local to the panel that created them; + * update blockers prevent the PWA from refreshing until they are saved. + */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { FormEvent, ReactNode } from 'react' import { @@ -35,7 +43,13 @@ function isDanmakuKind(kind: string): boolean { type Flash = { kind: 'success' | 'error'; text: string } | undefined -function Panel({ title, description, aside, children, className = '' }: { +function Panel({ + title, + description, + aside, + children, + className = '', +}: { title: string description?: string aside?: ReactNode @@ -58,10 +72,19 @@ function Panel({ title, description, aside, children, className = '' }: { function FlashMessage({ flash }: { flash: Flash }) { if (!flash) return null - return
{flash.text}
+ return ( +
+ {flash.text} +
+ ) } -function ControlLayout({ user, active, onLogout, children }: { +function ControlLayout({ + user, + active, + onLogout, + children, +}: { user: AuthUser active: 'components' | 'invitations' onLogout: () => Promise @@ -73,16 +96,30 @@ function ControlLayout({ user, active, onLogout, children }: {
-
洛星瓷直播云台OBS COMPONENT STUDIO
+
+ 洛星瓷直播云台 + OBS COMPONENT STUDIO +
-
{user.displayName || user.username}{isAdmin ? '系统管理员' : '用户'}
- +
+ {user.displayName || user.username} + {isAdmin ? '系统管理员' : '用户'} +
+
{children} @@ -90,7 +127,12 @@ function ControlLayout({ user, active, onLogout, children }: { ) } -function SettingsEditor({ settings, onChange, onSave, saving }: { +function SettingsEditor({ + settings, + onChange, + onSave, + saving, +}: { settings: OverlaySettings onChange: (settings: OverlaySettings) => void onSave: () => Promise @@ -113,32 +155,91 @@ function SettingsEditor({ settings, onChange, onSave, saving }: {
@@ -155,7 +256,11 @@ function SettingsEditor({ settings, onChange, onSave, saving }: { ))} @@ -163,15 +268,27 @@ function SettingsEditor({ settings, onChange, onSave, saving }: {
- +
) @@ -180,7 +297,11 @@ function SettingsEditor({ settings, onChange, onSave, saving }: { function OverlayPreview({ settings }: { settings: OverlaySettings }) { const [preset, setPreset] = useState(previewPresets[1]) return ( - +
{previewPresets.map(size => ( ))}
@@ -202,7 +326,10 @@ function OverlayPreview({ settings }: { settings: OverlaySettings }) { ) } -function SourceEditor({ source, onSaved }: { +function SourceEditor({ + source, + onSaved, +}: { source: CookieCloudSource onSaved: (source: CookieCloudSource) => void }) { @@ -212,10 +339,11 @@ function SourceEditor({ source, onSaved }: { 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) + const sourceDirty = + roomId !== source.roomId || + host.trim() !== source.cookieCloud.host || + key !== source.cookieCloud.key || + Boolean(password) usePwaUpdateBlocker('live-source', '保存或还原直播源与 CookieCloud 设置', busy || sourceDirty) useEffect(() => { @@ -230,14 +358,17 @@ function SourceEditor({ source, onSaved }: { 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 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('') @@ -253,29 +384,45 @@ function SourceEditor({ source, onSaved }: { - {source.connected ? '已连接' : '未连接'} - - )} + aside={ + source.connected === undefined ? undefined : ( + + {source.connected ? '已连接' : '未连接'} + + ) + } > {source.detail &&

{source.detail}

}
)}
- - {state.address && } + {state.address && ( + + )}
) @@ -424,13 +610,16 @@ function TestEvents({ componentId }: { componentId: string }) { 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 } : {}), - })) + 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, '测试事件发送失败') }) @@ -440,7 +629,10 @@ function TestEvents({ componentId }: { componentId: string }) { } return ( - + - - - {kind === 'danmaku' && } + + + {kind === 'danmaku' && ( + + )} {kind === 'gift' && ( <> - - - + + + )} -
+
+ +
) } -function ComponentList({ components, selectedId, onSelect }: { +function ComponentList({ + components, + selectedId, + onSelect, +}: { components: ComponentSummary[] selectedId?: string onSelect: (component: ComponentSummary) => void @@ -478,24 +712,32 @@ function ComponentList({ components, selectedId, onSelect }: {

COMPONENTS

我的组件

- {components.length === 0 - ?
还没有组件

账户初始化完成后,服务会为你创建默认弹幕姬。

- : ( -
- {components.map(component => ( - - ))} -
- )} + {components.length === 0 ? ( +
+ 还没有组件 +

账户初始化完成后,服务会为你创建默认弹幕姬。

+
+ ) : ( +
+ {components.map(component => ( + + ))} +
+ )}
即将支持 礼物展示 · 点歌姬 @@ -504,7 +746,13 @@ function ComponentList({ components, selectedId, onSelect }: { ) } -export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: () => Promise }) { +export function ComponentsPage({ + user, + onLogout, +}: { + user: AuthUser + onLogout: () => Promise +}) { const [components, setComponents] = useState([]) const [selectedId, setSelectedId] = useState() const [settings, setSettings] = useState() @@ -516,9 +764,11 @@ export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: ( 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 + 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) => { @@ -528,7 +778,9 @@ export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: ( savedSettingsRef.current = undefined setFlash(undefined) try { - const payload = await api(`/api/v1/components/${encodeURIComponent(component.id)}/settings`) + 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) @@ -553,16 +805,18 @@ export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: ( 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] + 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, '控制台数据加载失败') }) + if (!cancelled) + setFlash({ kind: 'error', text: errorMessage(reason, '控制台数据加载失败') }) } finally { if (!cancelled) setLoading(false) } @@ -603,7 +857,8 @@ export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: ( if (requestId !== settingsRequestRef.current || selectedIdRef.current !== componentId) return setFlash({ kind: 'error', text: errorMessage(reason, '组件设置保存失败') }) } finally { - if (requestId === settingsRequestRef.current && selectedIdRef.current === componentId) setSaving(false) + if (requestId === settingsRequestRef.current && selectedIdRef.current === componentId) + setSaving(false) } } @@ -617,26 +872,42 @@ export function ComponentsPage({ user, onLogout }: { user: AuthUser; onLogout: ( {!loading && selected && ( <>
-

{selected.kind.toUpperCase()}

{selected.name}

- +
+

{selected.kind.toUpperCase()}

+

{selected.name}

+
+ {selected.enabled === false ? '已停用' : '运行中'}
- {isDanmakuKind(selected.kind) && settings - ? ( - <> - - - - - - ) - :
该组件类型的设置编辑器尚未安装。
} + {isDanmakuKind(selected.kind) && settings ? ( + <> + + + + + + ) : ( + +
该组件类型的设置编辑器尚未安装。
+
+ )} )} - {!loading && !selected &&
当前账户还没有可配置的组件。
} + {!loading && !selected && ( + +
当前账户还没有可配置的组件。
+
+ )} {source && }
@@ -653,19 +924,30 @@ function formatDate(value?: string): string { 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.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 }) { +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)) + usePwaUpdateBlocker( + 'invitation-code', + '复制并妥善保存本次生成的一次性邀请码', + busy || Boolean(newCode), + ) const load = useCallback(async () => { const payload = await api('/api/v1/invitations') @@ -673,7 +955,9 @@ export function InvitationsPage({ user, onLogout }: { user: AuthUser; onLogout: }, []) useEffect(() => { - void load().catch(reason => setFlash({ kind: 'error', text: errorMessage(reason, '邀请码读取失败') })) + void load().catch(reason => + setFlash({ kind: 'error', text: errorMessage(reason, '邀请码读取失败') }), + ) }, [load]) const create = async (event: FormEvent) => { @@ -682,11 +966,16 @@ export function InvitationsPage({ user, onLogout }: { user: AuthUser; onLogout: 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 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: '邀请码已创建。明文只显示这一次。' }) @@ -721,47 +1010,122 @@ export function InvitationsPage({ user, onLogout }: { user: AuthUser; onLogout:
-

SYSTEM ADMIN

邀请码管理

+
+

SYSTEM ADMIN

+

邀请码管理

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

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

返回我的组件
+
+ +

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

+ + 返回我的组件 + +
+
) } diff --git a/apps/overlay/src/main.tsx b/apps/overlay/src/main.tsx index d582dae..b4943c7 100644 --- a/apps/overlay/src/main.tsx +++ b/apps/overlay/src/main.tsx @@ -1,3 +1,11 @@ +/** + * Browser entry point and intentionally small client-side router. + * + * `/obs/:publicId` is selected before the control application is initialized. + * That early split is a security and reliability boundary: OBS sources never + * register the control-console PWA or execute authenticated dashboard requests. + * All other routes share the session bootstrap and passwordless auth flow. + */ import { useCallback, useEffect, useState } from 'react' import { createRoot } from 'react-dom/client' import { ApiError, api, errorMessage, json, normalizeSession } from './api' @@ -20,11 +28,15 @@ function NotFoundPage() { return (
- +

404 · LOST IN THE CLOUDS

这里没有组件

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

- 返回控制台 + + 返回控制台 +
) @@ -85,9 +97,15 @@ function App() {

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

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

- {offline &&

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

} + {offline && ( +

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

+ )}
{loadError}
- +
) @@ -124,7 +142,8 @@ function App() { } if (path === '/control/invitations') { if (!session.user) return - if (session.user.role !== 'system_admin') return + if (session.user.role !== 'system_admin') + return return } return diff --git a/apps/overlay/src/overlay.tsx b/apps/overlay/src/overlay.tsx index 3381e21..e04cb84 100644 --- a/apps/overlay/src/overlay.tsx +++ b/apps/overlay/src/overlay.tsx @@ -1,3 +1,12 @@ +/** + * Transparent OBS renderer and component WebSocket client. + * + * The bearer token is read from the URL fragment, then sent as the first + * WebSocket frame; fragments never reach Nginx access logs or the initial HTTP + * request. Incoming events are treated as untrusted display data, bounded by + * component settings, and malformed frames are ignored without terminating a + * long-running browser source. + */ import { useEffect, useRef, useState } from 'react' import type { Dispatch, SetStateAction } from 'react' import { defaultOverlaySettings } from './types' @@ -55,7 +64,20 @@ type OverlayProps = { accessToken?: string } -const cardParticles = ['star', 'floret', 'star', 'star', 'floret', 'star', 'floret', 'star', 'star', 'floret', 'star', 'floret'] as const +const cardParticles = [ + 'star', + 'floret', + 'star', + 'star', + 'floret', + 'star', + 'floret', + 'star', + 'star', + 'floret', + 'star', + 'floret', +] as const const decorVariantCount = 6 function stableHash(value: string) { @@ -95,21 +117,27 @@ function streamUrl(publicId: string) { } 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) + 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 } + return { ...defaultOverlaySettings, ...(value as Partial) } } -function useEvents(disabled: boolean, publicId?: string, accessToken?: string): { +function useEvents( + disabled: boolean, + publicId?: string, + accessToken?: string, +): { settings: OverlaySettings items: Item[] setItems: Dispatch> @@ -117,7 +145,9 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string): } { const [settings, setSettings] = useState(defaultOverlaySettings) const [items, setItems] = useState([]) - const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle') + const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>( + 'idle', + ) const settingsRef = useRef(settings) useEffect(() => { @@ -142,7 +172,7 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string): retries = 0 socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken })) } - socket.onclose = (event) => { + socket.onclose = event => { if (dead) return if (event.code === 1008 || event.code === 4401 || event.code === 4403) { setConnection('denied') @@ -166,7 +196,10 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string): return } setConnection('connected') - if (envelope.type === 'overlay.settings.snapshot' || envelope.type === 'overlay.settings.updated') { + if ( + envelope.type === 'overlay.settings.snapshot' || + envelope.type === 'overlay.settings.updated' + ) { setSettings(parseSettings(envelope.payload?.settings)) return } @@ -176,10 +209,13 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string): 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) + 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. @@ -239,49 +275,75 @@ function DanmakuEmoticon({ segment }: { segment: Extract{payload.text || ''} - return <>{segments.map((segment, index) => segment.type === 'emoticon' && segment.url - ? - : {segment.text})} + return ( + <> + {segments.map((segment, index) => + segment.type === 'emoticon' && segment.url ? ( + + ) : ( + + {segment.text} + + ), + )} + + ) } -function Card({ item, settings, expanded }: { item: Item; settings: OverlaySettings; expanded: boolean }) { +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) || '送来了一份互动' + 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' - }} - /> - ) - : ✦} + {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)}} + {typeof gift?.priceCny === 'number' && gift.priceCny > 0 && ( + ¥ {gift.priceCny.toFixed(2)} + )}
{tier === 'featured' &&
✦ ✧ ✦
}
@@ -327,7 +389,13 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke payload: { viewer: { name: '星光旅人' }, quantity: 1, - gift: { name: '甜蜜告白', totalPrice: 12_000, priceCny: 12, imageUrl: '', animationUrl: '' }, + gift: { + name: '甜蜜告白', + totalPrice: 12_000, + priceCny: 12, + imageUrl: '', + animationUrl: '', + }, }, }, ]) @@ -343,7 +411,7 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke setExpandedKey(newest.key) const densityFactor = shape === 'short' ? 0.6 : 1 const timer = window.setTimeout( - () => setExpandedKey(key => key === newest.key ? undefined : key), + () => setExpandedKey(key => (key === newest.key ? undefined : key)), settings.collapseAfterSeconds * 1000 * densityFactor, ) return () => window.clearTimeout(timer) @@ -365,11 +433,20 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke }} >
- {missingAccess &&
OBS 地址不完整,请从控制台重新复制。
} - {!missingAccess && events.connection === 'denied' &&
OBS 访问令牌已失效。
} + {missingAccess && ( +
OBS 地址不完整,请从控制台重新复制。
+ )} + {!missingAccess && events.connection === 'denied' && ( +
OBS 访问令牌已失效。
+ )}
{items.map(item => ( - + ))}
diff --git a/apps/overlay/src/pwa.tsx b/apps/overlay/src/pwa.tsx index 85cc537..abb5d85 100644 --- a/apps/overlay/src/pwa.tsx +++ b/apps/overlay/src/pwa.tsx @@ -1,3 +1,12 @@ +/** + * Install, offline and explicit-update lifecycle for the control-console PWA. + * + * Registration is restricted to `/control/`; `/obs/*` is deliberately outside + * the scope. The worker caches only the public application shell and static + * assets. API responses, WebSockets and secrets remain network-only. A waiting + * worker activates only after user confirmation and after every registered + * one-time-secret or dirty-form blocker has cleared. + */ import { useEffect, useSyncExternalStore } from 'react' interface InstallChoice { @@ -58,9 +67,10 @@ function addManifest() { function observeRegistration(registration: ServiceWorkerRegistration) { emit({ registration, - waitingWorker: registration.waiting && navigator.serviceWorker.controller - ? registration.waiting - : snapshot.waitingWorker, + waitingWorker: + registration.waiting && navigator.serviceWorker.controller + ? registration.waiting + : snapshot.waitingWorker, }) registration.addEventListener('updatefound', () => { @@ -76,18 +86,23 @@ function observeRegistration(registration: ServiceWorkerRegistration) { 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() - })) + 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))) + await Promise.all( + cacheNames + .filter(name => name.startsWith('lxc-control-shell-')) + .map(name => caches.delete(name)), + ) } export function cleanupLegacyPwa() { @@ -121,18 +136,25 @@ export function initializePwa() { 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 + 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) + 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 })) + window.addEventListener('appinstalled', () => + emit({ installPrompt: undefined, standalone: true }), + ) if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return @@ -151,13 +173,17 @@ export function initializePwa() { void snapshot.registration?.update() } }) - window.setInterval(() => { - if (navigator.onLine) void snapshot.registration?.update() - }, 60 * 60 * 1000) + 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/') + const inControlScope = + location.pathname === '/control' || location.pathname.startsWith('/control/') return inControlScope ? `/control/${name}` : `/${name}` } @@ -165,7 +191,9 @@ export function usePwaUpdateBlocker(key: string, reason: string, active: boolean useEffect(() => { if (active) updateBlockers.set(key, reason) else updateBlockers.delete(key) - return () => { updateBlockers.delete(key) } + return () => { + updateBlockers.delete(key) + } }, [active, key, reason]) } @@ -182,10 +210,14 @@ function applyUpdate() { if (!worker) return const reasons = [...new Set(updateBlockers.values())] if (reasons.length > 0) { - window.alert(`暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`) + window.alert( + `暂时不能更新,请先处理以下内容:\n\n${reasons.map(reason => `• ${reason}`).join('\n')}`, + ) return } - const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?') + const confirmed = window.confirm( + '更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?', + ) if (!confirmed) return reloadForUpdate = true worker.postMessage({ type: 'SKIP_WAITING' }) @@ -198,7 +230,12 @@ export function PwaControls() { return (
- {!state.online &&