Compare commits

...
63 Commits
Author SHA1 Message Date
Disy bf95d715a4 fix: make hosted downloads controllable and failures diagnosable
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-20 21:25:19 +08:00
Disy 1838afce56 chore: release 1.0.2-beta11 2026-10-20 20:36:53 +08:00
Disy 7f3a1cdf5c fix: localize skin site UI and renew JWT on startup
Catalog missing skin-site messages and check source IDs during i18n validation. Renew the saved JWT before loading session data, guard overlapping login/logout requests, and reload announcements after authentication with a retry action.
2026-10-20 20:28:13 +08:00
Disy 2d7683b0bb ci: add credential-safe skin site authentication diagnosis 2026-10-20 19:45:32 +08:00
Disy 6edc44fe26 fix(ci): match skin site response envelopes 2026-10-20 19:19:36 +08:00
Disy f81a588dc1 fix(ci): include skin site API error details in failures 2026-10-20 19:17:07 +08:00
Disy be3a1eda8c fix(ci): publish skin site releases with chunked uploads
Use bounded chunks with retries, processing deadlines and verified activation. Fail publication explicitly on upload errors. Add a manual recovery workflow for existing release packages.
2026-10-20 19:14:58 +08:00
Disy 0098e9eb07 docs: add beta10 security release notes 2026-10-20 17:10:39 +08:00
Disy 629fe9c26a chore: bump launcher version to 1.0.2-beta10 2026-10-20 17:07:32 +08:00
Disy 159f9e3f38 fix(security): backport upstream modpack path traversal fixes
Backport Mystic-Stars/Axolotl b2f81391e584aebf3ef07e8db2fc9cdad2ffb4c7. Adapt validation to this fork and protect CurseForge archive paths and MRPack uninstall cleanup.
2026-10-20 17:04:46 +08:00
AxTps 598fb5c679 chore: 发布 1.0.2-beta9
Sync source to CNB / Sync Git ref (push) Skipped
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
2026-10-19 21:48:11 +08:00
AxTps aaf394f790 feat: 发版时自动同步到皮肤站
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
- build-on-tag 新增发布步骤,将 skin-site-update 包上传到皮肤站并激活
- 通过 SKIN_SITE_CI_UPLOAD_TOKEN 环境变量鉴权,未配置时跳过并告警
- 上传或激活失败不阻断发版流程
2026-10-18 14:02:57 +08:00
AxTps 3c8ac04269 feat: 补齐皮肤站管理台与维基阅读(用户、审核、Mod、聊天室、维基迁移)
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
feat: 新增皮肤站管理台资源下载、服务器、审计与捐赠页
2026-10-18 13:15:01 +08:00
AxTps 71b8d111fb feat: 新增皮肤站维基只读浏览页
Sync source to CNB / Sync Git ref (push) Skipped
- 新增 WikiView 页面:输入标题加载页面,支持内部词条跳转与返回栈
- 新增 WikiNodeRenderer 递归渲染器:标题、段落、加粗斜体下划线删除线、高亮、行内代码、黑幕、分隔线、内外链、有序无序列表、引用、代码块、表格、附件、提示块
- 新增 WikiNotice 提示块与 WikiAttachmentNode 附件组件,图片支持点击查看大图
- 补充维基节点与附件类型定义,新增附件查询接口与附件内容 URL 拼接
- 主页导航 Wiki 卡片改为站内打开
- 中英文各补 10 个文案
2026-10-18 13:13:03 +08:00
AxTps febbd84ae7 feat: 皮肤站管理台 Wiki 补 MediaWiki 迁移中心
Sync source to CNB / Sync Git ref (push) Skipped
- 新增迁移中心 Tab
- 自动迁移:填写 api.php 地址,可选 Bot 凭据,支持明确允许内网来源;可配置过时页面筛选规则(未更新天数、维护者统计窗口、维护者人数上限、过时标记)
- XML 导入:上传 MediaWiki 导出文件建立暂存任务
- 迁移任务列表:状态标签、审核进度条、已审核/导入/排除统计、错误信息
- 任务详情:分页加载候选页面,逐条显示更新时间、维护者数量、可疑原因,可设置目标标题与是否覆盖,执行导入或排除
- 整批发布与回滚,失败或已回滚任务可删除记录
- 补充迁移相关 API 与类型定义
- 中英文各补 55 个文案
2026-10-18 12:57:26 +08:00
AxTps 667c1a0900 feat: 聊天室管理接入实时语音子系统
Sync source to CNB / Sync Git ref (push) Skipped
- 顶部展示语音运行时状态:状态标签、LiveKit 版本、下载进度,未就绪时每 5 秒轮询
- 部署失败或不支持平台时提供重新部署入口
- 语音库配置弹窗:启用开关、目录、镜像地址、公网地址、监听地址、信令与媒体端口、启动超时、外部 IP 与节点 IP
- 语音日志弹窗:增量拉取、自动刷新开关、手动刷新、最多保留 2000 条
- 房间语音容量设置:参与人数与旁听人数上限
- 补充语音相关 API 与类型定义
- 中英文各补 26 个文案
2026-10-18 12:29:45 +08:00
AxTps ac38c98203 feat: 聊天室管理补房管任命、聚落主与审计
Sync source to CNB / Sync Git ref (push) Skipped
- 新增 ChatUserPicker 用户选择器:支持按用户名/邮箱/账号 ID 或玩家名称搜索,封禁与游客不可选,支持排除与白名单
- 房管任命与撤销
- 聚落房间可任命聚落主,展示当前聚落主
- 房间审计记录查看,操作类型本地化映射
- 补充对应 API 与类型定义
- i18n 白名单加入 ID 缩写
- 中英文各补 31 个文案
2026-10-18 12:20:45 +08:00
AxTps ef1435bd31 feat: 新增皮肤站管理台聊天室房间管理
Sync source to CNB / Sync Git ref (push) Skipped
- 房间列表:头像、名称、可见性、类型、语音标记、前置权限、创建时间
- 创建/编辑房间:名称、简介、公开或私有、普通或聚落、前置条件判定与所需权限
- 房间头像:上传、更换、移除,支持 PNG/JPEG/WebP,5 MB 限制
- 删除房间带二次确认
- 新增 RoomAvatar 与 RoomAvatarEditor 组件,头像按附件 id 缓存 Blob URL
- 补充聊天室相关 API:房间列表、创建、更新、删除、头像更新、附件上传、头像获取
- 管理台导航接入聊天室入口
- 中英文各补 37 个文案
2026-10-18 12:13:34 +08:00
AxTps 87de306245 feat: 补全皮肤站管理台 Mod 页上传与批量操作
Sync source to CNB / Sync Git ref (push) Skipped
Prepare pnpm cache / prepare (push) Canceled after 0s
- 新增 Mod 上传弹窗:拖拽或选择 .jar、多文件、大小限制、规则标签附加
- 上传流程:xxHash64 查重、同名检测、名称冲突确认(覆盖/跳过)、429 限流退避重试、HashDiff 重传
- Mod 列表支持多选模式:全选本页、取消选择、批量删除
- 新增服务器地址过滤,配合名称搜索与标签筛选
- 补充 Mod 相关 API:文件哈希、按哈希/名称查重、上传、批量更新
- 引入 xxhash-wasm 依赖
- 中英文各补 32 个文案
2026-10-18 12:02:39 +08:00
AxTps fbcbb803ac feat: 补全皮肤站管理台用户页
Sync source to CNB / Sync Git ref (push) Skipped
- 用户列表:搜索(用户名/邮箱/UUID)、分页、封禁状态、注册时间
- 编辑用户:用户名、邮箱、QQ、密码、权限组、封禁状态
- 绑定玩家:展示 Mojang / StarLight 来源与旧内服适配标记
- Bot 管理:查看状态、修改机器人用户名、使 Token 失效、封禁/解封 Bot 创建
- 频率限制:滑块与数字输入设置丢包频率,支持保存与取消
- 管理审计:按用户或全局查看,游标分页加载更早记录
- 邀请码:按邮箱生成邀请码并复制
- 补充对应 API 与类型定义,中英文各补 39 个文案
2026-10-18 11:18:55 +08:00
AxTps 7b3a5212e6 feat: 完成玩家审核试卷编辑器
Sync source to CNB / Sync Git ref (push) Skipped
- 新增试卷管理 Tab:列表展示、启用状态、题数/总分/通过分、目标权限标签、复制直链
- 新增试卷编辑弹窗:标题、描述、阅卷模式、通过分数、启用开关
- 目标权限多选、前置身份开关 + 判断方式(ANY/ALL)+ 权限多选
- 题目编辑:五种题型、上下移、删除、信息题、选答题、生死题、自动批复
- 选择题选项增删改、默认选项、正确选项、多选计分模式、选项得分/扣分
- 填空/简答作答字数上限与标准答案
- 查重开关与通过邮件配置(标题/正文/补充说明/附带邀请码)
- 保存前校验:标题、题目、权限、通过分、邮件必填项
- 公开端审核类型改名 Public 前缀,避免与后台同名冲突
- 补充中英文 locale 共 37 个键
2026-10-18 10:25:48 +08:00
AxTps 8d73689356 feat: 新增皮肤站管理台维基管理页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 09:11:58 +08:00
AxTps 4a3b4268b9 fix: 补齐皮肤站主页导航卡片的中英文案
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 09:01:43 +08:00
AxTps 64f2f85607 fix: 重命名公开下载与地图接口避免导出冲突
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 08:38:14 +08:00
AxTps ede28e0062 feat: 新增皮肤站管理台 Mod 管理页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 08:24:55 +08:00
AxTps b0178c046f feat: 新增皮肤站管理台捐赠管理页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 08:01:24 +08:00
AxTps 57ff974d79 feat: 服务器管理页仅对网页管理员展示
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 07:45:39 +08:00
AxTps ac9373ddae feat: 新增皮肤站管理台服务器与审计页 2026-10-18 07:43:37 +08:00
AxTps 47c4a3973d feat: 新增皮肤站管理台资源下载页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 07:37:15 +08:00
AxTps a85e88339f chore: 发布 1.0.2-beta8
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
2026-10-18 07:30:37 +08:00
AxTps d4eb492772 merge: 整合包下载地址切换到独立下载域名
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
2026-10-18 07:25:04 +08:00
AxTps e1aa1b47ec feat: 整合包下载地址切换到独立下载域名
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-18 07:24:19 +08:00
AxTps 023447fe85 Merge pull request #23 from ApTx2012/feat/home
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
feat: 皮肤站会话改为 API 驱动
2026-10-18 07:15:33 +08:00
AxTps 1baf1a041e Merge pull request #22 from ApTx2012/feat/ui
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
fix: 引导步骤切换时自动滚动到目标元素
2026-10-17 22:56:34 +08:00
AxTps 6a0d9b719c Merge pull request #19 from ApTx2012/feat/backend
fix: LogShare 采集改用轻量日志收集,修复崩溃日志上传失败
2026-10-17 22:56:15 +08:00
AxTps ba43b9d5bc feat: 新增皮肤站管理台启动器版本页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:54:43 +08:00
AxTps 1c671943b7 feat: 新增皮肤站管理台玩家审核页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:50:21 +08:00
AxTps eef7d02f45 fix: 补全皮肤站管理台 i18n 白名单
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:43:50 +08:00
AxTps 947a739672 feat: 新增皮肤站管理台用户与玩家页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:38:03 +08:00
AxTps 799d542227 feat: 新增皮肤站管理台概览与公告页
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:27:41 +08:00
AxTps 77258eee9d feat: 新增皮肤站管理控制台框架
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:21:03 +08:00
AxTps a4615c7b73 feat: 新增皮肤站问题创建与通知中心
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 22:07:47 +08:00
AxTps 146b88afce feat: 新增皮肤站问题详情与评论
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 21:59:25 +08:00
AxTps 9bdc965cd5 feat: 新增皮肤站问题列表视图
Sync source to CNB / Sync Git ref (push) Skipped
2026-10-17 21:45:01 +08:00
AxTps d6d4fcf8a0 feat: 新增皮肤站玩家审核视图
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充公开审核表单目录与提交申请接口。
- SkinSiteReviewView:表单列表、动态题目渲染(单选 / 多选 / 判断 /
  填空 / 问答 / 信息题)、必填校验,提交后展示审核状态。
- 主页导航卡的「玩家审核」改为打开该视图。
2026-10-17 21:34:38 +08:00
AxTps 8cc4782497 feat: 新增皮肤站服务器地图视图
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充地图服务器列表接口。
- SkinSiteMapView:内外服切换、选择子服与地图,用内嵌 iframe 展示,
  并提供在浏览器中打开的入口。
- 主页导航卡的「地图」改为打开该视图。
2026-10-17 21:28:04 +08:00
AxTps 77b381b8d0 feat: 新增皮肤站资源下载视图
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充下载列表、数量与分类接口。
- SkinSiteDownloadsView:按分类筛选下载项,展示文件名、描述、
  上传者与状态标签,点击后用系统浏览器打开下载地址。
- 主页导航卡的「下载」改为打开该视图,其余入口仍走浏览器。
2026-10-17 21:24:36 +08:00
AxTps 4124f26095 feat: 新增皮肤站玩家管理视图
Sync source to CNB / Sync Git ref (push) Skipped
- SkinSiteProfileView:玩家列表、选中玩家的 3D 预览,以及皮肤 /
  披风 / 鞘翅的上传与删除;Mojang 账号只读。
- 账号卡增加「My players」入口,主页在该视图与首页之间切换。
- 3D 预览复用 SkinPreviewRenderer。
2026-10-17 21:18:44 +08:00
AxTps 041d288b25 feat: 新增皮肤站账号设置弹窗
Sync source to CNB / Sync Git ref (push) Skipped
- API 层补充改名、改网站密码、改 Minecraft 密码、上传头像。
- SkinSiteProfileModal:用户名修改、头像上传(居中裁剪为 256×256)、
  网站密码修改、Minecraft 密码修改与恢复默认。
- 主页账号卡增加「Account」入口打开该弹窗。
2026-10-17 19:33:48 +08:00
AxTps 86306b8ddd refactor: 移除内嵌皮肤站页面
Sync source to CNB / Sync Git ref (push) Skipped
主页已改为原生界面,整页内嵌网页不再需要:

- 删除 SkinSiteFrame.vue 与 StarlightSkin.vue。
- 移除 /starlight-skin 路由。
- 删除不再使用的 skinSiteFrameUrl 导出。
- 实例玩家弹窗的"去皮肤站登录"改为直接打开登录弹窗,不再跳转页面。
2026-10-17 19:19:05 +08:00
AxTps 8599a014ad feat: 主页改为原生皮肤站界面
Sync source to CNB / Sync Git ref (push) Skipped
主页原先内嵌整个皮肤站网页,现在换成启动器自己的界面,数据全部走
接口:

- SkinSiteAccountCard:登录状态、玩家列表(头像由皮肤裁剪生成)、
  每日运势。
- SkinSiteNavCards:资源下载、玩家审核、Wiki、漏洞追踪、地图、个人
  资料的入口,点击后用系统浏览器打开。
- SkinSiteAnnouncements:拉取公告并按重要度与时间排序。
- SkinSiteDonationCard:捐款累计金额与人数。
- SkinSiteHome:把以上拼装成主页,并显示可复制的接入地址。
- API 层补充捐款与公告接口。

Index.vue 的 iframe 换成 SkinSiteHome。
2026-10-17 19:11:04 +08:00
AxTps bf175160b0 Merge remote-tracking branch 'origin/main' into feat/home 2026-10-17 18:57:46 +08:00
AxTps d17a66e44f chore: 补充 1.0.2-beta7 更新公告
Sync source to CNB / Sync Git ref (push) Skipped
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Starlight release / Release preflight (push) Canceled after 0s
Starlight release / Build (Windows x64) (push) Canceled after 0s
Starlight release / Build (Linux arm64) (push) Canceled after 0s
Starlight release / Build (Linux x64) (push) Canceled after 0s
Starlight release / Build (macOS universal) (push) Canceled after 0s
Starlight release / Verify and publish (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
汇总 beta6 以来的改动:实时日志积压修复、更新通道默认值跟随安装
包版本、Linux 游戏目录默认回退到用户主目录。
2026-10-17 18:56:23 +08:00
AxTps 3198eac35a feat: 新增皮肤站注册与找回密码弹窗
Sync source to CNB / Sync Git ref (push) Skipped
补齐登录之外的账号流程:

- SkinSiteRegisterModal:邮箱验证码(60 秒倒计时)、密码与确认、
  可选 QQ、按服务端配置显示邀请码,提交前走图片验证码。
- SkinSitePasswordResetModal:邮箱验证码、新密码与确认,同样走验证码。
- 登录弹窗底部增加「注册」「忘记密码」入口,取消或完成后回到登录。
- API 层补充注册、注册条件、邮箱验证码、密码重置接口。
2026-10-17 18:50:38 +08:00
AxTps d83a65a052 refactor: 移除隐藏的皮肤站会话 iframe
Sync source to CNB / Sync Git ref (push) Skipped
会话改为 API 驱动后,这个隐藏 iframe 只负责收发 postMessage。它每
10 秒检查一次"90 秒内没收到消息就重置会话",而现在永远收不到消息,
90 秒后必然把刚登录的会话清掉。

删除组件,改由 App.vue 在状态初始化完成后调用 initSkinSiteSession()
恢复登录态。
2026-10-17 18:45:57 +08:00
AxTps 00f0f111d8 feat: 新增皮肤站原生登录与验证码弹窗
Sync source to CNB / Sync Git ref (push) Skipped
登录入口原先会切到主页并打开内嵌网页。改为弹出启动器自己的登录弹窗:

- SkinSiteLoginModal:账号密码校验规则与皮肤站一致,登录前走验证码,
  成功后保存 JWT 并刷新会话。
- SkinSiteCaptchaModal:拉取旋转图片验证码,用滑块调整角度后提交。
- AccountsCard 的登录按钮改为打开该弹窗,登录成功后重新拉取玩家列表。
- 修正 verifyCaptcha 的参数:服务端校验的是旋转角度 degree,不是文字答案。
2026-10-17 18:42:34 +08:00
AxTps 0edf96fc2c feat: 皮肤站会话改为 API 驱动,不再依赖内嵌网页
Sync source to CNB / Sync Git ref (push) Skipped
主页原先通过隐藏 iframe + postMessage 与皮肤站网页通信,登录态、
玩家列表、运势等数据都要绕一圈从网页里取。改为启动器直接调用皮肤站
HTTP 接口:

- 新增 helpers/skin-site-api.ts:请求封装(tauriFetch 绕开 CORS)、
  JWT 本地存取、认证与玩家/皮肤接口。
- 新增 helpers/skin-site-render.ts:从皮肤图裁出头像,以及把图片转
  成 data URL。
- 重写 composables/skin-site-session.ts:登录态由本地 JWT 决定,
  玩家列表改为请求接口后逐个拉取皮肤并渲染头像;运势、皮肤上传同样
  走接口。旧的 iframe 相关导出保留为兼容空实现。
- 删除仅测试 postMessage 协议的 skin-site-session.test.ts。

下载凭据仍然返回皮肤站 JWT:Rust 侧 hosted_set_session 期望的就是它,
instance-player 与远程公告的鉴权方式保持不变。
2026-10-17 18:35:58 +08:00
AxTps 871b0ee001 Merge pull request #21 from ApTx2012/feat/instance
Sync source to CNB / Sync Git ref (push) Skipped
Rust checks / shear (push) Canceled after 0s
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
fix: Linux 系统安装时游戏目录默认回退到用户主目录
2026-10-17 18:17:54 +08:00
AxTps adebde714d Merge pull request #20 from ApTx2012/feat/updater
fix: 更新渠道默认值跟随安装包版本
2026-10-17 18:17:41 +08:00
AxTps 0195cbf5ac fix: 修复 collect_crash_context 中凭据收集的类型不匹配
Sync source to CNB / Sync Git ref (push) Skipped
Credentials::get_all 返回 DashMap,直接 collect 会得到
Vec<(Uuid, Credentials)>,与 CrashContext.credentials 的
Vec<Credentials> 不符,导致编译失败。补上与 analyze_crash 一致的
.map(|credential| credential.1)。
2026-10-17 18:16:36 +08:00
AxTps 13a56f4200 fix: Linux 系统安装时游戏目录默认回退到用户主目录
Sync source to CNB / Sync Git ref (push) Skipped
从 deb 等系统包安装时,启动器可执行文件位于 /usr/bin,此前一键安装
StarLight 实例的"选择游戏目录"弹窗会默认到这个系统目录,普通用户既
无写权限,也不适合存放游戏数据。

将默认目录计算移入 theseus::default_game_dir_root():Linux 下若可执行
文件位于 /usr、/opt、/snap 等系统路径,回退到用户主目录;便携版与其
他平台仍使用可执行文件所在目录。
2026-10-17 16:12:28 +08:00
AxTps 8b94e7dbbe fix: 更新渠道默认值跟随安装包版本
Sync source to CNB / Sync Git ref (push) Skipped
从 GitHub 下载 Beta 安装包安装后,更新通道默认选中 Release,
因为 update-channel.json 状态文件不存在时会硬编码回退到 release。

新增 theseus::default_update_channel(),按编译期版本号判断:含预发布
后缀(如 1.0.2-beta7)默认 Beta,否则 Release。数据库渠道与设置页
渠道两处默认值统一改为调用它,保证两者一致。
2026-10-13 22:11:06 +08:00
AxTps ccb96a8cf1 fix: LogShare 采集改用轻量日志收集,修复崩溃日志上传失败
Sync source to CNB / Sync Git ref (push) Skipped
崩溃弹窗的 LogShare 摘要与 LogAgent 深度分析此前都会失败。原因是
logshare 的 prepare_upload 直接调用完整崩溃分析 analyze_crash,会
额外扫描 mod jar、比对 mod 快照并查询 Windows 事件,在这种大型实例
上容易出错或超时,错误被前端 uploadTicketForInstance 捕获成 null,
最终表现为无法上传/无法准备诊断。

对齐 Axolotl 的做法,新增轻量的 collect_crash_context /
collect_crash_logs,只做路径解析、候选收集、读取与脱敏,不触发
mod 扫描与快照副作用。SourceCandidate / SourceText 补充 upload_name
字段(采集时按目录加 crash-reports/ 前缀),logshare 的 prepare_upload
改调该轻量函数。
2026-10-13 22:01:02 +08:00
104 changed files with 24450 additions and 2370 deletions

No files matched your search

+19
View File
@@ -51,6 +51,7 @@ jobs:
node scripts/axolotl/check-migrations.mjs --release node scripts/axolotl/check-migrations.mjs --release
node scripts/axolotl/brand-guard.mjs node scripts/axolotl/brand-guard.mjs
node scripts/axolotl/i18n-check.mjs node scripts/axolotl/i18n-check.mjs
node --test scripts/axolotl/publish-skin-site.test.mjs
cargo fmt --all --check cargo fmt --all --check
- name: Check updater signing configuration - name: Check updater signing configuration
@@ -286,6 +287,9 @@ jobs:
name: Verify and publish name: Verify and publish
needs: build needs: build
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
concurrency:
group: skin-site-${{ github.ref_name }}
cancel-in-progress: false
env: env:
UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }} UPDATE_SERVER_URL: ${{ secrets.UPDATE_SERVER_URL }}
UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }} UPDATE_SERVER_WEBHOOK_SECRET: ${{ secrets.UPDATE_SERVER_WEBHOOK_SECRET }}
@@ -298,6 +302,11 @@ jobs:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.ref }} ref: ${{ github.ref }}
- name: Set up Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version-file: .nvmrc
- name: Download updater signatures - name: Download updater signatures
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -374,6 +383,16 @@ jobs:
) )
gh release upload "${GITHUB_REF_NAME}" "release-check/${bundle_name}" --clobber gh release upload "${GITHUB_REF_NAME}" "release-check/${bundle_name}" --clobber
- name: Publish to skin site
env:
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME#v}"
bundle="release-check/Starlight_Launcher_${version}_skin-site-update.zip"
node scripts/axolotl/publish-skin-site.mjs "${bundle}" "${GITHUB_REF_NAME}"
- name: Notify Update Server to publish release assets - name: Notify Update Server to publish release assets
if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }} if: ${{ env.UPDATE_SERVER_URL != '' && env.UPDATE_SERVER_WEBHOOK_SECRET != '' }}
env: env:
@@ -0,0 +1,49 @@
name: Diagnose skin site CI authentication
on:
workflow_dispatch:
permissions:
contents: read
jobs:
diagnose:
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Inspect token format without revealing credentials
env:
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
run: |
node --input-type=module <<'NODE'
const token = process.env.SKIN_SITE_TOKEN || ''
const checks = {
configured: token.length > 0,
containsWhitespace: /\s/.test(token),
containsComma: token.includes(','),
containsQuotes: /["']/.test(token),
containsBearerPrefix: /^Bearer\s/i.test(token),
bearerTokenFormatValid: /^[A-Za-z0-9._~+/-]+=*$/.test(token),
}
console.log(JSON.stringify(checks))
if (!checks.bearerTokenFormatValid) {
console.error('::error::CI secret is not a valid Bearer token. No credential value has been logged.')
process.exitCode = 1
} else {
const base = process.env.SKIN_SITE_URL.replace(/\/$/, '')
if (new URL(base).protocol !== 'https:') throw new Error('HTTPS is required')
try {
const response = await fetch(base + '/starlight/launcher/admin', {
headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' },
redirect: 'error', signal: AbortSignal.timeout(30000),
})
console.log('Read-only CI authentication HTTP status: ' + response.status)
await response.body?.cancel()
if (!response.ok) process.exitCode = 1
} catch {
console.error('::error::Read-only CI authentication request failed; credentials have not been logged.')
process.exitCode = 1
}
}
NODE
+56
View File
@@ -0,0 +1,56 @@
name: Sync release to skin site
on:
workflow_dispatch:
inputs:
tag:
description: Existing GitHub release tag to sync (for example v1.0.2-beta10)
required: true
type: string
permissions:
contents: read
concurrency:
group: skin-site-${{ inputs.tag }}
cancel-in-progress: false
jobs:
publish:
name: Upload and activate existing release
runs-on: ubuntu-24.04
timeout-minutes: 40
steps:
- name: Check out publisher
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
with:
node-version-file: .nvmrc
- name: Download complete update package
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo '::error::Expected a semantic version tag beginning with v'
exit 1
fi
version="${RELEASE_TAG#v}"
gh release download "${RELEASE_TAG}" \
--pattern "Starlight_Launcher_${version}_skin-site-update.zip" \
--dir release-check
- name: Publish to skin site
env:
RELEASE_TAG: ${{ inputs.tag }}
SKIN_SITE_TOKEN: ${{ secrets.SKIN_SITE_CI_UPLOAD_TOKEN }}
SKIN_SITE_URL: ${{ vars.SKIN_SITE_URL || 'https://skin.starlight.cool' }}
run: |
set -euo pipefail
version="${RELEASE_TAG#v}"
node scripts/axolotl/publish-skin-site.mjs \
"release-check/Starlight_Launcher_${version}_skin-site-update.zip" "${RELEASE_TAG}"
Generated
+2 -2
View File
@@ -6841,7 +6841,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus" name = "theseus"
version = "1.0.2-beta7" version = "1.0.2-beta11"
dependencies = [ dependencies = [
"ariadne", "ariadne",
"async-minecraft-ping", "async-minecraft-ping",
@@ -6945,7 +6945,7 @@ dependencies = [
[[package]] [[package]]
name = "theseus_gui" name = "theseus_gui"
version = "1.0.2-beta7" version = "1.0.2-beta11"
dependencies = [ dependencies = [
"ashpd", "ashpd",
"async_zip", "async_zip",
+75 -74
View File
@@ -1,76 +1,77 @@
{ {
"name": "@modrinth/app-frontend", "name": "@modrinth/app-frontend",
"private": true, "private": true,
"version": "1.0.2-beta7", "version": "1.0.2-beta11",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend", "intl:prune-local": "pnpm -w scripts i18n-icu-contract prune-local --scope apps/app-frontend",
"test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\"" "test": "vue-tsc --noEmit && node --test \"src/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "6.3.1", "@dnd-kit/core": "6.3.1",
"@dnd-kit/utilities": "3.2.2", "@dnd-kit/utilities": "3.2.2",
"@dnd-kit/vue": "^0.5.0", "@dnd-kit/vue": "^0.5.0",
"@lobehub/icons-static-svg": "1.94.0", "@lobehub/icons-static-svg": "1.94.0",
"@modrinth/api-client": "workspace:^", "@modrinth/api-client": "workspace:^",
"@modrinth/assets": "workspace:*", "@modrinth/assets": "workspace:*",
"@modrinth/server": "workspace:*", "@modrinth/server": "workspace:*",
"@modrinth/ui": "workspace:*", "@modrinth/ui": "workspace:*",
"@modrinth/utils": "workspace:*", "@modrinth/utils": "workspace:*",
"@sfirew/minecraft-motd-parser": "^1.1.6", "@sfirew/minecraft-motd-parser": "^1.1.6",
"@tanstack/vue-query": "5.90.7", "@tanstack/vue-query": "5.90.7",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-dialog": "^2.2.1", "@tauri-apps/plugin-dialog": "^2.2.1",
"@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "~2.5.7", "@tauri-apps/plugin-http": "~2.5.7",
"@tauri-apps/plugin-opener": "^2.2.6", "@tauri-apps/plugin-opener": "^2.2.6",
"@tauri-apps/plugin-os": "^2.2.1", "@tauri-apps/plugin-os": "^2.2.1",
"@tauri-apps/plugin-updater": "^2.7.1", "@tauri-apps/plugin-updater": "^2.7.1",
"@tauri-apps/plugin-window-state": "^2.2.2", "@tauri-apps/plugin-window-state": "^2.2.2",
"@types/three": "^0.172.0", "@types/three": "^0.172.0",
"@vueuse/core": "^11.1.0", "@vueuse/core": "^11.1.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"deepslate": "0.26.0", "deepslate": "0.26.0",
"fflate": "0.8.2", "fflate": "0.8.2",
"floating-vue": "^5.2.2", "floating-vue": "^5.2.2",
"fuse.js": "^6.6.2", "fuse.js": "^6.6.2",
"intl-messageformat": "^10.7.7", "intl-messageformat": "^10.7.7",
"monaco-editor": "^0.56.0", "monaco-editor": "^0.56.0",
"ofetch": "^1.3.4", "ofetch": "^1.3.4",
"overlayscrollbars": "^2.15.1", "overlayscrollbars": "^2.15.1",
"pinia": "^3.0.0", "pinia": "^3.0.0",
"three": "^0.172.0", "three": "^0.172.0",
"vite-svg-loader": "^5.1.0", "vite-svg-loader": "^5.1.0",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^10.0.0", "vue-i18n": "^10.0.0",
"vue-router": "^4.6.0", "vue-router": "^4.6.0",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0",
}, "xxhash-wasm": "1.1.0"
"devDependencies": { },
"@eslint/compat": "^1.1.1", "devDependencies": {
"@formatjs/cli": "^6.2.12", "@eslint/compat": "^1.1.1",
"@modrinth/tooling-config": "workspace:*", "@formatjs/cli": "^6.2.12",
"@nuxt/eslint-config": "^0.5.6", "@modrinth/tooling-config": "workspace:*",
"@vitejs/plugin-vue": "^6.0.3", "@nuxt/eslint-config": "^0.5.6",
"autoprefixer": "^10.4.19", "@vitejs/plugin-vue": "^6.0.3",
"eslint": "^9.9.1", "autoprefixer": "^10.4.19",
"eslint-plugin-turbo": "^2.5.4", "eslint": "^9.9.1",
"postcss": "^8.4.39", "eslint-plugin-turbo": "^2.5.4",
"prettier": "^3.2.5", "postcss": "^8.4.39",
"sass": "^1.74.1", "prettier": "^3.2.5",
"tailwindcss": "^3.4.4", "sass": "^1.74.1",
"typescript": "^5.5.4", "tailwindcss": "^3.4.4",
"vite": "^8.0.0", "typescript": "^5.5.4",
"vite-plugin-vue-devtools": "^8.2.1", "vite": "^8.0.0",
"vue-component-type-helpers": "^3.1.8", "vite-plugin-vue-devtools": "^8.2.1",
"vue-tsc": "^2.1.6" "vue-component-type-helpers": "^3.1.8",
}, "vue-tsc": "^2.1.6"
"packageManager": "pnpm@9.4.0", },
"web-types": "../../web-types.json" "packageManager": "pnpm@9.4.0",
"web-types": "../../web-types.json"
} }
+5 -5
View File
@@ -94,13 +94,14 @@ import NavButton from '@/components/ui/NavButton.vue'
import NavRail from '@/components/ui/NavRail.vue' import NavRail from '@/components/ui/NavRail.vue'
import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue' import OnboardingOverlay from '@/components/ui/onboarding/OnboardingOverlay.vue'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import SkinSiteSessionFrame from '@/components/ui/SkinSiteSessionFrame.vue'
import RemoteAnnouncements from '@/components/ui/RemoteAnnouncements.vue' import RemoteAnnouncements from '@/components/ui/RemoteAnnouncements.vue'
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue' import SplashScreen from '@/components/ui/SplashScreen.vue'
import StartupAnnouncementModal from '@/components/ui/StartupAnnouncementModal.vue'
import WindowControls from '@/components/ui/WindowControls.vue' import WindowControls from '@/components/ui/WindowControls.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js' import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { initSkinSiteSession } from '@/composables/skin-site-session'
import { useDropImport } from '@/composables/useDropImport' import { useDropImport } from '@/composables/useDropImport'
import { setupKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError' import { minecraftLaunchErrorKey } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config' import { AxolotlBrandConfig, config, getOfficialLabrinthBaseUrl } from '@/config'
@@ -132,6 +133,7 @@ import {
} from '@/helpers/settings.ts' } from '@/helpers/settings.ts'
import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts' import { getSidebarExpanded, setSidebarExpanded } from '@/helpers/sidebar-state.ts'
import { get_opening_command, initialize_state } from '@/helpers/state' import { get_opening_command, initialize_state } from '@/helpers/state'
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { import {
areUpdatesEnabled, areUpdatesEnabled,
backupAppDbForUpdate, backupAppDbForUpdate,
@@ -145,7 +147,6 @@ import {
isNetworkMetered, isNetworkMetered,
setRestartAfterPendingUpdate, setRestartAfterPendingUpdate,
} from '@/helpers/utils.js' } from '@/helpers/utils.js'
import { installUiScale, setUiScale } from '@/helpers/ui-scale'
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts' import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
import i18n, { resolveInitialLocale } from '@/i18n.config' import i18n, { resolveInitialLocale } from '@/i18n.config'
import { import {
@@ -169,7 +170,6 @@ import { createServerInstall, provideServerInstall } from '@/providers/server-in
import { setupProviders } from '@/providers/setup' import { setupProviders } from '@/providers/setup'
import { setupAuthProvider } from '@/providers/setup/auth' import { setupAuthProvider } from '@/providers/setup/auth'
import { setupLoadingStateProvider } from '@/providers/setup/loading-state' import { setupLoadingStateProvider } from '@/providers/setup/loading-state'
import { setupKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useError } from '@/store/error.js' import { useError } from '@/store/error.js'
import { useTheming } from '@/store/state' import { useTheming } from '@/store/state'
@@ -1159,6 +1159,7 @@ async function setupApp() {
stateInitialized.value = true stateInitialized.value = true
showOnboarding.value = !onboarded showOnboarding.value = !onboarded
void reconcileMojangAuthSourceAtStartup().catch(handleError) void reconcileMojangAuthSourceAtStartup().catch(handleError)
void initSkinSiteSession().catch(handleError)
isMaximized.value = await getCurrentWindow().isMaximized() isMaximized.value = await getCurrentWindow().isMaximized()
@@ -2228,7 +2229,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<template> <template>
<AnniversaryCelebration /> <AnniversaryCelebration />
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region /> <SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<SkinSiteSessionFrame v-if="stateInitialized" />
<InstancePlayerModal v-if="stateInitialized" /> <InstancePlayerModal v-if="stateInitialized" />
<StartupAnnouncementModal ref="startupAnnouncementModal" /> <StartupAnnouncementModal ref="startupAnnouncementModal" />
<RemoteAnnouncements :ready="stateInitialized && !showOnboarding && !stateFailed" /> <RemoteAnnouncements :ready="stateInitialized && !showOnboarding && !stateFailed" />
+295 -64
View File
@@ -32,6 +32,212 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
] ]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.2-beta11',
version: '1.0.2-beta11',
publishedAt: '2026-10-20',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta11',
'zh-CN': 'Starlight Launcher 1.0.2-beta11',
},
changes: {
fixed: [
{
'en-US':
'Filled missing Chinese translations across skin site sign-in, announcements, verification, registration, account settings, downloads and maps, and localized remaining user and Wiki labels.',
'zh-CN':
'补齐皮肤站登录、公告、验证、注册、账号设置、下载和地图等界面的中文翻译,并修复用户信息与维基权限选项中的英文标签。',
},
{
'en-US':
'Renew the saved skin site JWT on launcher startup before loading the user and announcements. Fixed overlapping refresh, sign-in and sign-out requests that could leave the account in an incorrect state.',
'zh-CN':
'启动启动器时先刷新已保存的皮肤站 JWT,再加载用户与公告;修复刷新、登录和退出请求交错时可能导致的账号状态异常。',
},
{
'en-US':
'Announcements now reload after sign-in, show a sign-in hint when signed out, and offer a retry action after a loading failure.',
'zh-CN': '公告在登录后自动重新加载,未登录时显示登录提示,加载失败时可直接重试。',
},
],
changed: [
{
'en-US':
'Skin site release publishing now uses chunked uploads and reports synchronization failures explicitly. Localization checks also scan message IDs used in source to catch missing catalog entries.',
'zh-CN':
'皮肤站自动发布改用分片上传,同步失败会明确报错;国际化检查新增源码词条扫描,防止再次遗漏语言包内容。',
},
],
},
notes: {
'en-US':
'If the previously saved JWT has already expired, sign in again once to restore the session.',
'zh-CN': '如果此前保存的 JWT 已经过期,需要重新登录一次以恢复会话。',
},
},
{
id: 'launcher-1.0.2-beta10',
version: '1.0.2-beta10',
publishedAt: '2026-10-20',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta10',
'zh-CN': 'Starlight Launcher 1.0.2-beta10',
},
changes: {
security: [
{
'en-US':
'Backported the upstream Axolotl modpack path traversal fix to prevent malicious modpacks from writing files outside the intended installation directory.',
'zh-CN':
'合入上游 Axolotl 整合包路径穿越安全补丁,阻止恶意整合包向安装目录之外写入文件。',
},
{
'en-US':
'Strengthened path validation for Modrinth and CurseForge modpacks, server installations, cached files and modpack uninstall cleanup, including unsafe Windows path aliases.',
'zh-CN':
'加强 Modrinth 与 CurseForge 整合包、服务端安装、缓存文件及整合包卸载清理的路径校验,拦截不安全的 Windows 路径别名。',
},
],
},
notes: {
'en-US':
'Users of earlier versions should update promptly, especially before installing modpacks from untrusted sources.',
'zh-CN': '建议旧版本用户尽快更新,尤其是在安装来源不明的整合包之前。',
},
},
{
id: 'launcher-1.0.2-beta9',
version: '1.0.2-beta9',
publishedAt: '2026-10-19',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta9',
'zh-CN': 'Starlight Launcher 1.0.2-beta9',
},
changes: {
added: [
{
'en-US':
'Finished the skin site admin console: system overview, announcements, user and player management, review paper editor, mod upload with batch operations, chat room management with real-time voice, servers, audit log, resource downloads, donations and launcher releases.',
'zh-CN':
'补全皮肤站管理台:系统概览、公告、用户与玩家管理、审核试卷编辑器、Mod 上传与批量操作、聊天室房间管理与实时语音、服务器、审计日志、资源下载、捐赠与启动器版本管理。',
},
{
'en-US':
'Added Wiki management to the admin console, including a MediaWiki migration center that imports pages from an existing wiki and lets you review, publish or roll back the result.',
'zh-CN':
'管理台新增维基管理,包含 MediaWiki 迁移中心:可从现有 Wiki 导入页面,并对结果进行审核、发布或回滚。',
},
{
'en-US':
'Added a read-only Wiki reader in the launcher: render pages, follow links between entries, and keep a back stack while browsing.',
'zh-CN': '启动器新增维基阅读页:渲染词条正文、跟随词条链接跳转,并在浏览时保留返回栈。',
},
],
changed: [
{
'en-US':
'Releases are now published to the skin site automatically when a tag is pushed, so the download page stays in sync without a manual upload.',
'zh-CN': '推送 tag 发版时会自动把版本同步到皮肤站,下载页无需再手动上传即可保持同步。',
},
{
'en-US': 'Server management pages are only shown to web administrators.',
'zh-CN': '服务器管理页仅对网页管理员展示。',
},
],
fixed: [
{
'en-US': 'Fixed the Chinese and English text on the skin site home navigation cards.',
'zh-CN': '修复皮肤站主页导航卡片的中英文案。',
},
{
'en-US': 'Renamed the public download and map endpoints to resolve a duplicate export.',
'zh-CN': '重命名公开下载与地图接口,解决导出冲突。',
},
],
},
},
{
id: 'launcher-1.0.2-beta8',
version: '1.0.2-beta8',
publishedAt: '2026-10-18',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta8',
'zh-CN': 'Starlight Launcher 1.0.2-beta8',
},
changes: {
added: [
{
'en-US':
'The skin site is now fully native inside the launcher: sign-in, registration, password reset, account settings, players, downloads, world map and player review all run through the launcher UI instead of an embedded web page.',
'zh-CN':
'皮肤站全面改为启动器原生界面:登录、注册、找回密码、账号设置、角色管理、资源下载、服务器地图与玩家审核均不再依赖内嵌网页。',
},
{
'en-US':
'Added a bug tracker: browse and filter issues, read details and comments, post replies, create new issues and follow notifications.',
'zh-CN':
'新增问题追踪:可浏览与筛选问题、查看详情与评论、发表回复、提交新问题并接收通知。',
},
{
'en-US':
'Added an admin console with a permission gate: system overview, announcements, users, players, player review and launcher releases.',
'zh-CN':
'新增管理控制台(带权限校验):系统概览、公告管理、用户管理、玩家管理、玩家审核与启动器版本管理。',
},
],
changed: [
{
'en-US':
'Hosted modpack downloads now go through the dedicated download server, keeping large downloads off the main API host.',
'zh-CN': '整合包下载改为走独立的下载服务器,避免大文件下载挤占主 API 服务。',
},
],
fixed: [
{
'en-US':
'LogShare now collects logs with a lightweight reader, fixing crash log uploads that previously failed.',
'zh-CN': 'LogShare 改用轻量日志收集,修复崩溃日志上传失败的问题。',
},
{
'en-US': 'The onboarding tour now scrolls each step into view when the step changes.',
'zh-CN': '引导教程在切换步骤时自动滚动到目标元素。',
},
{
'en-US': 'Fixed a type mismatch while collecting crash context credentials.',
'zh-CN': '修复崩溃上下文凭据收集中的类型不匹配问题。',
},
],
},
},
{
id: 'launcher-1.0.2-beta7',
version: '1.0.2-beta7',
publishedAt: '2026-10-17',
title: {
'en-US': 'Starlight Launcher 1.0.2-beta7',
'zh-CN': 'Starlight Launcher 1.0.2-beta7',
},
changes: {
fixed: [
{
'en-US': 'Fixed real-time log backlog that could stall the console under heavy output.',
'zh-CN': '修复实时日志积压导致控制台卡顿的问题。',
},
{
'en-US':
'The update channel now defaults to the build type: installing a Beta build starts on the Beta channel instead of Release.',
'zh-CN':
'更新通道默认值改为跟随安装包版本:安装 Beta 版后默认使用 Beta 通道,不再回落为 Release。',
},
{
'en-US':
'On Linux, the default game directory for StarLight instances no longer points at the system install path (e.g. /usr/bin); it falls back to the home directory.',
'zh-CN':
'Linux 上 StarLight 实例的默认游戏目录不再指向系统安装路径(如 /usr/bin),改为回退到用户主目录。',
},
],
},
},
{ {
id: 'launcher-1.0.2-beta6', id: 'launcher-1.0.2-beta6',
version: '1.0.2-beta6', version: '1.0.2-beta6',
@@ -43,15 +249,19 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
added: [ added: [
{ {
'en-US': 'Added keyboard shortcut settings: enable and rebind navigation and quick-scrolling shortcuts.', 'en-US':
'Added keyboard shortcut settings: enable and rebind navigation and quick-scrolling shortcuts.',
'zh-CN': '新增键盘快捷键设置:可启用并自定义导航与快速滚动快捷键。', 'zh-CN': '新增键盘快捷键设置:可启用并自定义导航与快速滚动快捷键。',
}, },
{ {
'en-US': 'Added crash log sharing and AI analysis: share crash diagnostics to LogShare for a structured summary and optional AI analysis, with a rebuilt crash window that browses log files and streams the analysis.', 'en-US':
'zh-CN': '新增崩溃日志分享与 AI 分析:可将崩溃诊断分享到 LogShare 获取结构化摘要与 AI 分析;崩溃窗口重做为双栏布局,可浏览日志文件并流式查看分析结果。', 'Added crash log sharing and AI analysis: share crash diagnostics to LogShare for a structured summary and optional AI analysis, with a rebuilt crash window that browses log files and streams the analysis.',
'zh-CN':
'新增崩溃日志分享与 AI 分析:可将崩溃诊断分享到 LogShare 获取结构化摘要与 AI 分析;崩溃窗口重做为双栏布局,可浏览日志文件并流式查看分析结果。',
}, },
{ {
'en-US': 'Crash logs now also collect JVM crash reports (hs_err_pid*.log) and launcher logs.', 'en-US':
'Crash logs now also collect JVM crash reports (hs_err_pid*.log) and launcher logs.',
'zh-CN': '崩溃日志现在也会收集 JVM 崩溃报告(hs_err_pid*.log)与启动器日志。', 'zh-CN': '崩溃日志现在也会收集 JVM 崩溃报告(hs_err_pid*.log)与启动器日志。',
}, },
{ {
@@ -92,12 +302,16 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
added: [ added: [
{ {
'en-US': 'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.', 'en-US':
'zh-CN': '新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。', 'Added a remote announcements center: a bell in the top bar opens a panel listing launcher announcements fetched from the Starlight skin site.',
'zh-CN':
'新增远程公告中心:顶栏的铃铛可打开公告面板,展示从 Starlight 皮肤站拉取的启动器公告。',
}, },
{ {
'en-US': 'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.', 'en-US':
'zh-CN': '新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。', 'Added a custom Minecraft window title option: set a global toggle and default title, and let each instance override it. The game window is renamed on Windows.',
'zh-CN':
'新增自定义 Minecraft 窗口标题:可设置全局开关与默认标题,并允许各实例单独覆盖;Windows 下会重命名游戏窗口。',
}, },
], ],
fixed: [ fixed: [
@@ -119,24 +333,31 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
added: [ added: [
{ {
'en-US': 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.', 'en-US':
'zh-CN': '皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。', 'Added armor and armor-trim rendering to the skin preview, letting you try on armor pieces and trim patterns directly in the skin editor.',
'zh-CN':
'皮肤预览新增盔甲与盔甲纹饰渲染,可在皮肤编辑器内直接试穿各部位盔甲并调整纹饰图案。',
}, },
{ {
'en-US': 'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.', 'en-US':
'Armor preview selections in the skin editor are now saved locally, so they persist across sessions.',
'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。', 'zh-CN': '皮肤编辑器中的盔甲预览选择现在会保存在本地,跨会话保留。',
}, },
], ],
changed: [ changed: [
{ {
'en-US': 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.', 'en-US':
'zh-CN': '优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。', 'Improved download scheduling to reduce concurrency automatically when tasks are waiting for capacity, making large Minecraft installations more reliable on constrained connections.',
'zh-CN':
'优化下载任务调度:任务因容量不足排队时会自动降低并发,提升大型 Minecraft 版本在受限网络环境下的安装稳定性。',
}, },
], ],
fixed: [ fixed: [
{ {
'en-US': 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.', 'en-US':
'zh-CN': '修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。', 'Fixed downloads timing out while waiting for internal connection capacity, which could cause Minecraft installations to fail even when the network was available.',
'zh-CN':
'修复下载任务等待内部连接容量时过早超时,导致网络正常的情况下 Minecraft 安装仍可能失败的问题。',
}, },
{ {
'en-US': 'Fixed instance group changes not saving for some instances.', 'en-US': 'Fixed instance group changes not saving for some instances.',
@@ -156,8 +377,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
fixed: [ fixed: [
{ {
'en-US': 'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.', 'en-US':
'zh-CN': '修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。', 'Fixed the anniversary celebration banner not showing above the window title bar, and made the banner background semi-transparent so the content behind it stays visible.',
'zh-CN':
'修复周年庆庆祝横幅被窗口标题栏遮挡、无法显示的问题,并将横幅背景改为半透明,避免遮住下方内容。',
}, },
], ],
}, },
@@ -173,8 +396,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
fixed: [ fixed: [
{ {
'en-US': 'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.', 'en-US':
'zh-CN': '修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。', 'Fixed the launcher failing to start with "no such table: synced_pack_catalog": the cross-instance sync migration was missing the synced_pack_catalog and synced_pack_instances tables.',
'zh-CN':
'修复启动器因 "no such table: synced_pack_catalog" 无法启动的问题:跨实例同步迁移遗漏了 synced_pack_catalog 与 synced_pack_instances 两张表。',
}, },
], ],
}, },
@@ -190,15 +415,20 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
added: [ added: [
{ {
'en-US': 'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.', 'en-US':
'zh-CN': '跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。', 'Cross-instance sync: share game options, resource packs, data packs, multiplayer servers, command history, and creative hotbars across instances, with per-option toggles and a fine-grained game-settings editor.',
'zh-CN':
'跨实例同步:可跨实例共享游戏选项、资源包、数据包、多人服务器、命令历史与创造模式快捷栏,支持逐项开关与精细的游戏设置编辑器。',
}, },
{ {
'en-US': 'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.', 'en-US':
'zh-CN': 'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。', 'Starlight turns 12! On October 4th every year the launcher now shows a celebration banner and falling confetti. The anniversary number is computed from 2014.',
'zh-CN':
'Starlight 十二周年!每年 10 月 4 日,启动器会显示庆祝横幅与飘落的彩带、蛋糕,周年数按 2014 年起算。',
}, },
{ {
'en-US': 'Added a developer feature flag to preview the anniversary celebration at any time.', 'en-US':
'Added a developer feature flag to preview the anniversary celebration at any time.',
'zh-CN': '新增开发者功能开关,可随时预览周年庆效果。', 'zh-CN': '新增开发者功能开关,可随时预览周年庆效果。',
}, },
], ],
@@ -215,8 +445,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
added: [ added: [
{ {
'en-US': 'Instance backups: create deduplicated snapshots of an instance while it is closed, preview and restore them, exclude specific files or folders, and move the backup repository.', 'en-US':
'zh-CN': '实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。', 'Instance backups: create deduplicated snapshots of an instance while it is closed, preview and restore them, exclude specific files or folders, and move the backup repository.',
'zh-CN':
'实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。',
}, },
{ {
'en-US': "Customize the launcher's UI font and monospace font in appearance settings.", 'en-US': "Customize the launcher's UI font and monospace font in appearance settings.",
@@ -225,12 +457,16 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
], ],
fixed: [ fixed: [
{ {
'en-US': 'Fixed beta builds not auto-updating from beta9 to beta10 (prerelease versions were compared as text instead of by number).', 'en-US':
'zh-CN': '修复 beta 版本无法从 beta9 自动更新到 beta10 的问题(预发布版本此前按文本而非数字比较)。', 'Fixed beta builds not auto-updating from beta9 to beta10 (prerelease versions were compared as text instead of by number).',
'zh-CN':
'修复 beta 版本无法从 beta9 自动更新到 beta10 的问题(预发布版本此前按文本而非数字比较)。',
}, },
{ {
'en-US': 'Fixed a cross-crate visibility issue that prevented the launcher from compiling with the ignore-SSL-errors option.', 'en-US':
'zh-CN': '修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。', 'Fixed a cross-crate visibility issue that prevented the launcher from compiling with the ignore-SSL-errors option.',
'zh-CN':
'修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。',
}, },
], ],
}, },
@@ -246,11 +482,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
added: [ added: [
{ {
'en-US': 'Reworked the instance-page tour into four steps covering content, files, worlds, and logs.', 'en-US':
'Reworked the instance-page tour into four steps covering content, files, worlds, and logs.',
'zh-CN': '重做实例页导览,分为内容、文件、世界、日志四步。', 'zh-CN': '重做实例页导览,分为内容、文件、世界、日志四步。',
}, },
{ {
'en-US': 'When the install location is a drive root, the installer now creates a Starlight subfolder automatically.', 'en-US':
'When the install location is a drive root, the installer now creates a Starlight subfolder automatically.',
'zh-CN': '安装位置选到盘符根目录时,安装器会自动创建 Starlight 子文件夹。', 'zh-CN': '安装位置选到盘符根目录时,安装器会自动创建 Starlight 子文件夹。',
}, },
], ],
@@ -264,11 +502,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复开发者功能开关无法保存的问题。', 'zh-CN': '修复开发者功能开关无法保存的问题。',
}, },
{ {
'en-US': 'Fixed instance group edits not saving for instances without an applied content set.', 'en-US':
'Fixed instance group edits not saving for instances without an applied content set.',
'zh-CN': '修复没有应用内容集的实例无法保存分组修改的问题。', 'zh-CN': '修复没有应用内容集的实例无法保存分组修改的问题。',
}, },
{ {
'en-US': 'Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.', 'en-US':
'Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.',
'zh-CN': '修复前后端功能开关不同步导致启动器启动崩溃的问题。', 'zh-CN': '修复前后端功能开关不同步导致启动器启动崩溃的问题。',
}, },
{ {
@@ -385,12 +625,10 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{ {
'en-US': 'en-US':
'Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.', 'Fixed the onboarding tour skipping steps automatically, getting stuck, or not navigating between steps; the tour bubble no longer covers the left navigation bar.',
'zh-CN': 'zh-CN': '修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
'修复新手导览自动跳过、卡住或不跳转的问题;导览气泡不再遮挡左侧导航栏。',
}, },
{ {
'en-US': 'en-US': 'Fixed the onboarding tour target on the minimal home layout.',
'Fixed the onboarding tour target on the minimal home layout.',
'zh-CN': '修复精简主页下导览目标找不到的问题。', 'zh-CN': '修复精简主页下导览目标找不到的问题。',
}, },
], ],
@@ -421,13 +659,11 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
], ],
fixed: [ fixed: [
{ {
'en-US': 'en-US': 'Fixed the developer feature flags page rendering blank.',
'Fixed the developer feature flags page rendering blank.',
'zh-CN': '修复开发者“功能开关”页面空白的问题。', 'zh-CN': '修复开发者“功能开关”页面空白的问题。',
}, },
{ {
'en-US': 'en-US': 'Fixed a build error that prevented the launcher from compiling.',
'Fixed a build error that prevented the launcher from compiling.',
'zh-CN': '修复导致启动器无法编译的构建错误。', 'zh-CN': '修复导致启动器无法编译的构建错误。',
}, },
], ],
@@ -444,8 +680,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
fixed: [ fixed: [
{ {
'en-US': 'en-US': 'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
'Fixed a build error in 1.0.1-beta3 that prevented the launcher from compiling.',
'zh-CN': '修复 1.0.1-beta3 中导致启动器无法编译的构建错误。', 'zh-CN': '修复 1.0.1-beta3 中导致启动器无法编译的构建错误。',
}, },
], ],
@@ -504,8 +739,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
], ],
fixed: [ fixed: [
{ {
'en-US': 'en-US': 'Improved the reliability of live logs and database switching.',
'Improved the reliability of live logs and database switching.',
'zh-CN': '提高实时日志与数据库切换的可靠性。', 'zh-CN': '提高实时日志与数据库切换的可靠性。',
}, },
], ],
@@ -597,15 +831,13 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复更新清单的下载地址与发布顺序,更新检查现在可以成功。', 'zh-CN': '修复更新清单的下载地址与发布顺序,更新检查现在可以成功。',
}, },
{ {
'en-US': 'en-US': 'Updated the signing key pair used to sign update packages.',
'Updated the signing key pair used to sign update packages.',
'zh-CN': '更新了用于签名更新包的密钥对。', 'zh-CN': '更新了用于签名更新包的密钥对。',
}, },
], ],
changed: [ changed: [
{ {
'en-US': 'en-US': 'Removed upstream release announcements prior to 1.0.0-beta1.',
'Removed upstream release announcements prior to 1.0.0-beta1.',
'zh-CN': '移除了 1.0.0-beta1 之前的上游版本公告。', 'zh-CN': '移除了 1.0.0-beta1 之前的上游版本公告。',
}, },
], ],
@@ -622,13 +854,11 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
changes: { changes: {
added: [ added: [
{ {
'en-US': 'en-US': 'Installing a modpack now lets you choose the game directory.',
'Installing a modpack now lets you choose the game directory.',
'zh-CN': '一键安装整合包时现在可以选择游戏目录。', 'zh-CN': '一键安装整合包时现在可以选择游戏目录。',
}, },
{ {
'en-US': 'en-US': 'Approved (hosted) modpack sync and StarLight instance mode were added.',
'Approved (hosted) modpack sync and StarLight instance mode were added.',
'zh-CN': '新增获批整合包同步与 StarLight 实例模式。', 'zh-CN': '新增获批整合包同步与 StarLight 实例模式。',
}, },
{ {
@@ -639,8 +869,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
], ],
changed: [ changed: [
{ {
'en-US': 'en-US': 'The about page now plays a looping wither victory animation.',
'The about page now plays a looping wither victory animation.',
'zh-CN': '关于页现在会播放循环的凋灵胜利动画。', 'zh-CN': '关于页现在会播放循环的凋灵胜利动画。',
}, },
], ],
@@ -652,8 +881,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'修复从 .minecraft 文件夹导入实例的多项问题(内容复制、版本隔离、符号链接、加载器版本识别等)。', '修复从 .minecraft 文件夹导入实例的多项问题(内容复制、版本隔离、符号链接、加载器版本识别等)。',
}, },
{ {
'en-US': 'en-US': 'Fixed the instance log view stopping updates and overlapping log lines.',
'Fixed the instance log view stopping updates and overlapping log lines.',
'zh-CN': '修复实例日志窗口停更与日志行重叠的问题。', 'zh-CN': '修复实例日志窗口停更与日志行重叠的问题。',
}, },
{ {
@@ -672,8 +900,7 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
'zh-CN': '修复未登录皮肤站时启动卡住且不再弹出玩家选择的问题。', 'zh-CN': '修复未登录皮肤站时启动卡住且不再弹出玩家选择的问题。',
}, },
{ {
'en-US': 'en-US': 'Fixed various skin site login, player list sync, and display issues.',
'Fixed various skin site login, player list sync, and display issues.',
'zh-CN': '修复皮肤站登录、玩家列表同步及若干显示问题。', 'zh-CN': '修复皮肤站登录、玩家列表同步及若干显示问题。',
}, },
], ],
@@ -692,7 +919,8 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{ {
'en-US': 'en-US':
'The launcher has been rebranded from Axolotl Launcher to Starlight Launcher, with a new icon and a refreshed about page.', 'The launcher has been rebranded from Axolotl Launcher to Starlight Launcher, with a new icon and a refreshed about page.',
'zh-CN': '启动器已从 Axolotl Launcher 更名为 Starlight Launcher,并更换了全新图标与关于页。', 'zh-CN':
'启动器已从 Axolotl Launcher 更名为 Starlight Launcher,并更换了全新图标与关于页。',
}, },
{ {
'en-US': 'en-US':
@@ -706,7 +934,8 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
], ],
added: [ added: [
{ {
'en-US': 'A "Starlight" entry was added to the sidebar, which embeds the StarLight skin site.', 'en-US':
'A "Starlight" entry was added to the sidebar, which embeds the StarLight skin site.',
'zh-CN': '侧边栏新增「斯达莱特」入口,内嵌 StarLight 皮肤站。', 'zh-CN': '侧边栏新增「斯达莱特」入口,内嵌 StarLight 皮肤站。',
}, },
{ {
@@ -725,10 +954,12 @@ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{ {
'en-US': 'en-US':
'The hard-coded Starlight official server was removed from "Pinned servers"; only servers you favorite yourself are shown now.', 'The hard-coded Starlight official server was removed from "Pinned servers"; only servers you favorite yourself are shown now.',
'zh-CN': '移除了「固定的服务器」中写死的 Starlight 官方服务器,现在只显示你自己收藏的服务器。', 'zh-CN':
'移除了「固定的服务器」中写死的 Starlight 官方服务器,现在只显示你自己收藏的服务器。',
}, },
{ {
'en-US': 'The swimming axolotl animation on the about page was replaced with a static nether star.', 'en-US':
'The swimming axolotl animation on the about page was replaced with a static nether star.',
'zh-CN': '关于页中游动的美西螈动画已替换为静止的下界之星。', 'zh-CN': '关于页中游动的美西螈动画已替换为静止的下界之星。',
}, },
], ],
@@ -1,11 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui' import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue' import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import SkinSiteLoginModal from '@/components/ui/skin-site/SkinSiteLoginModal.vue'
import { import {
openSkinSiteLogin,
requestSkinSitePlayers, requestSkinSitePlayers,
skinSitePlayers, skinSitePlayers,
skinSiteStatus, skinSiteStatus,
@@ -28,7 +27,7 @@ const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>() const modal = ref<InstanceType<typeof NewModal>>()
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>() const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
const router = useRouter() const skinSiteLoginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
const accounts = ref<PlayerChoice[]>([]) const accounts = ref<PlayerChoice[]>([])
const active = ref(false) const active = ref(false)
const loading = ref(false) const loading = ref(false)
@@ -137,9 +136,8 @@ function signInSkinSite() {
rejectSelection?.(new PlayerSelectionNavigatedAwayError()) rejectSelection?.(new PlayerSelectionNavigatedAwayError())
resolveSelection = undefined resolveSelection = undefined
rejectSelection = undefined rejectSelection = undefined
openSkinSiteLogin() skinSiteLoginModal.value?.show()
modal.value?.hide() modal.value?.hide()
void router.push('/starlight-skin')
} }
const unregister = registerInstancePlayerPicker((id, saved) => { const unregister = registerInstancePlayerPicker((id, saved) => {
@@ -246,5 +244,6 @@ onUnmounted(() => {
<p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p> <p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p>
</div> </div>
</NewModal> </NewModal>
<SkinSiteLoginModal ref="skinSiteLoginModal" @signed-in="refresh" />
<MinecraftLoginModal ref="microsoftLogin" @complete="refresh" /> <MinecraftLoginModal ref="microsoftLogin" @complete="refresh" />
</template> </template>
@@ -244,6 +244,7 @@
</div> </div>
</Accordion> </Accordion>
<MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" /> <MinecraftLoginModal ref="minecraftLoginModal" @complete="onMicrosoftLogin" />
<SkinSiteLoginModal ref="skinSiteLoginModal" @signed-in="onSkinSiteSignedIn" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -269,12 +270,12 @@ import { useQueryClient } from '@tanstack/vue-query'
import { listen } from '@tauri-apps/api/event' import { listen } from '@tauri-apps/api/event'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { computed, nextTick, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute } from 'vue-router'
import axolotlLogo from '@/assets/netherstar.png' import axolotlLogo from '@/assets/netherstar.png'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue' import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import SkinSiteLoginModal from '@/components/ui/skin-site/SkinSiteLoginModal.vue'
import { import {
openSkinSiteLogin,
requestSkinSitePlayers, requestSkinSitePlayers,
selectedSkinSitePlayerId, selectedSkinSitePlayerId,
selectSkinSitePlayer, selectSkinSitePlayer,
@@ -298,21 +299,20 @@ import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
import type { Skin } from '@/helpers/skins' import type { Skin } from '@/helpers/skins'
import { get_available_skins } from '@/helpers/skins' import { get_available_skins } from '@/helpers/skins'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager() const { handleError } = injectNotificationManager()
const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus() const { browserOffline, offline, refreshBrowserOffline } = useNetworkStatus()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const route = useRoute() const route = useRoute()
const router = useRouter() const skinSiteLoginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
const themeStore = useTheming()
async function goToSkinSiteLogin() { async function goToSkinSiteLogin() {
openSkinSiteLogin() skinSiteLoginModal.value?.show()
// The login action must reveal the iframe even if Minimal Home was selected. }
themeStore.homeLayout = 'standard'
await router.push('/').catch(handleError) function onSkinSiteSignedIn() {
void requestSkinSitePlayers().catch(() => {})
} }
const refreshingNetwork = ref(false) const refreshingNetwork = ref(false)
@@ -1,23 +0,0 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
frameTitle: {
id: 'app.starlight-skin.frame-title',
defaultMessage: 'StarLight Skin Site',
},
})
</script>
<template>
<iframe
:src="skinSiteFrameUrl"
allow="clipboard-write; clipboard-read"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
/>
</template>
@@ -1,65 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watch } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { clearHostedSession } from '@/helpers/hosted-packs'
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
watch(
[skinSiteStatus, () => skinSiteUser.value?.uuid],
() => {
void clearHostedSession().catch(() => {})
},
{ flush: 'sync' },
)
function connect() {
resetSkinSiteSession()
lastMessage = 0
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
}
onMounted(() => {
window.addEventListener('message', receive)
connect()
expiryTimer = setInterval(() => {
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
}, 10_000)
})
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
void clearHostedSession().catch(() => {})
})
</script>
<template>
<iframe
ref="frame"
:src="`${SKIN_SITE_ORIGIN}/`"
title="StarLight Skin Site session"
aria-hidden="true"
tabindex="-1"
class="pointer-events-none fixed -left-[10000px] top-0 h-px w-px opacity-0"
@load="connect"
/>
</template>
@@ -0,0 +1,307 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { type SkinSiteChatUserInfo, skinSiteChatUserSearch } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const props = withDefaults(
defineProps<{
roomId?: number
label?: string
placeholder?: string
excludeUuids?: string[]
allowedUuids?: string[]
allowedHint?: string
}>(),
{
roomId: undefined,
label: undefined,
placeholder: undefined,
excludeUuids: () => [],
allowedUuids: undefined,
allowedHint: undefined,
},
)
const model = defineModel<SkinSiteChatUserInfo | null>({ required: true })
const messages = defineMessages({
label: { id: 'app.skin-site.chat.user-picker.label', defaultMessage: 'Pick a user' },
placeholder: {
id: 'app.skin-site.chat.user-picker.placeholder',
defaultMessage: 'Search username, email or account ID',
},
byUser: { id: 'app.skin-site.chat.user-picker.by-user', defaultMessage: 'By user' },
byPlayer: { id: 'app.skin-site.chat.user-picker.by-player', defaultMessage: 'By player' },
hint: {
id: 'app.skin-site.chat.user-picker.hint',
defaultMessage: 'Fuzzy search on username and email, or search by account ID.',
},
playerHint: {
id: 'app.skin-site.chat.user-picker.player-hint',
defaultMessage: 'Enter a player name to find the bound user.',
},
empty: {
id: 'app.skin-site.chat.user-picker.empty',
defaultMessage: 'No matching user.',
},
banned: {
id: 'app.skin-site.chat.user-picker.banned',
defaultMessage: 'This account is banned.',
},
guest: {
id: 'app.skin-site.chat.user-picker.guest',
defaultMessage: 'Guests cannot become a manager or settlement owner.',
},
alreadyIn: {
id: 'app.skin-site.chat.user-picker.already-in',
defaultMessage: 'This user is already in the room.',
},
notAllowed: {
id: 'app.skin-site.chat.user-picker.not-allowed',
defaultMessage: 'This user is outside the allowed range.',
},
select: { id: 'app.skin-site.chat.user-picker.select', defaultMessage: 'Select' },
selected: {
id: 'app.skin-site.chat.user-picker.selected',
defaultMessage: 'Selected',
},
idLabel: { id: 'app.skin-site.chat.user-picker.id', defaultMessage: 'ID' },
emailLabel: { id: 'app.skin-site.chat.user-picker.email', defaultMessage: 'Email' },
registeredLabel: {
id: 'app.skin-site.chat.user-picker.registered',
defaultMessage: 'Registered',
},
bannedStatus: { id: 'app.skin-site.chat.user-picker.banned-status', defaultMessage: 'Banned' },
activeStatus: { id: 'app.skin-site.chat.user-picker.active-status', defaultMessage: 'Active' },
})
const keyword = ref('')
const searchMode = ref<'user' | 'player'>('user')
const results = ref<SkinSiteChatUserInfo[]>([])
const preview = ref<SkinSiteChatUserInfo | null>(null)
const loading = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
const currentLabel = computed(() => props.label ?? formatMessage(messages.label))
const currentPlaceholder = computed(() => props.placeholder ?? formatMessage(messages.placeholder))
watch([keyword, searchMode], () => {
clearTimeout(timer)
const query = keyword.value.trim()
if (!query) {
results.value = []
preview.value = null
loading.value = false
return
}
timer = setTimeout(() => void search(query), 300)
})
watch(model, (value) => {
if (value) return
keyword.value = ''
preview.value = null
results.value = []
})
watch(
() => props.roomId,
() => {
model.value = null
},
)
onBeforeUnmount(() => clearTimeout(timer))
async function search(query: string) {
const current = ++requestId
loading.value = true
try {
const res = await skinSiteChatUserSearch(query, props.roomId, searchMode.value)
if (current !== requestId) return
if (!res.success) throw new Error(res.errorMessage)
results.value = res.payload ?? []
preview.value = results.value.length === 1 ? results.value[0] : null
} catch (error) {
if (current === requestId) {
results.value = []
handleError(error)
}
} finally {
if (current === requestId) loading.value = false
}
}
function unavailableReason(user: SkinSiteChatUserInfo): string | null {
if (user.banned) return formatMessage(messages.banned)
if (user.permissions.includes('GUEST')) return formatMessage(messages.guest)
if (props.excludeUuids.includes(user.uuid)) return formatMessage(messages.alreadyIn)
if (props.allowedUuids && !props.allowedUuids.includes(user.uuid)) {
return props.allowedHint ?? formatMessage(messages.notAllowed)
}
return null
}
function selectPreview() {
if (preview.value && !unavailableReason(preview.value)) {
model.value = preview.value
}
}
function formatTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
</script>
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-semibold text-secondary">{{ currentLabel }}</span>
<div class="flex gap-2">
<select
v-model="searchMode"
class="shrink-0 rounded-lg border border-solid border-surface-5 bg-bg-raised px-2 py-1.5 text-xs text-contrast outline-none focus:border-brand"
>
<option value="user">{{ formatMessage(messages.byUser) }}</option>
<option value="player">{{ formatMessage(messages.byPlayer) }}</option>
</select>
<input
v-model.trim="keyword"
type="text"
maxlength="100"
:placeholder="
searchMode === 'player' ? formatMessage(messages.playerHint) : currentPlaceholder
"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
autocomplete="off"
/>
</div>
<span class="text-[11px] text-secondary">
{{
searchMode === 'player'
? formatMessage(messages.playerHint)
: formatMessage(messages.hint)
}}
</span>
</div>
<div
v-if="keyword && !loading"
class="flex max-h-56 flex-col gap-1.5 overflow-y-auto rounded-lg border border-solid border-surface-5 bg-bg-raised p-2"
>
<button
v-for="user in results"
:key="user.uuid"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-lg border border-solid p-2 text-left transition-colors"
:class="
preview?.uuid === user.uuid
? 'border-brand bg-brand/10'
: 'border-transparent bg-bg hover:border-surface-5'
"
@click="preview = user"
>
<span class="flex min-w-0 items-center gap-2">
<span
class="grid size-8 shrink-0 place-items-center rounded-lg bg-brand/15 text-xs font-bold text-brand"
>
{{ user.username.slice(0, 1).toUpperCase() }}
</span>
<span class="min-w-0">
<strong class="block truncate text-xs text-contrast">{{ user.username }}</strong>
<span class="block truncate text-[10px] text-secondary">
{{ formatMessage(messages.idLabel) }} {{ user.id }} · {{ user.email }}
</span>
</span>
</span>
<span
v-if="unavailableReason(user)"
class="shrink-0 rounded-full bg-red/20 px-2 py-0.5 text-[10px] font-semibold text-red"
>
{{ unavailableReason(user) }}
</span>
<span
v-else-if="model?.uuid === user.uuid"
class="shrink-0 rounded-full bg-brand px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.selected) }}
</span>
</button>
<p v-if="!results.length" class="m-0 py-4 text-center text-xs text-secondary">
{{ formatMessage(messages.empty) }}
</p>
</div>
<section
v-if="preview"
class="flex flex-col gap-3 rounded-lg border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="m-0 text-sm font-bold text-contrast">{{ preview.username }}</h3>
<p class="m-0 text-[11px] text-secondary">
{{ formatMessage(messages.idLabel) }} {{ preview.id }}
</p>
</div>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="preview.banned ? 'bg-red text-contrast' : 'bg-green text-contrast'"
>
{{ formatMessage(preview.banned ? messages.bannedStatus : messages.activeStatus) }}
</span>
</div>
<div class="grid gap-2 text-[11px] sm:grid-cols-2">
<div class="min-w-0">
<span class="block text-secondary">UUID</span>
<span class="block break-all font-mono text-contrast">{{ preview.uuid }}</span>
</div>
<div>
<span class="block text-secondary">{{ formatMessage(messages.emailLabel) }}</span>
<span class="block break-all text-contrast">{{ preview.email }}</span>
</div>
<div>
<span class="block text-secondary">QQ</span>
<span class="block text-contrast">{{ preview.qq ?? '—' }}</span>
</div>
<div>
<span class="block text-secondary">{{ formatMessage(messages.registeredLabel) }}</span>
<span class="block text-contrast">{{ formatTime(preview.registerTime) }}</span>
</div>
</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="perm in preview.permissions"
:key="perm"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ perm }}
</span>
</div>
<p v-if="unavailableReason(preview)" class="m-0 text-[11px] text-red">
{{ unavailableReason(preview) }}
</p>
<button
type="button"
class="w-full rounded-lg bg-brand px-3 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="Boolean(unavailableReason(preview))"
@click="selectPreview"
>
{{
model?.uuid === preview.uuid
? formatMessage(messages.selected)
: formatMessage(messages.select)
}}
</button>
</section>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { loadCachedRoomAvatar } from './room-avatar-cache'
const props = withDefaults(
defineProps<{
roomId: number
avatarAttachmentId?: number | null
name: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}>(),
{ avatarAttachmentId: null, size: 'md' },
)
const avatarUrl = ref<string | null>(null)
const loading = ref(false)
const sizeClass = computed(
() =>
({
sm: 'size-9 rounded-lg text-lg',
md: 'size-10 rounded-lg text-xl',
lg: 'size-12 rounded-lg text-2xl',
xl: 'size-20 rounded-xl text-3xl',
})[props.size],
)
watch(
() => [props.roomId, props.avatarAttachmentId] as const,
async ([roomId, attachmentId]) => {
avatarUrl.value = null
if (!attachmentId) {
loading.value = false
return
}
loading.value = true
const url = await loadCachedRoomAvatar(roomId, attachmentId)
if (props.roomId === roomId && props.avatarAttachmentId === attachmentId) {
avatarUrl.value = url
}
loading.value = false
},
{ immediate: true },
)
</script>
<template>
<div
class="grid shrink-0 place-items-center overflow-hidden border border-solid border-surface-5 bg-brand/10 font-bold text-brand"
:class="sizeClass"
>
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="name"
class="h-full w-full object-cover"
decoding="async"
/>
<span v-else-if="loading" class="text-[10px] text-secondary">…</span>
<span v-else class="select-none">#</span>
</div>
</template>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onBeforeUnmount, ref, watch } from 'vue'
import { skinSiteChatAttachmentUpload } from '@/helpers/skin-site-api'
import RoomAvatar from './RoomAvatar.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const props = withDefaults(
defineProps<{
roomId?: number
avatarAttachmentId?: number | null
name: string
token?: string | null
remove?: boolean
}>(),
{ roomId: 0, avatarAttachmentId: null, token: null, remove: false },
)
const emit = defineEmits<{
(e: 'update:token', value: string | null): void
(e: 'update:remove', value: boolean): void
(e: 'update:uploading', value: boolean): void
}>()
const messages = defineMessages({
label: { id: 'app.skin-site.chat.avatar.label', defaultMessage: 'Room avatar' },
upload: { id: 'app.skin-site.chat.avatar.upload', defaultMessage: 'Upload avatar' },
replace: { id: 'app.skin-site.chat.avatar.replace', defaultMessage: 'Replace avatar' },
remove: { id: 'app.skin-site.chat.avatar.remove', defaultMessage: 'Remove avatar' },
hint: {
id: 'app.skin-site.chat.avatar.hint',
defaultMessage: 'PNG, JPEG or WebP, up to 8192 px per side and 5 MB.',
},
badFormat: {
id: 'app.skin-site.chat.avatar.bad-format',
defaultMessage: 'Only PNG, JPEG and WebP are supported.',
},
tooLarge: {
id: 'app.skin-site.chat.avatar.too-large',
defaultMessage: 'Avatar must not exceed 5 MB.',
},
notImage: {
id: 'app.skin-site.chat.avatar.not-image',
defaultMessage: 'Avatar must be an image.',
},
})
const MAX_BYTES = 5 * 1024 * 1024
const ALLOWED = ['png', 'jpg', 'jpeg', 'webp']
const uploading = ref(false)
const previewUrl = ref<string | null>(null)
watch(
() => props.token,
(token) => {
if (!token) revokePreview()
},
)
function revokePreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
async function selectAvatar(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !ALLOWED.includes(ext)) {
addNotification({ type: 'error', title: formatMessage(messages.badFormat) })
return
}
if (file.size > MAX_BYTES) {
addNotification({ type: 'error', title: formatMessage(messages.tooLarge) })
return
}
uploading.value = true
emit('update:uploading', true)
try {
const res = await skinSiteChatAttachmentUpload(file)
if (!res.success) throw new Error(res.errorMessage)
if (res.payload.attachment.kind !== 'IMAGE') {
addNotification({ type: 'error', title: formatMessage(messages.notImage) })
return
}
revokePreview()
previewUrl.value = URL.createObjectURL(file)
emit('update:token', res.payload.token)
emit('update:remove', false)
} catch (error) {
handleError(error)
} finally {
uploading.value = false
emit('update:uploading', false)
}
}
function removeAvatar() {
revokePreview()
emit('update:token', null)
emit('update:remove', true)
}
onBeforeUnmount(revokePreview)
</script>
<template>
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.label) }}
</span>
<div class="flex flex-wrap items-center gap-3">
<div
v-if="previewUrl"
class="size-20 overflow-hidden rounded-xl border border-solid border-surface-5 bg-bg-raised"
>
<img :src="previewUrl" alt="" class="h-full w-full object-cover" />
</div>
<RoomAvatar
v-else
:room-id="roomId"
:avatar-attachment-id="remove ? null : avatarAttachmentId"
:name="name || 'Room'"
size="xl"
/>
<div class="flex flex-wrap gap-2">
<label
class="cursor-pointer rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
:class="uploading ? 'pointer-events-none opacity-50' : ''"
>
{{ uploading ? '…' : (avatarAttachmentId || previewUrl ? formatMessage(messages.replace) : formatMessage(messages.upload)) }}
<input
type="file"
class="hidden"
accept=".png,.jpg,.jpeg,.webp"
@change="selectAvatar"
/>
</label>
<button
v-if="previewUrl || (avatarAttachmentId && !remove)"
type="button"
class="rounded-lg bg-red/20 px-3 py-1.5 text-xs font-semibold text-red hover:brightness-110 disabled:opacity-50"
:disabled="uploading"
@click="removeAvatar"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
<p class="m-0 text-[11px] text-secondary">{{ formatMessage(messages.hint) }}</p>
</div>
</template>
@@ -0,0 +1,171 @@
<script setup lang="ts">
import { SparklesIcon, SpinnerIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import {
requestSkinSiteLuck,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
signedOutTitle: { id: 'app.skin-site.home.signed-out-title', defaultMessage: 'Not signed in' },
signedOutBody: {
id: 'app.skin-site.home.signed-out-body',
defaultMessage: 'Sign in to the StarLight skin site to manage players and download packs.',
},
players: { id: 'app.skin-site.home.players', defaultMessage: 'Your players' },
noPlayers: {
id: 'app.skin-site.home.no-players',
defaultMessage: 'No players yet. Add one on the skin site.',
},
luck: { id: 'app.skin-site.home.luck', defaultMessage: "Today's luck" },
luckUnavailable: { id: 'app.skin-site.home.luck-unavailable', defaultMessage: 'Unavailable' },
loading: { id: 'app.skin-site.home.loading', defaultMessage: 'Loading…' },
error: { id: 'app.skin-site.home.error', defaultMessage: 'Could not load players.' },
signIn: { id: 'app.skin-site.home.sign-in', defaultMessage: 'Sign in' },
settings: { id: 'app.skin-site.home.settings', defaultMessage: 'Account' },
admin: { id: 'app.skin-site.home.admin', defaultMessage: 'Admin' },
myPlayers: { id: 'app.skin-site.home.my-players', defaultMessage: 'My players' },
})
const emit = defineEmits<{
(e: 'sign-in' | 'open-settings' | 'open-players' | 'open-admin'): void
}>()
const luck = ref<number | null>(null)
const luckLoading = ref(false)
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
const isAdmin = computed(() => {
const permissions = skinSiteUser.value?.permissions ?? []
return permissions.includes('WEB_ADMIN') || permissions.includes('SERVER_ADMIN')
})
const players = computed(() => skinSitePlayers.value)
const playersLoading = computed(
() => skinSitePlayersStatus.value === 'checking' || skinSitePlayersStatus.value === 'idle',
)
const playersError = computed(() => skinSitePlayersStatus.value === 'error')
async function loadLuck() {
if (!signedIn.value) {
luck.value = null
return
}
luckLoading.value = true
try {
luck.value = await requestSkinSiteLuck()
} catch {
luck.value = null
} finally {
luckLoading.value = false
}
}
onMounted(loadLuck)
watch(signedIn, loadLuck)
</script>
<template>
<div
class="flex flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<div v-if="!signedIn" class="flex flex-col items-start gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.signedOutTitle) }}
</h3>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.signedOutBody) }}</p>
<ButtonStyled color="brand">
<button type="button" @click="emit('sign-in')">
{{ formatMessage(messages.signIn) }}
</button>
</ButtonStyled>
</div>
<template v-else>
<div class="flex items-center gap-3">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-highlight text-lg font-bold text-contrast"
>
{{ skinSiteUser!.username.slice(0, 1).toUpperCase() }}
</div>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-contrast">{{ skinSiteUser!.username }}</span>
<span class="truncate text-xs text-secondary">{{ skinSiteUser!.uuid }}</span>
</div>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('open-settings')"
>
{{ formatMessage(messages.settings) }}
</button>
<button
v-if="isAdmin"
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('open-admin')"
>
{{ formatMessage(messages.admin) }}
</button>
<div
class="ml-auto flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-sm"
:title="formatMessage(messages.luck)"
>
<SpinnerIcon v-if="luckLoading" class="size-4 animate-spin" />
<template v-else-if="luck !== null">
<SparklesIcon class="size-4 text-brand" />
<span class="font-semibold text-contrast">{{ luck }}</span>
</template>
<span v-else class="text-xs text-secondary">{{
formatMessage(messages.luckUnavailable)
}}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.players)
}}</span>
<div v-if="playersLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="playersError" class="m-0 text-sm text-red">
{{ formatMessage(messages.error) }}
</p>
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.noPlayers) }}
</p>
<div v-else class="flex flex-wrap gap-3">
<div
v-for="player in players"
:key="player.uuid"
class="flex items-center gap-2 rounded-xl bg-button-bg px-2 py-1.5"
>
<Avatar
:src="player.headDataUrl"
:alt="player.name"
size="2rem"
pixelated
class="!rounded-md"
/>
<div class="flex flex-col">
<span class="text-sm font-medium text-contrast">{{ player.name }}</span>
<span v-if="player.isMojang" class="text-[10px] uppercase text-secondary"
>Mojang</span
>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,194 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session'
import AdminAccessAuditPage from './admin/AdminAccessAuditPage.vue'
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
import AdminChatPage from './admin/AdminChatPage.vue'
import AdminDonationsPage from './admin/AdminDonationsPage.vue'
import AdminDownloadsPage from './admin/AdminDownloadsPage.vue'
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
import AdminModsPage from './admin/AdminModsPage.vue'
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
import AdminReviewPage from './admin/AdminReviewPage.vue'
import AdminServersPage from './admin/AdminServersPage.vue'
import AdminSummaryPage from './admin/AdminSummaryPage.vue'
import AdminUsersPage from './admin/AdminUsersPage.vue'
import AdminWikiPage from './admin/AdminWikiPage.vue'
const emit = defineEmits<{ (e: 'back'): void }>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.title', defaultMessage: 'Admin console' },
back: { id: 'app.skin-site.admin.back', defaultMessage: 'Back to home' },
noAccess: { id: 'app.skin-site.admin.no-access', defaultMessage: 'You do not have access.' },
noAccessHint: {
id: 'app.skin-site.admin.no-access-hint',
defaultMessage: 'Sign in with an administrator account to use this console.',
},
groupSecurity: { id: 'app.skin-site.admin.group.security', defaultMessage: 'Security' },
groupUsers: { id: 'app.skin-site.admin.group.users', defaultMessage: 'Users' },
groupCommunity: { id: 'app.skin-site.admin.group.community', defaultMessage: 'Community' },
groupResources: { id: 'app.skin-site.admin.group.resources', defaultMessage: 'Resources' },
itemServers: { id: 'app.skin-site.admin.item.servers', defaultMessage: 'Servers' },
itemSummary: { id: 'app.skin-site.admin.item.summary', defaultMessage: 'Summary' },
itemAccessAudit: { id: 'app.skin-site.admin.item.access-audit', defaultMessage: 'Access audit' },
itemUsers: { id: 'app.skin-site.admin.item.users', defaultMessage: 'Users' },
itemPlayers: { id: 'app.skin-site.admin.item.players', defaultMessage: 'Players' },
itemReview: { id: 'app.skin-site.admin.item.review', defaultMessage: 'Player review' },
itemBroadcast: { id: 'app.skin-site.admin.item.broadcast', defaultMessage: 'Broadcast' },
itemChat: { id: 'app.skin-site.admin.item.chat', defaultMessage: 'Chat rooms' },
itemWiki: { id: 'app.skin-site.admin.item.wiki', defaultMessage: 'Wiki' },
itemDonations: { id: 'app.skin-site.admin.item.donations', defaultMessage: 'Donations' },
itemMods: { id: 'app.skin-site.admin.item.mods', defaultMessage: 'Mods' },
itemLauncher: { id: 'app.skin-site.admin.item.launcher', defaultMessage: 'Launcher' },
itemDownloads: { id: 'app.skin-site.admin.item.downloads', defaultMessage: 'Downloads' },
})
type AdminKey =
| 'servers'
| 'summary'
| 'access-audit'
| 'users'
| 'players'
| 'review'
| 'broadcast'
| 'chat'
| 'wiki'
| 'donations'
| 'mods'
| 'launcher'
| 'downloads'
interface NavItem {
key: AdminKey
label: string
}
interface NavGroup {
id: string
label: string
items: NavItem[]
}
const navGroups = computed<NavGroup[]>(() => [
{
id: 'security',
label: formatMessage(messages.groupSecurity),
items: [
...(hasWebAdmin.value
? [{ key: 'servers' as AdminKey, label: formatMessage(messages.itemServers) }]
: []),
{ key: 'summary', label: formatMessage(messages.itemSummary) },
{ key: 'access-audit', label: formatMessage(messages.itemAccessAudit) },
],
},
{
id: 'users',
label: formatMessage(messages.groupUsers),
items: [
{ key: 'users', label: formatMessage(messages.itemUsers) },
{ key: 'players', label: formatMessage(messages.itemPlayers) },
{ key: 'review', label: formatMessage(messages.itemReview) },
],
},
{
id: 'community',
label: formatMessage(messages.groupCommunity),
items: [
{ key: 'broadcast', label: formatMessage(messages.itemBroadcast) },
{ key: 'chat', label: formatMessage(messages.itemChat) },
{ key: 'wiki', label: formatMessage(messages.itemWiki) },
...(hasWebAdmin.value
? [{ key: 'donations' as AdminKey, label: formatMessage(messages.itemDonations) }]
: []),
],
},
{
id: 'resources',
label: formatMessage(messages.groupResources),
items: [
{ key: 'mods', label: formatMessage(messages.itemMods) },
{ key: 'launcher', label: formatMessage(messages.itemLauncher) },
{ key: 'downloads', label: formatMessage(messages.itemDownloads) },
],
},
])
const hasAdminAccess = computed(() => {
const permissions = skinSiteUser.value?.permissions ?? []
return permissions.includes('WEB_ADMIN') || permissions.includes('SERVER_ADMIN')
})
const hasWebAdmin = computed(() => {
const permissions = skinSiteUser.value?.permissions ?? []
return permissions.includes('WEB_ADMIN')
})
const current = ref<AdminKey>('summary')
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<div
v-if="!hasAdminAccess"
class="flex flex-col gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-6"
>
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.noAccess) }}</h2>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.noAccessHint) }}</p>
</div>
<div v-else class="flex min-h-0 flex-1 gap-4">
<nav class="flex w-48 shrink-0 flex-col gap-3 overflow-y-auto">
<div v-for="group in navGroups" :key="group.id" class="flex flex-col gap-1">
<span class="px-2 text-[10px] font-bold tracking-wide text-secondary uppercase">
{{ group.label }}
</span>
<button
v-for="item in group.items"
:key="item.key"
type="button"
class="rounded-lg px-2 py-1.5 text-left text-xs font-semibold transition-colors"
:class="
current === item.key
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="current = item.key"
>
{{ item.label }}
</button>
</div>
</nav>
<section
class="min-w-0 flex-1 overflow-y-auto rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<AdminSummaryPage v-if="current === 'summary'" />
<AdminServersPage v-else-if="current === 'servers' && hasWebAdmin" />
<AdminAccessAuditPage v-else-if="current === 'access-audit'" />
<AdminBroadcastPage v-else-if="current === 'broadcast'" />
<AdminChatPage v-else-if="current === 'chat'" />
<AdminUsersPage v-else-if="current === 'users'" />
<AdminPlayersPage v-else-if="current === 'players'" />
<AdminReviewPage v-else-if="current === 'review'" />
<AdminLauncherPage v-else-if="current === 'launcher'" />
<AdminModsPage v-else-if="current === 'mods'" />
<AdminWikiPage v-else-if="current === 'wiki'" />
<AdminDownloadsPage v-else-if="current === 'downloads'" />
<AdminDonationsPage v-else-if="current === 'donations' && hasWebAdmin" />
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
</section>
</div>
</div>
</template>
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { createRequire, registerHooks, stripTypeScriptTypes } from 'node:module'
import test from 'node:test'
import { createRenderer, nextTick, ref } from 'vue'
const testSession = {
skinSiteStatus: ref('checking'),
skinSiteUser: ref<{ uuid: string } | null>(null),
}
const testGlobal = globalThis as unknown as {
skinSiteAnnouncementSession: typeof testSession
skinSiteAnnouncementFetch: () => Promise<unknown>
}
testGlobal.skinSiteAnnouncementSession = testSession
const require = createRequire(import.meta.url)
const { parse, compileScript } = require('vue/compiler-sfc')
const componentUrl = new URL('./SkinSiteAnnouncements.vue', import.meta.url).href
const stubs: Record<string, string> = {
'@modrinth/assets': 'export const SpinnerIcon={}',
'@modrinth/ui':
'export const commonMessages={retryButton:{defaultMessage:"Retry"}}; export const defineMessages=x=>x; export const useVIntl=()=>({formatMessage:x=>x.defaultMessage})',
'@/helpers/skin-site-api':
'export const skinSiteAnnouncements=()=>globalThis.skinSiteAnnouncementFetch()',
'@/composables/skin-site-session':
'export const {skinSiteStatus,skinSiteUser}=globalThis.skinSiteAnnouncementSession; export const reloadSkinSiteSession=async()=>{}',
}
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier in stubs)
return {
url: `data:text/javascript,${encodeURIComponent(stubs[specifier])}`,
shortCircuit: true,
}
return nextResolve(specifier, context)
},
load(url, context, nextLoad) {
if (url !== componentUrl) return nextLoad(url, context)
const { descriptor } = parse(readFileSync(new URL(url), 'utf8'))
return {
format: 'module',
source: stripTypeScriptTypes(
compileScript(descriptor, { id: 'skin-announcement-test' }).content,
),
shortCircuit: true,
}
},
})
const { default: Announcements } = await import(componentUrl)
hooks.deregister()
Announcements.render = () => null
type Node = Record<string, unknown>
const renderer = createRenderer<Node, Node>({
createElement: () => ({}),
createText: () => ({}),
createComment: () => ({}),
insert() {},
remove() {},
setText() {},
setElementText() {},
patchProp() {},
parentNode: () => null,
nextSibling: () => null,
})
const flush = async () => {
await new Promise((resolve) => setImmediate(resolve))
await nextTick()
}
type State = {
announcements: Array<{ id: number }>
loading: boolean
failed: boolean
load: () => Promise<void>
}
let requests = 0
function mount(t: test.TestContext) {
const app = renderer.createApp(Announcements)
const instance = app.mount({})
t.after(() => app.unmount())
return instance.$.setupState as State
}
test.beforeEach(() => {
testSession.skinSiteStatus.value = 'checking'
testSession.skinSiteUser.value = null
requests = 0
testGlobal.skinSiteAnnouncementFetch = async () => {
requests++
return { success: true, payload: [{ id: 1, title: '公告', updateAt: 1, star: false }] }
}
})
test('announcements wait for startup renewal and reload when sign-in finishes', async (t) => {
const state = mount(t)
await flush()
assert.equal(requests, 0)
assert.equal(state.loading, true)
testSession.skinSiteUser.value = { uuid: 'owner' }
testSession.skinSiteStatus.value = 'signed-in'
await flush()
assert.equal(requests, 1)
assert.equal(state.announcements.length, 1)
})
test('signed-out users do not request protected announcements or see a load failure', async (t) => {
testSession.skinSiteStatus.value = 'signed-out'
const state = mount(t)
await flush()
assert.equal(requests, 0)
assert.equal(state.loading, false)
assert.equal(state.failed, false)
})
test('signing out clears announcements and ignores an old in-flight response', async (t) => {
testSession.skinSiteStatus.value = 'signed-in'
let finish!: (value: unknown) => void
testGlobal.skinSiteAnnouncementFetch = () =>
new Promise((resolve) => {
finish = resolve
})
const state = mount(t)
await flush()
testSession.skinSiteStatus.value = 'signed-out'
await flush()
finish({ success: true, payload: [{ id: 2 }] })
await flush()
assert.deepEqual(state.announcements, [])
assert.equal(state.failed, false)
})
test('a failed announcement request can be retried', async (t) => {
testSession.skinSiteStatus.value = 'signed-in'
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: false })
const state = mount(t)
await flush()
assert.equal(state.failed, true)
testGlobal.skinSiteAnnouncementFetch = async () => ({ success: true, payload: [{ id: 3 }] })
await state.load()
assert.equal(state.failed, false)
assert.equal(state.announcements[0].id, 3)
})
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
reloadSkinSiteSession,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { type SkinSiteAnnouncement, skinSiteAnnouncements } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.home.announcements.title', defaultMessage: 'Announcements' },
empty: {
id: 'app.skin-site.home.announcements.empty',
defaultMessage: 'No announcements right now.',
},
loading: { id: 'app.skin-site.home.announcements.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.home.announcements.failed',
defaultMessage: 'Could not load announcements.',
},
signedOut: {
id: 'app.skin-site.home.announcements.signed-out',
defaultMessage: 'Sign in to view announcements.',
},
})
const announcements = ref<SkinSiteAnnouncement[]>([])
const loading = ref(true)
const failed = ref(false)
let requestId = 0
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return ''
}
}
async function load() {
const currentRequest = ++requestId
announcements.value = []
failed.value = skinSiteStatus.value === 'error'
loading.value = skinSiteStatus.value === 'checking'
if (skinSiteStatus.value !== 'signed-in') return
loading.value = true
try {
const result = await skinSiteAnnouncements()
if (currentRequest !== requestId) return
if (result.success) {
announcements.value = [...(result.payload ?? [])]
.sort((a, b) => {
if (a.star !== b.star) return a.star ? -1 : 1
return b.updateAt - a.updateAt
})
.slice(0, 5)
} else {
failed.value = true
}
} catch {
if (currentRequest === requestId) failed.value = true
} finally {
if (currentRequest === requestId) loading.value = false
}
}
async function retry() {
if (skinSiteStatus.value === 'error') await reloadSkinSiteSession()
else await load()
}
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], load, { immediate: true })
onUnmounted(() => {
requestId++
})
</script>
<template>
<div class="flex flex-col gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<div v-else-if="failed" class="flex items-center gap-2 text-sm text-secondary">
<p class="m-0">{{ formatMessage(messages.failed) }}</p>
<button type="button" class="text-brand hover:underline" @click="retry">
{{ formatMessage(commonMessages.retryButton) }}
</button>
</div>
<p v-else-if="skinSiteStatus === 'signed-out'" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.signedOut) }}
</p>
<p v-else-if="!announcements.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in announcements"
:key="item.id"
class="flex flex-col gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex items-start gap-2">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ item.title }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
</div>
<span v-if="item.subtitle" class="truncate text-xs text-secondary">{{
item.subtitle
}}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,187 @@
<script setup lang="ts">
import {
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { type SkinSiteBugIssueType, skinSiteCreateBugIssue } from '@/helpers/skin-site-api'
const emit = defineEmits<{ (e: 'created', id: number): void }>()
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.bug-create.title', defaultMessage: 'New issue' },
type: { id: 'app.skin-site.bug-create.type', defaultMessage: 'Type' },
titleField: { id: 'app.skin-site.bug-create.title-field', defaultMessage: 'Title' },
titlePlaceholder: {
id: 'app.skin-site.bug-create.title-placeholder',
defaultMessage: 'A short summary of the problem',
},
description: { id: 'app.skin-site.bug-create.description', defaultMessage: 'Description' },
descriptionPlaceholder: {
id: 'app.skin-site.bug-create.description-placeholder',
defaultMessage: 'What happened, and how can it be reproduced?',
},
privateLabel: { id: 'app.skin-site.bug-create.private', defaultMessage: 'Private issue' },
privateHint: {
id: 'app.skin-site.bug-create.private-hint',
defaultMessage: 'Only you and the maintainers can see it.',
},
images: { id: 'app.skin-site.bug-create.images', defaultMessage: 'Images' },
imagesHint: {
id: 'app.skin-site.bug-create.images-hint',
defaultMessage: 'Optional, up to 5 images.',
},
submit: { id: 'app.skin-site.bug-create.submit', defaultMessage: 'Create issue' },
submitting: { id: 'app.skin-site.bug-create.submitting', defaultMessage: 'Creating…' },
created: { id: 'app.skin-site.bug-create.created', defaultMessage: 'Issue created.' },
cancel: { id: 'app.skin-site.bug-create.cancel', defaultMessage: 'Cancel' },
typeBug: { id: 'app.skin-site.bugs.type.bug', defaultMessage: 'Bug' },
typeFeature: { id: 'app.skin-site.bugs.type.feature', defaultMessage: 'Feature' },
typeImprovement: { id: 'app.skin-site.bugs.type.improvement', defaultMessage: 'Improvement' },
})
const modal = ref<InstanceType<typeof NewModal>>()
const type = ref<SkinSiteBugIssueType>('BUG')
const title = ref('')
const description = ref('')
const isPrivate = ref(false)
const images = ref<File[]>([])
const working = ref(false)
const TYPE_OPTIONS: SkinSiteBugIssueType[] = ['BUG', 'FEATURE', 'IMPROVEMENT']
function typeLabel(t: SkinSiteBugIssueType): string {
if (t === 'FEATURE') return formatMessage(messages.typeFeature)
if (t === 'IMPROVEMENT') return formatMessage(messages.typeImprovement)
return formatMessage(messages.typeBug)
}
const canSubmit = computed(
() => title.value.trim().length > 0 && description.value.trim().length > 0 && !working.value,
)
function reset() {
type.value = 'BUG'
title.value = ''
description.value = ''
isPrivate.value = false
images.value = []
working.value = false
}
function show() {
reset()
modal.value?.show()
}
function onFiles(event: Event) {
const input = event.target as HTMLInputElement
images.value = Array.from(input.files ?? []).slice(0, 5)
}
async function submit() {
if (!canSubmit.value) return
working.value = true
try {
const result = await skinSiteCreateBugIssue(
{
type: type.value,
title: title.value.trim(),
description: description.value.trim(),
private: isPrivate.value,
},
images.value,
)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.created) })
modal.value?.hide()
emit('created', result.payload.id)
} catch (error) {
handleError(error)
} finally {
working.value = false
}
}
function cancel() {
modal.value?.hide()
reset()
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.type) }}</span>
<select
v-model="type"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast"
>
<option v-for="t in TYPE_OPTIONS" :key="t" :value="t">{{ typeLabel(t) }}</option>
</select>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.titleField) }}</span>
<StyledInput
v-model="title"
type="text"
:placeholder="formatMessage(messages.titlePlaceholder)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.description) }}</span>
<textarea
v-model="description"
rows="5"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
:placeholder="formatMessage(messages.descriptionPlaceholder)"
/>
</label>
<label class="flex items-start gap-2">
<input v-model="isPrivate" type="checkbox" class="mt-1" />
<span class="flex flex-col">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.privateLabel) }}
</span>
<span class="text-xs text-secondary">{{ formatMessage(messages.privateHint) }}</span>
</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.images) }}</span>
<input type="file" accept="image/*" multiple @change="onFiles" />
<span class="text-xs text-secondary">{{ formatMessage(messages.imagesHint) }}</span>
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="cancel"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="submit"
>
{{ working ? formatMessage(messages.submitting) : formatMessage(messages.submit) }}
</button>
</div>
</div>
</NewModal>
</template>
@@ -0,0 +1,425 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteBugComment,
skinSiteBugComments,
type SkinSiteBugIssue,
skinSiteBugIssue,
type SkinSiteBugIssueType,
type SkinSiteBugPriority,
type SkinSiteBugResolution,
type SkinSiteBugStatus,
skinSiteCreateBugComment,
skinSiteDeleteBugComment,
} from '@/helpers/skin-site-api'
const props = defineProps<{ issueId: number }>()
const emit = defineEmits<{ (e: 'back'): void }>()
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
back: { id: 'app.skin-site.bug-detail.back', defaultMessage: 'Back to list' },
loading: { id: 'app.skin-site.bug-detail.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.bug-detail.failed', defaultMessage: 'Could not load this issue.' },
issueNo: { id: 'app.skin-site.bug-detail.issue-no', defaultMessage: 'Issue #{id}' },
by: { id: 'app.skin-site.bug-detail.by', defaultMessage: 'by {name}' },
assignee: { id: 'app.skin-site.bug-detail.assignee', defaultMessage: 'Assignee: {name}' },
unassigned: { id: 'app.skin-site.bug-detail.unassigned', defaultMessage: 'Unassigned' },
descriptionEmpty: {
id: 'app.skin-site.bug-detail.description-empty',
defaultMessage: 'No description provided.',
},
resolution: { id: 'app.skin-site.bug-detail.resolution', defaultMessage: 'Resolution: {name}' },
commentsTitle: { id: 'app.skin-site.bug-detail.comments-title', defaultMessage: 'Comments' },
commentsEmpty: {
id: 'app.skin-site.bug-detail.comments-empty',
defaultMessage: 'No comments yet. Be the first to reply.',
},
commentsLoading: {
id: 'app.skin-site.bug-detail.comments-loading',
defaultMessage: 'Loading comments…',
},
commentPlaceholder: {
id: 'app.skin-site.bug-detail.comment-placeholder',
defaultMessage: 'Write a comment…',
},
commentSubmit: { id: 'app.skin-site.bug-detail.comment-submit', defaultMessage: 'Comment' },
commentSending: { id: 'app.skin-site.bug-detail.comment-sending', defaultMessage: 'Sending…' },
commentEmptyError: {
id: 'app.skin-site.bug-detail.comment-empty-error',
defaultMessage: 'Comment cannot be empty.',
},
commentPosted: {
id: 'app.skin-site.bug-detail.comment-posted',
defaultMessage: 'Comment posted.',
},
commentDeleted: {
id: 'app.skin-site.bug-detail.comment-deleted',
defaultMessage: 'Comment deleted.',
},
delete: { id: 'app.skin-site.bug-detail.delete', defaultMessage: 'Delete' },
deleting: { id: 'app.skin-site.bug-detail.deleting', defaultMessage: 'Deleting…' },
system: { id: 'app.skin-site.bug-detail.system', defaultMessage: 'System' },
typeBug: { id: 'app.skin-site.bugs.type.bug', defaultMessage: 'Bug' },
typeFeature: { id: 'app.skin-site.bugs.type.feature', defaultMessage: 'Feature' },
typeImprovement: { id: 'app.skin-site.bugs.type.improvement', defaultMessage: 'Improvement' },
statusPending: { id: 'app.skin-site.bugs.status.pending', defaultMessage: 'Pending' },
statusConfirmed: { id: 'app.skin-site.bugs.status.confirmed', defaultMessage: 'Confirmed' },
statusNeedsInfo: { id: 'app.skin-site.bugs.status.needs-info', defaultMessage: 'Needs info' },
statusInProgress: { id: 'app.skin-site.bugs.status.in-progress', defaultMessage: 'In progress' },
statusVerifying: { id: 'app.skin-site.bugs.status.verifying', defaultMessage: 'Verifying' },
statusClosed: { id: 'app.skin-site.bugs.status.closed', defaultMessage: 'Closed' },
priorityCritical: { id: 'app.skin-site.bugs.priority.critical', defaultMessage: 'Critical' },
priorityHigh: { id: 'app.skin-site.bugs.priority.high', defaultMessage: 'High' },
priorityNormal: { id: 'app.skin-site.bugs.priority.normal', defaultMessage: 'Normal' },
priorityLow: { id: 'app.skin-site.bugs.priority.low', defaultMessage: 'Low' },
resFixed: { id: 'app.skin-site.bugs.resolution.fixed', defaultMessage: 'Fixed' },
resWontFix: { id: 'app.skin-site.bugs.resolution.wont-fix', defaultMessage: 'Won’t fix' },
resByDesign: { id: 'app.skin-site.bugs.resolution.by-design', defaultMessage: 'By design' },
resDuplicate: { id: 'app.skin-site.bugs.resolution.duplicate', defaultMessage: 'Duplicate' },
resCannotReproduce: {
id: 'app.skin-site.bugs.resolution.cannot-reproduce',
defaultMessage: 'Cannot reproduce',
},
resInvalid: { id: 'app.skin-site.bugs.resolution.invalid', defaultMessage: 'Invalid' },
})
const issue = ref<SkinSiteBugIssue | null>(null)
const loading = ref(true)
const failed = ref(false)
const comments = ref<SkinSiteBugComment[]>([])
const commentsLoading = ref(true)
const commentsHasMore = ref(false)
const commentsLoadingMore = ref(false)
const commentDraft = ref('')
const commentSending = ref(false)
const deletingId = ref<number | null>(null)
function typeLabel(type: SkinSiteBugIssueType): string {
switch (type) {
case 'FEATURE':
return formatMessage(messages.typeFeature)
case 'IMPROVEMENT':
return formatMessage(messages.typeImprovement)
default:
return formatMessage(messages.typeBug)
}
}
function statusLabel(status: SkinSiteBugStatus): string {
switch (status) {
case 'CONFIRMED':
return formatMessage(messages.statusConfirmed)
case 'NEEDS_INFO':
return formatMessage(messages.statusNeedsInfo)
case 'IN_PROGRESS':
return formatMessage(messages.statusInProgress)
case 'VERIFYING':
return formatMessage(messages.statusVerifying)
case 'CLOSED':
return formatMessage(messages.statusClosed)
default:
return formatMessage(messages.statusPending)
}
}
function priorityLabel(priority: SkinSiteBugPriority): string {
switch (priority) {
case 'CRITICAL':
return formatMessage(messages.priorityCritical)
case 'HIGH':
return formatMessage(messages.priorityHigh)
case 'LOW':
return formatMessage(messages.priorityLow)
default:
return formatMessage(messages.priorityNormal)
}
}
function resolutionLabel(resolution: SkinSiteBugResolution): string {
switch (resolution) {
case 'WONT_FIX':
return formatMessage(messages.resWontFix)
case 'BY_DESIGN':
return formatMessage(messages.resByDesign)
case 'DUPLICATE':
return formatMessage(messages.resDuplicate)
case 'CANNOT_REPRODUCE':
return formatMessage(messages.resCannotReproduce)
case 'INVALID':
return formatMessage(messages.resInvalid)
default:
return formatMessage(messages.resFixed)
}
}
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
const canSubmit = computed(() => !commentSending.value && commentDraft.value.trim().length > 0)
async function loadIssue() {
loading.value = true
failed.value = false
try {
const result = await skinSiteBugIssue(props.issueId)
if (!result.success) throw new Error(result.errorMessage)
issue.value = result.payload
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function loadComments(reset = true) {
if (reset) {
commentsLoading.value = true
comments.value = []
} else {
commentsLoadingMore.value = true
}
try {
const result = await skinSiteBugComments(props.issueId, reset ? 0 : comments.value.length, 50)
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
comments.value = reset ? page.items : [...comments.value, ...page.items]
commentsHasMore.value = page.hasMore
} catch (error) {
handleError(error)
} finally {
commentsLoading.value = false
commentsLoadingMore.value = false
}
}
async function submitComment() {
const content = commentDraft.value.trim()
if (!content || commentSending.value) return
commentSending.value = true
try {
const result = await skinSiteCreateBugComment(props.issueId, content)
if (!result.success) throw new Error(result.errorMessage)
comments.value = [...comments.value, result.payload]
commentDraft.value = ''
addNotification({ type: 'success', title: formatMessage(messages.commentPosted) })
} catch (error) {
handleError(error)
} finally {
commentSending.value = false
}
}
async function deleteComment(comment: SkinSiteBugComment) {
if (deletingId.value !== null) return
deletingId.value = comment.id
try {
const result = await skinSiteDeleteBugComment(comment.id)
if (!result.success) throw new Error(result.errorMessage)
comments.value = comments.value.filter((c) => c.id !== comment.id)
addNotification({ type: 'success', title: formatMessage(messages.commentDeleted) })
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
onMounted(() => {
void loadIssue()
void loadComments(true)
})
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<template v-else-if="issue">
<header
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<div class="flex flex-wrap items-center gap-2">
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ typeLabel(issue.type) }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-green text-contrast':
issue.status === 'CONFIRMED' || issue.status === 'VERIFYING',
'bg-yellow text-contrast':
issue.status === 'NEEDS_INFO' || issue.status === 'IN_PROGRESS',
'bg-button-bg text-secondary': issue.status === 'PENDING',
'bg-surface-5 text-secondary': issue.status === 'CLOSED',
}"
>
{{ statusLabel(issue.status) }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-red text-contrast': issue.priority === 'CRITICAL',
'bg-orange text-contrast': issue.priority === 'HIGH',
'bg-button-bg text-secondary':
issue.priority === 'NORMAL' || issue.priority === 'LOW',
}"
>
{{ priorityLabel(issue.priority) }}
</span>
<span v-if="issue.private" class="shrink-0 text-[10px] font-semibold text-secondary">
🔒
</span>
<span class="min-w-0 flex-1 truncate text-xs text-secondary">
{{ formatMessage(messages.issueNo, { id: issue.id }) }}
</span>
</div>
<h1 class="m-0 text-xl font-bold text-contrast">{{ issue.title }}</h1>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<span>{{ formatMessage(messages.by, { name: issue.authorName }) }}</span>
<span>{{ formatDateTime(issue.createdAt) }}</span>
<span v-if="issue.assigneeName">
{{ formatMessage(messages.assignee, { name: issue.assigneeName }) }}
</span>
<span v-else>{{ formatMessage(messages.unassigned) }}</span>
</div>
<span v-if="issue.resolution" class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.resolution, { name: resolutionLabel(issue.resolution) }) }}
</span>
</header>
<div class="rounded-xl border border-solid border-surface-5 bg-bg-raised p-4">
<p
v-if="issue.description"
class="m-0 whitespace-pre-wrap break-words text-sm text-contrast"
>
{{ issue.description }}
</p>
<p v-else class="m-0 text-sm text-secondary">
{{ formatMessage(messages.descriptionEmpty) }}
</p>
<div v-if="issue.images.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="image in issue.images"
:key="image.hash"
class="rounded-lg bg-surface-5 px-2 py-1 text-[10px] font-mono text-secondary"
>
{{ image.hash }}
</span>
</div>
</div>
<section class="flex flex-col gap-3">
<h2 class="m-0 text-base font-bold text-contrast">
{{ formatMessage(messages.commentsTitle) }}
</h2>
<div class="flex flex-col gap-2">
<div v-if="commentsLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.commentsLoading) }}
</div>
<p
v-else-if="!comments.length"
class="m-0 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.commentsEmpty) }}
</p>
<div
v-for="comment in comments"
v-else
:key="comment.id"
class="flex flex-col gap-1.5 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
<span class="font-semibold text-contrast">{{ comment.authorName }}</span>
<span
v-if="comment.system"
class="rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold"
>
{{ formatMessage(messages.system) }}
</span>
<span>{{ formatDateTime(comment.createdAt) }}</span>
<span v-if="comment.editedAt">(edited)</span>
<button
v-if="comment.canDelete"
type="button"
class="ml-auto rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
:disabled="deletingId !== null"
@click="deleteComment(comment)"
>
{{
deletingId === comment.id
? formatMessage(messages.deleting)
: formatMessage(messages.delete)
}}
</button>
</div>
<p class="m-0 whitespace-pre-wrap break-words text-sm text-contrast">
{{ comment.content }}
</p>
</div>
</div>
<button
v-if="commentsHasMore"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="commentsLoadingMore"
@click="loadComments(false)"
>
{{ formatMessage(messages.commentsTitle) }}
</button>
<form class="flex flex-col gap-2" @submit.prevent="submitComment">
<textarea
v-model="commentDraft"
rows="3"
class="w-full resize-y rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 text-sm text-contrast outline-none focus:border-brand"
:placeholder="formatMessage(messages.commentPlaceholder)"
/>
<button
type="submit"
class="w-fit self-end rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
>
{{
commentSending
? formatMessage(messages.commentSending)
: formatMessage(messages.commentSubmit)
}}
</button>
</form>
</section>
</template>
</div>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSiteBugIssue,
skinSiteBugIssues,
type SkinSiteBugIssueType,
type SkinSiteBugPriority,
type SkinSiteBugStatus,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.bugs.title', defaultMessage: 'Bugs & suggestions' },
back: { id: 'app.skin-site.bugs.back', defaultMessage: 'Back to home' },
subtitle: {
id: 'app.skin-site.bugs.subtitle',
defaultMessage: 'Report issues and follow their progress.',
},
loading: { id: 'app.skin-site.bugs.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.bugs.failed', defaultMessage: 'Could not load issues.' },
empty: { id: 'app.skin-site.bugs.empty', defaultMessage: 'No issues match these filters.' },
all: { id: 'app.skin-site.bugs.all', defaultMessage: 'All' },
more: { id: 'app.skin-site.bugs.more', defaultMessage: 'Load more' },
openOnly: { id: 'app.skin-site.bugs.open-only', defaultMessage: 'Open only' },
create: { id: 'app.skin-site.bugs.create', defaultMessage: 'New issue' },
notifications: {
id: 'app.skin-site.bugs.notifications',
defaultMessage: 'Notifications',
},
comments: { id: 'app.skin-site.bugs.comments', defaultMessage: '{count} comments' },
typeBug: { id: 'app.skin-site.bugs.type.bug', defaultMessage: 'Bug' },
typeFeature: { id: 'app.skin-site.bugs.type.feature', defaultMessage: 'Feature' },
typeImprovement: { id: 'app.skin-site.bugs.type.improvement', defaultMessage: 'Improvement' },
statusPending: { id: 'app.skin-site.bugs.status.pending', defaultMessage: 'Pending' },
statusConfirmed: { id: 'app.skin-site.bugs.status.confirmed', defaultMessage: 'Confirmed' },
statusNeedsInfo: { id: 'app.skin-site.bugs.status.needs-info', defaultMessage: 'Needs info' },
statusInProgress: { id: 'app.skin-site.bugs.status.in-progress', defaultMessage: 'In progress' },
statusVerifying: { id: 'app.skin-site.bugs.status.verifying', defaultMessage: 'Verifying' },
statusClosed: { id: 'app.skin-site.bugs.status.closed', defaultMessage: 'Closed' },
priorityCritical: { id: 'app.skin-site.bugs.priority.critical', defaultMessage: 'Critical' },
priorityHigh: { id: 'app.skin-site.bugs.priority.high', defaultMessage: 'High' },
priorityNormal: { id: 'app.skin-site.bugs.priority.normal', defaultMessage: 'Normal' },
priorityLow: { id: 'app.skin-site.bugs.priority.low', defaultMessage: 'Low' },
})
const emit = defineEmits<{
(e: 'back' | 'create' | 'notifications'): void
(e: 'open', id: number): void
}>()
const items = ref<SkinSiteBugIssue[]>([])
const hasMore = ref(false)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const typeFilter = ref<SkinSiteBugIssueType | ''>('')
const statusFilter = ref<SkinSiteBugStatus | ''>('')
const priorityFilter = ref<SkinSiteBugPriority | ''>('')
const openOnly = ref(true)
const TYPE_OPTIONS: SkinSiteBugIssueType[] = ['BUG', 'FEATURE', 'IMPROVEMENT']
const STATUS_OPTIONS: SkinSiteBugStatus[] = [
'PENDING',
'CONFIRMED',
'NEEDS_INFO',
'IN_PROGRESS',
'VERIFYING',
'CLOSED',
]
const PRIORITY_OPTIONS: SkinSiteBugPriority[] = ['CRITICAL', 'HIGH', 'NORMAL', 'LOW']
function typeLabel(type: SkinSiteBugIssueType): string {
switch (type) {
case 'FEATURE':
return formatMessage(messages.typeFeature)
case 'IMPROVEMENT':
return formatMessage(messages.typeImprovement)
default:
return formatMessage(messages.typeBug)
}
}
function statusLabel(status: SkinSiteBugStatus): string {
switch (status) {
case 'CONFIRMED':
return formatMessage(messages.statusConfirmed)
case 'NEEDS_INFO':
return formatMessage(messages.statusNeedsInfo)
case 'IN_PROGRESS':
return formatMessage(messages.statusInProgress)
case 'VERIFYING':
return formatMessage(messages.statusVerifying)
case 'CLOSED':
return formatMessage(messages.statusClosed)
default:
return formatMessage(messages.statusPending)
}
}
function priorityLabel(priority: SkinSiteBugPriority): string {
switch (priority) {
case 'CRITICAL':
return formatMessage(messages.priorityCritical)
case 'HIGH':
return formatMessage(messages.priorityHigh)
case 'LOW':
return formatMessage(messages.priorityLow)
default:
return formatMessage(messages.priorityNormal)
}
}
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return ''
}
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteBugIssues({
offset: reset ? 0 : items.value.length,
limit: 20,
type: typeFilter.value || undefined,
status: statusFilter.value || undefined,
priority: priorityFilter.value || undefined,
openOnly: openOnly.value,
})
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
hasMore.value = page.hasMore
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
function clearFilters() {
typeFilter.value = ''
statusFilter.value = ''
priorityFilter.value = ''
void load(true)
}
onMounted(() => load(true))
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<header class="flex flex-wrap items-start gap-3">
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</div>
<div class="ml-auto flex items-center gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="emit('notifications')"
>
{{ formatMessage(messages.notifications) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="emit('create')"
>
{{ formatMessage(messages.create) }}
</button>
</div>
</header>
<div class="flex flex-wrap items-center gap-2">
<select
v-model="typeFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="load(true)"
>
<option value="">{{ formatMessage(messages.all) }}</option>
<option v-for="t in TYPE_OPTIONS" :key="t" :value="t">{{ typeLabel(t) }}</option>
</select>
<select
v-model="statusFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="load(true)"
>
<option value="">{{ formatMessage(messages.all) }}</option>
<option v-for="s in STATUS_OPTIONS" :key="s" :value="s">{{ statusLabel(s) }}</option>
</select>
<select
v-model="priorityFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="load(true)"
>
<option value="">{{ formatMessage(messages.all) }}</option>
<option v-for="p in PRIORITY_OPTIONS" :key="p" :value="p">{{ priorityLabel(p) }}</option>
</select>
<label class="flex items-center gap-1.5 text-xs text-secondary">
<input v-model="openOnly" type="checkbox" @change="load(true)" />
{{ formatMessage(messages.openOnly) }}
</label>
<button
type="button"
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="clearFilters"
>
{{ formatMessage(messages.all) }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="issue in items"
:key="issue.id"
class="flex cursor-pointer flex-col gap-1.5 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 hover:border-brand"
@click="emit('open', issue.id)"
>
<div class="flex flex-wrap items-center gap-2">
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ typeLabel(issue.type) }}
</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
#{{ issue.id }} {{ issue.title }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-green text-contrast':
issue.status === 'CONFIRMED' || issue.status === 'VERIFYING',
'bg-yellow text-contrast':
issue.status === 'NEEDS_INFO' || issue.status === 'IN_PROGRESS',
'bg-button-bg text-secondary': issue.status === 'PENDING',
'bg-surface-5 text-secondary': issue.status === 'CLOSED',
}"
>
{{ statusLabel(issue.status) }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-red text-contrast': issue.priority === 'CRITICAL',
'bg-orange text-contrast': issue.priority === 'HIGH',
'bg-button-bg text-secondary':
issue.priority === 'NORMAL' || issue.priority === 'LOW',
}"
>
{{ priorityLabel(issue.priority) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<span>{{ issue.authorName }}</span>
<span>{{ formatDate(issue.createdAt) }}</span>
<span>{{ formatMessage(messages.comments, { count: issue.commentCount }) }}</span>
<span v-if="issue.assigneeName">→ {{ issue.assigneeName }}</span>
</div>
</div>
<button
v-if="hasMore"
type="button"
class="w-fit self-center rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="loadingMore"
@click="load(false)"
>
{{ formatMessage(messages.more) }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSiteBugNotification,
skinSiteBugNotifications,
skinSiteMarkBugNotificationRead,
} from '@/helpers/skin-site-api'
const emit = defineEmits<{ (e: 'back'): void; (e: 'open', id: number): void }>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.bug-notifications.title', defaultMessage: 'Notifications' },
back: { id: 'app.skin-site.bug-notifications.back', defaultMessage: 'Back to home' },
subtitle: {
id: 'app.skin-site.bug-notifications.subtitle',
defaultMessage: 'Activity on issues you reported or follow.',
},
loading: { id: 'app.skin-site.bug-notifications.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.bug-notifications.failed',
defaultMessage: 'Could not load notifications.',
},
empty: { id: 'app.skin-site.bug-notifications.empty', defaultMessage: 'No new notifications.' },
item: {
id: 'app.skin-site.bug-notifications.item',
defaultMessage: '{name} updated issue #{id}',
},
markAll: {
id: 'app.skin-site.bug-notifications.mark-all',
defaultMessage: 'Mark all as read',
},
markedAll: {
id: 'app.skin-site.bug-notifications.marked-all',
defaultMessage: 'All notifications marked as read.',
},
})
const items = ref<SkinSiteBugNotification[]>([])
const loading = ref(true)
const failed = ref(false)
const marking = ref(false)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteBugNotifications()
if (!result.success) throw new Error(result.errorMessage)
items.value = result.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function markAllRead() {
if (marking.value || !items.value.length) return
marking.value = true
try {
await skinSiteMarkBugNotificationRead()
items.value = []
} catch (error) {
handleError(error)
} finally {
marking.value = false
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<header class="flex flex-wrap items-center gap-3">
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</div>
<button
v-if="items.length"
type="button"
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
:disabled="marking"
@click="markAllRead"
>
{{ formatMessage(messages.markAll) }}
</button>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p
v-else-if="!items.length"
class="m-0 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in items"
:key="item.id"
class="flex cursor-pointer flex-wrap items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 hover:border-brand"
@click="emit('open', item.issueId)"
>
<span class="min-w-0 flex-1 truncate text-sm text-contrast">
{{ formatMessage(messages.item, { name: item.actorName, id: item.issueId }) }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatDateTime(item.createdAt) }}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { requestCaptcha, verifyCaptcha } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.captcha.title', defaultMessage: 'Let us check you are human' },
subtitle: {
id: 'app.skin-site.captcha.subtitle',
defaultMessage: 'Drag the slider to rotate the image upright.',
},
submit: { id: 'app.skin-site.captcha.submit', defaultMessage: 'Submit' },
loading: { id: 'app.skin-site.captcha.loading', defaultMessage: 'Loading captcha…' },
})
const emit = defineEmits<{ (e: 'verified', clientId: string): void; (e: 'cancel'): void }>()
const modal = ref<InstanceType<typeof NewModal>>()
const loading = ref(false)
const submitting = ref(false)
const image = ref('')
const challengeClientId = ref('')
const degree = ref(0)
/** The image is rotated by the server; the slider counter-rotates the preview. */
const previewRotation = computed(() => `${degree.value}deg`)
function resetState() {
degree.value = 0
image.value = ''
challengeClientId.value = ''
}
async function loadCaptcha() {
loading.value = true
try {
const result = await requestCaptcha()
if (!result.success) throw new Error(result.errorMessage)
image.value = result.payload.image ?? ''
challengeClientId.value = result.payload.clientId
degree.value = 0
} catch (error) {
handleError(error)
modal.value?.hide()
emit('cancel')
} finally {
loading.value = false
}
}
async function show() {
resetState()
modal.value?.show()
await loadCaptcha()
}
async function submit() {
if (submitting.value || !challengeClientId.value) return
submitting.value = true
try {
const result = await verifyCaptcha({
clientId: challengeClientId.value,
degree: degree.value,
})
if (!result.success) {
handleError(new Error(result.errorMessage))
await loadCaptcha()
return
}
modal.value?.hide()
emit('verified', result.payload)
} catch (error) {
handleError(error)
} finally {
submitting.value = false
}
}
function cancel() {
modal.value?.hide()
emit('cancel')
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="resetState">
<div class="flex flex-col items-center gap-6">
<p class="m-0 text-secondary">{{ formatMessage(messages.subtitle) }}</p>
<div
class="size-44 overflow-hidden rounded-full border-4 border-solid border-bg-raised bg-button-bg"
>
<img
v-if="image"
:src="image"
alt=""
class="size-full object-cover transition-transform duration-75"
:style="{ transform: `rotate(${previewRotation})` }"
draggable="false"
/>
</div>
<div class="w-full max-w-sm">
<input
v-model.number="degree"
type="range"
min="0"
max="360"
step="1"
class="w-full accent-brand"
:disabled="loading || submitting"
/>
<div class="mt-1 flex justify-between text-xs text-secondary">
<span>0°</span>
<span>90°</span>
<span>180°</span>
<span>270°</span>
<span>360°</span>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="cancel"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast"
:disabled="loading || submitting || !image"
@click="submit"
>
{{ formatMessage(loading ? messages.loading : messages.submit) }}
</button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { HeartIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSitePublicDonationSummary,
skinSitePublicDonationSummary,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.home.donation.title', defaultMessage: 'Donation thanks' },
total: { id: 'app.skin-site.home.donation.total', defaultMessage: 'Total raised' },
donors: { id: 'app.skin-site.home.donation.donors', defaultMessage: '{count} donors' },
unavailable: {
id: 'app.skin-site.home.donation.unavailable',
defaultMessage: 'Donation summary unavailable.',
},
loading: { id: 'app.skin-site.home.donation.loading', defaultMessage: 'Loading…' },
})
const summary = ref<SkinSitePublicDonationSummary | null>(null)
const loading = ref(true)
const failed = ref(false)
onMounted(async () => {
try {
const result = await skinSitePublicDonationSummary()
if (result.success) summary.value = result.payload
else failed.value = true
} catch {
failed.value = true
} finally {
loading.value = false
}
})
</script>
<template>
<div
class="flex items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3"
>
<HeartIcon class="size-5 shrink-0 text-brand" />
<div class="flex min-w-0 flex-1 flex-col">
<span class="text-sm font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<span v-if="loading" class="flex items-center gap-1 text-xs text-secondary">
<SpinnerIcon class="size-3 animate-spin" />
{{ formatMessage(messages.loading) }}
</span>
<span v-else-if="failed" class="text-xs text-secondary">
{{ formatMessage(messages.unavailable) }}
</span>
<span v-else-if="summary" class="text-xs text-secondary">
{{ formatMessage(messages.total) }}: {{ summary.totalAmount }} ·
{{ formatMessage(messages.donors, { count: summary.donorCount }) }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,198 @@
<script setup lang="ts">
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSitePublicDownloadCategories,
type SkinSitePublicDownloadCategory,
type SkinSitePublicDownloadItem,
skinSitePublicDownloadList,
type SkinSitePublicDownloadStatus,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.downloads.title', defaultMessage: 'Downloads' },
subtitle: {
id: 'app.skin-site.downloads.subtitle',
defaultMessage: 'Files shared by the server. Pick a category to narrow the list.',
},
back: { id: 'app.skin-site.downloads.back', defaultMessage: 'Back to home' },
all: { id: 'app.skin-site.downloads.all', defaultMessage: 'All' },
empty: { id: 'app.skin-site.downloads.empty', defaultMessage: 'Nothing to download yet.' },
loading: { id: 'app.skin-site.downloads.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.downloads.failed', defaultMessage: 'Could not load downloads.' },
goDownload: { id: 'app.skin-site.downloads.go', defaultMessage: 'Download' },
uploader: { id: 'app.skin-site.downloads.uploader', defaultMessage: 'by {name}' },
statusOk: { id: 'app.skin-site.downloads.status.ok', defaultMessage: 'OK' },
statusNew: { id: 'app.skin-site.downloads.status.new', defaultMessage: 'New' },
statusLegacy: { id: 'app.skin-site.downloads.status.legacy', defaultMessage: 'Legacy' },
statusOutdated: { id: 'app.skin-site.downloads.status.outdated', defaultMessage: 'Outdated' },
statusRemoval: { id: 'app.skin-site.downloads.status.removal', defaultMessage: 'Removal' },
})
const emit = defineEmits<{ (e: 'back'): void }>()
const items = ref<SkinSitePublicDownloadItem[]>([])
const categories = ref<SkinSitePublicDownloadCategory[]>([])
const selectedCategory = ref<string | null>(null)
const loading = ref(true)
const failed = ref(false)
const filtered = computed(() =>
selectedCategory.value
? items.value.filter((item) => item.category.includes(selectedCategory.value!))
: items.value,
)
function statusLabel(status: SkinSitePublicDownloadStatus): string {
switch (status) {
case 'NEW':
return formatMessage(messages.statusNew)
case 'LEGACY':
return formatMessage(messages.statusLegacy)
case 'OUTDATED':
return formatMessage(messages.statusOutdated)
case 'REMOVAL':
return formatMessage(messages.statusRemoval)
default:
return formatMessage(messages.statusOk)
}
}
async function openUrl(url: string) {
const { openUrl: open } = await import('@tauri-apps/plugin-opener')
await open(url)
}
function download(item: SkinSitePublicDownloadItem) {
const route = item.routes?.[0]
void openUrl(route?.url ?? item.url)
}
async function load() {
loading.value = true
failed.value = false
try {
const [listResult, categoryResult] = await Promise.all([
skinSitePublicDownloadList(),
skinSitePublicDownloadCategories(),
])
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
if (categoryResult.success) categories.value = categoryResult.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<header class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</header>
<div v-if="categories.length" class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="
selectedCategory === null
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:brightness-110'
"
@click="selectedCategory = null"
>
{{ formatMessage(messages.all) }}
</button>
<button
v-for="category in categories"
:key="category.id"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="
selectedCategory === category.name
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:brightness-110'
"
@click="selectedCategory = category.name"
>
{{ category.name }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!filtered.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in filtered"
:key="item.id"
class="flex flex-wrap items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col gap-1">
<div class="flex flex-wrap items-center gap-2">
<span class="min-w-0 truncate text-sm font-semibold text-contrast">
{{ item.filename }}
</span>
<span
v-for="status in item.status"
:key="status"
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="{
'bg-green text-contrast': status === 'NEW',
'bg-yellow text-contrast': status === 'LEGACY',
'bg-red text-contrast': status === 'OUTDATED' || status === 'REMOVAL',
'bg-button-bg text-secondary':
status !== 'NEW' &&
status !== 'LEGACY' &&
status !== 'OUTDATED' &&
status !== 'REMOVAL',
}"
>
{{ statusLabel(status) }}
</span>
</div>
<span v-if="item.description" class="truncate text-xs text-secondary">
{{ item.description }}
</span>
<span v-if="item.uploader" class="text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
</span>
</div>
<button
type="button"
class="flex shrink-0 items-center gap-1 rounded-full bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="download(item)"
>
<DownloadIcon class="size-3.5" />
{{ formatMessage(messages.goDownload) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { CopyIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import SkinSiteAccountCard from './SkinSiteAccountCard.vue'
import SkinSiteAdminView from './SkinSiteAdminView.vue'
import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
import SkinSiteBugCreateModal from './SkinSiteBugCreateModal.vue'
import SkinSiteBugDetailView from './SkinSiteBugDetailView.vue'
import SkinSiteBugListView from './SkinSiteBugListView.vue'
import SkinSiteBugNotificationsView from './SkinSiteBugNotificationsView.vue'
import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
import SkinSiteDownloadsView from './SkinSiteDownloadsView.vue'
import SkinSiteLoginModal from './SkinSiteLoginModal.vue'
import SkinSiteMapView from './SkinSiteMapView.vue'
import SkinSiteNavCards from './SkinSiteNavCards.vue'
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
import SkinSiteProfileView from './SkinSiteProfileView.vue'
import SkinSiteReviewView from './SkinSiteReviewView.vue'
import WikiView from './WikiView.vue'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.home.title', defaultMessage: 'StarLight Skin Site' },
subtitle: {
id: 'app.skin-site.home.subtitle',
defaultMessage: 'The wardrobe for StarLight Server players.',
},
address: { id: 'app.skin-site.home.address', defaultMessage: 'Skin site address' },
copied: { id: 'app.skin-site.home.copied', defaultMessage: 'Address copied.' },
})
const YGGDRASIL_URL = 'https://skin.starlight.cool/yggdrasil'
const loginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>()
const view = ref<
| 'home'
| 'players'
| 'downloads'
| 'map'
| 'review'
| 'bugs'
| 'bug-detail'
| 'bug-notifications'
| 'admin'
| 'wiki'
>('home')
const selectedBugId = ref<number | null>(null)
function openBug(id: number) {
selectedBugId.value = id
view.value = 'bug-detail'
}
function backToBugList() {
selectedBugId.value = null
view.value = 'bugs'
}
const bugCreateModal = ref<InstanceType<typeof SkinSiteBugCreateModal>>()
function openBugNotifications() {
view.value = 'bug-notifications'
}
function onBugCreated(id: number) {
openBug(id)
}
async function copyAddress() {
try {
await navigator.clipboard.writeText(YGGDRASIL_URL)
addNotification({ type: 'success', title: formatMessage(messages.copied) })
} catch {
// Clipboard may be unavailable; the address stays selectable.
}
}
</script>
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-y-auto">
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-6">
<SkinSiteProfileView v-if="view === 'players'" @back="view = 'home'" />
<SkinSiteDownloadsView v-else-if="view === 'downloads'" @back="view = 'home'" />
<SkinSiteMapView v-else-if="view === 'map'" @back="view = 'home'" />
<SkinSiteReviewView v-else-if="view === 'review'" @back="view = 'home'" />
<SkinSiteBugListView
v-else-if="view === 'bugs'"
@back="view = 'home'"
@open="openBug"
@create="bugCreateModal?.show()"
@notifications="openBugNotifications"
/>
<SkinSiteBugNotificationsView
v-else-if="view === 'bug-notifications'"
@back="view = 'bugs'"
@open="openBug"
/>
<SkinSiteAdminView v-else-if="view === 'admin'" @back="view = 'home'" />
<WikiView v-else-if="view === 'wiki'" />
<SkinSiteBugDetailView
v-else-if="view === 'bug-detail' && selectedBugId !== null"
:issue-id="selectedBugId"
@back="backToBugList"
/>
<template v-else>
<header class="flex flex-col items-center gap-1 text-center">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</header>
<SkinSiteAccountCard
@sign-in="loginModal?.show()"
@open-settings="profileModal?.show()"
@open-players="view = 'players'"
@open-admin="view = 'admin'"
/>
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkinSiteNavCards @navigate="(v) => (view = v)" />
<SkinSiteAnnouncements />
</div>
<SkinSiteDonationCard />
<div
class="flex flex-wrap items-center gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3"
>
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.address)
}}</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">{{ YGGDRASIL_URL }}</code>
<button
type="button"
class="flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="copyAddress"
>
<CopyIcon class="size-3.5" />
{{ formatMessage({ id: 'app.skin-site.home.copy', defaultMessage: 'Copy' }) }}
</button>
</div>
</template>
</div>
<SkinSiteLoginModal ref="loginModal" />
<SkinSiteProfileModal ref="profileModal" />
<SkinSiteBugCreateModal ref="bugCreateModal" @created="onBugCreated" />
</div>
</template>
@@ -0,0 +1,185 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { reloadSkinSiteSession, signInSkinSite } from '@/composables/skin-site-session'
import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue'
import SkinSitePasswordResetModal from './SkinSitePasswordResetModal.vue'
import SkinSiteRegisterModal from './SkinSiteRegisterModal.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.login.title', defaultMessage: 'Sign in to StarLight' },
username: { id: 'app.skin-site.login.username', defaultMessage: 'Username or email' },
password: { id: 'app.skin-site.login.password', defaultMessage: 'Password' },
signIn: { id: 'app.skin-site.login.sign-in', defaultMessage: 'Sign in' },
signingIn: { id: 'app.skin-site.login.signing-in', defaultMessage: 'Signing in…' },
register: { id: 'app.skin-site.login.register', defaultMessage: 'Create account' },
forgotPassword: { id: 'app.skin-site.login.forgot', defaultMessage: 'Forgot password?' },
usernameHint: {
id: 'app.skin-site.login.username-hint',
defaultMessage: '3–16 characters, or a registered email address.',
},
passwordHint: {
id: 'app.skin-site.login.password-hint',
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
},
success: { id: 'app.skin-site.login.success', defaultMessage: 'Signed in to StarLight.' },
})
const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/
const modal = ref<InstanceType<typeof NewModal>>()
const captchaModal = ref<InstanceType<typeof SkinSiteCaptchaModal>>()
const registerModal = ref<InstanceType<typeof SkinSiteRegisterModal>>()
const passwordResetModal = ref<InstanceType<typeof SkinSitePasswordResetModal>>()
function openRegister() {
modal.value?.hide()
registerModal.value?.show()
}
function openPasswordReset() {
modal.value?.hide()
passwordResetModal.value?.show()
}
const username = ref('')
const password = ref('')
const working = ref(false)
const emit = defineEmits<{ (e: 'signed-in' | 'cancel'): void }>()
const usernameValid = computed(() => {
const value = username.value.trim()
if (!value) return false
if (value.includes('@')) return value.length < 64
return value.length >= 3 && value.length <= 16
})
const passwordValid = computed(() => PASSWORD_RE.test(password.value))
const canSubmit = computed(() => usernameValid.value && passwordValid.value && !working.value)
function reset() {
username.value = ''
password.value = ''
working.value = false
}
function show() {
reset()
modal.value?.show()
}
async function beginSignIn() {
if (!canSubmit.value) return
captchaModal.value?.show()
}
async function onCaptchaVerified(clientId: string) {
working.value = true
try {
const result = await signInSkinSite({
username: username.value.trim(),
password: password.value,
clientId,
})
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
await reloadSkinSiteSession()
addNotification({ type: 'success', title: formatMessage(messages.success) })
modal.value?.hide()
reset()
emit('signed-in')
} catch (error) {
handleError(error)
} finally {
working.value = false
}
}
function cancel() {
modal.value?.hide()
emit('cancel')
}
function onRegisterSignedIn() {
emit('signed-in')
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.username) }}</span>
<StyledInput v-model="username" type="text" autocomplete="username" />
<span class="text-xs text-secondary">{{ formatMessage(messages.usernameHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.password) }}</span>
<StyledInput v-model="password" type="password" autocomplete="current-password" />
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
</label>
<div class="flex justify-between text-sm">
<button type="button" class="text-brand hover:underline" @click="openRegister">
{{ formatMessage(messages.register) }}
</button>
<button type="button" class="text-brand hover:underline" @click="openPasswordReset">
{{ formatMessage(messages.forgotPassword) }}
</button>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="cancel"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="beginSignIn"
>
{{ formatMessage(working ? messages.signingIn : messages.signIn) }}
</button>
</div>
</template>
</NewModal>
<SkinSiteRegisterModal
ref="registerModal"
@signed-in="onRegisterSignedIn"
@cancel="modal?.show()"
/>
<SkinSitePasswordResetModal
ref="passwordResetModal"
@done="modal?.show()"
@cancel="modal?.show()"
/>
<SkinSiteCaptchaModal
ref="captchaModal"
@verified="onCaptchaVerified"
@cancel="working = false"
/>
</template>
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ExternalIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import { type SkinSitePublicMapServer, skinSitePublicMapServers } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.map.title', defaultMessage: 'Server map' },
back: { id: 'app.skin-site.map.back', defaultMessage: 'Back to home' },
internal: { id: 'app.skin-site.map.internal', defaultMessage: 'Internal' },
external: { id: 'app.skin-site.map.external', defaultMessage: 'External' },
server: { id: 'app.skin-site.map.server', defaultMessage: 'Server' },
map: { id: 'app.skin-site.map.map', defaultMessage: 'Map' },
openExternal: { id: 'app.skin-site.map.open', defaultMessage: 'Open in browser' },
loading: { id: 'app.skin-site.map.loading', defaultMessage: 'Loading maps…' },
empty: { id: 'app.skin-site.map.empty', defaultMessage: 'No maps available.' },
failed: { id: 'app.skin-site.map.failed', defaultMessage: 'Could not load maps.' },
})
const emit = defineEmits<{ (e: 'back'): void }>()
const servers = ref<SkinSitePublicMapServer[]>([])
const internal = ref(false)
const serverIndex = ref(0)
const mapIndex = ref(0)
const loading = ref(true)
const failed = ref(false)
const filteredServers = computed(() => servers.value.filter((s) => s.internal === internal.value))
const selectedServer = computed(() => filteredServers.value[serverIndex.value])
const selectedMap = computed(() => selectedServer.value?.maps[mapIndex.value])
watch(internal, () => {
serverIndex.value = 0
mapIndex.value = 0
})
watch(serverIndex, () => {
mapIndex.value = 0
})
async function openExternal() {
if (!selectedMap.value) return
const { openUrl } = await import('@tauri-apps/plugin-opener')
await openUrl(selectedMap.value.url)
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSitePublicMapServers()
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? []
if (!servers.value.some((s) => !s.internal)) internal.value = true
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="flex items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<h1 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<select
v-model="internal"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option :value="false">{{ formatMessage(messages.external) }}</option>
<option :value="true">{{ formatMessage(messages.internal) }}</option>
</select>
<select
v-if="filteredServers.length"
v-model.number="serverIndex"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option v-for="(server, index) in filteredServers" :key="index" :value="index">
{{ server.name }}
</option>
</select>
<select
v-if="selectedServer?.maps.length"
v-model.number="mapIndex"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option v-for="(map, index) in selectedServer.maps" :key="index" :value="index">
{{ map.name }}
</option>
</select>
<button
v-if="selectedMap"
type="button"
class="ml-auto flex items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openExternal"
>
<ExternalIcon class="size-3.5" />
{{ formatMessage(messages.openExternal) }}
</button>
</div>
<div
v-if="loading"
class="flex flex-1 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 flex-1 text-center text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!selectedMap" class="m-0 flex-1 text-center text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<iframe
v-else
:key="selectedMap.url"
:src="selectedMap.url"
:title="`${selectedServer?.name} · ${selectedMap.name}`"
class="min-h-0 w-full flex-1 rounded-lg border border-solid border-surface-5"
allow="fullscreen"
/>
</div>
</template>
@@ -0,0 +1,145 @@
<script setup lang="ts">
import {
BookIcon,
BugIcon,
DownloadIcon,
MapIcon,
ShieldCheckIcon,
UserIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import type { Component } from 'vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki'): void
}>()
const messages = defineMessages({
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
download: { id: 'app.skin-site.home.nav.download', defaultMessage: 'Downloads' },
downloadDesc: {
id: 'app.skin-site.home.nav.download-desc',
defaultMessage: 'Browse files shared by the server.',
},
review: { id: 'app.skin-site.home.nav.review', defaultMessage: 'Player review' },
reviewDesc: {
id: 'app.skin-site.home.nav.review-desc',
defaultMessage: 'Submit and review player applications.',
},
wiki: { id: 'app.skin-site.home.nav.wiki', defaultMessage: 'Server wiki' },
wikiDesc: {
id: 'app.skin-site.home.nav.wiki-desc',
defaultMessage: 'Guides and documentation for the server.',
},
bugs: { id: 'app.skin-site.home.nav.bugs', defaultMessage: 'Bug tracker' },
bugsDesc: {
id: 'app.skin-site.home.nav.bugs-desc',
defaultMessage: 'Report issues and follow their progress.',
},
map: { id: 'app.skin-site.home.nav.map', defaultMessage: 'Server map' },
mapDesc: {
id: 'app.skin-site.home.nav.map-desc',
defaultMessage: 'Explore the live world map.',
},
profile: { id: 'app.skin-site.home.nav.profile', defaultMessage: 'Profile' },
profileDesc: {
id: 'app.skin-site.home.nav.profile-desc',
defaultMessage: 'Manage your account and textures.',
},
})
interface NavItem {
id: string
title: string
description: string
icon: Component
href: string
}
const SKIN_SITE = 'https://skin.starlight.cool'
const items: NavItem[] = [
{
id: 'download',
title: formatMessage(messages.download),
description: formatMessage(messages.downloadDesc),
icon: DownloadIcon,
href: `${SKIN_SITE}/download`,
},
{
id: 'review',
title: formatMessage(messages.review),
description: formatMessage(messages.reviewDesc),
icon: ShieldCheckIcon,
href: `${SKIN_SITE}/review`,
},
{
id: 'wiki',
title: formatMessage(messages.wiki),
description: formatMessage(messages.wikiDesc),
icon: BookIcon,
href: `${SKIN_SITE}/wiki`,
},
{
id: 'bugs',
title: formatMessage(messages.bugs),
description: formatMessage(messages.bugsDesc),
icon: BugIcon,
href: `${SKIN_SITE}/bugs`,
},
{
id: 'map',
title: formatMessage(messages.map),
description: formatMessage(messages.mapDesc),
icon: MapIcon,
href: `${SKIN_SITE}/map`,
},
{
id: 'profile',
title: formatMessage(messages.profile),
description: formatMessage(messages.profileDesc),
icon: UserIcon,
href: `${SKIN_SITE}/profile`,
},
]
function open(item: NavItem) {
if (
item.id === 'download' ||
item.id === 'review' ||
item.id === 'bugs' ||
item.id === 'map' ||
item.id === 'wiki'
) {
emit(
'navigate',
item.id === 'download'
? 'downloads'
: (item.id as 'review' | 'bugs' | 'map' | 'wiki'),
)
return
}
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(item.href))
}
</script>
<template>
<div class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-contrast">{{ formatMessage(messages.title) }}</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<button
v-for="item in items"
:key="item.id"
type="button"
class="flex flex-col items-start gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 text-left transition-colors hover:bg-button-bg"
@click="open(item)"
>
<component :is="item.icon" class="size-5 text-brand" />
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
<span class="text-xs text-secondary">{{ item.description }}</span>
</button>
</div>
</div>
</template>
@@ -0,0 +1,238 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { skinSitePasswordReset, skinSitePasswordResetEmailVerify } from '@/helpers/skin-site-api'
import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.reset.title', defaultMessage: 'Reset password' },
email: { id: 'app.skin-site.reset.email', defaultMessage: 'Email' },
emailHint: {
id: 'app.skin-site.reset.email-hint',
defaultMessage: 'Use the address you registered with.',
},
code: { id: 'app.skin-site.reset.code', defaultMessage: 'Verification code' },
sendCode: { id: 'app.skin-site.reset.send-code', defaultMessage: 'Send code' },
sending: { id: 'app.skin-site.reset.sending', defaultMessage: 'Sending…' },
resendIn: { id: 'app.skin-site.reset.resend-in', defaultMessage: 'Sent ({seconds}s)' },
newPassword: { id: 'app.skin-site.reset.new-password', defaultMessage: 'New password' },
repeatPassword: { id: 'app.skin-site.reset.repeat-password', defaultMessage: 'Repeat password' },
passwordHint: {
id: 'app.skin-site.reset.password-hint',
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
},
mismatch: { id: 'app.skin-site.reset.mismatch', defaultMessage: 'Passwords do not match.' },
submit: { id: 'app.skin-site.reset.submit', defaultMessage: 'Reset password' },
success: {
id: 'app.skin-site.reset.success',
defaultMessage: 'Password updated. You can sign in now.',
},
})
const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/
const COUNTDOWN_SECONDS = 60
const modal = ref<InstanceType<typeof NewModal>>()
const captchaModal = ref<InstanceType<typeof SkinSiteCaptchaModal>>()
const email = ref('')
const code = ref('')
const newPassword = ref('')
const repeatPassword = ref('')
const sending = ref(false)
const working = ref(false)
const countdown = ref(0)
let timer: ReturnType<typeof setInterval> | undefined
const emit = defineEmits<{ (e: 'done' | 'cancel'): void }>()
function stopTimer() {
if (timer) clearInterval(timer)
timer = undefined
}
function startCountdown() {
countdown.value = COUNTDOWN_SECONDS
stopTimer()
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) stopTimer()
}, 1000)
}
const canSendCode = computed(
() => email.value.trim().length > 0 && !sending.value && countdown.value <= 0,
)
const passwordValid = computed(() => PASSWORD_RE.test(newPassword.value))
const passwordsMatch = computed(() => newPassword.value === repeatPassword.value)
const canSubmit = computed(
() =>
email.value.trim().length > 0 &&
code.value.trim().length === 6 &&
passwordValid.value &&
passwordsMatch.value &&
!working.value,
)
function reset() {
stopTimer()
email.value = ''
code.value = ''
newPassword.value = ''
repeatPassword.value = ''
sending.value = false
working.value = false
countdown.value = 0
}
function show() {
reset()
modal.value?.show()
}
async function sendCode() {
if (!canSendCode.value) return
sending.value = true
try {
const result = await skinSitePasswordResetEmailVerify(email.value.trim())
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: result.payload })
startCountdown()
} catch (error) {
handleError(error)
} finally {
sending.value = false
}
}
function beginReset() {
if (!canSubmit.value) return
if (!passwordsMatch.value) {
handleError(new Error(formatMessage(messages.mismatch)))
return
}
captchaModal.value?.show()
}
async function onCaptchaVerified(clientId: string) {
working.value = true
try {
const result = await skinSitePasswordReset({
clientId,
email: email.value.trim(),
emailVerifyCode: code.value.trim(),
newPassword: newPassword.value,
})
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: formatMessage(messages.success) })
modal.value?.hide()
emit('done')
} catch (error) {
handleError(error)
} finally {
working.value = false
}
}
function cancel() {
modal.value?.hide()
emit('cancel')
}
onUnmounted(stopTimer)
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
<div class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.email) }}</span>
<StyledInput v-model="email" type="email" autocomplete="email" />
<span class="text-xs text-secondary">{{ formatMessage(messages.emailHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.code) }}</span>
<div class="flex gap-2">
<StyledInput v-model="code" type="text" class="flex-1" maxlength="6" />
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-4 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSendCode"
@click="sendCode"
>
{{
sending
? formatMessage(messages.sending)
: countdown > 0
? formatMessage(messages.resendIn, { seconds: countdown })
: formatMessage(messages.sendCode)
}}
</button>
</div>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.newPassword) }}</span>
<StyledInput v-model="newPassword" type="password" autocomplete="new-password" />
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{
formatMessage(messages.repeatPassword)
}}</span>
<StyledInput v-model="repeatPassword" type="password" autocomplete="new-password" />
<span v-if="repeatPassword && !passwordsMatch" class="text-xs text-red">
{{ formatMessage(messages.mismatch) }}
</span>
</label>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="cancel"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="beginReset"
>
{{ formatMessage(messages.submit) }}
</button>
</div>
</template>
</NewModal>
<SkinSiteCaptchaModal
ref="captchaModal"
@verified="onCaptchaVerified"
@cancel="working = false"
/>
</template>
@@ -0,0 +1,402 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { reloadSkinSiteSession, skinSiteUser } from '@/composables/skin-site-session'
import {
skinSiteChangePassword,
skinSiteChangeUsername,
skinSiteChangeYggdrasilPassword,
skinSiteUploadAvatar,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.profile.title', defaultMessage: 'Account settings' },
basic: { id: 'app.skin-site.profile.basic', defaultMessage: 'Profile' },
username: { id: 'app.skin-site.profile.username', defaultMessage: 'Username' },
usernameHint: {
id: 'app.skin-site.profile.username-hint',
defaultMessage: '3–16 characters.',
},
save: { id: 'app.skin-site.profile.save', defaultMessage: 'Save' },
avatar: { id: 'app.skin-site.profile.avatar', defaultMessage: 'Avatar' },
chooseAvatar: { id: 'app.skin-site.profile.choose-avatar', defaultMessage: 'Choose image' },
avatarHint: {
id: 'app.skin-site.profile.avatar-hint',
defaultMessage: 'Square images work best; the centre is cropped to 256×256.',
},
security: { id: 'app.skin-site.profile.security', defaultMessage: 'Security' },
sitePasswordHint: {
id: 'app.skin-site.profile.site-password-hint',
defaultMessage: 'This is the password used to sign in to the skin site.',
},
oldPassword: { id: 'app.skin-site.profile.old-password', defaultMessage: 'Current password' },
newPassword: { id: 'app.skin-site.profile.new-password', defaultMessage: 'New password' },
repeatPassword: {
id: 'app.skin-site.profile.repeat-password',
defaultMessage: 'Repeat new password',
},
mismatch: { id: 'app.skin-site.profile.mismatch', defaultMessage: 'Passwords do not match.' },
passwordHint: {
id: 'app.skin-site.profile.password-hint',
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
},
submitPassword: {
id: 'app.skin-site.profile.submit-password',
defaultMessage: 'Update password',
},
minecraftPassword: {
id: 'app.skin-site.profile.minecraft-password',
defaultMessage: 'Minecraft password',
},
minecraftPasswordHint: {
id: 'app.skin-site.profile.minecraft-password-hint',
defaultMessage: 'Used by the launcher; defaults to the website password.',
},
resetDefault: {
id: 'app.skin-site.profile.reset-default',
defaultMessage: 'Reset to website password',
},
saved: { id: 'app.skin-site.profile.saved', defaultMessage: 'Saved.' },
avatarUploaded: {
id: 'app.skin-site.profile.avatar-uploaded',
defaultMessage: 'Avatar updated.',
},
passwordChanged: {
id: 'app.skin-site.profile.password-changed',
defaultMessage: 'Password updated.',
},
minecraftPasswordChanged: {
id: 'app.skin-site.profile.minecraft-password-changed',
defaultMessage: 'Minecraft password updated.',
},
})
const PASSWORD_RE = /^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)\S{6,50}$/
const modal = ref<InstanceType<typeof NewModal>>()
const username = ref('')
const savingUsername = ref(false)
const avatarPreview = ref('')
const uploadingAvatar = ref(false)
const oldPassword = ref('')
const newPassword = ref('')
const repeatPassword = ref('')
const savingPassword = ref(false)
const newMinecraftPassword = ref('')
const repeatMinecraftPassword = ref('')
const savingMinecraftPassword = ref(false)
const usernameValid = computed(() => {
const value = username.value.trim()
return value.length >= 3 && value.length <= 16
})
const usernameChanged = computed(
() => !!skinSiteUser.value && username.value.trim() !== skinSiteUser.value.username,
)
const passwordValid = computed(() => PASSWORD_RE.test(newPassword.value))
const passwordsMatch = computed(() => newPassword.value === repeatPassword.value)
const canSavePassword = computed(
() => !!oldPassword.value && passwordValid.value && passwordsMatch.value && !savingPassword.value,
)
const minecraftPasswordValid = computed(() => PASSWORD_RE.test(newMinecraftPassword.value))
const minecraftPasswordsMatch = computed(
() => newMinecraftPassword.value === repeatMinecraftPassword.value,
)
const canSaveMinecraftPassword = computed(
() =>
minecraftPasswordValid.value && minecraftPasswordsMatch.value && !savingMinecraftPassword.value,
)
function show() {
username.value = skinSiteUser.value?.username ?? ''
avatarPreview.value = ''
oldPassword.value = ''
newPassword.value = ''
repeatPassword.value = ''
newMinecraftPassword.value = ''
repeatMinecraftPassword.value = ''
modal.value?.show()
}
async function saveUsername() {
if (!usernameChanged.value || !usernameValid.value) return
savingUsername.value = true
try {
const result = await skinSiteChangeUsername(username.value.trim())
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
await reloadSkinSiteSession()
addNotification({ type: 'success', title: formatMessage(messages.saved) })
} catch (error) {
handleError(error)
} finally {
savingUsername.value = false
}
}
/** Centre-crop the picked image to a square and scale it to 256×256. */
async function cropToSquare(file: File): Promise<Blob> {
const bitmap = await createImageBitmap(file)
const side = Math.min(bitmap.width, bitmap.height)
const sx = (bitmap.width - side) / 2
const sy = (bitmap.height - side) / 2
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 256
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('无法创建画布')
ctx.imageSmoothingQuality = 'high'
ctx.drawImage(bitmap, sx, sy, side, side, 0, 0, 256, 256)
bitmap.close?.()
return await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('无法处理图片'))), 'image/png')
})
}
async function onAvatarPicked(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
uploadingAvatar.value = true
try {
const blob = await cropToSquare(file)
avatarPreview.value = URL.createObjectURL(blob)
const result = await skinSiteUploadAvatar(blob)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
await reloadSkinSiteSession()
addNotification({ type: 'success', title: formatMessage(messages.avatarUploaded) })
} catch (error) {
handleError(error)
} finally {
uploadingAvatar.value = false
}
}
async function savePassword() {
if (!canSavePassword.value) return
savingPassword.value = true
try {
const result = await skinSiteChangePassword(oldPassword.value, newPassword.value)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
oldPassword.value = ''
newPassword.value = ''
repeatPassword.value = ''
addNotification({ type: 'success', title: formatMessage(messages.passwordChanged) })
} catch (error) {
handleError(error)
} finally {
savingPassword.value = false
}
}
async function saveMinecraftPassword(resetToDefault: boolean) {
if (!resetToDefault && !canSaveMinecraftPassword.value) return
savingMinecraftPassword.value = true
try {
const result = await skinSiteChangeYggdrasilPassword(
resetToDefault ? null : newMinecraftPassword.value,
)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
newMinecraftPassword.value = ''
repeatMinecraftPassword.value = ''
addNotification({ type: 'success', title: formatMessage(messages.minecraftPasswordChanged) })
} catch (error) {
handleError(error)
} finally {
savingMinecraftPassword.value = false
}
}
function close() {
modal.value?.hide()
}
onUnmounted(() => {
if (avatarPreview.value) URL.revokeObjectURL(avatarPreview.value)
})
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)">
<div class="flex flex-col gap-6">
<section class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.basic) }}
</h3>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.username)
}}</span>
<div class="flex gap-2">
<StyledInput v-model="username" type="text" class="flex-1" maxlength="16" />
<button
type="button"
class="shrink-0 rounded-full bg-brand px-4 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!usernameChanged || !usernameValid || savingUsername"
@click="saveUsername"
>
{{ formatMessage(messages.save) }}
</button>
</div>
<span class="text-xs text-secondary">{{ formatMessage(messages.usernameHint) }}</span>
</label>
<div class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.avatar)
}}</span>
<div class="flex items-center gap-3">
<img
v-if="avatarPreview"
:src="avatarPreview"
alt=""
class="size-12 rounded-lg object-cover"
/>
<label
class="cursor-pointer rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
>
{{ formatMessage(messages.chooseAvatar) }}
<input
type="file"
accept="image/*"
class="hidden"
:disabled="uploadingAvatar"
@change="onAvatarPicked"
/>
</label>
</div>
<span class="text-xs text-secondary">{{ formatMessage(messages.avatarHint) }}</span>
</div>
</section>
<section class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.security) }}
</h3>
<span class="text-sm text-secondary">{{ formatMessage(messages.sitePasswordHint) }}</span>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<StyledInput
v-model="oldPassword"
type="password"
:placeholder="formatMessage(messages.oldPassword)"
autocomplete="current-password"
/>
<StyledInput
v-model="newPassword"
type="password"
:placeholder="formatMessage(messages.newPassword)"
autocomplete="new-password"
/>
<StyledInput
v-model="repeatPassword"
type="password"
:placeholder="formatMessage(messages.repeatPassword)"
autocomplete="new-password"
/>
</div>
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
<span v-if="repeatPassword && !passwordsMatch" class="text-xs text-red">
{{ formatMessage(messages.mismatch) }}
</span>
<div class="flex justify-end">
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSavePassword"
@click="savePassword"
>
{{ formatMessage(messages.submitPassword) }}
</button>
</div>
</section>
<section class="flex flex-col gap-3">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.minecraftPassword)
}}</span>
<span class="text-xs text-secondary">{{
formatMessage(messages.minecraftPasswordHint)
}}</span>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<StyledInput
v-model="newMinecraftPassword"
type="password"
:placeholder="formatMessage(messages.newPassword)"
autocomplete="new-password"
/>
<StyledInput
v-model="repeatMinecraftPassword"
type="password"
:placeholder="formatMessage(messages.repeatPassword)"
autocomplete="new-password"
/>
</div>
<span v-if="repeatMinecraftPassword && !minecraftPasswordsMatch" class="text-xs text-red">
{{ formatMessage(messages.mismatch) }}
</span>
<div class="flex flex-wrap justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast"
:disabled="savingMinecraftPassword"
@click="saveMinecraftPassword(true)"
>
{{ formatMessage(messages.resetDefault) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSaveMinecraftPassword"
@click="saveMinecraftPassword(false)"
>
{{ formatMessage(messages.submitPassword) }}
</button>
</div>
</section>
</div>
<template #actions>
<div class="flex justify-end">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="close"
>
{{ formatMessage(commonMessages.closeButton) }}
</button>
</div>
</template>
</NewModal>
</template>
@@ -0,0 +1,283 @@
<script setup lang="ts">
import { SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import SkinPreviewRenderer from '@modrinth/ui/src/components/skin/SkinPreviewRenderer.vue'
import { computed, onMounted, ref, watch } from 'vue'
import {
requestSkinSitePlayers,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import {
skinSiteDeleteTexture,
type SkinSitePlayerSkin,
skinSitePlayerSkin,
skinSiteUploadTexture,
type SkinTextureType,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const emit = defineEmits<{ (e: 'back'): void }>()
const messages = defineMessages({
title: { id: 'app.skin-site.profile-view.title', defaultMessage: 'Your players' },
signedOut: {
id: 'app.skin-site.profile-view.signed-out',
defaultMessage: 'Sign in to manage your players.',
},
loading: { id: 'app.skin-site.profile-view.loading', defaultMessage: 'Loading players…' },
noPlayers: {
id: 'app.skin-site.profile-view.no-players',
defaultMessage: 'No players yet.',
},
selectHint: {
id: 'app.skin-site.profile-view.select-hint',
defaultMessage: 'Select a player to preview and edit its textures.',
},
skin: { id: 'app.skin-site.profile-view.skin', defaultMessage: 'Skin' },
cape: { id: 'app.skin-site.profile-view.cape', defaultMessage: 'Cape' },
elytra: { id: 'app.skin-site.profile-view.elytra', defaultMessage: 'Elytra' },
upload: { id: 'app.skin-site.profile-view.upload', defaultMessage: 'Upload' },
remove: { id: 'app.skin-site.profile-view.remove', defaultMessage: 'Remove' },
readonly: {
id: 'app.skin-site.profile-view.readonly',
defaultMessage: 'Mojang accounts are managed by Mojang; textures cannot be edited here.',
},
uploaded: { id: 'app.skin-site.profile-view.uploaded', defaultMessage: 'Texture updated.' },
removed: { id: 'app.skin-site.profile-view.removed', defaultMessage: 'Texture removed.' },
back: { id: 'app.skin-site.profile-view.back', defaultMessage: 'Back to home' },
})
const selectedId = ref<string | null>(null)
const texture = ref<SkinSitePlayerSkin>({})
const loadingTexture = ref(false)
const uploading = ref<SkinTextureType | null>(null)
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
const players = computed(() => skinSitePlayers.value)
const playersLoading = computed(
() => skinSitePlayersStatus.value === 'checking' || skinSitePlayersStatus.value === 'idle',
)
const selectedPlayer = computed(
() => players.value.find((p) => p.uuid === selectedId.value) ?? null,
)
const editable = computed(() => !!selectedPlayer.value && !selectedPlayer.value.isMojang)
const skinVariant = computed<'CLASSIC' | 'SLIM'>(() =>
texture.value.model === 'slim' ? 'SLIM' : 'CLASSIC',
)
function selectPlayer(id: string) {
selectedId.value = id
}
async function loadTexture(uuid: string) {
loadingTexture.value = true
texture.value = {}
try {
const result = await skinSitePlayerSkin(uuid)
if (result.success && result.payload) texture.value = result.payload
} catch (error) {
handleError(error)
} finally {
loadingTexture.value = false
}
}
watch(selectedId, (id) => {
if (id) void loadTexture(id)
else texture.value = {}
})
async function onUpload(type: SkinTextureType, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || !selectedId.value) return
uploading.value = type
try {
const metadata: Record<string, string> = {}
if (type === 'SKIN') metadata.model = texture.value.model ?? 'default'
const result = await skinSiteUploadTexture(selectedId.value, file, type, metadata)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: formatMessage(messages.uploaded) })
await loadTexture(selectedId.value)
} catch (error) {
handleError(error)
} finally {
uploading.value = null
}
}
async function removeTexture(type: SkinTextureType) {
if (!selectedId.value) return
uploading.value = type
try {
const result = await skinSiteDeleteTexture(selectedId.value, type)
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: formatMessage(messages.removed) })
await loadTexture(selectedId.value)
} catch (error) {
handleError(error)
} finally {
uploading.value = null
}
}
onMounted(() => {
if (signedIn.value) void requestSkinSitePlayers().catch(() => {})
})
watch(signedIn, (value) => {
if (value) void requestSkinSitePlayers().catch(() => {})
})
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<div v-if="!signedIn" class="rounded-2xl border border-solid border-surface-5 bg-bg-raised p-4">
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.signedOut) }}</p>
</div>
<div v-else class="grid grid-cols-1 gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
<!-- Player list -->
<div
class="flex flex-col gap-2 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<div v-if="playersLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.noPlayers) }}
</p>
<button
v-for="player in players"
:key="player.uuid"
type="button"
class="flex items-center gap-2 rounded-xl px-2 py-1.5 text-left transition-colors"
:class="
player.uuid === selectedId
? 'bg-brand-highlight text-contrast'
: 'bg-button-bg hover:brightness-110'
"
@click="selectPlayer(player.uuid)"
>
<Avatar
:src="player.headDataUrl"
:alt="player.name"
size="1.75rem"
pixelated
class="!rounded-md"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ player.name }}</span>
<span v-if="player.isMojang" class="shrink-0 text-[10px] uppercase text-secondary"
>Mojang</span
>
</button>
</div>
<!-- Preview + texture actions -->
<div
class="flex flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<p v-if="!selectedPlayer" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.selectHint) }}
</p>
<template v-else>
<div class="flex items-center gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">{{ selectedPlayer.name }}</h3>
<span v-if="selectedPlayer.isMojang" class="text-xs text-secondary">Mojang</span>
</div>
<div class="relative h-80 w-full overflow-hidden rounded-xl bg-button-bg">
<div v-if="loadingTexture" class="absolute inset-0 flex items-center justify-center">
<SpinnerIcon class="size-6 animate-spin text-secondary" />
</div>
<SkinPreviewRenderer
v-else
:texture-src="texture.skin ?? ''"
:cape-src="texture.cape"
:variant="skinVariant"
:nametag="selectedPlayer.name"
:initial-rotation="Math.PI / 8"
class="size-full"
/>
</div>
<p v-if="!editable" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.readonly) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="entry in [
{ type: 'SKIN' as SkinTextureType, label: messages.skin, value: texture.skin },
{ type: 'CAPE' as SkinTextureType, label: messages.cape, value: texture.cape },
{
type: 'ELYTRA' as SkinTextureType,
label: messages.elytra,
value: texture.elytra,
},
]"
:key="entry.type"
class="flex items-center gap-2 rounded-xl bg-button-bg px-3 py-2"
>
<span class="min-w-0 flex-1 text-sm font-medium text-contrast">
{{ formatMessage(entry.label) }}
</span>
<span class="shrink-0 text-xs text-secondary">
{{ entry.value ? '✓' : '—' }}
</span>
<label
class="flex shrink-0 cursor-pointer items-center gap-1 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast"
>
<UploadIcon class="size-3" />
{{ formatMessage(messages.upload) }}
<input
type="file"
accept="image/png"
class="hidden"
:disabled="uploading !== null"
@change="(e) => onUpload(entry.type, e)"
/>
</label>
<button
v-if="entry.value"
type="button"
class="flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-red"
:disabled="uploading !== null"
@click="removeTexture(entry.type)"
>
<TrashIcon class="size-3" />
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
@@ -0,0 +1,299 @@
<script setup lang="ts">
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref } from 'vue'
import { reloadSkinSiteSession, signInSkinSite } from '@/composables/skin-site-session'
import {
skinSiteRegister,
skinSiteRegisterCondition,
skinSiteRegisterEmailVerify,
} from '@/helpers/skin-site-api'
import SkinSiteCaptchaModal from './SkinSiteCaptchaModal.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.register.title', defaultMessage: 'Create a StarLight account' },
email: { id: 'app.skin-site.register.email', defaultMessage: 'Email' },
code: { id: 'app.skin-site.register.code', defaultMessage: 'Verification code' },
sendCode: { id: 'app.skin-site.register.send-code', defaultMessage: 'Send code' },
sending: { id: 'app.skin-site.register.sending', defaultMessage: 'Sending…' },
resendIn: { id: 'app.skin-site.register.resend-in', defaultMessage: 'Sent ({seconds}s)' },
password: { id: 'app.skin-site.register.password', defaultMessage: 'Password' },
repeatPassword: {
id: 'app.skin-site.register.repeat-password',
defaultMessage: 'Repeat password',
},
passwordHint: {
id: 'app.skin-site.register.password-hint',
defaultMessage: '6–50 characters with upper case, lower case and a digit.',
},
mismatch: { id: 'app.skin-site.register.mismatch', defaultMessage: 'Passwords do not match.' },
qq: { id: 'app.skin-site.register.qq', defaultMessage: 'QQ (optional)' },
inviteCode: { id: 'app.skin-site.register.invite-code', defaultMessage: 'Invite code' },
inviteRequired: {
id: 'app.skin-site.register.invite-required',
defaultMessage: 'This server requires an invite code.',
},
submit: { id: 'app.skin-site.register.submit', defaultMessage: 'Create account' },
success: {
id: 'app.skin-site.register.success',
defaultMessage: 'Account created and signed in.',
},
closed: {
id: 'app.skin-site.register.closed',
defaultMessage: 'Registration is currently closed on this server.',
},
})
const PASSWORD_RE = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{6,50}$/
const COUNTDOWN_SECONDS = 60
const modal = ref<InstanceType<typeof NewModal>>()
const captchaModal = ref<InstanceType<typeof SkinSiteCaptchaModal>>()
const email = ref('')
const code = ref('')
const password = ref('')
const repeatPassword = ref('')
const qq = ref('')
const inviteCode = ref('')
const inviteRequired = ref(false)
const registerAllowed = ref(true)
const sending = ref(false)
const working = ref(false)
const countdown = ref(0)
const loadingCondition = ref(false)
let timer: ReturnType<typeof setInterval> | undefined
const emit = defineEmits<{ (e: 'signed-in' | 'cancel'): void }>()
function stopTimer() {
if (timer) clearInterval(timer)
timer = undefined
}
function startCountdown() {
countdown.value = COUNTDOWN_SECONDS
stopTimer()
timer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) stopTimer()
}, 1000)
}
const canSendCode = computed(
() => email.value.trim().length > 0 && !sending.value && countdown.value <= 0,
)
const passwordValid = computed(() => PASSWORD_RE.test(password.value))
const passwordsMatch = computed(() => password.value === repeatPassword.value)
const qqValid = computed(() => qq.value.trim() === '' || /^\d{5,}$/.test(qq.value.trim()))
const inviteValid = computed(() => !inviteRequired.value || inviteCode.value.trim().length > 0)
const canSubmit = computed(
() =>
registerAllowed.value &&
email.value.trim().length > 0 &&
code.value.trim().length === 6 &&
passwordValid.value &&
passwordsMatch.value &&
qqValid.value &&
inviteValid.value &&
!working.value,
)
function reset() {
stopTimer()
email.value = ''
code.value = ''
password.value = ''
repeatPassword.value = ''
qq.value = ''
inviteCode.value = ''
sending.value = false
working.value = false
countdown.value = 0
}
async function loadCondition() {
loadingCondition.value = true
try {
const result = await skinSiteRegisterCondition()
if (result.success) {
registerAllowed.value = result.payload.registerAllowed
inviteRequired.value = result.payload.inviteCodeRequired
} else {
registerAllowed.value = false
}
} finally {
loadingCondition.value = false
}
}
async function show() {
reset()
modal.value?.show()
await loadCondition()
}
async function sendCode() {
if (!canSendCode.value) return
sending.value = true
try {
const result = await skinSiteRegisterEmailVerify(email.value.trim())
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
addNotification({ type: 'success', title: result.payload })
startCountdown()
} catch (error) {
handleError(error)
} finally {
sending.value = false
}
}
function beginRegister() {
if (!canSubmit.value) return
if (!passwordsMatch.value) {
handleError(new Error(formatMessage(messages.mismatch)))
return
}
captchaModal.value?.show()
}
async function onCaptchaVerified(clientId: string) {
working.value = true
try {
const result = await skinSiteRegister({
email: email.value.trim(),
emailVerifyCode: code.value.trim(),
password: password.value,
qq: qq.value.trim() ? Number(qq.value.trim()) : undefined,
inviteCode: inviteCode.value.trim() || undefined,
clientId,
})
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
// Registration returns a JWT; persist it through the normal sign-in path.
await signInSkinSite({ username: result.payload.username, password: password.value, clientId })
await reloadSkinSiteSession()
addNotification({ type: 'success', title: formatMessage(messages.success) })
modal.value?.hide()
emit('signed-in')
} catch (error) {
handleError(error)
} finally {
working.value = false
}
}
function cancel() {
modal.value?.hide()
emit('cancel')
}
onUnmounted(stopTimer)
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" @hide="reset">
<div v-if="!loadingCondition && !registerAllowed" class="text-secondary">
{{ formatMessage(messages.closed) }}
</div>
<div v-else class="flex flex-col gap-4">
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.email) }}</span>
<StyledInput v-model="email" type="email" autocomplete="email" />
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.code) }}</span>
<div class="flex gap-2">
<StyledInput v-model="code" type="text" class="flex-1" maxlength="6" />
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-4 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSendCode"
@click="sendCode"
>
{{
sending
? formatMessage(messages.sending)
: countdown > 0
? formatMessage(messages.resendIn, { seconds: countdown })
: formatMessage(messages.sendCode)
}}
</button>
</div>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.password) }}</span>
<StyledInput v-model="password" type="password" autocomplete="new-password" />
<span class="text-xs text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{
formatMessage(messages.repeatPassword)
}}</span>
<StyledInput v-model="repeatPassword" type="password" autocomplete="new-password" />
<span v-if="repeatPassword && !passwordsMatch" class="text-xs text-red">
{{ formatMessage(messages.mismatch) }}
</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.qq) }}</span>
<StyledInput v-model="qq" type="text" inputmode="numeric" />
</label>
<label v-if="inviteRequired" class="flex flex-col gap-1">
<span class="font-semibold text-contrast">{{ formatMessage(messages.inviteCode) }}</span>
<StyledInput v-model="inviteCode" type="text" />
<span class="text-xs text-secondary">{{ formatMessage(messages.inviteRequired) }}</span>
</label>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full px-4 py-2 text-secondary hover:bg-button-bg"
@click="cancel"
>
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="beginRegister"
>
{{ formatMessage(messages.submit) }}
</button>
</div>
</template>
</NewModal>
<SkinSiteCaptchaModal
ref="captchaModal"
@verified="onCaptchaVerified"
@cancel="working = false"
/>
</template>
@@ -0,0 +1,313 @@
<script setup lang="ts">
import { CheckCircleIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, StyledInput, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteReviewCatalog,
type SkinSiteReviewPublicPaper,
type SkinSiteReviewPublicQuestion,
type SkinSiteReviewReceipt,
skinSiteSubmitReview,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.review.title', defaultMessage: 'Player review' },
back: { id: 'app.skin-site.review.back', defaultMessage: 'Back to home' },
subtitle: {
id: 'app.skin-site.review.subtitle',
defaultMessage: 'Pick an application form and answer its questions.',
},
loading: { id: 'app.skin-site.review.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.review.failed', defaultMessage: 'Could not load the review forms.' },
empty: {
id: 'app.skin-site.review.empty',
defaultMessage: 'No application forms are available right now.',
},
email: { id: 'app.skin-site.review.email', defaultMessage: 'Your email' },
emailHint: {
id: 'app.skin-site.review.email-hint',
defaultMessage: 'Results are sent to this address.',
},
inviteCode: { id: 'app.skin-site.review.invite-code', defaultMessage: 'Invite code' },
submit: { id: 'app.skin-site.review.submit', defaultMessage: 'Submit application' },
submitting: { id: 'app.skin-site.review.submitting', defaultMessage: 'Submitting…' },
backToList: { id: 'app.skin-site.review.back-to-list', defaultMessage: 'Back to forms' },
required: { id: 'app.skin-site.review.required', defaultMessage: 'Required' },
optional: { id: 'app.skin-site.review.optional', defaultMessage: 'Optional' },
submitted: { id: 'app.skin-site.review.submitted', defaultMessage: 'Application submitted.' },
resultPending: { id: 'app.skin-site.review.result.pending', defaultMessage: 'Under review' },
resultPassed: { id: 'app.skin-site.review.result.passed', defaultMessage: 'Approved' },
resultRejected: { id: 'app.skin-site.review.result.rejected', defaultMessage: 'Rejected' },
})
const emit = defineEmits<{ (e: 'back'): void }>()
const papers = ref<SkinSiteReviewPublicPaper[]>([])
const loading = ref(true)
const failed = ref(false)
const activePaper = ref<SkinSiteReviewPublicPaper | null>(null)
const email = ref('')
const inviteCode = ref('')
const answers = ref<Record<string, string | string[]>>({})
const submitting = ref(false)
const receipt = ref<SkinSiteReviewReceipt | null>(null)
const emailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.trim()))
const requiredAnswered = computed(() => {
if (!activePaper.value) return false
return activePaper.value.questions.every((q) => {
if (q.informationQuestion || q.optional) return true
const value = answers.value[q.id]
if (Array.isArray(value)) return value.length > 0
return typeof value === 'string' ? value.trim().length > 0 : false
})
})
const canSubmit = computed(() => emailValid.value && requiredAnswered.value && !submitting.value)
function defaultAnswer(q: SkinSiteReviewPublicQuestion): string | string[] {
if (q.type === 'MULTIPLE' || q.type === 'SINGLE') {
const preset = (q.options ?? []).filter((o) => o.defaultSelected).map((o) => o.id)
return q.type === 'MULTIPLE' ? preset : (preset[0] ?? '')
}
return ''
}
function openPaper(paper: SkinSiteReviewPublicPaper) {
activePaper.value = paper
answers.value = {}
for (const q of paper.questions) answers.value[q.id] = defaultAnswer(q)
email.value = ''
inviteCode.value = ''
receipt.value = null
}
function closePaper() {
activePaper.value = null
answers.value = {}
receipt.value = null
}
function toggleMultiple(q: SkinSiteReviewPublicQuestion, optionId: string, checked: boolean) {
const current = Array.isArray(answers.value[q.id]) ? (answers.value[q.id] as string[]) : []
answers.value[q.id] = checked
? [...current.filter((id) => id !== optionId), optionId]
: current.filter((id) => id !== optionId)
}
async function loadCatalog() {
loading.value = true
failed.value = false
try {
const result = await skinSiteReviewCatalog()
if (!result.success) throw new Error(result.errorMessage)
papers.value = result.payload?.papers ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function submitApplication() {
if (!activePaper.value || !canSubmit.value) return
submitting.value = true
try {
const payloadAnswers: Record<string, unknown> = {}
for (const q of activePaper.value.questions) {
if (q.informationQuestion) continue
payloadAnswers[q.id] = answers.value[q.id] ?? ''
}
const result = await skinSiteSubmitReview({
paperId: activePaper.value.id,
email: email.value.trim(),
answers: payloadAnswers,
...(inviteCode.value.trim() ? { inviteCode: inviteCode.value.trim() } : {}),
})
if (!result.success) {
handleError(new Error(result.errorMessage))
return
}
receipt.value = result.payload
addNotification({ type: 'success', title: formatMessage(messages.submitted) })
} catch (error) {
handleError(error)
} finally {
submitting.value = false
}
}
function resultLabel(result?: string): string {
switch (result) {
case 'PASSED':
return formatMessage(messages.resultPassed)
case 'REJECTED':
return formatMessage(messages.resultRejected)
default:
return formatMessage(messages.resultPending)
}
}
onMounted(loadCatalog)
</script>
<template>
<div class="flex flex-col gap-4">
<button
type="button"
class="flex w-fit items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="activePaper ? closePaper() : emit('back')"
>
← {{ activePaper ? formatMessage(messages.backToList) : formatMessage(messages.back) }}
</button>
<template v-if="!activePaper">
<header class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</header>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!papers.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<button
v-for="paper in papers"
:key="paper.id"
type="button"
class="flex flex-col items-start gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 text-left transition-colors hover:bg-button-bg"
@click="openPaper(paper)"
>
<span class="text-sm font-semibold text-contrast">{{ paper.title }}</span>
<span v-if="paper.description" class="text-xs text-secondary">{{ paper.description }}</span>
</button>
</template>
<div
v-else-if="receipt"
class="flex flex-col items-center gap-3 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-6 text-center"
>
<CheckCircleIcon class="size-10 text-brand" />
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.submitted) }}</h2>
<p class="m-0 text-sm text-secondary">{{ resultLabel(receipt.result) }}</p>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast"
@click="closePaper"
>
{{ formatMessage(messages.backToList) }}
</button>
</div>
<template v-else>
<h1 class="m-0 text-xl font-bold text-contrast">{{ activePaper.title }}</h1>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">{{ formatMessage(messages.email) }}</span>
<StyledInput v-model="email" type="email" autocomplete="email" />
<span class="text-xs text-secondary">{{ formatMessage(messages.emailHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.inviteCode)
}}</span>
<StyledInput v-model="inviteCode" type="text" />
</label>
<div
v-for="question in activePaper.questions"
:key="question.id"
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-contrast">{{ question.title }}</span>
<span
v-if="!question.optional && !question.informationQuestion"
class="rounded-full bg-brand-highlight px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.required) }}
</span>
</div>
<span v-if="question.annotation" class="text-xs text-secondary">{{
question.annotation
}}</span>
<span v-if="question.informationQuestion" class="text-xs text-secondary">
{{ question.answer }}
</span>
<template v-else-if="question.type === 'SINGLE' || question.type === 'TRUE_FALSE'">
<label
v-for="option in question.options ?? []"
:key="option.id"
class="flex items-center gap-2 text-sm"
>
<input
type="radio"
:name="question.id"
:value="option.id"
:checked="answers[question.id] === option.id"
@change="answers[question.id] = option.id"
/>
<span class="text-contrast">{{ option.description }}</span>
</label>
</template>
<template v-else-if="question.type === 'MULTIPLE'">
<label
v-for="option in question.options ?? []"
:key="option.id"
class="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
:checked="
Array.isArray(answers[question.id]) &&
(answers[question.id] as string[]).includes(option.id)
"
@change="
toggleMultiple(question, option.id, ($event.target as HTMLInputElement).checked)
"
/>
<span class="text-contrast">{{ option.description }}</span>
</label>
</template>
<StyledInput
v-else-if="question.type === 'FILL'"
v-model="answers[question.id] as string"
type="text"
:maxlength="question.maxLength ?? 50"
/>
<textarea
v-else
v-model="answers[question.id] as string"
rows="4"
class="w-full rounded-lg border border-solid border-surface-5 bg-button-bg p-2 text-sm text-contrast"
:maxlength="question.maxLength ?? 300"
/>
</div>
<button
type="button"
class="w-fit self-end rounded-full bg-brand px-5 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSubmit"
@click="submitApplication"
>
{{ formatMessage(submitting ? messages.submitting : messages.submit) }}
</button>
</template>
</div>
</template>
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteWikiPageDetail,
skinSiteWikiPage,
} from '@/helpers/skin-site-api'
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const props = withDefaults(defineProps<{ initialTitle?: string }>(), {
initialTitle: '',
})
const messages = defineMessages({
title: { id: 'app.skin-site.wiki.title', defaultMessage: 'StarLight Wiki' },
searchPlaceholder: {
id: 'app.skin-site.wiki.search-placeholder',
defaultMessage: 'Enter a page title',
},
open: { id: 'app.skin-site.wiki.open', defaultMessage: 'Open' },
loading: { id: 'app.skin-site.wiki.loading', defaultMessage: 'Loading…' },
notFound: {
id: 'app.skin-site.wiki.not-found',
defaultMessage: 'Page not found.',
},
empty: {
id: 'app.skin-site.wiki.empty',
defaultMessage: 'Enter a page title above to start reading.',
},
updatedAt: {
id: 'app.skin-site.wiki.updated-at',
defaultMessage: 'Last edited {date} by {author}',
},
revision: { id: 'app.skin-site.wiki.revision', defaultMessage: 'Revision #{id}' },
categories: { id: 'app.skin-site.wiki.categories', defaultMessage: 'Categories' },
back: { id: 'app.skin-site.wiki.back', defaultMessage: 'Back' },
})
const titleInput = ref(props.initialTitle)
const page = ref<SkinSiteWikiPageDetail | null>(null)
const loading = ref(false)
const notFound = ref(false)
const history = ref<string[]>([])
const currentTitle = computed(() => page.value?.title ?? '')
async function openPage(title: string) {
const trimmed = title.trim()
if (!trimmed) return
titleInput.value = trimmed
loading.value = true
notFound.value = false
try {
const res = await skinSiteWikiPage(trimmed)
if (!res.success) {
notFound.value = true
page.value = null
return
}
page.value = res.payload
if (currentTitle.value && history.value[history.value.length - 1] !== currentTitle.value) {
history.value.push(currentTitle.value)
}
} catch (error) {
handleError(error)
notFound.value = true
page.value = null
} finally {
loading.value = false
}
}
function goBack() {
const prev = history.value.pop()
if (prev) void openPage(prev)
}
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
function onNavigate(title: string) {
if (title) void openPage(title)
}
onMounted(() => {
if (props.initialTitle) void openPage(props.initialTitle)
})
</script>
<template>
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<button
v-if="history.length"
type="button"
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="goBack"
>
← {{ formatMessage(messages.back) }}
</button>
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<input
v-model="titleInput"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="openPage(titleInput)"
/>
<button
type="button"
class="rounded-lg bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="openPage(titleInput)"
>
{{ formatMessage(messages.open) }}
</button>
</div>
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="notFound"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.notFound) }}
</p>
<p
v-else-if="!page"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<template v-else>
<div class="flex flex-wrap items-baseline justify-between gap-2 border-b border-solid border-surface-5 pb-2">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ page.title }}</h1>
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
</span>
</div>
<p class="m-0 text-[11px] text-secondary">
{{
formatMessage(messages.updatedAt, {
date: formatDate(page.updatedAt),
author: page.authorName,
})
}}
</p>
<div
v-if="page.redirectTarget"
class="rounded-lg border border-solid border-yellow/40 bg-yellow/10 p-3 text-xs text-yellow"
>
重定向到
<button
type="button"
class="font-semibold underline underline-offset-2"
@click="openPage(page.redirectTarget ?? '')"
>
{{ page.redirectTarget }}
</button>
</div>
<article class="min-w-0 text-sm leading-7 text-contrast">
<WikiNodeRenderer :nodes="page.nodes" @navigate="onNavigate" />
</article>
<div v-if="page.categories.length" class="flex flex-wrap items-center gap-2 pt-2">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.categories) }}:</span>
<span
v-for="category in page.categories"
:key="category"
class="rounded bg-bg-raised px-2 py-0.5 text-[11px] text-secondary"
>
{{ category }}
</span>
</div>
</template>
</div>
</template>
@@ -0,0 +1,273 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
skinSiteAccessAudit,
type SkinSiteAccessAuditEntry,
skinSiteUpdateAccessAuditSettings,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.audit.title', defaultMessage: 'Access audit' },
loading: { id: 'app.skin-site.admin.audit.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.audit.failed', defaultMessage: 'Could not load audit log.' },
empty: { id: 'app.skin-site.admin.audit.empty', defaultMessage: 'No audit entries.' },
subject: { id: 'app.skin-site.admin.audit.subject', defaultMessage: 'Subject' },
action: { id: 'app.skin-site.admin.audit.action', defaultMessage: 'Action' },
sourceIp: { id: 'app.skin-site.admin.audit.source-ip', defaultMessage: 'Source IP' },
reportedIp: {
id: 'app.skin-site.admin.audit.reported-ip',
defaultMessage: 'Reported player IP',
},
count: { id: 'app.skin-site.admin.audit.count', defaultMessage: 'Count' },
firstAt: { id: 'app.skin-site.admin.audit.first-at', defaultMessage: 'First seen' },
lastAt: { id: 'app.skin-site.admin.audit.last-at', defaultMessage: 'Last seen' },
proxy: { id: 'app.skin-site.admin.audit.proxy', defaultMessage: 'Proxy' },
internal: { id: 'app.skin-site.admin.audit.internal', defaultMessage: 'Internal' },
searchSubject: {
id: 'app.skin-site.admin.audit.search-subject',
defaultMessage: 'Subject',
},
searchIp: { id: 'app.skin-site.admin.audit.search-ip', defaultMessage: 'IP' },
searchAction: { id: 'app.skin-site.admin.audit.search-action', defaultMessage: 'Action' },
search: { id: 'app.skin-site.admin.audit.search', defaultMessage: 'Search' },
clear: { id: 'app.skin-site.admin.audit.clear', defaultMessage: 'Clear' },
loadMore: { id: 'app.skin-site.admin.audit.load-more', defaultMessage: 'Load more' },
retention: {
id: 'app.skin-site.admin.audit.retention',
defaultMessage: 'Retention (days)',
},
recordLimit: { id: 'app.skin-site.admin.audit.record-limit', defaultMessage: 'Record limit' },
saveRetention: {
id: 'app.skin-site.admin.audit.save-retention',
defaultMessage: 'Save',
},
retentionSaved: {
id: 'app.skin-site.admin.audit.retention-saved',
defaultMessage: 'Retention updated.',
},
})
const items = ref<SkinSiteAccessAuditEntry[]>([])
const nextCursor = ref<number | null>(null)
const retentionDays = ref(0)
const recordLimit = ref(0)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const subjectFilter = ref('')
const ipFilter = ref('')
const actionFilter = ref('')
const retentionDraft = ref(0)
const savingRetention = ref(false)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
nextCursor.value = null
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteAccessAudit({
before: reset ? undefined : (nextCursor.value ?? undefined),
subject: subjectFilter.value.trim() || undefined,
ip: ipFilter.value.trim() || undefined,
action: actionFilter.value.trim() || undefined,
})
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
nextCursor.value = page.nextCursor
retentionDays.value = page.retentionDays
recordLimit.value = page.recordLimit
retentionDraft.value = page.retentionDays
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
function clearFilters() {
subjectFilter.value = ''
ipFilter.value = ''
actionFilter.value = ''
void load(true)
}
async function saveRetention() {
if (savingRetention.value) return
savingRetention.value = true
try {
const result = await skinSiteUpdateAccessAuditSettings(Number(retentionDraft.value) || 0)
if (!result.success) throw new Error(result.errorMessage)
retentionDays.value = result.payload.retentionDays
retentionDraft.value = result.payload.retentionDays
addNotification({ type: 'success', title: formatMessage(messages.retentionSaved) })
} catch (error) {
handleError(error)
} finally {
savingRetention.value = false
}
}
onMounted(() => void load(true))
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.recordLimit) }}: {{ recordLimit }}
</span>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchSubject) }}</span>
<input
v-model="subjectFilter"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchIp) }}</span>
<input
v-model="ipFilter"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchAction) }}</span>
<input
v-model="actionFilter"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
</label>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="load(true)"
>
{{ formatMessage(messages.search) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="clearFilters"
>
{{ formatMessage(messages.clear) }}
</button>
</div>
<div class="flex flex-wrap items-end gap-2 rounded-xl bg-bg-raised p-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.retention) }}</span>
<input
v-model.number="retentionDraft"
type="number"
min="1"
class="w-24 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="savingRetention"
@click="saveRetention"
>
{{ formatMessage(messages.saveRetention) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.retention) }}: {{ retentionDays }}
</span>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="entry in items"
:key="entry.id"
class="flex flex-col gap-1.5 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="shrink-0 text-xs text-secondary">#{{ entry.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ entry.subject }}
</span>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ entry.action }}
</span>
<span class="shrink-0 text-xs text-secondary">×{{ entry.count }}</span>
<span
v-if="entry.proxy"
class="shrink-0 rounded-full bg-orange px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.proxy) }}
</span>
<span
v-if="entry.internalServer"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ formatMessage(messages.internal) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<span>{{ formatMessage(messages.sourceIp) }}: {{ entry.sourceIp }}</span>
<span v-if="entry.reportedPlayerIp">
{{ formatMessage(messages.reportedIp) }}: {{ entry.reportedPlayerIp }}
</span>
<span>{{ formatMessage(messages.firstAt) }}: {{ formatDateTime(entry.firstAt) }}</span>
<span>{{ formatMessage(messages.lastAt) }}: {{ formatDateTime(entry.lastAt) }}</span>
</div>
</div>
<button
v-if="nextCursor !== null"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="loadingMore"
@click="load(false)"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,366 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteBroadcast,
type SkinSiteBroadcastInput,
skinSiteBroadcasts,
skinSiteCreateBroadcast,
skinSiteDeleteBroadcast,
skinSiteUpdateBroadcast,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.broadcast.title', defaultMessage: 'Announcements' },
loading: { id: 'app.skin-site.admin.broadcast.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.broadcast.failed', defaultMessage: 'Could not load.' },
empty: { id: 'app.skin-site.admin.broadcast.empty', defaultMessage: 'No announcements yet.' },
create: { id: 'app.skin-site.admin.broadcast.create', defaultMessage: 'New announcement' },
save: { id: 'app.skin-site.admin.broadcast.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.broadcast.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.broadcast.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.broadcast.remove', defaultMessage: 'Delete' },
titleField: { id: 'app.skin-site.admin.broadcast.title-field', defaultMessage: 'Title' },
subtitleField: { id: 'app.skin-site.admin.broadcast.subtitle-field', defaultMessage: 'Subtitle' },
contentField: { id: 'app.skin-site.admin.broadcast.content-field', defaultMessage: 'Content' },
expireAt: { id: 'app.skin-site.admin.broadcast.expire-at', defaultMessage: 'Expires at' },
star: { id: 'app.skin-site.admin.broadcast.star', defaultMessage: 'Highlight' },
level: { id: 'app.skin-site.admin.broadcast.level', defaultMessage: 'Importance' },
image: { id: 'app.skin-site.admin.broadcast.image', defaultMessage: 'Image' },
removeImage: { id: 'app.skin-site.admin.broadcast.remove-image', defaultMessage: 'Remove image' },
created: { id: 'app.skin-site.admin.broadcast.created', defaultMessage: 'Announcement created.' },
updated: { id: 'app.skin-site.admin.broadcast.updated', defaultMessage: 'Announcement updated.' },
deleted: { id: 'app.skin-site.admin.broadcast.deleted', defaultMessage: 'Announcement deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.broadcast.confirm-delete',
defaultMessage: 'Delete “{title}”? This cannot be undone.',
},
uploader: { id: 'app.skin-site.admin.broadcast.uploader', defaultMessage: 'by {name}' },
})
const items = ref<SkinSiteBroadcast[]>([])
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const form = ref({
title: '',
subtitle: '',
content: '',
expireAt: '',
star: false,
importantLevel: 0,
})
const imageFile = ref<File | null>(null)
const removeImage = ref(false)
const sorted = computed(() => [...items.value].sort((a, b) => b.id - a.id))
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
function toDateInput(ts: number): string {
try {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return (
d.getFullYear() +
'-' +
pad(d.getMonth() + 1) +
'-' +
pad(d.getDate()) +
'T' +
pad(d.getHours()) +
':' +
pad(d.getMinutes())
)
} catch {
return ''
}
}
function resetForm() {
form.value = {
title: '',
subtitle: '',
content: '',
expireAt: '',
star: false,
importantLevel: 0,
}
imageFile.value = null
removeImage.value = false
editingId.value = null
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(item: SkinSiteBroadcast) {
form.value = {
title: item.title,
subtitle: item.subtitle,
content: item.content,
expireAt: toDateInput(item.expireAt),
star: item.star,
importantLevel: item.importantLevel,
}
imageFile.value = null
removeImage.value = false
editingId.value = item.id
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
function onImageChange(event: Event) {
const input = event.target as HTMLInputElement
imageFile.value = input.files?.[0] ?? null
}
function buildInput(): SkinSiteBroadcastInput {
const ts = form.value.expireAt ? new Date(form.value.expireAt).getTime() : 0
return {
title: form.value.title.trim(),
subtitle: form.value.subtitle.trim(),
content: form.value.content,
expireAt: Number.isFinite(ts) ? ts : 0,
star: form.value.star,
importantLevel: Number(form.value.importantLevel) || 0,
imageFile: imageFile.value,
removeImage: removeImage.value,
}
}
const canSave = computed(
() => form.value.title.trim().length > 0 && form.value.content.length > 0 && !saving.value,
)
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteBroadcasts()
if (!result.success) throw new Error(result.errorMessage)
items.value = result.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
const input = buildInput()
const result =
editingId.value === null
? await skinSiteCreateBroadcast(input)
: await skinSiteUpdateBroadcast(editingId.value, input)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingId.value === null ? messages.created : messages.updated),
})
closeEditor()
await load()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(item: SkinSiteBroadcast) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { title: item.title }))) return
deletingId.value = item.id
try {
const result = await skinSiteDeleteBroadcast(item.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load()
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-3">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openCreate"
>
{{ formatMessage(messages.create) }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!sorted.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in sorted"
:key="item.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<span class="shrink-0 text-xs text-secondary">#{{ item.id }}</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">{{ item.title }}</span>
<span v-if="item.subtitle" class="truncate text-xs text-secondary">
{{ item.subtitle }}
</span>
</div>
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
<span v-if="item.uploader" class="shrink-0 text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
</span>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEdit(item)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingId !== null"
@click="remove(item)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
<div
v-if="editorOpen"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.titleField) }}
</span>
<input
v-model="form.title"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.subtitleField) }}
</span>
<input
v-model="form.subtitle"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.contentField) }}
</span>
<textarea
v-model="form.content"
rows="5"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<div class="flex flex-wrap items-end gap-3">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.expireAt) }}
</span>
<input
v-model="form.expireAt"
type="datetime-local"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.level) }}
</span>
<input
v-model.number="form.importantLevel"
type="number"
min="0"
max="9"
class="w-20 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex items-center gap-2 pb-2">
<input v-model="form.star" type="checkbox" />
<span class="text-sm text-contrast">{{ formatMessage(messages.star) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.image) }}
</span>
<input type="file" accept="image/*" @change="onImageChange" />
</label>
<label v-if="editingId !== null" class="flex items-center gap-2 pb-2">
<input v-model="removeImage" type="checkbox" />
<span class="text-sm text-contrast">{{ formatMessage(messages.removeImage) }}</span>
</label>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSave"
@click="save"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,366 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAdminDonations,
skinSiteCreateDonation,
skinSiteDeleteDonation,
type SkinSiteDonationAdminEntry,
type SkinSiteDonationSummary,
skinSiteUpdateDonation,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.donations.title', defaultMessage: 'Donations' },
loading: { id: 'app.skin-site.admin.donations.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.donations.failed',
defaultMessage: 'Could not load donations.',
},
empty: { id: 'app.skin-site.admin.donations.empty', defaultMessage: 'No donations yet.' },
totalAmount: { id: 'app.skin-site.admin.donations.total-amount', defaultMessage: 'Total' },
donorCount: { id: 'app.skin-site.admin.donations.donor-count', defaultMessage: 'Donors' },
donor: { id: 'app.skin-site.admin.donations.donor', defaultMessage: 'Donor' },
amount: { id: 'app.skin-site.admin.donations.amount', defaultMessage: 'Amount' },
updatedAt: { id: 'app.skin-site.admin.donations.updated-at', defaultMessage: 'Updated' },
external: { id: 'app.skin-site.admin.donations.external', defaultMessage: 'External' },
linked: { id: 'app.skin-site.admin.donations.linked', defaultMessage: 'Linked' },
create: { id: 'app.skin-site.admin.donations.create', defaultMessage: 'Add donation' },
save: { id: 'app.skin-site.admin.donations.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.donations.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.donations.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.donations.remove', defaultMessage: 'Delete' },
amountField: { id: 'app.skin-site.admin.donations.amount-field', defaultMessage: 'Amount' },
displayName: {
id: 'app.skin-site.admin.donations.display-name',
defaultMessage: 'Display name',
},
userUuid: { id: 'app.skin-site.admin.donations.user-uuid', defaultMessage: 'User UUID' },
qq: { id: 'app.skin-site.admin.donations.qq', defaultMessage: 'QQ' },
appendMode: {
id: 'app.skin-site.admin.donations.append-mode',
defaultMessage: 'Append to existing amount',
},
created: { id: 'app.skin-site.admin.donations.created', defaultMessage: 'Donation created.' },
updated: { id: 'app.skin-site.admin.donations.updated', defaultMessage: 'Donation updated.' },
deleted: { id: 'app.skin-site.admin.donations.deleted', defaultMessage: 'Donation deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.donations.confirm-delete',
defaultMessage: 'Delete the donation from “{name}”? This cannot be undone.',
},
loadMore: { id: 'app.skin-site.admin.donations.load-more', defaultMessage: 'Load more' },
})
const items = ref<SkinSiteDonationAdminEntry[]>([])
const summary = ref<SkinSiteDonationSummary | null>(null)
const hasMore = ref(false)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const offset = ref(0)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const editingVersion = ref(0)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const form = ref({
amount: '',
displayName: '',
userUuid: '',
qq: '',
appendMode: false,
})
const canSave = computed(() => form.value.amount.trim().length > 0 && !saving.value)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
function resetForm() {
form.value = { amount: '', displayName: '', userUuid: '', qq: '', appendMode: false }
editingId.value = null
editingVersion.value = 0
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(entry: SkinSiteDonationAdminEntry) {
form.value = {
amount: entry.donor.amount,
displayName: entry.donor.username,
userUuid: entry.userUuid ?? '',
qq: entry.qq ?? '',
appendMode: false,
}
editingId.value = entry.donor.id
editingVersion.value = entry.version
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
offset.value = 0
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteAdminDonations(offset.value)
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
summary.value = page.summary
hasMore.value = page.hasMore
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
async function loadMore() {
if (!hasMore.value) return
offset.value += 20
await load(false)
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
if (editingId.value === null) {
const payload: Parameters<typeof skinSiteCreateDonation>[0] = {
amount: form.value.amount.trim(),
}
if (form.value.userUuid.trim()) payload.userUuid = form.value.userUuid.trim()
if (form.value.qq.trim()) payload.qq = form.value.qq.trim()
if (form.value.displayName.trim()) payload.displayName = form.value.displayName.trim()
const result = await skinSiteCreateDonation(payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.created) })
} else {
const result = await skinSiteUpdateDonation(editingId.value, {
amount: form.value.amount.trim(),
version: editingVersion.value,
displayName: form.value.displayName.trim() || undefined,
mode: form.value.appendMode ? 'APPEND' : 'REPLACE',
})
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
}
closeEditor()
await load(true)
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(entry: SkinSiteDonationAdminEntry) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: entry.donor.username }))) return
deletingId.value = entry.donor.id
try {
const result = await skinSiteDeleteDonation(entry.donor.id, entry.version)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load(true)
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
onMounted(() => void load(true))
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openCreate"
>
{{ formatMessage(messages.create) }}
</button>
</div>
<div v-if="summary" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.totalAmount) }}</span>
<span class="text-xl font-bold text-contrast">{{ summary.totalAmount }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.donorCount) }}</span>
<span class="text-xl font-bold text-contrast">{{ summary.donorCount }}</span>
</div>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="entry in items"
:key="entry.donor.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<span class="shrink-0 text-xs text-secondary">#{{ entry.donor.rank }}</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ entry.donor.username }}
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.updatedAt) }}:
{{ formatDateTime(entry.updatedAt) }}
</span>
</div>
<span class="shrink-0 text-sm font-bold text-contrast">{{ entry.donor.amount }}</span>
<span
v-if="entry.external"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ formatMessage(messages.external) }}
</span>
<span
v-else
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.linked) }}
</span>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEdit(entry)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingId !== null"
@click="remove(entry)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
<button
v-if="hasMore"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="loadingMore"
@click="loadMore"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
<div
v-if="editorOpen"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.amountField) }}
</span>
<input
v-model="form.amount"
type="text"
inputmode="decimal"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.displayName) }}
</span>
<input
v-model="form.displayName"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<template v-if="editingId === null">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.userUuid) }}
</span>
<input
v-model="form.userUuid"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.qq) }}
</span>
<input
v-model="form.qq"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
</template>
<label v-else class="flex items-center gap-2">
<input v-model="form.appendMode" type="checkbox" />
<span class="text-sm text-contrast">{{ formatMessage(messages.appendMode) }}</span>
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSave"
@click="save"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,576 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAddDownloadCategory,
skinSiteAddDownloadItem,
skinSiteBindDownloadCategory,
skinSiteDeleteDownloadCategory,
skinSiteDeleteDownloadItem,
skinSiteDownloadCategories,
type SkinSiteDownloadCategory,
type SkinSiteDownloadItem,
skinSiteDownloadItemCount,
skinSiteDownloadItems,
type SkinSiteDownloadRoute,
type SkinSiteDownloadStatus,
skinSiteUnbindDownloadCategory,
skinSiteUpdateDownloadItem,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.downloads.title', defaultMessage: 'Downloads' },
loading: { id: 'app.skin-site.admin.downloads.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.downloads.failed',
defaultMessage: 'Could not load downloads.',
},
empty: { id: 'app.skin-site.admin.downloads.empty', defaultMessage: 'No download items.' },
categories: { id: 'app.skin-site.admin.downloads.categories', defaultMessage: 'Categories' },
all: { id: 'app.skin-site.admin.downloads.all', defaultMessage: 'All items' },
addCategory: { id: 'app.skin-site.admin.downloads.add-category', defaultMessage: 'Add' },
categoryName: {
id: 'app.skin-site.admin.downloads.category-name',
defaultMessage: 'Category name',
},
removeCategory: {
id: 'app.skin-site.admin.downloads.remove-category',
defaultMessage: 'Remove',
},
total: { id: 'app.skin-site.admin.downloads.total', defaultMessage: '{count} items' },
addItem: { id: 'app.skin-site.admin.downloads.add-item', defaultMessage: 'New item' },
save: { id: 'app.skin-site.admin.downloads.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.downloads.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.downloads.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.downloads.remove', defaultMessage: 'Delete' },
filename: { id: 'app.skin-site.admin.downloads.filename', defaultMessage: 'File name' },
description: {
id: 'app.skin-site.admin.downloads.description',
defaultMessage: 'Description',
},
routes: { id: 'app.skin-site.admin.downloads.routes', defaultMessage: 'Download routes' },
routeName: { id: 'app.skin-site.admin.downloads.route-name', defaultMessage: 'Name' },
routeUrl: { id: 'app.skin-site.admin.downloads.route-url', defaultMessage: 'URL' },
addRoute: { id: 'app.skin-site.admin.downloads.add-route', defaultMessage: 'Add route' },
removeRoute: { id: 'app.skin-site.admin.downloads.remove-route', defaultMessage: 'Remove' },
statuses: { id: 'app.skin-site.admin.downloads.statuses', defaultMessage: 'Statuses' },
bindCategory: {
id: 'app.skin-site.admin.downloads.bind-category',
defaultMessage: 'Categories',
},
uploader: { id: 'app.skin-site.admin.downloads.uploader', defaultMessage: 'by {name}' },
created: { id: 'app.skin-site.admin.downloads.created', defaultMessage: 'Item created.' },
updated: { id: 'app.skin-site.admin.downloads.updated', defaultMessage: 'Item updated.' },
deleted: { id: 'app.skin-site.admin.downloads.deleted', defaultMessage: 'Item deleted.' },
categoryAdded: {
id: 'app.skin-site.admin.downloads.category-added',
defaultMessage: 'Category added.',
},
categoryDeleted: {
id: 'app.skin-site.admin.downloads.category-deleted',
defaultMessage: 'Category deleted.',
},
confirmDeleteItem: {
id: 'app.skin-site.admin.downloads.confirm-delete-item',
defaultMessage: 'Delete “{name}”? This cannot be undone.',
},
confirmDeleteCategory: {
id: 'app.skin-site.admin.downloads.confirm-delete-category',
defaultMessage: 'Delete category “{name}”?',
},
})
const STATUS_OPTIONS: SkinSiteDownloadStatus[] = ['OK', 'NEW', 'LEGACY', 'OUTDATED', 'REMOVAL']
const items = ref<SkinSiteDownloadItem[]>([])
const categories = ref<SkinSiteDownloadCategory[]>([])
const total = ref(0)
const loading = ref(true)
const failed = ref(false)
const activeCategory = ref<string>('')
const newCategoryName = ref('')
const categoryWorking = ref(false)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const form = ref({
filename: '',
description: '',
status: [] as SkinSiteDownloadStatus[],
routes: [{ name: '', url: '' }] as Array<{ name: string; url: string }>,
selectedCategories: [] as number[],
})
const canSave = computed(
() =>
form.value.filename.trim().length > 0 &&
form.value.routes.some((r) => r.url.trim().length > 0) &&
!saving.value,
)
function resetForm() {
form.value = {
filename: '',
description: '',
status: [],
routes: [{ name: '', url: '' }],
selectedCategories: [],
}
editingId.value = null
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(item: SkinSiteDownloadItem) {
form.value = {
filename: item.filename,
description: item.description,
status: [...item.status],
routes: item.routes.length
? item.routes.map((r) => ({ name: r.name ?? '', url: r.url }))
: [{ name: '', url: '' }],
selectedCategories: categories.value
.filter((c) => item.category.includes(c.name))
.map((c) => c.id),
}
editingId.value = item.id
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
function addRouteRow() {
form.value.routes.push({ name: '', url: '' })
}
function removeRouteRow(index: number) {
if (form.value.routes.length <= 1) return
form.value.routes.splice(index, 1)
}
function toggleStatus(status: SkinSiteDownloadStatus) {
const i = form.value.status.indexOf(status)
if (i >= 0) form.value.status.splice(i, 1)
else form.value.status.push(status)
}
async function loadCategories() {
try {
const result = await skinSiteDownloadCategories()
if (result.success) categories.value = result.payload ?? []
} catch (error) {
handleError(error)
}
}
async function loadItems() {
loading.value = true
failed.value = false
try {
const listResult = await skinSiteDownloadItems({
limit: 200,
offset: 0,
category: activeCategory.value || undefined,
})
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
const countResult = await skinSiteDownloadItemCount(activeCategory.value || undefined)
if (countResult.success && typeof countResult.payload === 'number') {
total.value = countResult.payload
}
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function selectCategory(name: string) {
activeCategory.value = name
await loadItems()
}
async function addCategory() {
const name = newCategoryName.value.trim()
if (!name || categoryWorking.value) return
categoryWorking.value = true
try {
const result = await skinSiteAddDownloadCategory(name)
if (!result.success) throw new Error(result.errorMessage)
newCategoryName.value = ''
addNotification({ type: 'success', title: formatMessage(messages.categoryAdded) })
await loadCategories()
} catch (error) {
handleError(error)
} finally {
categoryWorking.value = false
}
}
async function removeCategory(category: SkinSiteDownloadCategory) {
if (categoryWorking.value) return
if (!window.confirm(formatMessage(messages.confirmDeleteCategory, { name: category.name })))
return
categoryWorking.value = true
try {
const result = await skinSiteDeleteDownloadCategory(category.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.categoryDeleted) })
if (activeCategory.value === category.name) activeCategory.value = ''
await loadCategories()
await loadItems()
} catch (error) {
handleError(error)
} finally {
categoryWorking.value = false
}
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
const routes: SkinSiteDownloadRoute[] = form.value.routes
.filter((r) => r.url.trim())
.map((r) => ({ name: r.name.trim() || null, url: r.url.trim() }))
const payload = {
routes,
filename: form.value.filename.trim(),
description: form.value.description,
status: form.value.status,
}
const result =
editingId.value === null
? await skinSiteAddDownloadItem(payload)
: await skinSiteUpdateDownloadItem(editingId.value, payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingId.value === null ? messages.created : messages.updated),
})
closeEditor()
await loadItems()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(item: SkinSiteDownloadItem) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDeleteItem, { name: item.filename }))) return
deletingId.value = item.id
try {
const result = await skinSiteDeleteDownloadItem(item.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await loadItems()
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
async function toggleItemCategory(item: SkinSiteDownloadItem, category: SkinSiteDownloadCategory) {
try {
const bound = item.category.includes(category.name)
const result = bound
? await skinSiteUnbindDownloadCategory(item.id, category.id)
: await skinSiteBindDownloadCategory(item.id, category.id)
if (!result.success) throw new Error(result.errorMessage)
await loadItems()
} catch (error) {
handleError(error)
}
}
onMounted(async () => {
await loadCategories()
await loadItems()
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</span>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openCreate"
>
{{ formatMessage(messages.addItem) }}
</button>
</div>
<div class="flex min-h-0 gap-4">
<aside class="flex w-56 shrink-0 flex-col gap-2">
<span class="text-xs font-bold tracking-wide text-secondary uppercase">
{{ formatMessage(messages.categories) }}
</span>
<button
type="button"
class="rounded-lg px-2 py-1.5 text-left text-xs font-semibold"
:class="
activeCategory === ''
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="selectCategory('')"
>
{{ formatMessage(messages.all) }}
</button>
<div v-for="category in categories" :key="category.id" class="flex items-center gap-1">
<button
type="button"
class="min-w-0 flex-1 truncate rounded-lg px-2 py-1.5 text-left text-xs font-semibold"
:class="
activeCategory === category.name
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="selectCategory(category.name)"
>
{{ category.name }}
</button>
<button
type="button"
class="shrink-0 rounded px-1 text-[10px] text-secondary hover:text-red"
:disabled="categoryWorking"
@click="removeCategory(category)"
>
×
</button>
</div>
<div class="flex items-center gap-1 pt-1">
<input
v-model="newCategoryName"
type="text"
:placeholder="formatMessage(messages.categoryName)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="addCategory"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="categoryWorking || !newCategoryName.trim()"
@click="addCategory"
>
{{ formatMessage(messages.addCategory) }}
</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in items"
:key="item.id"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="shrink-0 text-xs text-secondary">#{{ item.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ item.filename }}
</span>
<span
v-for="s in item.status"
:key="s"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ s }}
</span>
<span class="shrink-0 text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
</span>
</div>
<div v-if="item.description" class="text-xs text-secondary">
{{ item.description }}
</div>
<div class="flex flex-wrap gap-1">
<span
v-for="route in item.routes"
:key="route.url"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ route.name ? route.name + ': ' : '' }}{{ route.url }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-secondary">
{{ formatMessage(messages.bindCategory) }}:
</span>
<button
v-for="category in categories"
:key="category.id"
type="button"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="
item.category.includes(category.name)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="toggleItemCategory(item, category)"
>
{{ category.name }}
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEdit(item)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingId !== null"
@click="remove(item)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</div>
</div>
</div>
<div
v-if="editorOpen"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.filename) }}
</span>
<input
v-model="form.filename"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.description) }}
</span>
<textarea
v-model="form.description"
rows="3"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<div class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.statuses) }}
</span>
<div class="flex flex-wrap gap-2">
<button
v-for="status in STATUS_OPTIONS"
:key="status"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="
form.status.includes(status)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary'
"
@click="toggleStatus(status)"
>
{{ status }}
</button>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.routes) }}
</span>
<div
v-for="(route, idx) in form.routes"
:key="idx"
class="flex flex-wrap items-center gap-2"
>
<input
v-model="route.name"
type="text"
:placeholder="formatMessage(messages.routeName)"
class="w-32 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<input
v-model="route.url"
type="text"
:placeholder="formatMessage(messages.routeUrl)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-[10px] font-semibold text-secondary hover:text-red disabled:opacity-50"
:disabled="form.routes.length <= 1"
@click="removeRouteRow(idx)"
>
{{ formatMessage(messages.removeRoute) }}
</button>
</div>
<button
type="button"
class="w-fit rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="addRouteRow"
>
{{ formatMessage(messages.addRoute) }}
</button>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSave"
@click="save"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteActivateLauncherRelease,
type SkinSiteLauncherAdminState,
skinSiteLauncherAdminState,
type SkinSiteLauncherRelease,
skinSiteUploadLauncherRelease,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.launcher.title', defaultMessage: 'Launcher releases' },
loading: { id: 'app.skin-site.admin.launcher.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.launcher.failed',
defaultMessage: 'Could not load releases.',
},
empty: { id: 'app.skin-site.admin.launcher.empty', defaultMessage: 'No releases uploaded yet.' },
latestUrl: { id: 'app.skin-site.admin.launcher.latest-url', defaultMessage: 'Latest URL' },
version: { id: 'app.skin-site.admin.launcher.version', defaultMessage: 'Version' },
channel: { id: 'app.skin-site.admin.launcher.channel', defaultMessage: 'Channel' },
uploader: { id: 'app.skin-site.admin.launcher.uploader', defaultMessage: 'Uploaded by' },
uploadedAt: { id: 'app.skin-site.admin.launcher.uploaded-at', defaultMessage: 'Uploaded' },
publishedAt: { id: 'app.skin-site.admin.launcher.published-at', defaultMessage: 'Published' },
forceUpdate: { id: 'app.skin-site.admin.launcher.force-update', defaultMessage: 'Forced' },
active: { id: 'app.skin-site.admin.launcher.active', defaultMessage: 'Active' },
activate: { id: 'app.skin-site.admin.launcher.activate', defaultMessage: 'Activate' },
activated: { id: 'app.skin-site.admin.launcher.activated', defaultMessage: 'Release activated.' },
upload: { id: 'app.skin-site.admin.launcher.upload', defaultMessage: 'Upload release' },
uploadHint: {
id: 'app.skin-site.admin.launcher.upload-hint',
defaultMessage: 'Choose a release ZIP (max 4 GiB).',
},
uploading: { id: 'app.skin-site.admin.launcher.uploading', defaultMessage: 'Uploading…' },
uploadDone: {
id: 'app.skin-site.admin.launcher.upload-done',
defaultMessage: 'Release uploaded.',
},
forceFlag: {
id: 'app.skin-site.admin.launcher.force-flag',
defaultMessage: 'Force update',
},
prev: { id: 'app.skin-site.admin.launcher.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.launcher.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.launcher.page', defaultMessage: 'Page {page}' },
total: { id: 'app.skin-site.admin.launcher.total', defaultMessage: '{count} releases' },
})
const PAGE_SIZE = 10
const state = ref<SkinSiteLauncherAdminState | null>(null)
const loading = ref(true)
const failed = ref(false)
const offset = ref(0)
const uploading = ref(false)
const uploadPercent = ref(0)
const forceUpdate = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const activatingId = ref<number | null>(null)
const releases = computed(() => state.value?.releases ?? [])
const total = computed(() => releases.value.length)
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const hasPrev = computed(() => offset.value > 0)
const hasNext = computed(() => offset.value + PAGE_SIZE < total.value)
const visible = computed(() => releases.value.slice(offset.value, offset.value + PAGE_SIZE))
function formatDateTime(ts?: number | null): string {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteLauncherAdminState()
if (!result.success) throw new Error(result.errorMessage)
state.value = result.payload
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
function prevPage() {
if (hasPrev.value) offset.value = Math.max(0, offset.value - PAGE_SIZE)
}
function nextPage() {
if (hasNext.value) offset.value += PAGE_SIZE
}
async function activate(release: SkinSiteLauncherRelease) {
if (activatingId.value !== null) return
activatingId.value = release.id
try {
const result = await skinSiteActivateLauncherRelease(release.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.activated) })
await load()
} catch (error) {
handleError(error)
} finally {
activatingId.value = null
}
}
function pickFile() {
fileInput.value?.click()
}
async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || uploading.value) return
uploading.value = true
uploadPercent.value = 0
try {
const result = await skinSiteUploadLauncherRelease(
file,
forceUpdate.value,
(p) => (uploadPercent.value = Math.round(p)),
)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.uploadDone) })
offset.value = 0
await load()
} catch (error) {
handleError(error)
} finally {
uploading.value = false
uploadPercent.value = 0
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</span>
</div>
<div
v-if="state && state.latestUrl"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.latestUrl) }}
</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">{{ state.latestUrl }}</code>
</div>
<div class="flex flex-wrap items-center gap-2">
<input
ref="fileInput"
type="file"
accept=".zip,application/zip"
class="hidden"
@change="onFileChosen"
/>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="uploading"
@click="pickFile"
>
{{ formatMessage(messages.upload) }}
</button>
<label class="flex items-center gap-2 text-xs text-contrast">
<input v-model="forceUpdate" type="checkbox" :disabled="uploading" />
{{ formatMessage(messages.forceFlag) }}
</label>
<span class="text-xs text-secondary">{{ formatMessage(messages.uploadHint) }}</span>
</div>
<div v-if="uploading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.uploading) }} {{ uploadPercent }}%
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!total" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="release in visible"
:key="release.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ release.version }}
<span class="text-xs font-normal text-secondary">#{{ release.id }}</span>
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.uploader) }}: {{ release.uploadedBy }} ·
{{ formatDateTime(release.uploadedAt) }}
</span>
</div>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ release.channel }}
</span>
<span
v-if="release.forceUpdate"
class="shrink-0 rounded-full bg-orange px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.forceUpdate) }}
</span>
<span
v-if="release.active"
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.active) }}
</span>
<button
v-if="!release.active"
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="activatingId !== null"
@click="activate(release)"
>
{{ formatMessage(messages.activate) }}
</button>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page }) }} / {{ pageCount }}
</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,982 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteActivateModRuntime,
skinSiteCreateModRuleTag,
skinSiteDeleteModFile,
skinSiteDeleteModRuleTag,
skinSiteDeleteModRuntime,
skinSiteDeleteMultiModFile,
skinSiteModCount,
type SkinSiteModInfo,
skinSiteModList,
type SkinSiteModRuleTag,
skinSiteModRuleTags,
type SkinSiteModRuntimeAdminState,
type SkinSiteModRuntimeRelease,
skinSiteModRuntimeReleases,
skinSiteModSearch,
skinSiteUpdateModInfo,
skinSiteUpdateModRuleTag,
skinSiteUpdateMultiModInfo,
skinSiteUploadModRuntime,
} from '@/helpers/skin-site-api'
import ModUploadModal from './ModUploadModal.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.mods.title', defaultMessage: 'Mods' },
tabMods: { id: 'app.skin-site.admin.mods.tab-mods', defaultMessage: 'Mod files' },
tabTags: { id: 'app.skin-site.admin.mods.tab-tags', defaultMessage: 'Rule tags' },
tabRuntime: { id: 'app.skin-site.admin.mods.tab-runtime', defaultMessage: 'Runtime' },
loading: { id: 'app.skin-site.admin.mods.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.mods.failed', defaultMessage: 'Could not load mods.' },
empty: { id: 'app.skin-site.admin.mods.empty', defaultMessage: 'No mods found.' },
total: { id: 'app.skin-site.admin.mods.total', defaultMessage: '{count} mods' },
search: { id: 'app.skin-site.admin.mods.search', defaultMessage: 'Search by name' },
filterAll: { id: 'app.skin-site.admin.mods.filter-all', defaultMessage: 'All tags' },
prev: { id: 'app.skin-site.admin.mods.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.mods.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.mods.page', defaultMessage: 'Page {page}' },
name: { id: 'app.skin-site.admin.mods.name', defaultMessage: 'Name' },
version: { id: 'app.skin-site.admin.mods.version', defaultMessage: 'Version' },
modId: { id: 'app.skin-site.admin.mods.mod-id', defaultMessage: 'Mod ID' },
size: { id: 'app.skin-site.admin.mods.size', defaultMessage: 'Size' },
loaders: { id: 'app.skin-site.admin.mods.loaders', defaultMessage: 'Loaders' },
domainRules: { id: 'app.skin-site.admin.mods.domain-rules', defaultMessage: 'Domain rules' },
edit: { id: 'app.skin-site.admin.mods.edit', defaultMessage: 'Edit' },
save: { id: 'app.skin-site.admin.mods.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.mods.cancel', defaultMessage: 'Cancel' },
remove: { id: 'app.skin-site.admin.mods.remove', defaultMessage: 'Delete' },
updated: { id: 'app.skin-site.admin.mods.updated', defaultMessage: 'Mod updated.' },
deleted: { id: 'app.skin-site.admin.mods.deleted', defaultMessage: 'Mod deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.mods.confirm-delete',
defaultMessage: 'Delete mod “{name}”? This cannot be undone.',
},
ruleTags: { id: 'app.skin-site.admin.mods.rule-tags', defaultMessage: 'Rule tags' },
tagName: { id: 'app.skin-site.admin.mods.tag-name', defaultMessage: 'Tag name' },
tagRules: { id: 'app.skin-site.admin.mods.tag-rules', defaultMessage: 'Rules' },
tagRulesHint: {
id: 'app.skin-site.admin.mods.tag-rules-hint',
defaultMessage: 'One rule per line.',
},
newTag: { id: 'app.skin-site.admin.mods.new-tag', defaultMessage: 'New tag' },
tagCreated: { id: 'app.skin-site.admin.mods.tag-created', defaultMessage: 'Tag created.' },
tagUpdated: { id: 'app.skin-site.admin.mods.tag-updated', defaultMessage: 'Tag updated.' },
tagDeleted: { id: 'app.skin-site.admin.mods.tag-deleted', defaultMessage: 'Tag deleted.' },
confirmDeleteTag: {
id: 'app.skin-site.admin.mods.confirm-delete-tag',
defaultMessage: 'Delete tag “{name}”?',
},
runtime: { id: 'app.skin-site.admin.mods.runtime', defaultMessage: 'Runtime releases' },
manifestUrl: { id: 'app.skin-site.admin.mods.manifest-url', defaultMessage: 'Manifest URL' },
uploadRuntime: {
id: 'app.skin-site.admin.mods.upload-runtime',
defaultMessage: 'Upload runtime',
},
runtimeVersion: {
id: 'app.skin-site.admin.mods.runtime-version',
defaultMessage: 'Version',
},
uploading: { id: 'app.skin-site.admin.mods.uploading', defaultMessage: 'Uploading…' },
runtimeUploaded: {
id: 'app.skin-site.admin.mods.runtime-uploaded',
defaultMessage: 'Runtime uploaded.',
},
activate: { id: 'app.skin-site.admin.mods.activate', defaultMessage: 'Activate' },
activated: { id: 'app.skin-site.admin.mods.activated', defaultMessage: 'Runtime activated.' },
available: { id: 'app.skin-site.admin.mods.available', defaultMessage: 'Available' },
unavailable: { id: 'app.skin-site.admin.mods.unavailable', defaultMessage: 'Unavailable' },
active: { id: 'app.skin-site.admin.mods.active', defaultMessage: 'Active' },
uploader: { id: 'app.skin-site.admin.mods.uploader', defaultMessage: 'by {name}' },
upload: { id: 'app.skin-site.admin.mods.upload', defaultMessage: 'Upload' },
selectMode: {
id: 'app.skin-site.admin.mods.select-mode',
defaultMessage: 'Select multiple',
},
exitSelect: {
id: 'app.skin-site.admin.mods.exit-select',
defaultMessage: 'Done selecting',
},
selectAll: { id: 'app.skin-site.admin.mods.select-all', defaultMessage: 'Select all' },
clearSelection: {
id: 'app.skin-site.admin.mods.clear-selection',
defaultMessage: 'Clear selection',
},
selectedCount: {
id: 'app.skin-site.admin.mods.selected-count',
defaultMessage: '{count} selected',
},
deleteSelected: {
id: 'app.skin-site.admin.mods.delete-selected',
defaultMessage: 'Delete selected',
},
confirmDeleteMulti: {
id: 'app.skin-site.admin.mods.confirm-delete-multi',
defaultMessage: 'Delete {count} selected mods? This cannot be undone.',
},
deletedMulti: {
id: 'app.skin-site.admin.mods.deleted-multi',
defaultMessage: 'Selected mods deleted.',
},
serverAddress: {
id: 'app.skin-site.admin.mods.server-address',
defaultMessage: 'Server address',
},
serverAddressHint: {
id: 'app.skin-site.admin.mods.server-address-hint',
defaultMessage: 'Filter by server address',
},
apply: { id: 'app.skin-site.admin.mods.apply', defaultMessage: 'Apply' },
clear: { id: 'app.skin-site.admin.mods.clear', defaultMessage: 'Clear' },
})
type Tab = 'mods' | 'tags' | 'runtime'
const tab = ref<Tab>('mods')
const PAGE_SIZE = 20
const mods = ref<SkinSiteModInfo[]>([])
const modTotal = ref(0)
const modOffset = ref(0)
const modLoading = ref(true)
const modFailed = ref(false)
const searchKeyword = ref('')
const tagFilter = ref<number | null>(null)
const editingHash = ref<string | null>(null)
const editForm = ref({ name: '', modId: '', version: '', domainRules: '' })
const savingMod = ref(false)
const deletingHash = ref<string | null>(null)
const selectedHashes = ref<Set<string>>(new Set())
const selectionMode = ref(false)
const deletingMulti = ref(false)
const serverAddressInput = ref('')
const serverAddressFilter = ref('')
const uploadOpen = ref(false)
const tags = ref<SkinSiteModRuleTag[]>([])
const tagsLoading = ref(false)
const tagEditorOpen = ref(false)
const editingTagId = ref<number | null>(null)
const tagForm = ref({ name: '', rules: '' })
const savingTag = ref(false)
const runtimeState = ref<SkinSiteModRuntimeAdminState | null>(null)
const runtimeLoading = ref(false)
const runtimeVersion = ref('')
const runtimeFile = ref<HTMLInputElement | null>(null)
const runtimeUploading = ref(false)
const activatingRuntimeId = ref<number | null>(null)
const deletingRuntimeId = ref<number | null>(null)
const modPage = computed(() => Math.floor(modOffset.value / PAGE_SIZE) + 1)
const modPageCount = computed(() => Math.max(1, Math.ceil(modTotal.value / PAGE_SIZE)))
const hasPrev = computed(() => modOffset.value > 0)
const hasNext = computed(() => modOffset.value + PAGE_SIZE < modTotal.value)
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return v.toFixed(i === 0 ? 0 : 2) + ' ' + units[i]
}
function formatDateTime(ts?: number | null): string {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
async function loadMods(reset = true) {
if (reset) {
modLoading.value = true
mods.value = []
modOffset.value = 0
}
modFailed.value = false
try {
const kw = searchKeyword.value.trim()
if (kw) {
const result = await skinSiteModSearch(kw, tagFilter.value)
if (!result.success) throw new Error(result.errorMessage)
mods.value = result.payload ?? []
modTotal.value = mods.value.length
} else {
const [listResult, countResult] = await Promise.all([
skinSiteModList({
limit: PAGE_SIZE,
offset: modOffset.value,
ruleTagId: tagFilter.value,
serverAddr: serverAddressFilter.value || undefined,
}),
skinSiteModCount({
ruleTagId: tagFilter.value,
serverAddr: serverAddressFilter.value || undefined,
}),
])
if (!listResult.success) throw new Error(listResult.errorMessage)
mods.value = listResult.payload ?? []
if (countResult.success && typeof countResult.payload === 'number') {
modTotal.value = countResult.payload
}
}
} catch (error) {
modFailed.value = true
handleError(error)
} finally {
modLoading.value = false
}
}
function prevPage() {
if (hasPrev.value) {
modOffset.value = Math.max(0, modOffset.value - PAGE_SIZE)
void loadMods(false)
}
}
function nextPage() {
if (hasNext.value) {
modOffset.value += PAGE_SIZE
void loadMods(false)
}
}
async function loadTags() {
tagsLoading.value = true
try {
const result = await skinSiteModRuleTags()
if (result.success) tags.value = result.payload ?? []
} catch (error) {
handleError(error)
} finally {
tagsLoading.value = false
}
}
async function loadRuntime() {
runtimeLoading.value = true
try {
const result = await skinSiteModRuntimeReleases()
if (result.success) runtimeState.value = result.payload
} catch (error) {
handleError(error)
} finally {
runtimeLoading.value = false
}
}
async function switchTab(next: Tab) {
tab.value = next
if (next === 'tags' && !tags.value.length) await loadTags()
if (next === 'runtime' && !runtimeState.value) await loadRuntime()
}
function startEdit(mod: SkinSiteModInfo) {
editingHash.value = mod.hash
editForm.value = {
name: mod.metadata.name ?? mod.name,
modId: mod.metadata.modId ?? '',
version: mod.metadata.version ?? '',
domainRules: mod.domainRules.join(', '),
}
}
function cancelEdit() {
editingHash.value = null
}
async function saveMod(mod: SkinSiteModInfo) {
if (savingMod.value) return
savingMod.value = true
try {
const payload: SkinSiteModInfo = {
...mod,
name: editForm.value.name.trim() || mod.name,
domainRules: editForm.value.domainRules
.split(',')
.map((r) => r.trim())
.filter(Boolean),
metadata: {
...mod.metadata,
name: editForm.value.name.trim() || undefined,
modId: editForm.value.modId.trim() || undefined,
version: editForm.value.version.trim() || undefined,
},
}
const result = await skinSiteUpdateModInfo(payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
cancelEdit()
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
savingMod.value = false
}
}
async function removeMod(mod: SkinSiteModInfo) {
if (deletingHash.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: mod.name }))) return
deletingHash.value = mod.hash
try {
const result = await skinSiteDeleteModFile(mod.hash)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
deletingHash.value = null
}
}
function toggleSelect(hash: string) {
const next = new Set(selectedHashes.value)
if (next.has(hash)) next.delete(hash)
else next.add(hash)
selectedHashes.value = next
}
function selectAllVisible() {
selectedHashes.value = new Set(mods.value.map((m) => m.hash))
}
function clearSelection() {
selectedHashes.value = new Set()
}
function exitSelectionMode() {
selectionMode.value = false
clearSelection()
}
async function deleteSelected() {
if (deletingMulti.value || !selectedHashes.value.size) return
const count = selectedHashes.value.size
if (!window.confirm(formatMessage(messages.confirmDeleteMulti, { count }))) return
deletingMulti.value = true
try {
const result = await skinSiteDeleteMultiModFile(Array.from(selectedHashes.value))
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deletedMulti) })
clearSelection()
selectionMode.value = false
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
deletingMulti.value = false
}
}
function applyServerAddress() {
serverAddressFilter.value = serverAddressInput.value.trim()
void loadMods(true)
}
function clearServerAddress() {
serverAddressInput.value = ''
serverAddressFilter.value = ''
void loadMods(true)
}
function openNewTag() {
editingTagId.value = null
tagForm.value = { name: '', rules: '' }
tagEditorOpen.value = true
}
function openEditTag(tag: SkinSiteModRuleTag) {
editingTagId.value = tag.id
tagForm.value = { name: tag.name, rules: tag.rules.join('\n') }
tagEditorOpen.value = true
}
function closeTagEditor() {
tagEditorOpen.value = false
tagForm.value = { name: '', rules: '' }
editingTagId.value = null
}
async function saveTag() {
const name = tagForm.value.name.trim()
if (!name || savingTag.value) return
savingTag.value = true
try {
const rules = tagForm.value.rules
.split('\n')
.map((r) => r.trim())
.filter(Boolean)
const result =
editingTagId.value === null
? await skinSiteCreateModRuleTag(name, rules)
: await skinSiteUpdateModRuleTag(editingTagId.value, name, rules)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingTagId.value === null ? messages.tagCreated : messages.tagUpdated),
})
closeTagEditor()
await loadTags()
} catch (error) {
handleError(error)
} finally {
savingTag.value = false
}
}
async function removeTag(tag: SkinSiteModRuleTag) {
if (!window.confirm(formatMessage(messages.confirmDeleteTag, { name: tag.name }))) return
try {
const result = await skinSiteDeleteModRuleTag(tag.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.tagDeleted) })
if (tagFilter.value === tag.id) tagFilter.value = null
await loadTags()
} catch (error) {
handleError(error)
}
}
function pickRuntimeFile() {
runtimeFile.value?.click()
}
async function onRuntimeFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const version = runtimeVersion.value.trim()
if (!file || !version || runtimeUploading.value) return
runtimeUploading.value = true
try {
const result = await skinSiteUploadModRuntime(version, file)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.runtimeUploaded) })
runtimeVersion.value = ''
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
runtimeUploading.value = false
}
}
async function activateRuntime(release: SkinSiteModRuntimeRelease) {
if (activatingRuntimeId.value !== null) return
activatingRuntimeId.value = release.id
try {
const result = await skinSiteActivateModRuntime(release.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.activated) })
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
activatingRuntimeId.value = null
}
}
async function removeRuntime(release: SkinSiteModRuntimeRelease) {
if (deletingRuntimeId.value !== null) return
deletingRuntimeId.value = release.id
try {
const result = await skinSiteDeleteModRuntime(release.id)
if (!result.success) throw new Error(result.errorMessage)
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
deletingRuntimeId.value = null
}
}
onMounted(async () => {
await loadTags()
await loadMods(true)
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'mods' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('mods')"
>
{{ formatMessage(messages.tabMods) }}
</button>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'tags' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('tags')"
>
{{ formatMessage(messages.tabTags) }}
</button>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'runtime' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('runtime')"
>
{{ formatMessage(messages.tabRuntime) }}
</button>
</div>
<template v-if="tab === 'mods'">
<div class="flex flex-wrap items-center gap-2">
<input
v-model="searchKeyword"
type="text"
:placeholder="formatMessage(messages.search)"
class="min-w-0 flex-1 rounded-full border border-solid border-surface-5 bg-bg px-3 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="loadMods(true)"
/>
<select
v-model="tagFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="loadMods(true)"
>
<option :value="null">{{ formatMessage(messages.filterAll) }}</option>
<option v-for="tag in tags" :key="tag.id" :value="tag.id">{{ tag.name }}</option>
</select>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="uploadOpen = true"
>
{{ formatMessage(messages.upload) }}
</button>
<button
v-if="!selectionMode"
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="selectionMode = true"
>
{{ formatMessage(messages.selectMode) }}
</button>
<template v-else>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="selectAllVisible"
>
{{ formatMessage(messages.selectAll) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!selectedHashes.size"
@click="clearSelection"
>
{{ formatMessage(messages.clearSelection) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!selectedHashes.size || deletingMulti"
@click="deleteSelected"
>
{{ formatMessage(messages.deleteSelected) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="exitSelectionMode"
>
{{ formatMessage(messages.exitSelect) }}
</button>
</template>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: modTotal }) }}
</span>
<span v-if="selectionMode && selectedHashes.size" class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.selectedCount, { count: selectedHashes.size }) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="serverAddressInput"
type="text"
:placeholder="formatMessage(messages.serverAddressHint)"
class="min-w-0 flex-1 rounded-full border border-solid border-surface-5 bg-bg px-3 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="applyServerAddress"
/>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="applyServerAddress"
>
{{ formatMessage(messages.apply) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!serverAddressFilter"
@click="clearServerAddress"
>
{{ formatMessage(messages.clear) }}
</button>
</div>
<div v-if="modLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="modFailed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!mods.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="mod in mods"
:key="mod.hash"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
:class="selectionMode && selectedHashes.has(mod.hash) ? 'ring-2 ring-brand' : ''"
>
<div class="flex flex-wrap items-center gap-2">
<input
v-if="selectionMode"
type="checkbox"
class="size-3.5 shrink-0"
:checked="selectedHashes.has(mod.hash)"
@change="toggleSelect(mod.hash)"
/>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ mod.metadata.name || mod.name }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatSize(mod.size) }}</span>
<span
v-for="loader in mod.metadata.loaderType"
:key="loader"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ loader }}
</span>
</div>
<span class="truncate font-mono text-[10px] text-secondary">{{ mod.hash }}</span>
<div v-if="editingHash === mod.hash" class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.name) }}</span>
<input
v-model="editForm.name"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.modId) }}</span>
<input
v-model="editForm.modId"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.version) }}</span>
<input
v-model="editForm.version"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1 sm:col-span-3">
<span class="text-xs text-secondary">{{ formatMessage(messages.domainRules) }}</span>
<input
v-model="editForm.domainRules"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
</div>
<div class="flex flex-wrap gap-2">
<template v-if="editingHash === mod.hash">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="cancelEdit"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="savingMod"
@click="saveMod(mod)"
>
{{ formatMessage(messages.save) }}
</button>
</template>
<template v-else>
<span
v-for="rule in mod.domainRules"
:key="rule"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ rule }}
</span>
<button
type="button"
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="startEdit(mod)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingHash !== null"
@click="removeMod(mod)"
>
{{ formatMessage(messages.remove) }}
</button>
</template>
</div>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page: modPage }) }} / {{ modPageCount }}
</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</div>
</template>
<template v-else-if="tab === 'tags'">
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openNewTag"
>
{{ formatMessage(messages.newTag) }}
</button>
</div>
<div v-if="tagsLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<div v-else-if="!tags.length" class="rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="tag in tags"
:key="tag.id"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ tag.name }}
</span>
<span class="text-xs text-secondary">×{{ tag.rules.length }}</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEditTag(tag)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="removeTag(tag)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
<div
v-if="tagEditorOpen"
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.tagName) }}
</span>
<input
v-model="tagForm.name"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.tagRules) }}
</span>
<textarea
v-model="tagForm.rules"
rows="5"
:placeholder="formatMessage(messages.tagRulesHint)"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 font-mono text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeTagEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="savingTag || !tagForm.name.trim()"
@click="saveTag"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-2">
<input ref="runtimeFile" type="file" class="hidden" @change="onRuntimeFile" />
<input
v-model="runtimeVersion"
type="text"
:placeholder="formatMessage(messages.runtimeVersion)"
class="w-40 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="runtimeUploading || !runtimeVersion.trim()"
@click="pickRuntimeFile"
>
{{
runtimeUploading
? formatMessage(messages.uploading)
: formatMessage(messages.uploadRuntime)
}}
</button>
</div>
<div
v-if="runtimeState && runtimeState.manifestUrl"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.manifestUrl) }}
</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">
{{ runtimeState.manifestUrl }}
</code>
</div>
<div v-if="runtimeLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="!runtimeState || !runtimeState.releases.length"
class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="release in runtimeState.releases"
:key="release.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ release.version }}
<span class="text-xs font-normal text-secondary">#{{ release.id }}</span>
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: release.uploadedBy }) }} ·
{{ formatDateTime(release.uploadedAt) }}
</span>
</div>
<span class="shrink-0 text-xs text-secondary">
{{ formatSize(release.fileSize) }}
</span>
<span
v-if="release.active"
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.active) }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="release.available ? 'bg-button-bg text-secondary' : 'bg-orange text-contrast'"
>
{{
release.available
? formatMessage(messages.available)
: formatMessage(messages.unavailable)
}}
</span>
<button
v-if="!release.active"
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="activatingRuntimeId !== null"
@click="activateRuntime(release)"
>
{{ formatMessage(messages.activate) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingRuntimeId !== null || release.active"
@click="removeRuntime(release)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</template>
<ModUploadModal
v-if="uploadOpen"
:rule-tags="tags"
@finished="uploadOpen = false"
/>
</div>
</template>
@@ -0,0 +1,315 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAdminDeletePlayer,
type SkinSiteAdminPlayer,
skinSiteAdminPlayerCount,
skinSiteAdminPlayerList,
skinSiteAdminUpdatePlayer,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.players.title', defaultMessage: 'Players' },
loading: { id: 'app.skin-site.admin.players.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.players.failed', defaultMessage: 'Could not load players.' },
empty: { id: 'app.skin-site.admin.players.empty', defaultMessage: 'No players found.' },
search: { id: 'app.skin-site.admin.players.search', defaultMessage: 'Search' },
searchName: { id: 'app.skin-site.admin.players.search-name', defaultMessage: 'Name' },
searchOwner: { id: 'app.skin-site.admin.players.search-owner', defaultMessage: 'Owner' },
searchUuid: { id: 'app.skin-site.admin.players.search-uuid', defaultMessage: 'UUID' },
total: { id: 'app.skin-site.admin.players.total', defaultMessage: '{count} players' },
prev: { id: 'app.skin-site.admin.players.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.players.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.players.page', defaultMessage: 'Page {page}' },
name: { id: 'app.skin-site.admin.players.name', defaultMessage: 'Name' },
owner: { id: 'app.skin-site.admin.players.owner', defaultMessage: 'Owner' },
source: { id: 'app.skin-site.admin.players.source', defaultMessage: 'Source' },
legacy: { id: 'app.skin-site.admin.players.legacy', defaultMessage: 'Legacy adapted' },
actions: { id: 'app.skin-site.admin.players.actions', defaultMessage: 'Actions' },
edit: { id: 'app.skin-site.admin.players.edit', defaultMessage: 'Edit' },
save: { id: 'app.skin-site.admin.players.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.players.cancel', defaultMessage: 'Cancel' },
remove: { id: 'app.skin-site.admin.players.remove', defaultMessage: 'Delete' },
updated: { id: 'app.skin-site.admin.players.updated', defaultMessage: 'Player updated.' },
deleted: { id: 'app.skin-site.admin.players.deleted', defaultMessage: 'Player deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.players.confirm-delete',
defaultMessage: 'Delete player “{name}”? This cannot be undone.',
},
mojang: { id: 'app.skin-site.admin.players.mojang', defaultMessage: 'Mojang' },
custom: { id: 'app.skin-site.admin.players.custom', defaultMessage: 'Skin site' },
})
const PAGE_SIZE = 20
const items = ref<SkinSiteAdminPlayer[]>([])
const total = ref(0)
const offset = ref(0)
const loading = ref(true)
const failed = ref(false)
const keyword = ref('')
const searchField = ref<'name' | 'ownerUsername' | 'uuid'>('name')
const editingUuid = ref<string | null>(null)
const editName = ref('')
const editIsMojang = ref(false)
const saving = ref(false)
const deletingUuid = ref<string | null>(null)
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const hasPrev = computed(() => offset.value > 0)
const hasNext = computed(() => offset.value + PAGE_SIZE < total.value)
const SEARCH_FIELDS: Array<'name' | 'ownerUsername' | 'uuid'> = ['name', 'ownerUsername', 'uuid']
function searchFieldLabel(field: string): string {
if (field === 'ownerUsername') return formatMessage(messages.searchOwner)
if (field === 'uuid') return formatMessage(messages.searchUuid)
return formatMessage(messages.searchName)
}
function buildQuery() {
const kw = keyword.value.trim()
return {
limit: PAGE_SIZE,
offset: offset.value,
searchField: kw ? searchField.value : undefined,
keyword: kw || undefined,
}
}
async function load(reset = false) {
if (reset) offset.value = 0
loading.value = true
failed.value = false
try {
const query = buildQuery()
const [listResult, countResult] = await Promise.all([
skinSiteAdminPlayerList(query),
skinSiteAdminPlayerCount(
query.keyword ? { searchField: query.searchField, keyword: query.keyword } : undefined,
),
])
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
if (countResult.success && typeof countResult.payload === 'number') {
total.value = countResult.payload
}
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function prevPage() {
if (!hasPrev.value) return
offset.value = Math.max(0, offset.value - PAGE_SIZE)
await load()
}
async function nextPage() {
if (!hasNext.value) return
offset.value += PAGE_SIZE
await load()
}
function startEdit(player: SkinSiteAdminPlayer) {
editingUuid.value = player.uuid
editName.value = player.name
editIsMojang.value = player.isMojang
}
function cancelEdit() {
editingUuid.value = null
editName.value = ''
editIsMojang.value = false
}
async function saveEdit() {
if (!editingUuid.value || saving.value) return
saving.value = true
try {
const result = await skinSiteAdminUpdatePlayer({
uuid: editingUuid.value,
name: editName.value.trim(),
isMojang: editIsMojang.value,
})
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
cancelEdit()
await load()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(player: SkinSiteAdminPlayer) {
if (deletingUuid.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: player.name }))) return
deletingUuid.value = player.uuid
try {
const result = await skinSiteAdminDeletePlayer(player.uuid)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load()
} catch (error) {
handleError(error)
} finally {
deletingUuid.value = null
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
v-model="searchField"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option v-for="f in SEARCH_FIELDS" :key="f" :value="f">
{{ searchFieldLabel(f) }}
</option>
</select>
<input
v-model="keyword"
type="text"
:placeholder="formatMessage(messages.search)"
class="min-w-0 flex-1 rounded-full border border-solid border-surface-5 bg-bg px-3 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="load(true)"
/>
<button
type="button"
class="rounded-full bg-brand px-4 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="load(true)"
>
{{ formatMessage(messages.search) }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="player in items"
:key="player.uuid"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col">
<input
v-if="editingUuid === player.uuid"
v-model="editName"
type="text"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-sm text-contrast outline-none focus:border-brand"
/>
<span v-else class="truncate text-sm font-semibold text-contrast">
{{ player.name }}
</span>
<span class="truncate font-mono text-[10px] text-secondary">{{ player.uuid }}</span>
</div>
<span class="shrink-0 text-xs text-secondary">{{ player.ownerUsername }}</span>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ player.isMojang ? formatMessage(messages.mojang) : formatMessage(messages.custom) }}
</span>
<span
v-if="player.legacyInternalAdapted"
class="shrink-0 rounded-full bg-yellow px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.legacy) }}
</span>
<template v-if="editingUuid === player.uuid">
<label class="flex shrink-0 items-center gap-1 text-xs text-contrast">
<input v-model="editIsMojang" type="checkbox" />
{{ formatMessage(messages.mojang) }}
</label>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="cancelEdit"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="saving"
@click="saveEdit"
>
{{ formatMessage(messages.save) }}
</button>
</template>
<template v-else>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="startEdit(player)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingUuid !== null"
@click="remove(player)"
>
{{ formatMessage(messages.remove) }}
</button>
</template>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page }) }} / {{ pageCount }}
</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSiteMapServer,
skinSiteMapServers,
skinSiteSaveMapServers,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.servers.title', defaultMessage: 'Map servers' },
loading: { id: 'app.skin-site.admin.servers.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.servers.failed', defaultMessage: 'Could not load servers.' },
empty: { id: 'app.skin-site.admin.servers.empty', defaultMessage: 'No map servers configured.' },
serverName: { id: 'app.skin-site.admin.servers.server-name', defaultMessage: 'Server name' },
internal: { id: 'app.skin-site.admin.servers.internal', defaultMessage: 'Internal' },
maps: { id: 'app.skin-site.admin.servers.maps', defaultMessage: 'Maps' },
mapName: { id: 'app.skin-site.admin.servers.map-name', defaultMessage: 'Map name' },
mapUrl: { id: 'app.skin-site.admin.servers.map-url', defaultMessage: 'Map URL' },
addMap: { id: 'app.skin-site.admin.servers.add-map', defaultMessage: 'Add map' },
removeMap: { id: 'app.skin-site.admin.servers.remove-map', defaultMessage: 'Remove' },
addServer: { id: 'app.skin-site.admin.servers.add-server', defaultMessage: 'Add server' },
removeServer: {
id: 'app.skin-site.admin.servers.remove-server',
defaultMessage: 'Remove server',
},
save: { id: 'app.skin-site.admin.servers.save', defaultMessage: 'Save changes' },
saving: { id: 'app.skin-site.admin.servers.saving', defaultMessage: 'Saving…' },
saved: { id: 'app.skin-site.admin.servers.saved', defaultMessage: 'Map servers saved.' },
})
const servers = ref<SkinSiteMapServer[]>([])
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteMapServers()
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
function addServer() {
servers.value.push({ name: '', internal: false, maps: [{ name: '', url: '' }] })
}
function removeServer(index: number) {
servers.value.splice(index, 1)
}
function addMap(server: SkinSiteMapServer) {
server.maps.push({ name: '', url: '' })
}
function removeMap(server: SkinSiteMapServer, index: number) {
server.maps.splice(index, 1)
}
async function save() {
if (saving.value) return
saving.value = true
try {
const cleaned = servers.value.map((s) => ({
name: s.name.trim(),
internal: s.internal,
maps: s.maps
.filter((m) => m.name.trim() || m.url.trim())
.map((m) => ({ name: m.name.trim(), url: m.url.trim() })),
}))
const result = await skinSiteSaveMapServers(cleaned)
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? cleaned
addNotification({ type: 'success', title: formatMessage(messages.saved) })
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="addServer"
>
{{ formatMessage(messages.addServer) }}
</button>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="saving"
@click="save"
>
{{ saving ? formatMessage(messages.saving) : formatMessage(messages.save) }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!servers.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-3">
<div
v-for="(server, si) in servers"
:key="si"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="server.name"
type="text"
:placeholder="formatMessage(messages.serverName)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-sm text-contrast outline-none focus:border-brand"
/>
<label class="flex shrink-0 items-center gap-1.5 text-xs text-contrast">
<input v-model="server.internal" type="checkbox" />
{{ formatMessage(messages.internal) }}
</label>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="removeServer(si)"
>
{{ formatMessage(messages.removeServer) }}
</button>
</div>
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.maps) }}
</span>
<div v-for="(map, mi) in server.maps" :key="mi" class="flex flex-wrap items-center gap-2">
<input
v-model="map.name"
type="text"
:placeholder="formatMessage(messages.mapName)"
class="w-40 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<input
v-model="map.url"
type="text"
:placeholder="formatMessage(messages.mapUrl)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-[10px] font-semibold text-secondary hover:text-red"
@click="removeMap(server, mi)"
>
{{ formatMessage(messages.removeMap) }}
</button>
</div>
<button
type="button"
class="w-fit rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="addMap(server)"
>
{{ formatMessage(messages.addMap) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { type SkinSiteAdminStatus, skinSiteAdminStatus } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.summary.title', defaultMessage: 'System overview' },
loading: { id: 'app.skin-site.admin.summary.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.summary.failed', defaultMessage: 'Could not load status.' },
uptime: { id: 'app.skin-site.admin.summary.uptime', defaultMessage: 'Uptime' },
memory: { id: 'app.skin-site.admin.summary.memory', defaultMessage: 'Memory usage' },
users: { id: 'app.skin-site.admin.summary.users', defaultMessage: 'Users' },
players: { id: 'app.skin-site.admin.summary.players', defaultMessage: 'Players' },
realtimeHint: {
id: 'app.skin-site.admin.summary.realtime-hint',
defaultMessage:
'Live CPU and GC charts require the admin WebSocket, which is not connected yet.',
},
})
const status = ref<SkinSiteAdminStatus | null>(null)
const loading = ref(true)
const failed = ref(false)
const now = ref(Date.now())
let ticker: number | undefined
const uptime = computed(() => {
if (!status.value) return ''
let ms = now.value - status.value.bootTime
if (ms < 0) ms = 0
const totalSeconds = Math.floor(ms / 1000)
const days = Math.floor(totalSeconds / 86400)
const hours = Math.floor((totalSeconds % 86400) / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const parts: string[] = []
if (days) parts.push(days + 'd')
if (hours) parts.push(hours + 'h')
if (minutes) parts.push(minutes + 'm')
parts.push(seconds + 's')
return parts.join(' ')
})
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let value = bytes
let idx = 0
while (value >= 1024 && idx < units.length - 1) {
value /= 1024
idx++
}
return value.toFixed(idx === 0 ? 0 : 2) + ' ' + units[idx]
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteAdminStatus()
if (!result.success) throw new Error(result.errorMessage)
status.value = result.payload
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
onMounted(() => {
void load()
ticker = window.setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
if (ticker) clearInterval(ticker)
})
</script>
<template>
<div class="flex flex-col gap-4">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<template v-else-if="status">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.uptime) }}</span>
<span class="text-xl font-bold text-contrast">{{ uptime }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.memory) }}</span>
<span class="text-xl font-bold text-contrast">{{ formatSize(status.memoryUsage) }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.users) }}</span>
<span class="text-xl font-bold text-contrast">{{ status.userCount }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.players) }}</span>
<span class="text-xl font-bold text-contrast">{{ status.playerCount }}</span>
</div>
</div>
<p class="m-0 rounded-xl border border-dashed border-surface-5 p-3 text-xs text-secondary">
{{ formatMessage(messages.realtimeHint) }}
</p>
</template>
</div>
</template>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,440 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
type SkinSiteModRuleTag,
skinSiteModExistsByHash,
skinSiteModExistsByName,
skinSiteModHashFromFile,
skinSiteUploadMod,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
defineProps<{ ruleTags: SkinSiteModRuleTag[] }>()
const emit = defineEmits<{ (e: 'finished'): void }>()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.mods.upload-title', defaultMessage: 'Upload mods' },
dropHint: {
id: 'app.skin-site.admin.mods.upload-drop',
defaultMessage: 'Drag .jar files here, or click to choose',
},
dropSub: {
id: 'app.skin-site.admin.mods.upload-sub',
defaultMessage: 'Multiple files supported, up to {size} MB each',
},
tagsLabel: {
id: 'app.skin-site.admin.mods.upload-tags',
defaultMessage: 'Attach rule tags',
},
queue: { id: 'app.skin-site.admin.mods.upload-queue', defaultMessage: 'Files' },
statusPending: {
id: 'app.skin-site.admin.mods.upload-pending',
defaultMessage: 'Pending',
},
statusChecking: {
id: 'app.skin-site.admin.mods.upload-checking',
defaultMessage: 'Checking…',
},
statusUploading: {
id: 'app.skin-site.admin.mods.upload-uploading',
defaultMessage: 'Uploading…',
},
statusConflict: {
id: 'app.skin-site.admin.mods.upload-conflict',
defaultMessage: 'Name exists',
},
statusSuccess: {
id: 'app.skin-site.admin.mods.upload-success',
defaultMessage: 'Done',
},
statusError: {
id: 'app.skin-site.admin.mods.upload-error',
defaultMessage: 'Failed',
},
existsAlready: {
id: 'app.skin-site.admin.mods.upload-exists',
defaultMessage: 'This file already exists on the server.',
},
nameConflictTitle: {
id: 'app.skin-site.admin.mods.upload-name-conflict',
defaultMessage: 'A mod with the same file name already exists',
},
nameConflictHint: {
id: 'app.skin-site.admin.mods.upload-name-conflict-hint',
defaultMessage: 'Overwrite the existing mod, or skip this file.',
},
overwrite: { id: 'app.skin-site.admin.mods.upload-overwrite', defaultMessage: 'Overwrite' },
skip: { id: 'app.skin-site.admin.mods.upload-skip', defaultMessage: 'Skip' },
close: { id: 'app.skin-site.admin.mods.upload-close', defaultMessage: 'Close' },
allDone: {
id: 'app.skin-site.admin.mods.upload-all-done',
defaultMessage: 'All files processed.',
},
tooLarge: {
id: 'app.skin-site.admin.mods.upload-too-large',
defaultMessage: 'File too large: {name}',
},
})
const MAX_MOD_SIZE_MB = 200
const MAX_RETRIES = 3
type TaskStatus = 'pending' | 'checking' | 'uploading' | 'conflict' | 'success' | 'error'
interface Task {
file: File
status: TaskStatus
message?: string
attempts: number
}
const queue = ref<Task[]>([])
const selectedTagIds = ref<Set<number>>(new Set())
const fileInput = ref<HTMLInputElement | null>(null)
const busy = ref(false)
const conflictTask = ref<Task | null>(null)
const finishedCount = computed(
() => queue.value.filter((t) => t.status === 'success' || t.status === 'error').length,
)
const allDone = computed(() => queue.value.length > 0 && finishedCount.value === queue.value.length)
const hasWork = computed(() => queue.value.length > 0)
function toggleTag(id: number) {
const next = new Set(selectedTagIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedTagIds.value = next
}
function pickFiles() {
if (busy.value) return
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
enqueue(files)
}
function onDrop(event: DragEvent) {
event.preventDefault()
if (busy.value) return
enqueue(Array.from(event.dataTransfer?.files ?? []))
}
function enqueue(files: File[]) {
const jars = files.filter((f) => f.name.toLowerCase().endsWith('.jar'))
if (!jars.length) return
const accepted: Task[] = []
for (const file of jars) {
if (file.size > MAX_MOD_SIZE_MB * 1024 * 1024) {
addNotification({
type: 'error',
title: formatMessage(messages.tooLarge, { name: file.name }),
})
continue
}
accepted.push({ file, status: 'pending', attempts: 0 })
}
if (!accepted.length) return
queue.value = [...queue.value, ...accepted]
void runQueue()
}
async function runQueue() {
if (busy.value) return
busy.value = true
try {
for (;;) {
const task = queue.value.find((t) => t.status === 'pending')
if (!task) break
await processTask(task)
if (task.status === 'conflict') break
}
} finally {
busy.value = false
}
if (allDone.value) {
addNotification({ type: 'success', title: formatMessage(messages.allDone) })
emit('finished')
}
}
async function processTask(task: Task) {
task.status = 'checking'
task.message = undefined
try {
const hash = await skinSiteModHashFromFile(task.file)
const exists = await skinSiteModExistsByHash(hash)
if (exists.success) {
task.status = 'error'
task.message = formatMessage(messages.existsAlready)
return
}
const nameExists = await skinSiteModExistsByName(task.file.name)
if (nameExists.success) {
task.status = 'conflict'
conflictTask.value = task
return
}
await upload(task, false)
} catch (error) {
task.status = 'error'
task.message = error instanceof Error ? error.message : String(error)
handleError(error)
}
}
async function upload(task: Task, overwrite: boolean) {
task.status = 'uploading'
try {
const res = await skinSiteUploadMod(task.file, Array.from(selectedTagIds.value), overwrite)
if (res.success) {
task.status = 'success'
return
}
if (res.errorCode === 429) {
task.attempts++
if (task.attempts < MAX_RETRIES) {
task.status = 'pending'
task.message = 'rate limited, retrying'
setTimeout(() => void runQueue(), 1500 * task.attempts)
} else {
task.status = 'error'
task.message = res.errorMessage
}
return
}
if (res.errorType === 'HashDiff') {
task.attempts++
if (task.attempts < MAX_RETRIES) {
task.status = 'pending'
task.message = 'hash mismatch, retrying'
setTimeout(() => void runQueue(), 800)
} else {
task.status = 'error'
task.message = res.errorMessage
}
return
}
if (res.errorType === 'ModNameConflict') {
task.status = 'conflict'
conflictTask.value = task
return
}
task.status = 'error'
task.message = res.errorMessage
} catch (error) {
task.status = 'error'
task.message = error instanceof Error ? error.message : String(error)
}
}
async function resolveConflict(overwrite: boolean) {
const task = conflictTask.value
conflictTask.value = null
if (!task) return
if (overwrite) {
busy.value = true
try {
await upload(task, true)
} finally {
busy.value = false
}
} else {
task.status = 'error'
task.message = formatMessage(messages.skip)
}
void runQueue()
}
function close() {
if (busy.value) return
queue.value = []
conflictTask.value = null
emit('finished')
}
function statusLabel(status: TaskStatus): string {
switch (status) {
case 'pending':
return formatMessage(messages.statusPending)
case 'checking':
return formatMessage(messages.statusChecking)
case 'uploading':
return formatMessage(messages.statusUploading)
case 'conflict':
return formatMessage(messages.statusConflict)
case 'success':
return formatMessage(messages.statusSuccess)
default:
return formatMessage(messages.statusError)
}
}
function statusClass(status: TaskStatus): string {
switch (status) {
case 'success':
return 'bg-green text-contrast'
case 'error':
return 'bg-red text-contrast'
case 'conflict':
return 'bg-yellow text-contrast'
default:
return 'bg-button-bg text-secondary'
}
}
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" @click.self="close">
<div class="flex max-h-[88vh] w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-xl bg-bg p-6">
<div class="flex items-start justify-between gap-3">
<h3 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h3>
<button
type="button"
class="shrink-0 rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="busy"
@click="close"
>
✕
</button>
</div>
<div
class="flex min-h-40 cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-surface-5 p-6 text-center transition-colors hover:bg-bg-raised"
:class="busy ? 'cursor-not-allowed opacity-60' : ''"
@dragover.prevent
@dragenter.prevent
@drop.prevent="onDrop"
@click="pickFiles"
>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
accept=".jar"
@change="onFileChange"
/>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.dropHint) }}
</span>
<span class="text-xs text-secondary">
{{ formatMessage(messages.dropSub, { size: MAX_MOD_SIZE_MB }) }}
</span>
</div>
<div v-if="ruleTags.length" class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.tagsLabel) }}
</span>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in ruleTags"
:key="tag.id"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="
selectedTagIds.has(tag.id)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="toggleTag(tag.id)"
>
{{ tag.name }}
</button>
</div>
</div>
<div v-if="hasWork" class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.queue) }} ({{ finishedCount }}/{{ queue.length }})
</span>
<div class="flex max-h-64 flex-col gap-1.5 overflow-y-auto">
<div
v-for="(task, index) in queue"
:key="task.file.name + index"
class="flex flex-wrap items-center gap-2 rounded-lg bg-bg-raised px-3 py-2"
>
<span class="min-w-0 flex-1 truncate text-xs text-contrast">
{{ task.file.name }}
</span>
<span class="shrink-0 text-[10px] text-secondary">
{{ (task.file.size / 1024 / 1024).toFixed(2) }} MB
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="statusClass(task.status)"
>
{{ statusLabel(task.status) }}
</span>
<span
v-if="task.message"
class="w-full truncate text-[10px] text-secondary"
:title="task.message"
>
{{ task.message }}
</span>
</div>
</div>
</div>
<div
v-if="busy"
class="flex items-center gap-2 text-xs text-secondary"
>
<SpinnerIcon class="size-3.5 animate-spin" />
{{ formatMessage(messages.statusUploading) }}
</div>
<div class="flex justify-end gap-2 border-t border-solid border-surface-5 pt-3">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
:disabled="busy"
@click="close"
>
{{ formatMessage(messages.close) }}
</button>
</div>
</div>
<div
v-if="conflictTask"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
>
<div class="flex w-full max-w-md flex-col gap-3 rounded-xl bg-bg p-6">
<h4 class="m-0 text-base font-bold text-contrast">
{{ formatMessage(messages.nameConflictTitle) }}
</h4>
<p class="m-0 text-xs break-all text-secondary">{{ conflictTask.file.name }}</p>
<p class="m-0 text-xs text-secondary">{{ formatMessage(messages.nameConflictHint) }}</p>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="resolveConflict(false)"
>
{{ formatMessage(messages.skip) }}
</button>
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="resolveConflict(true)"
>
{{ formatMessage(messages.overwrite) }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,30 @@
import { skinSiteChatRoomAvatarFetch } from '@/helpers/skin-site-api'
const avatarUrls = new Map<number, string>()
const pending = new Map<number, Promise<string | null>>()
export function loadCachedRoomAvatar(
roomId: number,
attachmentId: number,
): Promise<string | null> {
const cached = avatarUrls.get(attachmentId)
if (cached) return Promise.resolve(cached)
const running = pending.get(attachmentId)
if (running) return running
const request = skinSiteChatRoomAvatarFetch(roomId).then((blob) => {
pending.delete(attachmentId)
if (!blob) return null
const url = URL.createObjectURL(blob)
avatarUrls.set(attachmentId, url)
return url
})
pending.set(attachmentId, request)
return request
}
export function forgetRoomAvatar(attachmentId: number) {
const url = avatarUrls.get(attachmentId)
if (url) URL.revokeObjectURL(url)
avatarUrls.delete(attachmentId)
}
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteWikiAttachmentInfo,
skinSiteWikiAttachment,
skinSiteWikiAttachmentUrl,
} from '@/helpers/skin-site-api'
const props = defineProps<{ name: string; caption?: string | null }>()
const attachment = ref<SkinSiteWikiAttachmentInfo | null>(null)
const loading = ref(true)
const viewerOpen = ref(false)
const contentUrl = computed(() =>
attachment.value ? skinSiteWikiAttachmentUrl(attachment.value.id) : '#',
)
const imageCaption = computed(() => {
const caption = props.caption?.trim() ?? ''
return caption === props.name || caption === attachment.value?.name ? '' : caption
})
onMounted(async () => {
try {
const res = await skinSiteWikiAttachment(props.name)
if (res.success) attachment.value = res.payload
} catch {
// ignore
} finally {
loading.value = false
}
})
</script>
<template>
<span
v-if="loading"
class="inline-flex min-h-20 min-w-32 items-center justify-center rounded-lg border border-solid border-surface-5"
>
<span class="text-xs text-secondary">…</span>
</span>
<figure v-else-if="attachment?.image" class="my-5 inline-block max-w-full">
<button type="button" class="block max-w-full cursor-zoom-in rounded-lg" @click="viewerOpen = true">
<img
class="max-h-[70vh] max-w-full rounded-lg border border-solid border-surface-5 object-contain shadow-md"
:src="contentUrl"
:alt="caption || attachment.name"
loading="lazy"
decoding="async"
/>
</button>
<figcaption v-if="imageCaption" class="mt-2 text-center text-sm text-secondary">
{{ imageCaption }}
</figcaption>
</figure>
<a
v-else-if="attachment"
class="inline-flex items-center gap-1 text-brand underline-offset-2 hover:underline"
:href="contentUrl"
download
>
{{ caption || attachment.name }}
</a>
<span v-else class="text-red">附件“{{ name }}”不存在</span>
<div
v-if="viewerOpen"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 p-4"
@click.self="viewerOpen = false"
>
<img :src="contentUrl" :alt="caption || attachment?.name" class="max-h-[92vh] max-w-[92vw] object-contain" />
</div>
</template>
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { type SkinSiteWikiNode } from '@/helpers/skin-site-api'
import { safeExternalUrl, safeWikiColor } from './wiki-color'
defineOptions({ name: 'WikiNodeRenderer' })
defineProps<{ nodes: SkinSiteWikiNode[] }>()
const emit = defineEmits<{ (e: 'navigate', title: string): void }>()
function headingTag(level?: number | null): string {
return 'h' + Math.min(6, Math.max(1, level ?? 2))
}
function navigate(title?: string | null) {
emit('navigate', (title ?? '').replaceAll('_', ' '))
}
</script>
<template>
<template v-for="(node, index) in nodes" :key="node.type + '-' + index">
<component
:is="headingTag(node.level)"
v-if="node.type === 'heading'"
class="wiki-heading scroll-mt-24"
:style="{ textAlign: node.align ?? undefined }"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</component>
<p
v-else-if="node.type === 'paragraph'"
class="my-3 leading-7 break-words whitespace-pre-wrap"
:style="{ textAlign: node.align ?? undefined }"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</p>
<strong v-else-if="node.type === 'strong'" class="font-bold">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</strong>
<small v-else-if="node.type === 'small'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</small>
<span
v-else-if="node.type === 'color'"
:style="{ color: safeWikiColor(node.color ?? '') ?? undefined }"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</span>
<em v-else-if="node.type === 'emphasis'" class="italic">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</em>
<u v-else-if="node.type === 'underline'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</u>
<s v-else-if="node.type === 'strike'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</s>
<mark v-else-if="node.type === 'highlight'" class="rounded bg-yellow/25 px-0.5 text-contrast">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</mark>
<code v-else-if="node.type === 'inlineCode'" class="rounded bg-button-bg px-1 font-mono text-sm">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</code>
<span
v-else-if="node.type === 'spoiler'"
class="cursor-help rounded bg-contrast text-contrast transition-colors hover:bg-transparent hover:text-inherit"
:title="node.tooltip || '我知道你在偷看'"
tabindex="0"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</span>
<br v-else-if="node.type === 'break'" />
<hr v-else-if="node.type === 'rule'" class="my-6 border-surface-5" />
<button
v-else-if="node.type === 'internalLink'"
type="button"
class="font-medium underline-offset-2 hover:underline"
:class="node.exists === false ? 'text-red' : 'text-brand'"
:title="node.exists === false ? '该词条尚未创建' : undefined"
@click="navigate(node.title)"
>
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
<template v-else>{{ node.text }}</template>
</button>
<a
v-else-if="node.type === 'externalLink'"
class="text-brand underline-offset-2 hover:underline"
:href="safeExternalUrl(node.href)"
target="_blank"
rel="noopener noreferrer nofollow"
>
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
<template v-else>{{ node.text }}</template>
</a>
<ul v-else-if="node.type === 'unorderedList'" class="my-3 list-disc pl-7">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</ul>
<ol
v-else-if="node.type === 'orderedList'"
:start="node.start ?? undefined"
class="my-3 list-decimal pl-7"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</ol>
<li v-else-if="node.type === 'listItem'" class="my-1">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</li>
<blockquote
v-else-if="node.type === 'quote'"
class="my-4 border-l-4 border-brand/60 bg-brand/5 px-4 py-2"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</blockquote>
<pre
v-else-if="node.type === 'code'"
class="my-4 overflow-x-auto rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 font-mono text-sm"
><code>{{ node.text }}</code></pre>
<div v-else-if="node.type === 'table'" class="my-5 overflow-x-auto">
<table class="w-full border-collapse text-sm">
<tbody>
<WikiNodeRenderer
v-if="node.children.length"
:nodes="node.children"
@navigate="navigate"
/>
<tr v-for="(row, rowIndex) in node.rows" :key="rowIndex">
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="border border-solid border-surface-5 px-2 py-1">
<WikiNodeRenderer :nodes="cell" @navigate="navigate" />
</td>
</tr>
</tbody>
</table>
</div>
<tr v-else-if="node.type === 'tableRow'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</tr>
<component
:is="node.type === 'tableHeader' ? 'th' : 'td'"
v-else-if="node.type === 'tableCell' || node.type === 'tableHeader'"
class="border border-solid border-surface-5 px-2 py-1"
:colspan="node.colspan ?? 1"
:rowspan="node.rowspan ?? 1"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</component>
<WikiAttachmentNode
v-else-if="node.type === 'attachment' && node.title"
:name="node.title"
:caption="node.text"
/>
<WikiNotice
v-else-if="node.type === 'notice' && node.title"
:title="node.title"
:description="node.text"
:variant="node.variant"
>
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
<p v-else-if="node.text" class="whitespace-pre-wrap">{{ node.text }}</p>
</WikiNotice>
<span v-else>{{ node.text }}</span>
</template>
</template>
<style scoped>
.wiki-heading {
margin-top: 1.65rem;
margin-bottom: 0.7rem;
border-bottom: 1px solid color-mix(in oklab, currentColor 16%, transparent);
padding-bottom: 0.4rem;
font-weight: 750;
line-height: 1.25;
}
h1.wiki-heading {
font-size: 2rem;
}
h2.wiki-heading {
font-size: 1.55rem;
}
h3.wiki-heading {
font-size: 1.25rem;
}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
title: string
description?: string | null
variant?: string | null
}>()
const variantClass = computed(() => {
switch (props.variant) {
case 'warning':
return 'border-yellow/40 bg-yellow/10 text-yellow'
case 'error':
return 'border-red/40 bg-red/10 text-red'
case 'success':
return 'border-green/40 bg-green/10 text-green'
default:
return 'border-brand/40 bg-brand/10 text-brand'
}
})
</script>
<template>
<div class="my-4 rounded-lg border border-solid p-4" :class="variantClass">
<p class="m-0 text-sm font-bold">{{ title }}</p>
<p v-if="description" class="m-0 mt-1 text-xs whitespace-pre-wrap opacity-90">
{{ description }}
</p>
<slot />
</div>
</template>
@@ -0,0 +1,18 @@
export function safeWikiColor(value: unknown): string | null {
if (typeof value !== 'string') return null
const color = value.trim()
return color.length <= 80 &&
/^(?:[a-z]{1,20}|#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8}|rgba?\([\d\s.,%]+\))$/i.test(color)
? color
: null
}
export function safeExternalUrl(url?: string | null): string {
if (!url) return '#'
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : '#'
} catch {
return '#'
}
}
@@ -1,714 +1,208 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test from 'node:test' import test from 'node:test'
import { type FetchHandler = (url: string, options: RequestInit) => Promise<Response>
clearHostedSession, const testGlobal = globalThis as unknown as { skinSiteTestFetch: FetchHandler }
HOSTED_ACCOUNT_REQUIRED_MESSAGE, const hooks = registerHooks({
hostedCreate, resolve(specifier, context, nextResolve) {
hostedDefault, if (specifier === '@tauri-apps/plugin-http') {
hostedSync, return {
onHostedPackAttemptStarted, url: 'data:text/javascript,export const fetch=(...args)=>globalThis.skinSiteTestFetch(...args)',
setInstanceMode, shortCircuit: true,
} from '../helpers/hosted-packs.ts' }
import { }
openSkinSiteLogin, if (specifier.startsWith('@/')) {
receiveSkinSiteMessage, return nextResolve(new URL(`../${specifier.slice(2)}.ts`, import.meta.url).href, context)
requestSkinSiteDownloadToken, }
requestSkinSiteLuck, return nextResolve(specifier, context)
requestSkinSitePlayers, },
requestSkinSiteSkinUpdate,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from './skin-site-session.ts'
import { forgetHostedCreation, useHostedCreation } from './useHostedCreation.ts'
import { useHostedSync } from './useHostedSync.ts'
test('reconnecting the same skin site frame waits for verification instead of reporting signed out', () => {
const frame = { postMessage() {} } as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
resetSkinSiteSession()
setSkinSiteFrame(frame)
assert.equal(skinSiteStatus.value, 'checking')
setSkinSiteFrame(null)
resetSkinSiteSession()
}) })
const session = await import('./skin-site-session.ts')
hooks.deregister()
test('hosted installation sends the JWT to native commands while Local needs no session', async () => { const user = { uuid: 'owner', username: 'Player', permissions: [] }
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') const ok = (payload: unknown) => Response.json({ payload })
const calls: Array<{ command: string; args: Record<string, unknown> }> = [] const storage = new Map<string, string>()
let syncResponse: (() => Promise<unknown>) | undefined const tokenKey = 'starlight-skin-site-token'
let createdId = 'instance' let calls: Array<{ path: string; token: string | undefined }> = []
let instanceExists = false let respond: FetchHandler
let tokenRequests = 0
let accounts: Array<{ test.beforeEach(() => {
account_type: string storage.clear()
yggdrasil?: { api_root: string } Object.defineProperty(globalThis, 'localStorage', {
}> = [{ account_type: 'microsoft' }]
const frame = {
postMessage(data: { type: string; requestId: string }) {
if (data.type !== 'starlight-pack-token-request') return
tokenRequests++
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-pack-token-result',
requestId: data.requestId,
token: 'site.jwt.secret',
},
} as MessageEvent,
frame,
)
},
} as unknown as Window
Object.defineProperty(globalThis, 'window', {
configurable: true, configurable: true,
value: { value: {
__TAURI_INTERNALS__: { getItem: (key: string) => storage.get(key) ?? null,
async invoke(command: string, args: Record<string, unknown>) { setItem: (key: string, value: string) => storage.set(key, value),
calls.push({ command, args }) removeItem: (key: string) => storage.delete(key),
if (command === 'plugin:install|hosted_has_game_account')
return accounts.some(
(account) =>
account.account_type === 'microsoft' ||
(account.account_type === 'yggdrasil' &&
account.yggdrasil?.api_root.replace(/\/+$/, '') ===
'https://skin.starlight.cool/yggdrasil'),
)
if (command === 'plugin:install|hosted_sync' && syncResponse) return syncResponse()
if (command === 'plugin:instance|instance_get')
return instanceExists ? { id: args.instanceId } : null
return command === 'plugin:install|hosted_create' ? createdId : null
},
},
}, },
}) })
try { calls = []
resetSkinSiteSession() respond = async (url) => {
setSkinSiteFrame(frame) if (url.endsWith('/user/refresh')) return ok({ username: 'Player', jwt: 'fresh-token' })
receiveSkinSiteMessage( if (url.endsWith('/user')) return ok(user)
{ return ok([])
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site', username: 'Site' },
},
} as MessageEvent,
frame,
)
await hostedDefault()
assert.equal(await hostedCreate(), 'instance')
await hostedSync('instance')
await setInstanceMode('instance', 'starlight')
assert.equal(tokenRequests, 4)
assert.deepEqual(
calls
.map(({ command }) => command)
.filter(
(command) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
),
[
'hosted_set_session',
'hosted_default',
'hosted_set_session',
'hosted_create',
'hosted_set_session',
'hosted_sync',
'hosted_set_session',
'hosted_set_instance_mode',
].map((name) => `plugin:install|${name}`),
)
for (const call of calls.filter(({ command }) => command.endsWith('hosted_set_session'))) {
assert.equal(call.args.token, 'site.jwt.secret')
}
let rejectSync!: (error: unknown) => void
let syncCalls = 0
syncResponse = () => {
syncCalls++
return new Promise((_, reject) => {
rejectSync = reject
})
}
const originalPage = useHostedSync(() => 'shared-progress-instance')
const pendingSync = originalPage.sync()
const reopenedPage = useHostedSync(() => 'shared-progress-instance')
assert.equal(reopenedPage.busy.value, true)
assert.equal(useHostedSync(() => 'other-instance').busy.value, false)
await reopenedPage.sync()
await new Promise((resolve) => setImmediate(resolve))
assert.equal(syncCalls, 1)
rejectSync({ message: 'download interrupted' })
await pendingSync
assert.equal(reopenedPage.busy.value, false)
assert.match(reopenedPage.error.value, /download interrupted/)
assert.equal(
useHostedSync(() => 'shared-progress-instance').error.value,
reopenedPage.error.value,
)
syncResponse = async () => ({ revision: 'new-version' })
await reopenedPage.sync()
assert.equal(originalPage.error.value, '')
assert.deepEqual(originalPage.result.value, { revision: 'new-version' })
const creation = useHostedCreation()
createdId = 'deleted-instance'
syncResponse = async () => {
throw new Error('download failed')
}
await creation.install()
assert.equal(creation.createdInstance.value, 'deleted-instance')
assert.match(creation.installError.value, /download failed/)
createdId = 'replacement-instance'
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const retryCallStart = calls.length
assert.equal(await creation.install(), 'replacement-instance')
assert.equal(calls[retryCallStart].command, 'plugin:instance|instance_get')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_sync')).at(-1)?.args.instanceId,
'replacement-instance',
)
assert.equal(creation.installError.value, '')
forgetHostedCreation('unrelated-instance')
assert.equal(creation.completed.value, true)
forgetHostedCreation('replacement-instance')
assert.equal(creation.createdInstance.value, undefined)
assert.equal(creation.completed.value, false)
createdId = 'third-instance'
syncResponse = () =>
new Promise((_, reject) => {
rejectSync = reject
})
const deletedDuringSync = creation.install()
await new Promise((resolve) => setImmediate(resolve))
forgetHostedCreation('third-instance')
rejectSync(new Error('Unknown instance'))
await deletedDuringSync
assert.equal(creation.installError.value, '')
assert.equal(creation.createdInstance.value, undefined)
createdId = 'existing-instance'
syncResponse = async () => {
throw new Error('download interrupted')
}
await creation.install()
instanceExists = true
syncResponse = async () => ({
version: '1',
downloadedBytes: 0,
changedFiles: 0,
preservedFiles: [],
})
const createCount = calls.filter((call) => call.command.endsWith('|hosted_create')).length
assert.equal(await creation.install(), 'existing-instance')
assert.equal(
calls.filter((call) => call.command.endsWith('|hosted_create')).length,
createCount,
)
creation.acknowledge('existing-instance')
const requestsBeforeLogout = tokenRequests
resetSkinSiteSession()
await clearHostedSession()
assert.equal(calls.at(-1)!.args.token, null)
await setInstanceMode('instance', 'local')
assert.equal(tokenRequests, requestsBeforeLogout)
const attempts: string[] = []
const stopListening = onHostedPackAttemptStarted((id) => attempts.push(id))
accounts = []
const installCallsBeforeAccountFailure = calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length
await assert.rejects(hostedSync('failed-instance'), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
await assert.rejects(hostedCreate(), {
message: HOSTED_ACCOUNT_REQUIRED_MESSAGE,
})
assert.deepEqual(attempts, [])
assert.equal(
calls.filter(
({ command }) =>
command.startsWith('plugin:install|') &&
command !== 'plugin:install|hosted_has_game_account',
).length,
installCallsBeforeAccountFailure,
)
const unauthenticatedCreation = useHostedCreation()
await unauthenticatedCreation.install()
assert.equal(unauthenticatedCreation.installError.value, HOSTED_ACCOUNT_REQUIRED_MESSAGE)
assert.equal(unauthenticatedCreation.createdInstance.value, undefined)
accounts = [{ account_type: 'microsoft' }]
const unauthenticatedRetry = hostedSync('failed-instance')
await assert.rejects(unauthenticatedRetry)
assert.deepEqual(attempts, ['failed-instance'])
stopListening()
await assert.rejects(hostedSync('failed-instance'))
assert.equal(attempts.length, 1)
await assert.rejects(hostedCreate(), /无需选择玩家/)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
} }
testGlobal.skinSiteTestFetch = async (url, options) => {
calls.push({
path: new URL(url).pathname,
token: (options.headers as Record<string, string>)?.Authorization,
})
return respond(url, options)
}
session.resetSkinSiteSession()
}) })
test('pack download gets the site JWT without choosing a player and rejects stale replies', async () => { test.afterEach(async () => {
const sent: Array<{ type: string; requestId: string }> = [] session.signOutSkinSite()
const frame = { await new Promise((resolve) => setImmediate(resolve))
postMessage(data: { type: string; requestId: string }) { Reflect.deleteProperty(globalThis, 'localStorage')
sent.push(data)
},
} as unknown as Window
const receive = (data: unknown, origin = SKIN_SITE_ORIGIN) =>
receiveSkinSiteMessage(
{
origin,
source: frame,
data,
} as MessageEvent,
frame,
)
resetSkinSiteSession()
setSkinSiteFrame(frame)
await assert.rejects(requestSkinSiteDownloadToken(), /无需选择玩家/)
receive({
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
})
const token = requestSkinSiteDownloadToken()
const requestId = sent.at(-1)!.requestId
const reply = { type: 'starlight-pack-token-result', requestId, token: 'site.jwt.secret' }
assert.equal(receive(reply, 'https://evil.example'), false)
assert.equal(receive(reply), true)
assert.equal(await token, 'site.jwt.secret')
assert.equal(skinSitePlayers.value.length, 0)
assert.equal(JSON.stringify(skinSiteUser.value).includes('secret'), false)
const stale = requestSkinSiteDownloadToken()
const staleId = sent.at(-1)!.requestId
const rejected = assert.rejects(stale, /登录状态已变化/)
resetSkinSiteSession()
await rejected
assert.equal(receive({ ...reply, requestId: staleId }), false)
setSkinSiteFrame(null)
}) })
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => { test('startup renews the stored JWT before loading the user with the new token', async () => {
const frame = {} as Window storage.set(tokenKey, 'old-token')
const message = (status: string, user?: unknown) => await session.initSkinSiteSession()
({ assert.deepEqual(calls.slice(0, 2), [
origin: SKIN_SITE_ORIGIN, { path: '/starlight/user/refresh', token: 'Bearer old-token' },
source: frame, { path: '/starlight/user', token: 'Bearer fresh-token' },
data: { type: 'starlight-skin-session', status, user },
}) as MessageEvent
resetSkinSiteSession()
openSkinSiteLogin()
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/login`)
const signedIn = message('signed-in', {
uuid: 'test-user',
username: '测试用户',
jwt: 'must-not-copy',
})
assert.equal(
receiveSkinSiteMessage({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
false,
)
assert.equal(receiveSkinSiteMessage(signedIn, {} as Window), false)
assert.equal(receiveSkinSiteMessage(message('signed-in', {}), frame), false)
assert.equal(skinSiteUser.value, null)
assert.equal(receiveSkinSiteMessage(signedIn, frame), true)
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
assert.equal(skinSiteStatus.value, 'signed-in')
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
assert.equal(receiveSkinSiteMessage(message('checking'), frame), true)
assert.equal(skinSiteUser.value, null)
receiveSkinSiteMessage(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
receiveSkinSiteMessage(message('signed-out'), frame)
assert.equal(skinSiteUser.value, null)
assert.equal(skinSiteStatus.value, 'signed-out')
receiveSkinSiteMessage(message('error'), frame)
assert.equal(skinSiteStatus.value, 'error')
resetSkinSiteSession()
})
test('luck requests use only the connected skin site frame and validate its result', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
const sessionMessage = {
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'lucky-user', username: 'Lucky' },
},
} as MessageEvent
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(sessionMessage, frame)
const automaticPlayersRequest = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(automaticPlayersRequest.type, 'starlight-skin-players-request')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticPlayersRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteLuck()
assert.equal(sent.at(-1)?.targetOrigin, SKIN_SITE_ORIGIN)
const request = sent.at(-1)?.data as { type: string; requestId: string }
assert.equal(request.type, 'starlight-skin-luck-request')
assert.match(request.requestId, /^skin-luck-\d+-\d+$/)
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-luck-result',
requestId: request.requestId,
ok: true,
luck: 88,
},
} as MessageEvent,
frame,
),
true,
)
assert.equal(await result, 88)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
test('player requests validate identities and replace the collection atomically', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = []
const frame = {
postMessage(data: unknown, targetOrigin: string) {
sent.push({ data, targetOrigin })
},
} as unknown as Window
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
assert.equal(skinSitePlayersStatus.value, 'checking')
assert.equal(
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
{ uuid: 'invalid', name: 'Ignored', isMojang: false },
],
},
} as MessageEvent,
frame,
),
true,
)
assert.deepEqual(skinSitePlayers.value, [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'ready',
headDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
skinDataUrl: 'data:image/png;base64,SEVBRERBVEE=',
model: 'slim',
},
{
uuid: 'fedcba9876543210fedcba9876543210',
name: 'NoSkinPlayer',
isMojang: false,
skinState: 'empty',
},
]) ])
assert.equal(skinSitePlayersStatus.value, 'ready') assert.equal(storage.get(tokenKey), 'fresh-token')
assert.equal(session.skinSiteStatus.value, 'signed-in')
const transientFailure = requestSkinSitePlayers()
const transientFailureRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: transientFailureRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne Renamed',
isMojang: false,
skinState: 'error',
},
],
},
} as MessageEvent,
frame,
)
assert.equal((await transientFailure)[0].skinState, 'ready')
assert.equal(skinSitePlayers.value[0].name, 'PlayerOne Renamed')
assert.equal(skinSitePlayers.value[0].headDataUrl, 'data:image/png;base64,SEVBRERBVEE=')
const retry = requestSkinSitePlayers()
const retryRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: retryRequest.requestId,
ok: true,
players: [],
},
} as MessageEvent,
frame,
)
assert.deepEqual(await retry, [])
assert.deepEqual(skinSitePlayers.value, [])
setSkinSiteFrame(null)
resetSkinSiteSession()
}) })
test('skin updates are restricted to known non-Mojang players and validated results', async () => { test('simultaneous startup consumers share one refresh', async () => {
const sent: Array<{ data: unknown; targetOrigin: string }> = [] storage.set(tokenKey, 'old-token')
const frame = { await Promise.all([
postMessage(data: unknown, targetOrigin: string) { session.initSkinSiteSession(),
sent.push({ data, targetOrigin }) session.initSkinSiteSession(),
}, session.waitForSkinSiteSession(),
} as unknown as Window ])
assert.equal(calls.filter((call) => call.path.endsWith('/refresh')).length, 1)
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'skin-user', username: 'Skin User' },
},
} as MessageEvent,
frame,
)
const automaticRequest = sent.at(-1)?.data as { requestId: string }
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-players-result',
requestId: automaticRequest.requestId,
ok: true,
players: [
{
uuid: '0123456789abcdef0123456789abcdef',
name: 'PlayerOne',
isMojang: false,
skinState: 'empty',
},
],
},
} as MessageEvent,
frame,
)
const result = requestSkinSiteSkinUpdate(
'0123456789abcdef0123456789abcdef',
'data:image/png;base64,SEVBRERBVEE=',
'slim',
)
const request = sent.at(-1)?.data as { type: string; requestId: string; playerId: string }
assert.equal(request.type, 'starlight-skin-update-request')
assert.equal(request.playerId, '0123456789abcdef0123456789abcdef')
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: { type: 'starlight-skin-update-result', requestId: request.requestId, ok: true },
} as MessageEvent,
frame,
)
await result
await assert.rejects(
requestSkinSiteSkinUpdate(
'fedcba9876543210fedcba9876543210',
'data:image/png;base64,SEVBRERBVEE=',
'default',
),
)
setSkinSiteFrame(null)
resetSkinSiteSession()
}) })
test('hosted create auto-authenticates the first skin site player when no game account exists', async () => { test('startup without a stored token does not make authenticated requests', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window') await session.initSkinSiteSession()
const calls: Array<{ command: string; args: Record<string, unknown> }> = [] assert.equal(session.skinSiteStatus.value, 'signed-out')
let accounts: Array<{ assert.deepEqual(calls, [])
account_type: string })
yggdrasil?: { api_root: string }
}> = [] test('an expired token is cleared when renewal is rejected', async () => {
const frame = { storage.set(tokenKey, 'expired-token')
postMessage(data: { type: string; requestId: string }) { respond = async () => Response.json({ errorMessage: 'Expired' }, { status: 401 })
if (data.type === 'starlight-pack-token-request') { await session.initSkinSiteSession()
receiveSkinSiteMessage( assert.equal(storage.has(tokenKey), false)
{ assert.equal(session.skinSiteStatus.value, 'signed-out')
origin: SKIN_SITE_ORIGIN, assert.equal(calls.length, 1)
source: frame, })
data: {
type: 'starlight-pack-token-result', test('temporary renewal failure preserves the token and allows startup retry', async () => {
requestId: data.requestId, storage.set(tokenKey, 'old-token')
token: 'site.jwt.secret', respond = async () => Response.json({ errorMessage: 'Unavailable' }, { status: 503 })
}, await session.initSkinSiteSession()
} as MessageEvent, assert.equal(session.skinSiteStatus.value, 'error')
frame, assert.equal(storage.get(tokenKey), 'old-token')
) respond = async (url) =>
return url.endsWith('/refresh')
} ? ok({ jwt: 'fresh-token' })
if (data.type === 'starlight-skin-players-request') { : url.endsWith('/user')
receiveSkinSiteMessage( ? ok(user)
{ : ok([])
origin: SKIN_SITE_ORIGIN, await session.initSkinSiteSession()
source: frame, assert.equal(session.skinSiteStatus.value, 'signed-in')
data: { })
type: 'starlight-skin-players-result',
requestId: data.requestId, test('a late refresh cannot restore a session after sign-out', async () => {
ok: true, storage.set(tokenKey, 'old-token')
players: [ let finish!: (response: Response) => void
{ respond = () =>
uuid: '11111111-1111-1111-1111-111111111111', new Promise((resolve) => {
name: 'AutoPlayer', finish = resolve
isMojang: false, })
skinState: 'empty', const initializing = session.initSkinSiteSession()
}, await new Promise((resolve) => setImmediate(resolve))
], session.signOutSkinSite()
}, finish(ok({ username: 'Player', jwt: 'late-token' }))
} as MessageEvent, await initializing
frame, assert.equal(storage.has(tokenKey), false)
) assert.equal(session.skinSiteStatus.value, 'signed-out')
} assert.equal(session.skinSiteUser.value, null)
}, })
} as unknown as Window
Object.defineProperty(globalThis, 'window', { test('a malformed refresh response never replaces the saved token', async () => {
configurable: true, storage.set(tokenKey, 'old-token')
value: { respond = async () => ok({ username: 'Player' })
__TAURI_INTERNALS__: { await session.initSkinSiteSession()
async invoke(command: string, args: Record<string, unknown>) { assert.equal(storage.get(tokenKey), 'old-token')
calls.push({ command, args }) assert.equal(session.skinSiteStatus.value, 'error')
if (command === 'plugin:install|hosted_has_game_account') { })
return accounts.some(
(account) => test('a failed sign-in attempt does not invalidate a pending startup renewal', async () => {
account.account_type === 'microsoft' || storage.set(tokenKey, 'old-token')
(account.account_type === 'yggdrasil' && let finish!: (response: Response) => void
account.yggdrasil?.api_root.replace(/\/+$/, '') === respond = async (url) => {
'https://skin.starlight.cool/yggdrasil'), if (url.endsWith('/refresh'))
) return new Promise((resolve) => {
} finish = resolve
if (command === 'plugin:auth|login_skin_site_player') { })
accounts = [ if (url.endsWith('/login'))
{ return Response.json({ errorMessage: 'Invalid password' }, { status: 401 })
account_type: 'yggdrasil', return url.endsWith('/user') ? ok(user) : ok([])
yggdrasil: { api_root: 'https://skin.starlight.cool/yggdrasil' },
},
]
return null
}
if (command === 'plugin:instance|instance_get') return null
return command === 'plugin:install|hosted_create' ? 'auto-instance' : null
},
},
},
})
try {
resetSkinSiteSession()
setSkinSiteFrame(frame)
receiveSkinSiteMessage(
{
origin: SKIN_SITE_ORIGIN,
source: frame,
data: {
type: 'starlight-skin-session',
status: 'signed-in',
user: { uuid: 'site-user', username: 'Site' },
},
} as MessageEvent,
frame,
)
await requestSkinSitePlayers().catch(() => {})
assert.equal(await hostedCreate(), 'auto-instance')
assert.ok(
calls.some(({ command }) => command === 'plugin:auth|login_skin_site_player'),
'expected automatic player authentication',
)
} finally {
resetSkinSiteSession()
setSkinSiteFrame(null)
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else delete (globalThis as { window?: unknown }).window
} }
const initializing = session.initSkinSiteSession()
await new Promise((resolve) => setImmediate(resolve))
await session.signInSkinSite({ username: 'Player', password: 'wrong', clientId: 'captcha' })
finish(ok({ username: 'Player', jwt: 'fresh-token' }))
await initializing
assert.equal(session.skinSiteStatus.value, 'signed-in')
assert.equal(storage.get(tokenKey), 'fresh-token')
})
test('rejection of an old startup token does not discard a new successful login', async () => {
storage.set(tokenKey, 'expired-token')
let finishRefresh!: (response: Response) => void
let finishLogin!: (response: Response) => void
respond = async (url) => {
if (url.endsWith('/refresh'))
return new Promise((resolve) => {
finishRefresh = resolve
})
if (url.endsWith('/login'))
return new Promise((resolve) => {
finishLogin = resolve
})
return url.endsWith('/user') ? ok(user) : ok([])
}
const initializing = session.initSkinSiteSession()
const signingIn = session.signInSkinSite({
username: 'Player',
password: 'valid',
clientId: 'captcha',
})
await new Promise((resolve) => setImmediate(resolve))
finishRefresh(Response.json({ errorMessage: 'Expired' }, { status: 401 }))
await initializing
finishLogin(ok({ username: 'Player', jwt: 'login-token' }))
await signingIn
assert.equal(session.skinSiteStatus.value, 'signed-in')
assert.equal(storage.get(tokenKey), 'login-token')
})
test('explicit sign-out discards a late successful login', async () => {
let finishLogin!: (response: Response) => void
respond = () =>
new Promise((resolve) => {
finishLogin = resolve
})
const signingIn = session.signInSkinSite({
username: 'Player',
password: 'valid',
clientId: 'captcha',
})
await new Promise((resolve) => setImmediate(resolve))
session.signOutSkinSite()
finishLogin(ok({ username: 'Player', jwt: 'late-login-token' }))
await signingIn
assert.equal(session.skinSiteStatus.value, 'signed-out')
assert.equal(storage.has(tokenKey), false)
}) })
@@ -1,7 +1,25 @@
import { readonly, ref, watch } from 'vue' import { readonly, ref } from 'vue'
import {
clearSkinSiteToken,
getSkinSiteToken,
setSkinSiteToken,
skinSiteCurrentUser,
skinSiteLogin,
skinSiteLuck,
skinSitePlayerList,
type SkinSitePlayerSkin,
skinSitePlayerSkin,
skinSiteRefresh,
type SkinSiteResponse,
skinSiteUploadTexture,
type SkinSiteUser as ApiSkinSiteUser,
} from '@/helpers/skin-site-api'
import { fetchImageAsDataUrl, renderSkinHead } from '@/helpers/skin-site-render'
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool' export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
export type SkinSiteUser = { uuid: string; username: string }
export type SkinSiteUser = { uuid: string; username: string; permissions: string[] }
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error' export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
export type SkinSitePlayer = { export type SkinSitePlayer = {
uuid: string uuid: string
@@ -15,152 +33,290 @@ export type SkinSitePlayer = {
export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error' export type SkinSitePlayersStatus = 'idle' | 'checking' | 'ready' | 'error'
const user = ref<SkinSiteUser | null>(null) const user = ref<SkinSiteUser | null>(null)
const status = ref<SkinSiteStatus>('signed-out') const status = ref<SkinSiteStatus>('checking')
const players = ref<SkinSitePlayer[]>([]) const players = ref<SkinSitePlayer[]>([])
const playersStatus = ref<SkinSitePlayersStatus>('idle') const playersStatus = ref<SkinSitePlayersStatus>('idle')
const selectedPlayerId = ref<string | null>(null) const selectedPlayerId = ref<string | null>(null)
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`) let playersRequest: Promise<SkinSitePlayer[]> | null = null
let connectedFrame: Window | null = null
let luckRequestSequence = 0
let playersRequestSequence = 0
let skinUpdateRequestSequence = 0
let packTokenRequestSequence = 0
const pendingPackTokens = new Map<
string,
{
resolve: (token: string) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
>()
function rejectPendingPackTokens() {
for (const request of pendingPackTokens.values()) {
clearTimeout(request.timeout)
request.reject(new Error('StarLight 登录状态已变化,请重试。'))
}
pendingPackTokens.clear()
}
export function requestSkinSiteDownloadToken(): Promise<string> {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(
new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。无需选择玩家。'),
)
}
const requestId = `pack-token-${Date.now()}-${++packTokenRequestSequence}`
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPackTokens.delete(requestId)
reject(new Error('获取 StarLight 登录凭据超时,请重试。'))
}, 10_000)
pendingPackTokens.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-pack-token-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch {
clearTimeout(timeout)
pendingPackTokens.delete(requestId)
reject(new Error('无法读取 StarLight 登录状态,请重新登录。'))
}
})
}
type PendingLuckRequest = {
resolve: (luck: number) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingLuckRequests = new Map<string, PendingLuckRequest>()
type PendingPlayersRequest = {
resolve: (players: SkinSitePlayer[]) => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingPlayersRequests = new Map<string, PendingPlayersRequest>()
type PendingSkinUpdateRequest = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
const pendingSkinUpdateRequests = new Map<string, PendingSkinUpdateRequest>()
function isSafePngDataUrl(value: unknown, maxLength: number) {
return (
typeof value === 'string' &&
value.length <= maxLength &&
/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(value)
)
}
function hasSafeSkinHead(player: SkinSitePlayer) {
if (!['ready', 'empty', 'error'].includes(player.skinState)) return false
if (player.skinState !== 'ready') return player.headDataUrl === undefined
return (
isSafePngDataUrl(player.headDataUrl, 100_000) &&
(player.skinDataUrl === undefined || isSafePngDataUrl(player.skinDataUrl, 2_000_000)) &&
(player.model === undefined || player.model === 'default' || player.model === 'slim')
)
}
export const skinSiteUser = readonly(user) export const skinSiteUser = readonly(user)
export const skinSiteStatus = readonly(status) export const skinSiteStatus = readonly(status)
export const skinSitePlayers = readonly(players) export const skinSitePlayers = readonly(players)
export const skinSitePlayersStatus = readonly(playersStatus) export const skinSitePlayersStatus = readonly(playersStatus)
export const selectedSkinSitePlayerId = readonly(selectedPlayerId) export const selectedSkinSitePlayerId = readonly(selectedPlayerId)
export const skinSiteFrameUrl = readonly(frameUrl)
export async function waitForSkinSiteSession() { function toUser(raw: ApiSkinSiteUser): SkinSiteUser {
if (status.value !== 'checking') return return {
await new Promise<void>((resolve, reject) => { uuid: raw.uuid,
const stop = watch(status, next => { username: raw.username,
if (next === 'checking') return permissions: Array.isArray(raw.permissions) ? raw.permissions : [],
clearTimeout(timer) }
stop() }
resolve()
function clearSessionState() {
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
// ---------------------------------------------------------------------------
// Session lifecycle
// ---------------------------------------------------------------------------
let initialized: Promise<void> | null = null
let sessionRevision = 0
let loginRevision = 0
let refreshRequest: { token: string; revision: number; promise: Promise<boolean> } | null = null
/** Share renewals and discard results from a session that has since changed. */
function renewSkinSiteToken(): Promise<boolean> {
const token = getSkinSiteToken()
if (!token) return Promise.resolve(false)
const revision = sessionRevision
if (refreshRequest?.token === token && refreshRequest.revision === revision) {
return refreshRequest.promise
}
const request = {
token,
revision,
promise: (async () => {
let result: SkinSiteResponse<{ username: string; jwt: string }>
try {
result = await skinSiteRefresh()
} catch {
return false
}
if (revision !== sessionRevision || getSkinSiteToken() !== token) return false
if (result.success) {
if (typeof result.payload?.jwt !== 'string' || !result.payload.jwt.trim()) return false
setSkinSiteToken(result.payload.jwt)
return true
}
if (result.errorCode === 401 || result.errorCode === 403) invalidateSkinSiteSession()
return false
})(),
}
refreshRequest = request
const clearRequest = () => {
if (refreshRequest === request) refreshRequest = null
}
void request.promise.then(clearRequest, clearRequest)
return request.promise
}
/**
* Renew the saved JWT on each launcher startup before loading the user.
* Safe to call repeatedly;
* the first call performs the actual work and later ones reuse it.
*/
export function initSkinSiteSession(): Promise<void> {
if (initialized) return initialized
const revision = sessionRevision
initialized = (async () => {
const token = getSkinSiteToken()
if (!token) {
status.value = 'signed-out'
return
}
status.value = 'checking'
if (!(await renewSkinSiteToken())) {
if (revision === sessionRevision) status.value = 'error'
return
}
if (revision !== sessionRevision) return
const result = await skinSiteCurrentUser()
if (revision !== sessionRevision) return
if (result.success) {
user.value = toUser(result.payload)
status.value = 'signed-in'
void requestSkinSitePlayers().catch(() => {})
} else if (result.errorCode === 401 || result.errorCode === 403) {
clearSkinSiteToken()
clearSessionState()
status.value = 'signed-out'
} else {
status.value = 'error'
}
})()
.catch(() => {
if (revision === sessionRevision) status.value = 'error'
}) })
const timer = setTimeout(() => { .finally(() => {
stop() if (revision === sessionRevision && status.value === 'error') initialized = null
reject(new Error('皮肤站登录状态仍在检查,请稍后重试。')) })
}, 10_000) return initialized
})
} }
function rejectPendingLuckRequests(message: string) { /** Force a re-check (e.g. after the user signs in from another view). */
for (const request of pendingLuckRequests.values()) { export function reloadSkinSiteSession(): Promise<void> {
clearTimeout(request.timeout) sessionRevision++
request.reject(new Error(message)) initialized = null
return initSkinSiteSession()
}
export async function signInSkinSite(payload: {
username: string
password: string
clientId: string
}): Promise<SkinSiteResponse<{ username: string; jwt: string }>> {
const attemptRevision = ++loginRevision
const result = await skinSiteLogin(payload)
if (result.success && attemptRevision === loginRevision) {
const revision = ++sessionRevision
initialized = null
setSkinSiteToken(result.payload.jwt)
const current = await skinSiteCurrentUser()
if (revision !== sessionRevision) return result
if (current.success) {
user.value = toUser(current.payload)
status.value = 'signed-in'
void requestSkinSitePlayers().catch(() => {})
} else {
user.value = { uuid: '', username: result.payload.username, permissions: [] }
status.value = 'signed-in'
}
} }
pendingLuckRequests.clear() return result
} }
function rejectPendingPlayersRequests(message: string) { /** Reject an old credential without cancelling a newer explicit login attempt. */
for (const request of pendingPlayersRequests.values()) { function invalidateSkinSiteSession() {
clearTimeout(request.timeout) sessionRevision++
request.reject(new Error(message)) refreshRequest = null
clearSkinSiteToken()
clearSessionState()
status.value = 'signed-out'
initialized = null
}
export function signOutSkinSite() {
loginRevision++
invalidateSkinSiteSession()
}
/** Refresh the JWT; returns true when a new token was stored. */
export async function refreshSkinSiteSession(): Promise<boolean> {
if (status.value !== 'signed-in') return false
return renewSkinSiteToken()
}
// ---------------------------------------------------------------------------
// Compatibility shims for the old iframe-based API
// ---------------------------------------------------------------------------
/** @deprecated The session is no longer driven by an iframe. No-op. */
export function setSkinSiteFrame(_frame: Window | null) {}
/** @deprecated The session is no longer driven by an iframe. No-op. */
export function receiveSkinSiteMessage(_event: MessageEvent, _frame: Window | null): boolean {
return false
}
/** @deprecated Use the in-app login view instead. No-op. */
export function openSkinSiteLogin() {}
export function resetSkinSiteSession() {
loginRevision++
sessionRevision++
refreshRequest = null
clearSessionState()
status.value = getSkinSiteToken() ? 'checking' : 'signed-out'
initialized = null
}
/** Resolves once the initial session check has settled. */
export async function waitForSkinSiteSession(): Promise<void> {
await initSkinSiteSession()
}
// ---------------------------------------------------------------------------
// Download token (the launcher's hosted pack downloader)
// ---------------------------------------------------------------------------
/**
* The hosted pack downloader authenticates with the skin site JWT. We hold it
* locally now, so this simply surfaces it (refreshing once if it is missing).
*/
export async function requestSkinSiteDownloadToken(): Promise<string> {
if (status.value !== 'signed-in') {
await initSkinSiteSession()
} }
pendingPlayersRequests.clear() if (status.value !== 'signed-in') {
} throw new Error('请先在启动器中登录 StarLight 皮肤站,再下载整合包。')
function rejectPendingSkinUpdateRequests(message: string) {
for (const request of pendingSkinUpdateRequests.values()) {
clearTimeout(request.timeout)
request.reject(new Error(message))
} }
pendingSkinUpdateRequests.clear() let token = getSkinSiteToken()
if (!token) {
if (await refreshSkinSiteSession()) token = getSkinSiteToken()
}
if (!token) throw new Error('StarLight 登录凭据不可用,请重新登录。')
return token
} }
export function setSkinSiteFrame(frame: Window | null) { // ---------------------------------------------------------------------------
if (frame && !user.value) status.value = 'checking' // Players
if (connectedFrame === frame) return // ---------------------------------------------------------------------------
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site connection changed.') async function buildPlayer(entry: {
rejectPendingPlayersRequests('The skin site connection changed.') uuid: string
rejectPendingSkinUpdateRequests('The skin site connection changed.') name: string
connectedFrame = frame isMojang: boolean
}): Promise<SkinSitePlayer> {
const base: SkinSitePlayer = {
uuid: entry.uuid,
name: entry.name,
isMojang: entry.isMojang,
skinState: 'empty',
}
const skinResult = await skinSitePlayerSkin(entry.uuid)
if (!skinResult.success) return { ...base, skinState: 'error' }
const texture: SkinSitePlayerSkin = skinResult.payload ?? {}
if (!texture.skin) return base
try {
const [headDataUrl, skinDataUrl] = await Promise.all([
renderSkinHead(texture.skin),
fetchImageAsDataUrl(texture.skin),
])
return {
...base,
skinState: 'ready',
headDataUrl,
skinDataUrl,
model: texture.model === 'slim' ? 'slim' : 'default',
}
} catch {
return { ...base, skinState: 'error' }
}
}
export function requestSkinSitePlayers(): Promise<SkinSitePlayer[]> {
if (playersRequest) return playersRequest
if (status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('请先登录 StarLight 皮肤站。'))
}
playersStatus.value = 'checking'
playersRequest = (async () => {
try {
const list = await skinSitePlayerList()
if (!list.success) throw new Error(list.errorMessage)
const built = await Promise.all(list.payload.map(buildPlayer))
players.value = built
if (selectedPlayerId.value && !built.some((p) => p.uuid === selectedPlayerId.value)) {
selectedPlayerId.value = null
}
playersStatus.value = 'ready'
return players.value
} catch (error) {
playersStatus.value = 'error'
throw error
} finally {
playersRequest = null
}
})()
return playersRequest
} }
export function selectSkinSitePlayer(playerId: string | null) { export function selectSkinSitePlayer(playerId: string | null) {
@@ -171,267 +327,59 @@ export function selectSkinSitePlayer(playerId: string | null) {
if (players.value.some((player) => player.uuid === playerId)) selectedPlayerId.value = playerId if (players.value.some((player) => player.uuid === playerId)) selectedPlayerId.value = playerId
} }
export function openSkinSiteLogin() { // ---------------------------------------------------------------------------
frameUrl.value = `${SKIN_SITE_ORIGIN}/login` // Luck
} // ---------------------------------------------------------------------------
export function resetSkinSiteSession() { export async function requestSkinSiteLuck(): Promise<number> {
rejectPendingPackTokens() if (status.value !== 'signed-in') throw new Error('请先登录 StarLight 皮肤站。')
rejectPendingLuckRequests('The skin site session ended.') const result = await skinSiteLuck()
rejectPendingPlayersRequests('The skin site session ended.') if (!result.success) throw new Error(result.errorMessage)
rejectPendingSkinUpdateRequests('The skin site session ended.') return result.payload.luck
user.value = null
status.value = 'signed-out'
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
} }
// ---------------------------------------------------------------------------
// Legacy compatibility
// ---------------------------------------------------------------------------
export function requestSkinSiteSkinUpdate( /**
* Replace a player's skin texture.
*
* The caller hands us a base64 PNG (what the in-app editor produces); we turn
* it back into a File and upload it through the skin site's texture endpoint.
*/
export async function requestSkinSiteSkinUpdate(
playerId: string, playerId: string,
textureDataUrl: string, textureDataUrl: string,
model: 'default' | 'slim', model: 'default' | 'slim',
) { ): Promise<void> {
if (status.value !== 'signed-in' || !user.value) {
throw new Error('请先登录 StarLight 皮肤站。')
}
const player = players.value.find((candidate) => candidate.uuid === playerId) const player = players.value.find((candidate) => candidate.uuid === playerId)
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before changing a skin.'))
}
if (!player || player.isMojang) { if (!player || player.isMojang) {
return Promise.reject(new Error('This skin site player cannot be changed.')) throw new Error('该玩家无法修改皮肤。')
} }
if (!isSafePngDataUrl(textureDataUrl, 2_000_000)) { if (!/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i.test(textureDataUrl)) {
return Promise.reject(new Error('The selected skin texture is invalid or too large.')) throw new Error('皮肤图片无效或过大。')
} }
const requestId = `skin-update-${Date.now()}-${++skinUpdateRequestSequence}` const file = await dataUrlToFile(textureDataUrl, `${player.name}.png`)
return new Promise<void>((resolve, reject) => { const result = await skinSiteUploadTexture(playerId, file, 'SKIN', { model })
const timeout = setTimeout(() => { if (!result.success) throw new Error(result.errorMessage)
pendingSkinUpdateRequests.delete(requestId)
reject(new Error('The skin site update timed out.')) const refreshed = await skinSitePlayerSkin(playerId)
}, 20_000) if (refreshed.success && refreshed.payload?.skin) {
pendingSkinUpdateRequests.set(requestId, { resolve, reject, timeout }) const updated = await buildPlayer({
try { uuid: player.uuid,
connectedFrame?.postMessage( name: player.name,
{ isMojang: player.isMojang,
type: 'starlight-skin-update-request', })
requestId, players.value = players.value.map((entry) => (entry.uuid === playerId ? updated : entry))
playerId, }
textureDataUrl,
model,
},
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingSkinUpdateRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
} }
export function requestSkinSitePlayers() { async function dataUrlToFile(dataUrl: string, filename: string): Promise<File> {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) { const response = await fetch(dataUrl)
return Promise.reject(new Error('Sign in to the skin site before requesting players.')) const blob = await response.blob()
} return new File([blob], filename, { type: 'image/png' })
playersStatus.value = 'checking'
const requestId = `skin-players-${Date.now()}-${++playersRequestSequence}`
return new Promise<SkinSitePlayer[]>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(new Error('The skin site player request timed out.'))
}, 30_000)
pendingPlayersRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-players-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingPlayersRequests.delete(requestId)
playersStatus.value = 'error'
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function requestSkinSiteLuck() {
if (!connectedFrame || status.value !== 'signed-in' || !user.value) {
return Promise.reject(new Error('Sign in to the skin site before requesting luck.'))
}
const requestId = `skin-luck-${Date.now()}-${++luckRequestSequence}`
return new Promise<number>((resolve, reject) => {
const timeout = setTimeout(() => {
pendingLuckRequests.delete(requestId)
reject(new Error('The skin site luck request timed out.'))
}, 12_000)
pendingLuckRequests.set(requestId, { resolve, reject, timeout })
try {
connectedFrame?.postMessage(
{ type: 'starlight-skin-luck-request', requestId },
SKIN_SITE_ORIGIN,
)
} catch (error) {
clearTimeout(timeout)
pendingLuckRequests.delete(requestId)
reject(error instanceof Error ? error : new Error(String(error)))
}
})
}
export function receiveSkinSiteMessage(event: MessageEvent, frame: Window | null) {
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
const data = event.data
if (!data || typeof data !== 'object') return false
if (data.type === 'starlight-pack-token-result') {
const pending = pendingPackTokens.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPackTokens.delete(data.requestId)
if (
status.value === 'signed-in' &&
typeof data.token === 'string' &&
data.token.length > 0 &&
data.token.length <= 16_384 &&
!/\s/.test(data.token)
) {
pending.resolve(data.token)
} else pending.reject(new Error('StarLight 登录凭据不可用,请重新登录。'))
return true
}
if (data.type === 'starlight-skin-players-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingPlayersRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingPlayersRequests.delete(data.requestId)
if (data.ok === true && Array.isArray(data.players)) {
const nextPlayers = data.players.filter(
(player: unknown): player is SkinSitePlayer =>
typeof player === 'object' &&
player !== null &&
typeof (player as SkinSitePlayer).uuid === 'string' &&
/^[0-9a-f-]{32,36}$/i.test((player as SkinSitePlayer).uuid) &&
typeof (player as SkinSitePlayer).name === 'string' &&
(player as SkinSitePlayer).name.length > 0 &&
(player as SkinSitePlayer).name.length <= 64 &&
typeof (player as SkinSitePlayer).isMojang === 'boolean' &&
hasSafeSkinHead(player as SkinSitePlayer),
)
const previousPlayers = new Map(players.value.map((player) => [player.uuid, player]))
players.value = nextPlayers.map((player) => {
const previous = previousPlayers.get(player.uuid)
return player.skinState === 'error' && previous?.skinState === 'ready'
? { ...player, ...previous, name: player.name, isMojang: player.isMojang }
: player
})
if (
selectedPlayerId.value &&
!nextPlayers.some((player) => player.uuid === selectedPlayerId.value)
) {
selectedPlayerId.value = null
}
playersStatus.value = 'ready'
pending.resolve(players.value)
} else {
playersStatus.value = 'error'
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid player list.',
),
)
}
return true
}
if (data.type === 'starlight-skin-luck-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingLuckRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingLuckRequests.delete(data.requestId)
if (
data.ok === true &&
typeof data.luck === 'number' &&
Number.isFinite(data.luck) &&
data.luck >= 0 &&
data.luck <= 100
) {
pending.resolve(data.luck)
} else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site returned an invalid luck result.',
),
)
}
return true
}
if (data.type === 'starlight-skin-update-result') {
if (typeof data.requestId !== 'string') return false
const pending = pendingSkinUpdateRequests.get(data.requestId)
if (!pending) return false
clearTimeout(pending.timeout)
pendingSkinUpdateRequests.delete(data.requestId)
if (data.ok === true) pending.resolve()
else {
pending.reject(
new Error(
typeof data.error === 'string' && data.error
? data.error.slice(0, 300)
: 'The skin site rejected the skin update.',
),
)
}
return true
}
if (data.type !== 'starlight-skin-session') return false
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
let refreshPlayers = false
if (data.status === 'signed-in') {
if (
!data.user ||
typeof data.user.uuid !== 'string' ||
typeof data.user.username !== 'string' ||
!data.user.uuid ||
!data.user.username ||
data.user.username.length > 256
)
return false
if (user.value?.uuid && user.value.uuid !== data.user.uuid) {
rejectPendingPackTokens()
rejectPendingPlayersRequests('The skin site account changed.')
rejectPendingSkinUpdateRequests('The skin site account changed.')
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
user.value = { uuid: data.user.uuid, username: data.user.username }
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
}
refreshPlayers = true
} else {
rejectPendingPackTokens()
rejectPendingLuckRequests('The skin site session is unavailable.')
rejectPendingPlayersRequests('The skin site session is unavailable.')
rejectPendingSkinUpdateRequests('The skin site session is unavailable.')
user.value = null
players.value = []
playersStatus.value = 'idle'
selectedPlayerId.value = null
}
status.value = data.status
if (refreshPlayers) void requestSkinSitePlayers().catch(() => {})
return true
} }
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
hostedDownloadActions,
hostedDownloadState,
runHostedDownloadAction,
runHostedDownloadControl,
} from './hosted-download-controls.ts'
test('only a controllable parent hosted task offers pause, resume and stop', () => {
const bar = {
bar_type: {
type: 'hosted_pack_sync',
instance_id: 'one',
state: 'running' as const,
can_pause: true,
can_cancel: true,
},
}
assert.deepEqual(hostedDownloadActions(bar), ['pause', 'cancel'])
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state: 'paused' } }), [
'resume',
'cancel',
])
for (const state of ['finalizing', 'canceling'] as const) {
assert.deepEqual(hostedDownloadActions({ bar_type: { ...bar.bar_type, state } }), [])
}
assert.deepEqual(
hostedDownloadActions({ bar_type: { ...bar.bar_type, type: 'hosted_mod_download' } }),
[],
)
assert.deepEqual(
hostedDownloadActions({ bar_type: { type: 'hosted_pack_sync', instance_id: 'one' } }),
[],
)
})
test('failed and canceled hosted tasks offer direct retry, preserving terminal state', () => {
for (const state of ['failed', 'canceled'] as const) {
const bar = {
bar_type: { type: 'hosted_pack_sync', instance_id: 'one', state, error: 'failure' },
}
assert.deepEqual(hostedDownloadActions(bar), ['retry'])
assert.equal(hostedDownloadState(bar), state)
}
assert.equal(hostedDownloadState({ bar_type: { error: 'old backend failure' } }), 'failed')
})
test('resume refreshes the session before invoking controls; pause and stop need no session', async () => {
const calls: string[] = []
const deps = {
prepareSession: async () => {
calls.push('session')
},
invoke: async (command: string, args: { instanceId: string }) => {
calls.push(`${command}:${args.instanceId}`)
},
}
await runHostedDownloadControl('resume', 'one', deps)
await runHostedDownloadControl('pause', 'one', deps)
await runHostedDownloadControl('cancel', 'one', deps)
assert.deepEqual(calls, [
'session',
'plugin:install|hosted_resume:one',
'plugin:install|hosted_pause:one',
'plugin:install|hosted_cancel:one',
])
})
test('session refresh failures prevent resume', async () => {
await assert.rejects(
runHostedDownloadControl('resume', 'one', {
prepareSession: async () => {
throw new Error('signed out')
},
invoke: async () => {
assert.fail('resume must not run')
},
}),
/signed out/,
)
})
test('retry immediately restarts the existing hosted instance through the manager', async () => {
const calls: string[] = []
await runHostedDownloadAction('retry', 'existing-instance', {
retryHosted: async (instanceId) => {
calls.push(instanceId)
},
control: async () => {
assert.fail('retry must use the full hosted transaction')
},
})
assert.deepEqual(calls, ['existing-instance'])
})
@@ -0,0 +1,45 @@
import type { LoadingBar } from './state.ts'
export type HostedDownloadControl = 'pause' | 'resume' | 'cancel'
export type HostedDownloadAction = HostedDownloadControl | 'retry'
export function runHostedDownloadAction(
action: HostedDownloadAction,
instanceId: string,
operations: {
retryHosted: (instanceId: string) => Promise<void>
control: (action: HostedDownloadControl, instanceId: string) => Promise<void>
},
) {
return action === 'retry'
? operations.retryHosted(instanceId)
: operations.control(action, instanceId)
}
export function hostedDownloadState(bar: LoadingBar) {
return bar.bar_type?.state ?? (bar.bar_type?.error ? 'failed' : 'running')
}
export function hostedDownloadActions(bar: LoadingBar): HostedDownloadAction[] {
const type = bar.bar_type
if (type?.type !== 'hosted_pack_sync' || !type.instance_id) return []
const state = hostedDownloadState(bar)
if (state === 'failed' || state === 'canceled') return ['retry']
if (state !== 'running' && state !== 'paused') return []
const actions: HostedDownloadAction[] = []
if (type.can_pause) actions.push(state === 'paused' ? 'resume' : 'pause')
if (type.can_cancel) actions.push('cancel')
return actions
}
export async function runHostedDownloadControl(
action: HostedDownloadControl,
instanceId: string,
dependencies: {
prepareSession: () => Promise<void>
invoke: (command: string, args: { instanceId: string }) => Promise<void>
},
) {
if (action === 'resume') await dependencies.prepareSession()
await dependencies.invoke(`plugin:install|hosted_${action}`, { instanceId })
}
@@ -3,6 +3,27 @@ import test from 'node:test'
import { createHostedDownloadFailures } from './hosted-download-failures.ts' import { createHostedDownloadFailures } from './hosted-download-failures.ts'
test('stopped hosted downloads retain their state and instance for direct retry', () => {
const state = createHostedDownloadFailures()
state.update({
fraction: null,
loader_uuid: 'stopped',
event: {
type: 'hosted_pack_sync',
instance_id: 'existing-instance',
state: 'canceled',
error: 'Stopped by user',
can_pause: false,
can_cancel: false,
},
})
assert.equal(state.values()[0]?.bar_type?.state, 'canceled')
assert.equal(state.values()[0]?.bar_type?.instance_id, 'existing-instance')
state.begin('existing-instance', state.values())
assert.equal(state.has('existing-instance'), false)
assert.equal(state.isRetired('stopped'), true)
})
test('retry clears only its own failure before progress arrives and ignores late old events', () => { test('retry clears only its own failure before progress arrives and ignores late old events', () => {
const state = createHostedDownloadFailures() const state = createHostedDownloadFailures()
const failed = (instanceId: string, loader: string, error: string) => ({ const failed = (instanceId: string, loader: string, error: string) => ({
@@ -8,6 +8,7 @@ import {
skinSiteUser, skinSiteUser,
waitForSkinSiteSession, waitForSkinSiteSession,
} from '../composables/skin-site-session.ts' } from '../composables/skin-site-session.ts'
import { type HostedDownloadControl, runHostedDownloadControl } from './hosted-download-controls.ts'
import { authenticateInstancePlayer } from './instance-player.ts' import { authenticateInstancePlayer } from './instance-player.ts'
export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作' export const HOSTED_ACCOUNT_REQUIRED_MESSAGE = '您需要登录正版或皮肤站账号后才能操作'
@@ -146,6 +147,11 @@ export const hostedCreate = async (gameDirRoot?: string | null) => {
} }
export const hostedBinding = (instanceId: string) => export const hostedBinding = (instanceId: string) =>
invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId }) invokeHosted<HostedBinding | null>('plugin:install|hosted_binding', { instanceId })
export const controlHostedDownload = (action: HostedDownloadControl, instanceId: string) =>
runHostedDownloadControl(action, instanceId, {
prepareSession: prepareHostedSession,
invoke: invokeHosted<void>,
})
export const hostedSync = async (instanceId: string) => { export const hostedSync = async (instanceId: string) => {
await requireHostedGameAccount() await requireHostedGameAccount()
startHostedPackAttempt(instanceId) startHostedPackAttempt(instanceId)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,86 @@
/**
* Renders a player's skin into a base64 head image, mirroring the skin site's
* own head cropper (front face at 8,8 plus the hat overlay at 40,8).
*
* The skin image is loaded through the Tauri HTTP client first so cross-origin
* textures do not taint the canvas, then drawn once as a data URL.
*/
const HEAD_BUFFER_SIZE = 18
const HEAD_FACE = { sx: 8, sy: 8, sw: 8, sh: 8, dx: 1, dy: 1, dw: 16, dh: 16 }
const HEAD_HAT = { sx: 40, sy: 8, sw: 8, sh: 8, dx: 0, dy: 0, dw: 18, dh: 18 }
let buffer: HTMLCanvasElement | null = null
function getBuffer(): { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D } {
if (!buffer) buffer = document.createElement('canvas')
const ctx = buffer.getContext('2d')
if (!ctx) throw new Error('无法创建皮肤渲染画布')
return { canvas: buffer, ctx }
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve(img)
img.onerror = () => reject(new Error('无法加载皮肤图片'))
img.src = src
})
}
/** Crop the head (and hat overlay) out of a skin image URL. Returns a data URL. */
export async function renderSkinHead(skinUrl: string, scale = 2): Promise<string> {
const img = await loadImage(skinUrl)
const { canvas, ctx } = getBuffer()
canvas.width = HEAD_BUFFER_SIZE
canvas.height = HEAD_BUFFER_SIZE
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.imageSmoothingEnabled = false
ctx.drawImage(
img,
HEAD_FACE.sx,
HEAD_FACE.sy,
HEAD_FACE.sw,
HEAD_FACE.sh,
HEAD_FACE.dx,
HEAD_FACE.dy,
HEAD_FACE.dw,
HEAD_FACE.dh,
)
ctx.globalCompositeOperation = 'source-over'
ctx.drawImage(
img,
HEAD_HAT.sx,
HEAD_HAT.sy,
HEAD_HAT.sw,
HEAD_HAT.sh,
HEAD_HAT.dx,
HEAD_HAT.dy,
HEAD_HAT.dw,
HEAD_HAT.dh,
)
if (scale <= 1) return canvas.toDataURL('image/png')
const small = await createImageBitmap(canvas)
canvas.width = Math.floor(HEAD_BUFFER_SIZE * scale)
canvas.height = Math.floor(HEAD_BUFFER_SIZE * scale)
ctx.imageSmoothingEnabled = false
ctx.drawImage(small, 0, 0, HEAD_BUFFER_SIZE, HEAD_BUFFER_SIZE, 0, 0, canvas.width, canvas.height)
return canvas.toDataURL('image/png')
}
/** Fetch an image URL as a data URL (used to keep full skin textures local). */
export async function fetchImageAsDataUrl(url: string): Promise<string> {
const response = await fetch(url)
if (!response.ok) throw new Error(`无法加载图片(HTTP ${response.status})`)
const blob = await response.blob()
return await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result ?? ''))
reader.onerror = () => reject(new Error('无法读取图片数据'))
reader.readAsDataURL(blob)
})
}
+9 -1
View File
@@ -9,6 +9,9 @@ export interface LoadingBarType {
batch_id?: string batch_id?: string
file_name?: string file_name?: string
error?: string | null error?: string | null
state?: 'running' | 'paused' | 'canceling' | 'canceled' | 'failed' | 'finalizing'
can_pause?: boolean
can_cancel?: boolean
type?: string type?: string
version?: string version?: string
instance_id?: string instance_id?: string
@@ -28,7 +31,12 @@ export interface LoadingBar {
} }
export type OpeningCommandEvent = export type OpeningCommandEvent =
'RunMRPack' | 'InstallServer' | 'InstallVersion' | 'InstallMod' | 'InstallModpack' | string | 'RunMRPack'
| 'InstallServer'
| 'InstallVersion'
| 'InstallMod'
| 'InstallModpack'
| string
export interface OpeningCommand { export interface OpeningCommand {
event: OpeningCommandEvent event: OpeningCommandEvent
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+66 -18
View File
@@ -84,7 +84,7 @@
</TagItem> </TagItem>
<Badge <Badge
:color="bar.bar_type?.error ? 'red' : 'orange'" :color="bar.bar_type?.error ? 'red' : 'orange'"
:type="statusLabel(bar.bar_type?.error ? 'failed' : 'running')" :type="statusLabel(hostedDownloadState(bar))"
/> />
</div> </div>
<ProgressBar <ProgressBar
@@ -94,16 +94,35 @@
:progress="legacyPercent(bar)" :progress="legacyPercent(bar)"
:max="100" :max="100"
:label="formatMessage(messages.progress)" :label="formatMessage(messages.progress)"
:waiting="!bar.total" :waiting="!bar.total && hostedDownloadState(bar) !== 'paused'"
:show-progress="Boolean(bar.total)" :show-progress="Boolean(bar.total)"
/> />
<RouterLink <div
v-else v-if="hostedDownloadActions(bar).length"
:to="hostedRetryRoute(bar.bar_type?.instance_id ?? '')" class="mt-3 flex flex-wrap items-center gap-2"
class="mt-3 inline-block text-brand hover:underline"
> >
{{ formatMessage(messages.hostedRetry) }} <ButtonStyled
</RouterLink> v-for="action in hostedDownloadActions(bar)"
:key="action"
:color="action === 'retry' ? 'brand' : action === 'cancel' ? 'red' : 'standard'"
:type="action === 'retry' ? 'standard' : 'outlined'"
size="small"
>
<button
:disabled="busy.has(hostedBusyKey(bar, action))"
@click="hostedAction(bar, action)"
>
{{ formatMessage(hostedActionMessages[action]) }}
</button>
</ButtonStyled>
<RouterLink
v-if="bar.bar_type?.error"
:to="hostedRetryRoute(bar.bar_type.instance_id ?? '')"
class="text-brand hover:underline"
>
{{ formatMessage(messages.openInstance) }}
</RouterLink>
</div>
</Card> </Card>
<Card <Card
@@ -471,7 +490,14 @@ import { useRoute, useRouter } from 'vue-router'
import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue' import MissingModpackContentModal from '@/components/ui/modal/MissingModpackContentModal.vue'
import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge' import { listPendingCurseForgeManualDownloads } from '@/helpers/curseforge'
import type { CurseForgeManualDownloadItem } from '@/helpers/curseforge-manual' import type { CurseForgeManualDownloadItem } from '@/helpers/curseforge-manual'
import {
type HostedDownloadAction,
hostedDownloadActions,
hostedDownloadState,
runHostedDownloadAction,
} from '@/helpers/hosted-download-controls'
import { hostedRetryRoute } from '@/helpers/hosted-install-retry' import { hostedRetryRoute } from '@/helpers/hosted-install-retry'
import { controlHostedDownload } from '@/helpers/hosted-packs'
import { import {
download_job_support_details, download_job_support_details,
type InstallJobSnapshot, type InstallJobSnapshot,
@@ -517,10 +543,6 @@ const focusedJobId = computed(() => focusedDownloadJobId(route.query.job))
const focusState = ref(createDownloadFocusState(focusedJobId.value)) const focusState = ref(createDownloadFocusState(focusedJobId.value))
const messages = defineMessages({ const messages = defineMessages({
hostedRetry: {
id: 'app.hosted-packs.progress.retry',
defaultMessage: 'Return to the instance to retry',
},
newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' }, newDownload: { id: 'app.downloads.new-download', defaultMessage: 'New download' },
inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' }, inProgress: { id: 'app.downloads.in-progress', defaultMessage: 'In progress' },
history: { id: 'app.downloads.history', defaultMessage: 'History' }, history: { id: 'app.downloads.history', defaultMessage: 'History' },
@@ -653,7 +675,15 @@ const messages = defineMessages({
unknownPhase: { id: 'app.downloads.phase.unknown', defaultMessage: 'Working' }, unknownPhase: { id: 'app.downloads.phase.unknown', defaultMessage: 'Working' },
}) })
const hostedActionMessages = defineMessages({
pause: { id: 'app.hosted-packs.progress.pause', defaultMessage: 'Pause' },
resume: { id: 'app.hosted-packs.progress.resume', defaultMessage: 'Resume' },
cancel: { id: 'app.hosted-packs.progress.stop', defaultMessage: 'Stop' },
retry: { id: 'app.downloads.retry', defaultMessage: 'Retry' },
})
const statusMessages = defineMessages({ const statusMessages = defineMessages({
paused: { id: 'app.downloads.status.paused', defaultMessage: 'Paused' },
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' }, queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
running: { id: 'app.downloads.status.running', defaultMessage: 'Running' }, running: { id: 'app.downloads.status.running', defaultMessage: 'Running' },
canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' }, canceling: { id: 'app.downloads.status.canceling', defaultMessage: 'Canceling' },
@@ -1262,6 +1292,7 @@ function toggleExpanded(jobId: string) {
} }
async function withBusy(jobId: string, action: () => Promise<void>) { async function withBusy(jobId: string, action: () => Promise<void>) {
if (busy.value.has(jobId)) return
busy.value = new Set([...busy.value, jobId]) busy.value = new Set([...busy.value, jobId])
try { try {
await action() await action()
@@ -1278,6 +1309,21 @@ async function cancel(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.cancel(job.job_id)) await withBusy(job.job_id, () => manager.cancel(job.job_id))
} }
function hostedBusyKey(bar: LoadingBar, action: HostedDownloadAction) {
return `hosted-${action === 'retry' ? 'retry' : 'control'}:${bar.bar_type?.instance_id}`
}
async function hostedAction(bar: LoadingBar, action: HostedDownloadAction) {
const instanceId = bar.bar_type?.instance_id
if (!instanceId || !hostedDownloadActions(bar).includes(action)) return
await withBusy(hostedBusyKey(bar, action), () =>
runHostedDownloadAction(action, instanceId, {
retryHosted: manager.retryHosted,
control: controlHostedDownload,
}),
)
}
async function retry(job: InstallJobSnapshot) { async function retry(job: InstallJobSnapshot) {
await withBusy(job.job_id, () => manager.retry(job.job_id)) await withBusy(job.job_id, () => manager.retry(job.job_id))
} }
@@ -1293,12 +1339,14 @@ async function resolveMissing(job: InstallJobSnapshot) {
(item) => (item) =>
item.status === 'skipped' && item.manual_url && item.project_id && item.version_id, item.status === 'skipped' && item.manual_url && item.project_id && item.version_id,
) )
.map((item): CurseForgeManualDownloadItem => ({ .map(
projectId: Number(item.project_id), (item): CurseForgeManualDownloadItem => ({
fileId: Number(item.version_id), projectId: Number(item.project_id),
fileName: item.name, fileId: Number(item.version_id),
websiteUrl: item.manual_url ?? undefined, fileName: item.name,
})) websiteUrl: item.manual_url ?? undefined,
}),
)
const instanceId = job.instance_id const instanceId = job.instance_id
const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed') const hasGeneralMissingItems = job.items.some((item) => item.status === 'failed')
if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) { if (instanceId && (job.provider === 'curse_forge' || fallbackCurseForgeItems.length > 0)) {
+2 -2
View File
@@ -16,7 +16,7 @@ import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue' import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
import HomeMinimal from '@/components/home/HomeMinimal.vue' import HomeMinimal from '@/components/home/HomeMinimal.vue'
import HomePlayInsights from '@/components/home/HomePlayInsights.vue' import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue' import SkinSiteHome from '@/components/ui/skin-site/SkinSiteHome.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus' import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { get_default_user, users } from '@/helpers/auth' import { get_default_user, users } from '@/helpers/auth'
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync' import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
@@ -162,7 +162,7 @@ onUnmounted(() => {
@select="selectMinimalInstance" @select="selectMinimalInstance"
/> />
<div class="home-content" :class="{ 'is-minimal': isMinimal }"> <div class="home-content" :class="{ 'is-minimal': isMinimal }">
<SkinSiteFrame v-if="!isMinimal" /> <SkinSiteHome v-if="!isMinimal" />
<HomeMinimal <HomeMinimal
v-else v-else
@@ -1,9 +0,0 @@
<script setup lang="ts">
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
</script>
<template>
<div class="h-full min-h-0">
<SkinSiteFrame />
</div>
</template>
-8
View File
@@ -93,14 +93,6 @@ export default new createRouter({
discordActivity: 'Changing skins...', discordActivity: 'Changing skins...',
}, },
}, },
{
path: '/starlight-skin',
name: 'Starlight skin',
component: () => import('@/pages/StarlightSkin.vue'),
meta: {
breadcrumb: [{ name: 'Starlight skin' }],
},
},
{ {
path: '/screenshots', path: '/screenshots',
name: 'Screenshots', name: 'Screenshots',
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus_gui" name = "theseus_gui"
# The actual version is set by the theseus-build workflow on tagging # The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta7" version = "1.0.2-beta11"
edition.workspace = true edition.workspace = true
description = "Axolotl Launcher is a cross-platform Minecraft launcher" description = "Axolotl Launcher is a cross-platform Minecraft launcher"
homepage = "https://www.ghs.red" homepage = "https://www.ghs.red"
+3
View File
@@ -380,6 +380,9 @@ fn main() {
"hosted_create", "hosted_create",
"hosted_binding", "hosted_binding",
"hosted_sync", "hosted_sync",
"hosted_pause",
"hosted_resume",
"hosted_cancel",
"hosted_instance_mode", "hosted_instance_mode",
"hosted_set_instance_mode", "hosted_set_instance_mode",
"install_pack_to_existing_instance", "install_pack_to_existing_instance",
+18
View File
@@ -20,6 +20,9 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
hosted_create, hosted_create,
hosted_binding, hosted_binding,
hosted_sync, hosted_sync,
hosted_pause,
hosted_resume,
hosted_cancel,
hosted_instance_mode, hosted_instance_mode,
hosted_set_instance_mode, hosted_set_instance_mode,
install_get_modpack_preview, install_get_modpack_preview,
@@ -105,6 +108,21 @@ pub async fn hosted_sync(
Ok(theseus::pack::hosted::synchronize(&instance_id).await?) Ok(theseus::pack::hosted::synchronize(&instance_id).await?)
} }
#[tauri::command]
pub async fn hosted_pause(instance_id: String) -> Result<()> {
Ok(theseus::pack::hosted::pause(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_resume(instance_id: String) -> Result<()> {
Ok(theseus::pack::hosted::resume(&instance_id).await?)
}
#[tauri::command]
pub async fn hosted_cancel(instance_id: String) -> Result<()> {
Ok(theseus::pack::hosted::cancel(&instance_id).await?)
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct InstallCreateInstanceRequest { pub struct InstallCreateInstanceRequest {
+3 -6
View File
@@ -324,12 +324,9 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
/// external game-directory root for the one-click StarLight install flow. /// external game-directory root for the one-click StarLight install flow.
#[tauri::command] #[tauri::command]
fn get_launcher_root_dir() -> api::Result<String> { fn get_launcher_root_dir() -> api::Result<String> {
let exe = std::env::current_exe().map_err(|error| { let dir = theseus::default_game_dir_root().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::FSError(error.to_string()))
})?;
let dir = exe.parent().ok_or_else(|| {
theseus::Error::from(theseus::ErrorKind::FSError( theseus::Error::from(theseus::ErrorKind::FSError(
"Launcher executable has no parent directory".to_string(), "Could not determine a default game directory root".to_string(),
)) ))
})?; })?;
Ok(dir.to_string_lossy().into_owned()) Ok(dir.to_string_lossy().into_owned())
@@ -339,7 +336,7 @@ fn get_launcher_root_dir() -> api::Result<String> {
fn get_update_channel(app: tauri::AppHandle) -> api::Result<String> { fn get_update_channel(app: tauri::AppHandle) -> api::Result<String> {
let channel = read_update_channel_state(&app)? let channel = read_update_channel_state(&app)?
.active_channel .active_channel
.unwrap_or_else(|| "release".to_string()); .unwrap_or_else(|| theseus::default_update_channel().to_string());
match channel.as_str() { match channel.as_str() {
"release" | "beta" => Ok(channel), "release" | "beta" => Ok(channel),
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "theseus" name = "theseus"
# The actual version is set by the theseus-build workflow on tagging # The actual version is set by the theseus-build workflow on tagging
version = "1.0.2-beta7" version = "1.0.2-beta11"
edition.workspace = true edition.workspace = true
[dependencies] [dependencies]
+18 -13
View File
@@ -5197,19 +5197,9 @@ fn modpack_zip_error(error: zip::result::ZipError) -> crate::Error {
} }
fn safe_archive_relative_path(value: &str) -> crate::Result<String> { fn safe_archive_relative_path(value: &str) -> crate::Result<String> {
let path = Path::new(value); let normalized = value.replace('\\', "/");
if value.is_empty() crate::util::io::safe_relative_join(Path::new(""), &normalized)?;
|| path.is_absolute() Ok(normalized)
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(ErrorKind::InputError(
"CurseForge modpack contains an invalid override path".to_string(),
)
.into());
}
Ok(path.to_string_lossy().replace('\\', "/"))
} }
pub(crate) fn loader_family(loader_id: &str) -> &str { pub(crate) fn loader_family(loader_id: &str) -> &str {
@@ -10933,6 +10923,21 @@ mod tests {
); );
assert!(safe_archive_relative_path("../options.txt").is_err()); assert!(safe_archive_relative_path("../options.txt").is_err());
assert!(safe_archive_relative_path("/options.txt").is_err()); assert!(safe_archive_relative_path("/options.txt").is_err());
for path in [
r"..\options.txt",
"config/.. /outside",
"config/file:stream",
"C:/outside",
"C:outside",
"config/NUL",
"config/file.",
] {
assert!(safe_archive_relative_path(path).is_err(), "{path}");
}
assert_eq!(
safe_archive_relative_path(r"config\example.toml").unwrap(),
"config/example.toml"
);
} }
#[test] #[test]
@@ -109,6 +109,7 @@ pub struct CrashAnalysisMod {
struct SourceCandidate { struct SourceCandidate {
path: PathBuf, path: PathBuf,
filename: String, filename: String,
upload_name: String,
source_type: &'static str, source_type: &'static str,
modified: SystemTime, modified: SystemTime,
} }
@@ -116,10 +117,25 @@ struct SourceCandidate {
#[derive(Debug)] #[derive(Debug)]
struct SourceText { struct SourceText {
filename: String, filename: String,
upload_name: String,
source_type: &'static str, source_type: &'static str,
modified: SystemTime, modified: SystemTime,
content: String, content: String,
} }
pub(super) struct CollectedCrashLog {
pub name: String,
pub content: CensoredString,
}
pub(super) struct CollectedCrashLogs {
pub combined_log: CensoredString,
pub sources: Vec<CollectedCrashLog>,
}
struct CrashContext {
sources: Vec<SourceText>,
credentials: Vec<Credentials>,
}
struct Rule { struct Rule {
id: &'static str, id: &'static str,
@@ -1185,20 +1201,25 @@ async fn collect_candidates(
) -> crate::Result<Vec<SourceCandidate>> { ) -> crate::Result<Vec<SourceCandidate>> {
let mut candidates = Vec::new(); let mut candidates = Vec::new();
let mut locations = vec![ let mut locations = vec![
(directories.game_logs_dir(instance_root), "minecraft_log"), (
directories.game_logs_dir(instance_root),
"minecraft_log",
None,
),
( (
directories.game_crash_reports_dir(instance_root), directories.game_crash_reports_dir(instance_root),
"crash_report", "crash_report",
Some("crash-reports"),
), ),
(instance_root.to_path_buf(), "instance_log"), (instance_root.to_path_buf(), "instance_log", None),
]; ];
if let Some(dot_minecraft) = isolated_minecraft_root(instance_root) { if let Some(dot_minecraft) = isolated_minecraft_root(instance_root) {
// PCL-CE also searches the shared `.minecraft` root for JVM crash // PCL-CE also searches the shared `.minecraft` root for JVM crash
// logs when a version-isolated instance is active. // logs when a version-isolated instance is active.
locations.push((dot_minecraft, "minecraft_root_log")); locations.push((dot_minecraft, "minecraft_root_log", None));
} }
for (directory, default_type) in locations { for (directory, default_type, upload_prefix) in locations {
if !tokio::fs::try_exists(&directory).await? { if !tokio::fs::try_exists(&directory).await? {
continue; continue;
} }
@@ -1248,6 +1269,9 @@ async fn collect_candidates(
}; };
candidates.push(SourceCandidate { candidates.push(SourceCandidate {
path: entry.path(), path: entry.path(),
upload_name: upload_prefix
.map(|prefix| format!("{prefix}/{filename}"))
.unwrap_or_else(|| filename.clone()),
filename, filename,
source_type, source_type,
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
@@ -1257,6 +1281,53 @@ async fn collect_candidates(
Ok(candidates) Ok(candidates)
} }
async fn collect_crash_context(
instance_id: &str,
) -> crate::Result<CrashContext> {
let state = State::get().await?;
let (instance_path, game_dir_override) =
resolve_instance_path(instance_id, &state).await?;
let instance_root = state
.directories
.resolve_game_dir(&instance_path, game_dir_override.as_deref());
let candidates =
collect_candidates(&instance_root, &state.directories).await?;
let selected = select_run_candidates(candidates);
let sources = read_sources(selected).await;
let credentials = Credentials::get_all(&state.pool)
.await?
.into_iter()
.map(|credential| credential.1)
.collect::<Vec<_>>();
Ok(CrashContext {
sources,
credentials,
})
}
pub(super) async fn collect_crash_logs(
instance_id: &str,
) -> crate::Result<CollectedCrashLogs> {
let CrashContext {
sources,
credentials,
..
} = collect_crash_context(instance_id).await?;
let combined_log =
CensoredString::censor(build_combined_log(&sources), &credentials);
let sources = sources
.into_iter()
.map(|source| CollectedCrashLog {
name: source.upload_name,
content: CensoredString::censor(source.content, &credentials),
})
.collect();
Ok(CollectedCrashLogs {
combined_log,
sources,
})
}
fn isolated_minecraft_root(instance_root: &Path) -> Option<PathBuf> { fn isolated_minecraft_root(instance_root: &Path) -> Option<PathBuf> {
let versions_dir = instance_root.parent()?; let versions_dir = instance_root.parent()?;
if versions_dir if versions_dir
@@ -1320,6 +1391,7 @@ async fn read_sources(candidates: Vec<SourceCandidate>) -> Vec<SourceText> {
}; };
sources.push(SourceText { sources.push(SourceText {
filename: candidate.filename, filename: candidate.filename,
upload_name: candidate.upload_name,
source_type: candidate.source_type, source_type: candidate.source_type,
modified: candidate.modified, modified: candidate.modified,
content: String::from_utf8_lossy(bytes).into_owned(), content: String::from_utf8_lossy(bytes).into_owned(),
@@ -1779,6 +1851,7 @@ mod tests {
fn source(filename: &str, content: &str) -> SourceText { fn source(filename: &str, content: &str) -> SourceText {
SourceText { SourceText {
filename: filename.to_string(), filename: filename.to_string(),
upload_name: filename.to_string(),
source_type: "minecraft_log", source_type: "minecraft_log",
modified: SystemTime::now(), modified: SystemTime::now(),
content: content.to_string(), content: content.to_string(),
@@ -1802,6 +1875,7 @@ mod tests {
let candidates = vec![SourceCandidate { let candidates = vec![SourceCandidate {
path: PathBuf::from("launcher_log.txt"), path: PathBuf::from("launcher_log.txt"),
filename: "launcher_log.txt".to_string(), filename: "launcher_log.txt".to_string(),
upload_name: "launcher_log.txt".to_string(),
source_type: "minecraft_log", source_type: "minecraft_log",
modified: SystemTime::now() - Duration::from_secs(181), modified: SystemTime::now() - Duration::from_secs(181),
}]; }];
+1 -28
View File
@@ -4,8 +4,7 @@ use futures::StreamExt;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use super::CensoredString; use super::crash_analysis::collect_crash_logs;
use super::crash_analysis::analyze_crash;
use crate::State; use crate::State;
use crate::emit_logshare_ai_event; use crate::emit_logshare_ai_event;
@@ -200,32 +199,6 @@ fn source_identifier() -> String {
format!("{LOGSHARE_SOURCE_PREFIX}/{}", env!("CARGO_PKG_VERSION")) format!("{LOGSHARE_SOURCE_PREFIX}/{}", env!("CARGO_PKG_VERSION"))
} }
struct CollectedCrashLog {
name: String,
content: CensoredString,
}
struct CollectedCrashLogs {
combined_log: CensoredString,
sources: Vec<CollectedCrashLog>,
}
async fn collect_crash_logs(
instance_id: &str,
) -> crate::Result<CollectedCrashLogs> {
let analysis = analyze_crash(instance_id).await?;
Ok(CollectedCrashLogs {
combined_log: analysis.combined_log,
sources: analysis
.sources
.into_iter()
.map(|source| CollectedCrashLog {
name: source.filename,
content: source.content,
})
.collect(),
})
}
async fn prepare_upload(instance_id: &str) -> crate::Result<UploadBody> { async fn prepare_upload(instance_id: &str) -> crate::Result<UploadBody> {
let settings = get_log_share_settings().await?; let settings = get_log_share_settings().await?;
let logs = collect_crash_logs(instance_id).await?; let logs = collect_crash_logs(instance_id).await?;
+84 -18
View File
@@ -1,6 +1,6 @@
//! Shared helpers for extracting content from local modpack archives. //! Shared helpers for extracting content from local modpack archives.
use std::path::{Component, Path, PathBuf}; use std::path::{Path, PathBuf};
use super::detect::decode_zip_entry_name; use super::detect::decode_zip_entry_name;
use crate::util::io; use crate::util::io;
@@ -13,22 +13,6 @@ fn archive_error(error: zip::result::ZipError) -> crate::Error {
.into() .into()
} }
pub(crate) fn safe_relative_path(value: &str) -> crate::Result<String> {
let path = Path::new(value);
if value.is_empty()
|| path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(crate::ErrorKind::InputError(
"Modpack archive contains an invalid file path".to_string(),
)
.into());
}
Ok(path.to_string_lossy().replace('\\', "/"))
}
/// Extracts every file under `prefix` in the archive into `target_dir`, /// Extracts every file under `prefix` in the archive into `target_dir`,
/// preserving the directory structure below the prefix. Returns the number of /// preserving the directory structure below the prefix. Returns the number of
/// files written. /// files written.
@@ -114,7 +98,8 @@ fn extract_archive_subdir_sync(
) )
.into()); .into());
} }
let target = target_dir.join(safe_relative_path(relative)?); let target =
io::safe_relative_join(target_dir, &relative.replace('\\', "/"))?;
if let Some(parent) = target.parent() { if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
.map_err(|error| io::IOError::with_path(error, parent))?; .map_err(|error| io::IOError::with_path(error, parent))?;
@@ -252,6 +237,87 @@ pub(crate) async fn create_import_scratch_dir(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::io::Write;
#[test]
fn unsafe_override_paths_fail_without_writing_outside_target() {
let root = tempfile::tempdir().unwrap();
let archive_path = root.path().join("pack.zip");
let target_dir = root.path().join("server");
std::fs::create_dir(&target_dir).unwrap();
let outside = root.path().join("outside.txt");
std::fs::write(&outside, b"unchanged").unwrap();
for prefix in ["overrides/", "server/overrides/"] {
for relative in [
"../outside.txt",
r"..\outside.txt",
"config/../../outside.txt",
"/outside.txt",
"C:/outside.txt",
"C:outside.txt",
r"\\server\share\outside.txt",
"config/.. /outside.txt",
"config/mod.jar:payload",
"config/NUL",
"config/secret.",
] {
let mut archive = zip::ZipWriter::new(
std::fs::File::create(&archive_path).unwrap(),
);
archive
.start_file(
format!("{prefix}{relative}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
archive.write_all(b"malicious").unwrap();
archive.finish().unwrap();
assert!(
extract_archive_subdir_sync(
&archive_path,
prefix,
&target_dir,
None,
)
.is_err(),
"{prefix}{relative}"
);
assert_eq!(std::fs::read(&outside).unwrap(), b"unchanged");
assert_eq!(std::fs::read_dir(&target_dir).unwrap().count(), 0);
}
}
}
#[test]
fn override_paths_with_backslashes_are_normalized() {
let root = tempfile::tempdir().unwrap();
let archive_path = root.path().join("pack.zip");
let target_dir = root.path().join("server");
let mut archive =
zip::ZipWriter::new(std::fs::File::create(&archive_path).unwrap());
archive
.start_file(
r"overrides/config\example.toml",
zip::write::SimpleFileOptions::default(),
)
.unwrap();
archive.write_all(b"valid").unwrap();
archive.finish().unwrap();
assert_eq!(
extract_archive_subdir_sync(
&archive_path,
"overrides/",
&target_dir,
None,
)
.unwrap(),
1
);
assert_eq!(
std::fs::read(target_dir.join("config/example.toml")).unwrap(),
b"valid"
);
}
struct CancelOnRead { struct CancelOnRead {
cancellation: CancellationToken, cancellation: CancellationToken,
+22 -32
View File
@@ -1,5 +1,7 @@
//! Administrator-approved skin-site packs. Only content-addressed changed files cross the network. //! Administrator-approved skin-site packs. Only content-addressed changed files cross the network.
mod control;
mod progress; mod progress;
pub use control::{HostedTaskState, cancel, pause, resume};
mod tagged; mod tagged;
mod transport; mod transport;
use crate::{ use crate::{
@@ -31,7 +33,7 @@ use tokio::{
sync::{Mutex, OwnedMutexGuard}, sync::{Mutex, OwnedMutexGuard},
}; };
const API: &str = "https://skin.starlight.cool/starlight/mod/packs"; const API: &str = "https://download.starlight.cool:20443/starlight/mod/packs";
const STARLIGHT_YGGDRASIL_API_ROOT: &str = const STARLIGHT_YGGDRASIL_API_ROOT: &str =
"https://skin.starlight.cool/yggdrasil"; "https://skin.starlight.cool/yggdrasil";
const ACCOUNT_REQUIRED_MESSAGE: &str = "您需要登录正版或皮肤站账号后才能操作"; const ACCOUNT_REQUIRED_MESSAGE: &str = "您需要登录正版或皮肤站账号后才能操作";
@@ -938,10 +940,20 @@ async fn synchronize_locked(instance_id: &str) -> crate::Result<SyncResult> {
let metadata = crate::instance::get(instance_id) let metadata = crate::instance::get(instance_id)
.await? .await?
.ok_or_else(|| invalid("Unknown instance"))?; .ok_or_else(|| invalid("Unknown instance"))?;
let task = control::Registration::new(instance_id)?;
let progress = let progress =
PackProgress::new(instance_id, &metadata.instance.name).await?; PackProgress::new(instance_id, &metadata.instance.name).await?;
let result = let result = task
synchronize_with_progress(instance_id, metadata, &progress).await; .control
.run(|| {
synchronize_with_progress(
instance_id,
metadata.clone(),
&progress,
&task.control,
)
})
.await;
if let Err(error) = &result { if let Err(error) = &result {
progress.fail(error); progress.fail(error);
} }
@@ -952,6 +964,7 @@ async fn synchronize_with_progress(
instance_id: &str, instance_id: &str,
metadata: crate::state::InstanceMetadata, metadata: crate::state::InstanceMetadata,
progress: &PackProgress, progress: &PackProgress,
control: &control::Control,
) -> crate::Result<SyncResult> { ) -> crate::Result<SyncResult> {
if metadata.instance.linked_launcher.is_some() if metadata.instance.linked_launcher.is_some()
|| metadata.instance.symlink_target.is_some() || metadata.instance.symlink_target.is_some()
@@ -1275,7 +1288,7 @@ async fn synchronize_with_progress(
&format!("文件 0/{total_files}"), &format!("文件 0/{total_files}"),
true, true,
); );
let results = stream::iter(pending_downloads.into_values().map( downloaded += control::collect_downloads(stream::iter(pending_downloads.into_values().map(
|(file, url, object)| { |(file, url, object)| {
let current = &current; let current = &current;
let completed = &completed; let completed = &completed;
@@ -1342,34 +1355,14 @@ async fn synchronize_with_progress(
request = request request = request
.with_header("Authorization", auth.clone()); .with_header("Authorization", auth.clone());
} }
let first_result = download_to_path( download_to_path(
request.clone(), request.clone(),
&object, &object,
&state.fetch_semaphore, &state.fetch_semaphore,
&state.pool, &state.pool,
Some(&mut on_progress), Some(&mut on_progress),
) )
.await; .await.map_err(|error| error.with_context(format!("下载整合包文件失败:{file_path}")))?;
if let Err(error) = first_result {
tracing::warn!(
path = %file_path,
%error,
"Parallel StarLight file download failed; retrying after the batch load has eased"
);
ensure_session(auth).await?;
tokio::time::sleep(std::time::Duration::from_millis(
750,
))
.await;
download_to_path(
request,
&object,
&state.fetch_semaphore,
&state.pool,
Some(&mut on_progress),
)
.await?;
}
let completed_files = let completed_files =
completed.fetch_add(1, Ordering::Relaxed) + 1; completed.fetch_add(1, Ordering::Relaxed) + 1;
let completed_bytes = { let completed_bytes = {
@@ -1391,12 +1384,7 @@ async fn synchronize_with_progress(
} }
}, },
)) ))
.buffer_unordered(concurrency) .buffer_unordered(concurrency)).await?;
.collect::<Vec<_>>()
.await;
downloaded += results.into_iter().try_fold(0, |sum, result| {
Ok::<u64, crate::Error>(sum + result?)
})?;
} }
ensure_session(&auth).await?; ensure_session(&auth).await?;
downloaded += tagged::download( downloaded += tagged::download(
@@ -1446,6 +1434,8 @@ async fn synchronize_with_progress(
previous, previous,
metadata, metadata,
}; };
// From this atomic boundary onward the rollback/runtime transaction must finish.
control.begin_finalizing()?;
write_json(&root, JOURNAL, &journal)?; write_json(&root, JOURNAL, &journal)?;
let result = async { let result = async {
progress.update(0, 0, "正在安装游戏组件", true); progress.update(0, 0, "正在安装游戏组件", true);
@@ -0,0 +1,329 @@
use futures::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use std::{
future::Future,
sync::{Arc, LazyLock, Mutex},
};
use tokio::sync::watch;
#[derive(
Clone, Copy, Debug, Default, Deserialize, Serialize, Hash, PartialEq, Eq,
)]
#[serde(rename_all = "snake_case")]
pub enum HostedTaskState {
#[default]
Running,
Paused,
Canceling,
Canceled,
Failed,
Finalizing,
}
static CONTROLS: LazyLock<dashmap::DashMap<String, Arc<Control>>> =
LazyLock::new(dashmap::DashMap::new);
pub(super) struct Control {
instance_id: String,
state: Mutex<HostedTaskState>,
changes: watch::Sender<HostedTaskState>,
}
fn stopped() -> crate::Error {
super::invalid("已停止整合包下载;已下载的缓存会保留,可重试继续")
}
impl Control {
fn new(instance_id: &str) -> Self {
Self {
instance_id: instance_id.to_owned(),
state: Mutex::new(HostedTaskState::Running),
changes: watch::channel(HostedTaskState::Running).0,
}
}
fn state(&self) -> HostedTaskState {
*self.state.lock().unwrap_or_else(|error| error.into_inner())
}
fn transition(
&self,
allowed: &[HostedTaskState],
next: HostedTaskState,
) -> crate::Result<()> {
let mut state =
self.state.lock().unwrap_or_else(|error| error.into_inner());
if !allowed.contains(&state) {
return Err(super::invalid(
"任务状态已变化或正在应用安装文件,当前操作不可用",
));
}
*state = next;
// Publish under the transition lock, so a late pause cannot overwrite finalization.
crate::event::emit::set_hosted_loading_state(&self.instance_id, next);
self.changes.send_replace(next);
Ok(())
}
fn pause(&self) -> crate::Result<()> {
self.transition(&[HostedTaskState::Running], HostedTaskState::Paused)
}
fn resume(&self) -> crate::Result<()> {
self.transition(&[HostedTaskState::Paused], HostedTaskState::Running)
}
fn cancel(&self) -> crate::Result<()> {
self.transition(
&[HostedTaskState::Running, HostedTaskState::Paused],
HostedTaskState::Canceling,
)
}
pub(super) fn begin_finalizing(&self) -> crate::Result<()> {
self.transition(
&[HostedTaskState::Running],
HostedTaskState::Finalizing,
)
}
/// The operation may be dropped only before begin_finalizing. Restarting
/// re-plans from verified cache objects and the downloader's partial files.
pub(super) async fn run<T, F, Fut>(
&self,
mut operation: F,
) -> crate::Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = crate::Result<T>>,
{
let mut changes = self.changes.subscribe();
loop {
let state = *changes.borrow_and_update();
match state {
HostedTaskState::Paused => {
changes.changed().await.map_err(|_| stopped())?;
continue;
}
HostedTaskState::Canceling => return Err(stopped()),
_ => {}
}
let work = operation();
tokio::pin!(work);
loop {
tokio::select! {
biased;
changed = changes.changed() => {
changed.map_err(|_| stopped())?;
match self.state() {
HostedTaskState::Paused => break,
HostedTaskState::Canceling => return Err(stopped()),
_ => {},
}
}
result = &mut work => {
match self.state() {
HostedTaskState::Paused => break,
HostedTaskState::Canceling => return Err(stopped()),
_ => return result,
}
}
}
}
}
}
}
pub(super) struct Registration {
instance_id: String,
pub(super) control: Arc<Control>,
}
impl Registration {
pub(super) fn new(instance_id: &str) -> crate::Result<Self> {
use dashmap::mapref::entry::Entry;
let control = Arc::new(Control::new(instance_id));
match CONTROLS.entry(instance_id.to_owned()) {
Entry::Occupied(_) => {
return Err(super::invalid("此实例已有整合包下载任务"));
}
Entry::Vacant(entry) => {
entry.insert(control.clone());
}
}
Ok(Self {
instance_id: instance_id.to_owned(),
control,
})
}
}
impl Drop for Registration {
fn drop(&mut self) {
CONTROLS.remove_if(&self.instance_id, |_, value| {
Arc::ptr_eq(value, &self.control)
});
}
}
fn lookup(instance_id: &str) -> crate::Result<Arc<Control>> {
CONTROLS
.get(instance_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| super::invalid("整合包下载任务已结束,请重试"))
}
pub async fn pause(instance_id: &str) -> crate::Result<()> {
lookup(instance_id)?.pause()
}
pub async fn resume(instance_id: &str) -> crate::Result<()> {
lookup(instance_id)?.resume()
}
pub async fn cancel(instance_id: &str) -> crate::Result<()> {
lookup(instance_id)?.cancel()
}
/// Stop the batch on its first error so stalled peers cannot hide failure.
pub(super) async fn collect_downloads<S>(downloads: S) -> crate::Result<u64>
where
S: Stream<Item = crate::Result<u64>>,
{
tokio::pin!(downloads);
let mut total = 0;
while let Some(result) = downloads.next().await {
total += result?;
}
Ok(total)
}
#[cfg(test)]
mod tests {
use super::*;
use futures::FutureExt;
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use tokio::sync::{Notify, Semaphore};
struct TransferGuard(Arc<AtomicUsize>, Arc<Notify>);
impl Drop for TransferGuard {
fn drop(&mut self) {
self.0.fetch_add(1, Ordering::SeqCst);
self.1.notify_one();
}
}
#[tokio::test]
async fn pause_drops_live_transfers_and_resume_restarts_before_stop() {
let control = Arc::new(Control::new("pause-resume-test"));
let starts = Arc::new(Semaphore::new(0));
let drops = Arc::new(AtomicUsize::new(0));
let dropped = Arc::new(Notify::new());
let task = {
let control = control.clone();
let starts = starts.clone();
let drops = drops.clone();
let dropped = dropped.clone();
tokio::spawn(async move {
control
.run(|| {
let guard =
TransferGuard(drops.clone(), dropped.clone());
starts.add_permits(1);
async move {
let _guard = guard;
std::future::pending::<crate::Result<()>>().await
}
})
.await
})
};
starts.acquire().await.unwrap().forget();
control.pause().unwrap();
dropped.notified().await;
assert_eq!(drops.load(Ordering::SeqCst), 1);
assert_eq!(control.state(), HostedTaskState::Paused);
control.resume().unwrap();
starts.acquire().await.unwrap().forget();
control.cancel().unwrap();
let result = task.await.unwrap();
assert!(result.unwrap_err().to_string().contains("停止"));
assert_eq!(drops.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn stopping_an_already_paused_task_never_starts_a_transfer() {
let control = Arc::new(Control::new("paused-stop-test"));
control.pause().unwrap();
let task = {
let control = control.clone();
tokio::spawn(async move {
control
.run(|| async {
panic!("paused work was polled");
Ok::<(), crate::Error>(())
})
.await
})
};
control.cancel().unwrap();
assert!(task.await.unwrap().is_err());
}
#[test]
fn finalization_rejects_pause_and_stop() {
let control = Control::new("finalization-test");
control.begin_finalizing().unwrap();
assert_eq!(control.state(), HostedTaskState::Finalizing);
assert!(control.pause().is_err());
assert!(control.cancel().is_err());
}
#[test]
fn pause_and_finalization_have_only_one_winner() {
for _ in 0..32 {
let control = Arc::new(Control::new("finalization-race-test"));
let barrier = Arc::new(std::sync::Barrier::new(2));
let task = {
let control = control.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
control.pause().is_ok()
})
};
barrier.wait();
let finalized = control.begin_finalizing().is_ok();
assert_ne!(finalized, task.join().unwrap());
}
}
#[tokio::test]
async fn first_failure_drops_stalled_peer_downloads() {
let drops = Arc::new(AtomicUsize::new(0));
let guard = TransferGuard(drops.clone(), Arc::new(Notify::new()));
let downloads = futures::stream::iter(vec![
async move {
let _guard = guard;
std::future::pending::<crate::Result<u64>>().await
}
.boxed(),
async { Err(super::super::invalid("failed file")) }.boxed(),
])
.buffer_unordered(2);
let result = collect_downloads(downloads).await;
assert!(result.unwrap_err().to_string().contains("failed file"));
assert_eq!(drops.load(Ordering::SeqCst), 1);
}
#[test]
fn registration_is_unique_and_released_on_drop() {
let id = uuid::Uuid::new_v4().to_string();
let registration = Registration::new(&id).unwrap();
assert!(Registration::new(&id).is_err());
drop(registration);
assert!(Registration::new(&id).is_ok());
}
}
@@ -59,6 +59,9 @@ impl PackProgress {
instance_id: instance_id.to_owned(), instance_id: instance_id.to_owned(),
instance_name: name.to_owned(), instance_name: name.to_owned(),
error: None, error: None,
state: super::HostedTaskState::Running,
can_pause: true,
can_cancel: true,
}, },
1.0, 1.0,
"正在获取整合包信息", "正在获取整合包信息",
@@ -681,7 +681,7 @@ pub(super) async fn download(
set_loading(&bar, 0, file.size, "等待下载")?; set_loading(&bar, 0, file.size, "等待下载")?;
work.push((file, url, object, bar)); work.push((file, url, object, bar));
} }
let results = control::collect_downloads(
stream::iter(work.into_iter().map(|(file, url, object, bar)| { stream::iter(work.into_iter().map(|(file, url, object, bar)| {
let current = &current; let current = &current;
let state = &state; let state = &state;
@@ -740,10 +740,7 @@ pub(super) async fn download(
result result
} }
})) }))
.buffer_unordered(concurrency) .buffer_unordered(concurrency),
.collect::<Vec<_>>() )
.await; .await
results
.into_iter()
.try_fold(0, |sum, result| Ok(sum + result?))
} }
+70 -18
View File
@@ -20,7 +20,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::future::Future; use std::future::Future;
use std::path::{Component, Path, PathBuf}; use std::path::{Path, PathBuf};
use std::pin::Pin; use std::pin::Pin;
use zip::ZipArchive; use zip::ZipArchive;
@@ -320,23 +320,13 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
"Specified version has no files".to_string(), "Specified version has no files".to_string(),
) )
})?; })?;
let file_name = Path::new(&pack_file.filename);
if file_name.components().count() != 1
|| !matches!(file_name.components().next(), Some(Component::Normal(_)))
{
return Err(crate::ErrorKind::InputError(
"Modrinth returned an invalid modpack file name".to_string(),
)
.into());
}
let hash = pack_file.hashes.get("sha1"); let hash = pack_file.hashes.get("sha1");
let pack_path = state let pack_path = modpack_cache_path(
.directories &state.directories.caches_dir().join("modpacks"),
.caches_dir() &project_id,
.join("modpacks") &version_id,
.join(&project_id) &pack_file.filename,
.join(&version_id) )?;
.join(file_name);
let details = InstallPhaseDetails::Modpack { let details = InstallPhaseDetails::Modpack {
project_id: Some(project_id.clone()), project_id: Some(project_id.clone()),
@@ -517,8 +507,70 @@ pub(crate) async fn generate_pack_from_version_id_with_reporter(
}) })
} }
#[tracing::instrument] fn modpack_cache_path(
root: &Path,
project_id: &str,
version_id: &str,
filename: &str,
) -> crate::Result<PathBuf> {
let mut path = root.to_path_buf();
for segment in [project_id, version_id, filename] {
if segment.contains('/') {
return Err(crate::ErrorKind::InputError(
"Invalid modpack cache path component".to_string(),
)
.into());
}
path = crate::util::io::safe_relative_join(&path, segment)?;
}
Ok(path)
}
#[cfg(test)]
mod cache_path_tests {
use super::modpack_cache_path;
use std::path::Path;
#[test]
fn cache_components_cannot_escape_their_directories() {
let root = Path::new("modpacks");
assert_eq!(
modpack_cache_path(root, "project", "version", "pack.mrpack")
.unwrap(),
root.join("project/version/pack.mrpack")
);
for malicious in [
"../outside",
"nested/name",
r"..\outside",
"C:/outside",
"C:outside",
r"\\server\share",
".",
"",
"NUL",
"pack.mrpack:stream",
".. ",
] {
for position in 0..3 {
let mut components = ["project", "version", "pack.mrpack"];
components[position] = malicious;
assert!(
modpack_cache_path(
root,
components[0],
components[1],
components[2]
)
.is_err(),
"{position}: {malicious:?}"
);
}
}
}
}
#[tracing::instrument]
pub async fn generate_pack_from_file( pub async fn generate_pack_from_file(
path: PathBuf, path: PathBuf,
instance_id: String, instance_id: String,
+58 -18
View File
@@ -891,6 +891,12 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
num_files, num_files,
content_total_bytes, content_total_bytes,
}; };
for file in &pack.files {
io::join_within_root(
&content_context.instance_full_path,
&content_context.resolve_install_path(file),
)?;
}
let skipped_missing_content_paths = Arc::new( let skipped_missing_content_paths = Arc::new(
reporter reporter
.current_state() .current_state()
@@ -1039,8 +1045,14 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
let project_size = project.file_size as u64; let project_size = project.file_size as u64;
let project_path = let project_path =
content_context.resolve_install_path(&project); content_context.resolve_install_path(&project);
let target_path = let target_path = io::join_within_root(
content_context.instance_full_path.join(&project_path); &content_context.instance_full_path,
&project_path,
).map_err(|error| RequiredFileFailure::new(
manifest_index,
&project_path,
&error.to_string(),
))?;
let context = let context =
InstallErrorContext::new("download modpack content file") InstallErrorContext::new("download modpack content file")
.maybe_project_id( .maybe_project_id(
@@ -1451,7 +1463,10 @@ pub(crate) async fn install_zipped_mrpack_files_with_reporter(
entry_name: filename.to_string(), entry_name: filename.to_string(),
crc32: file.crc32(), crc32: file.crc32(),
uncompressed_size: file.uncompressed_size(), uncompressed_size: file.uncompressed_size(),
target_path: instance_full_path.join(&relative_path), target_path: io::safe_relative_join(
&instance_full_path,
&relative_path,
)?,
relative_path, relative_path,
}) })
}) })
@@ -1739,6 +1754,7 @@ fn override_relative_path(
"Invalid modpack override path: {relative}" "Invalid modpack override path: {relative}"
)) ))
})?; })?;
io::safe_relative_join(Path::new(""), relative.as_str())?;
Ok(relative.as_str().to_string()) Ok(relative.as_str().to_string())
} }
@@ -1885,7 +1901,11 @@ pub async fn remove_all_related_files(
// Iterate over all Modrinth project file paths in the json, and remove them // Iterate over all Modrinth project file paths in the json, and remove them
// (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized) // (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized)
for file in pack.files { for file in pack.files {
match io::remove_file(instance_full_path.join(file.path.as_str())).await match io::remove_file(io::safe_relative_join(
&instance_full_path,
file.path.as_str(),
)?)
.await
{ {
Ok(_) => (), Ok(_) => (),
Err(err) if err.kind() == ErrorKind::NotFound => (), Err(err) if err.kind() == ErrorKind::NotFound => (),
@@ -1904,23 +1924,14 @@ pub async fn remove_all_related_files(
for file in override_file_entries { for file in override_file_entries {
let entry_name = file.filename().as_str().unwrap(); let entry_name = file.filename().as_str().unwrap();
let entry_name =
entry_name.strip_prefix(&archive_base).unwrap_or(entry_name);
let relative_override_file_path = let relative_override_file_path =
SafeRelativeUtf8UnixPathBuf::try_from(entry_name.to_string())?; override_relative_path(entry_name, &archive_base)?;
let relative_override_file_path = relative_override_file_path
.strip_prefix("overrides")
.or_else(|_| relative_override_file_path.strip_prefix("client-overrides"))
.map_err(|_| {
crate::Error::from(crate::ErrorKind::OtherError(
format!("Failed to strip override prefix from override file path: {relative_override_file_path}")
))
})?;
// Remove this file if a corresponding one exists in the filesystem // Remove this file if a corresponding one exists in the filesystem
match io::remove_file( match io::remove_file(io::safe_relative_join(
instance_full_path.join(relative_override_file_path.as_str()), &instance_full_path,
) &relative_override_file_path,
)?)
.await .await
{ {
Ok(_) => (), Ok(_) => (),
@@ -1939,6 +1950,35 @@ mod tests {
use async_zip::{Compression, ZipEntryBuilder}; use async_zip::{Compression, ZipEntryBuilder};
use std::sync::Mutex as StdMutex; use std::sync::Mutex as StdMutex;
#[test]
fn override_paths_reject_portable_traversal_and_windows_aliases() {
for prefix in ["overrides/", "client-overrides/"] {
assert_eq!(
override_relative_path(
&format!("{prefix}config/example.toml"),
""
)
.unwrap(),
"config/example.toml"
);
for relative in [
"../outside",
"config/.. /outside",
"config/file:stream",
"C:/outside",
"C:outside",
"config/NUL",
"config/file.",
] {
assert!(
override_relative_path(&format!("{prefix}{relative}"), "")
.is_err(),
"{prefix}{relative}"
);
}
}
}
fn override_spec(index: usize, target: &str) -> OverrideExtractionSpec { fn override_spec(index: usize, target: &str) -> OverrideExtractionSpec {
OverrideExtractionSpec { OverrideExtractionSpec {
index, index,
+30 -44
View File
@@ -12,6 +12,8 @@ use crate::{ErrorKind, Result};
use super::manifest::server_path; use super::manifest::server_path;
pub(super) use crate::util::io::safe_relative_join;
const DOWNLOAD_PROGRESS_STEP: u64 = 512 * 1024; const DOWNLOAD_PROGRESS_STEP: u64 = 512 * 1024;
pub async fn read_file(server_id: &str, file: &str) -> Result<String> { pub async fn read_file(server_id: &str, file: &str) -> Result<String> {
@@ -124,44 +126,7 @@ pub(super) async fn download_to_dir(
async fn resolve_server_file(server_id: &str, file: &str) -> Result<PathBuf> { async fn resolve_server_file(server_id: &str, file: &str) -> Result<PathBuf> {
let dir = server_path(server_id).await?; let dir = server_path(server_id).await?;
safe_join(&dir, file) safe_relative_join(&dir, file)
}
fn safe_join(dir: &Path, file: &str) -> Result<PathBuf> {
if file.is_empty()
|| file.contains('\\')
|| file.starts_with('/')
|| file
.split('/')
.any(|segment| segment == ".." || segment.is_empty())
{
return Err(ErrorKind::InputError(format!(
"Invalid file name: {file}"
))
.as_error());
}
Ok(dir.join(file))
}
/// Joins a relative path that may contain `/` separators but never escapes the
/// server directory (no `..`, empty segments, or absolute/Windows roots).
pub(super) fn safe_relative_join(dir: &Path, rel: &str) -> Result<PathBuf> {
let is_windows_drive_root =
rel.as_bytes().get(1).is_some_and(|byte| *byte == b':')
&& rel.as_bytes().first().is_some_and(u8::is_ascii_alphabetic);
if rel.is_empty()
|| Path::new(rel).is_absolute()
|| is_windows_drive_root
|| rel.starts_with('/')
|| rel.starts_with('\\')
|| rel.split(['/', '\\']).any(|segment| {
segment.is_empty() || segment == ".." || segment == "."
})
{
return Err(ErrorKind::InputError(format!("Invalid file path: {rel}"))
.as_error());
}
Ok(dir.join(rel))
} }
#[cfg(test)] #[cfg(test)]
@@ -169,13 +134,34 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn safe_join_rejects_traversal() { fn safe_relative_join_rejects_unsafe_paths() {
let dir = Path::new("/tmp/servers/a"); let dir = Path::new("/tmp/servers/a");
assert!(safe_join(dir, "server.properties").is_ok()); for path in [
assert!(safe_join(dir, "../secret").is_err()); "../secret",
assert!(safe_join(dir, "/etc/passwd").is_err()); "mods/../../secret",
assert!(safe_join(dir, "a//b").is_err()); r"..\secret",
assert!(safe_join(dir, "").is_err()); r"mods/..\../secret",
"/etc/passwd",
"C:/windows/system32",
r"C:\windows\system32",
"C:secret",
r"\\server\share\secret",
r"\secret",
r"\\?\C:\secret",
"a//b",
"a/./b",
".",
"",
"mods/.. /secret",
"mods/secret.",
"mods/secret ",
"mods/mod.jar:payload",
"NUL/secret",
"mods/CON.jar",
"mods/mod\0.jar",
] {
assert!(safe_relative_join(dir, path).is_err(), "{path:?}");
}
} }
#[test] #[test]
+2 -2
View File
@@ -14,7 +14,7 @@ use crate::event::emit::emit_server;
use crate::util::io::IOError; use crate::util::io::IOError;
use crate::{ErrorKind, Result}; use crate::{ErrorKind, Result};
use super::files::download_to_dir; use super::files::{download_to_dir, safe_relative_join};
use super::manifest::{ use super::manifest::{
InstallState, read_manifest, server_path, write_manifest, InstallState, read_manifest, server_path, write_manifest,
}; };
@@ -41,6 +41,7 @@ pub async fn install_forge(
drop(manifest); drop(manifest);
let installer_name = format!("forge-{mc_version}-{build}-installer.jar"); let installer_name = format!("forge-{mc_version}-{build}-installer.jar");
let installer_path = safe_relative_join(&dir, &installer_name)?;
let installer_url = let installer_url =
format!("{FORGE_MAVEN}/{mc_version}-{build}/{installer_name}"); format!("{FORGE_MAVEN}/{mc_version}-{build}/{installer_name}");
@@ -52,7 +53,6 @@ pub async fn install_forge(
download_to_dir(server_id, &dir, &installer_url, &installer_name, None) download_to_dir(server_id, &dir, &installer_url, &installer_name, None)
.await?; .await?;
let installer_path = dir.join(&installer_name);
let java = java_path.clone().unwrap_or_else(|| "java".to_string()); let java = java_path.clone().unwrap_or_else(|| "java".to_string());
log(server_id, "Running Forge installer (this may take a while)").await?; log(server_id, "Running Forge installer (this may take a while)").await?;
@@ -18,6 +18,7 @@ use crate::state::{clear_log_buffer, get_log_buffer, push_log_line};
use crate::util::io::IOError; use crate::util::io::IOError;
use crate::{ErrorKind, Result}; use crate::{ErrorKind, Result};
use super::files::safe_relative_join;
use super::logs::{ use super::logs::{
analyze_exit_reason, stream_server_output, stream_server_pty_output, analyze_exit_reason, stream_server_output, stream_server_pty_output,
tail_server_log_file, tail_server_log_file,
@@ -131,7 +132,7 @@ async fn start_inner(
forge_launch_args(&dir)? forge_launch_args(&dir)?
} else { } else {
let jar_name = resolve_jar_name(&manifest); let jar_name = resolve_jar_name(&manifest);
let jar_path = dir.join(&jar_name); let jar_path = safe_relative_join(&dir, &jar_name)?;
if !jar_path.exists() { if !jar_path.exists() {
return Err(ErrorKind::LauncherError(format!( return Err(ErrorKind::LauncherError(format!(
"Server jar not found: {jar_name}. Download the server files first." "Server jar not found: {jar_name}. Download the server files first."
@@ -275,9 +276,11 @@ async fn start_inner(
// so these lines stop the console from looking frozen. // so these lines stop the console from looking frozen.
let loader_first_run = let loader_first_run =
matches!(manifest.server_type.as_str(), "fabric" | "quilt") matches!(manifest.server_type.as_str(), "fabric" | "quilt")
&& !dir && !safe_relative_join(
.join(format!("{}-server-launch.jar", manifest.server_type)) &dir,
.exists(); &format!("{}-server-launch.jar", manifest.server_type),
)?
.exists();
log_server_step( log_server_step(
server_id, server_id,
+2 -1
View File
@@ -7,6 +7,7 @@ use crate::state::remove_log_buffer;
use crate::util::io::{self, IOError}; use crate::util::io::{self, IOError};
use crate::{ErrorKind, Result, State}; use crate::{ErrorKind, Result, State};
use super::files::safe_relative_join;
use super::lifecycle::is_running; use super::lifecycle::is_running;
use super::manifest::{ use super::manifest::{
ServerInfo, ServerManifest, build_server_info, read_manifest, ServerInfo, ServerManifest, build_server_info, read_manifest,
@@ -72,7 +73,7 @@ pub async fn create(
let state = State::get().await?; let state = State::get().await?;
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let dir_name = format!("{}-{}", sanitize_folder_name(name), &id[..8]); let dir_name = format!("{}-{}", sanitize_folder_name(name), &id[..8]);
let dir = state.directories.servers_dir().join(&dir_name); let dir = safe_relative_join(&state.directories.servers_dir(), &dir_name)?;
io::create_dir_all(&dir).await?; io::create_dir_all(&dir).await?;
let manifest = ServerManifest { let manifest = ServerManifest {
+1 -1
View File
@@ -118,7 +118,7 @@ pub(super) async fn server_path(server_id: &str) -> Result<PathBuf> {
.as_error()); .as_error());
} }
let state = State::get().await?; let state = State::get().await?;
let path = state.directories.server_dir(server_id); let path = state.directories.server_dir(server_id)?;
if !path.is_dir() { if !path.is_dir() {
return Err(ErrorKind::InputError(format!( return Err(ErrorKind::InputError(format!(
"Unknown server: {server_id}" "Unknown server: {server_id}"
+187 -31
View File
@@ -33,7 +33,7 @@ use crate::util::fetch::{
use crate::util::io::IOError; use crate::util::io::IOError;
use crate::{ErrorKind, Result}; use crate::{ErrorKind, Result};
use super::files::download_to_dir; use super::files::{download_to_dir, safe_relative_join};
use super::manifest::{ use super::manifest::{
InstallState, ModpackInfo, read_manifest, server_path, write_manifest, InstallState, ModpackInfo, read_manifest, server_path, write_manifest,
}; };
@@ -92,10 +92,52 @@ struct MrpackFile {
env: Option<MrpackEnv>, env: Option<MrpackEnv>,
#[serde(default)] #[serde(default)]
downloads: Vec<String>, downloads: Vec<String>,
#[serde(default)] #[serde(default, rename = "fileSize")]
file_size: Option<u64>, file_size: Option<u64>,
} }
impl MrpackFile {
fn integrity(&self) -> Result<Integrity> {
let sha1 = self.hashes.get("sha1").cloned();
let sha512 = self.hashes.get("sha512").cloned();
if sha1.is_none() && sha512.is_none() {
return Err(ErrorKind::InputError(format!(
"Modpack file {} is missing a SHA-1 or SHA-512 checksum",
self.path
))
.as_error());
}
for (hash, length) in [(&sha1, 40), (&sha512, 128)] {
if let Some(hash) = hash
&& (hash.len() != length
|| !hash.bytes().all(|byte| byte.is_ascii_hexdigit()))
{
return Err(ErrorKind::InputError(format!(
"Modpack file {} has an invalid checksum",
self.path
))
.as_error());
}
}
Ok(Integrity {
sha1,
sha512,
size: self.file_size,
..Integrity::default()
})
}
}
/// Validate the entire index before starting concurrent downloads, including
/// entries excluded from server installation but fetched for metadata.
fn validate_index(index: &MrpackIndex, dir: &Path) -> Result<()> {
for file in &index.files {
safe_relative_join(dir, &file.path)?;
file.integrity()?;
}
Ok(())
}
#[derive(Deserialize, Clone)] #[derive(Deserialize, Clone)]
struct MrpackEnv { struct MrpackEnv {
#[serde(default)] #[serde(default)]
@@ -272,7 +314,7 @@ async fn run_modpack_install(
&state, &state,
mrpack_url, mrpack_url,
&dir.join(MRPACK_FILENAME), &dir.join(MRPACK_FILENAME),
mrpack_sha1.map(str::to_string), mrpack_sha1.map(Integrity::sha1).unwrap_or_default(),
ResourceClass::Modpack, ResourceClass::Modpack,
AggregateProgress::new(0), AggregateProgress::new(0),
) )
@@ -282,6 +324,7 @@ async fn run_modpack_install(
let manifest_entry = find_manifest_entry(&archive_path).await?; let manifest_entry = find_manifest_entry(&archive_path).await?;
let base_folder = base_folder(&manifest_entry); let base_folder = base_folder(&manifest_entry);
let index = parse_index(&archive_path, &manifest_entry).await?; let index = parse_index(&archive_path, &manifest_entry).await?;
validate_index(&index, dir)?;
let (installable_files, excluded_files): ( let (installable_files, excluded_files): (
Vec<&MrpackFile>, Vec<&MrpackFile>,
@@ -310,19 +353,19 @@ async fn run_modpack_install(
) )
.await?; .await?;
let files: Vec<(String, String, Option<String>, u64)> = installable_files let files: Vec<(String, String, Integrity, u64)> = installable_files
.iter() .iter()
.filter_map(|file| { .filter_map(|file| {
file.downloads.first().map(|url| { file.downloads.first().map(|url| {
( Ok((
file.path.clone(), file.path.clone(),
url.clone(), url.clone(),
file.hashes.get("sha1").cloned(), file.integrity()?,
file.file_size.unwrap_or(0), file.file_size.unwrap_or(0),
) ))
}) })
}) })
.collect(); .collect::<Result<_>>()?;
let progress = AggregateProgress::new(total_bytes); let progress = AggregateProgress::new(total_bytes);
let state_ref = state.clone(); let state_ref = state.clone();
@@ -336,14 +379,14 @@ async fn run_modpack_install(
0.0, 0.0,
num_files, num_files,
None, None,
move |(path, url, sha1, size)| { move |(path, url, integrity, size)| {
let server_id = server_id_ref.clone(); let server_id = server_id_ref.clone();
let dir = dir_ref.clone(); let dir = dir_ref.clone();
let state = state_ref.clone(); let state = state_ref.clone();
let progress = progress.clone(); let progress = progress.clone();
async move { async move {
log(&server_id, &format!("Downloading {path}")).await?; log(&server_id, &format!("Downloading {path}")).await?;
let destination = dir.join(&path); let destination = safe_relative_join(&dir, &path)?;
if let Some(parent) = destination.parent() { if let Some(parent) = destination.parent() {
crate::util::io::create_dir_all(parent).await?; crate::util::io::create_dir_all(parent).await?;
} }
@@ -352,7 +395,7 @@ async fn run_modpack_install(
&state, &state,
&url, &url,
&destination, &destination,
sha1, integrity,
ResourceClass::Modpack, ResourceClass::Modpack,
progress.clone(), progress.clone(),
) )
@@ -446,7 +489,7 @@ async fn download_icon(dir: &Path, icon_url: &str) -> Result<PathBuf> {
) )
}) })
.unwrap_or_else(|| "png".to_string()); .unwrap_or_else(|| "png".to_string());
let destination = dir.join(format!("icon.{extension}")); let destination = safe_relative_join(dir, &format!("icon.{extension}"))?;
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.user_agent(crate::launcher_user_agent()) .user_agent(crate::launcher_user_agent())
@@ -476,15 +519,13 @@ async fn download_with_engine(
state: &State, state: &State,
url: &str, url: &str,
destination: &Path, destination: &Path,
sha1: Option<String>, integrity: Integrity,
resource: ResourceClass, resource: ResourceClass,
progress: AggregateProgress, progress: AggregateProgress,
) -> Result<()> { ) -> Result<()> {
let mut request = DownloadRequest::new(url, resource); let request = DownloadRequest::new(url, resource)
if let Some(sha1) = &sha1 { .with_integrity(integrity)
request = request.with_integrity(Integrity::sha1(sha1.clone())); .with_segmented_download(true);
}
request = request.with_segmented_download(true);
let server_id = server_id.to_string(); let server_id = server_id.to_string();
let progress = progress.clone(); let progress = progress.clone();
@@ -579,7 +620,7 @@ async fn fetch_excluded_mod_ids(
state: &State, state: &State,
excluded_files: &[&MrpackFile], excluded_files: &[&MrpackFile],
) -> Result<HashSet<String>> { ) -> Result<HashSet<String>> {
let candidates: Vec<(String, String, Option<String>)> = excluded_files let candidates: Vec<(String, String, Integrity)> = excluded_files
.iter() .iter()
.filter_map(|file| { .filter_map(|file| {
let path = file.path.replace('\\', "/"); let path = file.path.replace('\\', "/");
@@ -589,14 +630,14 @@ async fn fetch_excluded_mod_ids(
return None; return None;
} }
file.downloads.first().map(|url| { file.downloads.first().map(|url| {
( Ok((
path.rsplit('/').next().unwrap_or(&path).to_string(), path.rsplit('/').next().unwrap_or(&path).to_string(),
url.clone(), url.clone(),
file.hashes.get("sha1").cloned(), file.integrity()?,
) ))
}) })
}) })
.collect(); .collect::<Result<_>>()?;
if candidates.is_empty() { if candidates.is_empty() {
return Ok(HashSet::new()); return Ok(HashSet::new());
} }
@@ -611,9 +652,10 @@ async fn fetch_excluded_mod_ids(
ErrorKind::FSError(format!("Failed to create temporary directory: {e}")) ErrorKind::FSError(format!("Failed to create temporary directory: {e}"))
})?; })?;
let mut unavailable = HashSet::new(); let mut unavailable = HashSet::new();
for (filename, url, sha1) in candidates { for (filename, url, integrity) in candidates {
let destination = temp_dir.path().join(&filename); let destination = safe_relative_join(temp_dir.path(), &filename)?;
match download_metadata_jar(state, &url, &destination, sha1).await { match download_metadata_jar(state, &url, &destination, integrity).await
{
Ok(()) => match read_mod_metadata(&destination) { Ok(()) => match read_mod_metadata(&destination) {
Some(metadata) => { Some(metadata) => {
unavailable unavailable
@@ -647,12 +689,10 @@ async fn download_metadata_jar(
state: &State, state: &State,
url: &str, url: &str,
destination: &Path, destination: &Path,
sha1: Option<String>, integrity: Integrity,
) -> Result<()> { ) -> Result<()> {
let mut request = DownloadRequest::new(url, ResourceClass::Modpack); let request = DownloadRequest::new(url, ResourceClass::Modpack)
if let Some(sha1) = &sha1 { .with_integrity(integrity);
request = request.with_integrity(Integrity::sha1(sha1.clone()));
}
download_to_path( download_to_path(
request, request,
destination, destination,
@@ -908,6 +948,122 @@ mod tests {
use super::*; use super::*;
use std::io::Write; use std::io::Write;
fn index_file(path: &str) -> serde_json::Value {
serde_json::json!({
"path": path,
"hashes": { "sha1": "0".repeat(40) },
"downloads": ["https://example.com/mod.jar"],
"fileSize": 10
})
}
#[tokio::test]
async fn malicious_index_paths_fail_before_materializing_files() {
let temp = tempfile::tempdir().unwrap();
let server = temp.path().join("server");
std::fs::create_dir(&server).unwrap();
let outside = temp.path().join("outside.txt");
std::fs::write(&outside, "unchanged").unwrap();
let archive = temp.path().join("pack.mrpack");
for path in [
"../outside.txt",
"mods/../../outside.txt",
r"..\outside.txt",
r"mods/..\../outside.txt",
"/tmp/outside.txt",
"C:/outside.txt",
r"C:\outside.txt",
"C:outside.txt",
r"\\server\share\outside.txt",
r"\outside.txt",
r"\\?\C:\outside.txt",
"mods//mod.jar",
"mods/./mod.jar",
"mods/.. /outside.txt",
"mods/mod.jar:payload",
"NUL/outside.txt",
"mods/CON.jar",
"",
] {
for environment in ["required", "unsupported"] {
let mut malicious = index_file(path);
// Filtered entries must also fail before any downloads.
malicious["env"] = serde_json::json!({"server": environment});
let contents = serde_json::json!({
"files": [index_file("mods/valid.jar"), malicious]
})
.to_string();
create_jar(&archive, &[(MRPACK_MANIFEST_ENTRY, &contents)]);
let index =
parse_index(&archive, MRPACK_MANIFEST_ENTRY).await.unwrap();
assert!(validate_index(&index, &server).is_err(), "{path:?}");
assert_eq!(
std::fs::read_to_string(&outside).unwrap(),
"unchanged"
);
assert_eq!(std::fs::read_dir(&server).unwrap().count(), 0);
}
}
}
#[test]
fn index_requires_valid_supported_checksums() {
for hashes in [
serde_json::json!({}),
serde_json::json!({"md5": "0".repeat(32)}),
serde_json::json!({"sha1": ""}),
serde_json::json!({"sha1": "0".repeat(39)}),
serde_json::json!({"sha512": "g".repeat(128)}),
serde_json::json!({"sha1": "invalid", "sha512": "0".repeat(128)}),
] {
let mut file = index_file("mods/mod.jar");
file["hashes"] = hashes;
let index: MrpackIndex =
serde_json::from_value(serde_json::json!({"files": [file]}))
.unwrap();
assert!(validate_index(&index, Path::new("server")).is_err());
}
let mut file = index_file("mods/mod.jar");
file.as_object_mut().unwrap().remove("hashes");
let index: MrpackIndex =
serde_json::from_value(serde_json::json!({"files": [file]}))
.unwrap();
assert!(validate_index(&index, Path::new("server")).is_err());
}
#[tokio::test]
async fn index_checksums_verify_file_contents() {
use crate::util::fetch::verify_file;
use sha2::{Digest, Sha512};
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("mod.jar");
let contents = b"verified mod";
std::fs::write(&target, contents).unwrap();
for hashes in [
serde_json::json!({"sha1": sha1_smol::Sha1::from(contents).digest().to_string()}),
serde_json::json!({"sha512": format!("{:x}", Sha512::digest(contents))}),
serde_json::json!({
"sha1": sha1_smol::Sha1::from(contents).digest().to_string(),
"sha512": format!("{:x}", Sha512::digest(contents))
}),
] {
let mut file = index_file("mods/mod.jar");
file["hashes"] = hashes;
file["fileSize"] = serde_json::json!(contents.len());
let index: MrpackIndex =
serde_json::from_value(serde_json::json!({"files": [file]}))
.unwrap();
validate_index(&index, temp.path()).unwrap();
let integrity = index.files[0].integrity().unwrap();
assert_eq!(integrity.size, Some(contents.len() as u64));
verify_file(&target, &integrity).await.unwrap();
std::fs::write(&target, b"tampered mod").unwrap();
assert!(verify_file(&target, &integrity).await.is_err());
std::fs::write(&target, contents).unwrap();
}
}
#[test] #[test]
fn server_only_files_are_installable() { fn server_only_files_are_installable() {
let installable = MrpackFile { let installable = MrpackFile {
+79 -2
View File
@@ -232,8 +232,24 @@ pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
&& let Some(mut bar) = state.loading_bars.get_mut(&key.0) && let Some(mut bar) = state.loading_bars.get_mut(&key.0)
{ {
match &mut bar.bar_type { match &mut bar.bar_type {
LoadingBarType::HostedPackSync { error: failure, .. } LoadingBarType::HostedPackSync {
| LoadingBarType::HostedModDownload { error: failure, .. } => { error: failure,
state,
can_pause,
can_cancel,
..
} => {
use crate::api::pack::hosted::HostedTaskState;
*state = if *state == HostedTaskState::Canceling {
HostedTaskState::Canceled
} else {
HostedTaskState::Failed
};
*can_pause = false;
*can_cancel = false;
*failure = Some(error.to_owned());
}
LoadingBarType::HostedModDownload { error: failure, .. } => {
*failure = Some(error.to_owned()) *failure = Some(error.to_owned())
} }
_ => {} _ => {}
@@ -241,6 +257,67 @@ pub fn fail_hosted_loading(key: &LoadingBarId, error: &str) {
} }
} }
/// Update the authoritative snapshot and notify observers even while all
/// transfer futures are paused and no byte-progress event can be produced.
pub fn set_hosted_loading_state(
instance_id: &str,
next: crate::api::pack::hosted::HostedTaskState,
) {
use crate::api::pack::hosted::HostedTaskState;
let Ok(event_state) = crate::EventState::get() else {
return;
};
for mut bar in event_state.loading_bars.iter_mut() {
let LoadingBarType::HostedPackSync {
instance_id: id,
state,
can_pause,
can_cancel,
..
} = &mut bar.bar_type
else {
continue;
};
if id != instance_id {
continue;
}
*state = next;
let controllable =
matches!(next, HostedTaskState::Running | HostedTaskState::Paused);
*can_pause = controllable;
*can_cancel = controllable;
match next {
HostedTaskState::Paused => {
bar.message = "已暂停,点击继续以恢复下载".into()
}
HostedTaskState::Canceling => {
bar.message = "正在停止整合包下载".into()
}
HostedTaskState::Running => {
bar.message = "正在继续整合包下载".into()
}
_ => {}
}
#[cfg(feature = "tauri")]
{
let _ = event_state.app.emit(
"loading",
LoadingPayload {
fraction: Some(if bar.total > 0.0 {
bar.current / bar.total
} else {
0.0
}),
message: bar.message.clone(),
event: bar.bar_type.clone(),
loader_uuid: bar.loading_bar_uuid,
total: Some(bar.total),
},
);
}
}
}
// emit_warning(message) // emit_warning(message)
pub async fn emit_warning(message: &str) -> crate::Result<()> { pub async fn emit_warning(message: &str) -> crate::Result<()> {
#[cfg(feature = "tauri")] #[cfg(feature = "tauri")]
+6
View File
@@ -142,6 +142,12 @@ pub enum LoadingBarType {
instance_id: String, instance_id: String,
instance_name: String, instance_name: String,
error: Option<String>, error: Option<String>,
#[serde(default)]
state: crate::api::pack::hosted::HostedTaskState,
#[serde(default)]
can_pause: bool,
#[serde(default)]
can_cancel: bool,
}, },
HostedModDownload { HostedModDownload {
instance_id: String, instance_id: String,
+2 -2
View File
@@ -33,13 +33,13 @@ pub use logger::start_logger;
pub use state::db::{ pub use state::db::{
backup_current_app_db_for_update, beta_database_exists, backup_current_app_db_for_update, beta_database_exists,
copy_database_between_channels, copy_release_database_to_beta, copy_database_between_channels, copy_release_database_to_beta,
current_app_database_path, current_app_database_path, default_update_channel,
}; };
pub use state::{DirectoryInfo, State}; pub use state::{DirectoryInfo, State};
pub use storage::*; pub use storage::*;
pub use util::fetch::build_proxied_client; pub use util::fetch::build_proxied_client;
pub use util::file_lock::{LockingProcess, get_locking_processes}; pub use util::file_lock::{LockingProcess, get_locking_processes};
pub use util::platform::is_process_elevated; pub use util::platform::{default_game_dir_root, is_process_elevated};
pub use util::proxy::{ProxyConfig, ProxyMode, ProxyTestResult}; pub use util::proxy::{ProxyConfig, ProxyMode, ProxyTestResult};
pub use util::symlink::SymlinkCapability; pub use util::symlink::SymlinkCapability;
+13 -1
View File
@@ -304,6 +304,18 @@ async fn app_db_path(settings_dir: &Path) -> crate::Result<PathBuf> {
Ok(settings_dir.join(channel).join(LEGACY_APP_DB_FILE)) Ok(settings_dir.join(channel).join(LEGACY_APP_DB_FILE))
} }
/// The update channel a fresh install should default to. Beta builds carry a
/// pre-release version (e.g. `1.0.2-beta7`), so their installers should start
/// on the Beta channel instead of silently falling back to Release.
pub fn default_update_channel() -> &'static str {
let version = env!("CARGO_PKG_VERSION");
if version.contains('-') {
"beta"
} else {
"release"
}
}
async fn read_update_channel( async fn read_update_channel(
settings_dir: &Path, settings_dir: &Path,
) -> crate::Result<&'static str> { ) -> crate::Result<&'static str> {
@@ -311,7 +323,7 @@ async fn read_update_channel(
let contents = match tokio::fs::read_to_string(&path).await { let contents = match tokio::fs::read_to_string(&path).await {
Ok(contents) => contents, Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => { Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok("release"); return Ok(default_update_channel());
} }
Err(error) => return Err(error.into()), Err(error) => return Err(error.into()),
}; };
+24 -2
View File
@@ -171,8 +171,8 @@ impl DirectoryInfo {
/// Gets the directory of a managed dedicated server by id /// Gets the directory of a managed dedicated server by id
#[inline] #[inline]
pub fn server_dir(&self, server_id: &str) -> PathBuf { pub fn server_dir(&self, server_id: &str) -> crate::Result<PathBuf> {
self.servers_dir().join(server_id) crate::util::io::safe_relative_join(&self.servers_dir(), server_id)
} }
#[inline] #[inline]
@@ -708,6 +708,28 @@ mod resolve_game_dir_tests {
} }
} }
#[test]
fn server_directory_rejects_absolute_and_windows_alias_paths() {
let dirs = dirs();
assert_eq!(
dirs.server_dir("server-1234").unwrap(),
dirs.servers_dir().join("server-1234")
);
for id in [
"../outside",
"C:outside",
"C:/outside",
r"\outside",
r"\\server\share",
".",
"NUL",
"server:stream",
"server.",
] {
assert!(dirs.server_dir(id).is_err(), "{id:?}");
}
}
#[test] #[test]
fn override_used_when_set_and_absolute() { fn override_used_when_set_and_absolute() {
let dirs = dirs(); let dirs = dirs();
+134 -13
View File
@@ -689,6 +689,46 @@ fn bounded_diagnostic_text(value: impl AsRef<str>, max_chars: usize) -> String {
value.as_ref().chars().take(max_chars).collect() value.as_ref().chars().take(max_chars).collect()
} }
fn sanitize_download_error_text(value: &str) -> String {
static URLS: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"(?i)\b(?:https?|socks4a?|socks5h?)://[^\s]+")
.expect("download diagnostic URL pattern should be valid")
});
let sanitized = URLS.replace_all(value, |matched: &regex::Captures<'_>| {
let url = &matched[0];
// A malformed URL must not fall back to logging its userinfo. The
// normal helper also strips signed query strings and fragments.
if Url::parse(url).is_ok() {
sanitize_url_for_log(url)
} else {
"[redacted URL]".to_string()
}
});
bounded_diagnostic_text(sanitized, 256)
}
fn download_transport_error_detail(source: &reqwest::Error) -> String {
// reqwest's Display stops before the useful DNS/TLS/socket cause and can
// include a signed request URL. Walk only its underlying causes instead.
let mut details = Vec::new();
let mut cause = source.source();
while let Some(error) = cause {
let detail = sanitize_download_error_text(&error.to_string());
if details.last() != Some(&detail) {
details.push(detail);
}
if details.len() == 8 {
break;
}
cause = error.source();
}
if details.is_empty() {
sanitize_download_error_text(&source.to_string())
} else {
bounded_diagnostic_text(details.join(": "), 256)
}
}
fn download_error_category(error: &crate::Error) -> &'static str { fn download_error_category(error: &crate::Error) -> &'static str {
match error.raw.as_ref() { match error.raw.as_ref() {
ErrorKind::FetchError(source) => { ErrorKind::FetchError(source) => {
@@ -750,7 +790,16 @@ fn download_error_category(error: &crate::Error) -> &'static str {
fn download_error_detail(error: &crate::Error) -> String { fn download_error_detail(error: &crate::Error) -> String {
match error.raw.as_ref() { match error.raw.as_ref() {
ErrorKind::FetchError(source) => source.status().map_or_else( ErrorKind::FetchError(source) => source.status().map_or_else(
|| format!("{} failure", download_error_category(error)), || {
bounded_diagnostic_text(
format!(
"{} failure: {}",
download_error_category(error),
download_transport_error_detail(source)
),
256,
)
},
|status| format!("HTTP {}", status.as_u16()), |status| format!("HTTP {}", status.as_u16()),
), ),
ErrorKind::LabrinthError(error) => error.status.map_or_else( ErrorKind::LabrinthError(error) => error.status.map_or_else(
@@ -929,8 +978,10 @@ fn add_starlight_direct_recovery_route(routes: &mut Vec<DownloadRoute>) {
.filter(|route| { .filter(|route| {
route.proxy == ProxyPolicy::System route.proxy == ProxyPolicy::System
&& Url::parse(&route.url).ok().is_some_and(|url| { && Url::parse(&route.url).ok().is_some_and(|url| {
url.host_str() == Some("skin.starlight.cool") matches!(
&& url.path().starts_with("/starlight/mod/packs/files/") url.host_str(),
Some("skin.starlight.cool" | "download.starlight.cool")
) && url.path().starts_with("/starlight/mod/packs/files/")
}) })
}) })
.cloned() .cloned()
@@ -6308,7 +6359,9 @@ async fn download_to_path_inner(
source = route.source.as_str(), source = route.source.as_str(),
attempt = attempts, attempt = attempts,
max_attempts = file_attempt_budget, max_attempts = file_attempt_budget,
error = %error, proxy = ?route.proxy,
category = download_error_category(&error),
error = %download_error_detail(&error),
"File download request failed; trying the next source or retry" "File download request failed; trying the next source or retry"
); );
last_error = Some(error); last_error = Some(error);
@@ -8857,18 +8910,86 @@ mod tests {
#[test] #[test]
fn starlight_hosted_files_have_a_direct_recovery_route() { fn starlight_hosted_files_have_a_direct_recovery_route() {
let mut routes = vec![official_route( for url in [
"https://skin.starlight.cool/starlight/mod/packs/files/release/hash", "https://skin.starlight.cool/starlight/mod/packs/files/release/hash",
ResourceClass::Modpack, "https://download.starlight.cool:20443/starlight/mod/packs/files/4/hash",
)]; ] {
let mut routes = vec![official_route(url, ResourceClass::Modpack)];
add_starlight_direct_recovery_route(&mut routes);
add_starlight_direct_recovery_route(&mut routes);
add_starlight_direct_recovery_route(&mut routes); assert_eq!(routes.len(), 2, "{url}");
assert_eq!(routes[0].proxy, ProxyPolicy::System);
assert_eq!(routes[1].proxy, ProxyPolicy::Direct);
assert_eq!(routes[0].url, routes[1].url);
assert!(routes[1].allow_sensitive_headers);
}
}
assert_eq!(routes.len(), 2); #[test]
assert_eq!(routes[0].proxy, ProxyPolicy::System); fn starlight_direct_recovery_excludes_other_paths_and_lookalike_hosts() {
assert_eq!(routes[1].proxy, ProxyPolicy::Direct); for url in [
assert_eq!(routes[0].url, routes[1].url); "https://download.starlight.cool:20443/starlight/mod/packs",
assert!(routes[1].allow_sensitive_headers); "https://download.starlight.cool.evil.example/starlight/mod/packs/files/4/hash",
"https://evil.example/starlight/mod/packs/files/4/hash",
"https://skin.starlight.cool/yggdrasil",
] {
let mut routes = vec![official_route(url, ResourceClass::Modpack)];
add_starlight_direct_recovery_route(&mut routes);
assert_eq!(routes.len(), 1, "{url}");
}
}
#[tokio::test]
async fn download_transport_diagnostic_preserves_causes_without_url_secrets()
{
let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
drop(listener);
let url = format!(
"http://username:password@{address}/file?token=secret#fragment"
);
let error: crate::Error = reqwest::Client::builder()
.no_proxy()
.build()
.unwrap()
.get(url)
.send()
.await
.unwrap_err()
.into();
let detail = download_error_detail(&error);
assert_eq!(download_error_category(&error), "connect");
assert!(detail.contains("connect"), "{detail}");
assert!(
detail.contains("tcp") || detail.contains("os error"),
"{detail}"
);
for secret in ["username", "password", "token", "secret", "fragment"] {
assert!(!detail.contains(secret), "{detail}");
}
}
#[test]
fn download_transport_diagnostic_scrubs_embedded_urls() {
let detail = sanitize_download_error_text(
"request https://username:password@example.com/file?token=secret#fragment failed: connecting to http://proxyuser:proxypass@proxy.example:8080/?key=proxysecret: connection refused",
);
assert!(detail.contains("https://example.com/file"), "{detail}");
assert!(detail.contains("http://proxy.example:8080/"), "{detail}");
assert!(detail.contains("connection refused"), "{detail}");
for secret in [
"username",
"password",
"token",
"secret",
"fragment",
"proxyuser",
"proxypass",
] {
assert!(!detail.contains(secret), "{detail}");
}
} }
#[test] #[test]
+62 -1
View File
@@ -60,6 +60,34 @@ pub(crate) fn io_error_with_lock_info(
io_error_with_lock_info_for_paths(source, path, &[path]) io_error_with_lock_info_for_paths(source, path, &[path])
} }
/// Joins an untrusted Unix-style relative file path, rejecting traversal and
/// Windows aliases on every platform. This is a lexical check, not a symlink
/// sandbox.
pub(crate) fn safe_relative_join(
root: &Path,
relative: &str,
) -> crate::Result<PathBuf> {
path_util::SafeRelativeUtf8UnixPathBuf::try_from(relative.to_string())
.map_err(|error| {
crate::ErrorKind::InputError(format!(
"Invalid file path {relative}: {error}"
))
})?;
if relative.split('/').any(|segment| {
segment.is_empty()
|| segment == "."
|| segment.ends_with(['.', ' '])
|| segment.contains(':')
|| segment.chars().any(char::is_control)
}) {
return Err(crate::ErrorKind::InputError(format!(
"Invalid file path: {relative}"
))
.into());
}
Ok(root.join(relative))
}
/// Join a user-provided relative path onto `root`, rejecting traversal. /// Join a user-provided relative path onto `root`, rejecting traversal.
pub(crate) fn join_within_root( pub(crate) fn join_within_root(
root: &Path, root: &Path,
@@ -95,7 +123,7 @@ pub(crate) fn join_within_root(
) )
.into()); .into());
} }
Ok(root.join(safe)) safe_relative_join(root, &safe.to_string_lossy().replace('\\', "/"))
} }
pub(crate) fn io_error_with_lock_info_for_paths( pub(crate) fn io_error_with_lock_info_for_paths(
@@ -895,6 +923,39 @@ mod windows_sharing_violation_tests {
} }
} }
#[cfg(test)]
mod path_join_tests {
use super::*;
#[test]
fn join_within_root_uses_portable_path_validation() {
let root = Path::new("root");
assert_eq!(
join_within_root(root, r"config\example.toml").unwrap(),
root.join("config/example.toml")
);
assert_eq!(
join_within_root(root, "./config/./example.toml").unwrap(),
root.join("config/example.toml")
);
for relative in [
"../outside",
r"..\outside",
"/outside",
"C:/outside",
"C:outside",
r"\\server\share",
"config/.. /outside",
"config/file:stream",
"config/NUL",
"config/file.",
"config/file\0",
] {
assert!(join_within_root(root, relative).is_err(), "{relative:?}");
}
}
}
#[cfg(test)] #[cfg(test)]
mod copy_dir_tests { mod copy_dir_tests {
use super::*; use super::*;
+29
View File
@@ -1,5 +1,6 @@
//! Platform-related code //! Platform-related code
use daedalus::minecraft::{Os, OsRule}; use daedalus::minecraft::{Os, OsRule};
use std::path::PathBuf;
/// (e.g. "Run as administrator" on Windows). /// (e.g. "Run as administrator" on Windows).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@@ -143,3 +144,31 @@ pub fn classpath_separator(java_arch: &str) -> &'static str {
Os::Windows | Os::WindowsArm64 => ";", Os::Windows | Os::WindowsArm64 => ";",
} }
} }
/// Root directory used as the default for one-click StarLight instance
/// installs. Portable builds live next to their executable, which is a fine
/// place for game data. System installs (e.g. a Linux `.deb` under `/usr/bin`)
/// do not, so fall back to the user's home directory there.
pub fn default_game_dir_root() -> Option<PathBuf> {
let exe_dir = std::env::current_exe().ok()?.parent()?.to_path_buf();
if cfg!(target_os = "linux") && is_system_install_dir(&exe_dir) {
return dirs::home_dir();
}
Some(exe_dir)
}
#[cfg(target_os = "linux")]
fn is_system_install_dir(path: &std::path::Path) -> bool {
const SYSTEM_PREFIXES: &[&str] = &[
"/usr/", "/opt/", "/snap/", "/bin/", "/sbin/", "/lib/", "/lib64/",
];
let display = path.to_string_lossy();
SYSTEM_PREFIXES
.iter()
.any(|prefix| display.starts_with(prefix))
}
#[cfg(not(target_os = "linux"))]
fn is_system_install_dir(_path: &std::path::Path) -> bool {
false
}
+8
View File
@@ -188,6 +188,9 @@ importers:
vuedraggable: vuedraggable:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(vue@3.5.27(typescript@5.9.3)) version: 4.1.0(vue@3.5.27(typescript@5.9.3))
xxhash-wasm:
specifier: 1.1.0
version: 1.1.0
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^1.1.1 specifier: ^1.1.1
@@ -7103,6 +7106,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'} engines: {node: '>=0.4'}
xxhash-wasm@1.1.0:
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
y18n@5.0.8: y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -14160,6 +14166,8 @@ snapshots:
xtend@4.0.2: {} xtend@4.0.2: {}
xxhash-wasm@1.1.0: {}
y18n@5.0.8: {} y18n@5.0.8: {}
yallist@3.1.1: {} yallist@3.1.1: {}
+23
View File
@@ -1,5 +1,6 @@
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import { parse, TYPE } from '@formatjs/icu-messageformat-parser' import { parse, TYPE } from '@formatjs/icu-messageformat-parser'
import { collectSkinSiteMessages } from './skin-site-messages.mjs'
const localePairs = [ const localePairs = [
[ [
@@ -10,6 +11,22 @@ const localePairs = [
] ]
const failures = [] const failures = []
const skinSiteCatalogs = Object.fromEntries(
await Promise.all(
['en-US', 'zh-CN'].map(async (locale) => [
locale,
JSON.parse(await readFile(`apps/app-frontend/src/locales/${locale}/index.json`, 'utf8')),
]),
),
)
for (const { id, file } of collectSkinSiteMessages()) {
for (const locale of ['en-US', 'zh-CN']) {
const catalogPath = `apps/app-frontend/src/locales/${locale}/index.json`
const catalog = skinSiteCatalogs[locale]
if (!catalog[id]?.message)
failures.push(`${catalogPath}: missing source message ${id} (${file})`)
}
}
const allowedUntranslatedMessages = new Set([ const allowedUntranslatedMessages = new Set([
'Chaos Cubed', 'Chaos Cubed',
'MINECON Earth 2017', 'MINECON Earth 2017',
@@ -30,6 +47,12 @@ const allowedUntranslatedMessages = new Set([
'Cleanroom', 'Cleanroom',
'LiteLoader', 'LiteLoader',
'LogShare.CN', 'LogShare.CN',
'UUID',
'ID',
'QQ',
'IP',
'URL',
'Mod ID',
'LogShare AI', 'LogShare AI',
'NeoForge', 'NeoForge',
'OptiFine', 'OptiFine',
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more