Compare commits

..
56 Commits
Author SHA1 Message Date
AxTps 2f34faa50f fix: 1.0.1 更新公告改到正确的 catalog.ts(网站 catalog.json 回滚)
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-03 14:01:35 +08:00
AxTps a57dfb828e docs: 添加 1.0.1 正式版更新公告
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-03 13:49:45 +08:00
Ap_Tx eec6b47ee7 Merge pull request #6 from ApTx2012/feat/axolotl-port
Feat/axolotl port
2026-10-03 12:55:16 +08:00
AxTps f776bf8995 fix: 修复跨 crate 可见性与 Version 导入,补充 semver 依赖
Sync source to CNB / Sync Git ref (push) Has been cancelled
- updater_impl.rs: Version 改从 semver 导入(tauri_plugin_updater::Version 是私有)

- files.rs: 移除未使用的 list 导入

- state/mod.rs: update_http_client_for_settings 由 pub(crate) 改 pub(apps/app 跨 crate 调用)

- workspace 与 apps/app 补充 semver 依赖,同步 Cargo.lock
2026-10-03 11:20:33 +08:00
AxTps 69a00888ba ci: 给 changelog-comment 的 job 显式声明 permissions
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 11:03:59 +08:00
AxTps 996044bdc2 fix: 接入备份维护钩子并补齐 SQLX 缓存
Sync source to CNB / Sync Git ref (push) Has been cancelled
- ProcessManager 新增 has_instance_process(备份启动守卫依赖)

- 删除/启动/编辑/迁移目录时接入备份维护钩子(lock_instance_maintenance + begin_instance_deletion + delete_instance_backups),remove_instance 拆出 remove_instance_files_and_state

- run 启动前检查活跃备份操作并加维护锁

- 目录迁移时搬移默认备份仓库并入队清理

- 更新 settings SELECT 的 SQLX 离线缓存(补 ui_font/backup_repository_path/mono_font)

- create_recorded_symlink 非 Windows 未用参数加 cfg_attr 豁免
2026-10-03 10:59:16 +08:00
AxTps 4674f31dd3 Merge remote-tracking branch 'origin/main' into feat/axolotl-port
Sync source to CNB / Sync Git ref (push) Has been cancelled
# Conflicts:
#	apps/app/src/updater_impl.rs
2026-10-03 10:21:43 +08:00
AxTps fe61337a34 style: 运行 cargo fmt 修正格式(备份命令/更新比较/事件与设置文件的换行与缩进)
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 10:18:53 +08:00
AxTps b633f7cdf0 fix: 修复 beta9 无法自动更新到 beta10——自定义版本比较器按数字解析 prerelease
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 10:11:49 +08:00
AxTps 8df1ac8a86 fix: 修复 beta9 无法自动更新到 beta10——自定义版本比较器按数字解析 prerelease
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
2026-10-03 10:08:43 +08:00
AxTps a99a0a6e30 feat(backup): 实例备份第5轮——新增备份设置/排除选择器/仓库设置组件,接入实例设置与存储设置页,补齐中英繁文案
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-03 09:43:57 +08:00
AxTps 773cf387a3 feat(backup): 实例备份第4轮——新增前端 helper(instance-backup.ts)及单元测试 2026-10-03 09:37:41 +08:00
AxTps 7414477bbd feat(backup): 实例备份第3轮——注册 15 个 Tauri 备份命令并补齐 ACL 权限 2026-10-03 09:32:46 +08:00
AxTps e8f10ab1ef feat(backup): 实例备份第2轮——移植 backup.rs 核心实现并接入 instance 模块导出 2026-10-03 09:19:28 +08:00
AxTps e91ba2ae74 feat(backup): 实例备份第1轮——独立备份库迁移、settings 仓库路径字段、备份进度事件 2026-10-03 09:12:35 +08:00
AxTps 95a3e00b03 feat(fonts): 外观设置新增界面/等宽字体选择器,含中英繁中文案
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 23:04:12 +08:00
AxTps f490600ee2 feat(fonts): 前端字体工具函数、主题字体状态与设置类型 2026-10-02 21:57:24 +08:00
AxTps b57219f348 feat(fonts): 后端字体命令 fonts_get_system_fonts 及 ui_font/mono_font 设置字段 2026-10-02 21:33:13 +08:00
AxTps ead942abb2 feat: 移植忽略 SSL 证书错误选项(设置开关 + 重建 HTTP 客户端)
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-02 21:20:26 +08:00
AxTps d2c32ed14f style: 修正 feature_flags 反序列化处的 rustfmt 格式,修复 release preflight 失败
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-02 20:47:19 +08:00
AxTps 4b9b9ebf47 docs: 添加 1.0.1-beta10 更新公告
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-02 20:42:12 +08:00
AxTps 457dfd8c30 refactor: App.vue 整包写回改为始终基于最新设置,彻底消除旧快照覆盖隐患
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-02 20:38:31 +08:00
AxTps 8ec5cf104c chore: 补充实例页导览四步的 en-US 文案
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-02 20:29:12 +08:00
AxTps 306328367e feat: 重做实例页导览为内容/文件/世界/日志四步,并修复 inspect 步点导览气泡无法继续的问题 2026-10-02 20:24:20 +08:00
AxTps 3901749d93 fix: 皮肤站内嵌 iframe 显式委派 clipboard-write/read 权限,修复无法复制 2026-10-02 20:06:24 +08:00
AxTps cee0c3bbd4 fix: 批量编辑分组失败时改用 handleError 提示,不再静默吞掉异常 2026-10-02 20:04:07 +08:00
AxTps 63ca20db04 fix: edit_to_core 仅在相关字段变更时才带 content_set/launch_overrides patch,修复无 content set 的实例改分组失败 2026-10-02 20:00:49 +08:00
AxTps 0388e339db feat: 安装位置选到盘符根时自动追加 Starlight 子目录,避免文件散落根目录
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 19:44:45 +08:00
AxTps d4554b0f4c fix: onboarding/locale 整包写回前用 themeStore 回填 feature_flags,避免功能开关被旧快照覆盖 2026-10-02 19:27:57 +08:00
AxTps d5471bb36c fix: feature_flags 反序列化忽略未知开关,避免前后端枚举不同步导致启动崩溃 2026-10-02 19:16:45 +08:00
AxTps f238eeae0e fix: 界面缩放持久化被 onboarding/locale 整包写回覆盖,保存前用最新 uiScale 回填 2026-10-02 19:02:48 +08:00
AxTps ae9a762439 fix: 修复 routes.js 中多人联机子路由缺失对象起始括号导致的语法错误
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-02 15:07:58 +08:00
AxTps 8480927b99 docs: 添加 1.0.1-beta9 更新公告
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-02 14:24:25 +08:00
AxTps 24f5761317 fix: 补齐 FeatureFlag 枚举的 MultiplayerInApp,修复极简主页 settings_set 反序列化报错
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-02 13:43:34 +08:00
AxTps 3d003f9b31 fix: 多人游戏根路径重定向到房间页
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-02 13:23:37 +08:00
AxTps 1b487da5c2 fix: 修复截图中心菜单/编辑不可用与导航项缺中文
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
2026-10-02 13:20:04 +08:00
AxTps b310018c38 docs: 添加 1.0.1-beta8 更新公告
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Sync LobeHub models / sync (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-02 11:03:59 +08:00
AxTps fbde37f5a0 fix: 补全新增 command 的 Tauri 权限
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-02 10:58:11 +08:00
AxTps e6f9987694 fix: 修复侧边栏导航项损坏与 hosted_has_game_account 权限缺失 2026-10-02 10:50:53 +08:00
AxTps 5a08a1ed3a fix: 修复公告目录缺失的对象起始括号
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Starlight release / Build (Windows x64) (push) Has been cancelled
Starlight release / Build (Linux x64) (push) Has been cancelled
Starlight release / Build (macOS universal) (push) Has been cancelled
Starlight release / Verify and publish (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Starlight release / Release preflight (push) Has been cancelled
Starlight release / Build (Linux arm64) (push) Has been cancelled
2026-10-02 09:09:53 +08:00
AxTps e51abeb5ad docs: 添加 1.0.1-beta7 更新公告
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-02 08:26:24 +08:00
Ap_Tx 4c13f849eb Merge pull request #5 from ApTx2012/feat/screenshot-center
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
feat: 移植截图中心(浏览/分组/编辑/导出)
2026-10-02 08:21:07 +08:00
AxTps 00bfe2de9f fix: 补上 fs_scope 所需的 FsExt trait 导入
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 06:47:40 +08:00
AxTps 95b9c044eb feat: 补充图像编辑器所需的图标
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 06:33:45 +08:00
AxTps 911a2cb872 feat: 补充图像编辑器所需的图标
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 06:22:03 +08:00
AxTps 0a60695dc9 fix: 补上缺失的 instance-groups helper
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-02 06:09:37 +08:00
AxTps 643d7276d7 chore: 豁免文件名与占位符类文案的翻译检查
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 23:04:33 +08:00
AxTps e066c6a8d7 i18n: 补全截图中心与图像编辑器的中文翻译
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 23:00:04 +08:00
AxTps b5406c2da0 feat: 移植截图中心(浏览/分组/编辑/导出)
Sync source to CNB / Sync Git ref (push) Has been cancelled
Prepare pnpm cache / prepare (push) Has been cancelled
2026-10-01 22:45:02 +08:00
AxTps 300df18aca chore: 更新 sqlx 缓存并修复截图模块告警
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 21:40:56 +08:00
AxTps 5efbe048eb chore: cargo fmt
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 21:24:05 +08:00
AxTps ead5d96b78 feat: 移植截图中心后端(索引/分组/编辑保存)
Sync source to CNB / Sync Git ref (push) Has been cancelled
2026-10-01 21:08:57 +08:00
AxTps e646598078 fix: 导览首步锚点缺失时不再自动跳过
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 18:52:50 +08:00
AxTps f1ff19e4b8 chore: 更新 sqlx 查询缓存(新增 ui_scale 列)
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 17:16:49 +08:00
AxTps 34cf186c33 feat: 界面缩放改用后端持久化 + 原生 webview zoom
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 16:21:30 +08:00
AxTps 81b7219085 feat: 恢复多人联机(陶瓦)侧边栏入口,挂于功能开关后
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
2026-10-01 15:59:18 +08:00
138 changed files with 19469 additions and 1204 deletions

No files matched your search

+5
View File
@@ -19,6 +19,11 @@ jobs:
name: Post changelog comment name: Post changelog comment
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
contents: read
steps: steps:
- name: Post or update changelog comment - name: Post or update changelog comment
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
Generated
+48
View File
@@ -1889,6 +1889,28 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -3477,6 +3499,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "memoffset" name = "memoffset"
version = "0.9.1" version = "0.9.1"
@@ -5102,6 +5133,12 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -5758,6 +5795,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.2" version = "1.15.2"
@@ -6912,6 +6958,7 @@ dependencies = [
"either", "either",
"enumset", "enumset",
"flate2", "flate2",
"fontdb",
"futures", "futures",
"hyper", "hyper",
"hyper-util", "hyper-util",
@@ -6926,6 +6973,7 @@ dependencies = [
"path-util", "path-util",
"quartz_nbt", "quartz_nbt",
"regex", "regex",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"serde_with", "serde_with",
+2
View File
@@ -44,6 +44,7 @@ encoding_rs = "0.8.35"
enumset = "1.1.10" enumset = "1.1.10"
eyre = "0.6.12" eyre = "0.6.12"
flate2 = "1.1.4" flate2 = "1.1.4"
fontdb = "0.24.0"
fs4 = { version = "0.13.1", default-features = false } fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31" futures = "0.3.31"
futures-lite = "2.6.1" futures-lite = "2.6.1"
@@ -83,6 +84,7 @@ rgb = "0.8.52"
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] } rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8.4" rustls-native-certs = "0.8.4"
rustls-pki-types = "1.15.0" rustls-pki-types = "1.15.0"
semver = "1.0.28"
serde = "1.0.228" serde = "1.0.228"
serde_bytes = "0.11.19" serde_bytes = "0.11.19"
serde_cbor = "0.11.2" serde_cbor = "0.11.2"
+3
View File
@@ -14,6 +14,9 @@
"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/utilities": "3.2.2",
"@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:*",
+27 -2
View File
@@ -8,6 +8,7 @@ import {
FlaskConicalIcon, FlaskConicalIcon,
FolderOpenIcon, FolderOpenIcon,
HomeIcon, HomeIcon,
ImagesIcon,
LeftArrowIcon, LeftArrowIcon,
LibraryIcon, LibraryIcon,
LogInIcon, LogInIcon,
@@ -19,6 +20,7 @@ import {
SettingsIcon, SettingsIcon,
SpinnerIcon, SpinnerIcon,
UserIcon, UserIcon,
UsersIcon,
WorldIcon, WorldIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
@@ -141,7 +143,7 @@ import {
isNetworkMetered, isNetworkMetered,
setRestartAfterPendingUpdate, setRestartAfterPendingUpdate,
} from '@/helpers/utils.js' } from '@/helpers/utils.js'
import { installUiScale } from '@/helpers/ui-scale' 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 {
@@ -765,6 +767,10 @@ const messages = defineMessages({
id: 'app.navigation.multiplayer', id: 'app.navigation.multiplayer',
defaultMessage: 'Multiplayer', defaultMessage: 'Multiplayer',
}, },
screenshots: {
id: 'app.navigation.screenshots',
defaultMessage: 'Screenshots',
},
downloads: { downloads: {
id: 'app.navigation.downloads', id: 'app.navigation.downloads',
defaultMessage: 'Downloads', defaultMessage: 'Downloads',
@@ -1090,6 +1096,7 @@ async function setupApp() {
close_behavior, close_behavior,
developer_mode, developer_mode,
feature_flags, feature_flags,
ui_scale,
} = initialSettings } = initialSettings
// Initialize locale from saved settings // Initialize locale from saved settings
@@ -1120,6 +1127,7 @@ async function setupApp() {
nativeDecorations.value = native_decorations nativeDecorations.value = native_decorations
if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations) if (os.value !== 'MacOS') await getCurrentWindow().setDecorations(native_decorations)
setUiScale(ui_scale)
themeStore.setThemeState(theme) themeStore.setThemeState(theme)
await initializeSystemAccentColor() await initializeSystemAccentColor()
themeStore.setAccentColor(accent_color) themeStore.setAccentColor(accent_color)
@@ -1252,7 +1260,7 @@ async function replayOnboarding(mode) {
async function finishOnboarding() { async function finishOnboarding() {
const wasReplay = onboardingReplay.value const wasReplay = onboardingReplay.value
const settings = onboardingSettings.value ?? (await getSettings()) const settings = await getSettings()
if (!onboardingReplay.value) { if (!onboardingReplay.value) {
if (onboardingMode.value === 'instance') { if (onboardingMode.value === 'instance') {
settings.onboarding_instance_tour_completed = true settings.onboarding_instance_tour_completed = true
@@ -2312,6 +2320,23 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
> >
<LibraryIcon /> <LibraryIcon />
</NavButton> </NavButton>
<NavButton
v-if="themeStore.featureFlags.multiplayer_in_app"
v-tooltip.right="formatMessage(messages.multiplayer)"
data-onboarding-id="nav-multiplayer"
to="/multiplayer"
:is-primary="(r) => r.path.startsWith('/multiplayer')"
>
<UsersIcon />
</NavButton>
<NavButton
v-tooltip.right="formatMessage(messages.screenshots)"
data-onboarding-id="nav-screenshots"
to="/screenshots"
:is-primary="(r) => r.path.startsWith('/screenshots')"
>
<ImagesIcon />
</NavButton>
<NavButton <NavButton
v-tooltip.right="formatMessage(messages.lab)" v-tooltip.right="formatMessage(messages.lab)"
data-onboarding-id="nav-lab" data-onboarding-id="nav-lab"
@@ -32,6 +32,174 @@ export const ANNOUNCEMENT_CHANGE_TYPES: readonly AnnouncementChangeType[] = [
] ]
export const launcherAnnouncements: readonly LauncherAnnouncement[] = [ export const launcherAnnouncements: readonly LauncherAnnouncement[] = [
{
id: 'launcher-1.0.1',
version: '1.0.1',
publishedAt: '2026-10-03',
title: {
'en-US': 'Starlight Launcher 1.0.1',
'zh-CN': 'Starlight Launcher 1.0.1',
},
changes: {
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.',
'zh-CN': '实例备份:在实例关闭时创建去重快照,可预览与恢复,支持排除指定文件或文件夹,并可迁移备份仓库。',
},
{
'en-US': "Customize the launcher's UI font and monospace font in appearance settings.",
'zh-CN': '在外观设置中自定义启动器界面字体与等宽字体。',
},
],
fixed: [
{
'en-US': '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.',
'zh-CN': '修复因跨 crate 可见性导致的编译失败,此前启用“忽略 SSL 证书错误”选项时无法编译。',
},
],
},
},
{
id: 'launcher-1.0.1-beta10',
version: '1.0.1-beta10',
publishedAt: '2026-10-02',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta10',
'zh-CN': 'Starlight Launcher 1.0.1-beta10',
},
changes: {
added: [
{
'en-US': 'Reworked the instance-page tour into four steps covering content, files, worlds, and logs.',
'zh-CN': '重做实例页导览,分为内容、文件、世界、日志四步。',
},
{
'en-US': 'When the install location is a drive root, the installer now creates a Starlight subfolder automatically.',
'zh-CN': '安装位置选到盘符根目录时,安装器会自动创建 Starlight 子文件夹。',
},
],
fixed: [
{
'en-US': 'Fixed the launcher UI scale reverting after a restart.',
'zh-CN': '修复启动器界面缩放重启后恢复原状的问题。',
},
{
'en-US': 'Fixed developer feature flags not saving.',
'zh-CN': '修复开发者功能开关无法保存的问题。',
},
{
'en-US': 'Fixed instance group edits not saving for instances without an applied content set.',
'zh-CN': '修复没有应用内容集的实例无法保存分组修改的问题。',
},
{
'en-US': 'Fixed the launcher crashing on startup when feature flags were out of sync between the frontend and backend.',
'zh-CN': '修复前后端功能开关不同步导致启动器启动崩溃的问题。',
},
{
'en-US': 'Fixed being unable to copy text inside the embedded skin site.',
'zh-CN': '修复内嵌皮肤站中无法复制文本的问题。',
},
{
'en-US': 'Fixed the onboarding prompt not dismissing when clicking anywhere.',
'zh-CN': '修复导览提示点击任意位置无法关闭的问题。',
},
],
},
},
{
id: 'launcher-1.0.1-beta9',
version: '1.0.1-beta9',
publishedAt: '2026-10-02',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta9',
'zh-CN': 'Starlight Launcher 1.0.1-beta9',
},
changes: {
fixed: [
{
'en-US':
'Fixed a crash on the minimal home layout caused by an unrecognized multiplayer feature flag (missing backend enum variant).',
'zh-CN': '修复多人联机功能开关缺少后端枚举变体、导致精简主页报错的问题。',
},
],
},
},
{
id: 'launcher-1.0.1-beta8',
version: '1.0.1-beta8',
publishedAt: '2026-10-02',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta8',
'zh-CN': 'Starlight Launcher 1.0.1-beta8',
},
changes: {
fixed: [
{
'en-US':
'Fixed the sidebar navigation entries breaking after the screenshot center was added.',
'zh-CN': '修复添加截图中心后侧边栏导航项损坏的问题。',
},
{
'en-US':
'Fixed being unable to install the StarLight hosted pack even after signing in (missing command permission).',
'zh-CN': '修复登录后仍无法安装 StarLight 实例的问题(命令权限缺失)。',
},
{
'en-US':
'Fixed the launcher UI scaling not applying when changed (missing command permission).',
'zh-CN': '修复修改启动器界面缩放后不生效的问题(命令权限缺失)。',
},
{
'en-US':
'Fixed several screenshot center actions failing due to missing command permissions.',
'zh-CN': '修复截图中心部分操作因命令权限缺失而失败的问题。',
},
],
},
},
{
id: 'launcher-1.0.1-beta7',
version: '1.0.1-beta7',
publishedAt: '2026-10-02',
title: {
'en-US': 'Starlight Launcher 1.0.1-beta7',
'zh-CN': 'Starlight Launcher 1.0.1-beta7',
},
changes: {
added: [
{
'en-US':
'Added a screenshot center: browse screenshots across every instance, group them (custom groups, by instance, or by date), preview, edit (crop, rotate, annotate, censor), and export as ZIP.',
'zh-CN':
'新增截图中心:跨实例浏览截图,支持自定义/按实例/按日期分组,可预览、编辑(裁剪、标注、打码等)并导出为 ZIP。',
},
{
'en-US':
'Added the multiplayer (Terracotta) entry to the sidebar, behind a feature flag.',
'zh-CN': '侧边栏新增多人联机(陶瓦)入口,由功能开关控制。',
},
],
changed: [
{
'en-US':
'Launcher UI scaling is now persisted in the backend and applied via native webview zoom, so it scales embedded pages correctly and keeps onboarding anchors aligned.',
'zh-CN':
'启动器界面缩放改为后端持久化,并通过原生 webview 缩放应用,可正确缩放内嵌页面且导览锚点不再偏移。',
},
],
fixed: [
{
'en-US':
'Fixed the onboarding tour skipping its first step when the target anchor was missing.',
'zh-CN': '修复导览首步在目标锚点缺失时被自动跳过的问题。',
},
],
},
},
{ {
id: 'launcher-1.0.1-beta6', id: 'launcher-1.0.1-beta6',
version: '1.0.1-beta6', version: '1.0.1-beta6',
@@ -16,6 +16,7 @@ const messages = defineMessages({
<template> <template>
<iframe <iframe
:src="skinSiteFrameUrl" :src="skinSiteFrameUrl"
allow="clipboard-write; clipboard-read"
:title="formatMessage(messages.frameTitle)" :title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0" class="block h-full min-h-0 w-full border-0"
/> />
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { FileIcon, FolderIcon, MinusIcon, PlusIcon } from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
import {
type BackupExclusion,
type BackupExclusionKind,
canonicalizeBackupExclusions,
formatBackupExclusionPath,
normalizeBackupExclusion,
} from '@/helpers/instance-backup'
const props = defineProps<{
instanceId: string
instanceRoot: string
modelValue: BackupExclusion[]
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: BackupExclusion[]] }>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const picking = ref<BackupExclusionKind | null>(null)
const messages = defineMessages({
empty: {
id: 'instance.backups.exclusions.empty',
defaultMessage: 'No files or folders are excluded.',
},
addFile: {
id: 'instance.backups.exclusions.add-file',
defaultMessage: 'Add excluded file',
},
addFolder: {
id: 'instance.backups.exclusions.add-folder',
defaultMessage: 'Add excluded folder',
},
remove: {
id: 'instance.backups.exclusions.remove',
defaultMessage: 'Remove {path} from exclusions',
},
})
function pickedPath(result: unknown): string | null {
if (typeof result === 'string') return result
if (result && typeof result === 'object' && 'path' in result) {
const path = (result as { path?: unknown }).path
return typeof path === 'string' ? path : null
}
return null
}
async function addExclusion(kind: BackupExclusionKind) {
if (props.disabled || picking.value) return
picking.value = kind
try {
const result = await open({
directory: kind === 'directory',
multiple: false,
defaultPath: props.instanceRoot,
})
const selectedPath = pickedPath(result)
if (!selectedPath) return
const exclusion = await normalizeBackupExclusion(props.instanceId, selectedPath, kind)
emit('update:modelValue', canonicalizeBackupExclusions([...props.modelValue, exclusion]))
} catch (error) {
handleError(error)
} finally {
picking.value = null
}
}
function removeExclusion(path: string) {
emit(
'update:modelValue',
props.modelValue.filter((exclusion) => exclusion.path !== path),
)
}
</script>
<template>
<div class="flex flex-col gap-3">
<p v-if="modelValue.length === 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.empty) }}
</p>
<div
v-else
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
>
<div
v-for="exclusion in modelValue"
:key="exclusion.path"
class="flex min-h-11 items-center gap-3 px-3 py-2"
>
<FolderIcon
v-if="exclusion.kind === 'directory'"
class="size-5 shrink-0 text-[var(--color-text-tertiary)]"
/>
<FileIcon v-else class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
<code class="min-w-0 flex-1 truncate text-sm text-[var(--color-text-primary)]">{{
formatBackupExclusionPath(exclusion)
}}</code>
<Button
type="quiet"
size="2xs"
circular
icon-only
:disabled="disabled || picking !== null"
:aria-label="
formatMessage(messages.remove, { path: formatBackupExclusionPath(exclusion) })
"
@click="removeExclusion(exclusion.path)"
><MinusIcon />
</Button>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button type="outlined" :disabled="disabled || picking !== null" @click="addExclusion('file')"
><PlusIcon />
{{ formatMessage(messages.addFile) }}
</Button>
<Button
type="outlined"
:disabled="disabled || picking !== null"
@click="addExclusion('directory')"
><PlusIcon />
{{ formatMessage(messages.addFolder) }}
</Button>
</div>
</div>
</template>
@@ -0,0 +1,675 @@
<script setup lang="ts">
import { FileArchiveIcon, SaveIcon, TrashIcon, UndoIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useFormatBytes,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { get_full_path } from '@/helpers/instance'
import {
type BackupExclusion,
type BackupOperation,
type BackupOperationState,
type BackupProgressStage,
type BackupRestorePreview,
type BackupSnapshot,
cancelBackup,
deleteBackup,
disableBackups,
enableBackups,
getBackupConfig,
type InstanceBackupConfig,
type InstanceBackupEligibility,
listBackupOperations,
listBackups,
listenBackupProgress,
restoreBackup,
startBackup,
startBackupRestorePreview,
updateBackupExclusions,
} from '@/helpers/instance-backup'
import { injectInstanceSettings } from '@/providers/instance-settings'
import BackupExclusionSelector from './BackupExclusionSelector.vue'
const { instance } = injectInstanceSettings()
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'instance.backups.title', defaultMessage: 'Instance backups' },
description: {
id: 'instance.backups.description',
defaultMessage: 'Create deduplicated snapshots of the whole instance while it is closed.',
},
loading: { id: 'instance.backups.loading', defaultMessage: 'Loading backup settings...' },
unavailable: { id: 'instance.backups.unavailable', defaultMessage: 'Backups are unavailable' },
notInstalled: {
id: 'instance.backups.unavailable.not-installed',
defaultMessage: 'Finish installing this instance before enabling backups.',
},
symlinkInstance: {
id: 'instance.backups.unavailable.symlink',
defaultMessage: 'Imported symbolic-link instances cannot be backed up.',
},
directLinked: {
id: 'instance.backups.unavailable.direct-linked',
defaultMessage: 'Instances linked directly to another launcher cannot be backed up.',
},
externalDirectory: {
id: 'instance.backups.unavailable.external-directory',
defaultMessage: 'Instances that use an external game directory cannot be backed up.',
},
rootMissing: {
id: 'instance.backups.unavailable.root-missing',
defaultMessage: 'The instance folder is missing.',
},
rootLink: {
id: 'instance.backups.unavailable.root-link',
defaultMessage: 'Instances whose root folder is a link cannot be backed up.',
},
choose: {
id: 'instance.backups.choose',
defaultMessage:
'Everything is backed up by default. Add files or folders that should be excluded.',
},
enable: { id: 'instance.backups.enable', defaultMessage: 'Enable backups' },
editExclusions: { id: 'instance.backups.edit-exclusions', defaultMessage: 'Edit exclusions' },
saveExclusions: {
id: 'instance.backups.save-exclusions',
defaultMessage: 'Save exclusions',
},
allIncluded: {
id: 'instance.backups.all-included',
defaultMessage: 'All instance files and folders are included.',
},
excludedPaths: {
id: 'instance.backups.excluded-paths',
defaultMessage: 'Excluded: {paths}',
},
backupNow: { id: 'instance.backups.backup-now', defaultMessage: 'Back up now' },
cancelBackup: { id: 'instance.backups.cancel', defaultMessage: 'Cancel backup' },
stageScanning: {
id: 'instance.backups.stage.scanning',
defaultMessage: 'Scanning files',
},
stageHashing: { id: 'instance.backups.stage.hashing', defaultMessage: 'Hashing files' },
stageSaving: { id: 'instance.backups.stage.saving', defaultMessage: 'Saving snapshot index' },
stageQueued: { id: 'instance.backups.stage.queued', defaultMessage: 'Waiting to start' },
stageValidating: {
id: 'instance.backups.stage.validating',
defaultMessage: 'Validating restore',
},
stageMaterializing: {
id: 'instance.backups.stage.materializing',
defaultMessage: 'Preparing changed files',
},
stageApplying: {
id: 'instance.backups.stage.applying',
defaultMessage: 'Applying changed files',
},
stagePreview: {
id: 'instance.backups.stage.preview',
defaultMessage: 'Analyzing restore changes',
},
progressBytes: {
id: 'instance.backups.progress-bytes',
defaultMessage: '{processed} of {total}',
},
stageFailed: { id: 'instance.backups.stage.failed', defaultMessage: 'Backup failed' },
stageInterrupted: {
id: 'instance.backups.stage.interrupted',
defaultMessage: 'Backup operation was interrupted',
},
snapshots: { id: 'instance.backups.snapshots', defaultMessage: 'Snapshots' },
empty: { id: 'instance.backups.empty', defaultMessage: 'No backups have been created yet.' },
snapshotMeta: {
id: 'instance.backups.snapshot-meta',
defaultMessage: '{files, plural, one {# file} other {# files}} · {size}',
},
addedSize: { id: 'instance.backups.added-size', defaultMessage: '{size} newly stored' },
restore: { id: 'instance.backups.restore', defaultMessage: 'Restore backup' },
delete: { id: 'instance.backups.delete', defaultMessage: 'Delete backup' },
disable: { id: 'instance.backups.disable', defaultMessage: 'Disable backups' },
disableBlocked: {
id: 'instance.backups.disable-blocked',
defaultMessage: 'Delete every snapshot before disabling backups.',
},
restoreTitle: { id: 'instance.backups.restore-title', defaultMessage: 'Restore this backup?' },
restoreBody: {
id: 'instance.backups.restore-body',
defaultMessage:
'Upcoming operations: add {added, plural, one {# file} other {# files}}, modify {modified, plural, one {# file} other {# files}}, and delete {deleted, plural, one {# file} other {# files}}. Continue?',
},
deleteTitle: { id: 'instance.backups.delete-title', defaultMessage: 'Delete this backup?' },
deleteBody: {
id: 'instance.backups.delete-body',
defaultMessage:
'This snapshot will be permanently deleted. Unused stored files will also be removed.',
},
})
const config = ref<InstanceBackupConfig | null>(null)
const snapshots = ref<BackupSnapshot[]>([])
const instanceRoot = ref('')
const loading = ref(true)
const editingExclusions = ref(false)
const excludedPaths = ref<BackupExclusion[]>([])
const action = ref<string | null>(null)
const operation = ref<BackupOperation | null>(null)
const selectedSnapshot = ref<BackupSnapshot | null>(null)
const restorePreview = ref<BackupRestorePreview | null>(null)
const deleteModal = ref<InstanceType<typeof NewModal>>()
const restoreModal = ref<InstanceType<typeof NewModal>>()
let unlisten: (() => void) | null = null
const eligible = computed(() => config.value?.eligibility === 'eligible')
const terminalStates: BackupOperationState[] = ['completed', 'cancelled', 'failed', 'interrupted']
const isRunning = computed(
() => operation.value !== null && !terminalStates.includes(operation.value.state),
)
const canCancel = computed(() => operation.value?.cancellable === true)
const operationLabel = computed(() => {
if (operation.value?.operation_type === 'restore_preview') {
return formatMessage(messages.stagePreview)
}
switch (operation.value?.state) {
case 'queued':
return formatMessage(messages.stageQueued)
case 'hashing':
return formatMessage(messages.stageHashing)
case 'saving':
return formatMessage(messages.stageSaving)
case 'validating':
return formatMessage(messages.stageValidating)
case 'materializing':
return formatMessage(messages.stageMaterializing)
case 'applying':
return formatMessage(messages.stageApplying)
default:
return formatMessage(messages.stageScanning)
}
})
function operationFromEvent(event: {
operationId: string
operationType: BackupOperation['operation_type']
instanceId?: string
stage: BackupProgressStage
processedBytes: number
totalBytes: number
snapshotId?: string
message?: string
}): BackupOperation {
const now = Date.now()
const state: BackupOperationState =
event.stage === 'restoring' || event.stage === 'copying'
? 'materializing'
: event.stage === 'deleting'
? 'queued'
: event.stage
return {
id: event.operationId,
operation_type: event.operationType,
instance_id: event.instanceId ?? instance.value.id,
snapshot_id: event.snapshotId,
state,
processed_bytes: event.processedBytes,
total_bytes: event.totalBytes,
cancellable: event.stage === 'scanning' || event.stage === 'hashing',
cancel_requested: false,
error: event.message,
created_at: operation.value?.id === event.operationId ? operation.value.created_at : now,
updated_at: now,
finished_at: ['completed', 'cancelled', 'failed'].includes(event.stage) ? now : undefined,
}
}
async function refreshOperation() {
const operations = await listBackupOperations(instance.value.id)
const latest = operations[0] ?? null
operation.value =
latest && (isRunningState(latest.state) || ['failed', 'interrupted'].includes(latest.state))
? latest
: null
if (operation.value?.operation_type === 'restore_preview' && operation.value.snapshot_id) {
selectedSnapshot.value =
snapshots.value.find((snapshot) => snapshot.id === operation.value?.snapshot_id) ?? null
}
}
function isRunningState(state: BackupOperationState) {
return !terminalStates.includes(state)
}
const eligibilityMessages: Record<
Exclude<InstanceBackupEligibility, 'eligible'>,
keyof typeof messages
> = {
not_installed: 'notInstalled',
symlink_instance: 'symlinkInstance',
direct_linked_instance: 'directLinked',
external_game_directory: 'externalDirectory',
root_missing: 'rootMissing',
root_is_link: 'rootLink',
}
const unavailableReason = computed(() => {
const eligibility = config.value?.eligibility
if (!eligibility || eligibility === 'eligible') return ''
return formatMessage(messages[eligibilityMessages[eligibility]])
})
async function refresh() {
loading.value = true
try {
const nextConfig = await getBackupConfig(instance.value.id)
config.value = nextConfig
excludedPaths.value = nextConfig.enabled ? [...nextConfig.excluded_paths] : []
const [nextRoot, nextSnapshots] = await Promise.all([
nextConfig.eligibility === 'eligible' ? get_full_path(instance.value.id) : '',
listBackups(instance.value.id),
])
instanceRoot.value = nextRoot
snapshots.value = nextSnapshots
await refreshOperation()
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
async function runAction(name: string, task: () => Promise<void>) {
action.value = name
try {
await task()
} catch (error) {
handleError(error)
} finally {
action.value = null
}
}
function enable() {
void runAction('enable', async () => {
config.value = await enableBackups(instance.value.id, excludedPaths.value)
editingExclusions.value = false
})
}
function beginEditingExclusions() {
excludedPaths.value = [...(config.value?.excluded_paths ?? [])]
editingExclusions.value = true
}
function saveExclusions() {
void runAction('exclusions', async () => {
config.value = await updateBackupExclusions(instance.value.id, excludedPaths.value)
editingExclusions.value = false
})
}
function start() {
void runAction('start', async () => {
const id = await startBackup(instance.value.id)
operation.value = {
id,
operation_type: 'create',
instance_id: instance.value.id,
state: 'queued',
processed_bytes: 0,
total_bytes: 0,
cancellable: true,
cancel_requested: false,
created_at: Date.now(),
updated_at: Date.now(),
}
})
}
function cancel() {
if (!operation.value || !canCancel.value) return
void runAction('cancel', async () => {
if (await cancelBackup(operation.value!.id)) {
operation.value = {
...operation.value!,
cancellable: false,
cancel_requested: true,
}
}
})
}
function confirmDelete(snapshot: BackupSnapshot) {
selectedSnapshot.value = snapshot
deleteModal.value?.show()
}
function confirmRestore(snapshot: BackupSnapshot) {
void runAction(`preview:${snapshot.id}`, async () => {
selectedSnapshot.value = snapshot
const id = await startBackupRestorePreview(snapshot.id)
operation.value = {
id,
operation_type: 'restore_preview',
instance_id: instance.value.id,
snapshot_id: snapshot.id,
state: 'queued',
processed_bytes: 0,
total_bytes: snapshot.logical_size,
cancellable: true,
cancel_requested: false,
created_at: Date.now(),
updated_at: Date.now(),
}
})
}
function removeSnapshot() {
if (!selectedSnapshot.value) return
void runAction(`delete:${selectedSnapshot.value.id}`, async () => {
await deleteBackup(selectedSnapshot.value!.id)
await refresh()
deleteModal.value?.hide()
selectedSnapshot.value = null
})
}
function restoreSnapshot() {
if (!selectedSnapshot.value || !restorePreview.value) return
void runAction(`restore:${selectedSnapshot.value.id}`, async () => {
const id = await restoreBackup(selectedSnapshot.value!.id, restorePreview.value!.plan_token)
operation.value = {
id,
operation_type: 'restore',
instance_id: instance.value.id,
snapshot_id: selectedSnapshot.value!.id,
state: 'queued',
processed_bytes: 0,
total_bytes: selectedSnapshot.value!.logical_size,
cancellable: true,
cancel_requested: false,
created_at: Date.now(),
updated_at: Date.now(),
}
restoreModal.value?.hide()
selectedSnapshot.value = null
restorePreview.value = null
})
}
function disable() {
void runAction('disable', async () => {
await disableBackups(instance.value.id)
await refresh()
})
}
onMounted(async () => {
unlisten = await listenBackupProgress(async (event) => {
if (
!['create', 'restore', 'restore_preview'].includes(event.operationType) ||
event.instanceId !== instance.value.id
)
return
operation.value = operationFromEvent(event)
if (event.operationType === 'restore_preview' && event.stage === 'completed') {
const completed = (await listBackupOperations(instance.value.id)).find(
(candidate) => candidate.id === event.operationId,
)
const snapshot = snapshots.value.find((candidate) => candidate.id === event.snapshotId)
if (completed?.restore_preview && snapshot) {
selectedSnapshot.value = snapshot
restorePreview.value = completed.restore_preview
operation.value = null
restoreModal.value?.show()
return
}
}
if (['completed', 'cancelled', 'failed'].includes(event.stage)) {
void refresh()
}
})
await refresh()
})
onUnmounted(() => unlisten?.())
watch(
() => instance.value.id,
() => void refresh(),
)
</script>
<template>
<div class="flex flex-col gap-5">
<header>
<h2 class="m-0 text-lg font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 mt-1 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.description) }}
</p>
</header>
<p v-if="loading" class="m-0 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.loading) }}
</p>
<Admonition v-else-if="!eligible" type="warning" :header="formatMessage(messages.unavailable)">
{{ unavailableReason }}
</Admonition>
<template v-else-if="config">
<div v-if="!config.enabled || editingExclusions" class="flex flex-col gap-4">
<p class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.choose) }}</p>
<BackupExclusionSelector
v-model="excludedPaths"
:instance-id="instance.id"
:instance-root="instanceRoot"
:disabled="action !== null"
/>
<div class="flex flex-wrap gap-2">
<Button :disabled="action !== null" @click="config.enabled ? saveExclusions() : enable()"
><SaveIcon />
{{ formatMessage(config.enabled ? messages.saveExclusions : messages.enable) }}
</Button>
<Button
v-if="config.enabled"
type="outlined"
:disabled="action !== null"
@click="editingExclusions = false"
><XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
</div>
<template v-else>
<div class="flex flex-col gap-2 rounded-lg border border-solid border-surface-4 p-3">
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{
config.excluded_paths.length === 0
? formatMessage(messages.allIncluded)
: formatMessage(messages.excludedPaths, {
paths: config.excluded_paths.map((entry) => entry.path).join(', '),
})
}}
</p>
<div class="flex flex-wrap gap-2">
<Button :disabled="isRunning || action !== null" @click="start"
><FileArchiveIcon />
{{ formatMessage(messages.backupNow) }}
</Button>
<Button v-if="canCancel" type="outlined" :disabled="action !== null" @click="cancel"
><XIcon />
{{ formatMessage(messages.cancelBackup) }}
</Button>
<Button
type="outlined"
:disabled="isRunning || action !== null"
@click="beginEditingExclusions"
>{{ formatMessage(messages.editExclusions) }}
</Button>
</div>
<p v-if="isRunning" class="m-0 text-sm font-medium text-[var(--color-text-primary)]">
{{ operationLabel }}
<span
v-if="(operation?.total_bytes ?? 0) > 0"
class="ml-2 text-[var(--color-text-tertiary)]"
>
{{
formatMessage(messages.progressBytes, {
processed: formatBytes(operation?.processed_bytes ?? 0),
total: formatBytes(operation?.total_bytes ?? 0),
})
}}
</span>
</p>
<p
v-else-if="operation?.state === 'failed' || operation?.state === 'interrupted'"
class="m-0 text-sm text-red"
>
{{
formatMessage(
operation.state === 'interrupted'
? messages.stageInterrupted
: messages.stageFailed,
)
}}<span v-if="operation.error">: {{ operation.error }}</span>
</p>
</div>
<section class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.snapshots) }}
</h3>
<p v-if="snapshots.length === 0" class="m-0 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.empty) }}
</p>
<div
v-else
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
>
<div
v-for="snapshot in snapshots"
:key="snapshot.id"
class="flex items-center gap-3 px-3 py-3"
>
<FileArchiveIcon class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
<div class="min-w-0 flex-1">
<p class="m-0 font-semibold text-[var(--color-text-primary)]">
{{ formatDate(new Date(snapshot.created_at)) }}
</p>
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{
formatMessage(messages.snapshotMeta, {
files: snapshot.file_count,
size: formatBytes(snapshot.logical_size),
})
}}
·
{{
formatMessage(messages.addedSize, { size: formatBytes(snapshot.added_size) })
}}
</p>
</div>
<Button
type="quiet"
size="2xs"
circular
icon-only
:disabled="action !== null || isRunning"
:aria-label="formatMessage(messages.restore)"
@click="confirmRestore(snapshot)"
><UndoIcon />
</Button>
<Button
type="quiet"
color="red"
size="2xs"
circular
icon-only
:disabled="action !== null || isRunning"
:aria-label="formatMessage(messages.delete)"
@click="confirmDelete(snapshot)"
><TrashIcon />
</Button>
</div>
</div>
</section>
<div class="flex flex-col items-start gap-2">
<Button
type="outlined"
color="red"
:disabled="snapshots.length > 0 || action !== null || isRunning"
@click="disable"
>{{ formatMessage(messages.disable) }}
</Button>
<p v-if="snapshots.length > 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.disableBlocked) }}
</p>
</div>
</template>
</template>
<NewModal
ref="deleteModal"
:header="formatMessage(messages.deleteTitle)"
fade="danger"
max-width="500px"
>
<Admonition type="critical">{{ formatMessage(messages.deleteBody) }}</Admonition>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button
type="outlined"
:disabled="action?.startsWith('delete:')"
@click="deleteModal?.hide()"
>{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="red"
:disabled="action?.startsWith('delete:')"
@click="removeSnapshot"
><TrashIcon />{{ formatMessage(messages.delete) }}
</Button>
</div>
</template>
</NewModal>
<NewModal ref="restoreModal" :header="formatMessage(messages.restoreTitle)" max-width="500px">
<Admonition v-if="restorePreview" type="warning">
{{
formatMessage(messages.restoreBody, {
added: restorePreview.added_files,
modified: restorePreview.modified_files,
deleted: restorePreview.deleted_files,
})
}}
</Admonition>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button
type="outlined"
:disabled="action?.startsWith('restore:')"
@click="restoreModal?.hide()"
>{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button :disabled="action?.startsWith('restore:')" @click="restoreSnapshot"
><UndoIcon />{{ formatMessage(messages.restore) }}
</Button>
</div>
</template>
</NewModal>
</div>
</template>
@@ -61,6 +61,7 @@ import {
ButtonStyled, ButtonStyled,
commonMessages, commonMessages,
defineMessages, defineMessages,
injectNotificationManager,
NewModal, NewModal,
RadioButtons, RadioButtons,
StyledInput, StyledInput,
@@ -72,6 +73,7 @@ import { edit, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types' import type { GameInstance } from '@/helpers/types'
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const props = defineProps<{ const props = defineProps<{
instanceIds: string[] instanceIds: string[]
@@ -148,7 +150,7 @@ async function deleteGroup(group: string) {
for (const instance of allInstances.value) { for (const instance of allInstances.value) {
if (instance.groups.includes(group)) { if (instance.groups.includes(group)) {
const newGroups = instance.groups.filter((g) => g !== group) const newGroups = instance.groups.filter((g) => g !== group)
await edit(instance.id, { groups: newGroups }).catch(() => {}) await edit(instance.id, { groups: newGroups }).catch(handleError)
instance.groups = newGroups instance.groups = newGroups
} }
} }
@@ -167,7 +169,7 @@ async function confirm() {
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : [] const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
for (const instanceId of props.instanceIds) { for (const instanceId of props.instanceIds) {
await edit(instanceId, { groups }).catch(() => {}) await edit(instanceId, { groups }).catch(handleError)
} }
emit('applied') emit('applied')
@@ -20,6 +20,7 @@ import type { PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import BackupSettings from '@/components/ui/instance_settings/BackupSettings.vue'
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue' import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue' import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue' import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
@@ -105,6 +106,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: WrenchIcon, icon: WrenchIcon,
content: InstallationSettings, content: InstallationSettings,
}, },
{
name: defineMessage({
id: 'instance.settings.tabs.backups',
defaultMessage: 'Backups',
}),
icon: FileArchiveIcon,
content: BackupSettings,
},
{ {
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding. // Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
name: defineMessage({ name: defineMessage({
@@ -35,7 +35,7 @@ const progressWidth = computed(() => `${Math.min(100, (props.current / props.tot
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2> <h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
</div> </div>
<ButtonStyled circular type="transparent"> <ButtonStyled circular type="transparent">
<button :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')"> <button data-onboarding-skip :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
<XIcon /> <XIcon />
</button> </button>
</ButtonStyled> </ButtonStyled>
@@ -252,6 +252,42 @@ export const onboardingMessages = defineMessages({
id: 'app.onboarding.instance-tabs.description', id: 'app.onboarding.instance-tabs.description',
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.', defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
}, },
instanceContentTabTitle: {
id: 'app.onboarding.instance-content-tab.title',
defaultMessage: 'Content',
},
instanceContentTabDescription: {
id: 'app.onboarding.instance-content-tab.description',
defaultMessage:
'Manage this instance’s mods, resource packs, and shaders here. Search, enable or disable entries, check for updates, and browse for more.',
},
instanceFilesTabTitle: {
id: 'app.onboarding.instance-files-tab.title',
defaultMessage: 'Files',
},
instanceFilesTabDescription: {
id: 'app.onboarding.instance-files-tab.description',
defaultMessage:
'Browse the instance folder and open configs or logs in the built-in editor without leaving the launcher.',
},
instanceWorldsTabTitle: {
id: 'app.onboarding.instance-worlds-tab.title',
defaultMessage: 'Worlds',
},
instanceWorldsTabDescription: {
id: 'app.onboarding.instance-worlds-tab.description',
defaultMessage:
'Manage saved worlds: back them up, rename, edit world settings, and open the world folder.',
},
instanceLogsTabTitle: {
id: 'app.onboarding.instance-logs-tab.title',
defaultMessage: 'Logs',
},
instanceLogsTabDescription: {
id: 'app.onboarding.instance-logs-tab.description',
defaultMessage:
'Read the instance’s live logs to trace crashes and errors without digging through files.',
},
labTitle: { labTitle: {
id: 'app.onboarding.lab.title', id: 'app.onboarding.lab.title',
defaultMessage: 'Useful tools, built in', defaultMessage: 'Useful tools, built in',
@@ -509,15 +545,29 @@ export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
onboardingMessages.instanceActionsTitle, onboardingMessages.instanceActionsTitle,
onboardingMessages.instanceActionsDescription, onboardingMessages.instanceActionsDescription,
), ),
step( inspect(
'instance-tabs', 'instance-content-tab',
'inspect', 'instance-tab-content',
copy( onboardingMessages.instanceContentTabTitle,
onboardingMessages.instanceTabsTitle, onboardingMessages.instanceContentTabDescription,
onboardingMessages.instanceTabsDescription,
onboardingMessages.finishArea,
), ),
{ targetId: 'instance-tabs' }, inspect(
'instance-files-tab',
'instance-tab-files',
onboardingMessages.instanceFilesTabTitle,
onboardingMessages.instanceFilesTabDescription,
),
inspect(
'instance-worlds-tab',
'instance-tab-worlds',
onboardingMessages.instanceWorldsTabTitle,
onboardingMessages.instanceWorldsTabDescription,
),
inspect(
'instance-logs-tab',
'instance-tab-logs',
onboardingMessages.instanceLogsTabTitle,
onboardingMessages.instanceLogsTabDescription,
), ),
], ],
} }
@@ -146,6 +146,14 @@ export function useOnboardingTour(
function scheduleMissingTargetRetry(stepId: string) { function scheduleMissingTargetRetry(stepId: string) {
if (targetRetryCount >= missingTargetRetryLimit) { if (targetRetryCount >= missingTargetRetryLimit) {
// 超过重试上限:inspect 步(介绍性步骤)不自动跳过,
// 保持无锚点状态,让气泡停靠底部等用户手动继续。
if (step.value.interaction === 'inspect') {
targetElement.value = undefined
targetRect.value = null
clearModalReservation()
return
}
void advance() void advance()
return return
} }
@@ -271,8 +279,11 @@ export function useOnboardingTour(
function handleDocumentClick(event: MouseEvent) { function handleDocumentClick(event: MouseEvent) {
if (!visible.value) return if (!visible.value) return
const clickedElement = event.target instanceof Element ? event.target : null const clickedElement = event.target instanceof Element ? event.target : null
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
// 跳过/关闭按钮由其自身处理,不在全局点击里推进
if (clickedElement?.closest('[data-onboarding-skip]')) return
// inspect 步:点击任意位置(含导览气泡本身)都继续下一步
if (step.value.interaction === 'inspect') { if (step.value.interaction === 'inspect') {
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation() event.stopImmediatePropagation()
@@ -280,6 +291,9 @@ export function useOnboardingTour(
return return
} }
// 非 inspect 步:点在导览 UI 自身时不处理
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
if (clickedElement && handleBranchClick(clickedElement)) return if (clickedElement && handleBranchClick(clickedElement)) return
if (!targetElement.value?.contains(event.target as Node)) return if (!targetElement.value?.contains(event.target as Node)) return
@@ -0,0 +1,289 @@
<script lang="ts"></script>
<script setup lang="ts">
import { KeyboardSensor, PointerSensor, useDraggable } from '@dnd-kit/vue'
import { CheckIcon, ClipboardCopyIcon, EditIcon, MoreHorizontalIcon } from '@modrinth/assets'
import {
defineMessages,
IconButton,
useDebugLogger,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
const props = defineProps<{
screenshot: InstanceScreenshot
selectionKey: string
selected: boolean
selectionActive: boolean
activeDragged: boolean
canDrag: boolean
showInstanceName: boolean
highlighted: boolean
copied: boolean
}>()
const emit = defineEmits<{
(e: 'activate', event: MouseEvent | KeyboardEvent): void
(e: 'copy' | 'edit' | 'toggle-selection'): void
(e: 'more', event: MouseEvent): void
}>()
const card = ref<HTMLElement>()
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const image = ref<HTMLImageElement>()
const imageReady = ref(false)
const { formatMessage } = useVIntl()
const debugImage = useDebugLogger('Screenshots:Card')
const formatTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
let loadStartedAt = performance.now()
let loadGeneration = 0
const messages = defineMessages({
select: { id: 'app.screenshots.select', defaultMessage: 'Select {name}' },
deselect: { id: 'app.screenshots.deselect', defaultMessage: 'Deselect {name}' },
copy: { id: 'app.screenshots.copy', defaultMessage: 'Copy image' },
copied: { id: 'app.screenshots.copied', defaultMessage: 'Copied' },
edit: { id: 'app.screenshots.edit', defaultMessage: 'Edit screenshot' },
moreActions: { id: 'app.screenshots.more-actions', defaultMessage: 'More actions' },
})
const sensors = [
PointerSensor.configure({
preventActivation: () => false,
}),
KeyboardSensor,
]
useDraggable({
id: computed(() => `screenshot:${props.selectionKey}`),
element: card,
disabled: computed(() => !props.canDrag),
sensors,
data: computed(() => ({
selectionKey: props.selectionKey,
instanceId: props.screenshot.instance_id,
})),
})
function activate(event: MouseEvent | KeyboardEvent) {
if (event instanceof KeyboardEvent) {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) {
return
}
event.preventDefault()
}
emit('activate', event)
}
async function markImageLoaded() {
const loadedImage = image.value
const loadedUrl = thumbnail.value
const generation = loadGeneration
if (!loadedImage) return
try {
await loadedImage.decode()
} catch {
if (!loadedImage.complete || loadedImage.naturalWidth === 0) return
}
await new Promise<void>((resolve) => {
window.requestAnimationFrame(() => window.requestAnimationFrame(() => resolve()))
})
if (
generation !== loadGeneration ||
image.value !== loadedImage ||
thumbnail.value !== loadedUrl
) {
return
}
const wasReady = imageReady.value
imageReady.value = true
if (!wasReady) {
debugImage('image loaded', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
naturalWidth: image.value?.naturalWidth,
naturalHeight: image.value?.naturalHeight,
})
}
}
function markImageFailed(event: Event) {
debugImage('image failed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loadDurationMs: performance.now() - loadStartedAt,
event,
})
}
onMounted(() => {
debugImage('mounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
complete: image.value?.complete,
})
if (image.value?.complete && image.value.naturalWidth > 0) markImageLoaded()
})
onBeforeUnmount(() => {
loadGeneration += 1
debugImage('unmounted', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
url: props.screenshot.url,
loaded: imageReady.value,
})
})
watch(thumbnail, (url, previousUrl) => {
loadGeneration += 1
loadStartedAt = performance.now()
imageReady.value = false
debugImage('source changed', {
id: props.screenshot.id,
fileName: props.screenshot.file_name,
previousUrl,
url,
})
})
</script>
<template>
<article
ref="card"
role="button"
tabindex="0"
class="group relative isolate aspect-video min-w-0 cursor-pointer overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 p-0 text-left shadow-sm transition-[filter] hover:brightness-110 focus-visible:outline focus-visible:outline-2 focus-visible:outline-brand"
:class="{
'!border-brand brightness-110': selected,
'!border-brand ring-2 ring-brand animate-pulse': highlighted,
'opacity-50': activeDragged,
'cursor-grab active:cursor-grabbing': canDrag,
}"
data-screenshot-card
:data-screenshot-id="screenshot.id"
:data-selection-key="selectionKey"
:aria-label="
selectionActive
? formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
: screenshot.file_name
"
:aria-pressed="selectionActive ? selected : undefined"
@click="activate"
@contextmenu.prevent.stop="emit('more', $event)"
@keydown="activate"
>
<button
type="button"
class="selection-button group/selection absolute right-0.5 top-0 z-[3] flex size-[50px] cursor-pointer items-start justify-center border-0 bg-transparent p-0 pt-4"
:aria-label="
formatMessage(selected ? messages.deselect : messages.select, {
name: screenshot.file_name,
})
"
:aria-pressed="selected"
@click.stop="emit('toggle-selection')"
>
<span
class="relative flex size-6 items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover:opacity-100 group-focus-within:opacity-100 group-hover/selection:brightness-125"
:class="
selected ? 'border-0 !opacity-100' : 'border-2 border-solid border-primary bg-transparent'
"
>
<span v-if="selected" class="absolute inset-0 rounded-full bg-brand" />
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3]" />
</span>
</button>
<img
v-if="thumbnail"
ref="image"
:src="thumbnail"
:alt="screenshot.file_name"
loading="eager"
decoding="async"
draggable="false"
class="screenshot-card-fade absolute inset-0 z-[1] h-full w-full object-cover"
:class="imageReady ? 'opacity-100' : 'opacity-0'"
@load="markImageLoaded"
@error="markImageFailed"
/>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-0 bg-surface-4"
:class="{ 'animate-pulse': !imageReady }"
/>
<div
class="absolute inset-x-0 bottom-0 z-[2] flex items-end justify-between gap-2 bg-gradient-to-t from-surface-1 to-transparent p-3 pt-[120px] text-[var(--color-text-primary)] opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
>
<div class="min-w-0">
<div v-tooltip="screenshot.file_name" class="truncate text-sm font-semibold">
{{ screenshot.file_name }}
</div>
<div class="truncate text-xs text-[var(--color-text-tertiary)]">
{{ showInstanceName ? screenshot.instance_name : formatTime(screenshot.created_at) }}
</div>
</div>
<div
v-if="!selectionActive"
class="flex shrink-0 translate-y-1 gap-1 opacity-0 transition group-hover:translate-y-0 group-hover:opacity-100 group-focus-within:translate-y-0 group-focus-within:opacity-100"
@click.stop
>
<IconButton
v-tooltip="formatMessage(messages.edit)"
:label="formatMessage(messages.edit)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('edit')"
>
<EditIcon />
</IconButton>
<IconButton
v-tooltip="formatMessage(copied ? messages.copied : messages.copy)"
:label="formatMessage(copied ? messages.copied : messages.copy)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('copy')"
>
<CheckIcon v-if="copied" class="text-green" />
<ClipboardCopyIcon v-else />
</IconButton>
<IconButton
v-tooltip="formatMessage(messages.moreActions)"
:label="formatMessage(messages.moreActions)"
type="quiet"
class="bg-surface-2 text-[var(--color-text-primary)] hover:bg-surface-3"
@click="emit('more', $event)"
>
<MoreHorizontalIcon />
</IconButton>
</div>
</div>
</article>
</template>
<style scoped>
.screenshot-card-fade {
transition: opacity 350ms ease-in-out;
}
</style>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ScreenshotDragPreview from './drag-preview.vue'
import { gatherDuration, type ScreenshotDragGatherItem } from './use-screenshot-drag-gather'
const props = defineProps<{
items: ScreenshotDragGatherItem[]
target: {
x: number
y: number
}
}>()
const emit = defineEmits<{
complete: []
}>()
const gathered = ref(false)
const finishing = ref(false)
let animationFrame: number | undefined
let completionTimer: number | undefined
const itemContainerStyles = computed(() =>
props.items.map((_, index) => ({
left: `${props.target.x}px`,
top: `${props.target.y}px`,
zIndex: `${index}`,
})),
)
const itemStyles = computed(() =>
props.items.map((item, index) => {
const originX = item.rect.left - props.target.x
const originY = item.rect.top - props.target.y
return {
width: `${item.rect.width}px`,
height: `${item.rect.height}px`,
transform: gathered.value
? `translate3d(${-item.rect.width / 2}px, ${-item.rect.height / 2}px, 0)`
: `translate3d(${originX}px, ${originY}px, 0)`,
transitionDuration: `${gatherDuration}ms`,
transitionDelay: `${Math.min(index, 8) * 10}ms`,
}
}),
)
onMounted(() => {
animationFrame = requestAnimationFrame(() => {
animationFrame = requestAnimationFrame(() => {
gathered.value = true
completionTimer = window.setTimeout(
() => {
finishing.value = true
emit('complete')
},
Math.max(gatherDuration - 250, 0),
)
})
})
})
onBeforeUnmount(() => {
if (animationFrame !== undefined) {
cancelAnimationFrame(animationFrame)
}
if (completionTimer !== undefined) {
clearTimeout(completionTimer)
}
})
</script>
<template>
<Teleport to="body">
<div
class="pointer-events-none fixed inset-0 z-[9998] overflow-hidden transition-opacity duration-150 ease-out"
:class="finishing ? 'opacity-0' : 'opacity-100'"
aria-hidden="true"
>
<div
v-for="(item, index) in items"
:key="item.selectionKey"
class="fixed"
:style="itemContainerStyles[index]"
>
<div
class="origin-center transition-transform ease-[cubic-bezier(0.2,0.8,0.2,1)]"
:style="itemStyles[index]"
>
<ScreenshotDragPreview :screenshot="item.screenshot" />
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { TagItem } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useImageThumbnail } from '@/composables/use-image-thumbnail'
import type { InstanceScreenshot } from '@/helpers/instance'
import { gatherDuration } from './use-screenshot-drag-gather'
const props = withDefaults(
defineProps<{
screenshot: InstanceScreenshot
count?: number
}>(),
{
count: 1,
},
)
const thumbnail = useImageThumbnail(
() => ({
instanceId: props.screenshot.instance_id,
filePath: `screenshots/${props.screenshot.file_name}`,
path: props.screenshot.path,
}),
512,
() => props.screenshot.modified_at,
)
const showGatheredCount = ref(false)
let countTimer: number | undefined
onMounted(() => {
if (props.count <= 1 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
showGatheredCount.value = true
return
}
countTimer = window.setTimeout(() => {
showGatheredCount.value = true
countTimer = undefined
}, gatherDuration - 150)
})
onBeforeUnmount(() => {
if (countTimer !== undefined) {
clearTimeout(countTimer)
}
})
</script>
<template>
<div aria-hidden="true" class="relative w-full select-none">
<div
v-if="count > 1"
class="absolute inset-x-3 -bottom-2 top-2 rounded-xl border border-solid border-surface-5 bg-surface-2 shadow-md motion-safe:transition-opacity motion-safe:duration-200 motion-safe:delay-100 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-60' : 'opacity-0'"
/>
<div
v-if="count > 1"
class="absolute inset-x-1.5 -bottom-1 top-1 rounded-xl border border-solid border-surface-4 bg-surface-3 shadow-md motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
:class="showGatheredCount ? 'opacity-80' : 'opacity-0'"
/>
<div
class="relative aspect-video w-full overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-2 opacity-90 shadow-lg"
>
<img v-if="thumbnail" :src="thumbnail" alt="" class="h-full w-full object-cover" />
<TagItem
v-if="count > 1"
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-4 tabular-nums text-[var(--color-text-tertiary)] motion-safe:transition-opacity motion-safe:duration-150 motion-safe:ease-out"
>
{{ count }}
</TagItem>
</div>
</div>
</template>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { useDroppable } from '@dnd-kit/vue'
import { defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
import ScreenshotCard from './card.vue'
import ScreenshotSection from './section.vue'
const props = defineProps<{
id: string
title: string
screenshots: InstanceScreenshot[]
renderedScreenshots?: InstanceScreenshot[]
virtualGridHeight?: number
virtualGridTop?: number
selectedKeys: ReadonlySet<string>
selectionActive: boolean
activeDraggedKeys: ReadonlySet<string>
showDropOutline: boolean
canDrag: boolean
dropInstanceId?: string
dropCustomGroup?: boolean
dropCustomGroupId?: string
showInstanceName: boolean
highlightedScreenshotId?: string
copiedScreenshotIds: ReadonlySet<string>
forceOpen: boolean
hideHeader?: boolean
editableTitle?: boolean
startEditingTitle?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>()
const collapsed = defineModel<boolean>('collapsed', { required: true })
const dropTarget = ref<HTMLElement>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Group')
const messages = defineMessages({
emptyGroup: {
id: 'app.screenshots.group.empty',
defaultMessage: 'Drag and drop to add screenshots.',
},
})
const shouldShowGrid = computed(() =>
Boolean(props.hideHeader || props.forceOpen || !collapsed.value),
)
const visibleScreenshots = ref<InstanceScreenshot[]>(props.renderedScreenshots ?? props.screenshots)
const renderGrid = ref(shouldShowGrid.value)
const virtualGridStyle = computed(() =>
props.virtualGridHeight === undefined ? undefined : { height: `${props.virtualGridHeight}px` },
)
const visibleGridStyle = computed(() =>
props.virtualGridTop === undefined
? undefined
: { transform: `translateY(${props.virtualGridTop}px)` },
)
let unmountGridTimeout: ReturnType<typeof setTimeout> | undefined
let resizeObserver: ResizeObserver | undefined
async function logGeometry(reason: string) {
await nextTick()
const rect = dropTarget.value?.getBoundingClientRect()
debugLayout(reason, {
id: props.id,
title: props.title,
collapsed: collapsed.value,
shouldShowGrid: shouldShowGrid.value,
renderGrid: renderGrid.value,
screenshotCount: props.screenshots.length,
renderedCount: visibleScreenshots.value.length,
firstScreenshotId: visibleScreenshots.value.at(0)?.id,
lastScreenshotId: visibleScreenshots.value.at(-1)?.id,
virtualGridHeight: props.virtualGridHeight,
virtualGridTop: props.virtualGridTop,
actual: rect
? { top: rect.top, left: rect.left, width: rect.width, height: rect.height }
: undefined,
})
}
watch(
() => props.renderedScreenshots ?? props.screenshots,
(screenshots) => {
if (shouldShowGrid.value) visibleScreenshots.value = screenshots
void logGeometry('rendered screenshots changed')
},
)
watch(
() => [props.virtualGridHeight, props.virtualGridTop] as const,
() => void logGeometry('virtual grid geometry changed'),
)
watch(
shouldShowGrid,
(showGrid, previouslyShown) => {
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
if (showGrid) {
visibleScreenshots.value = props.renderedScreenshots ?? props.screenshots
renderGrid.value = true
void logGeometry('grid shown')
return
}
if (!previouslyShown) {
renderGrid.value = false
return
}
unmountGridTimeout = setTimeout(() => {
renderGrid.value = false
void logGeometry('grid unmounted after collapse')
unmountGridTimeout = undefined
}, 300)
},
{ flush: 'post' },
)
onMounted(() => {
debugLayout('mounted', { id: props.id, title: props.title })
void logGeometry('mounted geometry')
if (dropTarget.value) {
resizeObserver = new ResizeObserver(([entry]) => {
const rect = entry?.target.getBoundingClientRect()
debugLayout('actual size changed', {
id: props.id,
title: props.title,
width: rect?.width,
height: rect?.height,
expectedHeight:
(props.hideHeader ? 0 : 40) +
(shouldShowGrid.value ? 10 + (props.virtualGridHeight ?? 0) : 0) +
12,
})
})
resizeObserver.observe(dropTarget.value)
}
})
onBeforeUnmount(() => {
debugLayout('unmounted', {
id: props.id,
title: props.title,
renderedCount: visibleScreenshots.value.length,
})
resizeObserver?.disconnect()
if (unmountGridTimeout) clearTimeout(unmountGridTimeout)
})
const emit = defineEmits<{
(e: 'activate', screenshot: InstanceScreenshot, event: MouseEvent | KeyboardEvent): void
(e: 'toggle-selection' | 'copy' | 'edit', screenshot: InstanceScreenshot): void
(e: 'more', screenshot: InstanceScreenshot, event: MouseEvent): void
}>()
useDroppable({
id: computed(() => `screenshot-group:${props.id}`),
element: dropTarget,
disabled: computed(() => !props.dropInstanceId && !props.dropCustomGroup),
data: computed(() =>
props.dropCustomGroup
? { groupId: props.id, customGroupId: props.dropCustomGroupId ?? null }
: { groupId: props.id, instanceId: props.dropInstanceId },
),
})
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
</script>
<template>
<div
ref="dropTarget"
class="group/instance-container relative select-none pb-3 transition-colors"
>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="!opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="!opacity-0"
>
<div
v-if="showDropOutline"
class="pointer-events-none absolute -inset-2 inset-y-0 z-20 rounded-xl border-2 border-dashed border-brand bg-brand-highlight opacity-40"
/>
</Transition>
<ScreenshotSection
v-model:collapsed="collapsed"
:title="title"
:count="screenshots.length"
:force-open="forceOpen"
:hide-header="hideHeader"
:editable="editableTitle"
:start-editing="startEditingTitle"
:max-title-length="maxTitleLength"
:validate-title="validateTitle"
:on-title-change="onTitleChange"
>
<template #actions="{ startEditing }">
<slot name="actions" :start-editing="startEditing" />
</template>
<div v-if="renderGrid" class="relative min-h-[45px] w-full" :style="virtualGridStyle">
<div
class="grid min-h-[45px] w-full grid-cols-1 gap-3 sm:grid-cols-2 2xl:grid-cols-4"
:class="{ 'absolute inset-x-0 top-0': virtualGridHeight !== undefined }"
:style="visibleGridStyle"
>
<ScreenshotCard
v-for="screenshot in visibleScreenshots"
:key="getSelectionKey(screenshot)"
:screenshot="screenshot"
:selection-key="getSelectionKey(screenshot)"
:selected="selectedKeys.has(getSelectionKey(screenshot))"
:selection-active="selectionActive"
:active-dragged="activeDraggedKeys.has(getSelectionKey(screenshot))"
:can-drag="canDrag"
:show-instance-name="showInstanceName"
:highlighted="highlightedScreenshotId === screenshot.id"
:copied="copiedScreenshotIds.has(screenshot.id)"
@activate="(event) => emit('activate', screenshot, event)"
@toggle-selection="emit('toggle-selection', screenshot)"
@copy="emit('copy', screenshot)"
@edit="emit('edit', screenshot)"
@more="(event) => emit('more', screenshot, event)"
/>
<p
v-if="screenshots.length === 0"
key="empty-group"
class="col-span-full m-0 pl-0.5 pt-1 text-base font-base text-[var(--color-text-tertiary)] opacity-80"
>
{{ formatMessage(messages.emptyGroup) }}
</p>
</div>
</div>
</ScreenshotSection>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { DropdownIcon } from '@modrinth/assets'
import {
Accordion,
commonMessages,
defineMessages,
InlineEditableText,
TagItem,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
const props = withDefaults(
defineProps<{
title: string
count: number
collapsed?: boolean
forceOpen?: boolean
hideHeader?: boolean
editable?: boolean
startEditing?: boolean
maxTitleLength?: number
validateTitle?: (value: string) => boolean
onTitleChange?: (value: string) => boolean | void | Promise<boolean | void>
}>(),
{
collapsed: false,
forceOpen: false,
hideHeader: false,
editable: false,
startEditing: false,
},
)
const emit = defineEmits<{
(e: 'update:collapsed', collapsed: boolean): void
}>()
const { formatMessage } = useVIntl()
const debugLayout = useDebugLogger('Screenshots:Section')
const accordion = ref<InstanceType<typeof Accordion>>()
const titleInput = ref<InstanceType<typeof InlineEditableText>>()
const titleModel = ref(props.title)
const messages = defineMessages({
collapse: { id: 'app.screenshots.group.collapse', defaultMessage: 'Collapse group' },
expand: { id: 'app.screenshots.group.expand', defaultMessage: 'Expand group' },
})
function toggle() {
debugLayout('toggle requested', {
title: props.title,
count: props.count,
isOpen: accordion.value?.isOpen,
})
if (accordion.value?.isOpen) {
accordion.value.close()
} else {
accordion.value?.open()
}
}
function handleOpen() {
debugLayout('opened', { title: props.title, count: props.count })
emit('update:collapsed', false)
}
function handleClose() {
debugLayout('closed', { title: props.title, count: props.count })
emit('update:collapsed', true)
}
onMounted(() => {
debugLayout('mounted', {
title: props.title,
count: props.count,
collapsed: props.collapsed,
hideHeader: props.hideHeader,
forceOpen: props.forceOpen,
})
})
onBeforeUnmount(() => {
debugLayout('unmounted', { title: props.title, count: props.count })
})
async function startTitleEditing() {
if (!props.editable) return
await titleInput.value?.startEditing()
}
watch(
() => props.title,
(title) => {
titleModel.value = title
},
)
watch(
() => props.startEditing,
async (shouldStartEditing) => {
if (!shouldStartEditing) return
await nextTick()
await startTitleEditing()
},
{ immediate: true, flush: 'post' },
)
</script>
<template>
<section class="flex w-full flex-col">
<div
v-if="!hideHeader"
class="group/header flex h-10 w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
>
<div class="group/open-target flex min-w-0 cursor-pointer items-center gap-2" @click="toggle">
<button
type="button"
class="flex shrink-0 cursor-pointer items-center border-0 bg-transparent p-0"
:aria-expanded="accordion?.isOpen"
:aria-label="formatMessage(accordion?.isOpen ? messages.collapse : messages.expand)"
@click.stop="toggle"
>
<DropdownIcon
class="size-5 shrink-0 text-[var(--color-text-tertiary)] transition-all duration-300 group-hover/open-target:text-[var(--color-text-default)]"
:class="{ 'rotate-180': accordion?.isOpen }"
/>
</button>
<InlineEditableText
v-if="editable"
ref="titleInput"
v-model="titleModel"
activation-mode="manual"
class="!h-10 select-none text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
:edit-label="formatMessage(commonMessages.renameButton)"
max-width="24rem"
icon-text-class="select-none"
:max-length="maxTitleLength"
:validate="validateTitle"
:on-change="onTitleChange"
/>
<span
v-else
class="select-none truncate text-base font-semibold text-[var(--color-text-default)] group-hover/open-target:text-[var(--color-text-primary)]"
>
{{ title }}
</span>
<TagItem v-if="count" class="shrink-0 border-surface-3 bg-surface-2">
{{ count }}
</TagItem>
</div>
<div class="min-w-0 flex-1" />
<slot name="actions" :start-editing="startTitleEditing" />
</div>
<Accordion
ref="accordion"
:open-by-default="hideHeader || !props.collapsed"
:force-open="forceOpen"
overflow-visible
class="w-full"
@on-open="handleOpen"
@on-close="handleClose"
>
<div class="mt-2.5">
<slot />
</div>
</Accordion>
</section>
</template>
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { ArrowUpDownIcon, GridIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Button,
Combobox,
type ComboboxOption,
defineMessages,
StyledInput,
useVIntl,
} from '@modrinth/ui'
const search = defineModel<string>('search', { required: true })
const sort = defineModel<string>('sort', { required: true })
const group = defineModel<string>('group', { required: true })
defineProps<{
sortOptions: ComboboxOption<string>[]
groupOptions: ComboboxOption<string>[]
}>()
const emit = defineEmits<{
(e: 'new-group'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
search: { id: 'app.screenshots.search', defaultMessage: 'Search' },
newGroup: { id: 'app.screenshots.group.new', defaultMessage: 'New group' },
sortBy: { id: 'app.screenshots.sort-by', defaultMessage: 'Sort by' },
groupBy: { id: 'app.screenshots.group-by', defaultMessage: 'Group by' },
})
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.search)"
clearable
wrapper-class="min-w-[16rem] flex-1"
/>
<Button @click="emit('new-group')">
<PlusIcon />
{{ formatMessage(messages.newGroup) }}
</Button>
</div>
<div class="flex flex-wrap items-center gap-2">
<Combobox
v-model="sort"
class="w-max"
:options="sortOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<ArrowUpDownIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.sortBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
<Combobox
v-model="group"
class="w-max"
:options="groupOptions"
:show-icon-in-selected="false"
dropdown-min-width="160px"
>
<template #prefix>
<GridIcon
class="size-5 text-[var(--color-text-default)]"
:aria-label="formatMessage(messages.groupBy)"
/>
</template>
<template #selected="{ label }">
<span>{{ label }}</span>
</template>
</Combobox>
</div>
</div>
</template>
@@ -0,0 +1,130 @@
import { onBeforeUnmount, type Ref, ref } from 'vue'
import type { InstanceScreenshot } from '@/helpers/instance'
type Point = {
x: number
y: number
}
export type ActiveScreenshotDrag = {
primarySelectionKey: string
selectionKeys: string[]
}
export const gatherDuration = 500
export type ScreenshotDragGatherItem = {
screenshot: InstanceScreenshot
selectionKey: string
rect: {
left: number
top: number
width: number
height: number
}
}
function getSelectionKey(screenshot: InstanceScreenshot) {
return JSON.stringify([screenshot.instance_id, screenshot.file_name])
}
export function useScreenshotDragGather(screenshots: Ref<InstanceScreenshot[]>) {
const items = ref<ScreenshotDragGatherItem[]>([])
const target = ref<Point>({ x: 0, y: 0 })
const targetOffset = ref<Point>({ x: 0, y: 0 })
const isGathering = ref(false)
let cleanupTimer: number | undefined
const clearCleanupTimer = () => {
if (cleanupTimer !== undefined) {
clearTimeout(cleanupTimer)
cleanupTimer = undefined
}
}
const clear = () => {
clearCleanupTimer()
items.value = []
targetOffset.value = { x: 0, y: 0 }
isGathering.value = false
}
const updateTarget = (pointer: Point) => {
target.value = {
x: pointer.x + targetOffset.value.x,
y: pointer.y + targetOffset.value.y,
}
}
const start = (drag: ActiveScreenshotDrag | null, pointer: Point) => {
clear()
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!drag || drag.selectionKeys.length < 2 || reduceMotion) return
const screenshotCards = Array.from(
document.querySelectorAll<HTMLElement>('[data-screenshot-card]'),
)
const sourceCard = screenshotCards.find(
(card) => card.dataset.selectionKey === drag.primarySelectionKey,
)
if (sourceCard) {
const sourceRect = sourceCard.getBoundingClientRect()
targetOffset.value = {
x: sourceRect.left + sourceRect.width / 2 - pointer.x,
y: sourceRect.top + sourceRect.height / 2 - pointer.y,
}
}
const screenshotsByKey = new Map(
screenshots.value.map((screenshot) => [getSelectionKey(screenshot), screenshot]),
)
items.value = drag.selectionKeys.flatMap((selectionKey) => {
const screenshot = screenshotsByKey.get(selectionKey)
const card = screenshotCards.find(
(candidate) =>
candidate.dataset.selectionKey === selectionKey && candidate.getClientRects().length > 0,
)
if (!screenshot || !card) return []
const rect = card.getBoundingClientRect()
return [
{
screenshot,
selectionKey,
rect: {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
},
},
]
})
updateTarget(pointer)
isGathering.value = items.value.length > 0
}
const finish = () => {
isGathering.value = false
clearCleanupTimer()
cleanupTimer = window.setTimeout(() => {
items.value = []
cleanupTimer = undefined
}, 400)
}
onBeforeUnmount(clearCleanupTimer)
return {
items,
target,
isGathering,
start,
updateTarget,
clear,
finish,
}
}
@@ -22,7 +22,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { appDataDir, join } from '@tauri-apps/api/path' import { appDataDir, join } from '@tauri-apps/api/path'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs' import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs'
import { computed, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts' import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils' import { getOS } from '@/helpers/utils'
@@ -41,6 +41,19 @@ import {
parseCustomAccentColor, parseCustomAccentColor,
} from '@/store/theme.ts' } from '@/store/theme.ts'
import {
buildFontFamilyOptions,
canonicalFontFamily,
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
type FontFamilyOption,
type FontFamilyOptionOrDivider,
optionValueToFontSetting,
resolveFontFamily,
type SystemFontFamily,
} from '@/helpers/font-family.ts'
import { getSystemFontFamilies } from '@/helpers/system-fonts.ts'
import SettingsRow from './SettingsRow.vue' import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue' import SettingsSection from './SettingsSection.vue'
@@ -363,6 +376,63 @@ const messages = defineMessages({
defaultMessage: defaultMessage:
'Hide the downloads button in the sidebar when there are no active download tasks.', 'Hide the downloads button in the sidebar when there are no active download tasks.',
}, },
fontsTitle: { id: 'app.appearance-settings.fonts.title', defaultMessage: 'Fonts' },
fontsDescription: {
id: 'app.appearance-settings.fonts.description',
defaultMessage: 'Choose the fonts the launcher interface and its logs are rendered in.',
},
uiFontTitle: { id: 'app.appearance-settings.fonts.ui-font', defaultMessage: 'Interface font' },
uiFontDescription: {
id: 'app.appearance-settings.fonts.ui-font-description',
defaultMessage:
'Used across the launcher interface. Only fonts installed on this system are listed.',
},
monoFontTitle: {
id: 'app.appearance-settings.fonts.mono-font',
defaultMessage: 'Monospace font',
},
monoFontDescription: {
id: 'app.appearance-settings.fonts.mono-font-description',
defaultMessage: 'Used for logs, the server console, and other monospace content.',
},
fontDefault: {
id: 'app.appearance-settings.fonts.default',
defaultMessage: 'Launcher default',
},
fontMonospace: {
id: 'app.appearance-settings.fonts.monospace',
defaultMessage: 'Monospace',
},
fontMissing: {
id: 'app.appearance-settings.fonts.missing',
defaultMessage: 'Not installed',
},
fontSearchPlaceholder: {
id: 'app.appearance-settings.fonts.search-placeholder',
defaultMessage: 'Search installed fonts',
},
fontNoResults: {
id: 'app.appearance-settings.fonts.no-results',
defaultMessage: 'No matching fonts',
},
fontLoadFailed: {
id: 'app.appearance-settings.fonts.load-failed',
defaultMessage: "Couldn't read the list of installed fonts.",
},
fontRetry: { id: 'app.appearance-settings.fonts.retry', defaultMessage: 'Retry' },
fontReset: {
id: 'app.appearance-settings.fonts.reset',
defaultMessage: 'Reset',
},
fontPreviewLabel: { id: 'app.appearance-settings.fonts.preview', defaultMessage: 'Preview' },
fontUiPreviewSample: {
id: 'app.appearance-settings.fonts.ui-preview-sample',
defaultMessage: 'The quick brown fox jumps over the lazy dog.',
},
fontMonoPreviewSample: {
id: 'app.appearance-settings.fonts.mono-preview-sample',
defaultMessage: 'const x = () => 0; // monospace',
},
}) })
const os = ref(await getOS()) const os = ref(await getOS())
@@ -373,6 +443,84 @@ const customBackgroundPreview = computed(() =>
: null, : null,
) )
const systemFonts = ref<SystemFontFamily[]>([])
const systemFontsLoading = ref(true)
const systemFontsFailed = ref(false)
async function loadSystemFonts() {
systemFontsLoading.value = true
systemFontsFailed.value = false
try {
systemFonts.value = await getSystemFontFamilies()
} catch (error) {
systemFontsFailed.value = true
console.warn('Failed to read the list of installed fonts', error)
} finally {
systemFontsLoading.value = false
}
}
function selectedOptionLabel(options: FontFamilyOptionOrDivider[], value: string): string {
const option = options.find(
(entry): entry is FontFamilyOption => 'value' in entry && entry.value === value,
)
return option?.label ?? ''
}
const uiFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
buildFontFamilyOptions(systemFonts.value, {
defaultLabel: formatMessage(messages.fontDefault),
missingLabel: formatMessage(messages.fontMissing),
selected: settings.value.ui_font,
}),
)
const monoFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
buildFontFamilyOptions(systemFonts.value, {
defaultLabel: formatMessage(messages.fontDefault),
missingLabel: formatMessage(messages.fontMissing),
monospaceLabel: formatMessage(messages.fontMonospace),
selected: settings.value.mono_font,
groupMonospaced: true,
}),
)
const uiFontSelection = computed({
get: () => canonicalFontFamily(systemFonts.value, settings.value.ui_font),
set: (value: string) => {
settings.value.ui_font = optionValueToFontSetting(value)
},
})
const monoFontSelection = computed({
get: () => canonicalFontFamily(systemFonts.value, settings.value.mono_font),
set: (value: string) => {
settings.value.mono_font = optionValueToFontSetting(value)
},
})
const uiFontSearchValue = computed(() =>
selectedOptionLabel(uiFontOptions.value, uiFontSelection.value),
)
const monoFontSearchValue = computed(() =>
selectedOptionLabel(monoFontOptions.value, monoFontSelection.value),
)
const uiFontPreview = computed(() =>
resolveFontFamily(settings.value.ui_font, DEFAULT_UI_FONT_STACK),
)
const monoFontPreview = computed(() =>
resolveFontFamily(settings.value.mono_font, DEFAULT_MONO_FONT_STACK),
)
function updateUiScale(value: number) {
const clamped = Math.min(200, Math.max(50, Math.round(value)))
setUiScale(clamped)
settings.value.ui_scale = clamped
}
const accentColorOptions: Array<{ const accentColorOptions: Array<{
value: AccentColor value: AccentColor
color: string color: string
@@ -483,6 +631,8 @@ watch(
settings.value.transparent_background_blur, settings.value.transparent_background_blur,
settings.value.sidebar_instance_count, settings.value.sidebar_instance_count,
settings.value.close_behavior, settings.value.close_behavior,
settings.value.ui_font,
settings.value.mono_font,
] as const, ] as const,
([ ([
path, path,
@@ -493,6 +643,8 @@ watch(
transparentBlur, transparentBlur,
sidebarInstanceCount, sidebarInstanceCount,
closeBehavior, closeBehavior,
uiFont,
monoFont,
]) => { ]) => {
themeStore.customBackgroundPath = path themeStore.customBackgroundPath = path
themeStore.customBackgroundBlur = blur themeStore.customBackgroundBlur = blur
@@ -503,10 +655,19 @@ watch(
themeStore.setTransparentBackgroundClass() themeStore.setTransparentBackgroundClass()
themeStore.sidebarInstanceCount = sidebarInstanceCount themeStore.sidebarInstanceCount = sidebarInstanceCount
themeStore.closeBehavior = closeBehavior as CloseBehavior themeStore.closeBehavior = closeBehavior as CloseBehavior
themeStore.uiFont = uiFont
themeStore.monoFont = monoFont
themeStore.setUiFont()
themeStore.setMonoFont()
}, },
{ immediate: true }, { immediate: true },
) )
onMounted(() => {
if (props.scope === 'interface') {
void loadSystemFonts()
}
})
watch( watch(
settings, settings,
async () => { async () => {
@@ -1050,7 +1211,7 @@ watch(
? 'border-brand bg-brand-highlight text-brand' ? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast' : 'border-surface-4 bg-surface-3 text-secondary hover:border-surface-5 hover:text-contrast'
" "
@click="setUiScale(preset)" @click="updateUiScale(preset)"
> >
{{ preset }}% {{ preset }}%
</button> </button>
@@ -1062,7 +1223,7 @@ watch(
:max="200" :max="200"
:step="5" :step="5"
unit="%" unit="%"
@update:model-value="(value) => setUiScale(Number(value))" @update:model-value="(value) => updateUiScale(Number(value))"
/> />
</div> </div>
</template> </template>
@@ -1142,6 +1303,118 @@ watch(
</SettingsRow> </SettingsRow>
</SettingsSection> </SettingsSection>
<SettingsSection v-if="props.scope === 'interface'">
<template #header>
<h2
id="settings-target-appearance-fonts"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.fontsTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.fontsDescription) }}
</p>
</template>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-ui-font" tabindex="-1">
{{ formatMessage(messages.uiFontTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.uiFontDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Combobox
v-model="uiFontSelection"
:options="uiFontOptions"
searchable
:search-value="uiFontSearchValue"
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
:no-options-message="formatMessage(messages.fontNoResults)"
show-no-options-when-empty
:disabled="systemFontsLoading"
/>
</div>
<Button
type="quiet"
:disabled="settings.ui_font === null"
@click="settings.ui_font = null"
>
{{ formatMessage(messages.fontReset) }}
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">
{{ formatMessage(messages.fontPreviewLabel) }}
</span>
<div
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-base text-contrast"
:style="{ fontFamily: uiFontPreview }"
>
{{ formatMessage(messages.fontUiPreviewSample) }}
</div>
</div>
</div>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-mono-font" tabindex="-1">
{{ formatMessage(messages.monoFontTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.monoFontDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Combobox
v-model="monoFontSelection"
:options="monoFontOptions"
searchable
:search-value="monoFontSearchValue"
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
:no-options-message="formatMessage(messages.fontNoResults)"
show-no-options-when-empty
:disabled="systemFontsLoading"
/>
</div>
<Button
type="quiet"
:disabled="settings.mono_font === null"
@click="settings.mono_font = null"
>
{{ formatMessage(messages.fontReset) }}
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">
{{ formatMessage(messages.fontPreviewLabel) }}
</span>
<div
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-xs text-contrast"
:style="{ fontFamily: monoFontPreview }"
>
{{ formatMessage(messages.fontMonoPreviewSample) }}
</div>
</div>
</div>
</template>
</SettingsRow>
<div
v-if="systemFontsFailed"
class="flex items-center justify-between gap-3 px-4 pb-4 text-sm text-secondary"
>
<span>{{ formatMessage(messages.fontLoadFailed) }}</span>
<Button type="quiet" @click="loadSystemFonts">
{{ formatMessage(messages.fontRetry) }}
</Button>
</div>
</SettingsSection>
<SettingsSection v-if="props.scope === 'content-downloads'"> <SettingsSection v-if="props.scope === 'content-downloads'">
<SettingsRow> <SettingsRow>
<template #label> <template #label>
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { FolderIcon } from '@modrinth/assets'
import {
Admonition,
Button,
defineMessages,
injectFilePicker,
injectNotificationManager,
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import {
type BackupRepositoryStatus,
getBackupRepositoryStatus,
listBackupOperations,
listenBackupProgress,
moveBackupRepository,
} from '@/helpers/instance-backup'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const filePicker = injectFilePicker()
const { handleError } = injectNotificationManager()
const status = ref<BackupRepositoryStatus | null>(null)
const loading = ref(true)
const moving = ref(false)
const movedBytes = ref(0)
const moveTotalBytes = ref(0)
const movingOperationId = ref<string | null>(null)
let unlisten: (() => void) | null = null
const messages = defineMessages({
title: { id: 'settings.backups.repository.title', defaultMessage: 'Backup repository' },
description: {
id: 'settings.backups.repository.description',
defaultMessage: 'Deduplicated instance backup data and its independent index database.',
},
location: { id: 'settings.backups.repository.location', defaultMessage: 'Location' },
locationDescription: {
id: 'settings.backups.repository.location-description',
defaultMessage:
'Changing the location moves the complete repository. Choose a new or empty folder.',
},
change: { id: 'settings.backups.repository.change', defaultMessage: 'Change folder' },
moving: { id: 'settings.backups.repository.moving', defaultMessage: 'Moving repository...' },
movingProgress: {
id: 'settings.backups.repository.moving-progress',
defaultMessage: 'Moving repository: {processed} of {total}',
},
usage: { id: 'settings.backups.repository.usage', defaultMessage: 'Storage usage' },
usageDescription: {
id: 'settings.backups.repository.usage-description',
defaultMessage:
'{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}.',
},
unavailable: {
id: 'settings.backups.repository.unavailable',
defaultMessage: 'Repository unavailable',
},
})
async function refresh() {
loading.value = true
try {
status.value = await getBackupRepositoryStatus()
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
async function changeLocation() {
const selection = await filePicker.pickFolder?.()
if (!selection) return
moving.value = true
movedBytes.value = 0
moveTotalBytes.value = 0
try {
movingOperationId.value = await moveBackupRepository(selection.path)
} catch (error) {
handleError(error)
moving.value = false
}
}
onMounted(async () => {
unlisten = await listenBackupProgress((event) => {
if (event.operationType !== 'repository_move') return
if (movingOperationId.value && event.operationId !== movingOperationId.value) return
movingOperationId.value = event.operationId
moving.value = !event.finalState
movedBytes.value = event.processedBytes
moveTotalBytes.value = event.totalBytes
if (event.finalState) void refresh()
})
const activeMove = (await listBackupOperations(undefined, true)).find(
(operation) => operation.operation_type === 'repository_move',
)
if (activeMove) {
movingOperationId.value = activeMove.id
moving.value = true
movedBytes.value = activeMove.processed_bytes
moveTotalBytes.value = activeMove.total_bytes
}
await refresh()
})
onUnmounted(() => unlisten?.())
</script>
<template>
<SettingsSection
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<SettingsRow>
<template #label>{{ formatMessage(messages.location) }}</template>
<template #description>
<span class="break-all">{{ status?.path ?? '...' }}</span>
<span class="mt-1 block">{{ formatMessage(messages.locationDescription) }}</span>
</template>
<template #control>
<Button type="outlined" :disabled="loading || moving" @click="changeLocation"
><FolderIcon />
{{
formatMessage(
moving && moveTotalBytes > 0
? messages.movingProgress
: moving
? messages.moving
: messages.change,
{
processed: formatBytes(movedBytes),
total: formatBytes(moveTotalBytes),
},
)
}}
</Button>
</template>
</SettingsRow>
<SettingsRow v-if="status">
<template #label>{{ formatMessage(messages.usage) }}</template>
<template #description>
{{
formatMessage(messages.usageDescription, {
stored: formatBytes(status.stored_size),
logical: formatBytes(status.logical_size),
count: status.snapshot_count,
})
}}
</template>
</SettingsRow>
<Admonition
v-if="status && !status.available"
type="critical"
:header="formatMessage(messages.unavailable)"
>
{{ status.error }}
</Admonition>
</SettingsSection>
</template>
@@ -69,6 +69,7 @@ const featureFlagLabels: Record<FeatureFlag, string> = {
show_instance_play_time: '显示实例游玩时间', show_instance_play_time: '显示实例游玩时间',
page_transitions: '页面切换动画', page_transitions: '页面切换动画',
advanced_filters_collapsed: '高级筛选默认折叠', advanced_filters_collapsed: '高级筛选默认折叠',
multiplayer_in_app: '多人联机',
auto_install_dependencies: '自动安装依赖', auto_install_dependencies: '自动安装依赖',
} }
@@ -253,6 +253,15 @@ const messages = defineMessages({
defaultMessage: defaultMessage:
'When CurseForge does not provide a download address, derive its CDN address and try downloading the file automatically. Disable this to use the manual download workflow.', 'When CurseForge does not provide a download address, derive its CDN address and try downloading the file automatically. Disable this to use the manual download workflow.',
}, },
ignoreSslErrors: {
id: 'app.settings.resources.ignore-ssl-errors',
defaultMessage: 'Ignore SSL certificate errors',
},
ignoreSslErrorsDescription: {
id: 'app.settings.resources.ignore-ssl-errors-description',
defaultMessage:
'Skip TLS certificate validation for launcher network requests. Only enable this if you trust your network and understand the security risk.',
},
mojangAuthService: { mojangAuthService: {
id: 'app.settings.resources.mojang-auth-service', id: 'app.settings.resources.mojang-auth-service',
defaultMessage: 'Mojang authentication service', defaultMessage: 'Mojang authentication service',
@@ -923,6 +932,15 @@ function validateMinecraftDirectory(value) {
/> />
</template> </template>
</SettingsRow> </SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.ignoreSslErrors) }}</template>
<template #description>
{{ formatMessage(messages.ignoreSslErrorsDescription) }}
</template>
<template #control>
<Toggle id="ignore-ssl-errors" v-model="settings.ignore_ssl_errors" />
</template>
</SettingsRow>
</SettingsSection> </SettingsSection>
<SettingsSection v-if="props.scope === 'content-downloads'"> <SettingsSection v-if="props.scope === 'content-downloads'">
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import BackupRepositorySettings from './BackupRepositorySettings.vue'
import ResourceManagementSettings from './ResourceManagementSettings.vue' import ResourceManagementSettings from './ResourceManagementSettings.vue'
import StorageSettings from './StorageSettings.vue' import StorageSettings from './StorageSettings.vue'
</script> </script>
@@ -6,6 +7,7 @@ import StorageSettings from './StorageSettings.vue'
<template> <template>
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<ResourceManagementSettings scope="storage-backups" /> <ResourceManagementSettings scope="storage-backups" />
<BackupRepositorySettings />
<StorageSettings /> <StorageSettings />
</div> </div>
</template> </template>
@@ -0,0 +1,17 @@
import { onUnmounted } from 'vue'
import { instance_listener } from '@/helpers/events.js'
export interface InstanceAppEvent {
event: string
instance_id?: string
}
export function useAppEvent(type: 'instance', callback: (event: InstanceAppEvent) => void) {
if (type !== 'instance') return
let unlisten: (() => void) | undefined
void instance_listener(callback).then((dispose) => {
unlisten = dispose
})
onUnmounted(() => unlisten?.())
}
@@ -0,0 +1,154 @@
import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { type MaybeRefOrGetter, ref, toValue, watch } from 'vue'
export type ImageThumbnailSource = {
instanceId: string
filePath: string
path: string
}
type ThumbnailCacheEntry = {
promise: Promise<string>
url?: string
users: number
lastUsed: number
}
const MAX_CONCURRENT_THUMBNAILS = 4
const MAX_CACHED_THUMBNAILS = 256
const thumbnailCache = new Map<string, ThumbnailCacheEntry>()
const thumbnailQueue: Array<() => void> = []
let activeThumbnailRequests = 0
function runThumbnailQueue() {
while (activeThumbnailRequests < MAX_CONCURRENT_THUMBNAILS && thumbnailQueue.length > 0) {
activeThumbnailRequests += 1
thumbnailQueue.shift()?.()
}
}
function enqueueThumbnail<T>(task: () => Promise<T>) {
return new Promise<T>((resolve, reject) => {
thumbnailQueue.push(() => {
void task()
.then(resolve, reject)
.finally(() => {
activeThumbnailRequests -= 1
runThumbnailQueue()
})
})
runThumbnailQueue()
})
}
function removeThumbnail(cacheKey: string, entry: ThumbnailCacheEntry) {
if (thumbnailCache.get(cacheKey) !== entry) return
thumbnailCache.delete(cacheKey)
if (entry.url) {
URL.revokeObjectURL(entry.url)
} else {
void entry.promise.then(
(url) => URL.revokeObjectURL(url),
() => undefined,
)
}
}
function trimThumbnailCache() {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) return
const unused = [...thumbnailCache.entries()]
.filter(([, entry]) => entry.users === 0)
.sort(([, left], [, right]) => left.lastUsed - right.lastUsed)
for (const [cacheKey, entry] of unused) {
if (thumbnailCache.size <= MAX_CACHED_THUMBNAILS) break
removeThumbnail(cacheKey, entry)
}
}
function acquireThumbnail(source: ImageThumbnailSource, maxDimension: number, revision: unknown) {
const cacheKey = JSON.stringify([
source.instanceId,
source.filePath,
maxDimension,
revision ?? null,
])
let entry = thumbnailCache.get(cacheKey)
if (!entry) {
entry = {
users: 0,
lastUsed: performance.now(),
promise: enqueueThumbnail(async () => {
const bytes = await invoke<ArrayBuffer>('plugin:files|screenshot_thumbnail', {
instanceId: source.instanceId,
filePath: source.filePath,
maxDimension,
})
return URL.createObjectURL(new Blob([bytes]))
}),
}
thumbnailCache.set(cacheKey, entry)
void entry.promise.then(
(url) => {
entry!.url = url
trimThumbnailCache()
},
() => {
if (thumbnailCache.get(cacheKey) === entry) thumbnailCache.delete(cacheKey)
},
)
}
entry.users += 1
entry.lastUsed = performance.now()
return {
promise: entry.promise,
release: () => {
entry!.users = Math.max(0, entry!.users - 1)
entry!.lastUsed = performance.now()
trimThumbnailCache()
},
}
}
export function useImageThumbnail(
source: MaybeRefOrGetter<ImageThumbnailSource | null | undefined>,
maxDimension: MaybeRefOrGetter<number> = 512,
revision: MaybeRefOrGetter<string | number | undefined> = undefined,
) {
const thumbnail = ref('')
watch(
[() => toValue(source), () => toValue(maxDimension), () => toValue(revision)],
([currentSource, currentMaxDimension, currentRevision], _, onCleanup) => {
thumbnail.value = ''
if (!currentSource) return
let active = true
const fallbackUrl = convertFileSrc(currentSource.path)
const acquired = acquireThumbnail(
currentSource,
Math.max(1, Math.round(currentMaxDimension)),
currentRevision,
)
onCleanup(() => {
active = false
acquired.release()
})
void acquired.promise.then(
(url) => {
if (active) thumbnail.value = url
},
(error) => {
console.warn('Could not create image thumbnail', error)
if (active) thumbnail.value = fallbackUrl
},
)
},
{ immediate: true },
)
return thumbnail
}
@@ -0,0 +1,179 @@
/**
* Font family plumbing shared by the appearance settings pickers and the theme
* store. Kept free of imports so `node --test` can load it directly.
*/
/** Mirrors `--font-standard` in `packages/assets/styles/defaults.scss`. */
export const DEFAULT_UI_FONT_STACK =
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif'
/** Mirrors `--mono-font` in `packages/assets/styles/defaults.scss`. */
export const DEFAULT_MONO_FONT_STACK =
'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
/** Picker value for "follow the launcher default"; persisted as `null`. */
export const FOLLOW_DEFAULT_FONT = ''
export interface SystemFontFamily {
family: string
monospaced: boolean
}
export interface FontFamilyOption {
value: string
label: string
subLabel?: string
searchTerms?: string[]
}
export type FontFamilyOptionOrDivider = FontFamilyOption | { type: 'divider' }
export interface FontFamilyOptionsConfig {
defaultLabel: string
selected?: string | null
missingLabel?: string
monospaceLabel?: string
groupMonospaced?: boolean
}
const MAX_FONT_FAMILY_LENGTH = 128
/**
* Quotes and escapes an installed family name so it cannot break out of the
* font-family declaration it is spliced into. Returns null when the name has
* nothing usable left.
*/
export function cssFontFamilyName(name: string): string | null {
const trimmed = name.replace(/\p{Cc}/gu, '').trim()
if (!trimmed || trimmed.length > MAX_FONT_FAMILY_LENGTH) {
return null
}
return `"${trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* The value written into `--font-standard` / `--mono-font`. The launcher stack
* always trails the chosen family so an unresolvable name degrades to the
* previous default instead of the user agent's own font.
*/
export function resolveFontFamily(
setting: string | null | undefined,
fallbackStack: string,
): string {
if (!setting) {
return fallbackStack
}
const family = cssFontFamilyName(setting)
return family ? `${family}, ${fallbackStack}` : fallbackStack
}
export function fontSettingToOptionValue(setting: string | null | undefined): string {
return setting ?? FOLLOW_DEFAULT_FONT
}
export function optionValueToFontSetting(value: string): string | null {
const trimmed = value.trim()
return trimmed === FOLLOW_DEFAULT_FONT ? null : trimmed
}
function compareFamilies(first: SystemFontFamily, second: SystemFontFamily): number {
const left = first.family.toLowerCase()
const right = second.family.toLowerCase()
if (left === right) {
return 0
}
return left < right ? -1 : 1
}
function toOption(family: SystemFontFamily, monospaceLabel?: string): FontFamilyOption {
return {
value: family.family,
label: family.family,
...(family.monospaced && monospaceLabel
? { subLabel: monospaceLabel, searchTerms: [monospaceLabel] }
: {}),
}
}
/**
* The spelling the font list uses for a stored family. Font names are matched
* case-insensitively so a stored value that only differs in case still selects
* its option instead of leaving the picker blank.
*/
export function canonicalFontFamily(
fonts: SystemFontFamily[],
setting: string | null | undefined,
): string {
const selected = optionValueToFontSetting(setting ?? '')
if (!selected) {
return FOLLOW_DEFAULT_FONT
}
const listed = fonts.find((font) => font.family.trim().toLowerCase() === selected.toLowerCase())
return listed ? listed.family.trim() : selected
}
/**
* Builds the picker options for the installed font collection: the default
* entry first, the current selection re-added when it is no longer installed,
* and — for the monospace picker — monospaced families ahead of the rest.
*/
export function buildFontFamilyOptions(
fonts: SystemFontFamily[],
config: FontFamilyOptionsConfig,
): FontFamilyOptionOrDivider[] {
const families = new Map<string, SystemFontFamily>()
for (const font of fonts) {
const family = font.family.trim()
if (!family) {
continue
}
const key = family.toLowerCase()
const known = families.get(key)
if (known) {
known.monospaced ||= font.monospaced
} else {
families.set(key, { family, monospaced: font.monospaced })
}
}
const sorted = [...families.values()].sort(compareFamilies)
const selected = optionValueToFontSetting(config.selected ?? '')
const options: FontFamilyOptionOrDivider[] = [
{ value: FOLLOW_DEFAULT_FONT, label: config.defaultLabel },
]
if (selected && !families.has(selected.toLowerCase()) && config.missingLabel) {
options.push({ value: selected, label: selected, subLabel: config.missingLabel })
}
if (config.groupMonospaced) {
const monospaced = sorted.filter((font) => font.monospaced)
const proportional = sorted.filter((font) => !font.monospaced)
options.push(...monospaced.map((font) => toOption(font, config.monospaceLabel)))
if (monospaced.length > 0 && proportional.length > 0) {
options.push({ type: 'divider' })
}
options.push(...proportional.map((font) => toOption(font, config.monospaceLabel)))
return options
}
options.push(...sorted.map((font) => toOption(font)))
return options
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { canonicalizeBackupExclusions, formatBackupExclusionPath } from './instance-backup.ts'
test('directory exclusions display with a trailing slash', () => {
assert.equal(formatBackupExclusionPath({ path: 'caches', kind: 'directory' }), 'caches/')
assert.equal(
formatBackupExclusionPath({ path: 'config/somemod/bigdb.sqlite', kind: 'file' }),
'config/somemod/bigdb.sqlite',
)
})
test('directory exclusions cover descendants while file exclusions stay exact', () => {
assert.deepEqual(
canonicalizeBackupExclusions([
{ path: 'saves/world', kind: 'directory' },
{ path: 'config\\bigdb.sqlite', kind: 'file' },
{ path: 'saves', kind: 'directory' },
{ path: 'config', kind: 'file' },
]),
[
{ path: 'config', kind: 'file' },
{ path: 'saves', kind: 'directory' },
{ path: 'config/bigdb.sqlite', kind: 'file' },
],
)
})
test('directory wins when the same exclusion is selected as both kinds', () => {
assert.deepEqual(
canonicalizeBackupExclusions([
{ path: 'cache', kind: 'file' },
{ path: 'cache', kind: 'directory' },
]),
[{ path: 'cache', kind: 'directory' }],
)
})
@@ -0,0 +1,237 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
export type InstanceBackupEligibility =
| 'eligible'
| 'not_installed'
| 'symlink_instance'
| 'direct_linked_instance'
| 'external_game_directory'
| 'root_missing'
| 'root_is_link'
export interface BackupRepositoryStatus {
path: string
initialized: boolean
available: boolean
stored_size: number
logical_size: number
snapshot_count: number
error: string | null
}
export type BackupExclusionKind = 'file' | 'directory'
export interface BackupExclusion {
path: string
kind: BackupExclusionKind
}
export function formatBackupExclusionPath(exclusion: BackupExclusion): string {
return exclusion.kind === 'directory' ? `${exclusion.path}/` : exclusion.path
}
export interface InstanceBackupConfig {
instance_id: string
enabled: boolean
eligibility: InstanceBackupEligibility
excluded_paths: BackupExclusion[]
snapshot_count: number
}
export interface BackupSnapshot {
id: string
instance_id: string
instance_name: string
created_at: number
file_count: number
symlink_count: number
logical_size: number
added_size: number
}
export interface BackupDeleteSummary {
snapshot_count: number
logical_size: number
}
export interface BackupRestorePreview {
added_files: number
modified_files: number
deleted_files: number
plan_token: string
}
export type BackupOperationType =
'create' | 'restore' | 'restore_preview' | 'delete' | 'repository_move'
export type BackupOperationState =
| 'queued'
| 'scanning'
| 'hashing'
| 'saving'
| 'validating'
| 'materializing'
| 'applying'
| 'completed'
| 'cancelled'
| 'failed'
| 'interrupted'
export interface BackupOperation {
id: string
operation_type: BackupOperationType
instance_id: string
snapshot_id?: string
state: BackupOperationState
processed_bytes: number
total_bytes: number
cancellable: boolean
cancel_requested: boolean
error?: string
created_at: number
updated_at: number
finished_at?: number
restore_preview?: BackupRestorePreview
}
export type BackupProgressStage =
| 'scanning'
| 'hashing'
| 'saving'
| 'validating'
| 'copying'
| 'restoring'
| 'deleting'
| 'completed'
| 'cancelled'
| 'failed'
export type BackupOperationFinalState = 'completed' | 'cancelled' | 'failed'
export interface BackupProgressEvent {
operationId: string
instanceId?: string
operationType: BackupOperationType
stage: BackupProgressStage
processedBytes: number
totalBytes: number
finalState?: BackupOperationFinalState
snapshotId?: string
message?: string
}
export function canonicalizeBackupExclusions(values: BackupExclusion[]): BackupExclusion[] {
const normalized = values
.map((value) => ({ ...value, path: value.path.replaceAll('\\', '/') }))
.sort(
(left, right) =>
left.path.split('/').length - right.path.split('/').length ||
left.path.localeCompare(right.path),
)
const result: BackupExclusion[] = []
for (const value of normalized) {
const duplicate = result.find((existing) => existing.path === value.path)
if (duplicate) {
if (value.kind === 'directory') duplicate.kind = 'directory'
continue
}
if (
result.some(
(parent) => parent.kind === 'directory' && value.path.startsWith(`${parent.path}/`),
)
) {
continue
}
result.push(value)
}
return result
}
export function getBackupRepositoryStatus(): Promise<BackupRepositoryStatus> {
return invoke('plugin:instance|instance_get_backup_repository_status')
}
export function moveBackupRepository(destination: string): Promise<string> {
return invoke('plugin:instance|instance_move_backup_repository', { destination })
}
export function getBackupConfig(instanceId: string): Promise<InstanceBackupConfig> {
return invoke('plugin:instance|instance_get_backup_config', { instanceId })
}
export function normalizeBackupExclusion(
instanceId: string,
selectedPath: string,
kind: BackupExclusionKind,
): Promise<BackupExclusion> {
return invoke('plugin:instance|instance_normalize_backup_exclusion', {
instanceId,
selectedPath,
kind,
})
}
export function enableBackups(
instanceId: string,
excludedPaths: BackupExclusion[],
): Promise<InstanceBackupConfig> {
return invoke('plugin:instance|instance_enable_backups', { instanceId, excludedPaths })
}
export function updateBackupExclusions(
instanceId: string,
excludedPaths: BackupExclusion[],
): Promise<InstanceBackupConfig> {
return invoke('plugin:instance|instance_update_backup_exclusions', {
instanceId,
excludedPaths,
})
}
export function disableBackups(instanceId: string): Promise<void> {
return invoke('plugin:instance|instance_disable_backups', { instanceId })
}
export function startBackup(instanceId: string): Promise<string> {
return invoke('plugin:instance|instance_start_backup', { instanceId })
}
export function cancelBackup(operationId: string): Promise<boolean> {
return invoke('plugin:instance|instance_cancel_backup', { operationId })
}
export function listBackupOperations(
instanceId?: string,
activeOnly = false,
): Promise<BackupOperation[]> {
return invoke('plugin:instance|instance_list_backup_operations', {
instanceId,
activeOnly,
})
}
export function listBackups(instanceId: string): Promise<BackupSnapshot[]> {
return invoke('plugin:instance|instance_list_backups', { instanceId })
}
export function deleteBackup(snapshotId: string): Promise<void> {
return invoke('plugin:instance|instance_delete_backup', { snapshotId })
}
export function startBackupRestorePreview(snapshotId: string): Promise<string> {
return invoke('plugin:instance|instance_get_backup_restore_preview', { snapshotId })
}
export function restoreBackup(snapshotId: string, planToken: string): Promise<string> {
return invoke('plugin:instance|instance_restore_backup', { snapshotId, planToken })
}
export function getBackupDeleteSummary(instanceId: string): Promise<BackupDeleteSummary> {
return invoke('plugin:instance|instance_get_backup_delete_summary', { instanceId })
}
export function listenBackupProgress(
handler: (event: BackupProgressEvent) => void,
): Promise<UnlistenFn> {
return listen<BackupProgressEvent>('instance_backup_progress', (event) => handler(event.payload))
}
@@ -0,0 +1 @@
export const MAX_INSTANCE_GROUP_NAME_LENGTH = 256
+88 -1
View File
@@ -9,7 +9,7 @@ import {
type ContentItem, type ContentItem,
type ContentOwner, type ContentOwner,
} from '@modrinth/ui' } from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core' import { convertFileSrc, invoke } from '@tauri-apps/api/core'
import { isOfflineMode } from '@/composables/useNetworkStatus' import { isOfflineMode } from '@/composables/useNetworkStatus'
import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector' import { buildGcCandidateChain, resolveAutoGcStrategy } from '@/helpers/gc/auto-selector'
@@ -1124,3 +1124,90 @@ export async function check_symlink_capability(): Promise<SymlinkCapability> {
export async function allow_symlink_target(path: string): Promise<void> { export async function allow_symlink_target(path: string): Promise<void> {
return await invoke('allow_symlink_target', { path }) return await invoke('allow_symlink_target', { path })
} }
export type ScreenshotKey = { instance_id: string; file_name: string }
export type InstanceScreenshot = {
id: string
instance_id: string
instance_name: string
file_name: string
created_at: string
modified_at: number
group_id: string | null
path: string
url: string
}
export type ScreenshotGroup = { id: string; name: string }
export type ScreenshotGroupImport = ScreenshotGroup & { screenshot_ids: string[] }
export type ScreenshotGroupMembershipUpdate = { screenshot_id: string; group_id: string | null }
export async function list_screenshots(instanceId: string): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_screenshots', { instanceId })
}
export async function list_all_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_all_screenshots')
}
export async function list_synced_screenshots(): Promise<InstanceScreenshot[]> {
return await invoke('plugin:instance|instance_list_synced_screenshots')
}
export async function list_screenshot_groups(): Promise<ScreenshotGroup[]> {
return await invoke('plugin:instance|instance_list_screenshot_groups')
}
export async function create_screenshot_group(
name: string,
screenshotIds: string[],
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_create_screenshot_group', {
name,
ids: screenshotIds,
})
}
export async function rename_screenshot_group(
id: string,
newName: string,
): Promise<ScreenshotGroup> {
return await invoke('plugin:instance|instance_rename_screenshot_group', { id, newName })
}
export async function delete_screenshot_group(id: string): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshot_group', { id })
}
export async function set_screenshot_group_memberships(
updates: ScreenshotGroupMembershipUpdate[],
): Promise<void> {
return await invoke('plugin:instance|instance_set_screenshot_group_memberships', { updates })
}
export async function import_screenshot_groups(groups: ScreenshotGroupImport[]): Promise<void> {
return await invoke('plugin:instance|instance_import_screenshot_groups', { groups })
}
export async function save_edited_screenshot(
key: ScreenshotKey,
pngBytes: Uint8Array,
mode: 'create_copy' | 'replace_edit',
): Promise<InstanceScreenshot> {
return await invoke('plugin:instance|instance_save_edited_screenshot', {
key,
bytes: Array.from(pngBytes),
mode,
})
}
export async function delete_screenshots(keys: ScreenshotKey[]): Promise<void> {
return await invoke('plugin:instance|instance_delete_screenshots', { keys })
}
export async function export_screenshots(keys: ScreenshotKey[], path: string): Promise<void> {
return await invoke('plugin:instance|instance_export_screenshots', { keys, path })
}
export async function open_screenshot(key: ScreenshotKey): Promise<void> {
return await invoke('plugin:instance|instance_open_screenshot', { key })
}
export async function move_screenshots(
keys: ScreenshotKey[],
targetInstanceId: string,
): Promise<ScreenshotKey[]> {
return await invoke('plugin:instance|instance_move_screenshots', {
keys,
target: targetInstanceId,
})
}
export function getInstanceIconUrl(iconPath?: string | null): string | undefined {
return iconPath ? convertFileSrc(iconPath) : undefined
}
@@ -151,6 +151,7 @@ export type AppSettings = {
curseforge_source: DownloadSourceMode curseforge_source: DownloadSourceMode
bypass_curseforge_download_restrictions: boolean bypass_curseforge_download_restrictions: boolean
mojang_auth_source: DownloadSourceMode mojang_auth_source: DownloadSourceMode
ignore_ssl_errors: boolean
theme: ColorTheme theme: ColorTheme
accent_color: AccentColorSetting accent_color: AccentColorSetting
@@ -172,6 +173,9 @@ export type AppSettings = {
home_layout: HomeLayout home_layout: HomeLayout
minimal_home_instance_id: string | null minimal_home_instance_id: string | null
close_behavior: 'ask' | 'close' | 'lightweight' close_behavior: 'ask' | 'close' | 'lightweight'
ui_scale: number
ui_font: string | null
mono_font: string | null
home_widgets: HomeDashboardConfig | null home_widgets: HomeDashboardConfig | null
terracotta_public_nodes: string[] terracotta_public_nodes: string[]
@@ -213,6 +217,9 @@ type LegacyMirrorSettings = {
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings { function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
settings.close_behavior ??= 'ask' settings.close_behavior ??= 'ask'
settings.ui_scale ??= 100
settings.ui_font ??= null
settings.mono_font ??= null
const hasLegacySettings = const hasLegacySettings =
typeof settings.use_minecraft_mirror === 'boolean' && typeof settings.use_minecraft_mirror === 'boolean' &&
typeof settings.use_modrinth_mirror === 'boolean' && typeof settings.use_modrinth_mirror === 'boolean' &&
@@ -239,6 +246,7 @@ function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings)
usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_curseforge_mirror) usesLegacyDefaults || !hasLegacySettings ? 'auto' : legacySource(settings.use_curseforge_mirror)
settings.bypass_curseforge_download_restrictions ??= true settings.bypass_curseforge_download_restrictions ??= true
settings.mojang_auth_source ??= 'auto' settings.mojang_auth_source ??= 'auto'
settings.ignore_ssl_errors ??= false
settings.terracotta_public_nodes ??= ['wss://center.node.1tmc.top'] settings.terracotta_public_nodes ??= ['wss://center.node.1tmc.top']
settings.feature_flags ??= { ...DEFAULT_FEATURE_FLAGS } settings.feature_flags ??= { ...DEFAULT_FEATURE_FLAGS }
for (const [key, value] of Object.entries(DEFAULT_FEATURE_FLAGS)) { for (const [key, value] of Object.entries(DEFAULT_FEATURE_FLAGS)) {
@@ -0,0 +1,19 @@
import { invoke } from '@tauri-apps/api/core'
import type { SystemFontFamily } from './font-family.ts'
let cached: Promise<SystemFontFamily[]> | null = null
/**
* Installed families are enumerated by the host once per session; installing a
* font while the launcher runs shows up after a restart.
*/
export function getSystemFontFamilies(): Promise<SystemFontFamily[]> {
cached ??= invoke<SystemFontFamily[]>('plugin:fonts|fonts_get_system_fonts').catch((error) => {
// A failed scan must not poison the session for a later retry.
cached = null
throw error
})
return cached
}
+23 -29
View File
@@ -1,10 +1,11 @@
import { invoke } from '@tauri-apps/api/core'
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
/** /**
* 启动器界面缩放(百分比)。 * 启动器界面缩放(百分比)。
* 这是纯本地的显示偏好,用 localStorage 保存即可,无需写入后端设置数据库。 * 通过 Tauri 原生 webview zoom(Rust 侧 set_zoom)应用,
* 缩放值持久化到后端设置数据库(settings.ui_scale)。
*/ */
const UI_SCALE_STORAGE_KEY = 'starlight-ui-scale'
const MIN_UI_SCALE = 50 const MIN_UI_SCALE = 50
const MAX_UI_SCALE = 200 const MAX_UI_SCALE = 200
const DEFAULT_UI_SCALE = 100 const DEFAULT_UI_SCALE = 100
@@ -15,18 +16,7 @@ function clampUiScale(value: number): number {
return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Math.round(value))) return Math.min(MAX_UI_SCALE, Math.max(MIN_UI_SCALE, Math.round(value)))
} }
/** 从 localStorage 读取已保存的缩放值。 */ export const uiScale = ref(DEFAULT_UI_SCALE)
function readStoredUiScale(): number {
try {
const raw = localStorage.getItem(UI_SCALE_STORAGE_KEY)
if (raw === null) return DEFAULT_UI_SCALE
return clampUiScale(Number(raw))
} catch {
return DEFAULT_UI_SCALE
}
}
export const uiScale = ref(readStoredUiScale())
export function setUiScale(value: number): void { export function setUiScale(value: number): void {
uiScale.value = clampUiScale(value) uiScale.value = clampUiScale(value)
@@ -38,23 +28,27 @@ export function resetUiScale(): void {
export { DEFAULT_UI_SCALE, MAX_UI_SCALE, MIN_UI_SCALE } export { DEFAULT_UI_SCALE, MAX_UI_SCALE, MIN_UI_SCALE }
/** 把缩放应用到文档根节点,并在变化时持久化。 */ /**
export function installUiScale(): void { * 把缩放值应用到原生 webview。
const apply = (value: number) => { * 同时保留 --ui-scale CSS 变量,供依赖它的样式/锚点使用。
const factor = String(value / 100) */
const root = document.documentElement async function applyUiScale(value: number): Promise<void> {
root.style.setProperty('--ui-scale', factor) const factor = value / 100
// 用 CSS zoom 整体缩放界面(含内嵌网页),在 WebView2/WebKit 上最可靠。 document.documentElement.style.setProperty('--ui-scale', String(factor))
root.style.setProperty('zoom', factor)
}
apply(uiScale.value)
watch(uiScale, (value) => {
apply(value)
try { try {
localStorage.setItem(UI_SCALE_STORAGE_KEY, String(value)) await invoke('plugin:settings|set_ui_scale', { factor })
} catch (error) { } catch (error) {
console.warn('保存界面缩放失败', error) console.warn('应用界面缩放失败', error)
} }
}
/**
* 初始化缩放:应用当前值,并在变化时同步到原生 webview。
* 初值由调用方(App 初始化流程)在读取后端设置后通过 setUiScale 覆盖。
*/
export function installUiScale(): void {
void applyUiScale(uiScale.value)
watch(uiScale, (value) => {
void applyUiScale(value)
}) })
} }
File diff suppressed because it is too large. Load diff
@@ -640,6 +640,57 @@
"app.appearance-settings.auto-hide-downloads-button.description": { "app.appearance-settings.auto-hide-downloads-button.description": {
"message": "没有进行中的下载任务时隐藏侧边栏中的下载按钮,有任务时自动显示。" "message": "没有进行中的下载任务时隐藏侧边栏中的下载按钮,有任务时自动显示。"
}, },
"app.appearance-settings.fonts.title": {
"message": "字体"
},
"app.appearance-settings.fonts.description": {
"message": "选择启动器界面与日志使用的字体。"
},
"app.appearance-settings.fonts.ui-font": {
"message": "界面字体"
},
"app.appearance-settings.fonts.ui-font-description": {
"message": "应用于启动器界面。仅列出系统已安装的字体。"
},
"app.appearance-settings.fonts.mono-font": {
"message": "等宽字体"
},
"app.appearance-settings.fonts.mono-font-description": {
"message": "用于日志、服务器控制台等等宽内容。"
},
"app.appearance-settings.fonts.default": {
"message": "启动器默认"
},
"app.appearance-settings.fonts.monospace": {
"message": "等宽"
},
"app.appearance-settings.fonts.missing": {
"message": "未安装"
},
"app.appearance-settings.fonts.search-placeholder": {
"message": "搜索已安装的字体"
},
"app.appearance-settings.fonts.no-results": {
"message": "没有匹配的字体"
},
"app.appearance-settings.fonts.load-failed": {
"message": "无法读取已安装的字体列表。"
},
"app.appearance-settings.fonts.retry": {
"message": "重试"
},
"app.appearance-settings.fonts.reset": {
"message": "重置"
},
"app.appearance-settings.fonts.preview": {
"message": "预览"
},
"app.appearance-settings.fonts.ui-preview-sample": {
"message": "永和九年,岁在癸丑,暮春之初。"
},
"app.appearance-settings.fonts.mono-preview-sample": {
"message": "const x = () => 0; // 等宽"
},
"app.appearance-settings.auto-hide-downloads-button.title": { "app.appearance-settings.auto-hide-downloads-button.title": {
"message": "自动隐藏下载按钮" "message": "自动隐藏下载按钮"
}, },
@@ -5995,6 +6046,30 @@
"app.onboarding.instance-actions.title": { "app.onboarding.instance-actions.title": {
"message": "主操作台" "message": "主操作台"
}, },
"app.onboarding.instance-content-tab.title": {
"message": "内容"
},
"app.onboarding.instance-content-tab.description": {
"message": "在这里管理实例的 Mod、资源包与光影。可搜索、启用或禁用条目、检查更新,也能继续浏览安装新内容。"
},
"app.onboarding.instance-files-tab.title": {
"message": "文件"
},
"app.onboarding.instance-files-tab.description": {
"message": "浏览实例目录,用内置编辑器直接打开配置文件或日志,无需离开启动器。"
},
"app.onboarding.instance-worlds-tab.title": {
"message": "世界"
},
"app.onboarding.instance-worlds-tab.description": {
"message": "管理存档世界:备份、重命名、编辑世界设置,或打开世界所在文件夹。"
},
"app.onboarding.instance-logs-tab.title": {
"message": "日志"
},
"app.onboarding.instance-logs-tab.description": {
"message": "查看实例的实时日志,排查崩溃与报错,不必再翻找文件。"
},
"app.onboarding.instance-tabs.description": { "app.onboarding.instance-tabs.description": {
"message": "内容、文件、截图、世界和日志都在这些标签里。乱中有序。" "message": "内容、文件、截图、世界和日志都在这些标签里。乱中有序。"
}, },
@@ -7195,6 +7270,12 @@
"app.settings.resources.curseforge-restriction-bypass-description": { "app.settings.resources.curseforge-restriction-bypass-description": {
"message": "当 CurseForge 不提供下载地址时,根据文件信息推导其 CDN 地址并尝试自动下载。关闭后将使用手动下载流程。" "message": "当 CurseForge 不提供下载地址时,根据文件信息推导其 CDN 地址并尝试自动下载。关闭后将使用手动下载流程。"
}, },
"app.settings.resources.ignore-ssl-errors": {
"message": "忽略 SSL 证书错误"
},
"app.settings.resources.ignore-ssl-errors-description": {
"message": "跳过启动器网络请求的 TLS 证书校验。仅当你信任当前网络并了解其安全风险时才启用。"
},
"app.settings.resources.database-backups": { "app.settings.resources.database-backups": {
"message": "应用数据库备份" "message": "应用数据库备份"
}, },
@@ -10458,5 +10539,395 @@
}, },
"app.settings.about.easteregg.game-title": { "app.settings.about.easteregg.game-title": {
"message": "彩蛋小游戏" "message": "彩蛋小游戏"
},
"app.home.launch-progress.empty": {
"message": "启动游戏后,这里会显示进度。"
},
"app.home.launch-progress.title": {
"message": "启动进度"
},
"app.screenshots.copied": {
"message": "已复制"
},
"app.screenshots.copy": {
"message": "复制图像"
},
"app.screenshots.delete-description": {
"message": "永久删除 {name}?此操作无法撤销。"
},
"app.screenshots.delete-success": {
"message": "截图已删除"
},
"app.screenshots.delete-title": {
"message": "删除截图"
},
"app.screenshots.deselect": {
"message": "取消选择 {name}"
},
"app.screenshots.edit": {
"message": "编辑截图"
},
"app.screenshots.empty-description": {
"message": "你在游戏中拍摄的截图会显示在这里。"
},
"app.screenshots.empty-heading": {
"message": "还没有截图"
},
"app.screenshots.error-heading": {
"message": "加载截图失败"
},
"app.screenshots.go-to-instance": {
"message": "前往实例"
},
"app.screenshots.group-by": {
"message": "分组方式"
},
"app.screenshots.group.all-screenshots": {
"message": "全部截图"
},
"app.screenshots.group.collapse": {
"message": "折叠分组"
},
"app.screenshots.group.custom": {
"message": "自定义分组"
},
"app.screenshots.group.date": {
"message": "日期"
},
"app.screenshots.group.delete": {
"message": "删除分组"
},
"app.screenshots.group.delete-description": {
"message": "此分组中的截图将变为未分组。"
},
"app.screenshots.group.edit": {
"message": "编辑分组名称"
},
"app.screenshots.group.empty": {
"message": "拖放以添加截图。"
},
"app.screenshots.group.expand": {
"message": "展开分组"
},
"app.screenshots.group.instance": {
"message": "实例"
},
"app.screenshots.group.new": {
"message": "新建分组"
},
"app.screenshots.group.none": {
"message": "不分组"
},
"app.screenshots.group.this-month": {
"message": "本月"
},
"app.screenshots.group.this-week": {
"message": "本周"
},
"app.screenshots.group.today": {
"message": "今天"
},
"app.screenshots.group.ungrouped": {
"message": "未分组"
},
"app.screenshots.group.yesterday": {
"message": "昨天"
},
"app.screenshots.heading": {
"message": "截图"
},
"app.screenshots.more-actions": {
"message": "更多操作"
},
"app.screenshots.no-results-description": {
"message": "请尝试其他搜索或筛选条件。"
},
"app.screenshots.no-results-heading": {
"message": "没有匹配的截图"
},
"app.screenshots.preview.instance-and-date": {
"message": "{instance} · {date}"
},
"app.screenshots.search": {
"message": "搜索"
},
"app.screenshots.select": {
"message": "选择 {name}"
},
"app.screenshots.selection.aria-label": {
"message": "已选中的截图"
},
"app.screenshots.selection.delete-description": {
"message": "删除 {count, plural, one {# 张截图} other {# 张截图}}?此操作无法撤销。"
},
"app.screenshots.selection.delete-success": {
"message": "{count, plural, one {已删除 # 张截图} other {已删除 # 张截图}}"
},
"app.screenshots.selection.delete-title": {
"message": "删除选中的截图"
},
"app.screenshots.selection.export-zip": {
"message": "导出 ZIP"
},
"app.screenshots.selection.global-export-filename": {
"message": "Modrinth screenshots.zip"
},
"app.screenshots.selection.instance-export-filename": {
"message": "{instance} screenshots.zip"
},
"app.screenshots.selection.remove-from-group": {
"message": "从分组中移除"
},
"app.screenshots.selection.selected-count": {
"message": "已选中 {count} 张"
},
"app.screenshots.selection.zip-archive": {
"message": "ZIP 压缩包"
},
"app.screenshots.show-in-folder": {
"message": "在文件夹中显示"
},
"app.screenshots.sort-by": {
"message": "排序方式"
},
"app.screenshots.sort.name": {
"message": "名称"
},
"app.screenshots.sort.newest": {
"message": "最新"
},
"app.screenshots.sort.oldest": {
"message": "最旧"
},
"app.settings.about.copyright": {
"message": "版权所有 © StarLight 保留所有权利。"
},
"app.settings.about.game.best": {
"message": "最佳:{score}"
},
"app.settings.about.game.best-label": {
"message": "最佳"
},
"app.settings.about.game.completed": {
"message": "下界之星!"
},
"app.settings.about.game.exit": {
"message": "退出游戏"
},
"app.settings.about.game.game-over": {
"message": "群星坠落!"
},
"app.settings.about.game.highest-level": {
"message": "最高:等级 {level}"
},
"app.settings.about.game.level-label": {
"message": "最高等级"
},
"app.settings.about.game.new-record": {
"message": "新纪录!"
},
"app.settings.about.game.overtime": {
"message": "涨潮!"
},
"app.settings.about.game.overtime-detail": {
"message": "潮水涌回——继续合成!"
},
"app.settings.about.game.overtimes-label": {
"message": "加时次数"
},
"app.settings.about.game.restart": {
"message": "重新开始"
},
"app.settings.about.game.score": {
"message": "得分:{score}"
},
"app.settings.about.game.score-label": {
"message": "得分"
},
"app.settings.about.game.settle": {
"message": "稳定"
},
"app.settings.about.game.settle-title": {
"message": "本局已稳定!"
},
"app.settings.about.game.tap-to-drop": {
"message": "点击投放一颗星"
},
"app.settings.about.game.tide-hint": {
"message": "天空正在坠落——继续合成!"
},
"app.settings.about.game.title": {
"message": "Starlight 合成"
},
"app.starlight-skin.frame-title": {
"message": "StarLight 皮肤站"
},
"app.navigation.screenshots": {
"message": "截图"
},
"instance.backups.added-size": {
"message": "新增存储 {size}"
},
"instance.backups.all-included": {
"message": "实例中的所有文件和文件夹都会纳入备份。"
},
"instance.backups.backup-now": {
"message": "立即备份"
},
"instance.backups.cancel": {
"message": "取消备份"
},
"instance.backups.choose": {
"message": "默认备份实例中的全部内容,请添加不需要备份的文件或文件夹。"
},
"instance.backups.delete": {
"message": "删除备份"
},
"instance.backups.delete-body": {
"message": "此快照将被永久删除,不再使用的仓库文件也会一并清理。"
},
"instance.backups.delete-title": {
"message": "删除此备份?"
},
"instance.backups.description": {
"message": "在实例关闭时,为整个实例创建去重快照。"
},
"instance.backups.disable": {
"message": "关闭备份"
},
"instance.backups.disable-blocked": {
"message": "必须先删除全部快照才能关闭备份。"
},
"instance.backups.edit-exclusions": {
"message": "编辑排除项"
},
"instance.backups.empty": {
"message": "尚未创建任何备份。"
},
"instance.backups.enable": {
"message": "启用备份"
},
"instance.backups.excluded-paths": {
"message": "不备份:{paths}"
},
"instance.backups.exclusions.add-file": {
"message": "添加忽略文件"
},
"instance.backups.exclusions.add-folder": {
"message": "添加忽略文件夹"
},
"instance.backups.exclusions.empty": {
"message": "没有忽略任何文件或文件夹。"
},
"instance.backups.exclusions.remove": {
"message": "从排除项中移除 {path}"
},
"instance.backups.loading": {
"message": "正在加载备份设置……"
},
"instance.backups.progress-bytes": {
"message": "已处理 {processed},共 {total}"
},
"instance.backups.restore": {
"message": "恢复备份"
},
"instance.backups.restore-body": {
"message": "即将进行的操作:新增 {added} 个文件,修改 {modified} 个文件,删除 {deleted} 个文件。是否继续?"
},
"instance.backups.restore-title": {
"message": "恢复此备份?"
},
"instance.backups.save-exclusions": {
"message": "保存排除项"
},
"instance.backups.snapshot-meta": {
"message": "{files, plural, other {# 个文件}} · {size}"
},
"instance.backups.snapshots": {
"message": "快照"
},
"instance.backups.stage.applying": {
"message": "正在应用变更文件"
},
"instance.backups.stage.failed": {
"message": "备份失败"
},
"instance.backups.stage.hashing": {
"message": "正在计算文件哈希"
},
"instance.backups.stage.interrupted": {
"message": "备份任务已中断"
},
"instance.backups.stage.materializing": {
"message": "正在准备变更文件"
},
"instance.backups.stage.preview": {
"message": "正在分析恢复变更"
},
"instance.backups.stage.queued": {
"message": "等待开始"
},
"instance.backups.stage.saving": {
"message": "正在保存快照索引"
},
"instance.backups.stage.scanning": {
"message": "正在扫描文件"
},
"instance.backups.stage.validating": {
"message": "正在验证恢复任务"
},
"instance.backups.title": {
"message": "实例备份"
},
"instance.backups.unavailable": {
"message": "备份不可用"
},
"instance.backups.unavailable.direct-linked": {
"message": "不支持备份直接关联到其他启动器的实例。"
},
"instance.backups.unavailable.external-directory": {
"message": "不支持备份使用外部游戏目录的实例。"
},
"instance.backups.unavailable.not-installed": {
"message": "请先完成实例安装,再启用备份。"
},
"instance.backups.unavailable.root-link": {
"message": "不支持备份根文件夹为链接的实例。"
},
"instance.backups.unavailable.root-missing": {
"message": "实例文件夹不存在。"
},
"instance.backups.unavailable.symlink": {
"message": "不支持备份以符号链接导入的实例。"
},
"settings.backups.repository.change": {
"message": "更改文件夹"
},
"settings.backups.repository.description": {
"message": "存放去重后的实例备份数据及其独立索引数据库。"
},
"settings.backups.repository.location": {
"message": "位置"
},
"settings.backups.repository.location-description": {
"message": "更改位置会移动完整仓库。请选择新的或空的文件夹。"
},
"settings.backups.repository.moving": {
"message": "正在移动仓库……"
},
"settings.backups.repository.moving-progress": {
"message": "正在移动备份仓库:已处理 {processed},共 {total}"
},
"settings.backups.repository.title": {
"message": "备份文件仓库"
},
"settings.backups.repository.unavailable": {
"message": "仓库不可用"
},
"settings.backups.repository.usage": {
"message": "存储占用"
},
"settings.backups.repository.usage-description": {
"message": "实际存储 {stored},对应 {logical} 的备份数据,共 {count, plural, other {# 个快照}}。"
} }
} }
@@ -572,6 +572,57 @@
"app.appearance-settings.auto-hide-downloads-button.description": { "app.appearance-settings.auto-hide-downloads-button.description": {
"message": "沒有進行中的下載任務時隱藏側邊欄中的下載按鈕,有任務時自動顯示。" "message": "沒有進行中的下載任務時隱藏側邊欄中的下載按鈕,有任務時自動顯示。"
}, },
"app.appearance-settings.fonts.title": {
"message": "字型"
},
"app.appearance-settings.fonts.description": {
"message": "選擇啟動器介面與日誌使用的字型。"
},
"app.appearance-settings.fonts.ui-font": {
"message": "介面字型"
},
"app.appearance-settings.fonts.ui-font-description": {
"message": "套用於啟動器介面。僅列出系統已安裝的字型。"
},
"app.appearance-settings.fonts.mono-font": {
"message": "等寬字型"
},
"app.appearance-settings.fonts.mono-font-description": {
"message": "用於日誌、伺服器主控台等等寬內容。"
},
"app.appearance-settings.fonts.default": {
"message": "啟動器預設"
},
"app.appearance-settings.fonts.monospace": {
"message": "等寬"
},
"app.appearance-settings.fonts.missing": {
"message": "未安裝"
},
"app.appearance-settings.fonts.search-placeholder": {
"message": "搜尋已安裝的字型"
},
"app.appearance-settings.fonts.no-results": {
"message": "沒有符合的字型"
},
"app.appearance-settings.fonts.load-failed": {
"message": "無法讀取已安裝的字型清單。"
},
"app.appearance-settings.fonts.retry": {
"message": "重試"
},
"app.appearance-settings.fonts.reset": {
"message": "重設"
},
"app.appearance-settings.fonts.preview": {
"message": "預覽"
},
"app.appearance-settings.fonts.ui-preview-sample": {
"message": "永和九年,歲在癸丑,暮春之初。"
},
"app.appearance-settings.fonts.mono-preview-sample": {
"message": "const x = () => 0; // 等寬"
},
"app.appearance-settings.auto-hide-downloads-button.title": { "app.appearance-settings.auto-hide-downloads-button.title": {
"message": "自動隱藏下載按鈕" "message": "自動隱藏下載按鈕"
}, },
@@ -6827,6 +6878,12 @@
"app.settings.resources.curseforge-restriction-bypass-description": { "app.settings.resources.curseforge-restriction-bypass-description": {
"message": "當 CurseForge 不提供下載地址時,根據檔案資訊推導其 CDN 地址並嘗試自動下載。關閉後將使用手動下載流程。" "message": "當 CurseForge 不提供下載地址時,根據檔案資訊推導其 CDN 地址並嘗試自動下載。關閉後將使用手動下載流程。"
}, },
"app.settings.resources.ignore-ssl-errors": {
"message": "忽略 SSL 憑證錯誤"
},
"app.settings.resources.ignore-ssl-errors-description": {
"message": "跳過啟動器網路請求的 TLS 憑證檢查。僅當你信任目前網路並了解其安全風險時才啟用。"
},
"app.settings.resources.database-backups": { "app.settings.resources.database-backups": {
"message": "應用程式資料庫備份" "message": "應用程式資料庫備份"
}, },
@@ -10138,5 +10195,170 @@
}, },
"app.instance.dependencies.zoom-out": { "app.instance.dependencies.zoom-out": {
"message": "縮小" "message": "縮小"
},
"instance.backups.added-size": {
"message": "新增存储 {size}"
},
"instance.backups.all-included": {
"message": "实例中的所有文件和文件夹都会纳入备份。"
},
"instance.backups.backup-now": {
"message": "立即备份"
},
"instance.backups.cancel": {
"message": "取消备份"
},
"instance.backups.choose": {
"message": "默认备份实例中的全部内容,请添加不需要备份的文件或文件夹。"
},
"instance.backups.delete": {
"message": "删除备份"
},
"instance.backups.delete-body": {
"message": "此快照将被永久删除,不再使用的仓库文件也会一并清理。"
},
"instance.backups.delete-title": {
"message": "删除此备份?"
},
"instance.backups.description": {
"message": "在实例关闭时,为整个实例创建去重快照。"
},
"instance.backups.disable": {
"message": "关闭备份"
},
"instance.backups.disable-blocked": {
"message": "必须先删除全部快照才能关闭备份。"
},
"instance.backups.edit-exclusions": {
"message": "编辑排除项"
},
"instance.backups.empty": {
"message": "尚未创建任何备份。"
},
"instance.backups.enable": {
"message": "启用备份"
},
"instance.backups.excluded-paths": {
"message": "不备份:{paths}"
},
"instance.backups.exclusions.add-file": {
"message": "添加忽略文件"
},
"instance.backups.exclusions.add-folder": {
"message": "添加忽略文件夹"
},
"instance.backups.exclusions.empty": {
"message": "没有忽略任何文件或文件夹。"
},
"instance.backups.exclusions.remove": {
"message": "从排除项中移除 {path}"
},
"instance.backups.loading": {
"message": "正在加载备份设置……"
},
"instance.backups.progress-bytes": {
"message": "已处理 {processed},共 {total}"
},
"instance.backups.restore": {
"message": "恢复备份"
},
"instance.backups.restore-body": {
"message": "即将进行的操作:新增 {added} 个文件,修改 {modified} 个文件,删除 {deleted} 个文件。是否继续?"
},
"instance.backups.restore-title": {
"message": "恢复此备份?"
},
"instance.backups.save-exclusions": {
"message": "保存排除项"
},
"instance.backups.snapshot-meta": {
"message": "{files, plural, other {# 个文件}} · {size}"
},
"instance.backups.snapshots": {
"message": "快照"
},
"instance.backups.stage.applying": {
"message": "正在应用变更文件"
},
"instance.backups.stage.failed": {
"message": "备份失败"
},
"instance.backups.stage.hashing": {
"message": "正在计算文件哈希"
},
"instance.backups.stage.interrupted": {
"message": "备份任务已中断"
},
"instance.backups.stage.materializing": {
"message": "正在准备变更文件"
},
"instance.backups.stage.preview": {
"message": "正在分析恢复变更"
},
"instance.backups.stage.queued": {
"message": "等待开始"
},
"instance.backups.stage.saving": {
"message": "正在保存快照索引"
},
"instance.backups.stage.scanning": {
"message": "正在扫描文件"
},
"instance.backups.stage.validating": {
"message": "正在验证恢复任务"
},
"instance.backups.title": {
"message": "实例备份"
},
"instance.backups.unavailable": {
"message": "备份不可用"
},
"instance.backups.unavailable.direct-linked": {
"message": "不支持备份直接关联到其他启动器的实例。"
},
"instance.backups.unavailable.external-directory": {
"message": "不支持备份使用外部游戏目录的实例。"
},
"instance.backups.unavailable.not-installed": {
"message": "请先完成实例安装,再启用备份。"
},
"instance.backups.unavailable.root-link": {
"message": "不支持备份根文件夹为链接的实例。"
},
"instance.backups.unavailable.root-missing": {
"message": "实例文件夹不存在。"
},
"instance.backups.unavailable.symlink": {
"message": "不支持备份以符号链接导入的实例。"
},
"settings.backups.repository.change": {
"message": "更改文件夹"
},
"settings.backups.repository.description": {
"message": "存放去重后的实例备份数据及其独立索引数据库。"
},
"settings.backups.repository.location": {
"message": "位置"
},
"settings.backups.repository.location-description": {
"message": "更改位置会移动完整仓库。请选择新的或空的文件夹。"
},
"settings.backups.repository.moving": {
"message": "正在移动仓库……"
},
"settings.backups.repository.moving-progress": {
"message": "正在移动备份仓库:已处理 {processed},共 {total}"
},
"settings.backups.repository.title": {
"message": "备份文件仓库"
},
"settings.backups.repository.unavailable": {
"message": "仓库不可用"
},
"settings.backups.repository.usage": {
"message": "存储占用"
},
"settings.backups.repository.usage-description": {
"message": "实际存储 {stored},对应 {logical} 的备份数据,共 {count, plural, other {# 个快照}}。"
} }
} }
@@ -0,0 +1,11 @@
<script setup lang="ts">
import ScreenshotsPage from '@/components/ui/screenshots-page/index.vue'
defineOptions({ name: 'ScreenshotsPage' })
</script>
<template>
<div class="box-border h-full p-6">
<ScreenshotsPage show-heading />
</div>
</template>
@@ -688,11 +688,13 @@ const tabs = computed(() => [
label: formatMessage(messages.contentTab), label: formatMessage(messages.contentTab),
href: `${basePath.value}`, href: `${basePath.value}`,
icon: BoxesIcon, icon: BoxesIcon,
onboardingId: 'instance-tab-content',
}, },
{ {
label: formatMessage(messages.filesTab), label: formatMessage(messages.filesTab),
href: `${basePath.value}/files`, href: `${basePath.value}/files`,
icon: FolderOpenIcon, icon: FolderOpenIcon,
onboardingId: 'instance-tab-files',
}, },
{ {
label: formatMessage(messages.screenshotsTab), label: formatMessage(messages.screenshotsTab),
@@ -703,11 +705,13 @@ const tabs = computed(() => [
label: formatMessage(messages.worldsTab), label: formatMessage(messages.worldsTab),
href: `${basePath.value}/worlds`, href: `${basePath.value}/worlds`,
icon: GlobeIcon, icon: GlobeIcon,
onboardingId: 'instance-tab-worlds',
}, },
{ {
label: formatMessage(messages.logsTab), label: formatMessage(messages.logsTab),
href: `${basePath.value}/logs`, href: `${basePath.value}/logs`,
icon: TerminalSquareIcon, icon: TerminalSquareIcon,
onboardingId: 'instance-tab-logs',
}, },
]) ])
@@ -0,0 +1,57 @@
import { queryOptions } from '@tanstack/vue-query'
import {
list,
list_all_screenshots,
list_screenshot_groups,
list_screenshots,
list_synced_screenshots,
} from '@/helpers/instance'
export const instanceKeys = {
all: ['instances'] as const,
list: () => ['instances', 'list'] as const,
}
export function instanceListQueryOptions() {
return queryOptions({
queryKey: instanceKeys.list(),
queryFn: list,
})
}
export const screenshotKeys = {
all: ['screenshots'] as const,
global: () => ['screenshots', 'global'] as const,
synced: () => ['screenshots', 'synced'] as const,
instance: (instanceId: string) => ['screenshots', 'instance', instanceId] as const,
groups: () => ['screenshots', 'groups'] as const,
}
export function instanceScreenshotsQueryOptions(instanceId: string) {
return queryOptions({
queryKey: screenshotKeys.instance(instanceId),
queryFn: () => list_screenshots(instanceId),
})
}
export function allScreenshotsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.global(),
queryFn: list_all_screenshots,
})
}
export function syncedScreenshotsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.synced(),
queryFn: list_synced_screenshots,
})
}
export function screenshotGroupsQueryOptions() {
return queryOptions({
queryKey: screenshotKeys.groups(),
queryFn: list_screenshot_groups,
})
}
+2
View File
@@ -3,6 +3,7 @@ import type { AbstractPopupNotificationManager, AbstractWebNotificationManager }
import { setupCreationModal } from './setup/creation-modal' import { setupCreationModal } from './setup/creation-modal'
import { setupFileDropProvider } from './setup/file-drop' import { setupFileDropProvider } from './setup/file-drop'
import { setupFilePickerProvider } from './setup/file-picker' import { setupFilePickerProvider } from './setup/file-picker'
import { setupImageViewerEditorProvider } from './setup/image-viewer-editor'
import { setupInstanceImportProvider } from './setup/instance-import' import { setupInstanceImportProvider } from './setup/instance-import'
import { setupTagsProvider } from './setup/tags' import { setupTagsProvider } from './setup/tags'
@@ -14,6 +15,7 @@ export function setupProviders(
setupTagsProvider(notificationManager, stateInitialization) setupTagsProvider(notificationManager, stateInitialization)
const fileDrop = setupFileDropProvider() const fileDrop = setupFileDropProvider()
const filePicker = setupFilePickerProvider() const filePicker = setupFilePickerProvider()
setupImageViewerEditorProvider()
setupInstanceImportProvider(notificationManager) setupInstanceImportProvider(notificationManager)
return { return {
@@ -0,0 +1,16 @@
import {
type ImageViewerEditorData,
type ImageViewerEditorSource,
provideImageViewerEditor,
} from '@modrinth/ui'
import { readFile } from '@tauri-apps/plugin-fs'
export function setupImageViewerEditorProvider() {
provideImageViewerEditor({
async loadEditorData(source: ImageViewerEditorSource): Promise<ImageViewerEditorData> {
return {
source: new Blob([await readFile(source.path)], { type: 'image/png' }),
}
},
})
}
+12
View File
@@ -101,6 +101,14 @@ export default new createRouter({
breadcrumb: [{ name: 'Starlight skin' }], breadcrumb: [{ name: 'Starlight skin' }],
}, },
}, },
{
path: '/screenshots',
name: 'Screenshots',
component: () => import('@/pages/Screenshots.vue'),
meta: {
breadcrumb: [{ name: 'Screenshots' }],
},
},
{ {
path: '/multiplayer', path: '/multiplayer',
name: 'Multiplayer', name: 'Multiplayer',
@@ -111,6 +119,10 @@ export default new createRouter({
pageTransitionGroup: 'multiplayer', pageTransitionGroup: 'multiplayer',
}, },
children: [ children: [
{
path: '',
redirect: { name: 'MultiplayerRooms' },
},
{ {
path: 'rooms', path: 'rooms',
name: 'MultiplayerRooms', name: 'MultiplayerRooms',
+39
View File
@@ -1,5 +1,11 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import {
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
resolveFontFamily,
} from '@/helpers/font-family.ts'
let systemThemeMq: MediaQueryList | null = null let systemThemeMq: MediaQueryList | null = null
export const DEFAULT_FEATURE_FLAGS = { export const DEFAULT_FEATURE_FLAGS = {
@@ -14,6 +20,7 @@ export const DEFAULT_FEATURE_FLAGS = {
skip_non_essential_warnings: false, skip_non_essential_warnings: false,
skip_unknown_pack_warning: false, skip_unknown_pack_warning: false,
i18n_debug: false, i18n_debug: false,
multiplayer_in_app: false,
show_instance_play_time: true, show_instance_play_time: true,
page_transitions: true, page_transitions: true,
advanced_filters_collapsed: true, advanced_filters_collapsed: true,
@@ -129,6 +136,9 @@ export type ThemeStore = {
devMode: boolean devMode: boolean
featureFlags: FeatureFlags featureFlags: FeatureFlags
uiFont: string | null
monoFont: string | null
} }
export const DEFAULT_THEME_STORE: ThemeStore = { export const DEFAULT_THEME_STORE: ThemeStore = {
@@ -153,6 +163,9 @@ export const DEFAULT_THEME_STORE: ThemeStore = {
devMode: false, devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS, featureFlags: DEFAULT_FEATURE_FLAGS,
uiFont: null,
monoFont: null,
} }
export const useTheming = defineStore('themeStore', { export const useTheming = defineStore('themeStore', {
@@ -248,6 +261,32 @@ export const useTheming = defineStore('themeStore', {
`${Math.min(Math.max(this.transparentBackgroundOpacity, 0), 100)}%`, `${Math.min(Math.max(this.transparentBackgroundOpacity, 0), 100)}%`,
) )
}, },
/**
* The font tokens are declared on `body` in the shared defaults, so the
* launcher picks up the chosen family by overriding the CSS variable there.
*/
setUiFont() {
const body = document.body
if (this.uiFont) {
body.style.setProperty(
'--font-standard',
resolveFontFamily(this.uiFont, DEFAULT_UI_FONT_STACK),
)
} else {
body.style.removeProperty('--font-standard')
}
},
setMonoFont() {
const body = document.body
if (this.monoFont) {
body.style.setProperty(
'--mono-font',
resolveFontFamily(this.monoFont, DEFAULT_MONO_FONT_STACK),
)
} else {
body.style.removeProperty('--mono-font')
}
},
getFeatureFlag(key: FeatureFlag) { getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key] return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
}, },
+2
View File
@@ -16,6 +16,7 @@ dashmap = { workspace = true }
either = { workspace = true } either = { workspace = true }
enumset = { workspace = true, features = ["serde"] } enumset = { workspace = true, features = ["serde"] }
flate2 = { workspace = true } flate2 = { workspace = true }
fontdb = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
hyper = { workspace = true, features = ["server"] } hyper = { workspace = true, features = ["server"] }
hyper-util = { workspace = true } hyper-util = { workspace = true }
@@ -28,6 +29,7 @@ paste = { workspace = true }
path-util = { workspace = true } path-util = { workspace = true }
quartz_nbt = { workspace = true, features = ["serde"] } quartz_nbt = { workspace = true, features = ["serde"] }
regex = { workspace = true } regex = { workspace = true }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true } serde_json = { workspace = true }
serde_with = { workspace = true } serde_with = { workspace = true }
+40
View File
@@ -365,6 +365,7 @@ fn main() {
"install_existing_instance", "install_existing_instance",
"hosted_default", "hosted_default",
"hosted_set_session", "hosted_set_session",
"hosted_has_game_account",
"hosted_create", "hosted_create",
"hosted_binding", "hosted_binding",
"hosted_sync", "hosted_sync",
@@ -427,6 +428,21 @@ fn main() {
InlinedPlugin::new() InlinedPlugin::new()
.commands(&[ .commands(&[
"instance_remove", "instance_remove",
"instance_get_backup_repository_status",
"instance_move_backup_repository",
"instance_get_backup_config",
"instance_normalize_backup_exclusion",
"instance_enable_backups",
"instance_update_backup_exclusions",
"instance_disable_backups",
"instance_start_backup",
"instance_cancel_backup",
"instance_list_backup_operations",
"instance_list_backups",
"instance_delete_backup",
"instance_get_backup_restore_preview",
"instance_restore_backup",
"instance_get_backup_delete_summary",
"instance_create_direct_link", "instance_create_direct_link",
"instance_sync_direct_links", "instance_sync_direct_links",
"instance_get", "instance_get",
@@ -508,6 +524,20 @@ fn main() {
"instance_edit_icon", "instance_edit_icon",
"instance_export_mrpack", "instance_export_mrpack",
"instance_get_pack_export_candidates", "instance_get_pack_export_candidates",
"instance_list_screenshots",
"instance_list_all_screenshots",
"instance_list_synced_screenshots",
"instance_save_edited_screenshot",
"instance_list_screenshot_groups",
"instance_create_screenshot_group",
"instance_rename_screenshot_group",
"instance_delete_screenshot_group",
"instance_set_screenshot_group_memberships",
"instance_import_screenshot_groups",
"instance_delete_screenshots",
"instance_export_screenshots",
"instance_move_screenshots",
"instance_open_screenshot",
]) ])
.default_permission( .default_permission(
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,
@@ -524,6 +554,7 @@ fn main() {
"proxy_get", "proxy_get",
"proxy_set", "proxy_set",
"proxy_test", "proxy_test",
"set_ui_scale",
]) ])
.default_permission( .default_permission(
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,
@@ -669,6 +700,7 @@ fn main() {
"file_save_as", "file_save_as",
"file_read_dragged_file", "file_read_dragged_file",
"screenshot_thumbnail", "screenshot_thumbnail",
"instance_icon_thumbnail",
"studio_read_text", "studio_read_text",
"studio_read_binary", "studio_read_binary",
"studio_write_binary", "studio_write_binary",
@@ -680,6 +712,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands, DefaultPermissionRule::AllowAllCommands,
), ),
) )
.plugin(
"fonts",
InlinedPlugin::new()
.commands(&["fonts_get_system_fonts"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin( .plugin(
"drop", "drop",
InlinedPlugin::new() InlinedPlugin::new()
+9
View File
@@ -650,6 +650,15 @@ Function PageLeaveOptions
Abort Abort
${EndIf} ${EndIf}
; 若安装位置被选在盘符根目录(如 E:\),自动在其下创建 Starlight 子目录,
; 避免启动器文件直接散落在根目录。用户选的具体子目录不受影响。
${GetRoot} "$INSTDIR" $0
StrCpy $1 "$0" -1
${If} "$INSTDIR" == "$1"
StrCpy $INSTDIR "$INSTDIR\Starlight"
${NSD_SetText} $InstallDirInput $INSTDIR
${EndIf}
${NSD_GetState} $DesktopShortcutCheckbox $DesktopShortcutState ${NSD_GetState} $DesktopShortcutCheckbox $DesktopShortcutState
${If} $FreshInstall <> 1 ${If} $FreshInstall <> 1
+91
View File
@@ -0,0 +1,91 @@
use serde::Serialize;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::api::Result;
#[derive(Debug, Clone, Serialize)]
pub struct SystemFontFamily {
pub family: String,
pub monospaced: bool,
}
/// Scanning every installed face costs a few hundred milliseconds, and the
/// collection only changes between sessions, so it is read once per process.
static SYSTEM_FONTS: OnceLock<Vec<SystemFontFamily>> = OnceLock::new();
type FontFamilies = [(String, fontdb::Language)];
/// fontdb lists the English US family first when the font has one, but the
/// lookup is explicit so a face with an unusual name table still resolves to
/// the name the webview matches against.
fn english_family_name(families: &FontFamilies) -> Option<&str> {
families
.iter()
.find(|(_, language)| {
*language == fontdb::Language::English_UnitedStates
})
.or_else(|| families.first())
.map(|(name, _)| name.trim())
.filter(|name| !name.is_empty())
}
#[derive(Default)]
struct FamilyAccumulator {
families: HashMap<String, bool>,
}
impl FamilyAccumulator {
fn add(&mut self, families: &FontFamilies, monospaced: bool) {
let Some(name) = english_family_name(families) else {
return;
};
self.families
.entry(name.to_owned())
.and_modify(|flag| *flag |= monospaced)
.or_insert(monospaced);
}
fn into_sorted_families(self) -> Vec<SystemFontFamily> {
let mut families: Vec<SystemFontFamily> = self
.families
.into_iter()
.map(|(family, monospaced)| SystemFontFamily { family, monospaced })
.collect();
families.sort_by_cached_key(|entry| entry.family.to_lowercase());
families
}
}
fn collect_system_fonts() -> Vec<SystemFontFamily> {
let mut database = fontdb::Database::new();
database.load_system_fonts();
let mut accumulator = FamilyAccumulator::default();
for face in database.faces() {
accumulator.add(&face.families, face.monospaced);
}
accumulator.into_sorted_families()
}
#[tauri::command]
pub async fn fonts_get_system_fonts() -> Result<Vec<SystemFontFamily>> {
if let Some(fonts) = SYSTEM_FONTS.get() {
return Ok(fonts.clone());
}
let fonts = tauri::async_runtime::spawn_blocking(collect_system_fonts)
.await
.map_err(std::io::Error::other)?;
// A concurrent caller may have won the race; the scan is deterministic.
Ok(SYSTEM_FONTS.get_or_init(|| fonts).clone())
}
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("fonts")
.invoke_handler(tauri::generate_handler![fonts_get_system_fonts])
.build()
}
@@ -6,6 +6,9 @@ use path_util::SafeRelativeUtf8UnixPathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tauri::Manager;
use tauri_plugin_fs::FsExt;
use tauri_plugin_opener::OpenerExt;
use theseus::data::{ use theseus::data::{
AppliedContentSetPatch, ContentItem, Dependency, AppliedContentSetPatch, ContentItem, Dependency,
EditInstance as CoreEditInstance, InstanceInstallCandidate, EditInstance as CoreEditInstance, InstanceInstallCandidate,
@@ -22,6 +25,21 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("instance") tauri::plugin::Builder::new("instance")
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
instance_remove, instance_remove,
instance_get_backup_repository_status,
instance_move_backup_repository,
instance_get_backup_config,
instance_normalize_backup_exclusion,
instance_enable_backups,
instance_update_backup_exclusions,
instance_disable_backups,
instance_start_backup,
instance_cancel_backup,
instance_list_backup_operations,
instance_list_backups,
instance_delete_backup,
instance_get_backup_restore_preview,
instance_restore_backup,
instance_get_backup_delete_summary,
instance_create_direct_link, instance_create_direct_link,
instance_sync_direct_links, instance_sync_direct_links,
instance_get, instance_get,
@@ -103,6 +121,20 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
instance_edit_icon, instance_edit_icon,
instance_export_mrpack, instance_export_mrpack,
instance_get_pack_export_candidates, instance_get_pack_export_candidates,
instance_list_screenshots,
instance_list_all_screenshots,
instance_list_synced_screenshots,
instance_save_edited_screenshot,
instance_list_screenshot_groups,
instance_create_screenshot_group,
instance_rename_screenshot_group,
instance_delete_screenshot_group,
instance_set_screenshot_group_memberships,
instance_import_screenshot_groups,
instance_delete_screenshots,
instance_export_screenshots,
instance_move_screenshots,
instance_open_screenshot,
]) ])
.build() .build()
} }
@@ -461,7 +493,18 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
None => Ok(CoreInstanceLink::Unmanaged), None => Ok(CoreInstanceLink::Unmanaged),
}) })
.transpose()?, .transpose()?,
launch_overrides: Some(InstanceLaunchOverridesPatch { launch_overrides: {
let has_launch_change = edit_instance.java_path.is_some()
|| edit_instance.extra_launch_args.is_some()
|| edit_instance.custom_env_vars.is_some()
|| edit_instance.memory.is_some()
|| edit_instance.force_fullscreen.is_some()
|| edit_instance.maximize_window.is_some()
|| edit_instance.game_resolution.is_some()
|| edit_instance.launch_preparation_timeout.is_some()
|| edit_instance.hooks.is_some();
if has_launch_change {
Some(InstanceLaunchOverridesPatch {
player: None, player: None,
instance_mode: None, instance_mode: None,
java_path: edit_instance.java_path, java_path: edit_instance.java_path,
@@ -474,14 +517,27 @@ fn edit_to_core(edit_instance: EditInstance) -> Result<CoreEditInstance> {
launch_preparation_timeout: edit_instance launch_preparation_timeout: edit_instance
.launch_preparation_timeout, .launch_preparation_timeout,
hooks: edit_instance.hooks, hooks: edit_instance.hooks,
}), })
content_set_patch: Some(AppliedContentSetPatch { } else {
None
}
},
content_set_patch: {
let has_content_change = edit_instance.game_version.is_some()
|| edit_instance.loader.is_some()
|| edit_instance.loader_version.is_some();
if has_content_change {
Some(AppliedContentSetPatch {
source_kind: None, source_kind: None,
game_version: edit_instance.game_version, game_version: edit_instance.game_version,
protocol_version: Some(None), protocol_version: Some(None),
loader: edit_instance.loader, loader: edit_instance.loader,
loader_version: edit_instance.loader_version, loader_version: edit_instance.loader_version,
}), })
} else {
None
}
},
last_played: None, last_played: None,
submitted_time_played: None, submitted_time_played: None,
recent_time_played: None, recent_time_played: None,
@@ -1490,3 +1546,290 @@ pub async fn instance_edit_icon(
theseus::instance::edit_icon(instance_id, icon_path).await?; theseus::instance::edit_icon(instance_id, icon_path).await?;
Ok(()) Ok(())
} }
#[derive(Serialize, Debug, Clone)]
pub struct InstanceScreenshot {
pub id: String,
pub instance_id: String,
pub instance_name: String,
pub file_name: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub modified_at: i64,
pub group_id: Option<String>,
pub path: PathBuf,
pub url: String,
}
fn serialize_screenshot<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
s: theseus::instance::InstanceScreenshot,
) -> Result<InstanceScreenshot> {
if let Some(parent) = s.path.parent() {
crate::api::files::ensure_browsable(app, parent);
}
let mut url = super::utils::tauri_convert_file_src(&s.path)?;
app.asset_protocol_scope().allow_file(&s.path)?;
app.fs_scope().allow_file(&s.path)?;
url.query_pairs_mut()
.append_pair("revision", &s.modified_at.to_string());
Ok(InstanceScreenshot {
id: s.id,
instance_id: s.instance_id,
instance_name: s.instance_name,
file_name: s.file_name,
created_at: s.created_at,
modified_at: s.modified_at,
group_id: s.group_id,
path: s.path,
url: url.to_string(),
})
}
fn serialize_screenshots<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
items: Vec<theseus::instance::InstanceScreenshot>,
) -> Result<Vec<InstanceScreenshot>> {
items
.into_iter()
.map(|s| serialize_screenshot(app, s))
.collect()
}
#[tauri::command]
pub async fn instance_list_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
instance_id: &str,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_screenshots(instance_id).await?,
)
}
#[tauri::command]
pub async fn instance_list_all_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_all_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_list_synced_screenshots<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
) -> Result<Vec<InstanceScreenshot>> {
serialize_screenshots(
&app,
theseus::instance::list_synced_screenshots().await?,
)
}
#[tauri::command]
pub async fn instance_save_edited_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
bytes: Vec<u8>,
mode: theseus::instance::ScreenshotEditSaveMode,
) -> Result<InstanceScreenshot> {
serialize_screenshot(
&app,
theseus::instance::save_edited_screenshot(key, bytes, mode).await?,
)
}
#[tauri::command]
pub async fn instance_list_screenshot_groups()
-> Result<Vec<theseus::instance::ScreenshotGroup>> {
Ok(theseus::instance::list_screenshot_groups().await?)
}
#[tauri::command]
pub async fn instance_create_screenshot_group(
name: String,
ids: Vec<String>,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::create_screenshot_group(name, ids).await?)
}
#[tauri::command]
pub async fn instance_rename_screenshot_group(
id: String,
new_name: String,
) -> Result<theseus::instance::ScreenshotGroup> {
Ok(theseus::instance::rename_screenshot_group(id, new_name).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshot_group(id: String) -> Result<()> {
Ok(theseus::instance::delete_screenshot_group(id).await?)
}
#[tauri::command]
pub async fn instance_set_screenshot_group_memberships(
updates: Vec<theseus::instance::ScreenshotGroupMembershipUpdate>,
) -> Result<()> {
Ok(theseus::instance::set_screenshot_group_memberships(updates).await?)
}
#[tauri::command]
pub async fn instance_import_screenshot_groups(
groups: Vec<theseus::instance::ScreenshotGroupImport>,
) -> Result<()> {
Ok(theseus::instance::import_screenshot_groups(groups).await?)
}
#[tauri::command]
pub async fn instance_delete_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
) -> Result<()> {
Ok(theseus::instance::delete_screenshots(&keys).await?)
}
#[tauri::command]
pub async fn instance_export_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
path: PathBuf,
) -> Result<()> {
Ok(theseus::instance::export_screenshots(&keys, path).await?)
}
#[tauri::command]
pub async fn instance_move_screenshots(
keys: Vec<theseus::instance::ScreenshotKey>,
target: &str,
) -> Result<Vec<theseus::instance::ScreenshotKey>> {
Ok(theseus::instance::move_screenshots(&keys, target).await?)
}
#[tauri::command]
pub async fn instance_open_screenshot<R: tauri::Runtime>(
app: tauri::AppHandle<R>,
key: theseus::instance::ScreenshotKey,
) -> Result<()> {
let path = theseus::instance::get_screenshot_path(&key).await?;
app.opener()
.reveal_item_in_dir(path)
.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(())
}
// ===== 实例备份命令(移植自 Axolotl) =====
#[tauri::command]
pub async fn instance_get_backup_repository_status()
-> Result<theseus::instance::BackupRepositoryStatus> {
Ok(theseus::instance::get_backup_repository_status().await?)
}
#[tauri::command]
pub async fn instance_move_backup_repository(
destination: PathBuf,
) -> Result<uuid::Uuid> {
Ok(theseus::instance::move_backup_repository(destination).await?)
}
#[tauri::command]
pub async fn instance_get_backup_config(
instance_id: &str,
) -> Result<theseus::instance::InstanceBackupConfig> {
Ok(theseus::instance::get_backup_config(instance_id).await?)
}
#[tauri::command]
pub async fn instance_normalize_backup_exclusion(
instance_id: &str,
selected_path: PathBuf,
kind: theseus::instance::BackupExclusionKind,
) -> Result<theseus::instance::BackupExclusion> {
Ok(theseus::instance::get_backup_exclusion_from_path(
instance_id,
selected_path,
kind,
)
.await?)
}
#[tauri::command]
pub async fn instance_enable_backups(
instance_id: &str,
excluded_paths: Vec<theseus::instance::BackupExclusion>,
) -> Result<theseus::instance::InstanceBackupConfig> {
Ok(theseus::instance::enable_backups(instance_id, excluded_paths).await?)
}
#[tauri::command]
pub async fn instance_update_backup_exclusions(
instance_id: &str,
excluded_paths: Vec<theseus::instance::BackupExclusion>,
) -> Result<theseus::instance::InstanceBackupConfig> {
Ok(
theseus::instance::update_backup_exclusions(
instance_id,
excluded_paths,
)
.await?,
)
}
#[tauri::command]
pub async fn instance_disable_backups(instance_id: &str) -> Result<()> {
Ok(theseus::instance::disable_backups(instance_id).await?)
}
#[tauri::command]
pub async fn instance_start_backup(instance_id: String) -> Result<uuid::Uuid> {
Ok(theseus::instance::start_backup_snapshot(instance_id).await?)
}
#[tauri::command]
pub async fn instance_cancel_backup(operation_id: uuid::Uuid) -> Result<bool> {
Ok(theseus::instance::cancel_backup(operation_id).await?)
}
#[tauri::command]
pub async fn instance_list_backup_operations(
instance_id: Option<String>,
active_only: bool,
) -> Result<Vec<theseus::instance::BackupOperation>> {
Ok(theseus::instance::list_backup_operations(
instance_id.as_deref(),
active_only,
)
.await?)
}
#[tauri::command]
pub async fn instance_list_backups(
instance_id: &str,
) -> Result<Vec<theseus::instance::BackupSnapshot>> {
Ok(theseus::instance::list_backup_snapshots(instance_id).await?)
}
#[tauri::command]
pub async fn instance_delete_backup(snapshot_id: &str) -> Result<()> {
Ok(theseus::instance::delete_backup_snapshot(snapshot_id).await?)
}
#[tauri::command]
pub async fn instance_get_backup_restore_preview(
snapshot_id: &str,
) -> Result<uuid::Uuid> {
Ok(theseus::instance::get_backup_restore_preview(snapshot_id).await?)
}
#[tauri::command]
pub async fn instance_restore_backup(
snapshot_id: &str,
plan_token: &str,
) -> Result<uuid::Uuid> {
Ok(theseus::instance::restore_snapshot(snapshot_id, plan_token).await?)
}
#[tauri::command]
pub async fn instance_get_backup_delete_summary(
instance_id: &str,
) -> Result<theseus::instance::BackupDeleteSummary> {
Ok(theseus::instance::get_backup_delete_summary(instance_id).await?)
}
+1
View File
@@ -36,6 +36,7 @@ pub mod curseforge;
pub mod datapacks; pub mod datapacks;
pub mod drop; pub mod drop;
pub mod files; pub mod files;
pub mod fonts;
pub mod friends; pub mod friends;
pub mod worlds; pub mod worlds;
+17 -3
View File
@@ -1,5 +1,5 @@
use crate::api::Result; use crate::api::Result;
use tauri::{Emitter, Runtime}; use tauri::{Emitter, Manager, Runtime};
use theseus::prelude::*; use theseus::prelude::*;
use theseus::{ProxyConfig, ProxyTestResult}; use theseus::{ProxyConfig, ProxyTestResult};
@@ -12,7 +12,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
cancel_directory_change, cancel_directory_change,
proxy_get, proxy_get,
proxy_set, proxy_set,
proxy_test proxy_test,
set_ui_scale
]) ])
.build() .build()
} }
@@ -32,11 +33,24 @@ pub async fn settings_set(
app: tauri::AppHandle<impl Runtime>, app: tauri::AppHandle<impl Runtime>,
settings: Settings, settings: Settings,
) -> Result<()> { ) -> Result<()> {
settings::set(settings).await?; settings::set(settings.clone()).await?;
let state = State::get().await?;
state.update_http_client_for_settings(&settings).await?;
let _ = app.emit("settings", ()); let _ = app.emit("settings", ());
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn set_ui_scale(
app: tauri::AppHandle<impl Runtime>,
factor: f64,
) -> Result<()> {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_zoom(factor);
}
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn download_engine_set( pub async fn download_engine_set(
engine: settings::DownloadEngine, engine: settings::DownloadEngine,
+1
View File
@@ -947,6 +947,7 @@ fn main() {
.plugin(api::datapacks::init()) .plugin(api::datapacks::init())
.plugin(api::drop::init()) .plugin(api::drop::init())
.plugin(api::files::init()) .plugin(api::files::init())
.plugin(api::fonts::init())
.plugin(api::friends::init()) .plugin(api::friends::init())
.plugin(api::worlds::init()) .plugin(api::worlds::init())
.plugin(api::terracotta::init()) .plugin(api::terracotta::init())
+81 -1
View File
@@ -1,5 +1,6 @@
use crate::api::Result; use crate::api::Result;
use futures::StreamExt; use futures::StreamExt;
use semver::Version;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@@ -26,6 +27,82 @@ const STARLIGHT_UPDATE_BASE_URL: &str = "https://skin.starlight.cool/";
const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration = const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(15 * 60); std::time::Duration::from_secs(15 * 60);
// semver compares prerelease identifiers lexically, so "beta10" < "beta9"
// (because the character '1' sorts before '9'). Our release names use a
// "betaN" scheme where N is a decimal counter, so a strict lexical compare
// would wrongly report an older beta as the latest. Use a comparator that
// treats the trailing digits of a prerelease identifier numerically.
fn is_remote_newer(current: &Version, remote: &Version) -> bool {
use std::cmp::Ordering;
let core = (remote.major, remote.minor, remote.patch).cmp(&(
current.major,
current.minor,
current.patch,
));
if core != Ordering::Equal {
return core == Ordering::Greater;
}
// Same x.y.z. A final release outranks any prerelease of that version.
match (current.pre.is_empty(), remote.pre.is_empty()) {
(true, true) => false,
(true, false) => false,
(false, true) => true,
(false, false) => {
compare_prerelease(remote.pre.as_str(), current.pre.as_str())
== Ordering::Greater
}
}
}
/// Split a prerelease identifier into its non-digit prefix and an optional
/// trailing decimal number (so "beta10" -> ("beta", Some(10))).
fn split_prerelease_identifier(segment: &str) -> (&str, Option<u64>) {
let digit_start = segment
.char_indices()
.find(|(_, c)| c.is_ascii_digit())
.map(|(i, _)| i);
match digit_start {
Some(i) => (&segment[..i], segment[i..].parse::<u64>().ok()),
None => (segment, None),
}
}
/// Compare two prerelease strings, treating a trailing run of ASCII digits
/// as a decimal number (so "beta10" > "beta9"). All other segments fall back
/// to a plain lexical comparison.
fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
use std::cmp::Ordering;
let left_segments: Vec<&str> = left.split('.').collect();
let right_segments: Vec<&str> = right.split('.').collect();
for (l, r) in left_segments.iter().zip(right_segments.iter()) {
let (l_prefix, l_num) = split_prerelease_identifier(l);
let (r_prefix, r_num) = split_prerelease_identifier(r);
match (l_num, r_num) {
(Some(ln), Some(rn)) if l_prefix == r_prefix => match ln.cmp(&rn) {
Ordering::Equal => continue,
other => return other,
},
_ => {
let prefix_cmp = l_prefix.cmp(r_prefix);
if prefix_cmp != Ordering::Equal {
return prefix_cmp;
}
let num_cmp = l_num.cmp(&r_num);
if num_cmp != Ordering::Equal {
return num_cmp;
}
}
}
}
left_segments.len().cmp(&right_segments.len())
}
// ── Shared types ───────────────────────────────────────────────── // ── Shared types ─────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
@@ -228,7 +305,10 @@ async fn check_with_endpoints<R: Runtime>(
.header("Accept", "application/json")? .header("Accept", "application/json")?
.header("X-Axolotl-Channel", channel)? .header("X-Axolotl-Channel", channel)?
.header("X-Axolotl-Platform", platform)? .header("X-Axolotl-Platform", platform)?
.header("X-Axolotl-Version", current_version)?; .header("X-Axolotl-Version", current_version)?
.version_comparator(|current, release| {
is_remote_newer(&current, &release.version)
});
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshot_groups WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "0a9977b44045c6d0e974770deabc5cb51b41bf5fa94b8bd4b9676f360bf2de7e"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS \"exists!: bool\"",
"describe": {
"columns": [
{
"name": "exists!: bool",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "247aa979f6f6fd116b832cc5a513bd43f0aaa4b554c2a3cac6fb1079e539bf27"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshots WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2ede94b5eab73255500fbd4b6f7eb85abf6c98d4bdfcd93a9a6054e9d5235099"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE instances\n\t\tSET\n\t\t\tsubmitted_time_played = CASE\n\t\t\t\tWHEN submitted_time_played < 0 THEN ?\n\t\t\t\tWHEN submitted_time_played > ? THEN ?\n\t\t\t\tELSE submitted_time_played + ?\n\t\t\tEND,\n\t\t\trecent_time_played = 0,\n\t\t\tmodified = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "3af5ce6fdc84ce12da06b92950d0249bdc4023c0b3d0c683318079ce93c9dd56"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE screenshots\n\t\tSET instance_id = ?, file_name = ?\n\t\tWHERE instance_id = ? AND file_name = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "48c53bc771b0c2af2ccef50cd121ab4de4ed309b54239bacb54b0d173c4e5acb"
}
@@ -0,0 +1,26 @@
{
"db_name": "SQLite",
"query": "\n SELECT id, name\n\t\tFROM screenshot_groups\n\t\tORDER BY display_order, name, id\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "name",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false
]
},
"hash": "4e75c5ee789d5a781967fc0845f12e9c53683afbe05ab5f441c5f5fb1bce0472"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n version\n FROM settings\n ", "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n ui_scale,\n ui_font,\n backup_repository_path,\n mono_font,\n version\n FROM settings\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -294,8 +294,28 @@
"type_info": "Text" "type_info": "Text"
}, },
{ {
"name": "version", "name": "ui_scale",
"ordinal": 58, "ordinal": 58,
"type_info": "Float"
},
{
"name": "ui_font",
"ordinal": 59,
"type_info": "Text"
},
{
"name": "backup_repository_path",
"ordinal": 60,
"type_info": "Text"
},
{
"name": "mono_font",
"ordinal": 61,
"type_info": "Text"
},
{
"name": "version",
"ordinal": 62,
"type_info": "Integer" "type_info": "Integer"
} }
], ],
@@ -361,8 +381,12 @@
null, null,
false, false,
null, null,
false,
null,
null,
null,
false false
] ]
}, },
"hash": "4034abe449274d6b517215f0780ef9685227fb64b061b1a728aebd56cb267f76" "hash": "52d2d61ed9239875d6188417f74bbde439c49582e1e81f5acb7b52d769a1bc18"
} }
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO screenshot_group_memberships (screenshot_id, group_id)\n\t\tSELECT ?, group_id\n\t\tFROM screenshot_group_memberships\n\t\tWHERE screenshot_id = ?\n\t\tON CONFLICT (screenshot_id) DO UPDATE SET group_id = excluded.group_id\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "5616069c5dd7233ef7cf867ac8367fdc4ff254e929cafce9fd3ff38449e88b89"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE screenshot_groups SET name = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "607eac5be3e2a29bb81cce3d0ff14690b61e08f5cb3316c4349c78dc0e4d81f7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "6c58c5daf14ed5198c14799f4a2dd02ebf24d7d61bb74b22abdf46e53b4efdfa"
}
@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tscreenshots.id,\n\t\t\tscreenshots.instance_id,\n\t\t\tscreenshots.file_name,\n\t\t\tscreenshots.content_hash,\n\t\t\tscreenshots.file_size,\n\t\t\tscreenshots.modified_at,\n\t\t\tscreenshots.created_at,\n\t\t\tmemberships.group_id AS \"group_id?\"\n\t\tFROM screenshots\n\t\tLEFT JOIN screenshot_group_memberships memberships\n\t\t\tON memberships.screenshot_id = screenshots.id\n\t\tWHERE screenshots.instance_id = ? AND screenshots.file_name = ?\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "instance_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "file_name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "content_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "file_size",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "modified_at",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "created_at",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "group_id?",
"ordinal": 7,
"type_info": "Text"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "6f17b1880668e6efb235eaa3808bd64a1c6bb5fc5f3c0082500afdcfa7340efa"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT DISTINCT screenshots.instance_id\n\t\tFROM screenshot_group_memberships memberships\n\t\tINNER JOIN screenshots ON screenshots.id = memberships.screenshot_id\n\t\tWHERE memberships.group_id = ?\n\t\t",
"describe": {
"columns": [
{
"name": "instance_id",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "743f351ba5f612262b98b0a12dc2a75e1c92cece3b2715f9a3392c058e9347a8"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\t\tINSERT INTO screenshot_group_memberships (screenshot_id, group_id)\n\t\t\tVALUES (?, ?)\n\t\t\tON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "7567783dcac147378086ed2bd7eab5a059f3d1b868a5c45dbb9de86dcd177085"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE screenshot_groups SET display_order = display_order + 1",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "778ef883e1e68bb549dd1b82761f674df6125b03954683a70431257cc8c923e9"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tINSERT INTO screenshots (\n\t\t\tid,\n\t\t\tinstance_id,\n\t\t\tfile_name,\n\t\t\tcontent_hash,\n\t\t\tfile_size,\n\t\t\tmodified_at,\n\t\t\tcreated_at\n\t\t)\n\t\tVALUES (?, ?, ?, ?, ?, ?, ?)\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "7ea54f8960b92b275b5f9d4190c44bedb489b84d81eba304af25523c8d38dcd2"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\tUPDATE screenshots\n\t\tSET\n\t\t\tinstance_id = ?,\n\t\t\tfile_name = ?,\n\t\t\tcontent_hash = ?,\n\t\t\tfile_size = ?,\n\t\t\tmodified_at = ?,\n\t\t\tcreated_at = ?\n\t\tWHERE id = ?\n\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 7
},
"nullable": []
},
"hash": "8aae262e7a2e6375164af3b296465d017d21a6c6476d0f2ae09c076d75f0dda3"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT instance_id FROM screenshots WHERE id = ?",
"describe": {
"columns": [
{
"name": "instance_id",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "98fbd284e94d1c509ded25c1c17004b3283447e78920e762bcac2765fca6f3eb"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM screenshots WHERE instance_id = ? AND file_name = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "a0705b02be354c37f7a1294eca7eba891c23cd79886a97d91bddb7019baab3ce"
}
@@ -0,0 +1,62 @@
{
"db_name": "SQLite",
"query": "\n\t\tSELECT\n\t\t\tscreenshots.id,\n\t\t\tscreenshots.instance_id,\n\t\t\tscreenshots.file_name,\n\t\t\tscreenshots.content_hash,\n\t\t\tscreenshots.file_size,\n\t\t\tscreenshots.modified_at,\n\t\t\tscreenshots.created_at,\n\t\t\tmemberships.group_id AS \"group_id?\"\n\t\tFROM screenshots\n\t\tLEFT JOIN screenshot_group_memberships memberships\n\t\t\tON memberships.screenshot_id = screenshots.id\n\t\tWHERE screenshots.instance_id = ?\n\t\tORDER BY screenshots.file_name, screenshots.id\n\t\t",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "instance_id",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "file_name",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "content_hash",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "file_size",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "modified_at",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "created_at",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "group_id?",
"ordinal": 7,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "a83e168791306f30ee6f03a274398455c6db66d485824f27ab198c3f00de1770"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n\t\t\tINSERT INTO screenshot_groups (id, name, display_order)\n\t\t\tVALUES (?, ?, ?)\n\t\t\tON CONFLICT(id) DO UPDATE SET name = excluded.name\n\t\t\t",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "c3448ac48772d99b4cfd296996a93230488492804bb4f1d5ccec4a90454438f7"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "c695093aadc64edd159fce723d1e053f5fb7e204e01e54892168c6c36873d60f"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "cd9918f43f0f30b863ee48a7626782521100fac266da99102379ba01a89e9076"
}
@@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups",
"describe": {
"columns": [
{
"name": "display_order!: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false
]
},
"hash": "fd2eb3ed33e2a0eaca507bbe156640acd542e91c99fa491b282542ec4c58032c"
}
@@ -0,0 +1,76 @@
CREATE TABLE repository_meta (
id INTEGER PRIMARY KEY CHECK (id = 0),
schema_name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
INSERT INTO repository_meta (id, schema_name, created_at)
VALUES (0, 'axolotl-instance-backups', unixepoch('subsec') * 1000);
CREATE TABLE instance_backup_configs (
instance_id TEXT PRIMARY KEY,
instance_name TEXT NOT NULL,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL
);
CREATE TABLE backup_selections (
instance_id TEXT NOT NULL REFERENCES instance_backup_configs(instance_id) ON DELETE CASCADE,
path TEXT NOT NULL,
PRIMARY KEY (instance_id, path)
);
CREATE TABLE backup_snapshots (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instance_backup_configs(instance_id) ON DELETE CASCADE,
instance_name TEXT NOT NULL,
created_at INTEGER NOT NULL,
file_count INTEGER NOT NULL,
symlink_count INTEGER NOT NULL,
logical_size INTEGER NOT NULL,
added_size INTEGER NOT NULL
);
CREATE INDEX backup_snapshots_instance_created
ON backup_snapshots(instance_id, created_at DESC);
CREATE TABLE snapshot_roots (
snapshot_id TEXT NOT NULL REFERENCES backup_snapshots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
existed INTEGER NOT NULL,
PRIMARY KEY (snapshot_id, path)
);
CREATE TABLE backup_objects (
hash TEXT PRIMARY KEY,
size INTEGER NOT NULL,
ref_count INTEGER NOT NULL CHECK (ref_count >= 0)
);
CREATE TABLE snapshot_entries (
snapshot_id TEXT NOT NULL REFERENCES backup_snapshots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory', 'symlink')),
object_hash TEXT REFERENCES backup_objects(hash),
size INTEGER NOT NULL DEFAULT 0,
modified_at INTEGER,
unix_mode INTEGER,
link_target TEXT,
link_is_directory INTEGER,
PRIMARY KEY (snapshot_id, path),
CHECK ((kind = 'file' AND object_hash IS NOT NULL) OR (kind != 'file' AND object_hash IS NULL)),
CHECK ((kind = 'symlink' AND link_target IS NOT NULL) OR (kind != 'symlink' AND link_target IS NULL))
);
CREATE INDEX snapshot_entries_object_hash
ON snapshot_entries(object_hash) WHERE object_hash IS NOT NULL;
CREATE TABLE object_gc_queue (
hash TEXT PRIMARY KEY,
queued_at INTEGER NOT NULL
);
CREATE TABLE pending_instance_deletions (
instance_id TEXT PRIMARY KEY,
marked_at INTEGER NOT NULL
);
@@ -0,0 +1,19 @@
ALTER TABLE backup_snapshots
ADD COLUMN scope_mode TEXT NOT NULL DEFAULT 'included_roots'
CHECK (scope_mode IN ('included_roots', 'excluded_paths'));
DROP TABLE backup_selections;
CREATE TABLE backup_exclusions (
instance_id TEXT NOT NULL REFERENCES instance_backup_configs(instance_id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory')),
PRIMARY KEY (instance_id, path)
);
CREATE TABLE snapshot_exclusions (
snapshot_id TEXT NOT NULL REFERENCES backup_snapshots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory')),
PRIMARY KEY (snapshot_id, path)
);
@@ -0,0 +1,26 @@
CREATE TABLE backup_operations (
id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL
CHECK (operation_type IN ('create', 'restore')),
instance_id TEXT NOT NULL,
snapshot_id TEXT,
state TEXT NOT NULL
CHECK (state IN (
'queued', 'scanning', 'hashing', 'saving', 'validating',
'materializing', 'applying', 'completed', 'cancelled', 'failed', 'interrupted'
)),
processed_bytes INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
cancellable INTEGER NOT NULL DEFAULT 1,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER
);
CREATE INDEX backup_operations_instance_created
ON backup_operations(instance_id, created_at DESC);
CREATE INDEX backup_operations_active
ON backup_operations(state, updated_at DESC)
WHERE state NOT IN ('completed', 'cancelled', 'failed', 'interrupted');
@@ -0,0 +1,2 @@
ALTER TABLE backup_operations
ADD COLUMN cancel_requested INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,45 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE backup_operations_new (
id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL
CHECK (operation_type IN ('create', 'restore', 'repository_move')),
instance_id TEXT NOT NULL,
snapshot_id TEXT,
state TEXT NOT NULL
CHECK (state IN (
'queued', 'scanning', 'hashing', 'saving', 'validating',
'materializing', 'applying', 'completed', 'cancelled', 'failed', 'interrupted'
)),
processed_bytes INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
cancellable INTEGER NOT NULL DEFAULT 1,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER,
cancel_requested INTEGER NOT NULL DEFAULT 0
);
INSERT INTO backup_operations_new (
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
)
SELECT
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
FROM backup_operations;
DROP TABLE backup_operations;
ALTER TABLE backup_operations_new RENAME TO backup_operations;
CREATE INDEX backup_operations_instance_created
ON backup_operations(instance_id, created_at DESC);
CREATE INDEX backup_operations_active
ON backup_operations(state, updated_at DESC)
WHERE state NOT IN ('completed', 'cancelled', 'failed', 'interrupted');
PRAGMA foreign_keys = ON;
@@ -0,0 +1,49 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE backup_operations_new (
id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL
CHECK (operation_type IN ('create', 'restore', 'repository_move', 'restore_preview')),
instance_id TEXT NOT NULL,
snapshot_id TEXT,
state TEXT NOT NULL
CHECK (state IN (
'queued', 'scanning', 'hashing', 'saving', 'validating',
'materializing', 'applying', 'completed', 'cancelled', 'failed', 'interrupted'
)),
processed_bytes INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
cancellable INTEGER NOT NULL DEFAULT 1,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER,
cancel_requested INTEGER NOT NULL DEFAULT 0,
preview_added_files INTEGER,
preview_modified_files INTEGER,
preview_deleted_files INTEGER,
preview_plan_token TEXT
);
INSERT INTO backup_operations_new (
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
)
SELECT
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
FROM backup_operations;
DROP TABLE backup_operations;
ALTER TABLE backup_operations_new RENAME TO backup_operations;
CREATE INDEX backup_operations_instance_created
ON backup_operations(instance_id, created_at DESC);
CREATE INDEX backup_operations_active
ON backup_operations(state, updated_at DESC)
WHERE state NOT IN ('completed', 'cancelled', 'failed', 'interrupted');
PRAGMA foreign_keys = ON;
@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN ui_scale REAL NOT NULL DEFAULT 100.0;
@@ -0,0 +1,38 @@
-- Screenshot center: unified index of per-instance screenshots plus
-- user-defined groups. Ported from the Axolotl implementation, trimmed of
-- the instance-sync / game-options tables that Starlight does not use.
CREATE TABLE screenshots (
id TEXT NOT NULL PRIMARY KEY,
instance_id TEXT NOT NULL,
file_name TEXT NOT NULL,
content_hash TEXT NOT NULL,
file_size INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
created_at INTEGER NOT NULL,
editor_state TEXT,
UNIQUE (instance_id, file_name),
FOREIGN KEY (instance_id) REFERENCES instances(id) ON DELETE CASCADE
);
CREATE INDEX screenshots_instance_id ON screenshots(instance_id);
CREATE INDEX screenshots_instance_hash ON screenshots(instance_id, content_hash, file_size);
CREATE TABLE screenshot_groups (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
display_order INTEGER NOT NULL DEFAULT 0,
CHECK (length(trim(name)) > 0)
);
CREATE INDEX screenshot_groups_display_order ON screenshot_groups(display_order);
CREATE TABLE screenshot_group_memberships (
screenshot_id TEXT NOT NULL PRIMARY KEY,
group_id TEXT NOT NULL,
FOREIGN KEY (screenshot_id) REFERENCES screenshots(id) ON DELETE CASCADE,
FOREIGN KEY (group_id) REFERENCES screenshot_groups(id) ON DELETE CASCADE
);
CREATE INDEX screenshot_group_memberships_group_id ON screenshot_group_memberships(group_id);
ALTER TABLE settings ADD COLUMN show_screenshots_tab_in_instances INTEGER NOT NULL DEFAULT FALSE CHECK (show_screenshots_tab_in_instances IN (0, 1));
@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN ignore_ssl_errors INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
ALTER TABLE settings ADD COLUMN ui_font TEXT;
ALTER TABLE settings ADD COLUMN mono_font TEXT;
@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN backup_repository_path TEXT;
@@ -0,0 +1,4 @@
CREATE TABLE pending_backup_repository_cleanups (
path TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
File diff suppressed because it is too large. Load diff
@@ -103,6 +103,7 @@ pub async fn edit(
patch: EditInstance, patch: EditInstance,
) -> crate::Result<InstanceMetadata> { ) -> crate::Result<InstanceMetadata> {
let state = State::get().await?; let state = State::get().await?;
super::ensure_backup_eligible_edit(instance_id, &patch).await?;
crate::state::edit_instance(instance_id, patch, &state.pool).await?; crate::state::edit_instance(instance_id, patch, &state.pool).await?;
let instance = crate::state::get_instance(instance_id, &state.pool) let instance = crate::state::get_instance(instance_id, &state.pool)
@@ -1,5 +1,6 @@
//! Theseus instance management interface //! Theseus instance management interface
mod backup;
mod content; mod content;
mod core_components; mod core_components;
mod export_mrpack; mod export_mrpack;
@@ -12,8 +13,37 @@ mod paths;
mod planet_minecraft; mod planet_minecraft;
mod projects; mod projects;
mod run; mod run;
mod screenshot_groups;
mod screenshots;
mod upgrade; mod upgrade;
pub use self::backup::{
BackupDeleteSummary, BackupExclusion, BackupExclusionKind, BackupOperation,
BackupOperationState, BackupOperationType, BackupRepositoryStatus,
BackupRestorePreview, BackupSnapshot, InstanceBackupConfig,
InstanceBackupEligibility, cancel_backup,
delete_snapshot as delete_backup_snapshot, disable as disable_backups,
enable as enable_backups,
exclusion_from_absolute_path as get_backup_exclusion_from_path,
has_active_operations as has_active_backup_operations,
has_active_repository_move as has_active_backup_repository_move,
instance_config as get_backup_config,
instance_delete_summary as get_backup_delete_summary,
interrupt_active_operations as interrupt_active_backup_operations,
list_operations as list_backup_operations,
list_snapshots as list_backup_snapshots,
maintain_repository as maintain_backup_repository,
repository_status as get_backup_repository_status, restore_snapshot,
start_repository_move as move_backup_repository,
start_restore_preview as get_backup_restore_preview,
start_snapshot as start_backup_snapshot,
update_exclusions as update_backup_exclusions,
};
pub(crate) use self::backup::{
begin_instance_deletion, cancel_instance_deletion, delete_instance_backups,
ensure_backup_eligible_edit, has_active_instance_operation,
lock_instance_maintenance, move_default_repository_for_launcher_directory,
};
pub use self::content::{ pub use self::content::{
apply_content_update_plan, get_content_items, get_content_items_by_paths, apply_content_update_plan, get_content_items, get_content_items_by_paths,
get_content_snapshot, get_dependencies_as_content_items, get_content_snapshot, get_dependencies_as_content_items,
@@ -70,6 +100,20 @@ pub use self::run::{
GcLaunchIntent, GcLaunchReport, QuickPlayType, kill, run, GcLaunchIntent, GcLaunchReport, QuickPlayType, kill, run,
run_with_extra_launch_args, run_with_extra_launch_args_with_gc, run_with_extra_launch_args, run_with_extra_launch_args_with_gc,
}; };
pub use self::screenshot_groups::{
ScreenshotGroup, ScreenshotGroupImport, ScreenshotGroupMembershipUpdate,
create_screenshot_group, delete_screenshot_group, import_screenshot_groups,
list_screenshot_groups, rename_screenshot_group,
set_screenshot_group_memberships,
};
#[allow(unused_imports)]
pub(crate) use self::screenshots::reconcile_screenshots;
pub use self::screenshots::{
InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey,
delete_screenshots, export_screenshots, get_screenshot_path,
list_all_screenshots, list_screenshots, list_synced_screenshots,
move_screenshots, save_edited_screenshot,
};
pub use self::upgrade::{ pub use self::upgrade::{
dismiss_instance_post_upgrade_notice, execute_instance_upgrade, dismiss_instance_post_upgrade_notice, execute_instance_upgrade,
get_instance_post_upgrade_notice, get_instance_upgrade_plan, get_instance_post_upgrade_notice, get_instance_upgrade_plan,
+9
View File
@@ -75,6 +75,15 @@ async fn run_with_extra_launch_args_inner(
extra_launch_args: Option<Vec<String>>, extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>, gc_intent: Option<GcLaunchIntent>,
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> { ) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
if crate::api::instance::has_active_instance_operation(instance_id) {
return Err(crate::ErrorKind::InputError(
"Wait for the active backup operation to finish before launching this instance"
.to_string(),
)
.into());
}
let _maintenance_guard =
crate::api::instance::lock_instance_maintenance(instance_id).await;
let _hosted_guard = let _hosted_guard =
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?; crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
let state = State::get().await?; let state = State::get().await?;
@@ -0,0 +1,349 @@
use crate::State;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::instances::adapters::sqlite::screenshot_rows;
use serde::{Deserialize, Serialize};
use sqlx::{Sqlite, Transaction};
use std::collections::HashSet;
use uuid::Uuid;
const MAX_GROUP_NAME_LENGTH: usize = 256;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenshotGroup {
pub id: String,
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenshotGroupMembershipUpdate {
pub screenshot_id: String,
pub group_id: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ScreenshotGroupImport {
pub id: String,
pub name: String,
pub screenshot_ids: Vec<String>,
}
fn validate_group_name(name: &str) -> crate::Result<&str> {
let name = name.trim();
if name.is_empty() {
return Err(crate::ErrorKind::InputError(
"Group name cannot be empty".to_string(),
)
.into());
}
if name.chars().count() > MAX_GROUP_NAME_LENGTH {
return Err(crate::ErrorKind::InputError(format!(
"Group name cannot exceed {MAX_GROUP_NAME_LENGTH} characters"
))
.into());
}
Ok(name)
}
pub async fn list_screenshot_groups() -> crate::Result<Vec<ScreenshotGroup>> {
let state = State::get().await?;
Ok(screenshot_rows::list_groups(&state.pool)
.await?
.into_iter()
.map(|group| ScreenshotGroup {
id: group.id,
name: group.name,
})
.collect())
}
pub async fn create_screenshot_group(
name: String,
screenshot_ids: Vec<String>,
) -> crate::Result<ScreenshotGroup> {
let name = validate_group_name(&name)?;
let state = State::get().await?;
let id = Uuid::new_v4().to_string();
let mut tx = state.pool.begin().await?;
sqlx::query!(
"UPDATE screenshot_groups SET display_order = display_order + 1",
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO screenshot_groups (id, name, display_order) VALUES (?, ?, 0)",
id,
name,
)
.execute(&mut *tx)
.await?;
let instance_ids = set_group_members(
&id,
normalize_screenshot_ids(screenshot_ids)?,
&mut tx,
)
.await?;
tx.commit().await?;
emit_screenshot_updates(instance_ids).await;
Ok(ScreenshotGroup {
id,
name: name.to_string(),
})
}
pub async fn rename_screenshot_group(
id: String,
new_name: String,
) -> crate::Result<ScreenshotGroup> {
let new_name = validate_group_name(&new_name)?;
let state = State::get().await?;
let result = sqlx::query!(
"UPDATE screenshot_groups SET name = ? WHERE id = ?",
new_name,
id,
)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::ErrorKind::InputError(format!(
"Unknown screenshot group {id}"
))
.into());
}
Ok(ScreenshotGroup {
id,
name: new_name.to_string(),
})
}
pub async fn delete_screenshot_group(id: String) -> crate::Result<()> {
let state = State::get().await?;
let instance_ids = sqlx::query_scalar!(
"
SELECT DISTINCT screenshots.instance_id
FROM screenshot_group_memberships memberships
INNER JOIN screenshots ON screenshots.id = memberships.screenshot_id
WHERE memberships.group_id = ?
",
id,
)
.fetch_all(&state.pool)
.await?;
let result =
sqlx::query!("DELETE FROM screenshot_groups WHERE id = ?", id,)
.execute(&state.pool)
.await?;
if result.rows_affected() == 0 {
return Err(crate::ErrorKind::InputError(format!(
"Unknown screenshot group {id}"
))
.into());
}
emit_screenshot_updates(instance_ids).await;
Ok(())
}
pub async fn set_screenshot_group_memberships(
updates: Vec<ScreenshotGroupMembershipUpdate>,
) -> crate::Result<()> {
let updates = normalize_membership_updates(updates)?;
if updates.is_empty() {
return Ok(());
}
let state = State::get().await?;
let mut tx = state.pool.begin().await?;
let group_ids = updates
.iter()
.filter_map(|update| update.group_id.as_deref())
.collect::<HashSet<_>>();
for group_id in group_ids {
ensure_group_exists(group_id, &mut tx).await?;
}
let mut instance_ids = HashSet::new();
for update in updates {
let instance_id =
screenshot_instance_id(&update.screenshot_id, &mut tx).await?;
instance_ids.insert(instance_id);
sqlx::query!(
"DELETE FROM screenshot_group_memberships WHERE screenshot_id = ?",
update.screenshot_id,
)
.execute(&mut *tx)
.await?;
if let Some(group_id) = update.group_id {
sqlx::query!(
"INSERT INTO screenshot_group_memberships (screenshot_id, group_id) VALUES (?, ?)",
update.screenshot_id,
group_id,
)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
emit_screenshot_updates(instance_ids).await;
Ok(())
}
pub async fn import_screenshot_groups(
groups: Vec<ScreenshotGroupImport>,
) -> crate::Result<()> {
if groups.is_empty() {
return Ok(());
}
let state = State::get().await?;
let mut tx = state.pool.begin().await?;
let base_order = sqlx::query_scalar!(
"SELECT COALESCE(MAX(display_order) + 1, 0) AS 'display_order!: i64' FROM screenshot_groups",
)
.fetch_one(&mut *tx)
.await?;
let mut group_ids = HashSet::new();
let mut affected_instance_ids = HashSet::new();
for (index, group) in groups.into_iter().enumerate() {
if group.id.trim().is_empty() || !group_ids.insert(group.id.clone()) {
return Err(crate::ErrorKind::InputError(
"Screenshot group import contains an invalid group ID"
.to_string(),
)
.into());
}
let name = validate_group_name(&group.name)?;
let display_order = base_order + index as i64;
sqlx::query!(
"
INSERT INTO screenshot_groups (id, name, display_order)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name
",
group.id,
name,
display_order,
)
.execute(&mut *tx)
.await?;
affected_instance_ids.extend(
set_group_members(
&group.id,
normalize_screenshot_ids(group.screenshot_ids)?,
&mut tx,
)
.await?,
);
}
tx.commit().await?;
emit_screenshot_updates(affected_instance_ids).await;
Ok(())
}
fn normalize_screenshot_ids(
screenshot_ids: Vec<String>,
) -> crate::Result<Vec<String>> {
let mut unique = HashSet::new();
if screenshot_ids
.iter()
.all(|screenshot_id| unique.insert(screenshot_id.clone()))
{
return Ok(screenshot_ids);
}
Err(crate::ErrorKind::InputError(
"Screenshot group contains duplicate screenshots".to_string(),
)
.into())
}
fn normalize_membership_updates(
updates: Vec<ScreenshotGroupMembershipUpdate>,
) -> crate::Result<Vec<ScreenshotGroupMembershipUpdate>> {
let mut screenshot_ids = HashSet::new();
if updates
.iter()
.all(|update| screenshot_ids.insert(update.screenshot_id.clone()))
{
return Ok(updates);
}
Err(crate::ErrorKind::InputError(
"Screenshot membership update contains duplicate screenshots"
.to_string(),
)
.into())
}
async fn set_group_members(
group_id: &str,
screenshot_ids: Vec<String>,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<HashSet<String>> {
let mut instance_ids = HashSet::new();
for screenshot_id in screenshot_ids {
instance_ids.insert(screenshot_instance_id(&screenshot_id, tx).await?);
sqlx::query!(
"
INSERT INTO screenshot_group_memberships (screenshot_id, group_id)
VALUES (?, ?)
ON CONFLICT(screenshot_id) DO UPDATE SET group_id = excluded.group_id
",
screenshot_id,
group_id,
)
.execute(&mut **tx)
.await?;
}
Ok(instance_ids)
}
async fn ensure_group_exists(
group_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<()> {
let exists = sqlx::query_scalar!(
r#"SELECT EXISTS(SELECT 1 FROM screenshot_groups WHERE id = ?) AS "exists!: bool""#,
group_id,
)
.fetch_one(&mut **tx)
.await?;
if !exists {
return Err(crate::ErrorKind::InputError(format!(
"Unknown screenshot group {group_id}"
))
.into());
}
Ok(())
}
async fn screenshot_instance_id(
screenshot_id: &str,
tx: &mut Transaction<'_, Sqlite>,
) -> crate::Result<String> {
sqlx::query_scalar!(
"SELECT instance_id FROM screenshots WHERE id = ?",
screenshot_id,
)
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(format!(
"Unknown screenshot {screenshot_id}"
))
})
.map_err(Into::into)
}
async fn emit_screenshot_updates(
instance_ids: impl IntoIterator<Item = String>,
) {
for instance_id in instance_ids {
let _ = emit_instance(
&instance_id,
InstancePayloadType::ScreenshotsUpdated,
)
.await;
}
}
@@ -0,0 +1,10 @@
mod operations;
mod reconciliation;
pub use self::operations::{
InstanceScreenshot, ScreenshotEditSaveMode, ScreenshotKey,
delete_screenshots, export_screenshots, get_screenshot_path,
list_all_screenshots, list_screenshots, list_synced_screenshots,
move_screenshots, save_edited_screenshot,
};
pub(crate) use self::reconciliation::reconcile_screenshots;
@@ -0,0 +1,650 @@
use chrono::{DateTime, Utc};
use futures::stream::{self, StreamExt, TryStreamExt};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use std::path::{Component, Path, PathBuf};
use tokio::sync::OwnedMutexGuard;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipWriter};
use super::reconciliation::{
list_source_screenshots, reconcile_source_screenshots,
scan_source_screenshots,
};
use crate::State;
use crate::event::InstancePayloadType;
use crate::event::emit::emit_instance;
use crate::state::instances::adapters::sqlite::{
instance_rows::{self, InstanceScreenshotSource},
screenshot_rows,
};
use crate::util::fetch::sha1_file_async;
use crate::util::io::{self, IOError};
const SCREENSHOTS_DIRECTORY: &str = "screenshots";
const SCREENSHOT_SCAN_CONCURRENCY: usize = 8;
#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
pub struct ScreenshotKey {
pub instance_id: String,
pub file_name: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct InstanceScreenshot {
pub id: String,
pub instance_id: String,
pub instance_name: String,
pub file_name: String,
pub created_at: DateTime<Utc>,
pub modified_at: i64,
pub group_id: Option<String>,
#[serde(skip)]
pub path: PathBuf,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScreenshotEditSaveMode {
CreateCopy,
ReplaceEdit,
}
pub async fn list_screenshots(
instance_id: &str,
) -> crate::Result<Vec<InstanceScreenshot>> {
let state = State::get().await?;
let source =
instance_rows::get_instance_screenshot_source(instance_id, &state.pool)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
list_source_screenshots(&state, source).await
}
pub async fn list_synced_screenshots() -> crate::Result<Vec<InstanceScreenshot>>
{
list_all_screenshots().await
}
pub async fn list_all_screenshots() -> crate::Result<Vec<InstanceScreenshot>> {
let state = State::get().await?;
let sources = instance_rows::list_screenshot_sources(&state.pool).await?;
list_source_screenshot_sets(&state, sources).await
}
async fn list_source_screenshot_sets(
state: &State,
sources: Vec<InstanceScreenshotSource>,
) -> crate::Result<Vec<InstanceScreenshot>> {
let mut screenshots =
stream::iter(sources.into_iter().map(|source| async move {
list_source_screenshots(state, source).await
}))
.buffer_unordered(SCREENSHOT_SCAN_CONCURRENCY)
.try_collect::<Vec<Vec<InstanceScreenshot>>>()
.await?
.into_iter()
.flatten()
.collect::<Vec<_>>();
sort_screenshots(&mut screenshots);
Ok(screenshots)
}
async fn lock_instance_screenshots<'a>(
state: &State,
instance_ids: impl IntoIterator<Item = &'a str>,
) -> Vec<OwnedMutexGuard<()>> {
let mut locks = Vec::new();
for instance_id in instance_ids {
locks.push(state.lock_instance_screenshots(instance_id).await);
}
locks
}
pub async fn delete_screenshots(keys: &[ScreenshotKey]) -> crate::Result<()> {
ensure_unique_keys(keys)?;
let state = State::get().await?;
let mut instance_ids = keys
.iter()
.map(|key| key.instance_id.as_str())
.collect::<Vec<_>>();
instance_ids.sort_unstable();
instance_ids.dedup();
let _locks = lock_instance_screenshots(&state, instance_ids).await;
for key in keys {
io::remove_file(get_screenshot_path(key).await?).await?;
screenshot_rows::delete_screenshot_by_key(
&key.instance_id,
&key.file_name,
&state.pool,
)
.await?;
}
Ok(())
}
pub async fn export_screenshots(
keys: &[ScreenshotKey],
export_path: PathBuf,
) -> crate::Result<()> {
ensure_unique_keys(keys)?;
if keys.is_empty() {
return Err(crate::ErrorKind::InputError(
"At least one screenshot must be selected".to_string(),
)
.into());
}
let state = State::get().await?;
let mut sources = HashMap::new();
let mut archive_folders = HashMap::<String, String>::new();
let mut used_archive_folders = HashSet::new();
let mut screenshots = Vec::with_capacity(keys.len());
for key in keys {
let source = match sources.get(&key.instance_id) {
Some(source) => source,
None => {
let source = instance_rows::get_instance_screenshot_source(
&key.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
sources.insert(key.instance_id.clone(), source);
&sources[&key.instance_id]
}
};
let archive_folder = match archive_folders.get(&key.instance_id) {
Some(folder) => folder.clone(),
None => {
let folder = unique_archive_folder(
&sanitize_archive_component(&source.name),
&mut used_archive_folders,
);
archive_folders.insert(key.instance_id.clone(), folder.clone());
folder
}
};
let archive_name = format!("{archive_folder}/{}", key.file_name);
screenshots.push((archive_name, get_screenshot_path(key).await?));
}
tokio::task::spawn_blocking(move || -> crate::Result<()> {
let file = std::fs::File::create(&export_path)
.map_err(|error| IOError::with_path(error, &export_path))?;
let mut writer = ZipWriter::new(file);
let options = SimpleFileOptions::default()
.compression_method(CompressionMethod::Stored);
for (archive_name, path) in screenshots {
writer
.start_file(archive_name, options)
.map_err(std::io::Error::from)?;
let mut source = std::fs::File::open(&path)
.map_err(|error| IOError::with_path(error, &path))?;
std::io::copy(&mut source, &mut writer).map_err(IOError::from)?;
}
writer.finish().map_err(std::io::Error::from)?;
Ok(())
})
.await??;
Ok(())
}
pub async fn move_screenshots(
keys: &[ScreenshotKey],
target_instance_id: &str,
) -> crate::Result<Vec<ScreenshotKey>> {
ensure_unique_keys(keys)?;
if keys.is_empty() {
return Ok(Vec::new());
}
let state = State::get().await?;
let mut instance_ids = keys
.iter()
.map(|key| key.instance_id.as_str())
.chain(std::iter::once(target_instance_id))
.collect::<Vec<_>>();
instance_ids.sort_unstable();
instance_ids.dedup();
let _locks = lock_instance_screenshots(&state, instance_ids).await;
let target_source = instance_rows::get_instance_screenshot_source(
target_instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown target instance".to_string())
})?;
let target_dir = source_screenshots_dir(&state, &target_source).await?;
io::create_dir_all(&target_dir).await?;
ensure_directory_is_not_symlink(&target_dir).await?;
let mut moved_keys = Vec::with_capacity(keys.len());
for key in keys {
if key.instance_id == target_instance_id {
continue;
}
let source_path = get_screenshot_path(key).await?;
let target_path =
available_target_path(&target_dir, &key.file_name).await?;
io::rename_or_move(&source_path, &target_path)
.await
.map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not move screenshot: {error}"
))
})?;
let file_name = target_path
.file_name()
.and_then(|file_name| file_name.to_str())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not determine moved screenshot file name"
.to_string(),
)
})?
.to_string();
screenshot_rows::move_screenshot(
&key.instance_id,
&key.file_name,
target_instance_id,
&file_name,
&state.pool,
)
.await?;
moved_keys.push(ScreenshotKey {
instance_id: target_instance_id.to_string(),
file_name,
});
}
Ok(moved_keys)
}
pub async fn get_screenshot_path(
key: &ScreenshotKey,
) -> crate::Result<PathBuf> {
validate_file_name(&key.file_name)?;
let state = State::get().await?;
let source = instance_rows::get_instance_screenshot_source(
&key.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
let canonical_dir = tokio::fs::canonicalize(&screenshots_dir)
.await
.map_err(|error| IOError::with_path(error, &screenshots_dir))?;
let requested_path = screenshots_dir.join(&key.file_name);
let metadata = tokio::fs::symlink_metadata(&requested_path)
.await
.map_err(|error| IOError::with_path(error, &requested_path))?;
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Err(crate::ErrorKind::InputError(
"Screenshot must be a regular file".to_string(),
)
.into());
}
let canonical_path = tokio::fs::canonicalize(&requested_path)
.await
.map_err(|error| IOError::with_path(error, &requested_path))?;
if !canonical_path.starts_with(&canonical_dir) {
return Err(crate::ErrorKind::InputError(
"Screenshot path is outside the instance screenshots directory"
.to_string(),
)
.into());
}
Ok(canonical_path)
}
pub async fn save_edited_screenshot(
key: ScreenshotKey,
png_bytes: Vec<u8>,
mode: ScreenshotEditSaveMode,
) -> crate::Result<InstanceScreenshot> {
validate_file_name(&key.file_name)?;
let state = State::get().await?;
let source = instance_rows::get_instance_screenshot_source(
&key.instance_id,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let _lock = state.lock_instance_screenshots(&source.id).await;
let scanned = scan_source_screenshots(&state, &source).await?;
let current =
reconcile_source_screenshots(&state, &source, scanned).await?;
let source_screenshot = current
.iter()
.find(|screenshot| screenshot.file_name == key.file_name)
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown screenshot".to_string())
})?;
let source_row = screenshot_rows::get_screenshot_by_key(
&key.instance_id,
&key.file_name,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown screenshot".to_string())
})?;
let png_bytes = tokio::task::spawn_blocking(move || {
validate_png_dimensions(&png_bytes)?;
Ok::<_, crate::Error>(png_bytes)
})
.await??;
let screenshots_dir = source_screenshots_dir(&state, &source).await?;
let (target_path, copy_group) = match mode {
ScreenshotEditSaveMode::CreateCopy => (
available_target_path(&screenshots_dir, &source_row.file_name)
.await?,
true,
),
ScreenshotEditSaveMode::ReplaceEdit => {
(source_screenshot.path.clone(), false)
}
};
let target_file_name = target_path
.file_name()
.and_then(|file_name| file_name.to_str())
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not determine edited screenshot file name".to_string(),
)
})?
.to_string();
io::write(&target_path, png_bytes).await?;
if !copy_group {
let (file_size, content_hash) = sha1_file_async(&target_path).await?;
let file_size = i64::try_from(file_size).map_err(|_| {
crate::ErrorKind::InputError(
"Screenshot is too large to index".to_string(),
)
})?;
let mut source_row = source_row.clone();
source_row.content_hash = content_hash;
source_row.file_size = file_size;
let mut tx = state.pool.begin().await?;
screenshot_rows::update_screenshot(&source_row, &mut tx).await?;
tx.commit().await?;
}
let scanned = scan_source_screenshots(&state, &source).await?;
let reconciled =
reconcile_source_screenshots(&state, &source, scanned).await?;
let mut saved = reconciled
.into_iter()
.find(|screenshot| screenshot.file_name == target_file_name)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not index edited screenshot".to_string(),
)
})?;
if copy_group {
let result: crate::Result<()> = async {
let mut tx = state.pool.begin().await?;
screenshot_rows::copy_group_membership(
&source_row.id,
&saved.id,
&mut tx,
)
.await?;
tx.commit().await?;
Ok(())
}
.await;
if let Err(error) = result {
let _ = io::remove_file(&target_path).await;
let _ = screenshot_rows::delete_screenshot_by_key(
&source.id,
&target_file_name,
&state.pool,
)
.await;
return Err(error);
}
}
let mut saved_row = screenshot_rows::get_screenshot_by_key(
&saved.instance_id,
&saved.file_name,
&state.pool,
)
.await?
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Could not load edited screenshot".to_string(),
)
})?;
if !copy_group {
let mut tx = state.pool.begin().await?;
if saved_row.created_at != source_row.created_at {
saved_row.created_at = source_row.created_at;
screenshot_rows::update_screenshot(&saved_row, &mut tx).await?;
saved.created_at =
DateTime::from_timestamp_millis(source_row.created_at)
.ok_or_else(|| {
crate::ErrorKind::InputError(
"Screenshot creation time is out of range"
.to_string(),
)
})?;
}
tx.commit().await?;
}
saved.modified_at = saved_row.modified_at;
saved.group_id = saved_row.group_id;
let _ = emit_instance(
&saved.instance_id,
InstancePayloadType::ScreenshotsUpdated,
)
.await;
Ok(saved)
}
pub(super) async fn source_screenshots_dir(
state: &State,
source: &InstanceScreenshotSource,
) -> crate::Result<PathBuf> {
// Directly associated instances keep their files in the external launcher's
// resolved game directory (which may be the shared `.minecraft` root or an
// isolated version directory). Use the same runtime resolver as launching
// and content browsing so screenshots are read from the directory the game
// actually uses.
let instance_dir =
match instance_rows::get_instance_by_id(&source.id, &state.pool).await?
{
Some(instance) => crate::launcher::linked_game_dir(&instance)
.unwrap_or_else(|| {
state.directories.instances_dir().join(&source.path)
}),
None => state.directories.instances_dir().join(&source.path),
};
let canonical_instance_dir =
tokio::fs::canonicalize(&instance_dir)
.await
.map_err(|error| IOError::with_path(error, &instance_dir))?;
let screenshots_dir = canonical_instance_dir.join(SCREENSHOTS_DIRECTORY);
ensure_directory_is_not_symlink(&screenshots_dir).await?;
Ok(screenshots_dir)
}
async fn ensure_directory_is_not_symlink(path: &Path) -> crate::Result<()> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(crate::ErrorKind::InputError(
"Instance screenshots directory cannot be a symbolic link"
.to_string(),
)
.into())
}
Ok(metadata) if !metadata.is_dir() => {
Err(crate::ErrorKind::InputError(
"Instance screenshots path must be a directory".to_string(),
)
.into())
}
Ok(_) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(IOError::with_path(error, path).into()),
}
}
async fn available_target_path(
target_dir: &Path,
file_name: &str,
) -> crate::Result<PathBuf> {
validate_file_name(file_name)?;
let original = target_dir.join(file_name);
if !tokio::fs::try_exists(&original)
.await
.map_err(|error| IOError::with_path(error, &original))?
{
return Ok(original);
}
let path = Path::new(file_name);
let stem = path.file_stem().and_then(|value| value.to_str()).unwrap();
let extension = path.extension().and_then(|value| value.to_str()).unwrap();
for suffix in 1_u32.. {
let candidate =
target_dir.join(format!("{stem} ({suffix}).{extension}"));
if !tokio::fs::try_exists(&candidate)
.await
.map_err(|error| IOError::with_path(error, &candidate))?
{
return Ok(candidate);
}
}
unreachable!()
}
fn validate_png_dimensions(bytes: &[u8]) -> crate::Result<(u32, u32)> {
let validate = || -> Result<_, png::DecodingError> {
let mut reader = png::Decoder::new(Cursor::new(bytes)).read_info()?;
let dimensions = (reader.info().width, reader.info().height);
while reader.next_row()?.is_some() {}
reader.finish()?;
Ok(dimensions)
};
validate().map_err(|error| {
crate::ErrorKind::InputError(format!(
"Could not decode screenshot as PNG: {error}"
))
.into()
})
}
fn ensure_unique_keys(keys: &[ScreenshotKey]) -> crate::Result<()> {
let mut unique = HashSet::new();
if keys.iter().all(|key| unique.insert(key)) {
return Ok(());
}
Err(crate::ErrorKind::InputError(
"Screenshot selection contains duplicates".to_string(),
)
.into())
}
fn validate_file_name(file_name: &str) -> crate::Result<()> {
let path = Path::new(file_name);
let mut components = path.components();
let is_single_file =
matches!(components.next(), Some(Component::Normal(_)))
&& components.next().is_none();
if !is_single_file || !has_png_extension(path) {
return Err(crate::ErrorKind::InputError(
"Invalid screenshot file name".to_string(),
)
.into());
}
Ok(())
}
pub(super) fn has_png_extension(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("png"))
}
pub(super) fn sort_screenshots(screenshots: &mut [InstanceScreenshot]) {
screenshots.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| left.instance_name.cmp(&right.instance_name))
.then_with(|| left.file_name.cmp(&right.file_name))
});
}
fn sanitize_archive_component(value: &str) -> String {
let sanitized = value
.chars()
.map(|character| match character {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => character,
})
.collect::<String>();
if sanitized.is_empty() || sanitized == "." || sanitized == ".." {
"Instance".to_string()
} else {
sanitized
}
}
fn unique_archive_folder(
base: &str,
used_folders: &mut HashSet<String>,
) -> String {
if used_folders.insert(base.to_string()) {
return base.to_string();
}
for suffix in 2_u32.. {
let candidate = format!("{base} ({suffix})");
if used_folders.insert(candidate.clone()) {
return candidate;
}
}
unreachable!()
}
@@ -0,0 +1,303 @@
use super::operations::{
InstanceScreenshot, has_png_extension, sort_screenshots,
source_screenshots_dir,
};
use crate::State;
use crate::state::instances::adapters::sqlite::{
instance_rows::{self, InstanceScreenshotSource},
screenshot_rows::{self, ScreenshotRow},
};
use crate::util::fetch::sha1_file_async;
use crate::util::io::IOError;
use chrono::{DateTime, Utc};
use futures::stream::{self, StreamExt, TryStreamExt};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use uuid::Uuid;
const SCREENSHOT_HASH_CONCURRENCY: usize = 8;
pub(super) struct ScannedScreenshot {
file_name: String,
created_at: DateTime<Utc>,
modified_at: i64,
file_size: i64,
path: PathBuf,
}
struct ResolvedScreenshot {
scanned: ScannedScreenshot,
row: ScreenshotRow,
is_new: bool,
changed: bool,
}
fn resolve_scanned_screenshot(
source: &InstanceScreenshotSource,
scanned: ScannedScreenshot,
content_hash: String,
matched: Option<ScreenshotRow>,
) -> ResolvedScreenshot {
let (mut row, is_new) = match matched {
Some(row) => (row, false),
None => (
ScreenshotRow {
id: Uuid::new_v4().to_string(),
instance_id: source.id.clone(),
file_name: scanned.file_name.clone(),
content_hash: content_hash.clone(),
file_size: scanned.file_size,
modified_at: scanned.modified_at,
created_at: scanned.created_at.timestamp_millis(),
group_id: None,
},
true,
),
};
let changed = !is_new
&& (row.file_name != scanned.file_name
|| row.content_hash != content_hash
|| row.file_size != scanned.file_size
|| row.modified_at != scanned.modified_at
|| row.created_at != scanned.created_at.timestamp_millis());
row.instance_id.clone_from(&source.id);
row.file_name.clone_from(&scanned.file_name);
row.content_hash = content_hash;
row.file_size = scanned.file_size;
row.modified_at = scanned.modified_at;
row.created_at = scanned.created_at.timestamp_millis();
ResolvedScreenshot {
scanned,
row,
is_new,
changed,
}
}
pub(super) async fn list_source_screenshots(
state: &State,
source: InstanceScreenshotSource,
) -> crate::Result<Vec<InstanceScreenshot>> {
let _lock = state.lock_instance_screenshots(&source.id).await;
let scanned = scan_source_screenshots(state, &source).await?;
reconcile_source_screenshots(state, &source, scanned).await
}
#[allow(dead_code)]
pub(crate) async fn reconcile_screenshots(
instance_id: &str,
) -> crate::Result<()> {
let state = State::get().await?;
let Some(source) =
instance_rows::get_instance_screenshot_source(instance_id, &state.pool)
.await?
else {
return Ok(());
};
list_source_screenshots(&state, source).await?;
Ok(())
}
pub(super) async fn scan_source_screenshots(
state: &State,
source: &InstanceScreenshotSource,
) -> crate::Result<Vec<ScannedScreenshot>> {
let screenshots_dir = source_screenshots_dir(state, source).await?;
tokio::task::spawn_blocking(move || scan_screenshots_dir(&screenshots_dir))
.await?
}
fn scan_screenshots_dir(
screenshots_dir: &Path,
) -> crate::Result<Vec<ScannedScreenshot>> {
let entries = match std::fs::read_dir(screenshots_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(Vec::new());
}
Err(error) => return Err(error.into()),
};
let mut screenshots = Vec::new();
for entry in entries {
let entry = entry
.map_err(|error| IOError::with_path(error, screenshots_dir))?;
let file_type = entry
.file_type()
.map_err(|error| IOError::with_path(error, entry.path()))?;
if !file_type.is_file() {
continue;
}
let path = entry.path();
if !has_png_extension(&path) {
continue;
}
let Some(file_name) = entry.file_name().to_str().map(str::to_owned)
else {
continue;
};
let metadata = entry
.metadata()
.map_err(|error| IOError::with_path(error, &path))?;
let created_at = metadata
.created()
.or_else(|_| metadata.modified())
.map(DateTime::<Utc>::from)
.map_err(|error| IOError::with_path(error, &path))?;
let modified_at = metadata
.modified()
.map(DateTime::<Utc>::from)
.map(|value| value.timestamp_millis())
.map_err(|error| IOError::with_path(error, &path))?;
let file_size = i64::try_from(metadata.len()).map_err(|_| {
crate::ErrorKind::InputError(
"Screenshot is too large to index".to_string(),
)
})?;
screenshots.push(ScannedScreenshot {
file_name,
created_at,
modified_at,
file_size,
path,
});
}
Ok(screenshots)
}
pub(super) async fn reconcile_source_screenshots(
state: &State,
source: &InstanceScreenshotSource,
scanned: Vec<ScannedScreenshot>,
) -> crate::Result<Vec<InstanceScreenshot>> {
let existing =
screenshot_rows::list_screenshots(&source.id, &state.pool).await?;
let mut unmatched_by_name = existing
.into_iter()
.map(|row| (row.file_name.clone(), row))
.collect::<HashMap<_, _>>();
let mut resolved = Vec::with_capacity(scanned.len());
let mut needs_hash = Vec::new();
for scanned in scanned {
match unmatched_by_name.remove(&scanned.file_name) {
Some(row)
if row.file_size == scanned.file_size
&& row.modified_at == scanned.modified_at
&& row.created_at
== scanned.created_at.timestamp_millis() =>
{
resolved.push(ResolvedScreenshot {
scanned,
row,
is_new: false,
changed: false,
});
}
matched => needs_hash.push((scanned, matched)),
}
}
let hashed = stream::iter(needs_hash.into_iter().map(
|(mut scanned, matched)| async move {
let (file_size, content_hash) =
sha1_file_async(&scanned.path).await?;
scanned.file_size = i64::try_from(file_size).map_err(|_| {
crate::ErrorKind::InputError(
"Screenshot is too large to index".to_string(),
)
})?;
Ok::<_, crate::Error>((scanned, content_hash, matched))
},
))
.buffer_unordered(SCREENSHOT_HASH_CONCURRENCY)
.try_collect::<Vec<_>>()
.await?;
let mut unmatched_by_hash =
HashMap::<(String, i64), Vec<ScreenshotRow>>::new();
for row in unmatched_by_name.into_values() {
unmatched_by_hash
.entry((row.content_hash.clone(), row.file_size))
.or_default()
.push(row);
}
for (scanned, content_hash, matched_by_name) in hashed {
if let Some(row) = matched_by_name {
resolved.push(resolve_scanned_screenshot(
source,
scanned,
content_hash,
Some(row),
));
continue;
}
let hash_key = (content_hash.clone(), scanned.file_size);
let matched = unmatched_by_hash.get_mut(&hash_key).and_then(|rows| {
if rows.is_empty() {
return None;
}
let created_at = scanned.created_at.timestamp_millis();
let index = rows
.iter()
.position(|row| {
row.modified_at == scanned.modified_at
&& row.created_at == created_at
})
.unwrap_or(rows.len() - 1);
Some(rows.swap_remove(index))
});
resolved.push(resolve_scanned_screenshot(
source,
scanned,
content_hash,
matched,
));
}
let mut tx = state.pool.begin().await?;
for row in unmatched_by_hash.into_values().flatten() {
screenshot_rows::delete_screenshot(&row.id, &mut tx).await?;
}
for screenshot in &resolved {
if !screenshot.is_new && screenshot.changed {
let mut row = screenshot.row.clone();
row.file_name = Uuid::new_v4().to_string();
screenshot_rows::update_screenshot(&row, &mut tx).await?;
}
}
for screenshot in &resolved {
if screenshot.is_new {
screenshot_rows::insert_screenshot(&screenshot.row, &mut tx)
.await?;
} else if screenshot.changed {
screenshot_rows::update_screenshot(&screenshot.row, &mut tx)
.await?;
}
}
tx.commit().await?;
let mut screenshots = resolved
.into_iter()
.map(|screenshot| InstanceScreenshot {
id: screenshot.row.id,
instance_id: source.id.clone(),
instance_name: source.name.clone(),
file_name: screenshot.scanned.file_name,
created_at: screenshot.scanned.created_at,
modified_at: screenshot.row.modified_at,
group_id: screenshot.row.group_id,
path: screenshot.scanned.path,
})
.collect::<Vec<_>>();
sort_screenshots(&mut screenshots);
Ok(screenshots)
}
+18 -3
View File
@@ -1,8 +1,8 @@
use super::{FriendPayload, LoadingBarId}; use super::{FriendPayload, LoadingBarId};
use crate::event::{ use crate::event::{
CommandPayload, EventError, InstanceBulkUpdateProgressPayload, CommandPayload, EventError, InstanceBackupProgressPayload,
InstancePayloadType, LoadingBar, LoadingBarType, ProcessPayloadType, InstanceBulkUpdateProgressPayload, InstancePayloadType, LoadingBar,
ServerPayloadType, LoadingBarType, ProcessPayloadType, ServerPayloadType,
}; };
#[cfg(feature = "tauri")] #[cfg(feature = "tauri")]
use crate::event::{ use crate::event::{
@@ -358,6 +358,21 @@ pub async fn emit_instance_bulk_update_progress(
Ok(()) Ok(())
} }
#[allow(unused_variables)]
pub async fn emit_instance_backup_progress(
payload: InstanceBackupProgressPayload,
) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
let event_state = crate::EventState::get()?;
event_state
.app
.emit("instance_backup_progress", payload)
.map_err(EventError::from)?;
}
Ok(())
}
// emit_command(CommandPayload::Something { something }) // emit_command(CommandPayload::Something { something })
// ie: installing a pack, opening an .mrpack, etc // ie: installing a pack, opening an .mrpack, etc
// Generally used for url deep links and file opens that we want to handle in the frontend // Generally used for url deep links and file opens that we want to handle in the frontend
Loaded 100 of 138 files, more files were not shown because too many files have changed in this diff. Show more