formatting and comments
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.git/
|
||||||
|
**/dist/
|
||||||
|
**/node_modules/
|
||||||
|
**/target/
|
||||||
|
vendor/
|
||||||
|
config.toml
|
||||||
|
*.png
|
||||||
|
*.svg
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"printWidth": 100,
|
||||||
|
"proseWrap": "always",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
@@ -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` 导出:
|
`src/main.rs` 只负责配置、组装、监听和优雅退出,可复用核心由 `src/lib.rs` 导出:
|
||||||
|
|
||||||
- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs` 的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。
|
- `live/` 中的 provider adapter 把 Bilibili 消息归一化为 `domain.rs`
|
||||||
- `components.rs` 注册组件定义、设置 schema/迁移、事件订阅、纯投影和独立副作用 handler;新礼物展示或点歌姬不需要修改弹幕姬队列核心。
|
的强类型事件;`SourceSupervisor` 保证每个用户的固定直播源只有一个 listener。
|
||||||
- `realtime.rs` 按 component UUID 建立独立广播通道,路由时同时校验 owner 与 source;副作用 handler 不依赖 OBS WebSocket 是否在线。
|
- `components.rs`
|
||||||
- `auth.rs`、`repository.rs` 和 PostgreSQL RLS 共同实现身份、凭据、直播源、组件与 token 的用户隔离;`http_api.rs` 从服务端会话推导 owner,不接受客户端自报 owner ID。
|
注册组件定义、设置 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
|
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
|
https://danmaku.luoxingci.com/control/setup
|
||||||
```
|
```
|
||||||
|
|
||||||
填写系统管理员用户名和 `config.toml` 中的 `admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
|
填写系统管理员用户名和 `config.toml` 中的
|
||||||
|
`admin.password`,扫描页面生成的 TOTP 二维码,再输入验证器中的 6 位动态码完成初始化。页面只显示一次恢复码,请立即离线保存。
|
||||||
|
|
||||||
`admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof:
|
`admin.password` 只是“允许创建第一个系统管理员”的一次性 bootstrap proof:
|
||||||
|
|
||||||
@@ -50,9 +100,14 @@ https://danmaku.luoxingci.com/control/setup
|
|||||||
- 所有账户都是 passwordless 账户,以“用户名 + TOTP”登录;丢失验证器时可使用一次性恢复码。
|
- 所有账户都是 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
|
## 必须使用 HTTPS
|
||||||
|
|
||||||
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持 `security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
|
公开部署账号、TOTP 和会话功能时必须在 Nginx(或可信反向代理)终止 HTTPS,并保持
|
||||||
|
`security.secure_cookies = true`。下面是核心反代设置;证书路径按实际 Certbot 配置填写:
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
@@ -103,36 +159,54 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置 `secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
|
只有在本机、无敏感数据的纯 HTTP 开发环境中才能临时设置
|
||||||
|
`secure_cookies = false`。不要用该选项把登录页面直接发布到公网。
|
||||||
|
|
||||||
## 控制台 PWA
|
## 控制台 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 令牌。
|
||||||
|
|
||||||
## 组件与 OBS 地址
|
## 组件与 OBS 地址
|
||||||
|
|
||||||
登录 `/control/` 后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是:
|
登录 `/control/`
|
||||||
|
后可以配置当前账户的直播源、管理组件、测试事件、调整弹幕样式,以及为每个组件单独生成或轮换只读 OBS 令牌。新的浏览器源地址格式是:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
|
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
`publicId` 是组件 UUID。`#token=...` 位于 URL fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向 `/api/v1/components/<publicId>/stream` 完成认证。令牌只带 `events:subscribe` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。
|
`publicId` 是组件 UUID。`#token=...` 位于 URL
|
||||||
|
fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通过 WebSocket 的第一帧向
|
||||||
|
`/api/v1/components/<publicId>/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 源。
|
每张消息卡片会从六套花纹组合中稳定选择一套,轮换使用对称花枝、横向自然藤纹和雏菊花簇,并改变上下、左右、镜像、配色与局部背景。连续的新卡避免使用相同款式,礼物连击更新保持原样式;透明消息墙本身不铺设全局装饰背景。粒子数量与速度、字号、事件类别、最大条数、自动收缩、卷轴展开时长、动效强度、低性能模式和礼物高亮阈值均可按组件在控制台调整,并实时推送给对应 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]` 调整。
|
||||||
|
|||||||
@@ -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)
|
||||||
Generated
+17
@@ -15,6 +15,7 @@
|
|||||||
"@types/react": "19.1.10",
|
"@types/react": "19.1.10",
|
||||||
"@types/react-dom": "19.1.7",
|
"@types/react-dom": "19.1.7",
|
||||||
"@vitejs/plugin-react": "5.0.2",
|
"@vitejs/plugin-react": "5.0.2",
|
||||||
|
"prettier": "3.9.5",
|
||||||
"typescript": "5.9.2",
|
"typescript": "5.9.2",
|
||||||
"vite": "7.3.6"
|
"vite": "7.3.6"
|
||||||
}
|
}
|
||||||
@@ -1573,6 +1574,22 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.1.1",
|
"version": "19.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz",
|
||||||
|
|||||||
@@ -2,7 +2,23 @@
|
|||||||
"name": "lxc-stream-overlay",
|
"name": "lxc-stream-overlay",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"scripts": { "build": "tsc -b && vite build", "dev": "vite" },
|
"scripts": {
|
||||||
"dependencies": { "react": "19.1.1", "react-dom": "19.1.1" },
|
"build": "tsc -b && vite build",
|
||||||
"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" }
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@
|
|||||||
- License: CC0 1.0 / public domain
|
- License: CC0 1.0 / public domain
|
||||||
- Retrieved: 2026-07-15
|
- Retrieved: 2026-07-15
|
||||||
|
|
||||||
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by
|
`floral-vine.svg` is a metadata-stripped copy of “Floral border 04 element by Paul Bürck”.
|
||||||
Paul Bürck”.
|
|
||||||
|
|
||||||
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
|
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_04_element_by_Paul_B%C3%BCrck.svg
|
||||||
- Original artist: Paul Bürck, 1899
|
- Original artist: Paul Bürck, 1899
|
||||||
@@ -16,8 +15,7 @@ Paul Bürck”.
|
|||||||
- License: original public domain; SVG adaptation CC0 1.0
|
- License: original public domain; SVG adaptation CC0 1.0
|
||||||
- Retrieved: 2026-07-15
|
- Retrieved: 2026-07-15
|
||||||
|
|
||||||
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by
|
`floral-cluster.svg` is a metadata-stripped copy of “Floral border 01 corner by Paul Bürck”.
|
||||||
Paul Bürck”.
|
|
||||||
|
|
||||||
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
|
- Source: https://commons.wikimedia.org/wiki/File:Floral_border_01_corner_by_Paul_B%C3%BCrck.svg
|
||||||
- Original artist: Paul Bürck, 1899
|
- Original artist: Paul Bürck, 1899
|
||||||
@@ -25,5 +23,5 @@ Paul Bürck”.
|
|||||||
- License: original public domain; SVG adaptation CC0 1.0
|
- License: original public domain; SVG adaptation CC0 1.0
|
||||||
- Retrieved: 2026-07-15
|
- Retrieved: 2026-07-15
|
||||||
|
|
||||||
The overlay uses these SVGs as recolorable CSS masks inside message cards and
|
The overlay uses these SVGs as recolorable CSS masks inside message cards and bundles them locally,
|
||||||
bundles them locally, so OBS never needs to load the source websites.
|
so OBS never needs to load the source websites.
|
||||||
|
|||||||
@@ -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 buildId = '__PWA_BUILD_ID__'
|
||||||
const cacheName = `lxc-control-v2-shell-${buildId}`
|
const cacheName = `lxc-control-v2-shell-${buildId}`
|
||||||
const shellPath = '/control/'
|
const shellPath = '/control/'
|
||||||
@@ -24,8 +31,9 @@ async function precacheShell() {
|
|||||||
|
|
||||||
const html = await response.clone().text()
|
const html = await response.clone().text()
|
||||||
await Promise.all(shellRoutes.map(route => cache.put(route, response.clone())))
|
await Promise.all(shellRoutes.map(route => cache.put(route, response.clone())))
|
||||||
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)]
|
const generatedAssets = [...html.matchAll(/(?:src|href)="(\/assets\/[^"?#]+)"/g)].map(
|
||||||
.map(match => match[1])
|
match => match[1],
|
||||||
|
)
|
||||||
await cache.addAll([...new Set([...stableAssets, ...generatedAssets])])
|
await cache.addAll([...new Set([...stableAssets, ...generatedAssets])])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,13 +42,17 @@ self.addEventListener('install', event => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
self.addEventListener('activate', event => {
|
self.addEventListener('activate', event => {
|
||||||
event.waitUntil((async () => {
|
event.waitUntil(
|
||||||
const names = await caches.keys()
|
(async () => {
|
||||||
await Promise.all(names
|
const names = await caches.keys()
|
||||||
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
|
await Promise.all(
|
||||||
.map(name => caches.delete(name)))
|
names
|
||||||
await self.clients.claim()
|
.filter(name => name.startsWith('lxc-control-v2-shell-') && name !== cacheName)
|
||||||
})())
|
.map(name => caches.delete(name)),
|
||||||
|
)
|
||||||
|
await self.clients.claim()
|
||||||
|
})(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
self.addEventListener('message', event => {
|
self.addEventListener('message', event => {
|
||||||
@@ -52,9 +64,11 @@ function isControlShell(pathname) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isStaticAsset(pathname) {
|
function isStaticAsset(pathname) {
|
||||||
return pathname.startsWith('/assets/')
|
return (
|
||||||
|| pathname.startsWith('/pwa/')
|
pathname.startsWith('/assets/') ||
|
||||||
|| pathname === '/control/manifest.webmanifest'
|
pathname.startsWith('/pwa/') ||
|
||||||
|
pathname === '/control/manifest.webmanifest'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function navigationResponse(request) {
|
async function navigationResponse(request) {
|
||||||
@@ -62,16 +76,20 @@ async function navigationResponse(request) {
|
|||||||
const pathname = new URL(request.url).pathname
|
const pathname = new URL(request.url).pathname
|
||||||
try {
|
try {
|
||||||
const response = await fetch(request)
|
const response = await fetch(request)
|
||||||
if (shellRoutes.includes(pathname)
|
if (
|
||||||
&& response.ok
|
shellRoutes.includes(pathname) &&
|
||||||
&& response.headers.get('content-type')?.includes('text/html')) {
|
response.ok &&
|
||||||
|
response.headers.get('content-type')?.includes('text/html')
|
||||||
|
) {
|
||||||
await cache.put(pathname, response.clone())
|
await cache.put(pathname, response.clone())
|
||||||
}
|
}
|
||||||
return response
|
return response
|
||||||
} catch {
|
} catch {
|
||||||
return (await cache.match(request, { ignoreSearch: true }))
|
return (
|
||||||
|| (await cache.match(shellPath))
|
(await cache.match(request, { ignoreSearch: true })) ||
|
||||||
|| Response.error()
|
(await cache.match(shellPath)) ||
|
||||||
|
Response.error()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.`)
|
||||||
|
}
|
||||||
+120
-71
@@ -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 {
|
import type {
|
||||||
AuthUser,
|
AuthUser,
|
||||||
ComponentSummary,
|
ComponentSummary,
|
||||||
@@ -13,7 +22,12 @@ export class ApiError extends Error {
|
|||||||
readonly code?: string
|
readonly code?: string
|
||||||
readonly fieldErrors?: Record<string, string>
|
readonly fieldErrors?: Record<string, string>
|
||||||
|
|
||||||
constructor(status: number, message: string, code?: string, fieldErrors?: Record<string, string>) {
|
constructor(
|
||||||
|
status: number,
|
||||||
|
message: string,
|
||||||
|
code?: string,
|
||||||
|
fieldErrors?: Record<string, string>,
|
||||||
|
) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'ApiError'
|
this.name = 'ApiError'
|
||||||
this.status = status
|
this.status = status
|
||||||
@@ -36,7 +50,8 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline')
|
throw new ApiError(0, '当前处于离线状态,操作没有提交;联网后请重试。', 'offline')
|
||||||
}
|
}
|
||||||
const headers = new Headers(init.headers)
|
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')
|
headers.set('accept', 'application/json')
|
||||||
|
|
||||||
const response = await fetch(path, {
|
const response = await fetch(path, {
|
||||||
@@ -49,13 +64,13 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|||||||
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
|
if (response.status === 401 && !path.startsWith('/api/v1/auth/')) {
|
||||||
window.dispatchEvent(new Event('lxc:session-expired'))
|
window.dispatchEvent(new Event('lxc:session-expired'))
|
||||||
}
|
}
|
||||||
const error = payload && typeof payload === 'object' ? payload as Record<string, unknown> : {}
|
const error = payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
response.status,
|
response.status,
|
||||||
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`),
|
String(error.message ?? error.error ?? `请求失败(HTTP ${response.status})`),
|
||||||
typeof error.code === 'string' ? error.code : undefined,
|
typeof error.code === 'string' ? error.code : undefined,
|
||||||
error.fieldErrors && typeof error.fieldErrors === 'object'
|
error.fieldErrors && typeof error.fieldErrors === 'object'
|
||||||
? error.fieldErrors as Record<string, string>
|
? (error.fieldErrors as Record<string, string>)
|
||||||
: undefined,
|
: undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -71,24 +86,29 @@ export function json(method: string, body?: unknown): RequestInit {
|
|||||||
|
|
||||||
function object(value: unknown): Record<string, unknown> {
|
function object(value: unknown): Record<string, unknown> {
|
||||||
return value != null && typeof value === 'object' && !Array.isArray(value)
|
return value != null && typeof value === 'object' && !Array.isArray(value)
|
||||||
? value as Record<string, unknown>
|
? (value as Record<string, unknown>)
|
||||||
: {}
|
: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSession(value: unknown): Session {
|
export function normalizeSession(value: unknown): Session {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const candidate = root.user
|
const candidate =
|
||||||
?? (root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string' ? root : null)
|
root.user ??
|
||||||
|
(root.authenticated === true || typeof root.id === 'string' || typeof root.username === 'string'
|
||||||
|
? root
|
||||||
|
: null)
|
||||||
const raw = object(candidate)
|
const raw = object(candidate)
|
||||||
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
|
const hasUser = typeof raw.id === 'string' || typeof raw.username === 'string'
|
||||||
const user: AuthUser | null = hasUser ? {
|
const user: AuthUser | null = hasUser
|
||||||
id: String(raw.id ?? ''),
|
? {
|
||||||
username: String(raw.username ?? ''),
|
id: String(raw.id ?? ''),
|
||||||
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
|
username: String(raw.username ?? ''),
|
||||||
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
|
roomId: typeof raw.roomId === 'string' ? raw.roomId : undefined,
|
||||||
role: typeof raw.role === 'string' ? raw.role : 'user',
|
displayName: typeof raw.displayName === 'string' ? raw.displayName : undefined,
|
||||||
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
|
role: typeof raw.role === 'string' ? raw.role : 'user',
|
||||||
} : null
|
totpEnabled: typeof raw.totpEnabled === 'boolean' ? raw.totpEnabled : undefined,
|
||||||
|
}
|
||||||
|
: null
|
||||||
return { user, setupRequired: root.setupRequired === true }
|
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
|
const enrollmentToken = root.enrollmentToken ?? totp.enrollmentToken ?? root.flowId ?? root.id
|
||||||
return {
|
return {
|
||||||
enrollmentToken: String(enrollmentToken ?? ''),
|
enrollmentToken: String(enrollmentToken ?? ''),
|
||||||
qrSvg: typeof totp.qrSvg === 'string' ? totp.qrSvg : typeof root.qrSvg === 'string' ? root.qrSvg : undefined,
|
qrSvg:
|
||||||
qrDataUrl: typeof totp.qrDataUrl === 'string'
|
typeof totp.qrSvg === 'string'
|
||||||
? totp.qrDataUrl
|
? totp.qrSvg
|
||||||
: typeof totp.qrCodeDataUrl === 'string'
|
: typeof root.qrSvg === 'string'
|
||||||
? totp.qrCodeDataUrl
|
? root.qrSvg
|
||||||
: typeof root.qrDataUrl === 'string'
|
: undefined,
|
||||||
? root.qrDataUrl
|
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,
|
: 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 ?? ''),
|
manualKey: String(totp.manualKey ?? totp.secret ?? root.manualKey ?? root.secret ?? ''),
|
||||||
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
|
expiresAt: typeof root.expiresAt === 'string' ? root.expiresAt : undefined,
|
||||||
}
|
}
|
||||||
@@ -119,24 +146,28 @@ export function normalizeEnrollment(value: unknown): TotpEnrollment {
|
|||||||
export function normalizeRecoveryCodes(value: unknown): string[] {
|
export function normalizeRecoveryCodes(value: unknown): string[] {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const codes = root.recoveryCodes ?? object(root.recovery).codes
|
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[] {
|
export function normalizeComponents(value: unknown): ComponentSummary[] {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
|
const list = Array.isArray(value) ? value : Array.isArray(root.components) ? root.components : []
|
||||||
return list.map((entry) => {
|
return list
|
||||||
const item = object(entry)
|
.map(entry => {
|
||||||
return {
|
const item = object(entry)
|
||||||
id: String(item.id ?? ''),
|
return {
|
||||||
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
|
id: String(item.id ?? ''),
|
||||||
kind: String(item.kind ?? item.type ?? 'danmaku'),
|
publicId: String(item.publicId ?? item.public_id ?? item.id ?? ''),
|
||||||
name: String(item.name ?? '弹幕姬'),
|
kind: String(item.kind ?? item.type ?? 'danmaku'),
|
||||||
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
|
name: String(item.name ?? '弹幕姬'),
|
||||||
settings: item.settings as OverlaySettings | undefined,
|
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
|
||||||
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
|
settings: item.settings as OverlaySettings | undefined,
|
||||||
}
|
updatedAt: typeof item.updatedAt === 'string' ? item.updatedAt : undefined,
|
||||||
}).filter(item => item.id)
|
}
|
||||||
|
})
|
||||||
|
.filter(item => item.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSettings(value: unknown): OverlaySettings {
|
export function normalizeSettings(value: unknown): OverlaySettings {
|
||||||
@@ -147,50 +178,68 @@ export function normalizeSettings(value: unknown): OverlaySettings {
|
|||||||
export function normalizeSource(value: unknown): CookieCloudSource {
|
export function normalizeSource(value: unknown): CookieCloudSource {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const source = object(root.source ?? root)
|
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)
|
const status = object(source.status)
|
||||||
return {
|
return {
|
||||||
roomId: String(source.roomId ?? root.roomId ?? ''),
|
roomId: String(source.roomId ?? root.roomId ?? ''),
|
||||||
cookieCloud: {
|
cookieCloud: {
|
||||||
host: String(cookieCloud.host ?? ''),
|
host: String(cookieCloud.host ?? ''),
|
||||||
key: '',
|
key: '',
|
||||||
keyConfigured: cookieCloud.keyConfigured === true
|
keyConfigured:
|
||||||
|| (typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
|
cookieCloud.keyConfigured === true ||
|
||||||
passwordConfigured: cookieCloud.passwordConfigured === true
|
(typeof cookieCloud.key === 'string' && cookieCloud.key.length > 0),
|
||||||
|| cookieCloud.configured === true
|
passwordConfigured:
|
||||||
|| (typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
|
cookieCloud.passwordConfigured === true ||
|
||||||
|
cookieCloud.configured === true ||
|
||||||
|
(typeof cookieCloud.password === 'string' && cookieCloud.password.length > 0),
|
||||||
},
|
},
|
||||||
connected: typeof source.connected === 'boolean'
|
connected:
|
||||||
? source.connected
|
typeof source.connected === 'boolean'
|
||||||
: typeof status.connected === 'boolean'
|
? source.connected
|
||||||
? status.connected
|
: typeof status.connected === 'boolean'
|
||||||
: undefined,
|
? status.connected
|
||||||
detail: typeof source.detail === 'string'
|
: undefined,
|
||||||
? source.detail
|
detail:
|
||||||
: typeof status.detail === 'string'
|
typeof source.detail === 'string'
|
||||||
? status.detail
|
? source.detail
|
||||||
: undefined,
|
: typeof status.detail === 'string'
|
||||||
|
? status.detail
|
||||||
|
: undefined,
|
||||||
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
|
updatedAt: typeof source.updatedAt === 'string' ? source.updatedAt : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeInvitations(value: unknown): Invitation[] {
|
export function normalizeInvitations(value: unknown): Invitation[] {
|
||||||
const root = object(value)
|
const root = object(value)
|
||||||
const list = Array.isArray(value) ? value : Array.isArray(root.invitations) ? root.invitations : []
|
const list = Array.isArray(value)
|
||||||
return list.map((entry) => {
|
? value
|
||||||
const item = object(entry)
|
: Array.isArray(root.invitations)
|
||||||
return {
|
? root.invitations
|
||||||
id: String(item.id ?? ''),
|
: []
|
||||||
code: typeof item.code === 'string' ? item.code : undefined,
|
return list
|
||||||
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
|
.map(entry => {
|
||||||
roomId: String(item.roomId ?? ''),
|
const item = object(entry)
|
||||||
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
|
return {
|
||||||
createdAt: typeof item.createdAt === 'string' ? item.createdAt : undefined,
|
id: String(item.id ?? ''),
|
||||||
expiresAt: typeof item.expiresAt === 'string' ? item.expiresAt : undefined,
|
code: typeof item.code === 'string' ? item.code : undefined,
|
||||||
consumedAt: typeof item.consumedAt === 'string' || item.consumedAt === null ? item.consumedAt : undefined,
|
codePrefix: typeof item.codePrefix === 'string' ? item.codePrefix : undefined,
|
||||||
revokedAt: typeof item.revokedAt === 'string' || item.revokedAt === null ? item.revokedAt : undefined,
|
roomId: String(item.roomId ?? ''),
|
||||||
}
|
createdBy: typeof item.createdBy === 'string' ? item.createdBy : undefined,
|
||||||
}).filter(item => item.id)
|
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 {
|
export function errorMessage(error: unknown, fallback = '操作失败,请稍后再试'): string {
|
||||||
|
|||||||
+113
-37
@@ -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 { useMemo, useState } from 'react'
|
||||||
import type { FormEvent, ReactNode } from 'react'
|
import type { FormEvent, ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -11,7 +19,12 @@ import {
|
|||||||
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
import { PwaControls, authRoute, usePwaUpdateBlocker } from './pwa'
|
||||||
import type { TotpEnrollment } from './types'
|
import type { TotpEnrollment } from './types'
|
||||||
|
|
||||||
function AuthShell({ eyebrow, title, children, footer }: {
|
function AuthShell({
|
||||||
|
eyebrow,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: {
|
||||||
eyebrow: string
|
eyebrow: string
|
||||||
title: string
|
title: string
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -21,7 +34,9 @@ function AuthShell({ eyebrow, title, children, footer }: {
|
|||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
<section className="auth-card jade-panel">
|
<section className="auth-card jade-panel">
|
||||||
<PwaControls />
|
<PwaControls />
|
||||||
<div className="auth-mark" aria-hidden="true">星</div>
|
<div className="auth-mark" aria-hidden="true">
|
||||||
|
星
|
||||||
|
</div>
|
||||||
<p className="eyebrow">{eyebrow}</p>
|
<p className="eyebrow">{eyebrow}</p>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
{children}
|
{children}
|
||||||
@@ -34,16 +49,19 @@ function AuthShell({ eyebrow, title, children, footer }: {
|
|||||||
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
||||||
const source = useMemo(() => {
|
const source = useMemo(() => {
|
||||||
if (enrollment.qrDataUrl) return enrollment.qrDataUrl
|
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
|
return undefined
|
||||||
}, [enrollment.qrDataUrl, enrollment.qrSvg])
|
}, [enrollment.qrDataUrl, enrollment.qrSvg])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="totp-enrollment">
|
<div className="totp-enrollment">
|
||||||
<div className="totp-qr">
|
<div className="totp-qr">
|
||||||
{source
|
{source ? (
|
||||||
? <img src={source} alt="TOTP 验证器绑定二维码" />
|
<img src={source} alt="TOTP 验证器绑定二维码" />
|
||||||
: <span>二维码暂不可用,请使用右侧密钥手工添加。</span>}
|
) : (
|
||||||
|
<span>二维码暂不可用,请使用右侧密钥手工添加。</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="totp-copy">
|
<div className="totp-copy">
|
||||||
<h2>绑定动态验证器</h2>
|
<h2>绑定动态验证器</h2>
|
||||||
@@ -57,7 +75,11 @@ function TotpQr({ enrollment }: { enrollment: TotpEnrollment }) {
|
|||||||
<div className="secret-row">
|
<div className="secret-row">
|
||||||
<code>{enrollment.manualKey || '未提供'}</code>
|
<code>{enrollment.manualKey || '未提供'}</code>
|
||||||
{enrollment.manualKey && (
|
{enrollment.manualKey && (
|
||||||
<button type="button" className="text-button" onClick={() => void copyToClipboard(enrollment.manualKey)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-button"
|
||||||
|
onClick={() => void copyToClipboard(enrollment.manualKey)}
|
||||||
|
>
|
||||||
复制
|
复制
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -72,12 +94,10 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
|
|||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const text = codes.join('\n')
|
const text = codes.join('\n')
|
||||||
const download = () => {
|
const download = () => {
|
||||||
const blob = new Blob([
|
const blob = new Blob(
|
||||||
'洛星瓷直播组件恢复码\n',
|
['洛星瓷直播组件恢复码\n', '每个恢复码只能使用一次,请离线妥善保存。\n\n', text, '\n'],
|
||||||
'每个恢复码只能使用一次,请离线妥善保存。\n\n',
|
{ type: 'text/plain;charset=utf-8' },
|
||||||
text,
|
)
|
||||||
'\n',
|
|
||||||
], { type: 'text/plain;charset=utf-8' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const anchor = document.createElement('a')
|
const anchor = document.createElement('a')
|
||||||
anchor.href = url
|
anchor.href = url
|
||||||
@@ -88,26 +108,45 @@ function RecoveryCodes({ codes, onContinue }: { codes: string[]; onContinue: ()
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
|
<AuthShell eyebrow="安全设置完成" title="保存账户恢复码">
|
||||||
<p className="auth-lead">手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。</p>
|
<p className="auth-lead">
|
||||||
{codes.length > 0
|
手机丢失或验证器不可用时,可用恢复码登录。服务端不会再次显示这些明文恢复码。
|
||||||
? <div className="recovery-grid">{codes.map(code => <code key={code}>{code}</code>)}</div>
|
</p>
|
||||||
: <div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>}
|
{codes.length > 0 ? (
|
||||||
|
<div className="recovery-grid">
|
||||||
|
{codes.map(code => (
|
||||||
|
<code key={code}>{code}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="notice warning">服务端没有返回恢复码,请先联系管理员确认恢复策略。</div>
|
||||||
|
)}
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
{codes.length > 0 && (
|
{codes.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<button type="button" className="secondary" onClick={() => void copyToClipboard(text).then(setCopied)}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => void copyToClipboard(text).then(setCopied)}
|
||||||
|
>
|
||||||
{copied ? '已复制' : '复制全部'}
|
{copied ? '已复制' : '复制全部'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="secondary" onClick={download}>下载文本</button>
|
<button type="button" className="secondary" onClick={download}>
|
||||||
|
下载文本
|
||||||
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={onContinue}>我已妥善保存</button>
|
<button type="button" onClick={onContinue}>
|
||||||
|
我已妥善保存
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoginPage({ onAuthenticated, setupRequired }: {
|
export function LoginPage({
|
||||||
|
onAuthenticated,
|
||||||
|
setupRequired,
|
||||||
|
}: {
|
||||||
onAuthenticated: () => Promise<void>
|
onAuthenticated: () => Promise<void>
|
||||||
setupRequired: boolean
|
setupRequired: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -142,13 +181,19 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
|||||||
<AuthShell
|
<AuthShell
|
||||||
eyebrow="洛星瓷直播组件"
|
eyebrow="洛星瓷直播组件"
|
||||||
title="回到你的云台"
|
title="回到你的云台"
|
||||||
footer={(
|
footer={
|
||||||
<p>
|
<p>
|
||||||
{setupRequired
|
{setupRequired ? (
|
||||||
? <>首次部署?<a href={authRoute('setup')}>创建系统管理员</a></>
|
<>
|
||||||
: <>持有邀请码?<a href={authRoute('register')}>注册新账户</a></>}
|
首次部署?<a href={authRoute('setup')}>创建系统管理员</a>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
持有邀请码?<a href={authRoute('register')}>注册新账户</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
|
<p className="auth-lead">这是无密码账户。输入用户名与验证器中的动态验证码即可登录。</p>
|
||||||
<form className="stack-form" onSubmit={submit}>
|
<form className="stack-form" onSubmit={submit}>
|
||||||
@@ -173,9 +218,13 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
|||||||
maxLength={useRecoveryCode ? 64 : 6}
|
maxLength={useRecoveryCode ? 64 : 6}
|
||||||
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
|
placeholder={useRecoveryCode ? '输入一个尚未使用的恢复码' : '000000'}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={event => setTotpCode(useRecoveryCode
|
onChange={event =>
|
||||||
? event.target.value.trimStart().slice(0, 64)
|
setTotpCode(
|
||||||
: event.target.value.replace(/\D/g, '').slice(0, 6))}
|
useRecoveryCode
|
||||||
|
? event.target.value.trimStart().slice(0, 64)
|
||||||
|
: event.target.value.replace(/\D/g, '').slice(0, 6),
|
||||||
|
)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
@@ -188,14 +237,21 @@ export function LoginPage({ onAuthenticated, setupRequired }: {
|
|||||||
>
|
>
|
||||||
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
|
{useRecoveryCode ? '改用动态验证码' : '验证器不可用?改用恢复码'}
|
||||||
</button>
|
</button>
|
||||||
{error && <div className="notice error" role="alert">{error}</div>}
|
{error && (
|
||||||
|
<div className="notice error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
|
<button disabled={busy}>{busy ? '正在验证…' : '安全登录'}</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnrollmentPage({ mode, onAuthenticated }: {
|
export function EnrollmentPage({
|
||||||
|
mode,
|
||||||
|
onAuthenticated,
|
||||||
|
}: {
|
||||||
mode: 'setup' | 'register'
|
mode: 'setup' | 'register'
|
||||||
onAuthenticated: () => Promise<void>
|
onAuthenticated: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
@@ -214,7 +270,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
enrollment || recoveryCodes
|
enrollment || recoveryCodes
|
||||||
? '完成 TOTP 绑定并保存一次性恢复码'
|
? '完成 TOTP 绑定并保存一次性恢复码'
|
||||||
: '完成或清空正在填写的注册表单',
|
: '完成或清空正在填写的注册表单',
|
||||||
busy || Boolean(inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes),
|
busy ||
|
||||||
|
Boolean(
|
||||||
|
inviteCode || username || bootstrapPassword || totpCode || enrollment || recoveryCodes,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const start = async (event: FormEvent) => {
|
const start = async (event: FormEvent) => {
|
||||||
@@ -276,7 +335,10 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
|
|
||||||
if (enrollment) {
|
if (enrollment) {
|
||||||
return (
|
return (
|
||||||
<AuthShell eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'} title="强制绑定 TOTP">
|
<AuthShell
|
||||||
|
eyebrow={isSetup ? '系统初始化 · 第二步' : '邀请码注册 · 第二步'}
|
||||||
|
title="强制绑定 TOTP"
|
||||||
|
>
|
||||||
<TotpQr enrollment={enrollment} />
|
<TotpQr enrollment={enrollment} />
|
||||||
<form className="stack-form compact-form" onSubmit={confirm}>
|
<form className="stack-form compact-form" onSubmit={confirm}>
|
||||||
<label>
|
<label>
|
||||||
@@ -294,8 +356,14 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
onChange={event => setTotpCode(event.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="notice error" role="alert">{error}</div>}
|
{error && (
|
||||||
<button disabled={busy || totpCode.length !== 6}>{busy ? '正在确认…' : '确认绑定并创建账户'}</button>
|
<div className="notice error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button disabled={busy || totpCode.length !== 6}>
|
||||||
|
{busy ? '正在确认…' : '确认绑定并创建账户'}
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
)
|
)
|
||||||
@@ -305,7 +373,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
<AuthShell
|
<AuthShell
|
||||||
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
|
eyebrow={isSetup ? '仅首次部署可用' : '仅限受邀用户'}
|
||||||
title={isSetup ? '创建系统管理员' : '创建你的账户'}
|
title={isSetup ? '创建系统管理员' : '创建你的账户'}
|
||||||
footer={<p>已有账户?<a href={authRoute('login')}>返回登录</a></p>}
|
footer={
|
||||||
|
<p>
|
||||||
|
已有账户?<a href={authRoute('login')}>返回登录</a>
|
||||||
|
</p>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<p className="auth-lead">
|
<p className="auth-lead">
|
||||||
{isSetup
|
{isSetup
|
||||||
@@ -350,7 +422,11 @@ export function EnrollmentPage({ mode, onAuthenticated }: {
|
|||||||
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
|
<small>填写部署配置中的旧管理员口令;它仅验证初始化权限,不会保存为用户密码。</small>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{error && <div className="notice error" role="alert">{error}</div>}
|
{error && (
|
||||||
|
<div className="notice error" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
|
<button disabled={busy}>{busy ? '正在准备 TOTP…' : '下一步:绑定验证器'}</button>
|
||||||
</form>
|
</form>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|||||||
+299
-179
@@ -7,7 +7,9 @@ html,
|
|||||||
body,
|
body,
|
||||||
#root,
|
#root,
|
||||||
.copy {
|
.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 {
|
.wall {
|
||||||
@@ -19,19 +21,33 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-decor {
|
.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-position: center 44%;
|
||||||
--decor-primary-size: 92% auto;
|
--decor-primary-size: 92% auto;
|
||||||
--decor-primary-transform: none;
|
--decor-primary-transform: none;
|
||||||
--decor-primary-opacity: .11;
|
--decor-primary-opacity: 0.11;
|
||||||
--decor-primary-color: linear-gradient(90deg, #e7d0f4 4%, #75e6cc 35%, #d5fff1 50%, #6adcc6 68%, #f4bfd4 96%);
|
--decor-primary-color: linear-gradient(
|
||||||
--decor-secondary-image: url("/assets/floral-cluster.svg");
|
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-position: right -10px bottom -22px;
|
||||||
--decor-secondary-size: 34% auto;
|
--decor-secondary-size: 34% auto;
|
||||||
--decor-secondary-transform: none;
|
--decor-secondary-transform: none;
|
||||||
--decor-secondary-opacity: .065;
|
--decor-secondary-opacity: 0.065;
|
||||||
--decor-secondary-color: linear-gradient(135deg, #e9c7f1, #79ead3 62%, #fff0bc);
|
--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;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
@@ -43,7 +59,7 @@ body,
|
|||||||
|
|
||||||
.card-decor::before,
|
.card-decor::before,
|
||||||
.card-decor::after {
|
.card-decor::after {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
@@ -59,7 +75,7 @@ body,
|
|||||||
opacity: var(--decor-primary-opacity);
|
opacity: var(--decor-primary-opacity);
|
||||||
transform: var(--decor-primary-transform);
|
transform: var(--decor-primary-transform);
|
||||||
transform-origin: center;
|
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 {
|
.card-decor::after {
|
||||||
@@ -78,12 +94,12 @@ body,
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 2px;
|
inset: 2px;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
border: 1px solid rgba(157, 255, 232, .14);
|
border: 1px solid rgba(157, 255, 232, 0.14);
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
background: var(--decor-surface);
|
background: var(--decor-surface);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px rgba(225, 255, 247, .1),
|
inset 0 1px rgba(225, 255, 247, 0.1),
|
||||||
inset 0 -1px rgba(82, 224, 198, .08);
|
inset 0 -1px rgba(82, 224, 198, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.decor-v1 {
|
.decor-v1 {
|
||||||
@@ -94,67 +110,82 @@ body,
|
|||||||
--decor-secondary-position: right -12px bottom -20px;
|
--decor-secondary-position: right -12px bottom -20px;
|
||||||
--decor-secondary-size: 31% auto;
|
--decor-secondary-size: 31% auto;
|
||||||
--decor-secondary-transform: rotate(180deg);
|
--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-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-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-position: left -12px bottom -13px;
|
||||||
--decor-primary-size: 73% auto;
|
--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-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-position: right -22px top -18px;
|
||||||
--decor-secondary-size: 62% auto;
|
--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-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-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-position: left -15px bottom -15px;
|
||||||
--decor-primary-size: 77% auto;
|
--decor-primary-size: 77% auto;
|
||||||
--decor-primary-transform: scaleX(-1);
|
--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-primary-color: linear-gradient(100deg, #f2bfd7, #8aead8 48%, #d6fff0);
|
||||||
--decor-secondary-position: right -12px bottom -20px;
|
--decor-secondary-position: right -12px bottom -20px;
|
||||||
--decor-secondary-size: 32% auto;
|
--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-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-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-position: left -18px bottom -28px;
|
||||||
--decor-primary-size: 45% auto;
|
--decor-primary-size: 45% auto;
|
||||||
--decor-primary-transform: rotate(-5deg);
|
--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-primary-color: linear-gradient(135deg, #9cecd8, #fff0bd 57%, #edc5e4);
|
||||||
--decor-secondary-position: left -15px bottom -25px;
|
--decor-secondary-position: left -15px bottom -25px;
|
||||||
--decor-secondary-size: 37% auto;
|
--decor-secondary-size: 37% auto;
|
||||||
--decor-secondary-transform: rotate(180deg);
|
--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-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-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-position: center top -17px;
|
||||||
--decor-primary-size: 90% auto;
|
--decor-primary-size: 90% auto;
|
||||||
--decor-primary-transform: scaleY(-1);
|
--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-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-position: left 18% top -15px;
|
||||||
--decor-secondary-size: 55% auto;
|
--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-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 {
|
.card::before {
|
||||||
@@ -173,8 +204,8 @@ body,
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
opacity: .5;
|
opacity: 0.5;
|
||||||
transition: opacity .3s ease;
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.decor-v1 .card-particle-layer,
|
.decor-v1 .card-particle-layer,
|
||||||
@@ -208,7 +239,7 @@ body,
|
|||||||
.card-particle.star {
|
.card-particle.star {
|
||||||
background: linear-gradient(135deg, #fff7ca, #b7fff0 58%, #f5d4ff);
|
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%);
|
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 {
|
.card-particle.floret {
|
||||||
@@ -219,30 +250,99 @@ body,
|
|||||||
radial-gradient(circle at 50% 83%, #ffd7e5 0 19%, transparent 22%),
|
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 18% 50%, #ffd7e5 0 19%, transparent 22%),
|
||||||
radial-gradient(circle at 50% 50%, #fff0a9 0 18%, transparent 21%);
|
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(1) {
|
||||||
.card-particle:nth-child(2) { right: 3%; top: 16%; --particle-size: 10px; animation-delay: -1.15s; }
|
left: 2%;
|
||||||
.card-particle:nth-child(3) { left: 4%; bottom: 13%; --particle-size: 7px; animation-delay: -2.4s; }
|
top: 14%;
|
||||||
.card-particle:nth-child(4) { right: 2%; bottom: 15%; --particle-size: 8px; animation-delay: -.75s; }
|
animation-delay: -0.35s;
|
||||||
.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(2) {
|
||||||
.card-particle:nth-child(7) { left: 47%; bottom: 4%; --particle-size: 8px; animation-delay: -2.75s; }
|
right: 3%;
|
||||||
.card-particle:nth-child(8) { right: 43%; top: 4%; --particle-size: 7px; animation-delay: -.15s; }
|
top: 16%;
|
||||||
.card-particle:nth-child(9) { left: 34%; top: 46%; --particle-size: 6px; animation-delay: -1.95s; }
|
--particle-size: 10px;
|
||||||
.card-particle:nth-child(10) { right: 32%; bottom: 30%; --particle-size: 9px; animation-delay: -3.55s; }
|
animation-delay: -1.15s;
|
||||||
.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(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 {
|
@keyframes card-sparkle {
|
||||||
0%, 100% { opacity: .04; transform: translate3d(0, 4px, 0) rotate(0) scale(.45); }
|
0%,
|
||||||
38% { opacity: .82; transform: translate3d(2px, -2px, 0) rotate(38deg) scale(1.08); }
|
100% {
|
||||||
68% { opacity: .22; transform: translate3d(-1px, -7px, 0) rotate(72deg) scale(.7); }
|
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),
|
.narrow .card-particle-layer > :nth-child(n + 9),
|
||||||
.short .card-particle-layer > :nth-child(n+7) {
|
.short .card-particle-layer > :nth-child(n + 7) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,9 +379,9 @@ body,
|
|||||||
width: auto;
|
width: auto;
|
||||||
height: 1.4em;
|
height: 1.4em;
|
||||||
max-width: 6em;
|
max-width: 6em;
|
||||||
margin-inline: .08em;
|
margin-inline: 0.08em;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
vertical-align: -.32em;
|
vertical-align: -0.32em;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
@@ -292,7 +392,7 @@ body,
|
|||||||
height: auto;
|
height: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 4.8em;
|
max-height: 4.8em;
|
||||||
margin: .12em 0;
|
margin: 0.12em 0;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,8 +400,8 @@ body,
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
max-width: 5em;
|
max-width: 5em;
|
||||||
max-height: 2.2em;
|
max-height: 2.2em;
|
||||||
margin: 0 .1em;
|
margin: 0 0.1em;
|
||||||
vertical-align: -.55em;
|
vertical-align: -0.55em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copy b,
|
.copy b,
|
||||||
@@ -316,46 +416,52 @@ body,
|
|||||||
.card.danmaku {
|
.card.danmaku {
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
transform-origin: center center;
|
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 {
|
.card.danmaku.expanded {
|
||||||
min-height: 92px;
|
min-height: 92px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
border-color: rgba(133, 255, 232, .58);
|
border-color: rgba(133, 255, 232, 0.58);
|
||||||
background:
|
background:
|
||||||
linear-gradient(90deg, rgba(91, 224, 199, .13), transparent 14% 86%, rgba(91, 224, 199, .13)),
|
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, .94), rgba(9, 82, 81, .78));
|
linear-gradient(115deg, rgba(4, 43, 59, 0.94), rgba(9, 82, 81, 0.78));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 12px 0 18px -15px rgba(142, 255, 230, .95),
|
inset 12px 0 18px -15px rgba(142, 255, 230, 0.95),
|
||||||
inset -12px 0 18px -15px rgba(142, 255, 230, .95),
|
inset -12px 0 18px -15px rgba(142, 255, 230, 0.95),
|
||||||
0 10px 28px rgba(0, 15, 25, .32);
|
0 10px 28px rgba(0, 15, 25, 0.32);
|
||||||
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
|
animation: scroll-unfurl var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.danmaku.expanded::after {
|
.card.danmaku.expanded::after {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-block: 4px;
|
inset-block: 4px;
|
||||||
inset-inline: -1px;
|
inset-inline: -1px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
pointer-events: none;
|
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;
|
border-radius: 11px;
|
||||||
background:
|
background: linear-gradient(
|
||||||
linear-gradient(90deg,
|
90deg,
|
||||||
rgba(201, 255, 240, .32) 0,
|
rgba(201, 255, 240, 0.32) 0,
|
||||||
rgba(63, 180, 165, .26) 5px,
|
rgba(63, 180, 165, 0.26) 5px,
|
||||||
transparent 5px,
|
transparent 5px,
|
||||||
transparent calc(100% - 5px),
|
transparent calc(100% - 5px),
|
||||||
rgba(63, 180, 165, .26) calc(100% - 5px),
|
rgba(63, 180, 165, 0.26) calc(100% - 5px),
|
||||||
rgba(201, 255, 240, .32) 100%);
|
rgba(201, 255, 240, 0.32) 100%
|
||||||
|
);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 6px 0 7px -7px rgba(216, 255, 246, .72),
|
inset 6px 0 7px -7px rgba(216, 255, 246, 0.72),
|
||||||
inset -6px 0 7px -7px rgba(216, 255, 246, .72),
|
inset -6px 0 7px -7px rgba(216, 255, 246, 0.72),
|
||||||
-2px 0 0 rgba(7, 46, 53, .72),
|
-2px 0 0 rgba(7, 46, 53, 0.72),
|
||||||
2px 0 0 rgba(7, 46, 53, .72);
|
2px 0 0 rgba(7, 46, 53, 0.72);
|
||||||
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(.25, .45, .45, .95) both;
|
animation: scroll-rails-open var(--unfold-duration, 1000ms) cubic-bezier(0.25, 0.45, 0.45, 0.95)
|
||||||
|
both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.danmaku.expanded .copy {
|
.card.danmaku.expanded .copy {
|
||||||
@@ -370,8 +476,8 @@ body,
|
|||||||
|
|
||||||
.card.danmaku.expanded .copy b {
|
.card.danmaku.expanded .copy b {
|
||||||
color: #f0fffb;
|
color: #f0fffb;
|
||||||
font-size: .78em;
|
font-size: 0.78em;
|
||||||
letter-spacing: .06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card.danmaku.expanded .copy span {
|
.card.danmaku.expanded .copy span {
|
||||||
@@ -386,7 +492,7 @@ body,
|
|||||||
min-height: 38px;
|
min-height: 38px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-radius: 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 {
|
.card.danmaku.compact .copy {
|
||||||
@@ -413,7 +519,7 @@ body,
|
|||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.short .cards .card:nth-child(n+4) {
|
.short .cards .card:nth-child(n + 4) {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +534,7 @@ body,
|
|||||||
|
|
||||||
@keyframes scroll-rails-open {
|
@keyframes scroll-rails-open {
|
||||||
from {
|
from {
|
||||||
transform: scaleX(.025);
|
transform: scaleX(0.025);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
transform: scaleX(1);
|
transform: scaleX(1);
|
||||||
@@ -436,11 +542,17 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes arrive {
|
@keyframes arrive {
|
||||||
from { transform: translateX(38px) scale(.96); }
|
from {
|
||||||
to { transform: none; }
|
transform: translateX(38px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay.narrow { padding: 6px; }
|
.overlay.narrow {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.narrow .card.danmaku.expanded {
|
.narrow .card.danmaku.expanded {
|
||||||
min-height: 82px;
|
min-height: 82px;
|
||||||
@@ -478,11 +590,11 @@ body,
|
|||||||
|
|
||||||
.preset-buttons small {
|
.preset-buttons small {
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
opacity: .72;
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-buttons .active {
|
.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 {
|
.control .obs-address {
|
||||||
@@ -503,7 +615,7 @@ body,
|
|||||||
max-height: 75vh;
|
max-height: 75vh;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
border: 1px solid rgba(89, 183, 173, .25);
|
border: 1px solid rgba(89, 183, 173, 0.25);
|
||||||
background: #04131b;
|
background: #04131b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,7 +627,9 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.preview-viewport { max-height: 560px; }
|
.preview-viewport {
|
||||||
|
max-height: 560px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Multi-user application shell ------------------------------------------------ */
|
/* Multi-user application shell ------------------------------------------------ */
|
||||||
@@ -523,10 +637,10 @@ body,
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
--app-bg: #041219;
|
--app-bg: #041219;
|
||||||
--app-panel: rgba(7, 32, 42, .88);
|
--app-panel: rgba(7, 32, 42, 0.88);
|
||||||
--app-panel-strong: rgba(7, 39, 48, .96);
|
--app-panel-strong: rgba(7, 39, 48, 0.96);
|
||||||
--app-line: rgba(110, 224, 205, .22);
|
--app-line: rgba(110, 224, 205, 0.22);
|
||||||
--app-line-strong: rgba(117, 244, 218, .48);
|
--app-line-strong: rgba(117, 244, 218, 0.48);
|
||||||
--app-text: #dcfff7;
|
--app-text: #dcfff7;
|
||||||
--app-muted: #83b8b0;
|
--app-muted: #83b8b0;
|
||||||
--app-accent: #67e6c5;
|
--app-accent: #67e6c5;
|
||||||
@@ -544,20 +658,20 @@ select {
|
|||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid var(--app-line);
|
border: 1px solid var(--app-line);
|
||||||
background:
|
background:
|
||||||
radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, .08), transparent 48%),
|
radial-gradient(ellipse at 100% 0, rgba(94, 220, 194, 0.08), transparent 48%),
|
||||||
linear-gradient(135deg, rgba(8, 39, 50, .94), rgba(5, 25, 35, .91));
|
linear-gradient(135deg, rgba(8, 39, 50, 0.94), rgba(5, 25, 35, 0.91));
|
||||||
box-shadow:
|
box-shadow:
|
||||||
inset 0 1px rgba(210, 255, 245, .055),
|
inset 0 1px rgba(210, 255, 245, 0.055),
|
||||||
0 18px 48px rgba(0, 8, 15, .22);
|
0 18px 48px rgba(0, 8, 15, 0.22);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.jade-panel::after {
|
.jade-panel::after {
|
||||||
content: "";
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 5px;
|
inset: 5px;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
border: 1px solid rgba(122, 237, 216, .06);
|
border: 1px solid rgba(122, 237, 216, 0.06);
|
||||||
border-radius: inherit;
|
border-radius: inherit;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -573,7 +687,7 @@ select {
|
|||||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: .19em;
|
letter-spacing: 0.19em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,15 +699,15 @@ select {
|
|||||||
place-items: center;
|
place-items: center;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 12% 12%, rgba(50, 145, 139, .2), transparent 30%),
|
radial-gradient(circle at 12% 12%, rgba(50, 145, 139, 0.2), transparent 30%),
|
||||||
radial-gradient(circle at 86% 82%, rgba(67, 105, 143, .18), transparent 33%),
|
radial-gradient(circle at 86% 82%, rgba(67, 105, 143, 0.18), transparent 33%),
|
||||||
linear-gradient(145deg, #04131b, #082b31 55%, #041119);
|
linear-gradient(145deg, #04131b, #082b31 55%, #041119);
|
||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.route-loading {
|
.route-loading {
|
||||||
color: #93cfc4;
|
color: #93cfc4;
|
||||||
letter-spacing: .12em;
|
letter-spacing: 0.12em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card {
|
.auth-card {
|
||||||
@@ -607,13 +721,13 @@ select {
|
|||||||
color: #dffff7;
|
color: #dffff7;
|
||||||
font-size: clamp(28px, 5vw, 44px);
|
font-size: clamp(28px, 5vw, 44px);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: .04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-card footer {
|
.auth-card footer {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding-top: 16px;
|
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 {
|
.auth-card footer p {
|
||||||
@@ -634,11 +748,11 @@ select {
|
|||||||
height: 54px;
|
height: 54px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid rgba(124, 239, 216, .32);
|
border: 1px solid rgba(124, 239, 216, 0.32);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
color: rgba(190, 255, 241, .8);
|
color: rgba(190, 255, 241, 0.8);
|
||||||
background: radial-gradient(circle, rgba(62, 173, 155, .23), transparent 72%);
|
background: radial-gradient(circle, rgba(62, 173, 155, 0.23), transparent 72%);
|
||||||
box-shadow: 0 0 30px rgba(80, 216, 191, .14);
|
box-shadow: 0 0 30px rgba(80, 216, 191, 0.14);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-lead {
|
.auth-lead {
|
||||||
@@ -676,12 +790,14 @@ select {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(75, 157, 151, .55);
|
border: 1px solid rgba(75, 157, 151, 0.55);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: #edfffb;
|
color: #edfffb;
|
||||||
background: rgba(3, 18, 26, .82);
|
background: rgba(3, 18, 26, 0.82);
|
||||||
transition: border-color .18s ease, box-shadow .18s ease;
|
transition:
|
||||||
|
border-color 0.18s ease,
|
||||||
|
box-shadow 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stack-form input:focus,
|
.stack-form input:focus,
|
||||||
@@ -690,14 +806,14 @@ select {
|
|||||||
.secret-address input:focus,
|
.secret-address input:focus,
|
||||||
.one-time-secret input:focus {
|
.one-time-secret input:focus {
|
||||||
border-color: #6ee8ce;
|
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 {
|
.otp-input {
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
|
||||||
font-size: 22px !important;
|
font-size: 22px !important;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: .35em;
|
letter-spacing: 0.35em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,12 +826,15 @@ select {
|
|||||||
border-radius: 9px;
|
border-radius: 9px;
|
||||||
color: #05241f;
|
color: #05241f;
|
||||||
background: linear-gradient(135deg, #86f2d8, #50cdb2);
|
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;
|
font-weight: 700;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
cursor: pointer;
|
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),
|
.auth-page button:hover:not(:disabled),
|
||||||
@@ -728,23 +847,23 @@ select {
|
|||||||
.auth-page button:disabled,
|
.auth-page button:disabled,
|
||||||
.dashboard-shell button:disabled {
|
.dashboard-shell button:disabled {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
filter: saturate(.45);
|
filter: saturate(0.45);
|
||||||
opacity: .65;
|
opacity: 0.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-page button.secondary,
|
.auth-page button.secondary,
|
||||||
.dashboard-shell button.secondary,
|
.dashboard-shell button.secondary,
|
||||||
.dashboard-shell .ghost-button {
|
.dashboard-shell .ghost-button {
|
||||||
border-color: rgba(91, 189, 176, .26);
|
border-color: rgba(91, 189, 176, 0.26);
|
||||||
color: #cffff4;
|
color: #cffff4;
|
||||||
background: rgba(16, 67, 76, .62);
|
background: rgba(16, 67, 76, 0.62);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-shell button.danger {
|
.dashboard-shell button.danger {
|
||||||
border-color: rgba(255, 138, 156, .3);
|
border-color: rgba(255, 138, 156, 0.3);
|
||||||
color: #ffd7dd;
|
color: #ffd7dd;
|
||||||
background: rgba(102, 32, 48, .58);
|
background: rgba(102, 32, 48, 0.58);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -773,32 +892,32 @@ select {
|
|||||||
|
|
||||||
.recovery-input {
|
.recovery-input {
|
||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace !important;
|
||||||
letter-spacing: .08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
padding: 11px 13px;
|
padding: 11px 13px;
|
||||||
border: 1px solid rgba(121, 222, 205, .24);
|
border: 1px solid rgba(121, 222, 205, 0.24);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #c9f8ee;
|
color: #c9f8ee;
|
||||||
background: rgba(17, 73, 76, .42);
|
background: rgba(17, 73, 76, 0.42);
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice.error {
|
.notice.error {
|
||||||
border-color: rgba(255, 133, 151, .36);
|
border-color: rgba(255, 133, 151, 0.36);
|
||||||
color: #ffd6dc;
|
color: #ffd6dc;
|
||||||
background: rgba(103, 31, 47, .45);
|
background: rgba(103, 31, 47, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice.warning {
|
.notice.warning {
|
||||||
border-color: rgba(255, 213, 127, .34);
|
border-color: rgba(255, 213, 127, 0.34);
|
||||||
color: #ffe4aa;
|
color: #ffe4aa;
|
||||||
background: rgba(93, 67, 24, .4);
|
background: rgba(93, 67, 24, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice.success {
|
.notice.success {
|
||||||
border-color: rgba(105, 239, 196, .34);
|
border-color: rgba(105, 239, 196, 0.34);
|
||||||
color: #baffea;
|
color: #baffea;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -849,10 +968,10 @@ select {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
border: 1px solid rgba(106, 216, 199, .2);
|
border: 1px solid rgba(106, 216, 199, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #e8fff9;
|
color: #e8fff9;
|
||||||
background: rgba(0, 12, 19, .66);
|
background: rgba(0, 12, 19, 0.66);
|
||||||
}
|
}
|
||||||
|
|
||||||
.compact-form {
|
.compact-form {
|
||||||
@@ -869,12 +988,12 @@ select {
|
|||||||
|
|
||||||
.recovery-grid code {
|
.recovery-grid code {
|
||||||
padding: 11px;
|
padding: 11px;
|
||||||
border: 1px solid rgba(104, 219, 200, .2);
|
border: 1px solid rgba(104, 219, 200, 0.2);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #e8fff9;
|
color: #e8fff9;
|
||||||
background: rgba(0, 13, 20, .7);
|
background: rgba(0, 13, 20, 0.7);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
letter-spacing: .08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-actions {
|
.form-actions {
|
||||||
@@ -899,9 +1018,8 @@ select {
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
color: var(--app-text);
|
color: var(--app-text);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 88% 8%, rgba(30, 119, 114, .17), transparent 28%),
|
radial-gradient(circle at 88% 8%, rgba(30, 119, 114, 0.17), transparent 28%),
|
||||||
radial-gradient(circle at 8% 80%, rgba(58, 83, 126, .12), transparent 32%),
|
radial-gradient(circle at 8% 80%, rgba(58, 83, 126, 0.12), transparent 32%), var(--app-bg);
|
||||||
var(--app-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar {
|
.dashboard-topbar {
|
||||||
@@ -914,8 +1032,8 @@ select {
|
|||||||
grid-template-columns: minmax(210px, 1fr) auto minmax(210px, 1fr);
|
grid-template-columns: minmax(210px, 1fr) auto minmax(210px, 1fr);
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid rgba(84, 178, 166, .16);
|
border-bottom: 1px solid rgba(84, 178, 166, 0.16);
|
||||||
background: rgba(3, 18, 26, .84);
|
background: rgba(3, 18, 26, 0.84);
|
||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -933,9 +1051,9 @@ select {
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid rgba(111, 232, 210, .35);
|
border: 1px solid rgba(111, 232, 210, 0.35);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(34, 116, 107, .22);
|
background: rgba(34, 116, 107, 0.22);
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand div,
|
.brand div,
|
||||||
@@ -949,16 +1067,16 @@ select {
|
|||||||
color: #689b94;
|
color: #689b94;
|
||||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
letter-spacing: .12em;
|
letter-spacing: 0.12em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar nav {
|
.dashboard-topbar nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
border: 1px solid rgba(89, 181, 169, .14);
|
border: 1px solid rgba(89, 181, 169, 0.14);
|
||||||
border-radius: 11px;
|
border-radius: 11px;
|
||||||
background: rgba(3, 20, 28, .54);
|
background: rgba(3, 20, 28, 0.54);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-topbar nav a {
|
.dashboard-topbar nav a {
|
||||||
@@ -970,7 +1088,7 @@ select {
|
|||||||
|
|
||||||
.dashboard-topbar nav a.active {
|
.dashboard-topbar nav a.active {
|
||||||
color: #e4fff9;
|
color: #e4fff9;
|
||||||
background: rgba(49, 132, 120, .35);
|
background: rgba(49, 132, 120, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-menu {
|
.account-menu {
|
||||||
@@ -999,9 +1117,9 @@ select {
|
|||||||
.auth-page .pwa-action {
|
.auth-page .pwa-action {
|
||||||
min-height: 34px;
|
min-height: 34px;
|
||||||
padding: 6px 11px;
|
padding: 6px 11px;
|
||||||
border-color: rgba(95, 215, 193, .28);
|
border-color: rgba(95, 215, 193, 0.28);
|
||||||
color: #cffff4;
|
color: #cffff4;
|
||||||
background: rgba(16, 67, 76, .72);
|
background: rgba(16, 67, 76, 0.72);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@@ -1012,10 +1130,10 @@ select {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid rgba(255, 202, 112, .3);
|
border: 1px solid rgba(255, 202, 112, 0.3);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
color: #ffe2a7;
|
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-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1026,29 +1144,31 @@ select {
|
|||||||
height: 7px;
|
height: 7px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #ffca70;
|
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 {
|
.dashboard-shell .pwa-action {
|
||||||
min-height: 30px;
|
min-height: 30px;
|
||||||
padding: 5px 10px;
|
padding: 5px 10px;
|
||||||
border-color: rgba(95, 215, 193, .28);
|
border-color: rgba(95, 215, 193, 0.28);
|
||||||
color: #cffff4;
|
color: #cffff4;
|
||||||
background: rgba(16, 67, 76, .72);
|
background: rgba(16, 67, 76, 0.72);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-shell .pwa-action.update {
|
.dashboard-shell .pwa-action.update {
|
||||||
border-color: rgba(255, 222, 143, .4);
|
border-color: rgba(255, 222, 143, 0.4);
|
||||||
color: #ffe8b7;
|
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;
|
animation: pwa-update-glow 2.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pwa-update-glow {
|
@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) {
|
@media (display-mode: standalone) {
|
||||||
@@ -1110,8 +1230,8 @@ select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.component-list button.selected {
|
.component-list button.selected {
|
||||||
border-color: rgba(91, 211, 190, .23);
|
border-color: rgba(91, 211, 190, 0.23);
|
||||||
background: linear-gradient(110deg, rgba(38, 126, 114, .35), rgba(17, 69, 76, .3));
|
background: linear-gradient(110deg, rgba(38, 126, 114, 0.35), rgba(17, 69, 76, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.component-list button > span:nth-child(2) {
|
.component-list button > span:nth-child(2) {
|
||||||
@@ -1137,10 +1257,10 @@ select {
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border: 1px solid rgba(106, 225, 203, .2);
|
border: 1px solid rgba(106, 225, 203, 0.2);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #a8f0df;
|
color: #a8f0df;
|
||||||
background: rgba(45, 135, 121, .2);
|
background: rgba(45, 135, 121, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.component-list i {
|
.component-list i {
|
||||||
@@ -1152,7 +1272,7 @@ select {
|
|||||||
|
|
||||||
.component-list i.enabled {
|
.component-list i.enabled {
|
||||||
background: #72ebc9;
|
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 {
|
.future-components {
|
||||||
@@ -1160,7 +1280,7 @@ select {
|
|||||||
padding-top: 15px;
|
padding-top: 15px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
border-top: 1px solid rgba(94, 187, 173, .12);
|
border-top: 1px solid rgba(94, 187, 173, 0.12);
|
||||||
color: #567d78;
|
color: #567d78;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,24 +1308,24 @@ select {
|
|||||||
.status-chip {
|
.status-chip {
|
||||||
width: max-content;
|
width: max-content;
|
||||||
padding: 5px 9px;
|
padding: 5px 9px;
|
||||||
border: 1px solid rgba(112, 197, 186, .2);
|
border: 1px solid rgba(112, 197, 186, 0.2);
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
color: #91bcb6;
|
color: #91bcb6;
|
||||||
background: rgba(17, 60, 66, .44);
|
background: rgba(17, 60, 66, 0.44);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-chip.online {
|
.status-chip.online {
|
||||||
border-color: rgba(99, 236, 199, .3);
|
border-color: rgba(99, 236, 199, 0.3);
|
||||||
color: #99f3d8;
|
color: #99f3d8;
|
||||||
background: rgba(31, 105, 87, .35);
|
background: rgba(31, 105, 87, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-chip.offline {
|
.status-chip.offline {
|
||||||
border-color: rgba(240, 151, 159, .25);
|
border-color: rgba(240, 151, 159, 0.25);
|
||||||
color: #e9aeb4;
|
color: #e9aeb4;
|
||||||
background: rgba(91, 39, 48, .34);
|
background: rgba(91, 39, 48, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-panel {
|
.dashboard-panel {
|
||||||
@@ -1251,7 +1371,7 @@ select {
|
|||||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-grid input[type="range"] {
|
.slider-grid input[type='range'] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
accent-color: #63ddc1;
|
accent-color: #63ddc1;
|
||||||
}
|
}
|
||||||
@@ -1262,7 +1382,7 @@ select {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
border: 1px solid rgba(89, 179, 167, .18);
|
border: 1px solid rgba(89, 179, 167, 0.18);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1381,9 +1501,9 @@ select {
|
|||||||
padding: 17px;
|
padding: 17px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 11px;
|
gap: 11px;
|
||||||
border: 1px solid rgba(255, 215, 127, .28);
|
border: 1px solid rgba(255, 215, 127, 0.28);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(86, 64, 24, .24);
|
background: rgba(86, 64, 24, 0.24);
|
||||||
}
|
}
|
||||||
|
|
||||||
.one-time-secret > b {
|
.one-time-secret > b {
|
||||||
@@ -1408,7 +1528,7 @@ table {
|
|||||||
th,
|
th,
|
||||||
td {
|
td {
|
||||||
padding: 12px 10px;
|
padding: 12px 10px;
|
||||||
border-bottom: 1px solid rgba(85, 169, 158, .12);
|
border-bottom: 1px solid rgba(85, 169, 158, 0.12);
|
||||||
color: #a9d3cc;
|
color: #a9d3cc;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1426,10 +1546,10 @@ td:last-child {
|
|||||||
|
|
||||||
.obs-configuration-error {
|
.obs-configuration-error {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(255, 143, 157, .42);
|
border: 1px solid rgba(255, 143, 157, 0.42);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #ffe1e5;
|
color: #ffe1e5;
|
||||||
background: rgba(72, 24, 37, .82);
|
background: rgba(72, 24, 37, 0.82);
|
||||||
font-size: clamp(13px, 3vw, 17px);
|
font-size: clamp(13px, 3vw, 17px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+531
-154
File diff suppressed because it is too large
Load Diff
@@ -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 { useCallback, useEffect, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
import { ApiError, api, errorMessage, json, normalizeSession } from './api'
|
||||||
@@ -20,11 +28,15 @@ function NotFoundPage() {
|
|||||||
return (
|
return (
|
||||||
<main className="auth-page">
|
<main className="auth-page">
|
||||||
<section className="auth-card jade-panel not-found">
|
<section className="auth-card jade-panel not-found">
|
||||||
<div className="auth-mark" aria-hidden="true">云</div>
|
<div className="auth-mark" aria-hidden="true">
|
||||||
|
云
|
||||||
|
</div>
|
||||||
<p className="eyebrow">404 · LOST IN THE CLOUDS</p>
|
<p className="eyebrow">404 · LOST IN THE CLOUDS</p>
|
||||||
<h1>这里没有组件</h1>
|
<h1>这里没有组件</h1>
|
||||||
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p>
|
<p className="auth-lead">地址可能已经失效,或者组件已被所属用户删除。</p>
|
||||||
<a className="button-link" href="/control/">返回控制台</a>
|
<a className="button-link" href="/control/">
|
||||||
|
返回控制台
|
||||||
|
</a>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -85,9 +97,15 @@ function App() {
|
|||||||
<PwaControls />
|
<PwaControls />
|
||||||
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
|
<p className="eyebrow">{offline ? 'OFFLINE SHELL' : 'CONNECTION ERROR'}</p>
|
||||||
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
|
<h1>{offline ? '控制台目前处于离线状态' : '云台暂时无法连接'}</h1>
|
||||||
{offline && <p className="auth-lead">应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。</p>}
|
{offline && (
|
||||||
|
<p className="auth-lead">
|
||||||
|
应用外壳已离线打开,但账户、直播源和组件数据不会缓存。联网后即可重新验证会话。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="notice error">{loadError}</div>
|
<div className="notice error">{loadError}</div>
|
||||||
<button type="button" disabled={offline} onClick={() => void refreshSession()}>重新连接</button>
|
<button type="button" disabled={offline} onClick={() => void refreshSession()}>
|
||||||
|
重新连接
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
@@ -124,7 +142,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
if (path === '/control/invitations') {
|
if (path === '/control/invitations') {
|
||||||
if (!session.user) return <Redirect to="/control/login" />
|
if (!session.user) return <Redirect to="/control/login" />
|
||||||
if (session.user.role !== 'system_admin') return <ForbiddenPage user={session.user} onLogout={logout} />
|
if (session.user.role !== 'system_admin')
|
||||||
|
return <ForbiddenPage user={session.user} onLogout={logout} />
|
||||||
return <InvitationsPage user={session.user} onLogout={logout} />
|
return <InvitationsPage user={session.user} onLogout={logout} />
|
||||||
}
|
}
|
||||||
return <NotFoundPage />
|
return <NotFoundPage />
|
||||||
|
|||||||
+124
-47
@@ -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 { useEffect, useRef, useState } from 'react'
|
||||||
import type { Dispatch, SetStateAction } from 'react'
|
import type { Dispatch, SetStateAction } from 'react'
|
||||||
import { defaultOverlaySettings } from './types'
|
import { defaultOverlaySettings } from './types'
|
||||||
@@ -55,7 +64,20 @@ type OverlayProps = {
|
|||||||
accessToken?: string
|
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
|
const decorVariantCount = 6
|
||||||
|
|
||||||
function stableHash(value: string) {
|
function stableHash(value: string) {
|
||||||
@@ -95,21 +117,27 @@ function streamUrl(publicId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function enabled(type: string, settings: OverlaySettings) {
|
function enabled(type: string, settings: OverlaySettings) {
|
||||||
return (type === 'live.danmaku' && settings.showDanmaku)
|
return (
|
||||||
|| (type === 'live.enter' && settings.showEnter)
|
(type === 'live.danmaku' && settings.showDanmaku) ||
|
||||||
|| (type.startsWith('live.gift') && settings.showGift)
|
(type === 'live.enter' && settings.showEnter) ||
|
||||||
|| (type === 'live.superchat' && settings.showSuperchat)
|
(type.startsWith('live.gift') && settings.showGift) ||
|
||||||
|| (type === 'live.guard.buy' && settings.showGuard)
|
(type === 'live.superchat' && settings.showSuperchat) ||
|
||||||
|| (type === 'live.like' && settings.showLike)
|
(type === 'live.guard.buy' && settings.showGuard) ||
|
||||||
|| (type === 'live.share' && settings.showShare)
|
(type === 'live.like' && settings.showLike) ||
|
||||||
|
(type === 'live.share' && settings.showShare)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSettings(value: unknown): OverlaySettings {
|
function parseSettings(value: unknown): OverlaySettings {
|
||||||
if (!value || typeof value !== 'object') return defaultOverlaySettings
|
if (!value || typeof value !== 'object') return defaultOverlaySettings
|
||||||
return { ...defaultOverlaySettings, ...value as Partial<OverlaySettings> }
|
return { ...defaultOverlaySettings, ...(value as Partial<OverlaySettings>) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function useEvents(disabled: boolean, publicId?: string, accessToken?: string): {
|
function useEvents(
|
||||||
|
disabled: boolean,
|
||||||
|
publicId?: string,
|
||||||
|
accessToken?: string,
|
||||||
|
): {
|
||||||
settings: OverlaySettings
|
settings: OverlaySettings
|
||||||
items: Item[]
|
items: Item[]
|
||||||
setItems: Dispatch<SetStateAction<Item[]>>
|
setItems: Dispatch<SetStateAction<Item[]>>
|
||||||
@@ -117,7 +145,9 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
|
|||||||
} {
|
} {
|
||||||
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
|
const [settings, setSettings] = useState<OverlaySettings>(defaultOverlaySettings)
|
||||||
const [items, setItems] = useState<Item[]>([])
|
const [items, setItems] = useState<Item[]>([])
|
||||||
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>('idle')
|
const [connection, setConnection] = useState<'idle' | 'connecting' | 'connected' | 'denied'>(
|
||||||
|
'idle',
|
||||||
|
)
|
||||||
const settingsRef = useRef(settings)
|
const settingsRef = useRef(settings)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -142,7 +172,7 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
|
|||||||
retries = 0
|
retries = 0
|
||||||
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
|
socket?.send(JSON.stringify({ type: 'authenticate', token: accessToken }))
|
||||||
}
|
}
|
||||||
socket.onclose = (event) => {
|
socket.onclose = event => {
|
||||||
if (dead) return
|
if (dead) return
|
||||||
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
|
if (event.code === 1008 || event.code === 4401 || event.code === 4403) {
|
||||||
setConnection('denied')
|
setConnection('denied')
|
||||||
@@ -166,7 +196,10 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setConnection('connected')
|
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))
|
setSettings(parseSettings(envelope.payload?.settings))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -176,10 +209,13 @@ function useEvents(disabled: boolean, publicId?: string, accessToken?: string):
|
|||||||
const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId
|
const combo = envelope.type === 'live.gift.combo' && envelope.payload?.comboId
|
||||||
const key = combo ? `combo:${combo}` : envelope.id
|
const key = combo ? `combo:${combo}` : envelope.id
|
||||||
const existing = old.find(item => item.key === key)
|
const existing = old.find(item => item.key === key)
|
||||||
const decorVariant = existing?.decorVariant
|
const decorVariant =
|
||||||
?? chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
|
existing?.decorVariant ??
|
||||||
return [{ ...envelope, key, received: Date.now(), decorVariant }, ...old.filter(item => item.key !== key)]
|
chooseDecorVariant(`${envelope.type}:${key}`, old[0]?.decorVariant)
|
||||||
.slice(0, current.maxVisible)
|
return [
|
||||||
|
{ ...envelope, key, received: Date.now(), decorVariant },
|
||||||
|
...old.filter(item => item.key !== key),
|
||||||
|
].slice(0, current.maxVisible)
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
// A malformed upstream event must not break a long-running OBS source.
|
// A malformed upstream event must not break a long-running OBS source.
|
||||||
@@ -239,49 +275,75 @@ function DanmakuEmoticon({ segment }: { segment: Extract<DanmakuSegment, { type:
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DanmakuBody({ payload }: { payload: LivePayload }) {
|
function DanmakuBody({ payload }: { payload: LivePayload }) {
|
||||||
const segments = Array.isArray(payload.segments) ? payload.segments as DanmakuSegment[] : undefined
|
const segments = Array.isArray(payload.segments)
|
||||||
|
? (payload.segments as DanmakuSegment[])
|
||||||
|
: undefined
|
||||||
if (!segments?.length) return <>{payload.text || ''}</>
|
if (!segments?.length) return <>{payload.text || ''}</>
|
||||||
return <>{segments.map((segment, index) => segment.type === 'emoticon' && segment.url
|
return (
|
||||||
? <DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
|
<>
|
||||||
: <span className="danmaku-text" key={`text:${index}`}>{segment.text}</span>)}</>
|
{segments.map((segment, index) =>
|
||||||
|
segment.type === 'emoticon' && segment.url ? (
|
||||||
|
<DanmakuEmoticon segment={segment} key={`${segment.unique || segment.url}:${index}`} />
|
||||||
|
) : (
|
||||||
|
<span className="danmaku-text" key={`text:${index}`}>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 payload = item.payload || {}
|
||||||
const viewer = payload.viewer || {}
|
const viewer = payload.viewer || {}
|
||||||
const gift = payload.gift
|
const gift = payload.gift
|
||||||
const isDanmaku = item.type === 'live.danmaku'
|
const isDanmaku = item.type === 'live.danmaku'
|
||||||
const tier = gift ? giftTier(item, settings) : ''
|
const tier = gift ? giftTier(item, settings) : ''
|
||||||
const body = gift
|
const body = gift ? (
|
||||||
? `献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
|
`献上 ${gift.name || '礼物'} ×${payload.quantity || 1}`
|
||||||
: isDanmaku
|
) : isDanmaku ? (
|
||||||
? <DanmakuBody payload={payload} />
|
<DanmakuBody payload={payload} />
|
||||||
: eventRenderers[item.type]?.(payload) || '送来了一份互动'
|
) : (
|
||||||
|
eventRenderers[item.type]?.(payload) || '送来了一份互动'
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}>
|
<article
|
||||||
|
className={`card ${gift ? 'gift' : ''} ${isDanmaku ? 'danmaku' : ''} ${expanded ? 'expanded' : 'compact'} ${tier}`}
|
||||||
|
>
|
||||||
<CardDecor count={settings.particleCount} variant={item.decorVariant} />
|
<CardDecor count={settings.particleCount} variant={item.decorVariant} />
|
||||||
{gift && (
|
{gift && (
|
||||||
<div className="gift-art">
|
<div className="gift-art">
|
||||||
{gift.animationUrl || gift.imageUrl
|
{gift.animationUrl || gift.imageUrl ? (
|
||||||
? (
|
<img
|
||||||
<img
|
src={gift.animationUrl || gift.imageUrl}
|
||||||
src={gift.animationUrl || gift.imageUrl}
|
alt={gift.name || '礼物'}
|
||||||
alt={gift.name || '礼物'}
|
onError={event => {
|
||||||
onError={event => {
|
const image = event.currentTarget
|
||||||
const image = event.currentTarget
|
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
|
||||||
if (gift.imageUrl && image.src !== gift.imageUrl) image.src = gift.imageUrl
|
else image.style.display = 'none'
|
||||||
else image.style.display = 'none'
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
) : (
|
||||||
)
|
<span>✦</span>
|
||||||
: <span>✦</span>}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="copy">
|
<div className="copy">
|
||||||
<b>{viewer.name || '直播间观众'}</b>
|
<b>{viewer.name || '直播间观众'}</b>
|
||||||
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
|
<span className={isDanmaku ? 'danmaku-content' : undefined}>{body}</span>
|
||||||
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && <em>¥ {gift.priceCny.toFixed(2)}</em>}
|
{typeof gift?.priceCny === 'number' && gift.priceCny > 0 && (
|
||||||
|
<em>¥ {gift.priceCny.toFixed(2)}</em>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
|
{tier === 'featured' && <div className="particles">✦ ✧ ✦</div>}
|
||||||
</article>
|
</article>
|
||||||
@@ -327,7 +389,13 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
|
|||||||
payload: {
|
payload: {
|
||||||
viewer: { name: '星光旅人' },
|
viewer: { name: '星光旅人' },
|
||||||
quantity: 1,
|
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)
|
setExpandedKey(newest.key)
|
||||||
const densityFactor = shape === 'short' ? 0.6 : 1
|
const densityFactor = shape === 'short' ? 0.6 : 1
|
||||||
const timer = window.setTimeout(
|
const timer = window.setTimeout(
|
||||||
() => setExpandedKey(key => key === newest.key ? undefined : key),
|
() => setExpandedKey(key => (key === newest.key ? undefined : key)),
|
||||||
settings.collapseAfterSeconds * 1000 * densityFactor,
|
settings.collapseAfterSeconds * 1000 * densityFactor,
|
||||||
)
|
)
|
||||||
return () => window.clearTimeout(timer)
|
return () => window.clearTimeout(timer)
|
||||||
@@ -365,11 +433,20 @@ export function Overlay({ preview = false, previewSettings, publicId, accessToke
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<section className="wall">
|
<section className="wall">
|
||||||
{missingAccess && <div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>}
|
{missingAccess && (
|
||||||
{!missingAccess && events.connection === 'denied' && <div className="obs-configuration-error">OBS 访问令牌已失效。</div>}
|
<div className="obs-configuration-error">OBS 地址不完整,请从控制台重新复制。</div>
|
||||||
|
)}
|
||||||
|
{!missingAccess && events.connection === 'denied' && (
|
||||||
|
<div className="obs-configuration-error">OBS 访问令牌已失效。</div>
|
||||||
|
)}
|
||||||
<div className="cards">
|
<div className="cards">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
<Card item={item} settings={settings} expanded={item.key === expandedKey} key={item.key} />
|
<Card
|
||||||
|
item={item}
|
||||||
|
settings={settings}
|
||||||
|
expanded={item.key === expandedKey}
|
||||||
|
key={item.key}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+64
-27
@@ -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'
|
import { useEffect, useSyncExternalStore } from 'react'
|
||||||
|
|
||||||
interface InstallChoice {
|
interface InstallChoice {
|
||||||
@@ -58,9 +67,10 @@ function addManifest() {
|
|||||||
function observeRegistration(registration: ServiceWorkerRegistration) {
|
function observeRegistration(registration: ServiceWorkerRegistration) {
|
||||||
emit({
|
emit({
|
||||||
registration,
|
registration,
|
||||||
waitingWorker: registration.waiting && navigator.serviceWorker.controller
|
waitingWorker:
|
||||||
? registration.waiting
|
registration.waiting && navigator.serviceWorker.controller
|
||||||
: snapshot.waitingWorker,
|
? registration.waiting
|
||||||
|
: snapshot.waitingWorker,
|
||||||
})
|
})
|
||||||
|
|
||||||
registration.addEventListener('updatefound', () => {
|
registration.addEventListener('updatefound', () => {
|
||||||
@@ -76,18 +86,23 @@ function observeRegistration(registration: ServiceWorkerRegistration) {
|
|||||||
|
|
||||||
async function removeLegacyRootWorker() {
|
async function removeLegacyRootWorker() {
|
||||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||||
await Promise.all(registrations.map(async registration => {
|
await Promise.all(
|
||||||
const scopePath = new URL(registration.scope).pathname
|
registrations.map(async registration => {
|
||||||
const workers = [registration.installing, registration.waiting, registration.active]
|
const scopePath = new URL(registration.scope).pathname
|
||||||
const isLegacy = scopePath === '/'
|
const workers = [registration.installing, registration.waiting, registration.active]
|
||||||
&& workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
|
const isLegacy =
|
||||||
if (isLegacy) await registration.unregister()
|
scopePath === '/' &&
|
||||||
}))
|
workers.some(worker => worker && new URL(worker.scriptURL).pathname === '/sw.js')
|
||||||
|
if (isLegacy) await registration.unregister()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const cacheNames = await caches.keys()
|
const cacheNames = await caches.keys()
|
||||||
await Promise.all(cacheNames
|
await Promise.all(
|
||||||
.filter(name => name.startsWith('lxc-control-shell-'))
|
cacheNames
|
||||||
.map(name => caches.delete(name)))
|
.filter(name => name.startsWith('lxc-control-shell-'))
|
||||||
|
.map(name => caches.delete(name)),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cleanupLegacyPwa() {
|
export function cleanupLegacyPwa() {
|
||||||
@@ -121,18 +136,25 @@ export function initializePwa() {
|
|||||||
const updateDisplayMode = () => emit({ standalone: isStandalone() })
|
const updateDisplayMode = () => emit({ standalone: isStandalone() })
|
||||||
window.addEventListener('online', updateConnection)
|
window.addEventListener('online', updateConnection)
|
||||||
window.addEventListener('offline', updateConnection)
|
window.addEventListener('offline', updateConnection)
|
||||||
const modernListener = (displayMode as unknown as {
|
const modernListener = (
|
||||||
addEventListener?: (type: 'change', listener: () => void) => void
|
displayMode as unknown as {
|
||||||
}).addEventListener
|
addEventListener?: (type: 'change', listener: () => void) => void
|
||||||
|
}
|
||||||
|
).addEventListener
|
||||||
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
|
if (modernListener) modernListener.call(displayMode, 'change', updateDisplayMode)
|
||||||
else (displayMode as unknown as { addListener?: (listener: () => void) => void })
|
else
|
||||||
.addListener?.call(displayMode, updateDisplayMode)
|
(displayMode as unknown as { addListener?: (listener: () => void) => void }).addListener?.call(
|
||||||
|
displayMode,
|
||||||
|
updateDisplayMode,
|
||||||
|
)
|
||||||
|
|
||||||
window.addEventListener('beforeinstallprompt', event => {
|
window.addEventListener('beforeinstallprompt', event => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
emit({ installPrompt: event as BeforeInstallPromptEvent })
|
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
|
if (!import.meta.env.PROD || !('serviceWorker' in navigator)) return
|
||||||
|
|
||||||
@@ -151,13 +173,17 @@ export function initializePwa() {
|
|||||||
void snapshot.registration?.update()
|
void snapshot.registration?.update()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
window.setInterval(() => {
|
window.setInterval(
|
||||||
if (navigator.onLine) void snapshot.registration?.update()
|
() => {
|
||||||
}, 60 * 60 * 1000)
|
if (navigator.onLine) void snapshot.registration?.update()
|
||||||
|
},
|
||||||
|
60 * 60 * 1000,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authRoute(name: 'login' | 'register' | 'setup'): string {
|
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}`
|
return inControlScope ? `/control/${name}` : `/${name}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +191,9 @@ export function usePwaUpdateBlocker(key: string, reason: string, active: boolean
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (active) updateBlockers.set(key, reason)
|
if (active) updateBlockers.set(key, reason)
|
||||||
else updateBlockers.delete(key)
|
else updateBlockers.delete(key)
|
||||||
return () => { updateBlockers.delete(key) }
|
return () => {
|
||||||
|
updateBlockers.delete(key)
|
||||||
|
}
|
||||||
}, [active, key, reason])
|
}, [active, key, reason])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,10 +210,14 @@ function applyUpdate() {
|
|||||||
if (!worker) return
|
if (!worker) return
|
||||||
const reasons = [...new Set(updateBlockers.values())]
|
const reasons = [...new Set(updateBlockers.values())]
|
||||||
if (reasons.length > 0) {
|
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
|
return
|
||||||
}
|
}
|
||||||
const confirmed = window.confirm('更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?')
|
const confirmed = window.confirm(
|
||||||
|
'更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?',
|
||||||
|
)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
reloadForUpdate = true
|
reloadForUpdate = true
|
||||||
worker.postMessage({ type: 'SKIP_WAITING' })
|
worker.postMessage({ type: 'SKIP_WAITING' })
|
||||||
@@ -198,7 +230,12 @@ export function PwaControls() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pwa-controls" aria-live="polite">
|
<div className="pwa-controls" aria-live="polite">
|
||||||
{!state.online && <span className="pwa-state offline"><i aria-hidden="true" />离线</span>}
|
{!state.online && (
|
||||||
|
<span className="pwa-state offline">
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
离线
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{state.waitingWorker && (
|
{state.waitingWorker && (
|
||||||
<button type="button" className="pwa-action update" onClick={applyUpdate}>
|
<button type="button" className="pwa-action update" onClick={applyUpdate}>
|
||||||
更新可用
|
更新可用
|
||||||
|
|||||||
+274
-1
@@ -1 +1,274 @@
|
|||||||
*{box-sizing:border-box}html,body,#root{margin:0;width:100%;height:100%;font-family:"Noto Serif SC","Microsoft YaHei",serif}body{background:transparent;color:#dcfffa}.overlay{width:100%;height:100%;padding:clamp(8px,2vw,22px);display:flex;align-items:center;justify-content:flex-end;overflow:hidden;background:radial-gradient(ellipse at 100% 50%,rgba(21,94,96,.2),transparent 62%)}.wall{width:min(100%,480px);display:flex;flex-direction:column;gap:9px;filter:drop-shadow(0 10px 28px rgba(0,11,19,.36))}.cards{display:flex;flex-direction:column;gap:8px}.card{position:relative;min-height:58px;padding:11px 14px;border:1px solid rgba(101,226,211,.28);border-radius:14px;overflow:hidden;display:flex;align-items:center;gap:10px;background:linear-gradient(115deg,rgba(4,34,49,.82),rgba(8,69,72,.61));backdrop-filter:blur(15px);animation:arrive calc(.35s + .35s*var(--motion)) cubic-bezier(.19,.9,.3,1) both}.card:before{content:"";position:absolute;inset:0;background:linear-gradient(105deg,transparent 25%,rgba(155,255,232,.14),transparent 65%);transform:translateX(-120%);animation:sheen calc(2.4s - 1.3s*var(--motion)) ease-in-out .25s both}.copy{position:relative;min-width:0;display:flex;flex-direction:column;gap:3px;font-size:clamp(12px,2.7vw,17px)}.copy b{color:#e6fff9;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy span{color:#a9dbd4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.copy em{font-style:normal;color:#ffdc89;font-size:.84em}.gift-art{z-index:1;width:48px;height:48px;flex:none;border-radius:12px;background:radial-gradient(circle,#2e9f9e,transparent 66%);display:grid;place-items:center}.gift-art img{width:100%;height:100%;object-fit:contain}.gift.high{min-height:72px;border-color:rgba(102,255,229,.65);background:linear-gradient(105deg,rgba(5,59,78,.92),rgba(14,119,103,.7))}.gift.featured{min-height:128px;border-color:#b6ffdc;background:radial-gradient(circle at 20% 50%,rgba(69,236,190,.4),transparent 35%),linear-gradient(118deg,rgba(7,63,84,.97),rgba(12,105,89,.85));animation:featured calc(2.8s - 1.5s*var(--motion)) ease-in-out infinite}.gift.featured .gift-art{width:92px;height:92px}.particles{position:absolute;right:9px;top:6px;color:#ceffe4;letter-spacing:9px;animation:float 2.2s ease-in-out infinite}.narrow .overlay{padding:6px}.narrow .wall{gap:5px}.narrow .card{min-height:45px;padding:7px 9px;border-radius:10px}.narrow .gift-art{width:34px;height:34px}.narrow .gift.featured{min-height:90px;align-items:flex-start}.narrow .gift.featured .gift-art{width:58px;height:58px}.short .cards .card:nth-child(n+4){display:none}.low-motion *{animation:none!important}.control{min-height:100%;padding:clamp(20px,5vw,56px);display:grid;grid-template-columns:minmax(300px,520px) minmax(280px,1fr);gap:36px;background:linear-gradient(135deg,#061923,#092c32 55%,#06151f);color:#d9fff6}.control h1{margin:0;color:#bffff0}.control h2{margin:0}.control p{color:#88bdb5}.control label{display:block;margin:14px 0;color:#bcebe3}.control input:not([type=checkbox]):not([type=range]){width:100%;padding:10px;margin-top:5px;border:1px solid #357e79;border-radius:8px;background:#071d28;color:#e6fff9}.control input[type=range]{margin-left:10px;accent-color:#5ae7c6}.control output{margin-left:8px}.control fieldset{border:1px solid #28645f;border-radius:10px;display:flex;flex-wrap:wrap;gap:3px 12px}.control fieldset label{margin:8px 0}.buttons{display:flex;gap:10px;margin-top:20px}.control button{padding:10px 14px;border:0;border-radius:8px;background:#55dcb9;color:#06211e;font-weight:bold;cursor:pointer}.control button.secondary{background:#173e4b;color:#d4fff7}.preview-frame{height:600px;resize:both;overflow:auto;border:1px dashed #4dafa4;background:linear-gradient(135deg,rgba(71,190,172,.12),transparent)}.login{display:grid;place-content:center;grid-template-columns:360px;background:#071923}.login form{display:flex;flex-direction:column;gap:12px}@keyframes arrive{from{opacity:0;transform:translateX(38px) scale(.96)}to{opacity:1;transform:none}}@keyframes sheen{to{transform:translateX(135%)}}@keyframes featured{50%{filter:brightness(1.18);transform:scale(1.015)}}@keyframes float{50%{transform:translateY(-8px);opacity:.5}}@media(max-width:720px){.control{grid-template-columns:1fr}.preview-frame{height:480px}}
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'Noto Serif SC', 'Microsoft YaHei', serif;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
background: transparent;
|
||||||
|
color: #dcfffa;
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: clamp(8px, 2vw, 22px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
overflow: hidden;
|
||||||
|
background: radial-gradient(ellipse at 100% 50%, rgba(21, 94, 96, 0.2), transparent 62%);
|
||||||
|
}
|
||||||
|
.wall {
|
||||||
|
width: min(100%, 480px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 9px;
|
||||||
|
filter: drop-shadow(0 10px 28px rgba(0, 11, 19, 0.36));
|
||||||
|
}
|
||||||
|
.cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
position: relative;
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border: 1px solid rgba(101, 226, 211, 0.28);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: linear-gradient(115deg, rgba(4, 34, 49, 0.82), rgba(8, 69, 72, 0.61));
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
animation: arrive calc(0.35s + 0.35s * var(--motion)) cubic-bezier(0.19, 0.9, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
.card:before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(105deg, transparent 25%, rgba(155, 255, 232, 0.14), transparent 65%);
|
||||||
|
transform: translateX(-120%);
|
||||||
|
animation: sheen calc(2.4s - 1.3s * var(--motion)) ease-in-out 0.25s both;
|
||||||
|
}
|
||||||
|
.copy {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: clamp(12px, 2.7vw, 17px);
|
||||||
|
}
|
||||||
|
.copy b {
|
||||||
|
color: #e6fff9;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.copy span {
|
||||||
|
color: #a9dbd4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.copy em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #ffdc89;
|
||||||
|
font-size: 0.84em;
|
||||||
|
}
|
||||||
|
.gift-art {
|
||||||
|
z-index: 1;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
flex: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: radial-gradient(circle, #2e9f9e, transparent 66%);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.gift-art img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.gift.high {
|
||||||
|
min-height: 72px;
|
||||||
|
border-color: rgba(102, 255, 229, 0.65);
|
||||||
|
background: linear-gradient(105deg, rgba(5, 59, 78, 0.92), rgba(14, 119, 103, 0.7));
|
||||||
|
}
|
||||||
|
.gift.featured {
|
||||||
|
min-height: 128px;
|
||||||
|
border-color: #b6ffdc;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 50%, rgba(69, 236, 190, 0.4), transparent 35%),
|
||||||
|
linear-gradient(118deg, rgba(7, 63, 84, 0.97), rgba(12, 105, 89, 0.85));
|
||||||
|
animation: featured calc(2.8s - 1.5s * var(--motion)) ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.gift.featured .gift-art {
|
||||||
|
width: 92px;
|
||||||
|
height: 92px;
|
||||||
|
}
|
||||||
|
.particles {
|
||||||
|
position: absolute;
|
||||||
|
right: 9px;
|
||||||
|
top: 6px;
|
||||||
|
color: #ceffe4;
|
||||||
|
letter-spacing: 9px;
|
||||||
|
animation: float 2.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.narrow .overlay {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
.narrow .wall {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.narrow .card {
|
||||||
|
min-height: 45px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.narrow .gift-art {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.narrow .gift.featured {
|
||||||
|
min-height: 90px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.narrow .gift.featured .gift-art {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
}
|
||||||
|
.short .cards .card:nth-child(n + 4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.low-motion * {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
.control {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: clamp(20px, 5vw, 56px);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 520px) minmax(280px, 1fr);
|
||||||
|
gap: 36px;
|
||||||
|
background: linear-gradient(135deg, #061923, #092c32 55%, #06151f);
|
||||||
|
color: #d9fff6;
|
||||||
|
}
|
||||||
|
.control h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #bffff0;
|
||||||
|
}
|
||||||
|
.control h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.control p {
|
||||||
|
color: #88bdb5;
|
||||||
|
}
|
||||||
|
.control label {
|
||||||
|
display: block;
|
||||||
|
margin: 14px 0;
|
||||||
|
color: #bcebe3;
|
||||||
|
}
|
||||||
|
.control input:not([type='checkbox']):not([type='range']) {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
margin-top: 5px;
|
||||||
|
border: 1px solid #357e79;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #071d28;
|
||||||
|
color: #e6fff9;
|
||||||
|
}
|
||||||
|
.control input[type='range'] {
|
||||||
|
margin-left: 10px;
|
||||||
|
accent-color: #5ae7c6;
|
||||||
|
}
|
||||||
|
.control output {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
.control fieldset {
|
||||||
|
border: 1px solid #28645f;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 3px 12px;
|
||||||
|
}
|
||||||
|
.control fieldset label {
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.control button {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #55dcb9;
|
||||||
|
color: #06211e;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.control button.secondary {
|
||||||
|
background: #173e4b;
|
||||||
|
color: #d4fff7;
|
||||||
|
}
|
||||||
|
.preview-frame {
|
||||||
|
height: 600px;
|
||||||
|
resize: both;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px dashed #4dafa4;
|
||||||
|
background: linear-gradient(135deg, rgba(71, 190, 172, 0.12), transparent);
|
||||||
|
}
|
||||||
|
.login {
|
||||||
|
display: grid;
|
||||||
|
place-content: center;
|
||||||
|
grid-template-columns: 360px;
|
||||||
|
background: #071923;
|
||||||
|
}
|
||||||
|
.login form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
@keyframes arrive {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(38px) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes sheen {
|
||||||
|
to {
|
||||||
|
transform: translateX(135%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes featured {
|
||||||
|
50% {
|
||||||
|
filter: brightness(1.18);
|
||||||
|
transform: scale(1.015);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes float {
|
||||||
|
50% {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.control {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.preview-frame {
|
||||||
|
height: 480px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Shared control-console view models.
|
||||||
|
*
|
||||||
|
* These types describe sanitized API responses and editable settings, not raw
|
||||||
|
* database rows or Bilibili packets. Secret fields are optional because the
|
||||||
|
* server normally returns only `*Configured` flags after initial submission.
|
||||||
|
*/
|
||||||
export type OverlaySettings = {
|
export type OverlaySettings = {
|
||||||
fontScale: number
|
fontScale: number
|
||||||
showDanmaku: boolean
|
showDanmaku: boolean
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["DOM", "DOM.Iterable", "ES2022"], "allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "strict": true, "module": "ESNext", "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx" },
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Vite build configuration and PWA asset emission.
|
||||||
|
*
|
||||||
|
* The same per-build identifier is compiled into the browser bundle and the
|
||||||
|
* service worker. This makes an hourly/visibility update check discover a new
|
||||||
|
* deployment while still leaving activation under explicit user control.
|
||||||
|
*/
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Rust 后端
|
||||||
|
|
||||||
|
这是多租户直播组件服务的可复用核心和唯一运行时进程。`src/main.rs` 只处理进程启动;主要行为由 library
|
||||||
|
crate 导出。
|
||||||
|
|
||||||
|
## 模块职责
|
||||||
|
|
||||||
|
| 模块 | 职责 |
|
||||||
|
| ------------- | -------------------------------------------------------- |
|
||||||
|
| `app` | 依赖组装、迁移、事件队列、源启动与旧配置导入 |
|
||||||
|
| `auth` | 邀请码、TOTP、恢复码、会话、secret 加密和组件 token |
|
||||||
|
| `components` | 组件定义、设置版本、订阅、projection 与 handler registry |
|
||||||
|
| `config` | TOML 解析、部署策略校验和旧单用户兼容字段 |
|
||||||
|
| `credentials` | CookieCloud URL 边界与 Bilibili Cookie 提取 |
|
||||||
|
| `db` | PostgreSQL pool、迁移和 RLS tenant context |
|
||||||
|
| `domain` | provider-independent event 与 WebSocket envelope |
|
||||||
|
| `http_api` | REST/WS、会话、权限、same-origin、静态资源与安全响应头 |
|
||||||
|
| `live` | provider trait、Bilibili adapter 与 source supervisor |
|
||||||
|
| `overlay` | 弹幕姬设置及礼物/表情目录 |
|
||||||
|
| `rate_limit` | 匿名登录和 enrollment 滥用限制 |
|
||||||
|
| `realtime` | source event routing 与 component-scoped fanout |
|
||||||
|
| `repository` | PostgreSQL component facade 和热路径缓存同步 |
|
||||||
|
|
||||||
|
## 重要不变量
|
||||||
|
|
||||||
|
- handler 不能信任请求体中的 owner;owner 必须来自 session 或 source context。
|
||||||
|
- tenant table 查询必须在 `Db::set_tenant` 后的事务中执行。
|
||||||
|
- provider 只能输出 canonical、bounded、sanitized `LiveEvent`。
|
||||||
|
- projection 无副作用;可靠业务动作必须使用幂等 handler。
|
||||||
|
- token、邀请码和恢复码只存摘要,TOTP/CookieCloud Secret 只存认证加密密文。
|
||||||
|
- EventHub 的 channel key 是 component ID,不允许增加无权限的全局 receiver。
|
||||||
|
|
||||||
|
## 本地质量检查
|
||||||
|
|
||||||
|
在仓库根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --manifest-path apps/server-rust/Cargo.toml --check
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
第三方 `vendor/blivedm` 不作为本项目风格重写目标;项目只维护保留原始 JSON 所需的小补丁。
|
||||||
|
|
||||||
|
更多设计说明:
|
||||||
|
|
||||||
|
- [总体架构](../../docs/architecture.md)
|
||||||
|
- [组件开发](../../docs/components/README.md)
|
||||||
|
- [实时协议](../../docs/protocol.md)
|
||||||
|
- [安全模型](../../docs/security.md)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
edition = "2024"
|
||||||
|
max_width = 100
|
||||||
|
newline_style = "Unix"
|
||||||
|
use_small_heuristics = "Default"
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Application composition root and process-wide dependencies.
|
||||||
|
//!
|
||||||
|
//! [`AppState::build`] wires the database, authentication service, component
|
||||||
|
//! registry, live-source supervisor and realtime router in dependency order.
|
||||||
|
//! Tenant-owned runtime state stays in PostgreSQL or the source supervisor;
|
||||||
|
//! this module only owns cloneable handles shared by Axum handlers.
|
||||||
|
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
//! Passwordless authentication, enrollment and secret-storage domain service.
|
||||||
|
//!
|
||||||
|
//! This module owns invite redemption, TOTP replay protection, recovery codes,
|
||||||
|
//! session issuance and tenant credential encryption. Raw sessions, recovery
|
||||||
|
//! codes and component tokens are returned only at creation time; persistent
|
||||||
|
//! rows contain hashes or authenticated ciphertext. HTTP-specific cookie and
|
||||||
|
//! origin policy deliberately live in `http_api`, not here.
|
||||||
|
|
||||||
use std::{fmt, sync::Arc, time::Duration};
|
use std::{fmt, sync::Arc, time::Duration};
|
||||||
|
|
||||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Extensible component registry and event-processing contracts.
|
||||||
|
//!
|
||||||
|
//! A component kind supplies a versioned settings definition, a pure browser
|
||||||
|
//! projection and optional durable handlers. The live provider and router know
|
||||||
|
//! only these traits, so adding a gift wall or song-request component does not
|
||||||
|
//! require branching on component kinds in the ingestion pipeline.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeSet, HashMap},
|
collections::{BTreeSet, HashMap},
|
||||||
error::Error,
|
error::Error,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! TOML configuration loading, validation and legacy bootstrap compatibility.
|
||||||
|
//!
|
||||||
|
//! Deployment-wide policy (bind address, encryption key, allowed CookieCloud
|
||||||
|
//! hosts and timeouts) remains in [`Config`]. Room IDs, credentials and component
|
||||||
|
//! settings become tenant-owned database records after enrollment; the legacy
|
||||||
|
//! TOML fields are import inputs and must not be treated as global live state.
|
||||||
|
|
||||||
use std::{env, fs, net::IpAddr, path::PathBuf};
|
use std::{env, fs, net::IpAddr, path::PathBuf};
|
||||||
|
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD};
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! CookieCloud boundary and Bilibili cookie extraction.
|
||||||
|
//!
|
||||||
|
//! Tenant input reaches an outbound HTTP client only after canonical URL
|
||||||
|
//! validation and exact allow-list matching in the caller. Redirects are
|
||||||
|
//! disabled by the shared client, the synchronization key is encoded as one
|
||||||
|
//! path segment, and only the minimum Bilibili cookie fields leave this module.
|
||||||
|
|
||||||
use reqwest::{
|
use reqwest::{
|
||||||
Url,
|
Url,
|
||||||
header::{REFERER, USER_AGENT},
|
header::{REFERER, USER_AGENT},
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! PostgreSQL pool, migrations and low-level tenant-scoped queries.
|
||||||
|
//!
|
||||||
|
//! Multi-tenant tables use PostgreSQL row-level security in addition to owner
|
||||||
|
//! columns and composite foreign keys. Every tenant query must execute inside a
|
||||||
|
//! transaction after [`Db::set_tenant`], which uses `SET LOCAL` so pooled
|
||||||
|
//! connections cannot retain the previous request's identity.
|
||||||
|
|
||||||
use std::{fmt, str::FromStr};
|
use std::{fmt, str::FromStr};
|
||||||
|
|
||||||
use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime};
|
use deadpool_postgres::{Manager, ManagerConfig, Object, Pool, RecyclingMethod, Runtime};
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Provider-independent live events and the versioned component wire envelope.
|
||||||
|
//!
|
||||||
|
//! Provider adapters normalize platform packets into [`LiveEvent`]. Components
|
||||||
|
//! subscribe to stable [`LiveEventKind`] values and project them to
|
||||||
|
//! [`ComponentMessage`], keeping Bilibili command names and raw payloads out of
|
||||||
|
//! browser contracts and future provider implementations.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
|
//! Axum HTTP/WebSocket adapter and public trust boundary.
|
||||||
|
//!
|
||||||
|
//! Handlers derive the tenant from a server-side session cookie; owner IDs from
|
||||||
|
//! request bodies are never trusted. This module also enforces same-origin
|
||||||
|
//! mutation checks, response cache policy, component-token WebSocket
|
||||||
|
//! authentication, bounded request bodies and security headers for the static
|
||||||
|
//! control console and OBS entry point.
|
||||||
|
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
@@ -326,7 +334,7 @@ async fn login(
|
|||||||
Ok(result) => result,
|
Ok(result) => result,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
state.login_limiter.failure(&body.username, &ip).await;
|
state.login_limiter.failure(&body.username, &ip).await;
|
||||||
return Err(ApiError::from(error).as_generic_login());
|
return Err(ApiError::from(error).into_generic_login());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
state.login_limiter.success(&body.username, &ip).await;
|
state.login_limiter.success(&body.username, &ip).await;
|
||||||
@@ -1146,7 +1154,7 @@ impl ApiError {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn as_generic_login(mut self) -> Self {
|
fn into_generic_login(mut self) -> Self {
|
||||||
if self.status != StatusCode::TOO_MANY_REQUESTS {
|
if self.status != StatusCode::TOO_MANY_REQUESTS {
|
||||||
self.status = StatusCode::UNAUTHORIZED;
|
self.status = StatusCode::UNAUTHORIZED;
|
||||||
self.code = "invalid_credentials";
|
self.code = "invalid_credentials";
|
||||||
@@ -1170,10 +1178,10 @@ impl IntoResponse for ApiError {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response();
|
.into_response();
|
||||||
if let Some(retry_after) = self.retry_after {
|
if let Some(retry_after) = self.retry_after
|
||||||
if let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string()) {
|
&& let Ok(value) = HeaderValue::from_str(&retry_after.as_secs().max(1).to_string())
|
||||||
response.headers_mut().insert(header::RETRY_AFTER, value);
|
{
|
||||||
}
|
response.headers_mut().insert(header::RETRY_AFTER, value);
|
||||||
}
|
}
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Bilibili `blivedm_rs` adapter and raw-command normalization.
|
||||||
|
//!
|
||||||
|
//! The adapter authenticates with a CookieCloud-derived cookie, refreshes gift
|
||||||
|
//! and emoticon catalogs, and converts supported Bilibili commands into bounded
|
||||||
|
//! domain payloads. Unknown commands expose only sanitized metadata—never the
|
||||||
|
//! original unbounded packet or authentication material.
|
||||||
|
|
||||||
use std::{sync::Arc, time::Duration};
|
use std::{sync::Arc, time::Duration};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -241,11 +248,11 @@ impl LiveProvider for BilibiliProvider {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
while let Ok(message) = upstream_receiver.try_recv() {
|
while let Ok(message) = upstream_receiver.try_recv() {
|
||||||
if let Some(message) = normalize(message) {
|
if let Some(message) = normalize(message)
|
||||||
if raw_sender.blocking_send(message).is_err() {
|
&& raw_sender.blocking_send(message).is_err()
|
||||||
client.close();
|
{
|
||||||
return Ok(());
|
client.close();
|
||||||
}
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,12 +541,12 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
|
|||||||
json_object(object.get("extra")?)
|
json_object(object.get("extra")?)
|
||||||
});
|
});
|
||||||
if let Some(extra) = extra {
|
if let Some(extra) = extra {
|
||||||
if let Some(emoticons) = extra.get("emots").and_then(json_object) {
|
if let Some(emoticons) = extra.get("emots").and_then(json_object)
|
||||||
if let Some(emoticons) = emoticons.as_object() {
|
&& let Some(emoticons) = emoticons.as_object()
|
||||||
for (token, metadata) in emoticons {
|
{
|
||||||
if let Some(hint) = emoticon_hint(metadata, token, false) {
|
for (token, metadata) in emoticons {
|
||||||
hints.push(hint);
|
if let Some(hint) = emoticon_hint(metadata, token, false) {
|
||||||
}
|
hints.push(hint);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -547,22 +554,20 @@ fn parse_danmaku_emoticons(info: &[Value], text: &str) -> Vec<EmoticonHint> {
|
|||||||
.get("emoticon_unique")
|
.get("emoticon_unique")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.filter(|value| !value.is_empty())
|
.filter(|value| !value.is_empty())
|
||||||
{
|
&& !hints
|
||||||
if !hints
|
|
||||||
.iter()
|
.iter()
|
||||||
.any(|hint| hint.unique.as_deref() == Some(unique))
|
.any(|hint| hint.unique.as_deref() == Some(unique))
|
||||||
{
|
{
|
||||||
hints.push(EmoticonHint {
|
hints.push(EmoticonHint {
|
||||||
text: text.to_owned(),
|
text: text.to_owned(),
|
||||||
unique: Some(unique.to_owned()),
|
unique: Some(unique.to_owned()),
|
||||||
url: None,
|
url: None,
|
||||||
width: None,
|
width: None,
|
||||||
height: None,
|
height: None,
|
||||||
is_dynamic: false,
|
is_dynamic: false,
|
||||||
bulge_display: value_is_truthy(extra.get("bulge_display")),
|
bulge_display: value_is_truthy(extra.get("bulge_display")),
|
||||||
standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1),
|
standalone: extra.get("dm_type").and_then(Value::as_i64) == Some(1),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hints
|
hints
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Live-provider abstraction and source lifecycle types.
|
||||||
|
//!
|
||||||
|
//! A provider receives trusted tenant/source context and emits canonical
|
||||||
|
//! [`LiveEvent`] values plus observable connection status. Cancellation and
|
||||||
|
//! restart ownership live in [`supervisor::SourceSupervisor`], keeping provider
|
||||||
|
//! implementations focused on one upstream connection.
|
||||||
|
|
||||||
pub mod bilibili;
|
pub mod bilibili;
|
||||||
pub mod supervisor;
|
pub mod supervisor;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! Per-source task ownership, restart and cancellation.
|
||||||
|
//!
|
||||||
|
//! The supervisor guarantees at most one provider generation for a source ID.
|
||||||
|
//! Reconfiguration cancels the old task before a replacement starts, preventing
|
||||||
|
//! duplicate Bilibili listeners and duplicate downstream events.
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! Executable entry point for the livestream component host.
|
||||||
|
//!
|
||||||
|
//! Keep this file limited to process concerns: configuration, logging, socket
|
||||||
|
//! binding and graceful shutdown. All application behavior belongs in the
|
||||||
|
//! library crate so it can be exercised without starting a real HTTP server.
|
||||||
|
|
||||||
use lxc_stream_server::{app::AppState, config::Config, http_api};
|
use lxc_stream_server::{app::AppState, config::Config, http_api};
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Danmaku-overlay settings plus Bilibili gift and emoticon metadata caches.
|
||||||
|
//!
|
||||||
|
//! Settings are sanitized before persistence or projection. Catalog refreshes
|
||||||
|
//! replace the in-memory snapshot only after a complete valid response, so a
|
||||||
|
//! transient upstream failure preserves the last known gift images, prices and
|
||||||
|
//! emoticon URLs instead of breaking live rendering.
|
||||||
|
|
||||||
use std::{collections::HashMap, sync::Arc};
|
use std::{collections::HashMap, sync::Arc};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -123,16 +130,30 @@ pub struct EmoticonCatalog {
|
|||||||
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
|
by_emoji: Arc<RwLock<HashMap<String, EmoticonMeta>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parser return aliases keep the atomic cache-replacement contract visible:
|
||||||
|
// both lookup maps and their source count are produced before either lock is
|
||||||
|
// updated, so readers never observe a half-refreshed catalog.
|
||||||
|
type GiftCatalogSnapshot = (HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize);
|
||||||
|
type EmoticonCatalogSnapshot = (
|
||||||
|
HashMap<String, EmoticonMeta>,
|
||||||
|
HashMap<String, EmoticonMeta>,
|
||||||
|
usize,
|
||||||
|
);
|
||||||
|
|
||||||
impl EmoticonCatalog {
|
impl EmoticonCatalog {
|
||||||
pub async fn len(&self) -> usize {
|
pub async fn len(&self) -> usize {
|
||||||
self.by_unique.read().await.len()
|
self.by_unique.read().await.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn is_empty(&self) -> bool {
|
||||||
|
self.len().await == 0
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
|
pub async fn get(&self, unique: Option<&str>, emoji: &str) -> Option<EmoticonMeta> {
|
||||||
if let Some(unique) = unique.filter(|value| !value.is_empty()) {
|
if let Some(unique) = unique.filter(|value| !value.is_empty())
|
||||||
if let Some(emoticon) = self.by_unique.read().await.get(unique).cloned() {
|
&& let Some(emoticon) = self.by_unique.read().await.get(unique).cloned()
|
||||||
return Some(emoticon);
|
{
|
||||||
}
|
return Some(emoticon);
|
||||||
}
|
}
|
||||||
self.by_emoji.read().await.get(emoji).cloned()
|
self.by_emoji.read().await.get(emoji).cloned()
|
||||||
}
|
}
|
||||||
@@ -184,11 +205,15 @@ impl GiftCatalog {
|
|||||||
self.by_id.read().await.len()
|
self.by_id.read().await.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn is_empty(&self) -> bool {
|
||||||
|
self.len().await == 0
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
|
pub async fn get(&self, id: Option<i64>, name: &str) -> Option<GiftMeta> {
|
||||||
if let Some(id) = id {
|
if let Some(id) = id
|
||||||
if let Some(gift) = self.by_id.read().await.get(&id).cloned() {
|
&& let Some(gift) = self.by_id.read().await.get(&id).cloned()
|
||||||
return Some(gift);
|
{
|
||||||
}
|
return Some(gift);
|
||||||
}
|
}
|
||||||
self.by_name
|
self.by_name
|
||||||
.read()
|
.read()
|
||||||
@@ -231,9 +256,7 @@ impl GiftCatalog {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_catalog(
|
fn parse_catalog(payload: &Value) -> Result<GiftCatalogSnapshot, String> {
|
||||||
payload: &Value,
|
|
||||||
) -> Result<(HashMap<i64, GiftMeta>, HashMap<String, GiftMeta>, usize), String> {
|
|
||||||
let list = payload
|
let list = payload
|
||||||
.pointer("/data/gift_config/base_config/list")
|
.pointer("/data/gift_config/base_config/list")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
@@ -282,16 +305,7 @@ fn string_field(value: &Value, name: &str) -> Option<String> {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_emoticon_catalog(
|
fn parse_emoticon_catalog(payload: &Value) -> Result<EmoticonCatalogSnapshot, String> {
|
||||||
payload: &Value,
|
|
||||||
) -> Result<
|
|
||||||
(
|
|
||||||
HashMap<String, EmoticonMeta>,
|
|
||||||
HashMap<String, EmoticonMeta>,
|
|
||||||
usize,
|
|
||||||
),
|
|
||||||
String,
|
|
||||||
> {
|
|
||||||
let packages = payload
|
let packages = payload
|
||||||
.pointer("/data/data")
|
.pointer("/data/data")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
//! In-memory abuse limits for anonymous authentication and enrollment routes.
|
||||||
|
//!
|
||||||
|
//! Limits are evaluated across both account and network-derived keys while
|
||||||
|
//! returning one generic result to callers. This reduces brute-force attempts
|
||||||
|
//! without turning timing or error messages into a username-enumeration API.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, VecDeque},
|
collections::{HashMap, VecDeque},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Tenant-aware event routing and component-scoped realtime fanout.
|
||||||
|
//!
|
||||||
|
//! The router resolves enabled instances by owner and source, validates their
|
||||||
|
//! settings/subscriptions, runs durable handlers, then publishes passive
|
||||||
|
//! projections. Each component owns a separate broadcast channel; there is no
|
||||||
|
//! global receiver that could accidentally observe another tenant's events.
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
error::Error,
|
error::Error,
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
//! Repository facade for tenant components, settings and the routing cache.
|
||||||
|
//!
|
||||||
|
//! PostgreSQL is authoritative. Successful writes are reflected into the
|
||||||
|
//! in-process [`InMemoryComponentStore`] used by the hot event path; startup and
|
||||||
|
//! source restarts hydrate that cache from tenant-scoped rows before events are
|
||||||
|
//! routed.
|
||||||
|
|
||||||
use std::{fmt, sync::Arc};
|
use std::{fmt, sync::Arc};
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
|
|||||||
+2
-2
@@ -3,7 +3,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
network_mode: host
|
network_mode: host
|
||||||
user: "${APP_UID:-1000}:${APP_GID:-1000}"
|
user: '${APP_UID:-1000}:${APP_GID:-1000}'
|
||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:size=16m,noexec,nosuid,nodev
|
- /tmp:size=16m,noexec,nosuid,nodev
|
||||||
@@ -15,7 +15,7 @@ services:
|
|||||||
# 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。
|
# 复制 config.toml.example 为 config.toml 并填入真实配置后再启动。
|
||||||
- ./config.toml:/app/config.toml:ro
|
- ./config.toml:/app/config.toml:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:9719/health"]
|
test: ['CMD', 'curl', '--fail', '--silent', 'http://127.0.0.1:9719/health']
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 系统架构
|
||||||
|
|
||||||
|
本文描述直播组件服务的运行边界、数据流和扩展点。实现代码分别位于 `apps/server-rust` 与
|
||||||
|
`apps/overlay`。
|
||||||
|
|
||||||
|
## 核心目标
|
||||||
|
|
||||||
|
- 每个账户固定绑定一个 Bilibili 直播间和一个独立直播源。
|
||||||
|
- 一个直播源只建立一条上游连接,但可以把事件投递给多个组件实例。
|
||||||
|
- 平台原始命令先转换成稳定的领域事件,组件不直接依赖 Bilibili `CMD`。
|
||||||
|
- HTTP 会话、直播源、组件、OBS token 和实时通道均以租户为边界。
|
||||||
|
- 新组件可以增加设置、投影和持久化副作用,而不修改直播连接核心。
|
||||||
|
|
||||||
|
## 运行时数据流
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
CC[CookieCloud] --> PF[ProviderFactory]
|
||||||
|
BL[Bilibili Live] <--> BP[BilibiliProvider]
|
||||||
|
PF --> BP
|
||||||
|
BP --> LE[LiveEvent]
|
||||||
|
LE --> SR[SourceEventRouter]
|
||||||
|
PG[(PostgreSQL)] --> CR[Component repository/cache]
|
||||||
|
CR --> SR
|
||||||
|
SR --> EH[Durable handlers]
|
||||||
|
SR --> PJ[Passive projection]
|
||||||
|
PJ --> HUB[Component-scoped EventHub]
|
||||||
|
HUB --> WS[Authenticated WebSocket]
|
||||||
|
WS --> OBS[OBS overlay]
|
||||||
|
```
|
||||||
|
|
||||||
|
1. `SourceSupervisor` 为每个 `source_id` 保持至多一个 provider task。
|
||||||
|
2. `BilibiliProvider` 使用该用户加密保存的 CookieCloud 凭据获取 Cookie,并把原始消息转换成
|
||||||
|
`LiveEvent`。
|
||||||
|
3. `SourceEventRouter` 同时使用 `owner_id` 与 `source_id` 查找启用的组件,并再次检查组件归属。
|
||||||
|
4. 匹配订阅后,路由器先执行可持久化的 `EventHandler`,再执行无副作用的 `EventProjection`。
|
||||||
|
5. 投影结果只发布到该 `component_id` 的广播通道。没有全局 WebSocket 事件总线。
|
||||||
|
6. OBS 使用组件级只读 token 订阅一个组件,不能读取控制台 API。
|
||||||
|
|
||||||
|
## 状态所有权
|
||||||
|
|
||||||
|
| 状态 | 权威来源 | 内存副本 | 说明 |
|
||||||
|
| ---------------- | ------------ | ------------------------ | ----------------------------- |
|
||||||
|
| 用户、TOTP、会话 | PostgreSQL | 无 | Secret 加密,token 只保存摘要 |
|
||||||
|
| CookieCloud 凭据 | PostgreSQL | provider 构建期间解密 | 不返回浏览器 |
|
||||||
|
| 直播源和房间 | PostgreSQL | `SourceSupervisor` | 每用户固定一个房间 |
|
||||||
|
| 组件实例与设置 | PostgreSQL | `InMemoryComponentStore` | 写入成功后刷新热路径缓存 |
|
||||||
|
| 礼物/表情目录 | Bilibili API | provider catalog | 刷新失败保留最近成功快照 |
|
||||||
|
| 实时消息 | provider | `EventHub` 有界广播 | 不作为业务持久化机制 |
|
||||||
|
| PWA 静态壳层 | Docker 镜像 | Cache Storage | 不包含 API 或用户数据 |
|
||||||
|
|
||||||
|
## 启动顺序
|
||||||
|
|
||||||
|
`AppState::build` 按以下顺序启动,避免事件进入未完成的运行时:
|
||||||
|
|
||||||
|
1. 创建 PostgreSQL 连接池并执行嵌入式迁移。
|
||||||
|
2. 构造加密和 passwordless 鉴权服务。
|
||||||
|
3. 注册内建组件定义。
|
||||||
|
4. 从 PostgreSQL hydrate 组件热路径缓存。
|
||||||
|
5. 创建有界源事件队列、路由器和每组件广播中心。
|
||||||
|
6. 为数据库中所有启用的直播源启动 provider。
|
||||||
|
7. 最后由 `main.rs` 绑定 HTTP 监听端口。
|
||||||
|
|
||||||
|
关机时先停止接收 HTTP,再取消所有 provider task,防止容器退出期间继续拉取上游事件。
|
||||||
|
|
||||||
|
## 进程与部署边界
|
||||||
|
|
||||||
|
- Node 只存在于 Docker 的前端构建阶段。
|
||||||
|
- 最终镜像仅运行 Rust 可执行文件,并从 `/app/web` 同域托管静态资源。
|
||||||
|
- 容器使用 host network,但默认只监听 `127.0.0.1:9719`。
|
||||||
|
- Nginx 负责公网 TLS、域名和 WebSocket upgrade。
|
||||||
|
- CookieCloud 与 PostgreSQL 是外部服务,不由本项目 Compose 创建。
|
||||||
|
|
||||||
|
## 代码导航
|
||||||
|
|
||||||
|
- 后端模块职责:[`apps/server-rust/README.md`](../apps/server-rust/README.md)
|
||||||
|
- 前端路由和状态:[`apps/overlay/README.md`](../apps/overlay/README.md)
|
||||||
|
- 组件扩展指南:[`components/README.md`](components/README.md)
|
||||||
|
- WebSocket 协议:[`protocol.md`](protocol.md)
|
||||||
|
- 安全边界:[`security.md`](security.md)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 组件开发指南
|
||||||
|
|
||||||
|
组件是“一个直播源上的独立功能实例”。当前内建
|
||||||
|
`danmaku_overlay`,未来礼物墙、点歌姬或统计组件都应使用同一套契约。
|
||||||
|
|
||||||
|
## 一个组件由什么组成
|
||||||
|
|
||||||
|
| 部分 | Rust 契约 | 职责 |
|
||||||
|
| -------- | --------------------- | ------------------------------------------------- |
|
||||||
|
| 定义 | `ComponentDefinition` | kind、设置版本、默认值、校验、迁移、订阅 |
|
||||||
|
| 投影 | `EventProjection` | 把 `LiveEvent` 转成浏览器消息,不执行持久化副作用 |
|
||||||
|
| Handler | `EventHandler` | 可选的数据库写入、点歌或外部动作 |
|
||||||
|
| 实例 | `ComponentInstance` | owner、source、kind、名称、设置和启用状态 |
|
||||||
|
| 实时通道 | `EventHub` | 按 component ID 隔离的有界广播 |
|
||||||
|
| 前端 | React renderer/editor | 管理设置、测试与 OBS 展示 |
|
||||||
|
|
||||||
|
## 新增组件步骤
|
||||||
|
|
||||||
|
1. 选择稳定、全小写的 kind,例如 `gift_wall` 或 `song_request`。
|
||||||
|
2. 为设置定义可序列化结构,提供安全默认值和 `sanitize`/校验逻辑。
|
||||||
|
3. 实现 `ComponentDefinition`:
|
||||||
|
- `settings_version` 从 `1` 开始。
|
||||||
|
- `migrate_settings` 必须能把已保存的旧版本升级到当前版本。
|
||||||
|
- `subscriptions` 只返回当前设置需要的 `LiveEventKind`。
|
||||||
|
4. 实现无副作用的 `EventProjection`。返回 `None` 表示该事件无需发送浏览器。
|
||||||
|
5. 若需要可靠业务动作,实现 `EventHandler`:
|
||||||
|
- 即使没有 OBS 客户端也会执行。
|
||||||
|
- 数据库操作必须包含 owner/source/component 条件。
|
||||||
|
- 上游可能重试或出现组合事件,因此 handler 自己负责幂等。
|
||||||
|
6. 在 `ComponentRegistry::with_builtin_components` 注册定义与投影,再注册 handler。
|
||||||
|
7. 增加数据库创建/设置 API;不要把组件专属关系数据无限塞入 JSON settings。
|
||||||
|
8. 在控制台增加设置编辑器,在 OBS 前端增加对应事件渲染器。
|
||||||
|
9. 增加以下测试:设置边界、版本迁移、订阅、跨租户拒绝、handler 幂等、投影 wire shape 和 OBS 渲染。
|
||||||
|
|
||||||
|
## Projection 与 Handler 的边界
|
||||||
|
|
||||||
|
Projection 面向“现在打开的浏览器”。消息丢失或没有接收者都属于正常情况。它必须快速、确定、无副作用。
|
||||||
|
|
||||||
|
Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写入必须在这里完成,而不是等待前端收到 WebSocket。一个 handler 失败会记录在
|
||||||
|
`RouteReport`,但不会阻止其他 handler 或 OBS 投影。
|
||||||
|
|
||||||
|
## 设置版本规则
|
||||||
|
|
||||||
|
- 数据库同时保存 `settings` 与 `settings_version`。
|
||||||
|
- 每次读取或路由前,通过 registry 迁移并校验设置。
|
||||||
|
- 新字段应提供默认值,删除/改义字段必须增加版本并编写迁移。
|
||||||
|
- 客户端输入只能作为待校验 JSON;后端返回的 sanitized 设置才是权威值。
|
||||||
|
- UI slider 的范围不能代替后端范围检查。
|
||||||
|
|
||||||
|
## 租户与 token 规则
|
||||||
|
|
||||||
|
- 组件必须属于同一个 owner 与 source。
|
||||||
|
- 路由和数据库查询都要重复检查这一关系。
|
||||||
|
- 每个组件单独签发 access token,默认只有 `events:subscribe`。
|
||||||
|
- 删除组件或轮换 token 时,关闭该组件 channel,使已有 socket 立即失效。
|
||||||
|
- 组件 WebSocket 不得暴露其他组件列表或控制 API。
|
||||||
|
|
||||||
|
当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md)。
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# `danmaku_overlay` 弹幕姬
|
||||||
|
|
||||||
|
弹幕姬把一个租户直播源的互动事件投影为透明 OBS 消息墙。它是被动展示组件:不记账、不回复弹幕,也不把 WebSocket 当作持久化业务通道。
|
||||||
|
|
||||||
|
## 订阅事件
|
||||||
|
|
||||||
|
组件根据设置动态订阅:
|
||||||
|
|
||||||
|
- 弹幕、进房、礼物与礼物连击、醒目留言、舰长、点赞、分享。
|
||||||
|
- 关闭类别后,后端不为该组件投影对应事件,前端也会进行一次兼容性过滤。
|
||||||
|
- 未归一化的 `live.unknown` 默认不进入弹幕姬。
|
||||||
|
|
||||||
|
## 设置
|
||||||
|
|
||||||
|
| 字段 | 范围/单位 | 行为 |
|
||||||
|
| ------------------------ | ----------- | -------------------------- |
|
||||||
|
| `fontScale` | 50–300% | 展开与收缩字号的统一比例 |
|
||||||
|
| `maxVisible` | 1–12 | 同时保留的消息卡数量 |
|
||||||
|
| `collapseAfterSeconds` | 2–120 秒 | 最新卡从展开态切换到紧凑态 |
|
||||||
|
| `unfoldDurationMs` | 200–5000 ms | 横向卷轴展开动画时间 |
|
||||||
|
| `motionIntensity` | 0–100% | 卡片、流光与焦点动画强度 |
|
||||||
|
| `particleCount` | 0–12 | 每卡星花粒子数量 |
|
||||||
|
| `particleSpeed` | 25–300% | 粒子动画速度 |
|
||||||
|
| `lowPerformanceMode` | boolean | 关闭高成本动态效果 |
|
||||||
|
| `highValueThreshold` | 千分之一元 | 高价值礼物起点 |
|
||||||
|
| `featuredValueThreshold` | 千分之一元 | 焦点礼物起点 |
|
||||||
|
| `show*` | boolean | 控制各事件类别订阅 |
|
||||||
|
|
||||||
|
后端的 `OverlaySettings::sanitize` 是最终边界。控制台 range input 只改善交互,不能取代服务端校验。
|
||||||
|
|
||||||
|
## 卡片生命周期
|
||||||
|
|
||||||
|
1. 新事件插入队首,以卷轴动画横向展开。
|
||||||
|
2. 用户名与内容在展开态分行显示,长内容完整换行。
|
||||||
|
3. 新事件到达或超时后,旧卡变成紧凑态;内容不会隐藏。
|
||||||
|
4. 紧凑态缩小字号并尽量压缩布局,但仍允许换行避免截断。
|
||||||
|
5. 超过 `maxVisible` 的最旧卡才会离开队列。
|
||||||
|
|
||||||
|
花纹由事件类型和事件 ID 的稳定 hash 选择。相邻卡会避开完全相同的款式;礼物连击更新沿用原卡片 key 和装饰,避免视觉跳动。
|
||||||
|
|
||||||
|
## 礼物展示
|
||||||
|
|
||||||
|
- `live.gift` 创建礼物卡;`live.gift.combo` 使用 `comboId` 更新同一张卡。
|
||||||
|
- 元数据优先使用礼物目录中的静态图、GIF、币种和价格。
|
||||||
|
- GIF 加载失败时降级为静态图片;静态图失败时隐藏图片但保留名称、数量和用户。
|
||||||
|
- 普通、高价值和焦点礼物按 `totalPrice` 与两个阈值分级。
|
||||||
|
- `priceCny` 只用于人类可读展示;整数 `totalPrice` 用于精确分级。
|
||||||
|
|
||||||
|
## 弹幕表情
|
||||||
|
|
||||||
|
文字和表情按 `segments` 顺序混排。表情 URL 来自消息本身或用户级表情目录补全;图片使用
|
||||||
|
`no-referrer`,失败后显示原始表情文字。整条大表情可通过 `standalone` 使用更合适的尺寸。
|
||||||
|
|
||||||
|
## OBS 自适应
|
||||||
|
|
||||||
|
- 根背景完全透明,不存在 1920×1080 固定画布。
|
||||||
|
- `ResizeObserver` 根据浏览器源实际宽高选择 `narrow`、`short` 或 `standard`。
|
||||||
|
- 字号和间距使用 CSS custom properties 与 `clamp()`,OBS 自由缩放时不拉伸素材比例。
|
||||||
|
- 建议从 `360×600`、`440×760`、`600×1080` 或 `720×320` 开始测试。
|
||||||
|
- 低高度会减少视觉密度,但不会把仍在队列中的文字裁成省略号。
|
||||||
|
|
||||||
|
## 测试页面
|
||||||
|
|
||||||
|
控制台的事件测试直接构造 canonical
|
||||||
|
`LiveEvent`,使用当前账户、source 和 component 走同一套订阅、handler、projection 与 EventHub。测试事件标记为
|
||||||
|
`simulated=true`,不会发送到 Bilibili,也不会跨组件广播。
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 组件实时协议
|
||||||
|
|
||||||
|
当前协议版本为 `1`。后端的权威定义在 `domain.rs` 的
|
||||||
|
`ComponentMessage`,浏览器渲染器只依赖本文列出的稳定字段。
|
||||||
|
|
||||||
|
## 连接地址与认证
|
||||||
|
|
||||||
|
组件流地址:
|
||||||
|
|
||||||
|
```text
|
||||||
|
wss://danmaku.luoxingci.com/api/v1/components/<publicId>/stream
|
||||||
|
```
|
||||||
|
|
||||||
|
OBS 页面地址中的 token 位于 fragment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://danmaku.luoxingci.com/obs/<publicId>#token=<component-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
fragment 不会进入首次 HTTP 请求或 Nginx access
|
||||||
|
log。WebSocket 建立后,客户端必须在 8 秒内发送第一帧:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "authenticate",
|
||||||
|
"token": "component-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
服务端验证 token 摘要、组件归属和 `events:subscribe` scope。认证成功后返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"type": "authenticated",
|
||||||
|
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
之后立即发送
|
||||||
|
`overlay.settings.snapshot`,再开始发送实时事件。token 无效、被轮换或属于其他组件时,服务端以 policy
|
||||||
|
close 结束连接。
|
||||||
|
|
||||||
|
## 版本化事件信封
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"id": "d56c1a28-a6f1-4ad9-b02f-4c22dc4d3c27",
|
||||||
|
"componentId": "08d31d19-3e4b-4c11-9cf7-9bd786a39465",
|
||||||
|
"sourceId": "c8087bb0-0dde-40eb-a43d-a9a2574c2717",
|
||||||
|
"occurredAt": "2026-07-15T20:10:30.125Z",
|
||||||
|
"roomId": "123456",
|
||||||
|
"type": "live.danmaku",
|
||||||
|
"payload": {
|
||||||
|
"viewer": { "uid": "42", "name": "观众" },
|
||||||
|
"text": "晚上好",
|
||||||
|
"segments": [{ "type": "text", "text": "晚上好" }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`ownerId` 永远不会序列化到浏览器。消费者应按 `version` 和 `type`
|
||||||
|
分派,并忽略不认识的 payload 字段,从而允许兼容地增加元数据。
|
||||||
|
|
||||||
|
## 事件类型
|
||||||
|
|
||||||
|
| `type` | 主要 payload | 说明 |
|
||||||
|
| --------------------------- | --------------------------------------------- | ---------------------- |
|
||||||
|
| `overlay.settings.snapshot` | `settings` | 认证后当前设置快照 |
|
||||||
|
| `overlay.settings.updated` | `settings` | 控制台保存后的实时设置 |
|
||||||
|
| `live.danmaku` | `viewer`, `text`, `segments` | 普通文字和表情分段 |
|
||||||
|
| `live.enter` | `viewer` | 进房事件 |
|
||||||
|
| `live.gift` | `viewer`, `gift`, `quantity`, `sourceEventId` | 一次可记账的礼物事件 |
|
||||||
|
| `live.gift.combo` | `viewer`, `gift`, `quantity`, `comboId` | 同一连击的视觉更新 |
|
||||||
|
| `live.superchat` | `viewer`, `message`, `price`, `sourceEventId` | 醒目留言 |
|
||||||
|
| `live.guard.buy` | `viewer`, `guardName`, `quantity`, `price` | 舰长购买 |
|
||||||
|
| `live.like` | `viewer` | 点赞 |
|
||||||
|
| `live.share` | `viewer` | 分享 |
|
||||||
|
| `live.unknown` | `command`, `metadata` | 有界且已清理的未知事件 |
|
||||||
|
|
||||||
|
礼物目录价格的原始单位是人民币的千分之一。`gift.totalPrice` 保留该整数单位,`gift.priceCny`
|
||||||
|
是供展示使用的人民币数值。
|
||||||
|
|
||||||
|
`live.gift` 与 `live.gift.combo` 不是两笔礼物。需要持久化计数的组件通常只消费
|
||||||
|
`live.gift`;连击事件用于更新同一张视觉卡片。
|
||||||
|
|
||||||
|
## 表情分段
|
||||||
|
|
||||||
|
`live.danmaku.payload.segments` 是判别联合:
|
||||||
|
|
||||||
|
- `text`:包含可直接显示的文字。
|
||||||
|
- `emoticon`:包含回退文字、图片 URL、可选尺寸、动态标记和整条大表情标记。
|
||||||
|
|
||||||
|
图片失败时客户端必须回退到 `text`,不能让一张失效图片破坏整条弹幕。
|
||||||
|
|
||||||
|
## 背压与重连
|
||||||
|
|
||||||
|
- 每个组件使用有界 `tokio::broadcast` channel。
|
||||||
|
- 慢客户端发生 `Lagged` 时跳过已经丢失的旧帧,继续接收新事件;实时展示不保证历史重放。
|
||||||
|
- 浏览器对非鉴权关闭使用指数退避重连,上限 12 秒。
|
||||||
|
- 鉴权失败不会自动重试,避免对已撤销 token 形成无限请求。
|
||||||
|
- 需要可靠业务处理的功能必须实现 `EventHandler` 并写入数据库,不能把 WebSocket 当作消息队列。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# 安全模型
|
||||||
|
|
||||||
|
本服务同时处理 Bilibili 登录 Cookie、TOTP Secret、一次性恢复码、管理员邀请码和 OBS
|
||||||
|
token。以下规则是实现约束,而不是可选部署建议。
|
||||||
|
|
||||||
|
## 租户隔离
|
||||||
|
|
||||||
|
- HTTP handler 只从服务端会话解析 `owner_id`,不接受客户端声明的 owner。
|
||||||
|
- 组件查询同时限定 `owner_user_id` 与 `source_id`。
|
||||||
|
- 数据库使用 owner 复合外键、RLS 和 `FORCE ROW LEVEL SECURITY`。
|
||||||
|
- tenant 查询必须在事务中执行 `SET LOCAL app.user_id`,不能使用会泄漏到连接池的 session-level
|
||||||
|
`SET`。
|
||||||
|
- 实时广播按 `component_id` 建立独立 channel,不提供全局订阅。
|
||||||
|
- 路由器在投影发布前再次验证 owner、source 和 component ID。
|
||||||
|
|
||||||
|
## Secret 生命周期
|
||||||
|
|
||||||
|
| Secret | 浏览器可见性 | 数据库存储 | 轮换/消费 |
|
||||||
|
| -------------------- | ------------------- | ----------------------- | ---------------- |
|
||||||
|
| TOTP Secret | 注册时显示一次 | XChaCha20-Poly1305 密文 | 账户绑定 |
|
||||||
|
| 恢复码 | 注册完成时显示一次 | SHA-256 摘要 | 单次消费 |
|
||||||
|
| 登录 session | HttpOnly Cookie | SHA-256 摘要 | 到期、登出或撤销 |
|
||||||
|
| CookieCloud Key/密码 | 用户提交时 | XChaCha20-Poly1305 密文 | 覆盖更新 |
|
||||||
|
| 邀请码 | 创建时显示一次 | SHA-256 摘要和前缀 | 单次消费或撤销 |
|
||||||
|
| OBS token | 创建/轮换时显示一次 | SHA-256 摘要 | 组件级轮换 |
|
||||||
|
|
||||||
|
`security.data_encryption_key`
|
||||||
|
是恢复密文所必需的主密钥。它必须独立备份,但不能提交到 Git 或写入镜像。
|
||||||
|
|
||||||
|
## Passwordless 认证
|
||||||
|
|
||||||
|
- 第一个系统管理员只能通过数据库为空时的 bootstrap 流程创建。
|
||||||
|
- bootstrap proof 不成为账户密码,也不能用于日常登录。
|
||||||
|
- TOTP 接受有限时钟偏移,并持久化最近使用的 time step,阻止同一码重放。
|
||||||
|
- 登录与匿名注册同时按账户维度和网络维度限流,错误消息不暴露用户名是否存在。
|
||||||
|
- 注册先写入短期 pending enrollment;只有正确 TOTP 确认后才原子创建账户并消费邀请码。
|
||||||
|
|
||||||
|
## CookieCloud 与 SSRF
|
||||||
|
|
||||||
|
- 部署者通过 `cookiecloud_allowed_hosts` 指定精确的基础地址白名单。
|
||||||
|
- URL 会规范化,并拒绝 embedded credentials、query 和 fragment。
|
||||||
|
- Key 编码成单一路径段,不能注入额外路径。
|
||||||
|
- HTTP 客户端禁止重定向,防止允许的地址跳转到内网目标。
|
||||||
|
- 浏览器只看到 host 和 `keyConfigured`/`passwordConfigured`,不会读回凭据。
|
||||||
|
- 日志不得把 `blivedm` 调到可能打印认证响应的详细级别。
|
||||||
|
|
||||||
|
## HTTP、WebSocket 与 OBS
|
||||||
|
|
||||||
|
- 公开部署必须在受信任反代终止 HTTPS,并保持 `secure_cookies = true`。
|
||||||
|
- 生产 session 使用 `__Host-` Cookie、HttpOnly、Secure 与 SameSite 策略。
|
||||||
|
- 写 API 执行 same-origin 检查并限制 body 大小。
|
||||||
|
- 所有 `/api/*` 响应使用 `Cache-Control: no-store`。
|
||||||
|
- OBS token 放在 URL fragment,并作为 WebSocket 第一帧发送。
|
||||||
|
- token 只具有 `events:subscribe` scope,且只能订阅其绑定的组件。
|
||||||
|
- WebSocket 首帧、frame size、认证时间和全局连接数均有上限。
|
||||||
|
|
||||||
|
## PWA 边界
|
||||||
|
|
||||||
|
- manifest、Service Worker 和 start URL 都限定在 `/control/`。
|
||||||
|
- `/obs/*` 不在 Service Worker scope 内。
|
||||||
|
- worker 只缓存静态应用壳层、图标和构建资源;不缓存 API、WebSocket 或直播事件。
|
||||||
|
- 离线 mutation 直接失败,不使用 Background Sync。
|
||||||
|
- 新 worker 仅在用户确认后激活。
|
||||||
|
- TOTP、恢复码、邀请码、新 OBS 地址或未保存设置可见时,更新会被阻止。
|
||||||
|
|
||||||
|
## 上线检查表
|
||||||
|
|
||||||
|
- [ ] 使用独立随机 `data_encryption_key`,并在安全位置备份。
|
||||||
|
- [ ] `config.toml` 权限为 `0600`,且已被 Git 和 Docker build context 排除。
|
||||||
|
- [ ] PostgreSQL runtime role 可以执行迁移,但 PUBLIC 无权执行 SECURITY DEFINER helper。
|
||||||
|
- [ ] `secure_cookies = true`,Nginx 正确传递 `X-Forwarded-Proto https`。
|
||||||
|
- [ ] 应用仅监听 loopback,9719 未直接暴露公网。
|
||||||
|
- [ ] CookieCloud 白名单只包含管理员批准的实例。
|
||||||
|
- [ ] 日志中没有 Cookie、TOTP、token、邀请码或上游认证响应。
|
||||||
|
- [ ] 轮换 OBS token 后,旧浏览器源立即断开且无法重连。
|
||||||
Reference in New Issue
Block a user