diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5ed6338 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-gnu] +linker = "x86_64-w64-mingw32-gcc" diff --git a/.gitignore b/.gitignore index df061d2..18e2c71 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ WebView2Loader.dll # Rust/Tauri Target src-tauri/target/ +steam-bridge/target/ # Editor directories and files .vscode/* @@ -34,3 +35,10 @@ src-tauri/target/ *.njsproj *.sln *.sw? + +# Left4Addons specific ignores +.playwright-mcp +known_addons.json +/l4a +package-lock.json + diff --git a/README.md b/README.md index c3df750..b9a48d4 100644 --- a/README.md +++ b/README.md @@ -24,17 +24,13 @@ > > Antigravity 并没有我想象中的那么难用, 但是就想闲暇之余蹬一下额度而已. > 我只做了找bug和UI/UX反馈, 没有对其进行代码Review. -> 环境为 Antigravity + Gemini (3.5 Flash High & 3.1 Pro High) - -> [!WARNING] -> -> 当前分支 `main` 落后太多且有较多问题. 在这上面冒的险还不如构建使用分支 `integrated-steam-workshop`. 但在我看来两个分支都有相当多问题, 在稳定前不建议深度使用. - +> 环境: +> - Antigravity + Gemini 3.5 Flash / 3.1 Pro (High) +> - Codex + GPT 5.4 / 5.5 / 5.6 Terra (xHigh / High) --- ## 特色功能 -- 战役 (Campaign) Part 合并: 支持自动检测将分散的战役 Part 文件合并为一组. - (批量) 重命名: 自动重命名为可读名称, 避免在创意工坊 ID 列表里大海捞针 - 快速移动: 将想要的附加组件从创意工坊内移动到 `addons` 文件夹下. (但是不支持自动取消订阅) @@ -42,6 +38,10 @@ (不支持还未移动到 `addons` 文件夹下的组件) - Dummy Bypass (实验性): 通过一个空的合法 `.vpk` 文件, 允许在不取消订阅的情况下将附件移出创意工坊并绕过创意工坊更新检查. +- 内置创意工坊 & 下载: 无需订阅, 无需启动游戏 一键下载并加载组件! + 自动解析组件依赖关系(创意工坊), 自动重试断点续传和并发下载(默认2)等. +- 一键抓取现有 Steam 创意工坊订阅 (需要启用 Steamworks SDK) +- 为高级用户准备的 [mirror.json](docs/mirrors-configuration.md), 修改 HTTP 请求头, 自配置CDN等.. ## 局限性 - Dummy Bypass: @@ -50,6 +50,12 @@ - 游戏内的附加内容会看到两份相同的附件 一份是创意工坊名 一份是附件内 `addoninfo.txt` 的名 尽管创意工坊侧的 Dummy 附件不添加任何游戏内容. +- Steamworks SDK 集成: + - 使用 Steamworks SDK 会让 Left 4 Addons 伪造成“求生之路”游戏, 以通过 Steam 访问其创意工坊内容. + - Left 4 Addons 使用 Steamworks SDK 时, + 启动《求生之路》会导致游戏本体不被 VAC 信任. (但不会导致 VAC 封禁) + 启动游戏时应先关闭 L4A. + - 更完整的 SDK 搜索/下载限制与实现细节见 [文档](docs/steamworks-sdk-details.md). --- @@ -92,6 +98,24 @@ npm run tauri:build 构建完成后,你可以在 `src-tauri/target/release/` 下找到生成的可执行文件。 +### Linux 上交叉构建 Windows 版本 + +如果你当前在 Linux 上开发,推荐先使用 `x86_64-pc-windows-gnu` 目标: + +```bash +rustup target add x86_64-pc-windows-gnu +npm run tauri:build:win-gnu +``` + +构建完成后,Windows 产物位于 `src-tauri/target/x86_64-pc-windows-gnu/release/`。 + +其中 Steamworks bridge 运行时文件会自动放到 `src-tauri/target/x86_64-pc-windows-gnu/release/steam/`: + +- `l4a-steam-bridge.dll` +- `steam_api64.dll` + +这样无需再手动复制 Steam bridge 相关 DLL。 + --- ## 许可证 diff --git a/docs/mirrors-configuration.md b/docs/mirrors-configuration.md new file mode 100644 index 0000000..4ad6f75 --- /dev/null +++ b/docs/mirrors-configuration.md @@ -0,0 +1,119 @@ +# 镜像代理配置指南 (Mirrors Configuration) + +Left4Addons 支持配置自定义的请求镜像代理 (`mirrors.json`)。通过配置,你可以将应用发往特定域名(例如 Steam 创意工坊)的 HTTP/HTTPS 请求自动重定向到自定义的反向代理或者镜像节点。 + +## 配置文件位置 + +当你启动应用后,配置文件会自动读取自应用的运行时目录,通常位于应用的配置文件夹下。如果没有文件,可以手动新建一个 `mirrors.json`。 + +## 基本语法 + +`mirrors.json` 是一个包含多个**规则对象**的 JSON 数组。代理匹配支持三种模式:**对象模式**、**高级正则模式**、以及**简易字符串模式**。 + +### 字段说明 (通用) + +- **`target`** (`String`, 必填) + 重定向到的目标域名。可以使用 `$1`, `$2` 等占位符来引用匹配规则中捕获的内容(例如被匹配到的子域名)。 + - 若为了保持向后兼容,如果你使用了简易字符串模式并保留了 `*` 号,`*` 仍然会被自动替换为第一个捕获组。 +- **`keep-host`** (`Boolean`, 可选,默认 `false`) + 如果设置为 `true`,将在发送请求时自动添加一个 `Host` 请求头,值为被代理前的原始域名。这通常用于需要验证原始域名的反向代理。 +- **`headers`** (`Object`, 可选,默认无) + 允许在通过此镜像规则发起请求时,自动添加或覆盖特定的 HTTP 请求头。 + +--- + +### 模式一:对象模式 (推荐) +通过结构化的对象定义匹配规则,清晰且易于维护。 + +- **`domain`** (`String`, 必填) + 需要匹配的基础域名,例如 `"steamstatic.com"`。 +- **`subdomains`** (`Array`, 可选) + 需要匹配的子域名列表。例如 `["store", "media"]`。 + - 如果想要匹配任意子域名,可以使用 `["*"]`。 + - 子域名将被作为**第一个捕获组** `$1`。 +- **`allow-www`** (`Boolean`, 可选,默认 `false`) + 是否允许可选的 `www.` 前缀。如果你希望 `www.steamstatic.com` 和 `steamstatic.com` 都能被匹配到,请设为 `true`。(`www.` 不会被捕获,不影响 `$1`) + +**配置示例:** +```json +[ + { + "domain": "steamstatic.com", + "subdomains": ["store", "media"], + "allow-www": true, + "target": "$1.mirror.example.com" + } +] +``` +*以上规则可以匹配 `store.steamstatic.com` 和 `www.media.steamstatic.com`,重定向时 `$1` 会被替换为 `store` 或 `media`。* + +--- + +### 模式二:简易字符串模式 (向下兼容) +通过一条字符串规则快速匹配,支持简单的分组和通配符。 + +- **`rules`** (`String`, 必填) + - 组匹配:`[a,b].example.com`(相当于正则中的 `(a|b).example.com`),会作为捕获组 `$1`。 + - 可选组:`[www.]?example.com`,在 `]` 后面加上 `?` 表示该组为可选,这解决了以往匹配可选前缀时语法怪异的问题。 + - 通配符匹配:`*`,匹配并捕获任何内容。 + - **提示**:在新版本中,建议在 `target` 中使用 `$1`, `$2` 来精确引用对应的捕获组,以避免 `[]` 组匹配意外抢占了通配符 `*` 的内容。 + +**配置示例:** +```json +[ + { + "rules": "[www.]?*.steamcommunity.com", + "target": "$2.steam.example.com", + "keep-host": true + } +] +``` +*上述规则中,`[www.]?` 是第一个捕获组 `$1`(无论是否匹配上),而 `*` 则是第二个捕获组 `$2`。我们在 `target` 中明确使用了 `$2`,避免了冲突。* + +--- + +### 模式三:高级正则模式 +如果内置的匹配模式无法满足需求,你可以直接提供标准的正则表达式进行完全匹配控制。 + +- **`regex`** (`String`, 必填) + 标准正则表达式字符串。正则表达式将针对请求的完整 Host (包括端口,如果有的话) 进行匹配。 + +**配置示例:** +```json +[ + { + "regex": "^(?:www\\.)?(.*?)\\.steampowered\\.com$", + "target": "$1.proxy.my-server.com" + } +] +``` + +## 其它配置示例 + +### 代理 Steam 创意工坊接口并保持 Host + +```json +[ + { + "rules": "api.steampowered.com", + "target": "192.168.1.100:8080", + "keep-host": true + } +] +``` + +### 附加特定的 User-Agent + +```json +[ + { + "domain": "steampowered.com", + "subdomains": ["store", "cdn"], + "target": "$1.proxy.my-server.com", + "headers": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "X-Forwarded-For": "114.114.114.114" + } + } +] +``` diff --git a/docs/steamworks-sdk-details.md b/docs/steamworks-sdk-details.md new file mode 100644 index 0000000..54577df --- /dev/null +++ b/docs/steamworks-sdk-details.md @@ -0,0 +1,125 @@ +# Steamworks SDK 细节与限制 + +本文档记录 Left 4 Addons 当前接入 Steamworks SDK 时已经确认的行为、限制和取舍. + +## 1. 总结 + +- Steamworks SDK 的查询结果与 Steam Community 网页端并不等价, 目前无法仅依靠 SDK 实现高可用性的精准搜索. +- L4A (Left 4 Addons) 可通过 Steamworks SDK 触发 Steam 下载附件, 但过程不可靠. 这也是为何我们要尽可能回退到“通过 URL 下载”方案的原因之一. + +## 2. 搜索行为限制 + +### 2.1 SDK 搜索不等同于网页搜索 + +当前使用的 SDK 搜索基于 `ISteamUGC` 文本检索能力. 实测它和网页端 `steamcommunity.com/workshop/browse` 的语义差异很大: + +多词查询经常劣化为宽松匹配 (逻辑近似 OR, 而不是网页端符合预期的短语或相关性匹配): + - 搜索 `Early Days` + - 网页端可返回 `Early Days` 战役相关 `Part 1-6` + - SDK 侧则可能返回 `Early 2008 style`、`Early Zombie`、`Day Break`、`Release Day Bots` + +单词查询结果同样可能严重偏离: + - 搜索 `Cambalache` + - 预期结果是 `Cambalache 2` + - SDK 结果里完全没有相关附件 + + +SDK 文本搜索目前完全无法复刻网页端的搜索体验. 如果需要“像网页那样”的精准搜索, 仍必须依赖网页抓取或混合方案. + +### 2.2 分类浏览无法纯 SDK 完整复原 + +当前分类浏览依赖 Steam Community 首页 SSR 数据里的 `declared_tags_v5` 等页面信息. + +- “分类树/分类浏览”目前是网页端或 SDK+HTML 混合能力 +- 截止目前, 仅靠纯 Steamworks SDK 不可能实现该功能. + +## 3. 下载行为限制 + +### 3.1 文件格式与较新创意工坊物品不同 + +对于较新的游戏 (如 Wallpaper Engine) , 创意工坊物品通常是一个包含多个文件的文件夹 (甚至涉及更复杂的着色器编译等) . + +但对 L4D2 而言, Workshop 物品由游戏自身处理并安装到 `steamapps/common/Left 4 Dead 2/left4dead2/addons/workshop/` 目录下. + +通过 SDK 触发的下载产物位于: + +`steamapps/workshop/content/550//_legacy.bin` + +> 注: _legacy.bin 似乎是 Steam 针对部分较老的单文件 (如起源引擎用的 `.vpk`) 的重命名机制, +> 但具体细节仍不透明. +> 尽管将 _legacy.bin 手动改名为 .vpk 后能够正常工作, 但这已经是所有下载问题中最小的一个. + +### 3.2 Steam 下载进度不可靠 + +Steam客户端的下载管理会出现非真实状态的情况: + +- Steam 下载管理里长时间显示 0 进度 / 0 网络 / 0 磁盘 +- 可能会进入“正在停止” +- 没有稳定可见的进度条 +- 突然显示已完成 + +虽然 L4A 内部获取的进度看起来正常, 但无法保证底层确实在正常工作. + +### 3.3 无法重新下载或只能保留两个副本 + +- 如果直接删除 `steamapps/workshop/content/550/...` 下的文件夹或里面的文件, + 再次请求后 Steam 也不会重新下载. +- 取消订阅后退出游戏进程 (无论是L4A还是求生之路 即游戏退出未运行的状态时), + Steam 不会删除目标文件/文件夹. +- Steam 可能还持久化保存了如 Manifest 状态. + 虽怀疑是 `steamapps/workshop/appworkshop_550.acf` 作祟, + 但删除后重启 Steam 仍然不起作用 + 这是 L4A 无法稳定通过 SDK 下载附件它的核心阻碍之一. + +在 Wallpaper Engine 侧测试时发现, +如果在 Steam 自动删除前手动删除了物品缓存 (无论是否已订阅) 都会导致严重状态错误. +最致命的影响之一是永久无法再次订阅该物品 +(除非去网页端订阅, 或换一台计算机操作, 但后续行为暂未测试, 具体后续行为也是个未知数). + +## 4. Steamworks SDK 无法直接获取下载 URL + +SDK 里确实有 `ISteamRemoteStorage::GetPublishedFileDetails` + +> `Deprecated - Only used with the deprecated RemoteStorage based Workshop API` + +这是旧版 RemoteStorage API 的接口,且其返回值中并没有暴露文件的直接下载链接。 + +虽然它看起来和网页端免鉴权的 Web API +即 (https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/) +名字几乎一样, 但 SDK 侧偏偏缺少了最核心的 `file_url` 字段, +导致无法在不额外请求网络 API 的情况下得到下载链接. + +## 5. 从旧README中迁移过来的已知缺点 + +- 使用 Steamworks SDK 会使 Left 4 Addons 在 Steam 状态中“伪装”成求生之路, 以此来获取访问创意工坊的权限. +- **VAC 信任问题**: + 如果你在使用 L4A 调用 SDK 时启动游戏. 会导致游戏本体短暂不被 VAC 信任. + 这不会导致封号,但你必须彻底退出 L4A 后再启动游戏才能恢复正常。 +- 仅靠 SDK 无法满足现有需求, 但某些加速器只给加速 Steam 相关进程. 求生之路本体都是自己下的Addon 那些加速器那就是啥用没有 挂个梯子吧. +- 只要 L4A 调用过一次 SDK, Steam 就会一直显示你“正在玩求生之路”, 直到 L4A 进程完全结束. + 如果你在 Steam 库里好奇点了一下“停止”, L4A 就会被关掉. +- 由于 L4D2 创意工坊的 Addon 是游戏内/第三方直连下载 + 市面上那些仅加速特定进程的加速器对此毫无作用. + 建议根据自身网络环境决定是否禁用 Steamworks SDK, 或直接使用系统级代理工具. + +## 后记 + +我要写点有人味的东西, 这样你才不会觉得太枯燥(或许) 或者完全是用 AI 生成的垃圾什么的. + +清理了一下 GPT 生成的无意义描写. 依旧给GPT语擦屁股. 虽然我叫它狗屁通 但写出来的东西也不完全是狗屁不通. 跑分还怪高的嘞 + +没有网页版的创意工坊简直就是地狱, 这个 SDK 的搜索逻辑 ——小红车用户自适应太牛逼了. + +写了这么多缺点, Steamworks SDK 也并非没有好处. 相反效果还挺好的, 只是明显只靠它不是最佳解决方案. + +Steamworks SDK 特色功能: +- (几乎) 无速率限制的 API 请求. 或者 Steam 自己已经帮我们处理好了这点 +- 不需要爬网站就可以解析创意工坊物品的依赖 (这样不会战役Part冲突就瞎几把报“假阳性"了) +- 我可以一把抓到你的所有创意工坊订阅 (但是我好像拉不到收藏的合集 但自己导入L4A其实也很简单了) + +你别看我现在就写出来这3个 其实算是很强大的了 因为我总不可能爬几百个附件对应的网站. +也可以其实, 那 Too many request 伺候就你就不嘻嘻了. + +希望 V 社越做越好吧. 别跟我说史山的事, 至少我希望之后 SDK 和创意工坊相关的逻辑不会这么屎. 大概多久? 可能是 2028 年吧. 2028 年应该有足够强大的模型给史山擦屁股了. + +最近几周创意工坊网页更新很棒. SDK 只要能做一样的事那算是祖坟冒青烟了. diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index a41ce5f..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2857 +0,0 @@ -{ - "name": "addon-manager", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "addon-manager", - "version": "0.0.0", - "dependencies": { - "@tauri-apps/api": "^2.11.1", - "i18next": "^26.3.4", - "lucide-react": "^1.23.0", - "react": "^19.2.7", - "react-dom": "^19.2.7", - "react-i18next": "^17.0.8" - }, - "devDependencies": { - "@tauri-apps/cli": "^2.1.0", - "@testing-library/react": "^16.3.2", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.3", - "jsdom": "^29.1.1", - "oxlint": "^1.71.0", - "typescript": "^6.0.3", - "vite": "^8.1.1", - "vitest": "^4.1.9" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", - "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", - "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", - "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.1.0", - "@csstools/css-calc": "^3.2.1" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", - "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.138.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", - "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", - "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-android-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", - "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", - "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", - "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", - "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", - "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", - "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", - "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", - "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", - "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", - "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", - "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", - "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", - "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", - "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", - "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", - "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", - "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", - "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tauri-apps/api": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz", - "integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==", - "license": "Apache-2.0 OR MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - } - }, - "node_modules/@tauri-apps/cli": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz", - "integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.11.4", - "@tauri-apps/cli-darwin-x64": "2.11.4", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", - "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", - "@tauri-apps/cli-linux-arm64-musl": "2.11.4", - "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-gnu": "2.11.4", - "@tauri-apps/cli-linux-x64-musl": "2.11.4", - "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", - "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", - "@tauri-apps/cli-win32-x64-msvc": "2.11.4" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", - "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", - "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", - "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", - "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", - "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", - "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz", - "integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", - "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", - "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", - "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", - "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/react": { - "version": "16.3.2", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", - "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", - "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rolldown/pluginutils": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", - "babel-plugin-react-compiler": "^1.0.0", - "vite": "^8.0.0" - }, - "peerDependenciesMeta": { - "@rolldown/plugin-babel": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", - "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", - "license": "MIT", - "dependencies": { - "void-elements": "3.1.0" - } - }, - "node_modules/i18next": { - "version": "26.3.4", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz", - "integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==", - "funding": [ - { - "type": "individual", - "url": "https://www.locize.com/i18next" - }, - { - "type": "individual", - "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" - }, - { - "type": "individual", - "url": "https://www.locize.com" - } - ], - "license": "MIT", - "peerDependencies": { - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lru-cache": { - "version": "11.5.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", - "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/lucide-react": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", - "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/oxlint": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", - "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", - "dev": true, - "license": "MIT", - "bin": { - "oxlint": "bin/oxlint" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.72.0", - "@oxlint/binding-android-arm64": "1.72.0", - "@oxlint/binding-darwin-arm64": "1.72.0", - "@oxlint/binding-darwin-x64": "1.72.0", - "@oxlint/binding-freebsd-x64": "1.72.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", - "@oxlint/binding-linux-arm-musleabihf": "1.72.0", - "@oxlint/binding-linux-arm64-gnu": "1.72.0", - "@oxlint/binding-linux-arm64-musl": "1.72.0", - "@oxlint/binding-linux-ppc64-gnu": "1.72.0", - "@oxlint/binding-linux-riscv64-gnu": "1.72.0", - "@oxlint/binding-linux-riscv64-musl": "1.72.0", - "@oxlint/binding-linux-s390x-gnu": "1.72.0", - "@oxlint/binding-linux-x64-gnu": "1.72.0", - "@oxlint/binding-linux-x64-musl": "1.72.0", - "@oxlint/binding-openharmony-arm64": "1.72.0", - "@oxlint/binding-win32-arm64-msvc": "1.72.0", - "@oxlint/binding-win32-ia32-msvc": "1.72.0", - "@oxlint/binding-win32-x64-msvc": "1.72.0" - }, - "peerDependencies": { - "oxlint-tsgolint": ">=0.22.1", - "vite-plus": "*" - }, - "peerDependenciesMeta": { - "oxlint-tsgolint": { - "optional": true - }, - "vite-plus": { - "optional": true - } - } - }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", - "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.7" - } - }, - "node_modules/react-i18next": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", - "integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.29.2", - "html-parse-stringify": "^3.0.1", - "use-sync-external-store": "^1.6.0" - }, - "peerDependencies": { - "i18next": ">= 26.2.0", - "react": ">= 16.8.0", - "typescript": "^5 || ^6" - }, - "peerDependenciesMeta": { - "react-dom": { - "optional": true - }, - "react-native": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/rolldown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", - "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.138.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.4", - "@rolldown/binding-darwin-arm64": "1.1.4", - "@rolldown/binding-darwin-x64": "1.1.4", - "@rolldown/binding-freebsd-x64": "1.1.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", - "@rolldown/binding-linux-arm64-gnu": "1.1.4", - "@rolldown/binding-linux-arm64-musl": "1.1.4", - "@rolldown/binding-linux-ppc64-gnu": "1.1.4", - "@rolldown/binding-linux-s390x-gnu": "1.1.4", - "@rolldown/binding-linux-x64-gnu": "1.1.4", - "@rolldown/binding-linux-x64-musl": "1.1.4", - "@rolldown/binding-openharmony-arm64": "1.1.4", - "@rolldown/binding-wasm32-wasi": "1.1.4", - "@rolldown/binding-win32-arm64-msvc": "1.1.4", - "@rolldown/binding-win32-x64-msvc": "1.1.4" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.6.tgz", - "integrity": "sha512-rbP0Gyx8b3Ae9yO//CU2wbSnQNoQ66m1nJdSbSHmnwKwzkkz/u8mERYU8T2rmlmy+bJvRNn84yNCW8gYqox44Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.4.6" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.4.6", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.6.tgz", - "integrity": "sha512-TkQNGJIhlEphpHCjKodMTSe23egUZr/g+flI2qkLgiJ/maAzSgXypSLRTNH3nCmqgayEmtcJBiLcfODSAr1xoA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.16", - "rolldown": "~1.1.3", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - } - } -} diff --git a/package.json b/package.json index 8c63bf5..39bc0c4 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "scripts": { "dev": "vite", "build": "vite build", + "tauri:build:win-gnu": "npm run build && cargo build --release --target x86_64-pc-windows-gnu --manifest-path src-tauri/Cargo.toml", "lint": "oxlint", "preview": "vite preview", "tauri:dev": "tauri dev", @@ -28,6 +29,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.3", + "esbuild": "^0.28.1", "jsdom": "^29.1.1", "oxlint": "^1.71.0", "typescript": "^6.0.3", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b3b5b17..c955935 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -81,8 +81,10 @@ version = "0.1.0" dependencies = [ "chrono", "image", + "libloading 0.8.9", "log", "md-5", + "notify", "open", "regex", "reqwest 0.12.28", @@ -928,6 +930,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1007,6 +1019,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1565,7 +1586,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -1742,6 +1763,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1884,6 +1925,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -1904,7 +1965,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -1933,6 +1994,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.16" @@ -2030,6 +2101,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.1" @@ -2119,6 +2202,25 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -3678,7 +3780,7 @@ dependencies = [ "unicode-segmentation", "url", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] @@ -4094,7 +4196,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.1", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -4706,7 +4808,7 @@ dependencies = [ "webview2-com-macros", "webview2-com-sys", "windows", - "windows-core 0.61.2", + "windows-core", "windows-implement", "windows-interface", ] @@ -4730,7 +4832,7 @@ checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", "windows", - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4786,7 +4888,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "windows-future", "windows-link 0.1.3", "windows-numerics", @@ -4798,7 +4900,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" dependencies = [ - "windows-core 0.61.2", + "windows-core", ] [[package]] @@ -4814,26 +4916,13 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-future" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", "windows-threading", ] @@ -4878,7 +4967,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" dependencies = [ - "windows-core 0.61.2", + "windows-core", "windows-link 0.1.3", ] @@ -4938,6 +5027,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -4980,6 +5078,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5020,6 +5133,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5032,6 +5151,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5044,6 +5169,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5062,6 +5193,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5074,6 +5211,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5086,6 +5229,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5098,6 +5247,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5189,7 +5344,7 @@ dependencies = [ "webkit2gtk-sys", "webview2-com", "windows", - "windows-core 0.61.2", + "windows-core", "windows-version", "x11-dl", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c480df4..008fbfc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -7,6 +7,8 @@ license = "" repository = "" edition = "2021" rust-version = "1.77.2" +default-run = "app" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -21,7 +23,7 @@ tauri-build = { version = "2.6.3", features = [] } serde_json = "1.0" serde = { version = "1.0", features = ["derive"] } log = "0.4" -tauri = { version = "2.11.3", features = ["custom-protocol"] } +tauri = { version = "2.11.5", features = [] } tauri-plugin-log = "2" reqwest = { version = "0.12", features = ["json"] } regex = "1" @@ -30,6 +32,5 @@ open = "5.3" image = { version = "0.25", default-features = false, features = ["jpeg"] } vtf = "0.4" md-5 = "0.10.6" - - - +libloading = "0.8" +notify = "6.1" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7..cb793e5 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,280 @@ +use std::env; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + fn main() { - tauri_build::build() + tauri_build::build(); + + if let Err(err) = stage_steam_bridge() { + println!("cargo:warning=failed to stage Steam bridge runtime files: {err}"); + } +} + +fn stage_steam_bridge() -> Result<(), String> { + let target = env::var("TARGET").map_err(|e| e.to_string())?; + let artifacts = if target.contains("windows") { + SteamBridgeArtifacts { + bridge_source_name: "l4a_steam_bridge.dll", + bridge_staged_name: "l4a-steam-bridge.dll", + steam_api_name: "steam_api64.dll", + } + } else if target.contains("linux") { + SteamBridgeArtifacts { + bridge_source_name: "libl4a_steam_bridge.so", + bridge_staged_name: "libl4a-steam-bridge.so", + steam_api_name: "libsteam_api.so", + } + } else if target.contains("darwin") { + SteamBridgeArtifacts { + bridge_source_name: "libl4a_steam_bridge.dylib", + bridge_staged_name: "libl4a-steam-bridge.dylib", + steam_api_name: "libsteam_api.dylib", + } + } else { + return Ok(()); + }; + + println!("cargo:rerun-if-env-changed=STEAM_SDK_LOCATION"); + println!("cargo:rerun-if-changed=../steam-bridge/Cargo.toml"); + println!("cargo:rerun-if-changed=../steam-bridge/Cargo.lock"); + println!("cargo:rerun-if-changed=../steam-bridge/src/lib.rs"); + + let cargo = env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()); + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|e| e.to_string())?); + let workspace_dir = manifest_dir + .parent() + .ok_or_else(|| "failed to resolve workspace root".to_string())?; + let bridge_manifest = workspace_dir.join("steam-bridge").join("Cargo.toml"); + let bridge_target_dir = manifest_dir.join("target").join("steam-bridge"); + let profile = env::var("PROFILE").map_err(|e| e.to_string())?; + + let status = Command::new(cargo) + .arg("build") + .arg("--manifest-path") + .arg(&bridge_manifest) + .arg("--target") + .arg(&target) + .args(cargo_profile_args(&profile)) + .arg("--target-dir") + .arg(&bridge_target_dir) + .status() + .map_err(|e| format!("failed to invoke cargo for steam bridge: {e}"))?; + + if !status.success() { + return Err(format!("steam bridge build failed with status {}", status)); + } + + let app_profile_dir = find_profile_dir()?; + let steam_dir = app_profile_dir.join("steam"); + fs::create_dir_all(&steam_dir).map_err(|e| { + format!( + "failed to create steam runtime directory {}: {e}", + steam_dir.display() + ) + })?; + + let bridge_output_dir = bridge_target_dir.join(&target).join(&profile); + copy_if_newer( + &bridge_output_dir.join(artifacts.bridge_source_name), + &steam_dir.join(artifacts.bridge_staged_name), + )?; + + let steam_api = + find_file_named(&bridge_output_dir.join("build"), artifacts.steam_api_name).ok_or_else( + || { + format!( + "failed to locate {} in steam bridge build output; set STEAM_SDK_LOCATION to your Steamworks SDK root if needed", + artifacts.steam_api_name + ) + }, + )?; + copy_if_newer(&steam_api, &steam_dir.join(artifacts.steam_api_name))?; + + if target.contains("windows-gnu") { + for dll_name in gnu_runtime_dlls( + &target, + &bridge_output_dir.join(artifacts.bridge_source_name), + ) { + if let Some(runtime_dll) = locate_linker_runtime_dll(&target, &dll_name) { + copy_if_newer(&runtime_dll, &steam_dir.join(&dll_name))?; + } + } + } + + Ok(()) +} + +struct SteamBridgeArtifacts { + bridge_source_name: &'static str, + bridge_staged_name: &'static str, + steam_api_name: &'static str, +} + +fn cargo_profile_args(profile: &str) -> Vec<&str> { + match profile { + "debug" => Vec::new(), + "release" => vec!["--release"], + _ => vec!["--profile", profile], + } +} + +fn find_profile_dir() -> Result { + let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|e| e.to_string())?); + let profile = env::var("PROFILE").map_err(|e| e.to_string())?; + + for ancestor in out_dir.ancestors() { + if ancestor.file_name().and_then(OsStr::to_str) == Some(profile.as_str()) { + return Ok(ancestor.to_path_buf()); + } + } + + Err(format!( + "failed to resolve Cargo profile directory from OUT_DIR={}", + out_dir.display() + )) +} + +fn copy_if_newer(src: &Path, dst: &Path) -> Result<(), String> { + let src_meta = fs::metadata(src) + .map_err(|e| format!("failed to read source file {}: {e}", src.display()))?; + + let should_copy = match fs::metadata(dst) { + Ok(dst_meta) => { + let src_mtime = src_meta.modified().ok(); + let dst_mtime = dst_meta.modified().ok(); + src_mtime.zip(dst_mtime).map(|(s, d)| s > d).unwrap_or(true) + } + Err(_) => true, + }; + + if should_copy { + fs::copy(src, dst) + .map_err(|e| format!("failed to copy {} -> {}: {e}", src.display(), dst.display()))?; + } + + Ok(()) +} + +fn find_file_named(root: &Path, name: &str) -> Option { + if !root.exists() { + return None; + } + + let mut stack = vec![root.to_path_buf()]; + while let Some(path) = stack.pop() { + let entries = fs::read_dir(&path).ok()?; + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry.file_name() == name { + return Some(entry_path); + } + if entry.file_type().ok()?.is_dir() { + stack.push(entry_path); + } + } + } + + None +} + +fn gnu_runtime_dlls(target: &str, bridge_dll: &Path) -> Vec { + let mut dlls = imported_dlls_from_objdump(target, bridge_dll).unwrap_or_else(|| { + vec![ + "libgcc_s_seh-1.dll".to_string(), + "libwinpthread-1.dll".to_string(), + "libstdc++-6.dll".to_string(), + ] + }); + + dlls.retain(|name| { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".dll") + && lower != "steam_api64.dll" + && lower != "kernel32.dll" + && lower != "user32.dll" + && lower != "advapi32.dll" + && lower != "shell32.dll" + && lower != "ole32.dll" + && lower != "ws2_32.dll" + && lower != "ntdll.dll" + }); + dlls.sort(); + dlls.dedup(); + dlls +} + +fn imported_dlls_from_objdump(target: &str, bridge_dll: &Path) -> Option> { + let objdump = objdump_command_for_target(target)?; + let output = Command::new(objdump) + .arg("-p") + .arg(bridge_dll) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + let dlls = stdout + .lines() + .filter_map(|line| line.trim().strip_prefix("DLL Name: ").map(str::to_string)) + .collect::>(); + + if dlls.is_empty() { + None + } else { + Some(dlls) + } +} + +fn locate_linker_runtime_dll(target: &str, dll_name: &str) -> Option { + let linker = linker_command_for_target(target)?; + let output = Command::new(linker) + .arg(format!("-print-file-name={dll_name}")) + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let path = String::from_utf8(output.stdout).ok()?.trim().to_string(); + if path.is_empty() || path.eq_ignore_ascii_case(dll_name) { + return None; + } + + let resolved = PathBuf::from(path); + resolved.exists().then_some(resolved) +} + +fn linker_command_for_target(target: &str) -> Option { + let target_env = format!( + "CARGO_TARGET_{}_LINKER", + target.replace('-', "_").to_ascii_uppercase() + ); + + env::var(&target_env) + .ok() + .or_else(|| env::var("RUSTC_LINKER").ok()) + .or_else(|| { + if target == "x86_64-pc-windows-gnu" { + Some("x86_64-w64-mingw32-gcc".to_string()) + } else if target == "i686-pc-windows-gnu" { + Some("i686-w64-mingw32-gcc".to_string()) + } else { + None + } + }) +} + +fn objdump_command_for_target(target: &str) -> Option { + let linker = linker_command_for_target(target)?; + if let Some(prefix) = linker.strip_suffix("-gcc") { + return Some(format!("{prefix}-objdump")); + } + if let Some(prefix) = linker.strip_suffix("gcc.exe") { + return Some(format!("{prefix}objdump.exe")); + } + Some("objdump".to_string()) } diff --git a/src-tauri/src/bin/test_vpk.rs b/src-tauri/src/bin/test_vpk.rs deleted file mode 100644 index becbee3..0000000 --- a/src-tauri/src/bin/test_vpk.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - println!("Testing VPK write..."); -} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs deleted file mode 100644 index 6968ca8..0000000 --- a/src-tauri/src/commands.rs +++ /dev/null @@ -1,1009 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::fs; -use std::path::{Path, PathBuf}; -use serde::{Serialize, Deserialize}; -use tauri::{AppHandle, State, Manager}; -use regex::Regex; -use crate::vpk::{extract_addon_metadata, generate_dummy_vpk}; - -#[derive(Serialize, Deserialize, Debug, Clone, Default)] -pub struct Settings { - #[serde(rename = "workshopDir")] - pub workshop_dir: String, - #[serde(rename = "loadingDir")] - pub loading_dir: String, - #[serde(rename = "enableDummyBypass", default)] - pub enable_dummy_bypass: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Addon { - #[serde(rename = "vpkName")] - pub vpk_name: String, - #[serde(rename = "workshopId")] - pub workshop_id: Option, - #[serde(rename = "addonInfo")] - pub addon_info: serde_json::Value, - #[serde(rename = "hasImage")] - pub has_image: bool, - #[serde(rename = "imagePath")] - pub image_path: Option, - #[serde(rename = "filesCount")] - pub files_count: usize, - #[serde(rename = "fileSize")] - pub file_size: u64, - #[serde(rename = "parsedAt")] - pub parsed_at: String, - #[serde(rename = "currentPath")] - pub current_path: String, - #[serde(rename = "dirType")] - pub dir_type: String, // "workshop" or "loading" - #[serde(rename = "isEnabled")] - pub is_enabled: bool, - #[serde(rename = "steamDetails")] - pub steam_details: Option, - #[serde(rename = "isDummy", default)] - pub is_dummy: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct Group { - pub id: String, - pub name: String, - pub addons: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default)] -pub struct Database { - pub settings: Settings, - pub addons: HashMap, - pub groups: Vec, -} - -pub fn load_db(db_path: &Path, app_handle: &AppHandle) -> Database { - let runtime_dir = db_path.parent().unwrap_or(Path::new("")); - let default_loading = runtime_dir.join("addons-loading").to_string_lossy().to_string(); - let default_workshop = Path::new(&default_loading).join("workshop").to_string_lossy().to_string(); - - let default_db = Database { - settings: Settings { - workshop_dir: default_workshop, - loading_dir: default_loading, - enable_dummy_bypass: false, - }, - addons: HashMap::new(), - groups: Vec::new(), - }; - - if !db_path.exists() { - if let Ok(json) = serde_json::to_string_pretty(&default_db) { - let _ = fs::write(db_path, json); - } - return default_db; - } - - let old_default_loading = app_handle.path().app_data_dir() - .map(|p| p.join("addons-loading").to_string_lossy().to_string()) - .unwrap_or_default(); - - match fs::read_to_string(db_path) { - Ok(content) => match serde_json::from_str::(&content) { - Ok(mut data) => { - if data.settings.loading_dir.is_empty() || data.settings.loading_dir == old_default_loading { - data.settings.loading_dir = default_db.settings.loading_dir.clone(); - } - // Enforce workshop_dir is loading_dir/workshop - let loading_path = Path::new(&data.settings.loading_dir); - data.settings.workshop_dir = loading_path.join("workshop").to_string_lossy().to_string(); - data - } - Err(_) => default_db, - }, - Err(_) => default_db, - } -} - -pub fn save_db(db_path: &Path, db: &Database) { - if let Ok(json) = serde_json::to_string_pretty(db) { - let _ = fs::write(db_path, json); - } -} - -async fn fetch_steam_details(workshop_ids: &[String]) -> Result, String> { - if workshop_ids.is_empty() { - return Ok(Vec::new()); - } - - let url = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"; - let mut params = Vec::new(); - params.push(("itemcount".to_string(), workshop_ids.len().to_string())); - for (index, id) in workshop_ids.iter().enumerate() { - params.push((format!("publishedfileids[{}]", index), id.clone())); - } - - let client = reqwest::Client::new(); - let res = client.post(url) - .form(¶ms) - .send() - .await - .map_err(|e| format!("Failed to send Steam API request: {}", e))?; - - if !res.status().is_success() { - return Err(format!("Steam API responded with status {}", res.status())); - } - - let json: serde_json::Value = res.json() - .await - .map_err(|e| format!("Failed to parse Steam API response: {}", e))?; - - let details = json["response"]["publishedfiledetails"] - .as_array() - .cloned() - .unwrap_or_default(); - - Ok(details) -} - -pub async fn scan_addons_internal( - db: &mut Database, - db_path: &Path, - cache_dir: &Path, -) -> Result<(), String> { - let workshop_dir = Path::new(&db.settings.workshop_dir); - let loading_dir = Path::new(&db.settings.loading_dir); - - if !workshop_dir.exists() { - let _ = fs::create_dir_all(workshop_dir); - } - if !loading_dir.exists() { - let _ = fs::create_dir_all(loading_dir); - } - if !cache_dir.exists() { - let _ = fs::create_dir_all(cache_dir); - } - - struct DiskFileInfo { - vpk_name: String, - full_path: PathBuf, - size: u64, - dir_type: String, - is_enabled: bool, - } - - let mut files_on_disk = Vec::new(); - - // Scan workshop dir - if let Ok(entries) = fs::read_dir(workshop_dir) { - for entry in entries.flatten() { - if let Ok(file_type) = entry.file_type() { - if file_type.is_file() { - let path = entry.path(); - let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if filename.ends_with(".vpk") || filename.ends_with(".vpk.disabled") { - let is_enabled = filename.ends_with(".vpk"); - let vpk_name = if is_enabled { - filename.to_string() - } else { - filename.strip_suffix(".disabled").unwrap().to_string() - }; - let size = entry.metadata().map(|m| m.len()).unwrap_or(0); - files_on_disk.push(DiskFileInfo { - vpk_name, - full_path: path, - size, - dir_type: "workshop".to_string(), - is_enabled, - }); - } - } - } - } - } - - // Scan loading dir - if let Ok(entries) = fs::read_dir(loading_dir) { - for entry in entries.flatten() { - if let Ok(file_type) = entry.file_type() { - if file_type.is_file() { - let path = entry.path(); - let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if filename.ends_with(".vpk") || filename.ends_with(".vpk.disabled") { - let is_enabled = filename.ends_with(".vpk"); - let vpk_name = if is_enabled { - filename.to_string() - } else { - filename.strip_suffix(".disabled").unwrap().to_string() - }; - let size = entry.metadata().map(|m| m.len()).unwrap_or(0); - files_on_disk.push(DiskFileInfo { - vpk_name, - full_path: path, - size, - dir_type: "loading".to_string(), - is_enabled, - }); - } - } - } - } - } - - let mut active_addons = HashMap::new(); - let mut new_workshop_ids = HashSet::new(); - - for file_info in files_on_disk { - let vpk_name = file_info.vpk_name.clone(); - let cached = db.addons.get(&vpk_name).cloned(); - - let has_capitalized_keys = cached.as_ref().map_or(false, |addon| { - addon.addon_info.as_object().map_or(false, |obj| { - obj.keys().any(|k| k.chars().any(|c| c.is_uppercase())) - }) - }); - - let needs_metadata = match &cached { - Some(addon) => { - addon.addon_info.is_null() - || addon.addon_info.as_object().map_or(true, |m| m.is_empty()) - || has_capitalized_keys - || (addon.image_path.is_none() && !addon.has_image) - || (addon.has_image && addon.image_path.as_ref().map_or(true, |p| { - p.starts_with("/cache/") && !cache_dir.join(p.trim_start_matches("/cache/")).exists() - })) - }, - None => true, - }; - - if needs_metadata { - println!("Parsing metadata for: {}", vpk_name); - let meta = extract_addon_metadata(&file_info.full_path, cache_dir); - - let mut workshop_id = None; - let base_name = Path::new(&vpk_name) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(""); - if base_name.chars().all(|c| c.is_ascii_digit()) { - workshop_id = Some(base_name.to_string()); - } - - // Fallback: extract workshop ID from url keys inside addon_info (all keys are lowercase now) - if workshop_id.is_none() { - if let Some(url_val) = meta.addon_info.get("addonurl0").or_else(|| meta.addon_info.get("addonurl")) { - if let Some(url_str) = url_val.as_str() { - workshop_id = extract_workshop_id_from_url(url_str); - } - } - } - - let is_dummy = meta.addon_info.get("addondescription") - .and_then(|v| v.as_str()) - .map(|v| v == "A dummy addon generated by Left 4 Addons") - .unwrap_or(false); - - let addon = Addon { - vpk_name: vpk_name.clone(), - workshop_id: workshop_id.clone(), - addon_info: meta.addon_info, - has_image: meta.has_image, - image_path: meta.image_path, - files_count: meta.files_count, - file_size: file_info.size, - parsed_at: chrono::Utc::now().to_rfc3339(), - current_path: file_info.full_path.to_string_lossy().to_string(), - dir_type: file_info.dir_type.clone(), - is_enabled: file_info.is_enabled, - steam_details: None, - is_dummy, - }; - - if let Some(ref w_id) = workshop_id { - new_workshop_ids.insert(w_id.clone()); - } - - active_addons.insert(vpk_name, addon); - } else if let Some(mut addon) = cached { - addon.file_size = file_info.size; - addon.current_path = file_info.full_path.to_string_lossy().to_string(); - addon.dir_type = file_info.dir_type; - addon.is_enabled = file_info.is_enabled; - addon.is_dummy = addon.addon_info.get("addondescription") - .and_then(|v| v.as_str()) - .map(|v| v == "A dummy addon generated by Left 4 Addons") - .unwrap_or(false); - - // Fallback for cached addons that don't have workshop_id yet - if addon.workshop_id.is_none() { - if let Some(url_val) = addon.addon_info.get("addonurl0").or_else(|| addon.addon_info.get("addonurl")) { - if let Some(url_str) = url_val.as_str() { - addon.workshop_id = extract_workshop_id_from_url(url_str); - if let Some(ref w_id) = addon.workshop_id { - new_workshop_ids.insert(w_id.clone()); - } - } - } - } - - active_addons.insert(vpk_name, addon); - } - } - - if !new_workshop_ids.is_empty() { - let ids_array: Vec = new_workshop_ids.into_iter().collect(); - println!("Syncing Steam details for {} items...", ids_array.len()); - if let Ok(steam_details_list) = fetch_steam_details(&ids_array).await { - for details in steam_details_list { - if let Some(w_id) = details.get("publishedfileid").and_then(|id| id.as_str()) { - for addon in active_addons.values_mut() { - if addon.workshop_id.as_deref() == Some(w_id) { - addon.steam_details = Some(details.clone()); - if !addon.has_image { - if let Some(preview_url) = details.get("preview_url").and_then(|u| u.as_str()) { - addon.image_path = Some(preview_url.to_string()); - } - } - break; - } - } - } - } - } - } - - db.addons = active_addons; - - for g in &mut db.groups { - g.addons.retain(|vpk| db.addons.contains_key(vpk)); - } - db.groups.retain(|g| !g.addons.is_empty()); - - save_db(db_path, db); - Ok(()) -} - -fn extract_workshop_id_from_url(url: &str) -> Option { - if let Some(pos) = url.find("id=") { - let start = pos + 3; - let end = url[start..].find('&').map(|idx| start + idx).unwrap_or(url.len()); - let id_str = &url[start..end]; - if id_str.chars().all(|c| c.is_ascii_digit()) && !id_str.is_empty() { - return Some(id_str.to_string()); - } - } - None -} - -fn clean_group_name(name: &str) -> String { - let mut s = name.trim().to_string(); - - let re_ver = Regex::new(r"(?i)\s+v?\d+(?:\.\d+)*$").unwrap(); - let re_part_num = Regex::new(r"(?i)(?:[-#_/:,\s]+(?:part|pt|partie|pts|vol|volume|chapter|ch|act)?\s*(?:\d+|[ivxldcm]+)(?:\/\d+)?)$").unwrap(); - let re_part_word = Regex::new(r"(?i)(?:[-#_/:,\s]+(?:part|pt|partie|pts|vol|volume|chapter|ch|act))$").unwrap(); - - loop { - let prev_len = s.len(); - - s = re_ver.replace(&s, "").into_owned(); - s = re_part_num.replace(&s, "").into_owned(); - s = re_part_word.replace(&s, "").into_owned(); - - s = s.trim_end_matches(|c: char| c.is_whitespace() || c == ':' || c == '_' || c == '-' || c == '/' || c == '\\' || c == '#' || c == '+' || c == ',' || c == '.').to_string(); - - if s.len() == prev_len { - break; - } - } - - s -} - -fn auto_group_internal(db: &mut Database) { - let mut grouped_vpks = HashSet::new(); - for g in &db.groups { - for addon in &g.addons { - grouped_vpks.insert(addon.clone()); - } - } - - let mut ungrouped = Vec::new(); - for (name, addon) in &db.addons { - if !grouped_vpks.contains(name) { - ungrouped.push(addon.clone()); - } - } - - struct Candidate { - vpk_name: String, - title: String, - description: String, - } - - let mut candidates = Vec::new(); - for addon in ungrouped { - let title = addon.steam_details - .as_ref() - .and_then(|d| d.get("title").and_then(|t| t.as_str())) - .or_else(|| addon.addon_info.get("addontitle").and_then(|t| t.as_str())) - .unwrap_or(&addon.vpk_name) - .to_string(); - - let description = addon.addon_info - .get("addondescription") - .and_then(|t| t.as_str()) - .or_else(|| addon.addon_info.get("addontagline").and_then(|t| t.as_str())) - .or_else(|| { - addon.steam_details - .as_ref() - .and_then(|d| d.get("description").and_then(|t| t.as_str())) - }) - .unwrap_or("") - .to_string(); - - candidates.push(Candidate { - vpk_name: addon.vpk_name.clone(), - title, - description, - }); - } - - let mut desc_groups: HashMap> = HashMap::new(); - for (i, c) in candidates.iter().enumerate() { - let desc_trim = c.description.trim(); - if desc_trim.len() > 10 { - let key = desc_trim.to_lowercase(); - desc_groups.entry(key).or_default().push(i); - } - } - - let mut indices_to_remove = HashSet::new(); - - for (_desc, idxs) in desc_groups { - if idxs.len() >= 2 { - let mut common_prefix = candidates[idxs[0]].title.clone(); - for &idx in &idxs[1..] { - let current_title = &candidates[idx].title; - let mut common_len = 0; - for (c1, c2) in common_prefix.chars().zip(current_title.chars()) { - if c1 == c2 { - common_len += c1.len_utf8(); - } else { - break; - } - } - common_prefix.truncate(common_len); - } - - let mut group_name = clean_group_name(&common_prefix); - if group_name.is_empty() { - group_name = "Campaign Pack".to_string(); - } - - let group_id = format!("group_{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos()); - - let mut addons = Vec::new(); - for &idx in &idxs { - addons.push(candidates[idx].vpk_name.clone()); - indices_to_remove.insert(idx); - } - - db.groups.push(Group { - id: group_id, - name: group_name, - addons, - }); - } - } - - let mut remaining_candidates = Vec::new(); - for (i, c) in candidates.into_iter().enumerate() { - if !indices_to_remove.contains(&i) { - remaining_candidates.push(c); - } - } - - let re_part = Regex::new(r"(?i)^(.*?)\s*(?:[-#_]*\s*(?:part|pt|partie|pts)\s*(\d+|[ivxldcm]+)(?:\/\d+)?|\s+v?\d+\.\d+|\s+v\d+)$").unwrap(); - let mut title_groups: HashMap> = HashMap::new(); - for c in remaining_candidates { - if let Some(caps) = re_part.captures(&c.title) { - let prefix = caps.get(1).unwrap().as_str().trim().to_string(); - if prefix.len() >= 3 { - let cleaned_prefix = clean_group_name(&prefix); - if cleaned_prefix.len() >= 3 { - title_groups.entry(cleaned_prefix).or_default().push(c.vpk_name); - } - } - } - } - - for (prefix, addons) in title_groups { - if addons.len() >= 2 { - let group_id = format!("group_{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos()); - - db.groups.push(Group { - id: group_id, - name: prefix, - addons, - }); - } - } -} - -#[tauri::command] -pub async fn get_settings( - state: State<'_, crate::AppState>, -) -> Result { - let db = state.db.lock().await; - Ok(db.settings.clone()) -} - -#[tauri::command] -pub async fn save_settings( - loading_dir: String, - enable_dummy_bypass: bool, - state: State<'_, crate::AppState>, -) -> Result { - let mut db = state.db.lock().await; - let loading_path = Path::new(&loading_dir); - let workshop_dir = loading_path.join("workshop").to_string_lossy().to_string(); - - db.settings.workshop_dir = workshop_dir; - db.settings.loading_dir = loading_dir; - db.settings.enable_dummy_bypass = enable_dummy_bypass; - save_db(&state.db_path, &db); - - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; - Ok(db.clone()) -} - -#[tauri::command] -pub async fn get_addons( - state: State<'_, crate::AppState>, -) -> Result { - let mut db = state.db.lock().await; - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; - Ok(db.clone()) -} - -#[tauri::command] -pub async fn move_addons( - vpk_names: Vec, - target_dir_type: String, - state: State<'_, crate::AppState>, -) -> Result { - let mut db = state.db.lock().await; - let target_dir = if target_dir_type == "loading" { - PathBuf::from(&db.settings.loading_dir) - } else { - PathBuf::from(&db.settings.workshop_dir) - }; - - if !target_dir.exists() { - fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?; - } - - for vpk_name in vpk_names { - if let Some(addon) = db.addons.get(&vpk_name) { - let current_path = PathBuf::from(&addon.current_path); - if current_path.exists() { - let file_name = current_path.file_name().unwrap(); - let dest_path = target_dir.join(file_name); - - // If moving back to workshop, check if dest_path is a dummy addon and remove it first - if target_dir_type == "workshop" && dest_path.exists() { - let is_dummy = if let Ok((files, mut file)) = crate::vpk::parse_vpk(&dest_path) { - let addoninfo_key = files.keys().find(|k| { - let lower = k.to_lowercase(); - lower == "addoninfo.txt" || lower.ends_with("/addoninfo.txt") || lower.ends_with("\\addoninfo.txt") - }); - if let Some(key) = addoninfo_key { - if let Some(entry) = files.get(key) { - if let Ok(content_bytes) = crate::vpk::get_file_content(&mut file, entry) { - let text = String::from_utf8_lossy(&content_bytes); - let parsed = crate::vpk::parse_key_values(&text); - parsed.get("addondescription") - .and_then(|v| v.as_str()) - .map(|v| v == "A dummy addon generated by Left 4 Addons") - .unwrap_or(false) - } else { false } - } else { false } - } else { false } - } else { false }; - - if is_dummy { - let _ = fs::remove_file(&dest_path); - } - } - - if !dest_path.exists() { - if fs::rename(¤t_path, &dest_path).is_ok() { - // If moving out of workshop and dummy bypass is enabled, generate dummy addon in workshop directory - if target_dir_type == "loading" && addon.dir_type == "workshop" && db.settings.enable_dummy_bypass { - if let Some(ref w_id) = addon.workshop_id { - let workshop_dir = PathBuf::from(&db.settings.workshop_dir); - let dummy_vpk_path = workshop_dir.join(format!("{}.vpk", w_id)); - if dest_path.exists() { - let _ = generate_dummy_vpk(&dest_path, &dummy_vpk_path); - } - } - } - } - } - } - } - } - - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; - Ok(db.clone()) -} - -#[tauri::command] -pub async fn toggle_addons( - vpk_names: Vec, - enabled: bool, - state: State<'_, crate::AppState>, -) -> Result { - let mut db = state.db.lock().await; - - for vpk_name in vpk_names { - if let Some(addon) = db.addons.get(&vpk_name) { - let current_path = PathBuf::from(&addon.current_path); - if current_path.exists() { - let current_dir = current_path.parent().unwrap(); - let dest_path = if enabled { - if !addon.current_path.ends_with(".disabled") { - continue; - } - current_dir.join(&addon.vpk_name) - } else { - if addon.current_path.ends_with(".disabled") { - continue; - } - current_dir.join(format!("{}.disabled", addon.vpk_name)) - }; - - if !dest_path.exists() { - let _ = fs::rename(¤t_path, &dest_path); - } - } - } - } - - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; - Ok(db.clone()) -} - -#[tauri::command] -pub async fn rename_addon( - vpk_name: String, - new_vpk_name: String, - state: State<'_, crate::AppState>, -) -> Result { - let mut db = state.db.lock().await; - - let sanitize_filename = |name: &str| { - let re = Regex::new(r"[\\/:*?<>|]").unwrap(); - let s = re.replace_all(name, "_").trim().to_string(); - if s.ends_with(".vpk") { - s - } else { - format!("{}.vpk", s) - } - }; - - let sanitized = sanitize_filename(&new_vpk_name); - - if let Some(addon) = db.addons.get(&vpk_name).cloned() { - let current_path = PathBuf::from(&addon.current_path); - if current_path.exists() { - let dir = current_path.parent().unwrap(); - let new_filename = if addon.is_enabled { - sanitized.clone() - } else { - format!("{}.disabled", sanitized) - }; - let dest_path = dir.join(&new_filename); - - if dest_path.exists() { - return Err(format!("A file named \"{}\" already exists", new_filename)); - } - - fs::rename(¤t_path, &dest_path).map_err(|e| e.to_string())?; - - let mut updated_addon = addon.clone(); - updated_addon.vpk_name = sanitized.clone(); - updated_addon.current_path = dest_path.to_string_lossy().to_string(); - - db.addons.insert(sanitized.clone(), updated_addon); - db.addons.remove(&vpk_name); - - for g in &mut db.groups { - g.addons = g.addons.iter().map(|name| { - if name == &vpk_name { - sanitized.clone() - } else { - name.clone() - } - }).collect(); - } - - let old_base = Path::new(&vpk_name).file_stem().and_then(|s| s.to_str()).unwrap_or("").replace(".disabled", ""); - let new_base = Path::new(&sanitized).file_stem().and_then(|s| s.to_str()).unwrap_or(""); - - use md5::{Md5, Digest}; - let mut hasher_old = Md5::new(); - hasher_old.update(old_base.as_bytes()); - let old_hash = hasher_old.finalize(); - - let mut hasher_new = Md5::new(); - hasher_new.update(new_base.as_bytes()); - let new_hash = hasher_new.finalize(); - - let old_img = state.cache_dir.join(format!("{:x}_image.jpg", old_hash)); - let new_img = state.cache_dir.join(format!("{:x}_image.jpg", new_hash)); - - if old_img.exists() { - if let Ok(_) = fs::rename(&old_img, &new_img) { - if let Some(addon_ref) = db.addons.get_mut(&sanitized) { - addon_ref.image_path = Some(format!("/cache/{:x}_image.jpg", new_hash)); - } - } - } - } - } else { - return Err("Addon not found".to_string()); - } - - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; - Ok(db.clone()) -} - -#[derive(Deserialize)] -pub struct RenameItem { - #[serde(rename = "vpkName")] - pub vpk_name: String, - #[serde(rename = "newVpkName")] - pub new_vpk_name: String, -} - -#[tauri::command] -pub async fn rename_addons( - renames: Vec, - state: State<'_, crate::AppState>, -) -> Result { - let mut db = state.db.lock().await; - - let sanitize_filename = |name: &str| { - let re = Regex::new(r"[\\/:*?<>|]").unwrap(); - let s = re.replace_all(name, "_").trim().to_string(); - if s.ends_with(".vpk") { - s - } else { - format!("{}.vpk", s) - } - }; - - for item in renames { - let sanitized = sanitize_filename(&item.new_vpk_name); - - if let Some(addon) = db.addons.get(&item.vpk_name).cloned() { - let current_path = PathBuf::from(&addon.current_path); - if current_path.exists() { - let dir = current_path.parent().unwrap(); - let new_filename = if addon.is_enabled { - sanitized.clone() - } else { - format!("{}.disabled", sanitized) - }; - let dest_path = dir.join(&new_filename); - - if dest_path.exists() { - continue; // Skip if already exists - } - - if fs::rename(¤t_path, &dest_path).is_err() { - continue; - } - - let mut updated_addon = addon.clone(); - updated_addon.vpk_name = sanitized.clone(); - updated_addon.current_path = dest_path.to_string_lossy().to_string(); - - db.addons.insert(sanitized.clone(), updated_addon); - db.addons.remove(&item.vpk_name); - - for g in &mut db.groups { - g.addons = g.addons.iter().map(|name| { - if name == &item.vpk_name { - sanitized.clone() - } else { - name.clone() - } - }).collect(); - } - - let old_base = Path::new(&item.vpk_name).file_stem().and_then(|s| s.to_str()).unwrap_or("").replace(".disabled", ""); - let new_base = Path::new(&sanitized).file_stem().and_then(|s| s.to_str()).unwrap_or(""); - - use md5::{Md5, Digest}; - let mut hasher_old = Md5::new(); - hasher_old.update(old_base.as_bytes()); - let old_hash = hasher_old.finalize(); - - let mut hasher_new = Md5::new(); - hasher_new.update(new_base.as_bytes()); - let new_hash = hasher_new.finalize(); - - let old_img = state.cache_dir.join(format!("{:x}_image.jpg", old_hash)); - let new_img = state.cache_dir.join(format!("{:x}_image.jpg", new_hash)); - - if old_img.exists() { - if let Ok(_) = fs::rename(&old_img, &new_img) { - if let Some(addon_ref) = db.addons.get_mut(&sanitized) { - addon_ref.image_path = Some(format!("/cache/{:x}_image.jpg", new_hash)); - } - } - } - } - } - } - - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; - Ok(db.clone()) -} - -#[tauri::command] -pub async fn group_action( - action: String, - name: Option, - group_id: Option, - vpk_names: Option>, - state: State<'_, crate::AppState>, -) -> Result { - let mut db = state.db.lock().await; - - if action == "create" { - let name = name.ok_or_else(|| "Missing name".to_string())?; - let vpk_names = vpk_names.ok_or_else(|| "Missing vpkNames".to_string())?; - - let group_id = format!("group_{}", std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_nanos()); - - for g in &mut db.groups { - g.addons.retain(|n| !vpk_names.contains(n)); - } - - let filtered_vpks: Vec = vpk_names.into_iter() - .filter(|n| db.addons.contains_key(n)) - .collect(); - - db.groups.push(Group { - id: group_id, - name, - addons: filtered_vpks, - }); - - } else if action == "delete" { - let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; - db.groups.retain(|g| g.id != group_id); - - } else if action == "add-addons" { - let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; - let vpk_names = vpk_names.ok_or_else(|| "Missing vpkNames".to_string())?; - - for g in &mut db.groups { - g.addons.retain(|n| !vpk_names.contains(n)); - } - - let valid_vpk_names: Vec = vpk_names.into_iter() - .filter(|n| db.addons.contains_key(n)) - .collect(); - - if let Some(target_group) = db.groups.iter_mut().find(|g| g.id == group_id) { - for name in valid_vpk_names { - if !target_group.addons.contains(&name) { - target_group.addons.push(name); - } - } - } else { - return Err("Group not found".to_string()); - } - - - } else if action == "remove-addons" { - let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; - let vpk_names = vpk_names.ok_or_else(|| "Missing vpkNames".to_string())?; - - if let Some(group) = db.groups.iter_mut().find(|g| g.id == group_id) { - group.addons.retain(|n| !vpk_names.contains(n)); - } - - } else if action == "rename-group" { - let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; - let name = name.ok_or_else(|| "Missing name".to_string())?; - - if let Some(group) = db.groups.iter_mut().find(|g| g.id == group_id) { - group.name = name; - } - - } else if action == "auto-group" { - auto_group_internal(&mut db); - } else { - return Err("Unknown action".to_string()); - } - - db.groups.retain(|g| !g.addons.is_empty()); - - save_db(&state.db_path, &db); - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; - Ok(db.clone()) -} - -#[tauri::command] -pub async fn open_workshop( - workshop_id: String, -) -> Result<(), String> { - let url = format!("https://steamcommunity.com/sharedfiles/filedetails/?id={}", workshop_id); - open::that(&url).map_err(|e| format!("Failed to open workshop URL: {}", e))?; - Ok(()) -} - -#[tauri::command] -pub async fn open_url( - url: String, -) -> Result<(), String> { - open::that(&url).map_err(|e| format!("Failed to open URL: {}", e))?; - Ok(()) -} - -#[tauri::command] -pub async fn steam_sync( - state: State<'_, crate::AppState>, -) -> Result { - let mut db = state.db.lock().await; - - // First, scan addons to populate database with any new/removed files - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; - - let mut ids = Vec::new(); - for addon in db.addons.values() { - if let Some(ref w_id) = addon.workshop_id { - ids.push(w_id.clone()); - } - } - - if ids.is_empty() { - return Ok(db.clone()); - } - - println!("Syncing Steam details manually for {} items...", ids.len()); - let steam_details_list = fetch_steam_details(&ids).await?; - - for details in steam_details_list { - if let Some(w_id) = details.get("publishedfileid").and_then(|id| id.as_str()) { - for addon in db.addons.values_mut() { - if addon.workshop_id.as_deref() == Some(w_id) { - addon.steam_details = Some(details.clone()); - if !addon.has_image { - if let Some(preview_url) = details.get("preview_url").and_then(|u| u.as_str()) { - addon.image_path = Some(preview_url.to_string()); - } - } - break; - } - } - } - } - - save_db(&state.db_path, &db); - scan_addons_internal(&mut db, &state.db_path, &state.cache_dir).await?; - Ok(db.clone()) -} - diff --git a/src-tauri/src/commands/handlers/addons.rs b/src-tauri/src/commands/handlers/addons.rs new file mode 100644 index 0000000..e9ef7ac --- /dev/null +++ b/src-tauri/src/commands/handlers/addons.rs @@ -0,0 +1,1547 @@ +use super::*; +use crate::mirrors::MirrorClientExt; +use tauri::{AppHandle, State}; + +#[derive(Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct SdkDownloadWarningPayload { + workshop_id: String, + title: String, + reason: String, + source: String, +} + +fn emit_sdk_download_warning(app_handle: &AppHandle, workshop_id: &str, title: &str, reason: &str) { + let _ = app_handle.emit( + "sdk-download-warning", + SdkDownloadWarningPayload { + workshop_id: workshop_id.to_string(), + title: title.to_string(), + reason: reason.to_string(), + source: "steam-sdk".to_string(), + }, + ); +} + +#[tauri::command] +pub async fn move_addons( + ids: Vec, + target_dir_type: String, + state: State<'_, crate::AppState>, +) -> Result { + let mut db = state.db.lock().await; + + if target_dir_type != "loading" && target_dir_type != "workshop" { + return Err(format!( + "Invalid target directory type: {}", + target_dir_type + )); + } + + let target_dir = if target_dir_type == "loading" { + PathBuf::from(&db.settings.loading_dir) + } else { + PathBuf::from(&db.settings.workshop_dir) + }; + + if !target_dir.exists() { + fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?; + } + + struct MovePlan { + id: String, + current_path: PathBuf, + dest_path: PathBuf, + from_dir_type: String, + workshop_id: Option, + } + + let mut plans = Vec::new(); + let mut errors = Vec::new(); + + for id in ids { + let Some(addon) = db.addons.get(&id) else { + errors.push(format!("Addon not found: {}", id)); + continue; + }; + + if !move_requires_dir_change(addon, &target_dir_type) { + continue; + } + + let current_path = PathBuf::from(&addon.current_path); + if !current_path.exists() { + errors.push(format!("Source file does not exist for {}", addon.vpk_name)); + continue; + } + + let Some(file_name) = current_path.file_name() else { + errors.push(format!("Invalid source file path for {}", addon.vpk_name)); + continue; + }; + + let dest_path = target_dir.join(file_name); + if dest_path.exists() && !(target_dir_type == "workshop" && is_dummy_vpk(&dest_path)) { + errors.push(format!( + "Target file already exists: {}", + dest_path.display() + )); + continue; + } + + plans.push(MovePlan { + id, + current_path, + dest_path, + from_dir_type: addon.dir_type.clone(), + workshop_id: addon.workshop_id.clone(), + }); + } + + if !errors.is_empty() { + return Err(errors.join("\n")); + } + + crate::watcher::suppress_internal_refresh(&state); + for plan in plans { + if target_dir_type == "workshop" && plan.dest_path.exists() && is_dummy_vpk(&plan.dest_path) + { + fs::remove_file(&plan.dest_path) + .map_err(|e| format!("Failed to remove dummy addon for {}: {}", plan.id, e))?; + } + + fs::rename(&plan.current_path, &plan.dest_path) + .map_err(|e| format!("Failed to move {}: {}", plan.id, e))?; + + if target_dir_type == "loading" + && plan.from_dir_type == "workshop" + && db.settings.enable_dummy_bypass + { + if let Some(ref w_id) = plan.workshop_id { + let workshop_dir = PathBuf::from(&db.settings.workshop_dir); + let dummy_vpk_path = workshop_dir.join(format!("{}.vpk", w_id)); + generate_dummy_vpk(&plan.dest_path, &dummy_vpk_path).map_err(|e| { + format!("Failed to generate dummy addon for {}: {}", plan.id, e) + })?; + } + } + } + + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +pub async fn toggle_addons( + ids: Vec, + enabled: bool, + state: State<'_, crate::AppState>, +) -> Result { + let mut db = state.db.lock().await; + + let mut plans = Vec::new(); + let mut errors = Vec::new(); + + for id in ids { + let Some(addon) = db.addons.get(&id) else { + errors.push(format!("Addon not found: {}", id)); + continue; + }; + + if !toggle_requires_state_change(addon, enabled) { + continue; + } + + let current_path = PathBuf::from(&addon.current_path); + if !current_path.exists() { + errors.push(format!("Source file does not exist for {}", addon.vpk_name)); + continue; + } + + let Some(current_dir) = current_path.parent() else { + errors.push(format!("Invalid source file path for {}", addon.vpk_name)); + continue; + }; + + let dest_path = if enabled { + if !addon.current_path.ends_with(".disabled") { + continue; + } + current_dir.join(&addon.vpk_name) + } else { + if addon.current_path.ends_with(".disabled") { + continue; + } + current_dir.join(format!("{}.disabled", addon.vpk_name)) + }; + + if dest_path.exists() { + errors.push(format!( + "Target file already exists: {}", + dest_path.display() + )); + continue; + } + + plans.push((id, current_path, dest_path)); + } + + if !errors.is_empty() { + return Err(errors.join("\n")); + } + + crate::watcher::suppress_internal_refresh(&state); + for (id, current_path, dest_path) in plans { + fs::rename(¤t_path, &dest_path) + .map_err(|e| format!("Failed to toggle {}: {}", id, e))?; + } + + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +pub async fn rename_addon( + id: String, + new_vpk_name: String, + state: State<'_, crate::AppState>, +) -> Result { + let mut db = state.db.lock().await; + + let sanitize_filename = |name: &str| { + let re = Regex::new(r"[\\/:*?<>|]").unwrap(); + let s = re.replace_all(name, "_").trim().to_string(); + if s.ends_with(".vpk") { + s + } else { + format!("{}.vpk", s) + } + }; + + let sanitized = sanitize_filename(&new_vpk_name); + + if let Some(addon) = db.addons.get(&id).cloned() { + let current_path = PathBuf::from(&addon.current_path); + if current_path.exists() { + crate::watcher::suppress_internal_refresh(&state); + let dir = current_path.parent().unwrap(); + let new_filename = if addon.is_enabled { + sanitized.clone() + } else { + format!("{}.disabled", sanitized) + }; + let dest_path = dir.join(&new_filename); + + if dest_path.exists() { + return Err(format!("A file named \"{}\" already exists", new_filename)); + } + + fs::rename(¤t_path, &dest_path).map_err(|e| e.to_string())?; + + let mut updated_addon = addon.clone(); + updated_addon.vpk_name = sanitized.clone(); + updated_addon.current_path = dest_path.to_string_lossy().to_string(); + + db.addons.insert(id.clone(), updated_addon); + + let old_base = Path::new(&addon.vpk_name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .replace(".disabled", ""); + let new_base = Path::new(&sanitized) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + use md5::{Digest, Md5}; + let mut hasher_old = Md5::new(); + hasher_old.update(old_base.as_bytes()); + let old_hash = hasher_old.finalize(); + + let mut hasher_new = Md5::new(); + hasher_new.update(new_base.as_bytes()); + let new_hash = hasher_new.finalize(); + + let old_img = state.cache_dir.join(format!("{:x}_image.jpg", old_hash)); + let new_img = state.cache_dir.join(format!("{:x}_image.jpg", new_hash)); + + if old_img.exists() && fs::rename(&old_img, &new_img).is_ok() { + if let Some(addon_ref) = db.addons.get_mut(&id) { + addon_ref.image_path = Some(format!("/cache/{:x}_image.jpg", new_hash)); + } + } + } + } else { + return Err("Addon not found".to_string()); + } + + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +pub async fn rename_addons( + renames: Vec, + state: State<'_, crate::AppState>, +) -> Result { + let mut db = state.db.lock().await; + + let sanitize_filename = |name: &str| { + let re = Regex::new(r"[\\/:*?<>|]").unwrap(); + let s = re.replace_all(name, "_").trim().to_string(); + if s.ends_with(".vpk") { + s + } else { + format!("{}.vpk", s) + } + }; + + struct RenamePlan { + id: String, + current_path: PathBuf, + dest_path: PathBuf, + sanitized: String, + old_vpk_name: String, + } + + let mut plans = Vec::new(); + let mut errors = Vec::new(); + let mut destination_paths = HashSet::new(); + + for item in renames { + let sanitized = sanitize_filename(&item.new_vpk_name); + + let Some(addon) = db.addons.get(&item.id).cloned() else { + errors.push(format!("Addon not found: {}", item.id)); + continue; + }; + + if !rename_requires_name_change(&addon, &sanitized) { + continue; + } + + let current_path = PathBuf::from(&addon.current_path); + if !current_path.exists() { + errors.push(format!("Source file does not exist for {}", addon.vpk_name)); + continue; + } + + let Some(dir) = current_path.parent() else { + errors.push(format!("Invalid source file path for {}", addon.vpk_name)); + continue; + }; + + let new_filename = if addon.is_enabled { + sanitized.clone() + } else { + format!("{}.disabled", sanitized) + }; + let dest_path = dir.join(&new_filename); + + if dest_path.exists() { + errors.push(format!( + "Target file already exists: {}", + dest_path.display() + )); + continue; + } + + if !destination_paths.insert(dest_path.clone()) { + errors.push(format!("Duplicate rename target: {}", dest_path.display())); + continue; + } + + plans.push(RenamePlan { + id: item.id, + current_path, + dest_path, + sanitized, + old_vpk_name: addon.vpk_name, + }); + } + + if !errors.is_empty() { + return Err(errors.join("\n")); + } + + crate::watcher::suppress_internal_refresh(&state); + for plan in plans { + fs::rename(&plan.current_path, &plan.dest_path) + .map_err(|e| format!("Failed to rename {}: {}", plan.id, e))?; + + if let Some(addon) = db.addons.get_mut(&plan.id) { + addon.vpk_name = plan.sanitized.clone(); + addon.current_path = plan.dest_path.to_string_lossy().to_string(); + } + + let old_base = Path::new(&plan.old_vpk_name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .replace(".disabled", ""); + let new_base = Path::new(&plan.sanitized) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + use md5::{Digest, Md5}; + let mut hasher_old = Md5::new(); + hasher_old.update(old_base.as_bytes()); + let old_hash = hasher_old.finalize(); + + let mut hasher_new = Md5::new(); + hasher_new.update(new_base.as_bytes()); + let new_hash = hasher_new.finalize(); + + let old_img = state.cache_dir.join(format!("{:x}_image.jpg", old_hash)); + let new_img = state.cache_dir.join(format!("{:x}_image.jpg", new_hash)); + + if old_img.exists() { + fs::rename(&old_img, &new_img) + .map_err(|e| format!("Failed to rename cached image for {}: {}", plan.id, e))?; + if let Some(addon_ref) = db.addons.get_mut(&plan.id) { + addon_ref.image_path = Some(format!("/cache/{:x}_image.jpg", new_hash)); + } + } + } + + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +pub async fn group_action( + action: String, + name: Option, + group_id: Option, + ids: Option>, + tags: Option>, + workshop_collection_id: Option, + source: Option, + state: State<'_, crate::AppState>, +) -> Result { + let mut db = state.db.lock().await; + + if action == "create" { + let name = name.ok_or_else(|| "Missing name".to_string())?; + let ids = ids.ok_or_else(|| "Missing ids".to_string())?; + let source = source.unwrap_or_else(|| "manual".to_string()); + + let group_id = format!( + "group_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + + for g in &mut db.groups { + g.addons.retain(|n| !ids.contains(n)); + } + + // For workshop imports, add unknown IDs to known_uninstalled_addons + let raw_ids = ids.clone(); + let mut new_workshop_ids: Vec = Vec::new(); + if source == "workshop-import" { + for raw_id in &raw_ids { + let workshop_id = raw_id.trim().to_string(); + if !db.addons.contains_key(&workshop_id) + && !db.known_uninstalled_addons.contains_key(&workshop_id) + { + db.known_uninstalled_addons.insert( + workshop_id.clone(), + Addon { + id: workshop_id.clone(), + vpk_name: workshop_id.clone(), + workshop_id: Some(workshop_id.clone()), + workshop_id_candidate: None, + workshop_id_source: None, + workshop_id_validation_status: None, + workshop_id_last_attempt_at: None, + addon_info: serde_json::Value::Object(serde_json::Map::new()), + has_image: false, + image_path: None, + files_count: 0, + file_size: 0, + parsed_at: String::new(), + current_path: String::new(), + dir_type: "none".to_string(), + is_enabled: false, + steam_details: None, + workshop_details: None, + is_dummy: false, + }, + ); + new_workshop_ids.push(workshop_id); + } + } + // Fetch Steam details for newly added workshop items + if !new_workshop_ids.is_empty() { + let source_policy = SourcePolicy::from_settings(&db.settings); + if let Ok(steam_details_list) = fetch_steam_details_hybrid( + &state.workshop_service, + &new_workshop_ids, + source_policy.allow_bridge(), + source_policy.allow_web_api(), + source_policy.source_order(), + ) + .await + { + for details in steam_details_list { + let wid = details["publishedfileid"].as_str().unwrap_or(""); + if let Some(addon) = db.known_uninstalled_addons.get_mut(wid) { + // Store raw Steam API response + addon.steam_details = Some(details.clone()); + if let Some(preview_url) = details["preview_url"].as_str() { + if !preview_url.is_empty() { + addon.image_path = Some(preview_url.to_string()); + addon.has_image = true; + } + } + if let Some(file_size_str) = details["file_size"].as_str() { + if let Ok(file_size) = file_size_str.parse::() { + addon.file_size = file_size; + } + } + // Update vpk_name to title if available + if let Some(title) = details["title"].as_str() { + if !title.is_empty() { + addon.vpk_name = title.to_string(); + } + } + } + } + } + } + } + + let filtered_ids: Vec = ids + .into_iter() + .filter_map(|n| { + if db.addons.contains_key(&n) || db.known_uninstalled_addons.contains_key(&n) { + return Some(n); + } + None + }) + .collect(); + + // Auto-assign to master collections based on source + // Ensure system master collections exist + if source == "auto-group" || source == "workshop-import" { + if !db + .master_collections + .iter() + .any(|mc| mc.name_key.as_deref() == Some("masterCollections.systemCampaignAuto")) + { + let mc_id = format!( + "mc_system_auto_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + db.master_collections.push(MasterCollection { + id: mc_id, + name: "战役 (自动识别)".to_string(), + name_key: Some("masterCollections.systemCampaignAuto".to_string()), + group_ids: Vec::new(), + is_system: true, + icon: Some("Sparkles".to_string()), + }); + } + if !db + .master_collections + .iter() + .any(|mc| mc.name_key.as_deref() == Some("masterCollections.systemWorkshopImport")) + { + let mc_id = format!( + "mc_system_ws_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + db.master_collections.push(MasterCollection { + id: mc_id, + name: "从创意工坊导入".to_string(), + name_key: Some("masterCollections.systemWorkshopImport".to_string()), + group_ids: Vec::new(), + is_system: true, + icon: Some("Globe".to_string()), + }); + } + } + + let mut mc_ids: Vec = Vec::new(); + if source == "auto-group" { + if let Some(mc) = db + .master_collections + .iter() + .find(|mc| mc.name_key.as_deref() == Some("masterCollections.systemCampaignAuto")) + { + mc_ids.push(mc.id.clone()); + } + } else if source == "workshop-import" { + if let Some(mc) = db + .master_collections + .iter() + .find(|mc| mc.name_key.as_deref() == Some("masterCollections.systemWorkshopImport")) + { + mc_ids.push(mc.id.clone()); + } + } + + db.groups.push(Group { + id: group_id.clone(), + name, + addons: filtered_ids, + tags, + workshop_collection_id, + master_collection_ids: if mc_ids.is_empty() { + None + } else { + Some(mc_ids.clone()) + }, + source: Some(source), + }); + + // Also update master collections' group_ids + for mc_id in &mc_ids { + if let Some(mc) = db.master_collections.iter_mut().find(|mc| &mc.id == mc_id) { + if !mc.group_ids.contains(&group_id) { + mc.group_ids.push(group_id.clone()); + } + } + } + } else if action == "delete" { + let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; + // Remove group from all master collections + for mc in &mut db.master_collections { + mc.group_ids.retain(|gid| gid != &group_id); + } + db.groups.retain(|g| g.id != group_id); + } else if action == "add-addons" { + let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; + let ids = ids.ok_or_else(|| "Missing ids".to_string())?; + + for g in &mut db.groups { + g.addons.retain(|n| !ids.contains(n)); + } + + let valid_ids: Vec = ids + .into_iter() + .filter_map(|n| { + if db.addons.contains_key(&n) || db.known_uninstalled_addons.contains_key(&n) { + return Some(n); + } + None + }) + .collect(); + + if let Some(target_group) = db.groups.iter_mut().find(|g| g.id == group_id) { + for name in valid_ids { + if !target_group.addons.contains(&name) { + target_group.addons.push(name); + } + } + } else { + return Err("Group not found".to_string()); + } + } else if action == "remove-addons" { + let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; + let ids = ids.ok_or_else(|| "Missing ids".to_string())?; + + if let Some(group) = db.groups.iter_mut().find(|g| g.id == group_id) { + group.addons.retain(|n| !ids.contains(n)); + } + } else if action == "rename-group" || action == "update-group" { + let group_id = group_id.ok_or_else(|| "Missing groupId".to_string())?; + + let valid_addon_names: HashSet = db + .addons + .keys() + .cloned() + .chain(db.known_uninstalled_addons.keys().cloned()) + .collect(); + + if let Some(group) = db.groups.iter_mut().find(|g| g.id == group_id) { + if let Some(n) = name { + group.name = n; + } + group.tags = tags; + group.workshop_collection_id = workshop_collection_id; + + if let Some(vpks) = ids { + let filtered_ids: Vec = vpks + .into_iter() + .filter(|id| valid_addon_names.contains(id)) + .collect(); + group.addons = filtered_ids; + } + } + } else if action == "auto-group" { + auto_group_internal(&mut db); + // Auto-assign auto-grouped groups to system master collection + let mc_name_key = "masterCollections.systemCampaignAuto".to_string(); + if !db + .master_collections + .iter() + .any(|mc| mc.name_key.as_deref() == Some("masterCollections.systemCampaignAuto")) + { + let mc_id = format!( + "mc_system_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + db.master_collections.push(MasterCollection { + id: mc_id.clone(), + name: "战役 (自动识别)".to_string(), + name_key: Some(mc_name_key), + group_ids: Vec::new(), + is_system: true, + icon: Some("Sparkles".to_string()), + }); + } + // Collect auto-group IDs first to avoid borrow conflicts + let auto_group_ids: Vec = db + .groups + .iter() + .filter(|g| g.source.as_deref() == Some("auto-group")) + .map(|g| g.id.clone()) + .collect(); + let mc_id = db + .master_collections + .iter() + .find(|mc| mc.name_key.as_deref() == Some("masterCollections.systemCampaignAuto")) + .map(|mc| mc.id.clone()) + .unwrap(); + // Update master collection group_ids + if let Some(mc) = db.master_collections.iter_mut().find(|mc| mc.id == mc_id) { + for gid in &auto_group_ids { + if !mc.group_ids.contains(gid) { + mc.group_ids.push(gid.clone()); + } + } + } + // Update groups' master_collection_ids + for g in &mut db.groups { + if g.source.as_deref() == Some("auto-group") { + if g.master_collection_ids.is_none() { + g.master_collection_ids = Some(vec![mc_id.clone()]); + } else if let Some(ref mut ids) = g.master_collection_ids { + if !ids.contains(&mc_id) { + ids.push(mc_id.clone()); + } + } + } + } + } else if action == "create-master-collection" { + let name = name.ok_or_else(|| "Missing name".to_string())?; + let mc_id = format!( + "mc_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let group_ids = ids.unwrap_or_default(); + // Update groups to reference this master collection + for gid in &group_ids { + if let Some(group) = db.groups.iter_mut().find(|g| g.id == *gid) { + if group.master_collection_ids.is_none() { + group.master_collection_ids = Some(vec![mc_id.clone()]); + } else if let Some(ref mut ids) = group.master_collection_ids { + if !ids.contains(&mc_id) { + ids.push(mc_id.clone()); + } + } + } + } + db.master_collections.push(MasterCollection { + id: mc_id, + name, + name_key: None, + group_ids, + is_system: false, + icon: None, + }); + } else if action == "delete-master-collection" { + let mc_id = group_id.ok_or_else(|| "Missing masterCollectionId".to_string())?; + // Remove master collection reference from all groups + for group in &mut db.groups { + if let Some(ref mut ids) = group.master_collection_ids { + ids.retain(|id| id != &mc_id); + if ids.is_empty() { + group.master_collection_ids = None; + } + } + } + db.master_collections.retain(|mc| mc.id != mc_id); + } else if action == "rename-master-collection" { + let mc_id = group_id.ok_or_else(|| "Missing masterCollectionId".to_string())?; + let name = name.ok_or_else(|| "Missing name".to_string())?; + if let Some(mc) = db.master_collections.iter_mut().find(|mc| mc.id == mc_id) { + if mc.is_system { + return Err("Cannot rename system master collection".to_string()); + } + mc.name = name; + } else { + return Err("Master collection not found".to_string()); + } + } else if action == "add-to-master-collection" { + let mc_id = group_id.ok_or_else(|| "Missing masterCollectionId".to_string())?; + let group_ids = ids.ok_or_else(|| "Missing ids".to_string())?; + // First update master collection group_ids + if let Some(mc) = db.master_collections.iter_mut().find(|mc| mc.id == mc_id) { + for gid in &group_ids { + if !mc.group_ids.contains(gid) { + mc.group_ids.push(gid.clone()); + } + } + } else { + return Err("Master collection not found".to_string()); + } + // Then update groups' master_collection_ids + for gid in &group_ids { + if let Some(group) = db.groups.iter_mut().find(|g| g.id == *gid) { + if group.master_collection_ids.is_none() { + group.master_collection_ids = Some(vec![mc_id.clone()]); + } else if let Some(ref mut ids) = group.master_collection_ids { + if !ids.contains(&mc_id) { + ids.push(mc_id.clone()); + } + } + } + } + } else if action == "remove-from-master-collection" { + let mc_id = group_id.ok_or_else(|| "Missing masterCollectionId".to_string())?; + let group_ids = ids.ok_or_else(|| "Missing ids".to_string())?; + if let Some(mc) = db.master_collections.iter_mut().find(|mc| mc.id == mc_id) { + mc.group_ids.retain(|gid| !group_ids.contains(gid)); + } + for gid in &group_ids { + if let Some(group) = db.groups.iter_mut().find(|g| g.id == *gid) { + if let Some(ref mut ids) = group.master_collection_ids { + ids.retain(|id| id != &mc_id); + if ids.is_empty() { + group.master_collection_ids = None; + } + } + } + } + } else if action == "ensure-system-master-collections" { + // Ensure system master collections exist + if !db + .master_collections + .iter() + .any(|mc| mc.name_key.as_deref() == Some("masterCollections.systemCampaignAuto")) + { + let mc_id = format!( + "mc_system_auto_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + db.master_collections.push(MasterCollection { + id: mc_id, + name: "战役 (自动识别)".to_string(), + name_key: Some("masterCollections.systemCampaignAuto".to_string()), + group_ids: Vec::new(), + is_system: true, + icon: Some("Sparkles".to_string()), + }); + } + if !db + .master_collections + .iter() + .any(|mc| mc.name_key.as_deref() == Some("masterCollections.systemWorkshopImport")) + { + let mc_id = format!( + "mc_system_ws_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + db.master_collections.push(MasterCollection { + id: mc_id, + name: "从创意工坊导入".to_string(), + name_key: Some("masterCollections.systemWorkshopImport".to_string()), + group_ids: Vec::new(), + is_system: true, + icon: Some("Globe".to_string()), + }); + } + // Sync group memberships + let auto_mc_id = db + .master_collections + .iter() + .find(|mc| mc.name_key.as_deref() == Some("masterCollections.systemCampaignAuto")) + .map(|mc| mc.id.clone()); + let ws_mc_id = db + .master_collections + .iter() + .find(|mc| mc.name_key.as_deref() == Some("masterCollections.systemWorkshopImport")) + .map(|mc| mc.id.clone()); + for g in &mut db.groups { + let source = g.source.clone().unwrap_or_default(); + if source == "auto-group" { + if let Some(ref mc_id) = auto_mc_id { + if let Some(ref mut ids) = g.master_collection_ids { + if !ids.contains(mc_id) { + ids.push(mc_id.clone()); + } + } else { + g.master_collection_ids = Some(vec![mc_id.clone()]); + } + } + } else if source == "workshop-import" { + if let Some(ref mc_id) = ws_mc_id { + if let Some(ref mut ids) = g.master_collection_ids { + if !ids.contains(mc_id) { + ids.push(mc_id.clone()); + } + } else { + g.master_collection_ids = Some(vec![mc_id.clone()]); + } + } + } + } + // Rebuild group_ids for system master collections + if let Some(ref mc_id) = auto_mc_id { + let auto_group_ids: Vec = db + .groups + .iter() + .filter(|g| g.source.as_deref() == Some("auto-group")) + .map(|g| g.id.clone()) + .collect(); + if let Some(mc) = db.master_collections.iter_mut().find(|mc| mc.id == *mc_id) { + mc.group_ids = auto_group_ids; + } + } + if let Some(ref mc_id) = ws_mc_id { + let ws_group_ids: Vec = db + .groups + .iter() + .filter(|g| g.source.as_deref() == Some("workshop-import")) + .map(|g| g.id.clone()) + .collect(); + if let Some(mc) = db.master_collections.iter_mut().find(|mc| mc.id == *mc_id) { + mc.group_ids = ws_group_ids; + } + } + } else { + return Err("Unknown action".to_string()); + } + + normalize_master_collection_group_refs(&mut db); + + save_db_internal( + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &db, + ); + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +pub async fn delete_addons( + ids: Vec, + delete_file: bool, + remove_from_known: bool, + state: State<'_, crate::AppState>, +) -> Result { + let mut db = state.db.lock().await; + + if delete_file { + crate::watcher::suppress_internal_refresh(&state); + for id in &ids { + if let Some(addon) = db.addons.get(id) { + let path = PathBuf::from(&addon.current_path); + if path.exists() { + fs::remove_file(&path) + .map_err(|e| format!("Failed to delete {}: {}", path.display(), e))?; + } + let disabled_path = path.with_extension("vpk.disabled"); + if disabled_path.exists() { + fs::remove_file(&disabled_path).map_err(|e| { + format!("Failed to delete {}: {}", disabled_path.display(), e) + })?; + } + } + } + } + + if remove_from_known { + let mut known_addons = load_known_addons(&state.known_addons_path); + + for id in &ids { + known_addons.remove(id); + db.known_uninstalled_addons.remove(id); + } + + fs::write( + &state.known_addons_path, + serde_json::to_string_pretty(&known_addons).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + } + + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +pub async fn download_addon( + workshop_id: String, + state: State<'_, crate::AppState>, + app_handle: AppHandle, +) -> Result { + clear_download_cancellation(&state, &workshop_id)?; + + let result = async { + let (workshop_dir, source_policy, force_sdk_download) = { + let db = state.db.lock().await; + ( + PathBuf::from(&db.settings.workshop_dir), + SourcePolicy::from_settings(&db.settings), + db.settings.force_steamworks_sdk_download, + ) + }; + let details_list = fetch_steam_details_hybrid( + &state.workshop_service, + std::slice::from_ref(&workshop_id), + source_policy.allow_bridge(), + source_policy.allow_web_api(), + source_policy.source_order(), + ) + .await?; + if details_list.is_empty() { + return Err("Failed to retrieve details for workshop item".to_string()); + } + let details = details_list[0].clone(); + + let title = details + .get("title") + .and_then(|t| t.as_str()) + .unwrap_or("Workshop Item"); + let file_url = details + .get("file_url") + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + let prefer_sdk_download = force_sdk_download || file_url.is_none(); + + if !workshop_dir.exists() { + fs::create_dir_all(&workshop_dir).map_err(|e| e.to_string())?; + } + if !state.download_cache_dir.exists() { + fs::create_dir_all(&state.download_cache_dir).map_err(|e| { + format!( + "Failed to create download cache directory {}: {}", + state.download_cache_dir.display(), + e + ) + })?; + } + + let dest_filename = format!("{}.vpk", workshop_id); + let dest_path = workshop_dir.join(&dest_filename); + let partial_path = partial_download_path(&state.download_cache_dir, &workshop_id); + let partial_metadata_path = + partial_download_metadata_path(&state.download_cache_dir, &workshop_id); + remove_dummy_workshop_targets(&dest_path)?; + if dest_path.exists() { + return Err(format!( + "Addon file already exists: {}", + dest_path.display() + )); + } + let disabled_dest_path = dest_path.with_extension("vpk.disabled"); + if disabled_dest_path.exists() { + return Err(format!( + "Disabled addon file already exists: {}", + disabled_dest_path.display() + )); + } + + if prefer_sdk_download { + emit_sdk_download_warning( + &app_handle, + &workshop_id, + title, + if force_sdk_download { "forced-sdk" } else { "web-download-unavailable" }, + ); + match attempt_bridge_download( + &state, + &state.workshop_service, + &workshop_id, + &workshop_dir, + &app_handle, + source_policy.allow_bridge(), + ) + .await + { + Ok(true) => { + let _ = cleanup_partial_download(&state.download_cache_dir, &workshop_id); + crate::watcher::suppress_internal_refresh(&state); + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + return Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )); + } + Ok(false) => {} + Err(err) if err == DOWNLOAD_CANCELLED_ERR => return Err(err), + Err(_) => {} + } + } + + let file_url = match file_url { + Some(url) => url, + None if !prefer_sdk_download => { + emit_sdk_download_warning( + &app_handle, + &workshop_id, + title, + "web-download-unavailable", + ); + match attempt_bridge_download( + &state, + &state.workshop_service, + &workshop_id, + &workshop_dir, + &app_handle, + source_policy.allow_bridge(), + ) + .await + { + Ok(true) => { + let _ = cleanup_partial_download(&state.download_cache_dir, &workshop_id); + crate::watcher::suppress_internal_refresh(&state); + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + return Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )); + } + Ok(false) => {} + Err(err) if err == DOWNLOAD_CANCELLED_ERR => return Err(err), + Err(_) => {} + } + + return Err( + "Workshop item has no direct download URL, and Steamworks SDK fallback did not install it".to_string() + ); + } + None => { + return Err( + "Workshop item has no download URL (Steamworks SDK did not install it and no direct Web API file_url is available)".to_string() + ); + } + }; + + println!("Downloading: {} (URL: {})", title, file_url); + let client = reqwest::Client::new(); + let resolved_file_url = crate::mirrors::MirrorManager::resolve(&file_url).0; + + let mut resume_from = 0u64; + if partial_path.exists() || partial_metadata_path.exists() { + match load_partial_download_metadata(&partial_metadata_path) { + Ok(metadata) + if metadata.workshop_id == workshop_id + && metadata.target_filename == dest_filename + && metadata.file_url == resolved_file_url => + { + resume_from = fs::metadata(&partial_path) + .map(|meta| meta.len()) + .unwrap_or(0); + } + Ok(_) | Err(_) => { + cleanup_partial_download(&state.download_cache_dir, &workshop_id)?; + } + } + } + + let mut response = if resume_from > 0 { + let resumed = client.get_mirrored(&file_url) + .header(reqwest::header::RANGE, format!("bytes={}-", resume_from)) + .send() + .await + .map_err(|e| format!("Download request failed: {}", e))?; + + match resumed.status() { + reqwest::StatusCode::PARTIAL_CONTENT => resumed, + reqwest::StatusCode::OK => { + cleanup_partial_download(&state.download_cache_dir, &workshop_id)?; + resume_from = 0; + client.get_mirrored(&file_url) + .send() + .await + .map_err(|e| format!("Download request failed: {}", e))? + } + _ => resumed, + } + } else { + client.get_mirrored(&file_url) + .send() + .await + .map_err(|e| format!("Download request failed: {}", e))? + }; + + if !response.status().is_success() { + if !prefer_sdk_download { + emit_sdk_download_warning( + &app_handle, + &workshop_id, + title, + "web-download-failed", + ); + match attempt_bridge_download( + &state, + &state.workshop_service, + &workshop_id, + &workshop_dir, + &app_handle, + source_policy.allow_bridge(), + ) + .await + { + Ok(true) => { + let _ = cleanup_partial_download(&state.download_cache_dir, &workshop_id); + crate::watcher::suppress_internal_refresh(&state); + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + return Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )); + } + Ok(false) => {} + Err(err) if err == DOWNLOAD_CANCELLED_ERR => return Err(err), + Err(_) => {} + } + } + + return Err(format!( + "Download server responded with status {}", + response.status() + )); + } + + let response_etag = normalized_etag(response.headers()); + let response_last_modified = normalized_last_modified(response.headers()); + let total_size = if response.status() == reqwest::StatusCode::PARTIAL_CONTENT { + let content_range = response + .headers() + .get(reqwest::header::CONTENT_RANGE) + .and_then(|value| value.to_str().ok()) + .ok_or_else(|| "Partial download response missing Content-Range".to_string())?; + let (range_start, range_total) = parse_content_range_start(content_range) + .ok_or_else(|| format!("Invalid Content-Range header: {}", content_range))?; + + if range_start != resume_from { + cleanup_partial_download(&state.download_cache_dir, &workshop_id)?; + return Err(format!( + "Resume offset mismatch: expected {}, got {}", + resume_from, range_start + )); + } + + if let Ok(existing_metadata) = load_partial_download_metadata(&partial_metadata_path) { + if let (Some(existing), Some(current)) = + (existing_metadata.etag.as_deref(), response_etag.as_deref()) + { + if existing != current { + cleanup_partial_download(&state.download_cache_dir, &workshop_id)?; + return Err( + "Remote file changed while resuming download (ETag mismatch)" + .to_string(), + ); + } + } + if let (Some(existing), Some(current)) = ( + existing_metadata.last_modified.as_deref(), + response_last_modified.as_deref(), + ) { + if existing != current { + cleanup_partial_download(&state.download_cache_dir, &workshop_id)?; + return Err( + "Remote file changed while resuming download (Last-Modified mismatch)" + .to_string(), + ); + } + } + if let Some(existing_total) = existing_metadata.total_size { + if existing_total != range_total { + cleanup_partial_download(&state.download_cache_dir, &workshop_id)?; + return Err( + "Remote file changed while resuming download (size mismatch)" + .to_string(), + ); + } + } + } + + range_total + } else { + response.content_length().unwrap_or(0) + }; + + let metadata = DownloadResumeMetadata { + workshop_id: workshop_id.clone(), + target_filename: dest_filename.clone(), + file_url: resolved_file_url.clone(), + total_size: (total_size > 0).then_some(total_size), + etag: response_etag, + last_modified: response_last_modified, + }; + save_partial_download_metadata(&partial_metadata_path, &metadata)?; + + let mut file = if resume_from > 0 { + OpenOptions::new() + .create(true) + .append(true) + .open(&partial_path) + .map_err(|e| format!("Failed to reopen partial download file: {}", e))? + } else { + fs::File::create(&partial_path) + .map_err(|e| format!("Failed to create local file: {}", e))? + }; + let mut downloaded = resume_from; + + #[derive(Serialize, Clone)] + struct DownloadProgress { + #[serde(rename = "workshopId")] + workshop_id: String, + percent: u32, + downloaded: u64, + total: u64, + source: String, + phase: String, + } + + while let Some(chunk) = response + .chunk() + .await + .map_err(|e| format!("Download chunk failed: {}", e))? + { + if is_download_cancelled(&state, &workshop_id)? { + drop(file); + let _ = cleanup_partial_download(&state.download_cache_dir, &workshop_id); + return Err(DOWNLOAD_CANCELLED_ERR.to_string()); + } + + use std::io::Write; + file.write_all(&chunk) + .map_err(|e| format!("Failed to write chunk: {}", e))?; + downloaded += chunk.len() as u64; + + let percent = if total_size > 0 { + ((downloaded as f64 / total_size as f64) * 100.0) as u32 + } else { + 0 + } + .min(99); + + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + workshop_id: workshop_id.clone(), + percent, + downloaded, + total: total_size, + source: "web-fallback".to_string(), + phase: "fallback-download".to_string(), + }, + ); + } + file.flush() + .map_err(|e| format!("Failed to flush downloaded file: {}", e))?; + drop(file); + + if is_download_cancelled(&state, &workshop_id)? { + let _ = cleanup_partial_download(&state.download_cache_dir, &workshop_id); + return Err(DOWNLOAD_CANCELLED_ERR.to_string()); + } + + crate::watcher::suppress_internal_refresh_for( + &state, + Duration::from_millis(DOWNLOAD_FINALIZE_SUPPRESS_MS), + ); + move_or_copy_file(&partial_path, &dest_path) + .map_err(|e| format!("Failed to finalize downloaded file: {}", e))?; + let _ = fs::remove_file(&partial_metadata_path); + + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + workshop_id: workshop_id.clone(), + percent: 100, + downloaded: total_size, + total: total_size, + source: "web-fallback".to_string(), + phase: "finalize".to_string(), + }, + ); + + let mut known_addons = load_known_addons(&state.known_addons_path); + + let has_image = details.get("preview_url").is_some(); + let image_path = details + .get("preview_url") + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + + let id = workshop_id.clone(); + known_addons.insert( + id.clone(), + KnownAddonEntry { + id: id.clone(), + vpk_name: dest_filename.clone(), + workshop_id: Some(workshop_id.clone()), + workshop_id_candidate: None, + workshop_id_source: None, + workshop_id_validation_status: None, + workshop_id_last_attempt_at: None, + addon_info: serde_json::Value::Null, + has_image, + image_path, + steam_details: Some(details.clone()), + }, + ); + + fs::write( + &state.known_addons_path, + serde_json::to_string_pretty(&known_addons).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) + } + .await; + + let _ = clear_download_cancellation(&state, &workshop_id); + result +} diff --git a/src-tauri/src/commands/handlers/cache.rs b/src-tauri/src/commands/handlers/cache.rs new file mode 100644 index 0000000..74db30f --- /dev/null +++ b/src-tauri/src/commands/handlers/cache.rs @@ -0,0 +1,71 @@ +use super::*; +use crate::mirrors::MirrorClientExt; +use std::time::Duration; +use tauri::State; + +#[tauri::command] +pub async fn get_cache_image( + image_path: String, + state: State<'_, crate::AppState>, +) -> Result, String> { + let file_path = resolve_cache_file(&state.cache_dir, &image_path)?; + fs::read(&file_path).map_err(|e| format!("Failed to read cache image: {}", e)) +} + +#[tauri::command] +pub async fn cache_remote_image( + url: String, + state: State<'_, crate::AppState>, +) -> Result { + let parsed = validate_open_url(&url)?; + if !matches!(parsed.scheme(), "http" | "https") { + return Err("Only HTTP(S) images can be cached".to_string()); + } + + use md5::{Digest, Md5}; + let mut hasher = Md5::new(); + hasher.update(parsed.as_str().as_bytes()); + let hash = format!("{:x}", hasher.finalize()); + if let Some(cached_path) = find_cached_remote_image(&state.cache_dir, &hash) { + return Ok(cached_path); + } + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(8)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let response = client + .get_mirrored(parsed.as_str()) + .send() + .await + .map_err(|e| format!("Failed to fetch remote image: {}", e))?; + if !response.status().is_success() { + return Err(format!( + "Remote image responded with status {}", + response.status() + )); + } + + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_string()); + let extension = cache_image_extension(content_type.as_deref(), &parsed); + let filename = format!("{}_remote_image.{}", hash, extension); + let file_path = state.cache_dir.join(&filename); + if file_path.exists() { + return Ok(format!("/cache/{}", filename)); + } + + let bytes = response + .bytes() + .await + .map_err(|e| format!("Failed to read remote image: {}", e))?; + if let Some(parent) = file_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create image cache directory: {}", e))?; + } + fs::write(&file_path, &bytes).map_err(|e| format!("Failed to cache remote image: {}", e))?; + Ok(format!("/cache/{}", filename)) +} diff --git a/src-tauri/src/commands/handlers/db.rs b/src-tauri/src/commands/handlers/db.rs new file mode 100644 index 0000000..cb84a64 --- /dev/null +++ b/src-tauri/src/commands/handlers/db.rs @@ -0,0 +1,1120 @@ +use super::*; +use tauri::{AppHandle, Manager, State}; + +const WORKSHOP_ID_VALIDATION_RETRY_SECS: i64 = 30 * 60; +const WORKSHOP_ID_STATUS_PENDING: &str = "pending"; +const WORKSHOP_ID_STATUS_VERIFIED: &str = "verified"; +const WORKSHOP_ID_STATUS_REJECTED: &str = "rejected"; +const WORKSHOP_ID_SOURCE_FILENAME: &str = "filename"; +const WORKSHOP_ID_SOURCE_ADDON_URL: &str = "addonUrl"; + +fn normalize_filename_workshop_id(value: &str) -> Option { + if value.is_empty() || !value.chars().all(|ch| ch.is_ascii_digit()) { + return None; + } + + value + .parse::() + .ok() + .filter(|id| *id > 0) + .map(|id| id.to_string()) +} + +fn extract_workshop_id_from_vpk_filename(vpk_name: &str) -> Option { + let vpk_name = vpk_name.strip_suffix(".disabled").unwrap_or(vpk_name); + if !vpk_name.ends_with(".vpk") { + return None; + } + + let stem = Path::new(vpk_name).file_stem()?.to_str()?; + if let Some(id) = normalize_filename_workshop_id(stem) { + return Some(id); + } + + let bracketed = stem.strip_prefix('[')?; + let (id, _) = bracketed.split_once(']')?; + normalize_filename_workshop_id(id) +} + +fn workshop_id_from_addon_url(addon: &Addon) -> Option { + addon + .addon_info + .get("addonurl0") + .or_else(|| addon.addon_info.get("addonurl")) + .and_then(|value| value.as_str()) + .and_then(extract_workshop_id_from_url) +} + +fn workshop_detail_id(details: &Value) -> Option { + details + .get("publishedfileid") + .and_then(|value| { + value + .as_str() + .map(str::to_string) + .or_else(|| value.as_u64().map(|id| id.to_string())) + }) + .and_then(|id| normalize_filename_workshop_id(&id)) +} + +fn is_successful_workshop_detail(details: &Value) -> bool { + match details.get("result") { + None => true, + Some(value) => value + .as_u64() + .map(|result| result == 1) + .or_else(|| value.as_str().map(|result| result == "1")) + .unwrap_or(false), + } +} + +fn is_valid_workshop_item_detail(candidate: &str, details: &Value) -> bool { + workshop_detail_id(details).as_deref() == Some(candidate) + && is_successful_workshop_detail(details) + && !is_collection_detail(details) +} + +fn workshop_id_candidate_is_due( + addon: &Addon, + force: bool, + now: chrono::DateTime, +) -> bool { + if addon.is_dummy + || addon.workshop_id_candidate.is_none() + || matches!( + addon.workshop_id_validation_status.as_deref(), + Some(WORKSHOP_ID_STATUS_VERIFIED | WORKSHOP_ID_STATUS_REJECTED) + ) + { + return false; + } + + if force { + return true; + } + + addon + .workshop_id_last_attempt_at + .as_deref() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .map(|attempted_at| { + now.signed_duration_since(attempted_at.with_timezone(&chrono::Utc)) + .num_seconds() + >= WORKSHOP_ID_VALIDATION_RETRY_SECS + }) + .unwrap_or(true) +} + +fn apply_workshop_id_validation( + addons: &mut HashMap, + attempted_ids: &HashSet, + details_by_id: Option<&HashMap>, + attempted_at: &str, + id_migrations: &mut HashMap, +) { + let mut updated = HashMap::new(); + + for (old_id, mut addon) in std::mem::take(addons) { + let Some(candidate) = addon.workshop_id_candidate.clone() else { + updated.insert(old_id, addon); + continue; + }; + if !attempted_ids.contains(&candidate) { + updated.insert(old_id, addon); + continue; + } + + addon.workshop_id_last_attempt_at = Some(attempted_at.to_string()); + match details_by_id { + Some(details_by_id) => { + let valid_details = details_by_id + .get(&candidate) + .filter(|details| is_valid_workshop_item_detail(&candidate, details)); + if let Some(details) = valid_details { + addon.id = candidate.clone(); + addon.workshop_id = Some(candidate.clone()); + addon.workshop_id_validation_status = + Some(WORKSHOP_ID_STATUS_VERIFIED.to_string()); + addon.steam_details = Some(details.clone()); + if old_id != addon.id { + id_migrations.insert(old_id, addon.id.clone()); + } + } else { + let fallback = (addon.workshop_id_source.as_deref() + == Some(WORKSHOP_ID_SOURCE_FILENAME)) + .then(|| workshop_id_from_addon_url(&addon)) + .flatten() + .filter(|url_id| url_id != &candidate); + if let Some(url_id) = fallback { + addon.workshop_id_candidate = Some(url_id); + addon.workshop_id_source = Some(WORKSHOP_ID_SOURCE_ADDON_URL.to_string()); + addon.workshop_id_validation_status = + Some(WORKSHOP_ID_STATUS_PENDING.to_string()); + addon.workshop_id_last_attempt_at = None; + } else { + addon.workshop_id_validation_status = + Some(WORKSHOP_ID_STATUS_REJECTED.to_string()); + } + addon.workshop_id = None; + addon.steam_details = None; + } + } + None => { + addon.workshop_id_validation_status = Some(WORKSHOP_ID_STATUS_PENDING.to_string()); + } + } + + let new_id = addon.id.clone(); + updated.insert(new_id, addon); + } + + *addons = updated; +} + +pub(super) async fn validate_workshop_id_candidates( + db: &mut Database, + workshop_service: &WorkshopService, + force: bool, +) { + let now = chrono::Utc::now(); + let source_policy = SourcePolicy::from_settings(&db.settings); + if !source_policy.allow_bridge() && !source_policy.allow_web_api() { + return; + } + + // A rejected filename candidate can immediately fall back to addonurl0. + for _ in 0..2 { + let mut candidate_ids = HashSet::new(); + for addon in db + .addons + .values() + .chain(db.known_uninstalled_addons.values()) + { + if workshop_id_candidate_is_due(addon, force, now) { + if let Some(candidate) = addon.workshop_id_candidate.as_ref() { + candidate_ids.insert(candidate.clone()); + } + } + } + if candidate_ids.is_empty() { + break; + } + + let mut candidate_ids = candidate_ids.into_iter().collect::>(); + candidate_ids.sort(); + let attempted_ids = candidate_ids.iter().cloned().collect::>(); + let attempted_at = now.to_rfc3339(); + let details_by_id = match fetch_steam_details_hybrid( + workshop_service, + &candidate_ids, + source_policy.allow_bridge(), + source_policy.allow_web_api(), + source_policy.source_order(), + ) + .await + { + Ok(details) => Some( + details + .into_iter() + .filter_map(|details| workshop_detail_id(&details).map(|id| (id, details))) + .collect::>(), + ), + Err(err) => { + eprintln!("Failed to validate Workshop IDs from VPK metadata: {}", err); + None + } + }; + + let mut id_migrations = HashMap::new(); + apply_workshop_id_validation( + &mut db.addons, + &attempted_ids, + details_by_id.as_ref(), + &attempted_at, + &mut id_migrations, + ); + apply_workshop_id_validation( + &mut db.known_uninstalled_addons, + &attempted_ids, + details_by_id.as_ref(), + &attempted_at, + &mut id_migrations, + ); + + if !id_migrations.is_empty() { + for group in &mut db.groups { + let mut seen = HashSet::new(); + group.addons = group + .addons + .iter() + .map(|id| id_migrations.get(id).cloned().unwrap_or_else(|| id.clone())) + .filter(|id| seen.insert(id.clone())) + .collect(); + } + } + } +} + +pub fn load_db( + settings_path: &Path, + groups_path: &Path, + known_addons_path: &Path, + runtime_dir: &Path, + app_handle: &AppHandle, +) -> Database { + let default_loading = runtime_dir + .join("addons-loading") + .to_string_lossy() + .to_string(); + let default_workshop = Path::new(&default_loading) + .join("workshop") + .to_string_lossy() + .to_string(); + + let default_db = Database { + settings: Settings { + workshop_dir: default_workshop, + loading_dir: default_loading, + download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY, + enable_dummy_bypass: false, + suppress_sdk_unavailable_warning: false, + disable_steamworks_sdk: false, + force_steamworks_sdk_download: false, + max_download_retries: 3, + dependency_missing_behavior: "ask".to_string(), + workshop_source_settings: WorkshopSourceSettings::default(), + }, + addons: HashMap::new(), + groups: Vec::new(), + known_uninstalled_addons: HashMap::new(), + master_collections: Vec::new(), + }; + + let legacy_db = load_legacy_db_value(runtime_dir); + let settings_existed = settings_path.exists(); + let groups_existed = groups_path.exists(); + let known_addons_existed = known_addons_path.exists(); + + let mut settings_store = if settings_existed { + load_settings_store(settings_path) + } else if let Some(legacy_db) = legacy_db.as_ref() { + SettingsStore { + settings: serde_json::from_value( + legacy_db.get("settings").cloned().unwrap_or_default(), + ) + .unwrap_or_default(), + master_collections: serde_json::from_value( + legacy_db + .get("masterCollections") + .cloned() + .unwrap_or_default(), + ) + .unwrap_or_default(), + } + } else { + SettingsStore { + settings: default_db.settings.clone(), + master_collections: Vec::new(), + } + }; + let groups = if groups_existed { + load_groups(groups_path) + } else if let Some(legacy_db) = legacy_db.as_ref() { + serde_json::from_value(legacy_db.get("groups").cloned().unwrap_or_default()) + .unwrap_or_default() + } else { + Vec::new() + }; + + let known_addons: HashMap = if known_addons_path.exists() { + load_known_addons(known_addons_path) + } else if let Some(legacy_db) = legacy_db.as_ref() { + let migrated = load_known_addons_from_legacy_db(legacy_db); + if !migrated.is_empty() { + let _ = fs::write( + known_addons_path, + serde_json::to_string_pretty(&migrated).unwrap_or_default(), + ); + } + migrated + } else { + HashMap::new() + }; + + let old_default_loading = app_handle + .path() + .app_data_dir() + .map(|p| p.join("addons-loading").to_string_lossy().to_string()) + .unwrap_or_default(); + + if settings_store.settings.loading_dir.is_empty() + || settings_store.settings.loading_dir == old_default_loading + { + settings_store.settings.loading_dir = default_db.settings.loading_dir.clone(); + } + if settings_store.settings.download_concurrency == 0 { + settings_store.settings.download_concurrency = DEFAULT_DOWNLOAD_CONCURRENCY; + } + + let loading_path = Path::new(&settings_store.settings.loading_dir); + settings_store.settings.workshop_dir = + loading_path.join("workshop").to_string_lossy().to_string(); + + let merged_addons = HashMap::new(); + let mut known_uninstalled_addons = HashMap::new(); + for (id, entry) in &known_addons { + if !merged_addons.contains_key(id) { + known_uninstalled_addons.insert( + id.clone(), + Addon { + id: id.clone(), + vpk_name: entry.vpk_name.clone(), + workshop_id: entry.workshop_id.clone(), + workshop_id_candidate: entry.workshop_id_candidate.clone(), + workshop_id_source: entry.workshop_id_source.clone(), + workshop_id_validation_status: entry.workshop_id_validation_status.clone(), + workshop_id_last_attempt_at: entry.workshop_id_last_attempt_at.clone(), + addon_info: entry.addon_info.clone(), + has_image: entry.has_image, + image_path: entry.image_path.clone(), + files_count: 0, + file_size: 0, + parsed_at: "".to_string(), + current_path: "".to_string(), + dir_type: "none".to_string(), + is_enabled: false, + steam_details: entry.steam_details.clone(), + workshop_details: None, + is_dummy: false, + }, + ); + } + } + + let mut db = Database { + settings: settings_store.settings, + addons: merged_addons, + groups, + known_uninstalled_addons, + master_collections: settings_store.master_collections, + }; + let normalized_master_collections = normalize_master_collection_group_refs(&mut db); + + if normalized_master_collections + || !settings_existed + || !groups_existed + || !known_addons_existed + { + save_db_internal(settings_path, groups_path, known_addons_path, &db); + } + + db +} + +pub fn save_db_internal( + settings_path: &Path, + groups_path: &Path, + known_addons_path: &Path, + db: &Database, +) { + if let Some(parent) = settings_path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Some(parent) = groups_path.parent() { + let _ = fs::create_dir_all(parent); + } + if let Some(parent) = known_addons_path.parent() { + let _ = fs::create_dir_all(parent); + } + + let settings_store = SettingsStore { + settings: db.settings.clone(), + master_collections: db.master_collections.clone(), + }; + if let Ok(json) = serde_json::to_string_pretty(&settings_store) { + let _ = fs::write(settings_path, json); + } + + if let Ok(json) = serde_json::to_string_pretty(&db.groups) { + let _ = fs::write(groups_path, json); + } + + let mut known_addons = load_known_addons(known_addons_path); + let active_vpk_ids = db + .addons + .values() + .chain(db.known_uninstalled_addons.values()) + .map(|addon| (addon.vpk_name.clone(), addon.id.clone())) + .collect::>(); + known_addons.retain(|id, entry| { + active_vpk_ids + .get(&entry.vpk_name) + .map(|active_id| active_id == id) + .unwrap_or(true) + }); + + for (id, addon) in &db.addons { + if addon.workshop_id.is_none() { + known_addons.remove(&addon.vpk_name); + } + known_addons.insert( + id.clone(), + KnownAddonEntry { + id: id.clone(), + vpk_name: addon.vpk_name.clone(), + workshop_id: addon.workshop_id.clone(), + workshop_id_candidate: addon.workshop_id_candidate.clone(), + workshop_id_source: addon.workshop_id_source.clone(), + workshop_id_validation_status: addon.workshop_id_validation_status.clone(), + workshop_id_last_attempt_at: addon.workshop_id_last_attempt_at.clone(), + addon_info: addon.addon_info.clone(), + has_image: addon.has_image, + image_path: addon.image_path.clone(), + steam_details: addon.steam_details.clone(), + }, + ); + } + + for (id, addon) in &db.known_uninstalled_addons { + if addon.workshop_id.is_none() { + known_addons.remove(&addon.vpk_name); + } + known_addons.insert( + id.clone(), + KnownAddonEntry { + id: id.clone(), + vpk_name: addon.vpk_name.clone(), + workshop_id: addon.workshop_id.clone(), + workshop_id_candidate: addon.workshop_id_candidate.clone(), + workshop_id_source: addon.workshop_id_source.clone(), + workshop_id_validation_status: addon.workshop_id_validation_status.clone(), + workshop_id_last_attempt_at: addon.workshop_id_last_attempt_at.clone(), + addon_info: addon.addon_info.clone(), + has_image: addon.has_image, + image_path: addon.image_path.clone(), + steam_details: addon.steam_details.clone(), + }, + ); + } + + if let Ok(json) = serde_json::to_string_pretty(&known_addons) { + let _ = fs::write(known_addons_path, json); + } +} + +pub async fn rescan_database_snapshot(app_handle: &AppHandle) -> Result<(Database, bool), String> { + let state = app_handle.state::(); + let mut db = state.db.lock().await; + let before = + database_with_workshop_cache(&db, &state.workshop_cache_path, &state.known_addons_path); + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + let after = + database_with_workshop_cache(&db, &state.workshop_cache_path, &state.known_addons_path); + let changed = after != before; + Ok((after, changed)) +} + +pub async fn scan_addons_internal( + db: &mut Database, + settings_path: &Path, + groups_path: &Path, + known_addons_path: &Path, + cache_dir: &Path, + workshop_service: &WorkshopService, +) -> Result<(), String> { + let workshop_dir = Path::new(&db.settings.workshop_dir); + let loading_dir = Path::new(&db.settings.loading_dir); + + if !workshop_dir.exists() { + let _ = fs::create_dir_all(workshop_dir); + } + if !loading_dir.exists() { + let _ = fs::create_dir_all(loading_dir); + } + if !cache_dir.exists() { + let _ = fs::create_dir_all(cache_dir); + } + + struct DiskFileInfo { + vpk_name: String, + full_path: PathBuf, + size: u64, + dir_type: String, + is_enabled: bool, + } + + let mut files_on_disk = Vec::new(); + + // Scan workshop dir + if let Ok(entries) = fs::read_dir(workshop_dir) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_file() { + let path = entry.path(); + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if filename.ends_with(".vpk") || filename.ends_with(".vpk.disabled") { + let is_enabled = filename.ends_with(".vpk"); + let vpk_name = if is_enabled { + filename.to_string() + } else { + filename.strip_suffix(".disabled").unwrap().to_string() + }; + let size = entry.metadata().map(|m| m.len()).unwrap_or(0); + files_on_disk.push(DiskFileInfo { + vpk_name, + full_path: path, + size, + dir_type: "workshop".to_string(), + is_enabled, + }); + } + } + } + } + } + + // Scan loading dir + if let Ok(entries) = fs::read_dir(loading_dir) { + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + if file_type.is_file() { + let path = entry.path(); + let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if filename.ends_with(".vpk") || filename.ends_with(".vpk.disabled") { + let is_enabled = filename.ends_with(".vpk"); + let vpk_name = if is_enabled { + filename.to_string() + } else { + filename.strip_suffix(".disabled").unwrap().to_string() + }; + let size = entry.metadata().map(|m| m.len()).unwrap_or(0); + files_on_disk.push(DiskFileInfo { + vpk_name, + full_path: path, + size, + dir_type: "loading".to_string(), + is_enabled, + }); + } + } + } + } + } + + let mut known_addons = load_known_addons(known_addons_path); + + let mut active_addons = HashMap::new(); + + for file_info in files_on_disk { + let vpk_name = file_info.vpk_name.clone(); + let cached = db + .addons + .values() + .find(|a| a.vpk_name == vpk_name) + .cloned() + .or_else(|| { + db.known_uninstalled_addons + .values() + .find(|a| a.vpk_name == vpk_name) + .cloned() + }); + + let has_capitalized_keys = cached.as_ref().is_some_and(|addon| { + addon + .addon_info + .as_object() + .is_some_and(|obj| obj.keys().any(|k| k.chars().any(|c| c.is_uppercase()))) + }); + + let needs_metadata = match &cached { + Some(addon) => { + addon.addon_info.is_null() + || addon.addon_info.as_object().map_or(true, |m| m.is_empty()) + || has_capitalized_keys + || (addon.image_path.is_none() && !addon.has_image) + || (addon.has_image + && addon.image_path.as_ref().map_or(true, |p| { + p.starts_with("/cache/") + && !cache_dir.join(p.trim_start_matches("/cache/")).exists() + })) + } + None => true, + }; + + if needs_metadata { + println!("Parsing metadata for: {}", vpk_name); + + let meta = extract_addon_metadata(&file_info.full_path, cache_dir); + let filename_candidate = extract_workshop_id_from_vpk_filename(&vpk_name); + let url_workshop_id = meta + .addon_info + .get("addonurl0") + .or_else(|| meta.addon_info.get("addonurl")) + .and_then(|value| value.as_str()) + .and_then(extract_workshop_id_from_url); + + if is_dummy_addon_info(&meta.addon_info) { + continue; + } + + let filename_rejected = filename_candidate.as_ref().is_some_and(|candidate| { + cached.as_ref().is_some_and(|addon| { + addon.workshop_id_candidate.as_deref() == Some(candidate) + && addon.workshop_id_source.as_deref() == Some(WORKSHOP_ID_SOURCE_FILENAME) + && addon.workshop_id_validation_status.as_deref() + == Some(WORKSHOP_ID_STATUS_REJECTED) + }) + }); + let (candidate, source) = if filename_rejected { + url_workshop_id + .clone() + .map(|id| (id, WORKSHOP_ID_SOURCE_ADDON_URL.to_string())) + } else { + filename_candidate + .clone() + .map(|id| (id, WORKSHOP_ID_SOURCE_FILENAME.to_string())) + .or_else(|| { + url_workshop_id + .clone() + .map(|id| (id, WORKSHOP_ID_SOURCE_ADDON_URL.to_string())) + }) + } + .unzip(); + let candidate_matches = cached.as_ref().is_some_and(|addon| { + addon.workshop_id_candidate == candidate + && addon.workshop_id_source.as_deref() == source.as_deref() + }); + let candidate_verified = candidate_matches + && cached.as_ref().is_some_and(|addon| { + addon.workshop_id_validation_status.as_deref() + == Some(WORKSHOP_ID_STATUS_VERIFIED) + && addon.workshop_id == candidate + }); + let workshop_id = if candidate_verified { + candidate.clone() + } else { + None + }; + + let id = workshop_id.clone().unwrap_or_else(|| meta.hash.clone()); + + if workshop_id.is_none() && !id.is_empty() { + known_addons.remove(&vpk_name); + } + + let entry = known_addons.get(&id); + let addon_info = entry + .map(|e| e.addon_info.clone()) + .unwrap_or(meta.addon_info); + let has_image = entry.map(|e| e.has_image).unwrap_or(meta.has_image); + let image_path = entry.and_then(|e| e.image_path.clone()).or(meta.image_path); + let steam_details = entry.and_then(|e| e.steam_details.clone()); + + let addon = Addon { + id: id.clone(), + vpk_name: vpk_name.clone(), + workshop_id: workshop_id.clone(), + workshop_id_candidate: candidate.clone(), + workshop_id_source: source.clone(), + workshop_id_validation_status: if candidate.is_none() { + None + } else if candidate_matches { + cached + .as_ref() + .and_then(|addon| addon.workshop_id_validation_status.clone()) + } else { + Some(WORKSHOP_ID_STATUS_PENDING.to_string()) + }, + workshop_id_last_attempt_at: if candidate_matches { + cached + .as_ref() + .and_then(|addon| addon.workshop_id_last_attempt_at.clone()) + } else { + None + }, + addon_info, + has_image, + image_path, + files_count: meta.files_count, + file_size: file_info.size, + parsed_at: chrono::Utc::now().to_rfc3339(), + current_path: file_info.full_path.to_string_lossy().to_string(), + dir_type: file_info.dir_type.clone(), + is_enabled: file_info.is_enabled, + steam_details, + workshop_details: None, + is_dummy: false, + }; + + known_addons.insert( + id.clone(), + KnownAddonEntry { + id: id.clone(), + vpk_name: vpk_name.clone(), + workshop_id: workshop_id.clone(), + workshop_id_candidate: addon.workshop_id_candidate.clone(), + workshop_id_source: addon.workshop_id_source.clone(), + workshop_id_validation_status: addon.workshop_id_validation_status.clone(), + workshop_id_last_attempt_at: addon.workshop_id_last_attempt_at.clone(), + addon_info: addon.addon_info.clone(), + has_image: addon.has_image, + image_path: addon.image_path.clone(), + steam_details: addon.steam_details.clone(), + }, + ); + + active_addons.insert(id.clone(), addon); + } else if let Some(mut addon) = cached { + addon.file_size = file_info.size; + addon.current_path = file_info.full_path.to_string_lossy().to_string(); + addon.dir_type = file_info.dir_type; + addon.is_enabled = file_info.is_enabled; + addon.is_dummy = is_dummy_addon_info(&addon.addon_info); + if addon.is_dummy { + continue; + } + + let filename_candidate = extract_workshop_id_from_vpk_filename(&vpk_name); + let url_candidate = workshop_id_from_addon_url(&addon); + let filename_rejected = filename_candidate.as_ref().is_some_and(|candidate| { + addon.workshop_id_candidate.as_deref() == Some(candidate) + && addon.workshop_id_source.as_deref() == Some(WORKSHOP_ID_SOURCE_FILENAME) + && addon.workshop_id_validation_status.as_deref() + == Some(WORKSHOP_ID_STATUS_REJECTED) + }); + let (candidate, source) = if filename_rejected { + url_candidate.map(|id| (id, WORKSHOP_ID_SOURCE_ADDON_URL.to_string())) + } else { + filename_candidate + .map(|id| (id, WORKSHOP_ID_SOURCE_FILENAME.to_string())) + .or_else(|| { + url_candidate.map(|id| (id, WORKSHOP_ID_SOURCE_ADDON_URL.to_string())) + }) + } + .unzip(); + if candidate.is_some() { + let matches_cached = + addon.workshop_id_candidate == candidate && addon.workshop_id_source == source; + let verified = matches_cached + && addon.workshop_id_validation_status.as_deref() + == Some(WORKSHOP_ID_STATUS_VERIFIED); + addon.workshop_id_candidate = candidate.clone(); + addon.workshop_id_source = source; + if verified { + addon.workshop_id = candidate; + } else { + addon.workshop_id_validation_status = + Some(WORKSHOP_ID_STATUS_PENDING.to_string()); + addon.workshop_id_last_attempt_at = None; + addon.workshop_id = None; + } + } + + if addon.workshop_id.is_none() + && (addon.id.ends_with(".vpk") + || addon.id.ends_with(".vpk.disabled") + || addon.id.len() != 32) + { + let meta = extract_addon_metadata(&file_info.full_path, cache_dir); + let hash = meta.hash; + if !hash.is_empty() { + let old_id = addon.id.clone(); + addon.id = hash.clone(); + known_addons.remove(&old_id); + if !addon.is_dummy && !is_dummy_addon_info(&addon.addon_info) { + known_addons.insert( + hash.clone(), + KnownAddonEntry { + id: hash.clone(), + vpk_name: addon.vpk_name.clone(), + workshop_id: None, + workshop_id_candidate: addon.workshop_id_candidate.clone(), + workshop_id_source: addon.workshop_id_source.clone(), + workshop_id_validation_status: addon + .workshop_id_validation_status + .clone(), + workshop_id_last_attempt_at: addon + .workshop_id_last_attempt_at + .clone(), + addon_info: addon.addon_info.clone(), + has_image: addon.has_image, + image_path: addon.image_path.clone(), + steam_details: addon.steam_details.clone(), + }, + ); + } + } + } + + let id = addon.id.clone(); + active_addons.insert(id, addon); + } + } + + let mut uninstalled = HashMap::new(); + for (id, entry) in &known_addons { + if !active_addons.contains_key(id) { + uninstalled.insert( + id.clone(), + Addon { + id: id.clone(), + vpk_name: entry.vpk_name.clone(), + workshop_id: entry.workshop_id.clone(), + workshop_id_candidate: entry.workshop_id_candidate.clone(), + workshop_id_source: entry.workshop_id_source.clone(), + workshop_id_validation_status: entry.workshop_id_validation_status.clone(), + workshop_id_last_attempt_at: entry.workshop_id_last_attempt_at.clone(), + addon_info: entry.addon_info.clone(), + has_image: entry.has_image, + image_path: entry.image_path.clone(), + files_count: 0, + file_size: 0, + parsed_at: "".to_string(), + current_path: "".to_string(), + dir_type: "none".to_string(), + is_enabled: false, + steam_details: entry.steam_details.clone(), + workshop_details: None, + is_dummy: false, + }, + ); + } + } + for (id, addon) in &db.known_uninstalled_addons { + if !addon.is_dummy + && !is_dummy_addon_info(&addon.addon_info) + && !active_addons.contains_key(id) + && !uninstalled.contains_key(id) + { + uninstalled.insert(id.clone(), addon.clone()); + } + } + + db.addons = active_addons; + db.known_uninstalled_addons = uninstalled; + validate_workshop_id_candidates(db, workshop_service, false).await; + + let mut vpk_to_id = HashMap::new(); + for addon in db.addons.values() { + vpk_to_id.insert(addon.vpk_name.clone(), addon.id.clone()); + } + for addon in db.known_uninstalled_addons.values() { + vpk_to_id.insert(addon.vpk_name.clone(), addon.id.clone()); + } + + for g in &mut db.groups { + g.addons = g + .addons + .iter() + .map(|item| { + let clean_item = item.strip_suffix(".disabled").unwrap_or(item); + if let Some(new_id) = vpk_to_id.get(clean_item) { + new_id.clone() + } else { + item.clone() + } + }) + .collect(); + } + normalize_master_collection_group_refs(db); + + save_db_internal(settings_path, groups_path, known_addons_path, db); + Ok(()) +} + +#[tauri::command] +pub async fn get_addons(state: State<'_, crate::AppState>) -> Result { + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[cfg(test)] +mod tests { + use super::{ + apply_workshop_id_validation, extract_workshop_id_from_vpk_filename, + is_valid_workshop_item_detail, WORKSHOP_ID_SOURCE_ADDON_URL, WORKSHOP_ID_SOURCE_FILENAME, + WORKSHOP_ID_STATUS_PENDING, WORKSHOP_ID_STATUS_REJECTED, WORKSHOP_ID_STATUS_VERIFIED, + }; + use crate::commands::types::Addon; + use serde_json::json; + use std::collections::{HashMap, HashSet}; + + fn addon(id: &str, candidate: &str) -> Addon { + Addon { + id: id.to_string(), + vpk_name: format!("[{}]Manual Addon.vpk", candidate), + workshop_id: None, + workshop_id_candidate: Some(candidate.to_string()), + workshop_id_source: Some(WORKSHOP_ID_SOURCE_FILENAME.to_string()), + workshop_id_validation_status: Some(WORKSHOP_ID_STATUS_PENDING.to_string()), + workshop_id_last_attempt_at: None, + addon_info: json!({}), + has_image: false, + image_path: None, + files_count: 0, + file_size: 0, + parsed_at: String::new(), + current_path: String::new(), + dir_type: "loading".to_string(), + is_enabled: true, + steam_details: None, + workshop_details: None, + is_dummy: false, + } + } + + #[test] + fn extracts_only_explicit_workshop_id_filename_formats() { + assert_eq!( + extract_workshop_id_from_vpk_filename("3560883926.vpk"), + Some("3560883926".to_string()) + ); + assert_eq!( + extract_workshop_id_from_vpk_filename("[3560883926] Manual Addon.vpk"), + Some("3560883926".to_string()) + ); + assert_eq!( + extract_workshop_id_from_vpk_filename("[3560883926] Manual Addon.vpk.disabled"), + Some("3560883926".to_string()) + ); + assert_eq!( + extract_workshop_id_from_vpk_filename("3560883926 addon.vpk"), + None + ); + assert_eq!( + extract_workshop_id_from_vpk_filename("addon-3560883926.vpk"), + None + ); + assert_eq!( + extract_workshop_id_from_vpk_filename("[not-an-id] addon.vpk"), + None + ); + } + + #[test] + fn accepts_only_existing_non_collection_details() { + assert!(is_valid_workshop_item_detail( + "3560883926", + &json!({ "publishedfileid": "3560883926", "result": 1, "file_type": "item" }) + )); + assert!(!is_valid_workshop_item_detail( + "3560883926", + &json!({ "publishedfileid": "3560883926", "result": 1, "file_type": "collection" }) + )); + assert!(!is_valid_workshop_item_detail( + "3560883926", + &json!({ "publishedfileid": "3560883926", "result": 1, "file_type": 2 }) + )); + assert!(!is_valid_workshop_item_detail( + "3560883926", + &json!({ "publishedfileid": "3560883926", "result": 9 }) + )); + assert!(!is_valid_workshop_item_detail( + "3560883926", + &json!({ "publishedfileid": "123" }) + )); + } + + #[test] + fn confirmed_filename_id_migrates_the_local_addon_key() { + let mut addons = + HashMap::from([("local-hash".to_string(), addon("local-hash", "3560883926"))]); + let attempted_ids = HashSet::from(["3560883926".to_string()]); + let details = HashMap::from([( + "3560883926".to_string(), + json!({ "publishedfileid": "3560883926", "file_type": "item" }), + )]); + let mut migrations = HashMap::new(); + + apply_workshop_id_validation( + &mut addons, + &attempted_ids, + Some(&details), + "2026-07-12T00:00:00Z", + &mut migrations, + ); + + let resolved = addons.get("3560883926").unwrap(); + assert_eq!(resolved.workshop_id.as_deref(), Some("3560883926")); + assert_eq!( + resolved.workshop_id_validation_status.as_deref(), + Some(WORKSHOP_ID_STATUS_VERIFIED) + ); + assert_eq!( + migrations.get("local-hash").map(String::as_str), + Some("3560883926") + ); + } + + #[test] + fn rejects_collection_id_from_addon_url_after_filename_fallback() { + let mut local = addon("local-hash", "3560883926"); + local.addon_info = json!({ + "addonurl0": "https://steamcommunity.com/sharedfiles/filedetails/?id=1234567890" + }); + let mut addons = HashMap::from([("local-hash".to_string(), local)]); + let attempted_ids = HashSet::from(["3560883926".to_string()]); + + apply_workshop_id_validation( + &mut addons, + &attempted_ids, + Some(&HashMap::new()), + "2026-07-12T00:00:00Z", + &mut HashMap::new(), + ); + + let rejected = addons.get("local-hash").unwrap(); + assert_eq!(rejected.workshop_id, None); + assert_eq!( + rejected.workshop_id_candidate.as_deref(), + Some("1234567890") + ); + assert_eq!( + rejected.workshop_id_source.as_deref(), + Some(WORKSHOP_ID_SOURCE_ADDON_URL) + ); + assert_eq!( + rejected.workshop_id_validation_status.as_deref(), + Some(WORKSHOP_ID_STATUS_PENDING) + ); + + apply_workshop_id_validation( + &mut addons, + &HashSet::from(["1234567890".to_string()]), + Some(&HashMap::from([( + "1234567890".to_string(), + json!({ "publishedfileid": "1234567890", "file_type": "collection" }), + )])), + "2026-07-12T00:01:00Z", + &mut HashMap::new(), + ); + + let rejected = addons.get("local-hash").unwrap(); + assert_eq!(rejected.workshop_id, None); + assert_eq!( + rejected.workshop_id_validation_status.as_deref(), + Some(WORKSHOP_ID_STATUS_REJECTED) + ); + } +} diff --git a/src-tauri/src/commands/handlers/mod.rs b/src-tauri/src/commands/handlers/mod.rs new file mode 100644 index 0000000..feb237e --- /dev/null +++ b/src-tauri/src/commands/handlers/mod.rs @@ -0,0 +1,4019 @@ +use crate::steam::{ + fetch_collection_children_web, fetch_steam_details_web, BridgeDownloadStatus, + WorkshopBrowseQuery, WorkshopCapabilities, WorkshopCollectionResponse, WorkshopHomeResponse, + WorkshopItemResponse, WorkshopItemsResponse, WorkshopService, +}; +use crate::vpk::{extract_addon_metadata, generate_dummy_vpk}; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter}; + +use super::types::{ + is_dummy_addon_info, Addon, Database, Group, KnownAddonEntry, MasterCollection, RenameItem, + Settings, SettingsStore, WorkshopSeenItem, WorkshopSourceSettings, +}; + +pub mod addons; +pub mod cache; +pub mod db; +pub mod settings; +pub mod tasks; +pub mod workshop; +pub use addons::*; +pub use cache::*; +pub use db::*; +pub use settings::*; +pub use tasks::*; +pub use workshop::*; + +const DOWNLOAD_CANCELLED_ERR: &str = "Download cancelled"; +const WORKSHOP_HTML_FETCH_INTERVAL: Duration = Duration::from_secs(6); +const WORKSHOP_HTML_FETCH_PAUSE_DURATION: Duration = Duration::from_secs(10 * 60); +const DEFAULT_DOWNLOAD_CONCURRENCY: u32 = 2; +const DOWNLOAD_FINALIZE_SUPPRESS_MS: u64 = 30_000; +type IdenticalTitleGroupKey = (String, String, String); +type IdenticalTitleGroup = (String, String, Vec); + +#[derive(Default)] +struct WorkshopHtmlFetchGate { + next_allowed_at: Option, + pause_until: Option, + pause_reason: Option, +} + +static WORKSHOP_HTML_FETCH_GATE: OnceLock> = OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DownloadResumeMetadata { + workshop_id: String, + target_filename: String, + file_url: String, + total_size: Option, + etag: Option, + last_modified: Option, +} + +#[derive(Debug, Clone)] +struct SourcePolicy { + allow_steamworks_sdk: bool, + allow_steam_web_api: bool, + allow_steam_community_html: bool, + sdk_html_scope: String, + source_order: Vec, +} + +impl SourcePolicy { + fn from_settings(settings: &Settings) -> Self { + let configured = &settings.workshop_source_settings; + let preset = configured.preset.trim().to_string(); + let disable_sdk = settings.disable_steamworks_sdk; + let sdk_html_scope = resolve_sdk_html_scope( + &configured.sdk_html_scope, + &preset, + configured.allow_sdk_html_hybrid, + ); + + match preset.as_str() { + "offline" => Self { + allow_steamworks_sdk: false, + allow_steam_web_api: false, + allow_steam_community_html: false, + sdk_html_scope: "disabled".to_string(), + source_order: configured.source_order.clone(), + }, + "sdk-only" | "sdkOnly" => Self { + allow_steamworks_sdk: !disable_sdk && configured.allow_steamworks_sdk, + allow_steam_web_api: false, + allow_steam_community_html: false, + sdk_html_scope: "disabled".to_string(), + source_order: configured.source_order.clone(), + }, + "hybrid" => Self { + allow_steamworks_sdk: !disable_sdk && configured.allow_steamworks_sdk, + allow_steam_web_api: configured.allow_steam_web_api, + allow_steam_community_html: configured.allow_steam_community_html, + sdk_html_scope: "all".to_string(), + source_order: configured.source_order.clone(), + }, + _ => Self { + allow_steamworks_sdk: !disable_sdk && configured.allow_steamworks_sdk, + allow_steam_web_api: configured.allow_steam_web_api, + allow_steam_community_html: configured.allow_steam_community_html, + sdk_html_scope, + source_order: configured.source_order.clone(), + }, + } + } + + fn allow_bridge(&self) -> bool { + self.allow_steamworks_sdk + } + + fn allow_web_api(&self) -> bool { + self.allow_steam_web_api + } + + fn source_order(&self) -> &[String] { + &self.source_order + } + + fn allow_html(&self, source: &str, sdk_query_available: bool) -> bool { + if !self.allow_steam_community_html { + return false; + } + if !sdk_query_available { + return true; + } + if is_manual_detail_workshop_fetch_source(source) { + return true; + } + if is_search_workshop_fetch_source(source) { + return matches!( + self.sdk_html_scope.as_str(), + "search" | "navigation" | "all" + ); + } + if is_navigation_workshop_fetch_source(source) { + return matches!(self.sdk_html_scope.as_str(), "navigation" | "all"); + } + if is_background_workshop_fetch_source(source) { + return self.sdk_html_scope == "all"; + } + false + } +} + +fn resolve_sdk_html_scope( + configured_scope: &str, + preset: &str, + allow_sdk_html_hybrid: bool, +) -> String { + match configured_scope.trim() { + "disabled" | "search" | "navigation" | "all" => configured_scope.trim().to_string(), + _ if preset == "hybrid" || allow_sdk_html_hybrid => "all".to_string(), + _ => "search".to_string(), + } +} + +fn is_manual_detail_workshop_fetch_source(source: &str) -> bool { + matches!( + source, + "addon-detail" | "workshop-detail" | "dependency-check" + ) +} + +fn is_search_workshop_fetch_source(source: &str) -> bool { + matches!(source, "workshop-search" | "workshop-creator") +} + +fn is_navigation_workshop_fetch_source(source: &str) -> bool { + matches!(source, "workshop-home" | "workshop-browse") +} + +fn source_position(source_order: &[String], source: &str, fallback: usize) -> usize { + source_order + .iter() + .position(|item| item == source) + .unwrap_or(fallback) +} + +fn normalize_master_collection_group_refs(db: &mut Database) -> bool { + let valid_group_ids: HashSet = db.groups.iter().map(|group| group.id.clone()).collect(); + let valid_master_collection_ids: HashSet = db + .master_collections + .iter() + .map(|collection| collection.id.clone()) + .collect(); + let mut changed = false; + + for collection in &mut db.master_collections { + let before = collection.group_ids.len(); + let mut seen = HashSet::new(); + collection + .group_ids + .retain(|group_id| valid_group_ids.contains(group_id) && seen.insert(group_id.clone())); + changed |= before != collection.group_ids.len(); + } + + for group in &mut db.groups { + if let Some(master_collection_ids) = &mut group.master_collection_ids { + let before = master_collection_ids.len(); + let mut seen = HashSet::new(); + master_collection_ids.retain(|collection_id| { + valid_master_collection_ids.contains(collection_id) + && seen.insert(collection_id.clone()) + }); + changed |= before != master_collection_ids.len(); + if master_collection_ids.is_empty() { + group.master_collection_ids = None; + changed = true; + } + } + } + + changed +} + +fn find_sdk_installed_workshop_file(install_folder: &Path) -> Result, String> { + if !install_folder.exists() { + return Ok(None); + } + + if install_folder.is_file() { + let filename = install_folder + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if filename.ends_with(".vpk") || filename.ends_with("_legacy.bin") { + return Ok(Some(install_folder.to_path_buf())); + } + return Ok(None); + } + + if !install_folder.is_dir() { + return Ok(None); + } + + let mut vpk_candidates = Vec::new(); + let mut legacy_candidates = Vec::new(); + let mut file_candidates = Vec::new(); + + for entry in fs::read_dir(install_folder).map_err(|e| { + format!( + "Failed to read SDK install folder {}: {}", + install_folder.display(), + e + ) + })? { + let entry = + entry.map_err(|e| format!("Failed to inspect SDK install folder entry: {}", e))?; + let file_type = entry + .file_type() + .map_err(|e| format!("Failed to read SDK install folder entry type: {}", e))?; + if !file_type.is_file() { + continue; + } + + let path = entry.path(); + let filename = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(""); + if filename.ends_with(".vpk") { + vpk_candidates.push(path); + } else if filename.ends_with("_legacy.bin") { + legacy_candidates.push(path); + } else { + file_candidates.push(path); + } + } + + vpk_candidates.sort(); + legacy_candidates.sort(); + file_candidates.sort(); + + Ok(vpk_candidates + .into_iter() + .next() + .or_else(|| legacy_candidates.into_iter().next()) + .or_else(|| { + if file_candidates.len() == 1 { + file_candidates.into_iter().next() + } else { + None + } + })) +} + +fn import_sdk_workshop_file( + source_path: &Path, + workshop_id: &str, + workshop_dir: &Path, +) -> Result { + if !workshop_dir.exists() { + fs::create_dir_all(workshop_dir).map_err(|e| { + format!( + "Failed to create workshop directory {}: {}", + workshop_dir.display(), + e + ) + })?; + } + + let dest_path = workshop_dir.join(format!("{}.vpk", workshop_id)); + let temp_dest_path = workshop_dir.join(format!("{}.vpk.download", workshop_id)); + + remove_dummy_workshop_targets(&dest_path)?; + + if temp_dest_path.exists() { + fs::remove_file(&temp_dest_path).map_err(|e| { + format!( + "Failed to remove stale SDK import temp file {}: {}", + temp_dest_path.display(), + e + ) + })?; + } + + fs::copy(source_path, &temp_dest_path).map_err(|e| { + format!( + "Failed to copy SDK workshop file from {} to {}: {}", + source_path.display(), + temp_dest_path.display(), + e + ) + })?; + move_or_copy_file(&temp_dest_path, &dest_path).map_err(|e| { + format!( + "Failed to finalize SDK workshop file from {} to {}: {}", + temp_dest_path.display(), + dest_path.display(), + e + ) + })?; + + Ok(dest_path) +} + +fn resolve_cache_file(cache_dir: &Path, image_path: &str) -> Result { + let filename = image_path.strip_prefix("/cache/").unwrap_or(image_path); + let rel_path = Path::new(filename); + + if filename.is_empty() + || filename.contains("..") + || filename.contains('/') + || filename.contains('\\') + || rel_path.components().count() != 1 + { + return Err("Invalid cache image path".to_string()); + } + + let cache_root = fs::canonicalize(cache_dir) + .map_err(|e| format!("Failed to resolve cache directory: {}", e))?; + let file_path = fs::canonicalize(cache_root.join(filename)) + .map_err(|e| format!("Failed to resolve cache image: {}", e))?; + + if !file_path.starts_with(&cache_root) { + return Err("Cache image path escapes cache directory".to_string()); + } + + Ok(file_path) +} + +fn validate_open_url(url: &str) -> Result { + let parsed = reqwest::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?; + match parsed.scheme() { + "http" | "https" | "steam" => Ok(parsed), + scheme => Err(format!("Unsupported URL scheme: {}", scheme)), + } +} + +fn validate_steamcommunity_url(url: &str) -> Result { + let parsed = reqwest::Url::parse(url).map_err(|e| format!("Invalid URL: {}", e))?; + if parsed.scheme() != "https" { + return Err("Only HTTPS Steam Community URLs are allowed".to_string()); + } + + let host = parsed.host_str().unwrap_or_default(); + if host == "steamcommunity.com" || host.ends_with(".steamcommunity.com") { + Ok(parsed) + } else { + Err("Only steamcommunity.com URLs are allowed".to_string()) + } +} + +fn cache_image_extension(content_type: Option<&str>, url: &reqwest::Url) -> &'static str { + if let Some(content_type) = content_type { + if content_type.contains("png") { + return "png"; + } + if content_type.contains("webp") { + return "webp"; + } + } + if url.path().to_lowercase().ends_with(".png") { + return "png"; + } + if url.path().to_lowercase().ends_with(".webp") { + return "webp"; + } + "jpg" +} + +fn find_cached_remote_image(cache_dir: &Path, hash: &str) -> Option { + for extension in ["jpg", "png", "webp"] { + let filename = format!("{}_remote_image.{}", hash, extension); + if cache_dir.join(&filename).exists() { + return Some(format!("/cache/{}", filename)); + } + } + None +} + +fn workshop_html_fetch_gate() -> &'static Mutex { + WORKSHOP_HTML_FETCH_GATE.get_or_init(|| Mutex::new(WorkshopHtmlFetchGate::default())) +} + +fn reserve_workshop_html_fetch_slot() -> Result, String> { + let now = Instant::now(); + let mut gate = workshop_html_fetch_gate() + .lock() + .map_err(|_| "Workshop HTML fetch gate mutex poisoned".to_string())?; + + if let Some(pause_until) = gate.pause_until { + if pause_until > now { + return Err(gate.pause_reason.clone().unwrap_or_else(|| { + "Steam Workshop HTML fetching is temporarily paused".to_string() + })); + } + gate.pause_until = None; + gate.pause_reason = None; + } + + let scheduled_at = gate.next_allowed_at.unwrap_or(now).max(now); + gate.next_allowed_at = Some(scheduled_at + WORKSHOP_HTML_FETCH_INTERVAL); + let wait = scheduled_at.saturating_duration_since(now); + if wait.is_zero() { + Ok(None) + } else { + Ok(Some(wait)) + } +} + +fn pause_workshop_html_fetches(reason: &str) { + if let Ok(mut gate) = workshop_html_fetch_gate().lock() { + let until = Instant::now() + WORKSHOP_HTML_FETCH_PAUSE_DURATION; + gate.pause_until = Some(until); + gate.pause_reason = Some(reason.trim().to_string()); + gate.next_allowed_at = Some(until); + } +} + +fn strip_html_tags(input: &str) -> String { + let tag_re = Regex::new(r"(?is)<[^>]+>").expect("valid tag regex"); + let without_tags = tag_re.replace_all(input, " "); + without_tags + .replace(" ", " ") + .replace("&", "&") + .split_whitespace() + .collect::>() + .join(" ") +} + +fn extract_steamcommunity_error_message(html: &str) -> Option { + let re = Regex::new( + r#"(?is)]*class=["'][^"']*\berror_ctn\b[^"']*["'][^>]*>.*?]*id=["'][^"']*bottom[^"']*["'][^>]*>.*?]*>\s*]*>(.*?)"#, + ) + .expect("valid workshop error regex"); + let captures = re.captures(html)?; + let message = strip_html_tags(captures.get(1)?.as_str()); + if message.trim().is_empty() { + None + } else { + Some(message) + } +} + +fn is_background_workshop_fetch_source(source: &str) -> bool { + matches!(source, "startup-auto" | "background-refresh") +} + +fn is_known_group_collection_id(groups_path: &Path, workshop_id: &str) -> bool { + let workshop_id = workshop_id.trim(); + if workshop_id.is_empty() { + return false; + } + + load_groups(groups_path) + .into_iter() + .filter_map(|group| group.workshop_collection_id) + .any(|collection_id| collection_id.trim() == workshop_id) +} + +fn is_known_workshop_id(known_addons_path: &Path, groups_path: &Path, workshop_id: &str) -> bool { + let workshop_id = workshop_id.trim(); + if workshop_id.is_empty() { + return false; + } + + let known_addons = load_known_addons(known_addons_path); + if known_addons.contains_key(workshop_id) { + return true; + } + + if is_known_group_collection_id(groups_path, workshop_id) { + return true; + } + + known_addons + .values() + .any(|entry| entry.id == workshop_id || entry.workshop_id.as_deref() == Some(workshop_id)) +} + +fn ensure_background_workshop_fetch_allowed( + source: &str, + workshop_id: &str, + known_addons_path: &Path, + groups_path: &Path, +) -> Result<(), String> { + if !is_background_workshop_fetch_source(source) { + return Ok(()); + } + + if is_known_workshop_id(known_addons_path, groups_path, workshop_id) { + return Ok(()); + } + + Err(format!( + "Background workshop fetch blocked: workshop item {} is not present in known_addons", + workshop_id + )) +} + +fn clear_persisted_workshop_media_and_content( + obj: &mut serde_json::Map, +) { + for key in [ + "previewUrl", + "imagePath", + "shortDescription", + "galleryPreviewUrls", + "description", + "descriptionHtml", + "imageGallery", + "galleryUrls", + "backgroundImageUrl", + ] { + obj.remove(key); + } +} + +fn persist_seen_workshop_item_entry( + obj: &mut serde_json::Map, + item: &WorkshopSeenItem, + source: &str, + now: &str, + allow_rich_content: bool, +) { + insert_non_empty_string(obj, "workshopId", &item.workshop_id); + insert_non_empty_string(obj, "title", &item.title); + let author_ids = [ + item.author_id.clone(), + item.author_steam_id.clone().unwrap_or_default(), + item.author_account_id.clone().unwrap_or_default(), + item.author_vanity_id.clone().unwrap_or_default(), + ]; + insert_author_name_if_useful(obj, &item.author_name, &author_ids); + insert_non_empty_string(obj, "creatorId", &item.author_id); + insert_non_empty_string(obj, "authorId", &item.author_id); + insert_non_empty_string(obj, "creatorProfileUrl", &item.author_url); + insert_non_empty_string(obj, "authorUrl", &item.author_url); + if let Some(vanity_id) = item.author_vanity_id.as_deref() { + insert_non_empty_string(obj, "creatorVanityId", vanity_id); + } + if let Some(account_id) = item.author_account_id.as_deref() { + insert_non_empty_string(obj, "creatorAccountId", account_id); + } + if let Some(steam_id) = item.author_steam_id.as_deref() { + insert_non_empty_string(obj, "creatorSteamId", steam_id); + } + if item.author_id.chars().all(|c| c.is_ascii_digit()) && !item.author_id.is_empty() { + insert_non_empty_string(obj, "creatorSteamId", &item.author_id); + } + if item.author_url.contains("/profiles/") { + if let Some(id) = item + .author_url + .split("/profiles/") + .nth(1) + .and_then(|s| s.split('/').next()) + { + if id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty() { + insert_non_empty_string(obj, "creatorSteamId", id); + } + } + } + insert_optional_value( + obj, + "fileSizeDisplay", + item.file_size.clone().map(serde_json::Value::String), + ); + insert_optional_value(obj, "tags", item.tags.clone().map(|v| serde_json::json!(v))); + insert_optional_u64(obj, "subscriptions", item.subscriptions); + insert_optional_u64(obj, "favorites", item.favorites); + insert_optional_u64(obj, "lifetimeSubscriptions", item.lifetime_subscriptions); + insert_optional_u64(obj, "lifetimeFavorites", item.lifetime_favorites); + insert_optional_u64(obj, "views", item.views); + insert_optional_u64(obj, "comments", item.comments); + insert_optional_u64(obj, "totalVotes", item.total_votes); + insert_optional_u64(obj, "timeCreated", item.time_created); + insert_optional_u64(obj, "timeUpdated", item.time_updated); + insert_optional_u64(obj, "childCount", item.child_count); + insert_optional_u64(obj, "previewCount", item.preview_count); + insert_optional_vec_string(obj, "childItemIds", item.child_item_ids.clone()); + insert_non_empty_string(obj, "lastSeenSource", source); + insert_non_empty_string(obj, "lastSeenAt", now); + + if allow_rich_content { + insert_non_empty_string(obj, "previewUrl", &item.image_path); + insert_non_empty_string(obj, "imagePath", &item.image_path); + insert_optional_value( + obj, + "shortDescription", + item.short_description + .clone() + .map(serde_json::Value::String), + ); + insert_optional_vec_string(obj, "galleryPreviewUrls", item.gallery_preview_urls.clone()); + } else { + clear_persisted_workshop_media_and_content(obj); + } +} + +fn persist_workshop_page_details_entry( + obj: &mut serde_json::Map, + workshop_id: &str, + details: &serde_json::Value, + source: &str, + now: &str, + allow_rich_content: bool, +) { + insert_non_empty_string(obj, "workshopId", workshop_id); + insert_non_empty_string(obj, "lastPageFetchedAt", now); + insert_non_empty_string(obj, "lastPageSource", source); + if let Some(title) = details.get("title").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "title", title); + } + if let Some(creator_name) = details.get("creatorName").and_then(|v| v.as_str()) { + let author_ids = [ + details + .get("creatorSteamId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + details + .get("creatorAccountId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + details + .get("creatorVanityId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ]; + insert_author_name_if_useful(obj, creator_name, &author_ids); + } + if let Some(profile_url) = details.get("creatorProfileUrl").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "creatorProfileUrl", profile_url); + insert_non_empty_string(obj, "authorUrl", profile_url); + } + if let Some(steam_id) = details.get("creatorSteamId").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "creatorSteamId", steam_id); + insert_non_empty_string(obj, "creatorId", steam_id); + } + if let Some(vanity_id) = details.get("creatorVanityId").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "creatorVanityId", vanity_id); + if obj + .get("creatorId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .is_empty() + { + insert_non_empty_string(obj, "creatorId", vanity_id); + } + } + if let Some(account_id) = details.get("creatorAccountId").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "creatorAccountId", account_id); + } + if let Some(file_size) = details.get("fileSizeDisplay").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "fileSizeDisplay", file_size); + } + if let Some(posted_text) = details.get("postedDateText").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "postedDateText", posted_text); + } + if let Some(updated_text) = details.get("updatedDateText").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "updatedDateText", updated_text); + } + insert_optional_value( + obj, + "changeNoteCount", + details.get("changeNoteCount").cloned(), + ); + insert_optional_value(obj, "ratingStars", details.get("ratingStars").cloned()); + insert_optional_value(obj, "ratingCount", details.get("ratingCount").cloned()); + insert_optional_value( + obj, + "uniqueVisitors", + details.get("uniqueVisitors").cloned(), + ); + insert_optional_value( + obj, + "currentSubscribers", + details.get("currentSubscribers").cloned(), + ); + insert_optional_value( + obj, + "currentFavorites", + details.get("currentFavorites").cloned(), + ); + + if let Some(tags) = details.get("tags").cloned() { + obj.insert("pageTags".to_string(), tags); + } + if let Some(required) = details.get("requiredItems").cloned() { + obj.insert("requiredItems".to_string(), required); + } + if let Some(child_ids) = details.get("childItemIds").cloned() { + obj.insert("childItemIds".to_string(), child_ids); + } else if let Some(collection_items) = details.get("collectionItems").and_then(|v| v.as_array()) + { + let child_ids: Vec = collection_items + .iter() + .filter_map(|item| item.get("workshopId").and_then(|v| v.as_str())) + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect(); + insert_optional_vec_string(obj, "childItemIds", Some(child_ids)); + } + if let Some(parent_collections) = details.get("parentCollections").cloned() { + obj.insert("parentCollections".to_string(), parent_collections); + } + + if allow_rich_content { + if let Some(preview_url) = details.get("previewUrl").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "previewUrl", preview_url); + insert_non_empty_string(obj, "imagePath", preview_url); + } + if let Some(description) = details.get("description").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "description", description); + } + if let Some(description_html) = details.get("descriptionHtml").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "descriptionHtml", description_html); + } + if let Some(gallery) = details.get("imageGallery").cloned() { + obj.insert("imageGallery".to_string(), gallery.clone()); + obj.insert("galleryUrls".to_string(), gallery); + } + if let Some(background) = details.get("backgroundImageUrl").and_then(|v| v.as_str()) { + insert_non_empty_string(obj, "backgroundImageUrl", background); + } + } else { + clear_persisted_workshop_media_and_content(obj); + } +} + +fn request_download_cancellation(state: &crate::AppState, workshop_id: &str) -> Result<(), String> { + let mut cancelled = state + .cancelled_downloads + .lock() + .map_err(|_| "Failed to acquire cancelled downloads lock".to_string())?; + cancelled.insert(workshop_id.to_string()); + Ok(()) +} + +fn clear_download_cancellation(state: &crate::AppState, workshop_id: &str) -> Result<(), String> { + let mut cancelled = state + .cancelled_downloads + .lock() + .map_err(|_| "Failed to acquire cancelled downloads lock".to_string())?; + cancelled.remove(workshop_id); + Ok(()) +} + +fn is_download_cancelled(state: &crate::AppState, workshop_id: &str) -> Result { + let cancelled = state + .cancelled_downloads + .lock() + .map_err(|_| "Failed to acquire cancelled downloads lock".to_string())?; + Ok(cancelled.contains(workshop_id)) +} + +fn is_dummy_vpk(path: &Path) -> bool { + if let Ok((files, mut file)) = crate::vpk::parse_vpk(path) { + let addoninfo_key = files.keys().find(|k| { + let lower = k.to_lowercase(); + lower == "addoninfo.txt" + || lower.ends_with("/addoninfo.txt") + || lower.ends_with("\\addoninfo.txt") + }); + if let Some(key) = addoninfo_key { + if let Some(entry) = files.get(key) { + if let Ok(content_bytes) = crate::vpk::get_file_content(&mut file, entry) { + let text = String::from_utf8_lossy(&content_bytes); + let parsed = crate::vpk::parse_key_values(&text); + return is_dummy_addon_info(&parsed); + } + } + } + } + false +} + +fn remove_dummy_workshop_targets(dest_path: &Path) -> Result<(), String> { + for candidate in [ + dest_path.to_path_buf(), + dest_path.with_extension("vpk.disabled"), + ] { + if candidate.exists() && is_dummy_vpk(&candidate) { + fs::remove_file(&candidate).map_err(|e| { + format!( + "Failed to remove dummy workshop target {}: {}", + candidate.display(), + e + ) + })?; + } + } + + Ok(()) +} + +fn load_known_addons(known_addons_path: &Path) -> HashMap { + if known_addons_path.exists() { + if let Ok(content) = fs::read_to_string(known_addons_path) { + if let Ok(parsed) = serde_json::from_str::>(&content) { + let mut normalized = HashMap::new(); + for (key, mut entry) in parsed + .into_iter() + .filter(|(_, entry)| !is_dummy_addon_info(&entry.addon_info)) + { + let Some(workshop_id) = entry + .workshop_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + else { + normalized.insert(key, entry); + continue; + }; + + entry.id = workshop_id.to_string(); + let is_canonical_key = key == workshop_id; + match normalized.entry(workshop_id.to_string()) { + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(entry); + } + std::collections::hash_map::Entry::Occupied(mut slot) + if is_canonical_key => + { + slot.insert(entry); + } + std::collections::hash_map::Entry::Occupied(_) => {} + } + } + return normalized; + } + } + } + HashMap::new() +} + +fn normalized_etag(headers: &reqwest::header::HeaderMap) -> Option { + headers + .get(reqwest::header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn normalized_last_modified(headers: &reqwest::header::HeaderMap) -> Option { + headers + .get(reqwest::header::LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn partial_download_path(download_cache_dir: &Path, workshop_id: &str) -> PathBuf { + download_cache_dir.join(format!("{}.vpk.part", workshop_id)) +} + +fn partial_download_metadata_path(download_cache_dir: &Path, workshop_id: &str) -> PathBuf { + download_cache_dir.join(format!("{}.json", workshop_id)) +} + +fn cleanup_partial_download(download_cache_dir: &Path, workshop_id: &str) -> Result<(), String> { + for path in [ + partial_download_path(download_cache_dir, workshop_id), + partial_download_metadata_path(download_cache_dir, workshop_id), + ] { + if path.exists() { + fs::remove_file(&path).map_err(|e| { + format!( + "Failed to remove partial download artifact {}: {}", + path.display(), + e + ) + })?; + } + } + Ok(()) +} + +fn move_or_copy_file(src: &Path, dst: &Path) -> std::io::Result<()> { + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + + match fs::rename(src, dst) { + Ok(()) => Ok(()), + Err(rename_err) => { + fs::copy(src, dst)?; + fs::remove_file(src)?; + if dst.exists() { + Ok(()) + } else { + Err(rename_err) + } + } + } +} + +fn load_partial_download_metadata(metadata_path: &Path) -> Result { + let content = fs::read_to_string(metadata_path).map_err(|e| { + format!( + "Failed to read partial download metadata {}: {}", + metadata_path.display(), + e + ) + })?; + serde_json::from_str(&content).map_err(|e| { + format!( + "Failed to parse partial download metadata {}: {}", + metadata_path.display(), + e + ) + }) +} + +fn save_partial_download_metadata( + metadata_path: &Path, + metadata: &DownloadResumeMetadata, +) -> Result<(), String> { + let json = serde_json::to_string_pretty(metadata).map_err(|e| { + format!( + "Failed to serialize partial download metadata {}: {}", + metadata_path.display(), + e + ) + })?; + fs::write(metadata_path, json).map_err(|e| { + format!( + "Failed to write partial download metadata {}: {}", + metadata_path.display(), + e + ) + }) +} + +fn parse_content_range_start(value: &str) -> Option<(u64, u64)> { + let trimmed = value.trim(); + let bytes = trimmed.strip_prefix("bytes ")?; + let (range, total) = bytes.split_once('/')?; + let total = total.trim().parse::().ok()?; + let (start, _) = range.split_once('-')?; + let start = start.trim().parse::().ok()?; + Some((start, total)) +} + +fn load_settings_store(settings_path: &Path) -> SettingsStore { + if !settings_path.exists() { + return SettingsStore::default(); + } + + let Ok(content) = fs::read_to_string(settings_path) else { + return SettingsStore::default(); + }; + + serde_json::from_str::(&content).unwrap_or_else(|_| { + if let Ok(value) = serde_json::from_str::(&content) { + SettingsStore { + settings: serde_json::from_value( + value.get("settings").cloned().unwrap_or_default(), + ) + .unwrap_or_default(), + master_collections: serde_json::from_value( + value.get("masterCollections").cloned().unwrap_or_default(), + ) + .unwrap_or_default(), + } + } else { + SettingsStore::default() + } + }) +} + +fn load_groups(groups_path: &Path) -> Vec { + if !groups_path.exists() { + return Vec::new(); + } + + let Ok(content) = fs::read_to_string(groups_path) else { + return Vec::new(); + }; + + serde_json::from_str::>(&content).unwrap_or_else(|_| { + if let Ok(value) = serde_json::from_str::(&content) { + serde_json::from_value(value.get("groups").cloned().unwrap_or_default()) + .unwrap_or_default() + } else { + Vec::new() + } + }) +} + +fn load_legacy_db_value(runtime_dir: &Path) -> Option { + let legacy_db_path = runtime_dir.join("db.json"); + if !legacy_db_path.exists() { + return None; + } + + let content = fs::read_to_string(legacy_db_path).ok()?; + serde_json::from_str::(&content).ok() +} + +fn load_known_addons_from_legacy_db( + legacy_db: &serde_json::Value, +) -> HashMap { + let mut migrated = HashMap::new(); + + if let Some(addons_obj) = legacy_db.get("addons").and_then(|v| v.as_object()) { + for value in addons_obj.values() { + if let Ok(mut addon) = serde_json::from_value::(value.clone()) { + if addon.id.is_empty() { + addon.id = addon + .workshop_id + .clone() + .unwrap_or_else(|| addon.vpk_name.clone()); + } + if addon.is_dummy || is_dummy_addon_info(&addon.addon_info) { + continue; + } + let id = addon + .workshop_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .unwrap_or(&addon.id) + .to_string(); + migrated.insert( + id.clone(), + KnownAddonEntry { + id, + vpk_name: addon.vpk_name.clone(), + workshop_id: addon.workshop_id.clone(), + workshop_id_candidate: addon.workshop_id_candidate, + workshop_id_source: addon.workshop_id_source, + workshop_id_validation_status: addon.workshop_id_validation_status, + workshop_id_last_attempt_at: addon.workshop_id_last_attempt_at, + addon_info: addon.addon_info, + has_image: addon.has_image, + image_path: addon.image_path, + steam_details: addon.steam_details, + }, + ); + } + } + } + + migrated +} + +fn load_workshop_cache(cache_path: &Path) -> HashMap { + let (items, authors) = load_workshop_cache_document(cache_path); + let mut items = items; + expand_author_directory_into_cache(&mut items, &authors); + expand_cached_item_compat_fields(&mut items); + items +} + +fn load_workshop_cache_document( + cache_path: &Path, +) -> ( + HashMap, + HashMap, +) { + if !cache_path.exists() { + return (HashMap::new(), HashMap::new()); + } + + let Ok(content) = fs::read_to_string(cache_path) else { + return (HashMap::new(), HashMap::new()); + }; + + let Ok(value) = serde_json::from_str::(&content) else { + return (HashMap::new(), HashMap::new()); + }; + + let authors = value + .get("authors") + .and_then(|v| v.as_object()) + .map(|authors| { + authors + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .unwrap_or_default(); + + (workshop_cache_items_from_value(&value), authors) +} + +fn workshop_cache_items_from_value( + value: &serde_json::Value, +) -> HashMap { + let mut items = HashMap::new(); + if let Some(obj) = value.as_object() { + collect_workshop_cache_items(obj, &mut items); + } + items +} + +fn collect_workshop_cache_items( + obj: &serde_json::Map, + items: &mut HashMap, +) { + for (key, value) in obj { + if key == "schemaVersion" || key == "authors" { + continue; + } + if key == "items" { + if let Some(child_obj) = value.as_object() { + collect_workshop_cache_items(child_obj, items); + } + continue; + } + + let Some(value_obj) = value.as_object() else { + continue; + }; + let workshop_id = value_obj + .get("workshopId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .or_else(|| { + key.chars() + .all(|c| c.is_ascii_digit()) + .then(|| key.to_string()) + }); + + if let Some(workshop_id) = workshop_id { + items.insert(workshop_id, value.clone()); + } + } +} + +fn json_string(value: &serde_json::Value, key: &str) -> Option { + value + .get(key) + .and_then(|v| v.as_str()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn account_id_to_steam_id(account_id: &str) -> Option { + let account = account_id.trim().parse::().ok()?; + Some((76561197960265728u64 + account).to_string()) +} + +fn known_addon_to_workshop_cache_entry( + key: &str, + entry: &KnownAddonEntry, +) -> Option<(String, serde_json::Value)> { + let workshop_id = entry + .workshop_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + .or_else(|| { + let trimmed = key.trim(); + trimmed + .chars() + .all(|c| c.is_ascii_digit()) + .then_some(trimmed) + })?; + + let mut obj = serde_json::Map::new(); + insert_non_empty_string(&mut obj, "workshopId", workshop_id); + insert_non_empty_string(&mut obj, "title", &entry.vpk_name); + if let Some(image_path) = entry.image_path.as_deref() { + insert_non_empty_string(&mut obj, "imagePath", image_path); + insert_non_empty_string(&mut obj, "previewUrl", image_path); + } + + if let Some(details) = entry.steam_details.as_ref() { + if let Some(title) = json_string(details, "title") { + insert_non_empty_string(&mut obj, "title", &title); + } + if let Some(preview_url) = json_string(details, "preview_url") { + insert_non_empty_string(&mut obj, "previewUrl", &preview_url); + insert_non_empty_string(&mut obj, "imagePath", &preview_url); + } + if let Some(description) = json_string(details, "description") { + insert_non_empty_string(&mut obj, "description", &description); + insert_non_empty_string(&mut obj, "shortDescription", &description); + } + if let Some(creator) = json_string(details, "creator") { + insert_non_empty_string(&mut obj, "creatorId", &creator); + if creator.chars().all(|c| c.is_ascii_digit()) { + insert_non_empty_string(&mut obj, "creatorSteamId", &creator); + } + } + if let Some(account_id) = json_string(details, "creator_account_id") { + insert_non_empty_string(&mut obj, "creatorAccountId", &account_id); + if let Some(steam_id) = account_id_to_steam_id(&account_id) { + insert_non_empty_string(&mut obj, "creatorSteamId", &steam_id); + } + } + if let Some(vanity_id) = json_string(details, "creator_vanity_id") { + insert_non_empty_string(&mut obj, "creatorVanityId", &vanity_id); + } + if let Some(creator_name) = json_string(details, "creator_name") { + let ids = [ + json_string(&serde_json::Value::Object(obj.clone()), "creatorSteamId") + .unwrap_or_default(), + json_string(&serde_json::Value::Object(obj.clone()), "creatorAccountId") + .unwrap_or_default(), + json_string(&serde_json::Value::Object(obj.clone()), "creatorVanityId") + .unwrap_or_default(), + ]; + insert_author_name_if_useful(&mut obj, &creator_name, &ids); + } + if let Some(file_size) = json_string(details, "file_size") { + insert_non_empty_string(&mut obj, "fileSizeDisplay", &file_size); + } + if let Some(tags) = details.get("tags").cloned() { + obj.insert("tags".to_string(), tags); + } + obj.insert("steamDetails".to_string(), details.clone()); + } + + Some((workshop_id.to_string(), serde_json::Value::Object(obj))) +} + +fn merge_workshop_cache_values(base: &mut serde_json::Value, overlay: serde_json::Value) { + let Some(base_obj) = base.as_object_mut() else { + *base = overlay; + return; + }; + let Some(overlay_obj) = overlay.as_object() else { + *base = overlay; + return; + }; + for (key, value) in overlay_obj { + if !value.is_null() { + base_obj.insert(key.clone(), value.clone()); + } + } +} + +fn load_known_addons_as_workshop_cache( + known_addons_path: &Path, +) -> HashMap { + load_known_addons(known_addons_path) + .iter() + .filter_map(|(key, entry)| known_addon_to_workshop_cache_entry(key, entry)) + .collect() +} + +fn author_identity_values(obj: &serde_json::Map) -> HashSet { + let mut ids = HashSet::new(); + if let Some(value) = obj.get("authorKey").and_then(|v| v.as_str()) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + ids.insert(trimmed.to_lowercase()); + } + } + for key in [ + "creatorSteamId", + "creatorAccountId", + "creatorVanityId", + "creatorId", + "authorId", + "creatorProfileUrl", + "authorUrl", + ] { + if let Some(value) = obj.get(key).and_then(|v| v.as_str()) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + ids.insert(trimmed.to_lowercase()); + if key == "creatorAccountId" { + if let Some(steam_id) = account_id_to_steam_id(trimmed) { + ids.insert(steam_id); + } + } + if let Some(profile_id) = trimmed + .split("/profiles/") + .nth(1) + .and_then(|s| s.split('/').next()) + { + if !profile_id.is_empty() { + ids.insert(profile_id.to_lowercase()); + } + } + if let Some(vanity_id) = trimmed + .split("/id/") + .nth(1) + .and_then(|s| s.split('/').next()) + { + if !vanity_id.is_empty() { + ids.insert(vanity_id.to_lowercase()); + } + } + } + } + } + ids +} + +fn object_string(obj: &serde_json::Map, key: &str) -> Option { + obj.get(key) + .and_then(|v| v.as_str()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn profile_steam_id(url: &str) -> Option { + url.split("/profiles/") + .nth(1) + .and_then(|s| s.split('/').next()) + .map(str::trim) + .filter(|id| id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty()) + .map(str::to_string) +} + +fn profile_vanity_id(url: &str) -> Option { + url.split("/id/") + .nth(1) + .and_then(|s| s.split('/').next()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .map(str::to_string) +} + +fn author_directory_key( + steam_id: Option<&str>, + account_id: Option<&str>, + vanity_id: Option<&str>, + profile_url: Option<&str>, +) -> Option { + if let Some(steam_id) = steam_id + .map(str::trim) + .filter(|id| id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty()) + { + return Some(steam_id.to_string()); + } + if let Some(steam_id) = account_id.and_then(account_id_to_steam_id) { + return Some(steam_id); + } + if let Some(profile_steam_id) = profile_url.and_then(profile_steam_id) { + return Some(profile_steam_id); + } + if let Some(vanity_id) = vanity_id.map(str::trim).filter(|id| !id.is_empty()) { + return Some(format!("vanity:{}", vanity_id.to_lowercase())); + } + if let Some(profile_vanity_id) = profile_url.and_then(profile_vanity_id) { + return Some(format!("vanity:{}", profile_vanity_id.to_lowercase())); + } + profile_url + .map(str::trim) + .filter(|url| !url.is_empty()) + .map(|url| format!("url:{}", url.to_lowercase())) +} + +fn author_key_for_item(obj: &serde_json::Map) -> Option { + object_string(obj, "authorKey").or_else(|| { + let profile_url = + object_string(obj, "creatorProfileUrl").or_else(|| object_string(obj, "authorUrl")); + author_directory_key( + object_string(obj, "creatorSteamId") + .or_else(|| object_string(obj, "authorSteamId")) + .or_else(|| { + object_string(obj, "creatorId") + .filter(|id| id.chars().all(|c| c.is_ascii_digit()) && id.len() >= 16) + }) + .as_deref(), + object_string(obj, "creatorAccountId") + .or_else(|| object_string(obj, "authorAccountId")) + .as_deref(), + object_string(obj, "creatorVanityId") + .or_else(|| object_string(obj, "authorVanityId")) + .as_deref(), + profile_url.as_deref(), + ) + }) +} + +fn author_identity_values_from_entry( + key: &str, + obj: &serde_json::Map, +) -> HashSet { + let mut ids = HashSet::from([key.to_lowercase()]); + for value in [ + object_string(obj, "steamId"), + object_string(obj, "accountId"), + object_string(obj, "vanityId"), + object_string(obj, "profileUrl"), + ] + .into_iter() + .flatten() + { + ids.insert(value.to_lowercase()); + if let Some(steam_id) = account_id_to_steam_id(&value) { + ids.insert(steam_id); + } + if let Some(steam_id) = profile_steam_id(&value) { + ids.insert(steam_id.to_lowercase()); + } + if let Some(vanity_id) = profile_vanity_id(&value) { + ids.insert(vanity_id.to_lowercase()); + ids.insert(format!("vanity:{}", vanity_id.to_lowercase())); + } + } + ids +} + +fn collect_author_directory( + cache: &HashMap, +) -> HashMap { + let mut authors: HashMap = HashMap::new(); + + for value in cache.values() { + let Some(obj) = value.as_object() else { + continue; + }; + let profile_url = + object_string(obj, "creatorProfileUrl").or_else(|| object_string(obj, "authorUrl")); + let steam_id = object_string(obj, "creatorSteamId") + .or_else(|| object_string(obj, "authorSteamId")) + .or_else(|| profile_url.as_deref().and_then(profile_steam_id)) + .or_else(|| { + object_string(obj, "creatorId") + .filter(|id| id.chars().all(|c| c.is_ascii_digit()) && id.len() >= 16) + }); + let account_id = object_string(obj, "creatorAccountId") + .or_else(|| object_string(obj, "authorAccountId")); + let vanity_id = object_string(obj, "creatorVanityId") + .or_else(|| object_string(obj, "authorVanityId")) + .or_else(|| profile_url.as_deref().and_then(profile_vanity_id)); + let Some(key) = author_directory_key( + steam_id.as_deref(), + account_id.as_deref(), + vanity_id.as_deref(), + profile_url.as_deref(), + ) else { + continue; + }; + + let mut entry = authors + .remove(&key) + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + insert_non_empty_string(&mut entry, "key", &key); + if let Some(steam_id) = steam_id.as_deref() { + insert_non_empty_string(&mut entry, "steamId", steam_id); + } + if let Some(account_id) = account_id.as_deref() { + insert_non_empty_string(&mut entry, "accountId", account_id); + if !entry.contains_key("steamId") { + if let Some(steam_id) = account_id_to_steam_id(account_id) { + insert_non_empty_string(&mut entry, "steamId", &steam_id); + } + } + } + if let Some(vanity_id) = vanity_id.as_deref() { + insert_non_empty_string(&mut entry, "vanityId", vanity_id); + } + if let Some(profile_url) = profile_url.as_deref() { + insert_non_empty_string(&mut entry, "profileUrl", profile_url); + } + + let ids = author_identity_values(obj); + let ids_vec = ids.iter().cloned().collect::>(); + let name = object_string(obj, "creatorName").or_else(|| object_string(obj, "authorName")); + if let Some(name) = name.filter(|name| !looks_like_placeholder_author_name(name, &ids_vec)) + { + insert_non_empty_string(&mut entry, "name", &name); + } + authors.insert(key, serde_json::Value::Object(entry)); + } + + authors +} + +fn expand_author_directory_into_cache( + cache: &mut HashMap, + authors: &HashMap, +) { + if authors.is_empty() { + return; + } + let author_entries: Vec<( + String, + serde_json::Map, + HashSet, + )> = authors + .iter() + .filter_map(|(key, value)| { + let obj = value.as_object()?.clone(); + let ids = author_identity_values_from_entry(key, &obj); + Some((key.clone(), obj, ids)) + }) + .collect(); + + for value in cache.values_mut() { + let Some(obj) = value.as_object_mut() else { + continue; + }; + let ids = author_identity_values(obj); + let matched = author_key_for_item(obj) + .and_then(|key| { + author_entries + .iter() + .find(|(author_key, _, _)| author_key.eq_ignore_ascii_case(&key)) + }) + .or_else(|| { + author_entries + .iter() + .find(|(_, _, author_ids)| !ids.is_disjoint(author_ids)) + }); + + let Some((key, author, _)) = matched else { + continue; + }; + insert_non_empty_string(obj, "authorKey", key); + if let Some(name) = object_string(author, "name") { + insert_non_empty_string(obj, "creatorName", &name); + insert_non_empty_string(obj, "authorName", &name); + } + if let Some(profile_url) = object_string(author, "profileUrl") { + insert_non_empty_string(obj, "creatorProfileUrl", &profile_url); + insert_non_empty_string(obj, "authorUrl", &profile_url); + } + if let Some(steam_id) = object_string(author, "steamId") { + insert_non_empty_string(obj, "creatorSteamId", &steam_id); + if obj + .get("creatorId") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|id| !id.is_empty()) + .is_none() + { + insert_non_empty_string(obj, "creatorId", &steam_id); + } + } + if let Some(account_id) = object_string(author, "accountId") { + insert_non_empty_string(obj, "creatorAccountId", &account_id); + } + if let Some(vanity_id) = object_string(author, "vanityId") { + insert_non_empty_string(obj, "creatorVanityId", &vanity_id); + } + } +} + +fn expand_cached_item_compat_fields(cache: &mut HashMap) { + for value in cache.values_mut() { + let Some(obj) = value.as_object_mut() else { + continue; + }; + if !obj.contains_key("shortDescription") { + if let Some(description) = object_string(obj, "description") { + insert_non_empty_string(obj, "shortDescription", &description); + } + } + if !obj.contains_key("galleryUrls") { + if let Some(gallery) = obj.get("imageGallery").cloned() { + obj.insert("galleryUrls".to_string(), gallery); + } + } + } +} + +fn compact_workshop_cache_item_for_save( + value: &serde_json::Value, + authors: &HashMap, +) -> serde_json::Value { + let Some(source_obj) = value.as_object() else { + return value.clone(); + }; + let mut obj = source_obj.clone(); + if let Some(key) = author_key_for_item(&obj) { + if authors.contains_key(&key) { + insert_non_empty_string(&mut obj, "authorKey", &key); + obj.remove("creatorName"); + obj.remove("authorName"); + obj.remove("creatorProfileUrl"); + obj.remove("authorUrl"); + } + } + + let description = object_string(&obj, "description"); + let short_description = object_string(&obj, "shortDescription"); + if description.is_some() && description == short_description { + obj.remove("shortDescription"); + } + if obj.get("imageGallery") == obj.get("galleryUrls") { + obj.remove("galleryUrls"); + } + + serde_json::Value::Object(obj) +} + +fn propagate_author_names(cache: &mut HashMap) { + let learned: Vec<(String, Option, HashSet)> = cache + .values() + .filter_map(|value| { + let obj = value.as_object()?; + let ids = author_identity_values(obj); + let name = obj + .get("creatorName") + .or_else(|| obj.get("authorName")) + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|name| { + !looks_like_placeholder_author_name( + name, + &ids.iter().cloned().collect::>(), + ) + })?; + let url = obj + .get("creatorProfileUrl") + .or_else(|| obj.get("authorUrl")) + .and_then(|v| v.as_str()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + Some((name.to_string(), url, ids)) + }) + .collect(); + + for value in cache.values_mut() { + let Some(obj) = value.as_object_mut() else { + continue; + }; + let ids = author_identity_values(obj); + let current_name = obj + .get("creatorName") + .or_else(|| obj.get("authorName")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let ids_vec = ids.iter().cloned().collect::>(); + let has_placeholder_name = looks_like_placeholder_author_name(¤t_name, &ids_vec); + if has_placeholder_name { + obj.remove("creatorName"); + obj.remove("authorName"); + } + if ids.is_empty() { + continue; + } + for (name, url, learned_ids) in &learned { + if ids.is_disjoint(learned_ids) { + continue; + } + if has_placeholder_name { + insert_non_empty_string(obj, "creatorName", name); + insert_non_empty_string(obj, "authorName", name); + } + if let Some(url) = url { + insert_non_empty_string(obj, "creatorProfileUrl", url); + insert_non_empty_string(obj, "authorUrl", url); + } + break; + } + } +} + +fn merge_known_addon_snapshots_into_cache( + cache: &mut HashMap, + known_addons_path: &Path, +) { + for (workshop_id, mut known_value) in load_known_addons_as_workshop_cache(known_addons_path) { + if let Some(existing) = cache.get(&workshop_id).cloned() { + merge_workshop_cache_values(&mut known_value, existing); + } + cache.insert(workshop_id, known_value); + } +} + +fn workshop_cache_with_known_addons( + cache_path: &Path, + known_addons_path: &Path, +) -> HashMap { + let mut combined = load_known_addons_as_workshop_cache(known_addons_path); + for (workshop_id, value) in load_workshop_cache(cache_path) { + if let Some(existing) = combined.get_mut(&workshop_id) { + merge_workshop_cache_values(existing, value); + } else { + combined.insert(workshop_id, value); + } + } + propagate_author_names(&mut combined); + combined +} + +fn save_workshop_cache( + cache_path: &Path, + cache: &HashMap, +) -> Result<(), String> { + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + } + let authors = collect_author_directory(cache); + let compact_items: HashMap = cache + .iter() + .filter(|(key, value)| { + key.chars().all(|c| c.is_ascii_digit()) + || value + .get("workshopId") + .and_then(|v| v.as_str()) + .map(|id| !id.trim().is_empty()) + .unwrap_or(false) + }) + .map(|(key, value)| { + ( + key.clone(), + compact_workshop_cache_item_for_save(value, &authors), + ) + }) + .collect(); + let json = serde_json::to_string_pretty(&serde_json::json!({ + "schemaVersion": 3, + "items": compact_items, + "authors": authors, + })) + .map_err(|e| format!("Failed to serialize workshop cache: {}", e))?; + fs::write(cache_path, json).map_err(|e| format!("Failed to write workshop cache: {}", e)) +} + +fn insert_non_empty_string( + obj: &mut serde_json::Map, + key: &str, + value: &str, +) { + let value = value.trim(); + if !value.is_empty() { + obj.insert( + key.to_string(), + serde_json::Value::String(value.to_string()), + ); + } +} + +fn looks_like_placeholder_author_name(name: &str, ids: &[String]) -> bool { + let name = name.trim(); + if name.is_empty() + || name.chars().all(|c| c.is_ascii_digit()) + || name.eq_ignore_ascii_case("AUTHOR_NAME") + || name.eq_ignore_ascii_case("[unknown]") + { + return true; + } + + ids.iter() + .map(|id| id.trim()) + .filter(|id| !id.is_empty()) + .any(|id| name.eq_ignore_ascii_case(id)) +} + +fn insert_author_name_if_useful( + obj: &mut serde_json::Map, + name: &str, + ids: &[String], +) { + if looks_like_placeholder_author_name(name, ids) { + return; + } + insert_non_empty_string(obj, "creatorName", name); + insert_non_empty_string(obj, "authorName", name); +} + +fn insert_optional_value( + obj: &mut serde_json::Map, + key: &str, + value: Option, +) { + if let Some(value) = value { + if !value.is_null() { + obj.insert(key.to_string(), value); + } + } +} + +fn insert_optional_u64( + obj: &mut serde_json::Map, + key: &str, + value: Option, +) { + if let Some(value) = value { + obj.insert(key.to_string(), serde_json::json!(value)); + } +} + +fn insert_optional_vec_string( + obj: &mut serde_json::Map, + key: &str, + value: Option>, +) { + if let Some(values) = value { + let cleaned: Vec = values + .into_iter() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .collect(); + if !cleaned.is_empty() { + obj.insert(key.to_string(), serde_json::json!(cleaned)); + } + } +} + +fn cache_entry_object<'a>( + cache: &'a mut HashMap, + workshop_id: &str, +) -> &'a mut serde_json::Map { + let entry = cache + .entry(workshop_id.to_string()) + .or_insert_with(|| serde_json::json!({ "workshopId": workshop_id })); + if !entry.is_object() { + *entry = serde_json::json!({ "workshopId": workshop_id }); + } + entry + .as_object_mut() + .expect("workshop cache entry must be an object") +} + +fn append_workshop_crawl_log_internal( + crawl_log_path: &Path, + record: serde_json::Value, +) -> Result<(), String> { + if let Some(parent) = crawl_log_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create crawl log directory: {}", e))?; + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(crawl_log_path) + .map_err(|e| format!("Failed to open crawl log: {}", e))?; + let line = serde_json::to_string(&record) + .map_err(|e| format!("Failed to serialize crawl log record: {}", e))?; + writeln!(file, "{}", line).map_err(|e| format!("Failed to append crawl log: {}", e)) +} + +fn merge_workshop_details_into_addon(addon: &mut Addon, details: &serde_json::Value) { + addon.workshop_details = Some(details.clone()); + + if addon.image_path.is_none() { + if let Some(preview_url) = details + .get("previewUrl") + .or_else(|| details.get("imagePath")) + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + { + addon.image_path = Some(preview_url.to_string()); + addon.has_image = true; + } + } + + let creator_name = details + .get("creatorName") + .or_else(|| details.get("authorName")) + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + .map(|v| v.trim().to_string()); + + let creator_id = details + .get("creatorSteamId") + .or_else(|| details.get("creatorId")) + .or_else(|| details.get("authorId")) + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + .map(|v| v.trim().to_string()); + + let creator_vanity_id = details + .get("creatorVanityId") + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + .map(|v| v.trim().to_string()); + + let creator_account_id = details + .get("creatorAccountId") + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + .map(|v| v.trim().to_string()); + + if creator_name.is_some() || creator_id.is_some() { + let mut steam_details = addon + .steam_details + .clone() + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())); + if !steam_details.is_object() { + steam_details = serde_json::Value::Object(serde_json::Map::new()); + } + if let Some(obj) = steam_details.as_object_mut() { + if let Some(name) = creator_name { + let current = obj + .get("creator_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if current.trim().is_empty() || current == creator_id.as_deref().unwrap_or_default() + { + obj.insert("creator_name".to_string(), serde_json::Value::String(name)); + } + } + if let Some(id) = creator_id { + obj.entry("creator".to_string()) + .or_insert_with(|| serde_json::Value::String(id)); + } + if let Some(vanity_id) = creator_vanity_id { + obj.entry("creator_vanity_id".to_string()) + .or_insert_with(|| serde_json::Value::String(vanity_id)); + } + if let Some(account_id) = creator_account_id { + obj.entry("creator_account_id".to_string()) + .or_insert_with(|| serde_json::Value::String(account_id)); + } + if let Some(description) = details + .get("description") + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + { + obj.entry("description".to_string()) + .or_insert_with(|| serde_json::Value::String(description.trim().to_string())); + } + } + addon.steam_details = Some(steam_details); + } +} + +fn database_with_workshop_cache( + db: &Database, + workshop_cache_path: &Path, + known_addons_path: &Path, +) -> Database { + let cache = workshop_cache_with_known_addons(workshop_cache_path, known_addons_path); + if cache.is_empty() { + return db.clone(); + } + + let mut response = db.clone(); + for addon in response.addons.values_mut() { + if let Some(workshop_id) = addon.workshop_id.as_deref() { + if let Some(details) = cache.get(workshop_id) { + merge_workshop_details_into_addon(addon, details); + } + } + } + for addon in response.known_uninstalled_addons.values_mut() { + if let Some(workshop_id) = addon.workshop_id.as_deref() { + if let Some(details) = cache.get(workshop_id) { + merge_workshop_details_into_addon(addon, details); + } + } + } + response +} + +async fn fetch_steam_details(workshop_ids: &[String]) -> Result, String> { + fetch_steam_details_web(workshop_ids).await +} + +async fn fetch_steam_details_hybrid( + workshop_service: &WorkshopService, + workshop_ids: &[String], + allow_bridge: bool, + allow_web_api: bool, + source_order: &[String], +) -> Result, String> { + if workshop_ids.is_empty() { + return Ok(Vec::new()); + } + + if !allow_bridge && !allow_web_api { + return Err("All remote workshop metadata sources are disabled".to_string()); + } + + if !allow_bridge { + return fetch_steam_details(workshop_ids).await; + } + + let web_first = source_position(source_order, "steam-web-api", 1) + < source_position(source_order, "steamworks-sdk", 0); + if web_first && allow_web_api { + match fetch_steam_details(workshop_ids).await { + Ok(details) if !details.is_empty() => return Ok(details), + Ok(_) | Err(_) => {} + } + } + + match workshop_service.bridge_fetch_details(workshop_ids) { + Ok(details) if !details.is_empty() => Ok(details), + Ok(_) | Err(_) if allow_web_api => fetch_steam_details(workshop_ids).await, + Ok(_) | Err(_) => Err( + "Steamworks SDK did not return workshop details and Steam Web API is disabled" + .to_string(), + ), + } +} + +async fn fetch_collection_children_hybrid( + workshop_service: &WorkshopService, + collection_id: &str, + allow_bridge: bool, + allow_web_api: bool, + source_order: &[String], +) -> Result, String> { + if !allow_bridge && !allow_web_api { + return Err("All remote workshop collection sources are disabled".to_string()); + } + + if !allow_bridge { + return fetch_collection_children_web(collection_id).await; + } + + let web_first = source_position(source_order, "steam-web-api", 1) + < source_position(source_order, "steamworks-sdk", 0); + if web_first && allow_web_api { + match fetch_collection_children_web(collection_id).await { + Ok(ids) if !ids.is_empty() => return Ok(ids), + Ok(_) | Err(_) => {} + } + } + + if let Ok(payload) = workshop_service.bridge_query_collection(collection_id) { + let mut ids = Vec::new(); + for item in payload.items { + if let Some(id) = item.get("publishedfileid").and_then(|v| v.as_str()) { + ids.push(id.to_string()); + } + } + if !ids.is_empty() { + return Ok(ids); + } + } + + if allow_web_api { + fetch_collection_children_web(collection_id).await + } else { + Err( + "Steamworks SDK did not return collection children and Steam Web API is disabled" + .to_string(), + ) + } +} + +async fn attempt_bridge_download( + state: &crate::AppState, + workshop_service: &WorkshopService, + workshop_id: &str, + workshop_dir: &Path, + app_handle: &AppHandle, + allow_bridge: bool, +) -> Result { + if !allow_bridge || !workshop_service.has_bridge() { + return Ok(false); + } + + workshop_service.bridge_request_download(workshop_id)?; + + #[derive(Serialize, Clone)] + #[serde(rename_all = "camelCase")] + struct DownloadProgress { + workshop_id: String, + percent: u32, + downloaded: u64, + total: u64, + source: String, + phase: String, + } + + let expected_path = workshop_dir.join(format!("{}.vpk", workshop_id)); + let expected_disabled_path = workshop_dir.join(format!("{}.vpk.disabled", workshop_id)); + let started_at = Instant::now(); + let timeout = Duration::from_secs(120); + let mut saw_download_activity = false; + let mut saw_install_folder = false; + let mut last_reported_percent = 0u32; + let mut last_status_log = Instant::now() - Duration::from_secs(30); + let mut last_status_signature = String::new(); + + while started_at.elapsed() < timeout { + if is_download_cancelled(state, workshop_id)? { + return Err(DOWNLOAD_CANCELLED_ERR.to_string()); + } + + let status: BridgeDownloadStatus = + match workshop_service.bridge_download_status(workshop_id) { + Ok(status) => status, + Err(err) => return Err(err), + }; + + let item_states = status.item_state.join(","); + let install_folder = status.install_folder.as_deref().unwrap_or(""); + let installed_dir = if install_folder.trim().is_empty() { + None + } else { + Some(PathBuf::from(install_folder)) + }; + let status_signature = format!( + "installed={} downloaded={:?} total={:?} installFolder={} itemState={}", + status.installed, status.downloaded, status.total, install_folder, item_states + ); + if status_signature != last_status_signature + || last_status_log.elapsed() >= Duration::from_secs(5) + { + println!( + "Steam SDK download status for {}: {}", + workshop_id, status_signature + ); + last_status_signature = status_signature; + last_status_log = Instant::now(); + } + + if let (Some(downloaded), Some(total)) = (status.downloaded, status.total) { + if downloaded > 0 || total > 0 { + saw_download_activity = true; + } + let raw_percent = if total > 0 { + ((downloaded as f64 / total as f64) * 100.0) as u32 + } else { + 0 + } + .min(99); + let percent = raw_percent.max(last_reported_percent); + last_reported_percent = percent; + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + workshop_id: workshop_id.to_string(), + percent, + downloaded, + total, + source: "steam-sdk".to_string(), + phase: if status.installed { + "install".to_string() + } else { + "download".to_string() + }, + }, + ); + } + + if (status.installed || installed_dir.is_some()) && last_reported_percent < 99 { + last_reported_percent = 99; + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + workshop_id: workshop_id.to_string(), + percent: 99, + downloaded: status.downloaded.unwrap_or(0), + total: status.total.unwrap_or(0), + source: "steam-sdk".to_string(), + phase: "install".to_string(), + }, + ); + } + + if expected_path.exists() || expected_disabled_path.exists() { + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + workshop_id: workshop_id.to_string(), + percent: 100, + downloaded: status.total.unwrap_or(0), + total: status.total.unwrap_or(0), + source: "steam-sdk".to_string(), + phase: "download".to_string(), + }, + ); + return Ok(true); + } + + if let Some(ref folder) = installed_dir { + saw_install_folder = true; + if let Some(source_path) = find_sdk_installed_workshop_file(folder)? { + println!( + "Steam SDK install path resolved for {}: {} -> importing {}", + workshop_id, + folder.display(), + source_path.display() + ); + crate::watcher::suppress_internal_refresh_for( + state, + Duration::from_millis(DOWNLOAD_FINALIZE_SUPPRESS_MS), + ); + import_sdk_workshop_file(&source_path, workshop_id, workshop_dir)?; + let final_total = status.total.unwrap_or_else(|| { + fs::metadata(&source_path) + .map(|meta| meta.len()) + .unwrap_or(0) + }); + let _ = app_handle.emit( + "download-progress", + DownloadProgress { + workshop_id: workshop_id.to_string(), + percent: 100, + downloaded: final_total, + total: final_total, + source: "steam-sdk".to_string(), + phase: "import".to_string(), + }, + ); + return Ok(true); + } else { + println!( + "Steam SDK install path present for {} but no importable file found yet: {}", + workshop_id, + folder.display() + ); + } + } + + if status.installed && !saw_install_folder { + eprintln!( + "Steam SDK marked workshop item {} installed without install folder; itemState={}, downloaded={:?}, total={:?}", + workshop_id, item_states, status.downloaded, status.total + ); + } + + std::thread::sleep(std::time::Duration::from_millis(250)); + } + + eprintln!( + "Steam SDK workshop download did not complete within timeout for {}: saw_download_activity={}, saw_install_folder={}, expected_path_exists={}, item_state_polling_fell_through", + workshop_id, + saw_download_activity, + saw_install_folder, + expected_path.exists() || expected_disabled_path.exists(), + ); + + Ok(false) +} + +fn extract_workshop_id_from_url(url: &str) -> Option { + if let Some(pos) = url.find("id=") { + let start = pos + 3; + let end = url[start..] + .find('&') + .map(|idx| start + idx) + .unwrap_or(url.len()); + let id_str = &url[start..end]; + if id_str.chars().all(|c| c.is_ascii_digit()) && !id_str.is_empty() { + return Some(id_str.to_string()); + } + } + None +} + +fn clean_group_name(name: &str) -> String { + let mut s = name.trim().to_string(); + + let re_ver = Regex::new(r"(?i)\s+v?\d+(?:\.\d+)*$").unwrap(); + let re_part_num = Regex::new(r"(?i)(?:[-#_/:,\s]+(?:part|pt|partie|pts|vol|volume|chapter|ch|act)?\s*(?:\d+|[ivxldcm]+)(?:\/\d+)?)$").unwrap(); + let re_part_word = + Regex::new(r"(?i)(?:[-#_/:,\s]+(?:part|pt|partie|pts|vol|volume|chapter|ch|act))$") + .unwrap(); + + loop { + let prev_len = s.len(); + + s = re_ver.replace(&s, "").into_owned(); + s = re_part_num.replace(&s, "").into_owned(); + s = re_part_word.replace(&s, "").into_owned(); + + s = s + .trim_end_matches(|c: char| { + c.is_whitespace() + || c == ':' + || c == '_' + || c == '-' + || c == '/' + || c == '\\' + || c == '#' + || c == '+' + || c == ',' + || c == '.' + }) + .to_string(); + + if s.len() == prev_len { + break; + } + } + + s +} + +fn is_collection_detail(details: &Value) -> bool { + details + .get("file_type") + .and_then(|v| v.as_str()) + .map(|v| v.eq_ignore_ascii_case("collection")) + .unwrap_or_else(|| { + details + .get("file_type") + .and_then(|v| v.as_u64()) + .map(|v| v == 2) + .unwrap_or(false) + }) +} + +fn upsert_known_addon_entry( + known_addons: &mut HashMap, + workshop_id: &str, + details: Option<&Value>, +) { + let workshop_id = workshop_id.trim(); + if workshop_id.is_empty() { + return; + } + + let existing = known_addons.get(workshop_id).cloned(); + + let preview_url = details + .and_then(|value| value.get("preview_url")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(|value| value.to_string()) + .or_else(|| existing.as_ref().and_then(|entry| entry.image_path.clone())); + + let addon_info = existing + .as_ref() + .map(|entry| entry.addon_info.clone()) + .unwrap_or(serde_json::Value::Null); + let vpk_name = existing + .as_ref() + .map(|entry| entry.vpk_name.clone()) + .unwrap_or_else(|| format!("{}.vpk", workshop_id)); + + known_addons.insert( + workshop_id.to_string(), + KnownAddonEntry { + id: workshop_id.to_string(), + vpk_name, + workshop_id: Some(workshop_id.to_string()), + workshop_id_candidate: None, + workshop_id_source: None, + workshop_id_validation_status: None, + workshop_id_last_attempt_at: None, + addon_info, + has_image: preview_url.is_some() + || existing + .as_ref() + .map(|entry| entry.has_image) + .unwrap_or(false), + image_path: preview_url, + steam_details: details + .cloned() + .or_else(|| existing.and_then(|entry| entry.steam_details)), + }, + ); +} + +fn ensure_workshop_import_master_collection(db: &mut Database) -> String { + if let Some(existing) = db + .master_collections + .iter() + .find(|mc| mc.name_key.as_deref() == Some("masterCollections.systemWorkshopImport")) + { + return existing.id.clone(); + } + + let mc_id = format!( + "mc_system_ws_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + db.master_collections.push(MasterCollection { + id: mc_id.clone(), + name: "从创意工坊导入".to_string(), + name_key: Some("masterCollections.systemWorkshopImport".to_string()), + group_ids: Vec::new(), + is_system: true, + icon: Some("Globe".to_string()), + }); + mc_id +} + +fn ensure_group_in_master_collection(group: &mut Group, mc_id: &str) { + if let Some(ref mut ids) = group.master_collection_ids { + if !ids.iter().any(|id| id == mc_id) { + ids.push(mc_id.to_string()); + } + } else { + group.master_collection_ids = Some(vec![mc_id.to_string()]); + } +} + +fn clear_auto_groups(db: &mut Database) { + let auto_group_ids: HashSet = db + .groups + .iter() + .filter(|group| group.source.as_deref() == Some("auto-group")) + .map(|group| group.id.clone()) + .collect(); + if auto_group_ids.is_empty() { + return; + } + + db.groups + .retain(|group| group.source.as_deref() != Some("auto-group")); + for collection in &mut db.master_collections { + collection + .group_ids + .retain(|group_id| !auto_group_ids.contains(group_id)); + } +} + +fn addon_info_flag(addon: &Addon, key: &str) -> bool { + addon + .addon_info + .as_object() + .and_then(|fields| { + fields + .iter() + .find(|(field, _)| field.eq_ignore_ascii_case(key)) + .map(|(_, value)| value) + }) + .is_some_and(|value| { + value + .as_str() + .map(|value| value.trim() == "1") + .unwrap_or_else(|| { + value.as_i64() == Some(1) + || value.as_u64() == Some(1) + || value.as_bool() == Some(true) + }) + }) +} + +fn tags_indicate_campaign_or_map(details: Option<&serde_json::Value>) -> bool { + let Some(details) = details else { + return false; + }; + let Some(details) = details.as_object() else { + return false; + }; + + ["tags", "pageTags"].iter().any(|key| { + details + .get(*key) + .and_then(|value| value.as_array()) + .is_some_and(|tags| { + tags.iter().any(|tag| { + let tag = tag + .as_str() + .or_else(|| tag.get("tag").and_then(|value| value.as_str())) + .or_else(|| tag.get("name").and_then(|value| value.as_str())) + .unwrap_or("") + .to_lowercase(); + tag.contains("campaign") || tag.contains("map") + }) + }) + }) +} + +fn addon_is_campaign_or_map(addon: &Addon) -> bool { + addon_info_flag(addon, "addoncontent_campaign") + || addon_info_flag(addon, "addoncontent_map") + || tags_indicate_campaign_or_map(addon.steam_details.as_ref()) + || tags_indicate_campaign_or_map(addon.workshop_details.as_ref()) +} + +fn normalize_group_comparison_text(value: &str) -> String { + value + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() +} + +fn addon_author_identity(addon: &Addon) -> Option { + let addon_info_id = addon + .addon_info + .get("addonauthorsteamid") + .or_else(|| addon.addon_info.get("addonAuthorSteamID")) + .and_then(|value| value.as_str()); + let steam_id = addon + .steam_details + .as_ref() + .and_then(|details| { + details + .get("creator_steam_id") + .or_else(|| details.get("creatorSteamId")) + .or_else(|| details.get("creator")) + }) + .and_then(|value| value.as_str()); + let workshop_id = addon + .workshop_details + .as_ref() + .and_then(|details| { + details + .get("creatorSteamId") + .or_else(|| details.get("creatorId")) + .or_else(|| details.get("authorId")) + }) + .and_then(|value| value.as_str()); + + addon_info_id + .or(steam_id) + .or(workshop_id) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_lowercase()) +} + +fn addon_group_description(addon: &Addon) -> Option { + let description = addon + .addon_info + .get("addondescription") + .or_else(|| addon.addon_info.get("addontagline")) + .or_else(|| { + addon + .steam_details + .as_ref() + .and_then(|details| details.get("description")) + }) + .or_else(|| { + addon + .workshop_details + .as_ref() + .and_then(|details| details.get("description")) + }) + .and_then(|value| value.as_str())?; + let description = normalize_group_comparison_text(description); + (description.len() > 10).then_some(description) +} + +fn texture_group_root_title(title: &str) -> Option { + let texture_suffix = Regex::new(r"(?i)^(.*?)\s+textures?$").ok()?; + let root = texture_suffix.captures(title)?.get(1)?.as_str().trim(); + (root.len() >= 3).then(|| root.to_string()) +} + +fn auto_group_internal(db: &mut Database) { + clear_auto_groups(db); + + let mut grouped_vpks = HashSet::new(); + for g in &db.groups { + for addon in &g.addons { + grouped_vpks.insert(addon.clone()); + } + } + + let mut ungrouped = Vec::new(); + for (name, addon) in &db.addons { + if !grouped_vpks.contains(name) { + ungrouped.push(addon.clone()); + } + } + + struct Candidate { + id: String, + title: String, + title_key: String, + author_identity: Option, + description: Option, + } + + let mut candidates = Vec::new(); + for addon in ungrouped { + if addon.is_dummy || !addon_is_campaign_or_map(&addon) { + continue; + } + let title = addon + .addon_info + .get("addontitle") + .and_then(|title| title.as_str()) + .filter(|title| !title.trim().is_empty()) + .or_else(|| { + addon + .steam_details + .as_ref() + .and_then(|details| details.get("title").and_then(|title| title.as_str())) + .filter(|title| !title.trim().is_empty()) + }) + .unwrap_or(&addon.vpk_name) + .to_string(); + + candidates.push(Candidate { + id: addon.id.clone(), + title_key: normalize_group_comparison_text(&title), + title, + author_identity: addon_author_identity(&addon), + description: addon_group_description(&addon), + }); + } + + let re_part = Regex::new(r"(?i)^(.*?)\s*(?:[-#_]*\s*(?:part|pt|partie|pts|pack)\s*(\d+|[ivxldcm]+)(?:\/\d+)?|\s+v?\d+\.\d+|\s+v\d+)$").unwrap(); + let mut title_groups: HashMap)> = HashMap::new(); + for c in &candidates { + if let Some(caps) = re_part.captures(&c.title) { + let prefix = caps.get(1).unwrap().as_str().trim().to_string(); + if prefix.len() >= 3 { + let cleaned_prefix = clean_group_name(&prefix); + if cleaned_prefix.len() >= 3 { + let key = cleaned_prefix.to_lowercase(); + let entry = title_groups + .entry(key) + .or_insert_with(|| (cleaned_prefix.clone(), Vec::new())); + if cleaned_prefix < entry.0 { + entry.0 = cleaned_prefix; + } + entry.1.push(c.id.clone()); + } + } + } + } + + title_groups.retain(|_, (_, addons)| { + let authors = addons + .iter() + .filter_map(|id| { + candidates + .iter() + .find(|candidate| &candidate.id == id) + .and_then(|candidate| candidate.author_identity.as_ref()) + }) + .collect::>(); + authors.len() <= 1 + }); + + for (prefix, addons) in title_groups.values_mut() { + let prefix_key = normalize_group_comparison_text(prefix); + let group_author = addons.iter().find_map(|id| { + candidates + .iter() + .find(|candidate| &candidate.id == id) + .and_then(|candidate| candidate.author_identity.as_ref()) + }); + for candidate in &candidates { + let candidate_prefix_key = + normalize_group_comparison_text(&clean_group_name(&candidate.title)); + let has_matching_author = group_author + .map(|author| { + candidate + .author_identity + .as_ref() + .map(|candidate_author| candidate_author == author) + .unwrap_or(true) + }) + .unwrap_or(true); + if candidate_prefix_key == prefix_key + && has_matching_author + && !addons.contains(&candidate.id) + { + addons.push(candidate.id.clone()); + } + } + } + + let mut title_groups = title_groups.into_values().collect::>(); + title_groups.sort_by(|left, right| left.0.cmp(&right.0)); + let mut grouped_ids = HashSet::new(); + for (prefix, mut addons) in title_groups { + if addons.len() >= 2 { + addons.sort(); + grouped_ids.extend(addons.iter().cloned()); + let group_id = format!( + "group_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + + db.groups.push(Group { + id: group_id, + name: prefix, + addons, + tags: None, + workshop_collection_id: None, + master_collection_ids: None, + source: Some("auto-group".to_string()), + }); + } + } + + let mut identical_title_groups: HashMap = + HashMap::new(); + for candidate in &candidates { + if grouped_ids.contains(&candidate.id) { + continue; + } + let (Some(author_identity), Some(description)) = ( + candidate.author_identity.as_ref(), + candidate.description.as_ref(), + ) else { + continue; + }; + let entry = identical_title_groups + .entry(( + candidate.title_key.clone(), + author_identity.clone(), + description.clone(), + )) + .or_insert_with(|| (candidate.title.clone(), author_identity.clone(), Vec::new())); + if candidate.title < entry.0 { + entry.0 = candidate.title.clone(); + } + entry.2.push(candidate.id.clone()); + } + + let mut identical_title_groups = identical_title_groups.into_values().collect::>(); + identical_title_groups.sort_by(|left, right| left.0.cmp(&right.0)); + for (mut title, author_identity, mut addons) in identical_title_groups { + if addons.len() < 2 { + continue; + } + if let Some(root_title) = texture_group_root_title(&title) { + let root_key = normalize_group_comparison_text(&root_title); + for candidate in &candidates { + let has_matching_author = candidate + .author_identity + .as_ref() + .map(|candidate_author| candidate_author == &author_identity) + .unwrap_or(true); + if !grouped_ids.contains(&candidate.id) + && candidate.title_key == root_key + && has_matching_author + && !addons.contains(&candidate.id) + { + addons.push(candidate.id.clone()); + } + } + if addons.len() >= 3 { + title = root_title; + } + } + addons.sort(); + let group_id = format!( + "group_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + db.groups.push(Group { + id: group_id, + name: title, + addons, + tags: None, + workshop_collection_id: None, + master_collection_ids: None, + source: Some("auto-group".to_string()), + }); + } +} + +fn move_requires_dir_change(addon: &Addon, target_dir_type: &str) -> bool { + addon.dir_type != target_dir_type +} + +fn toggle_requires_state_change(addon: &Addon, enabled: bool) -> bool { + addon.is_enabled != enabled +} + +fn rename_requires_name_change(addon: &Addon, sanitized: &str) -> bool { + addon.vpk_name != sanitized +} + +#[cfg(test)] +mod tests { + use super::{ + auto_group_internal, ensure_background_workshop_fetch_allowed, + extract_steamcommunity_error_message, is_background_workshop_fetch_source, + load_known_addons, load_workshop_cache, looks_like_placeholder_author_name, + merge_known_addon_snapshots_into_cache, move_or_copy_file, move_requires_dir_change, + normalize_master_collection_group_refs, parse_content_range_start, + persist_seen_workshop_item_entry, persist_workshop_page_details_entry, + propagate_author_names, remove_dummy_workshop_targets, rename_requires_name_change, + save_workshop_cache, toggle_requires_state_change, workshop_cache_with_known_addons, + SourcePolicy, + }; + use crate::commands::types::{Addon, Database, Group, MasterCollection, WorkshopSeenItem}; + use crate::vpk::generate_dummy_vpk; + use serde_json::json; + use std::collections::HashMap; + use std::fs; + + fn auto_group_addon( + id: &str, + title: &str, + addon_info: serde_json::Value, + tags: serde_json::Value, + ) -> Addon { + Addon { + id: id.to_string(), + vpk_name: format!("{}.vpk", id), + workshop_id: None, + workshop_id_candidate: None, + workshop_id_source: None, + workshop_id_validation_status: None, + workshop_id_last_attempt_at: None, + addon_info, + has_image: false, + image_path: None, + files_count: 0, + file_size: 0, + parsed_at: String::new(), + current_path: String::new(), + dir_type: "loading".to_string(), + is_enabled: true, + steam_details: Some(json!({ "title": title, "tags": tags })), + workshop_details: None, + is_dummy: false, + } + } + + fn write_known_addons_file(name: &str, value: serde_json::Value) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "left4addons-{}-{}-known_addons.json", + name, + std::process::id() + )); + fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap(); + path + } + + fn write_groups_file(name: &str, value: serde_json::Value) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "left4addons-{}-{}-groups.json", + name, + std::process::id() + )); + fs::write(&path, serde_json::to_string(&value).unwrap()).unwrap(); + path + } + + fn create_source_vpk(path: &std::path::Path) { + use std::io::Write; + + let mut file = fs::File::create(path).unwrap(); + let content = b"\"addoninfo\"\n{\n\"addontitle\" \"Mock Addon\"\n}"; + + let mut tree = Vec::new(); + tree.extend_from_slice(b"txt\0"); + tree.extend_from_slice(b"my_folder\0"); + tree.extend_from_slice(b"addoninfo\0"); + tree.extend_from_slice(&0u32.to_le_bytes()); + tree.extend_from_slice(&0u16.to_le_bytes()); + tree.extend_from_slice(&0x7fffu16.to_le_bytes()); + tree.extend_from_slice(&0u32.to_le_bytes()); + tree.extend_from_slice(&(content.len() as u32).to_le_bytes()); + tree.extend_from_slice(&0xffffu16.to_le_bytes()); + tree.extend_from_slice(b"\0"); + tree.extend_from_slice(b"\0"); + tree.extend_from_slice(b"\0"); + + let tree_size = tree.len() as u32; + + file.write_all(&0x55aa1234u32.to_le_bytes()).unwrap(); + file.write_all(&1u32.to_le_bytes()).unwrap(); + file.write_all(&tree_size.to_le_bytes()).unwrap(); + file.write_all(&tree).unwrap(); + file.write_all(content).unwrap(); + } + + #[test] + fn background_source_detection_is_limited_to_silent_refreshes() { + assert!(is_background_workshop_fetch_source("startup-auto")); + assert!(is_background_workshop_fetch_source("background-refresh")); + assert!(!is_background_workshop_fetch_source("workshop-detail")); + assert!(!is_background_workshop_fetch_source("workshop-home")); + } + + #[test] + fn normalizes_master_collection_refs_without_dropping_empty_groups() { + let mut db = Database { + groups: vec![ + Group { + id: "empty-group".to_string(), + name: "Empty Group".to_string(), + addons: vec![], + tags: None, + workshop_collection_id: None, + master_collection_ids: Some(vec![ + "collection-1".to_string(), + "missing-collection".to_string(), + ]), + source: None, + }, + Group { + id: "filled-group".to_string(), + name: "Filled Group".to_string(), + addons: vec!["addon-1".to_string()], + tags: None, + workshop_collection_id: None, + master_collection_ids: None, + source: None, + }, + ], + master_collections: vec![MasterCollection { + id: "collection-1".to_string(), + name: "Collection".to_string(), + name_key: None, + group_ids: vec![ + "empty-group".to_string(), + "missing-group".to_string(), + "empty-group".to_string(), + "filled-group".to_string(), + ], + is_system: false, + icon: None, + }], + ..Database::default() + }; + + assert!(normalize_master_collection_group_refs(&mut db)); + assert_eq!(db.groups.len(), 2); + assert_eq!( + db.master_collections[0].group_ids, + vec!["empty-group".to_string(), "filled-group".to_string()] + ); + assert_eq!( + db.groups[0].master_collection_ids, + Some(vec!["collection-1".to_string()]) + ); + } + + #[test] + fn auto_group_rebuilds_campaign_parts_without_description_heuristics() { + let mut db = Database { + addons: HashMap::from([ + ( + "base".to_string(), + auto_group_addon( + "base", + "City Escape", + json!({ "addoncontent_campaign": "1" }), + json!([]), + ), + ), + ( + "part-1".to_string(), + auto_group_addon( + "part-1", + "City Escape Part 1/2", + json!({ "addoncontent_campaign": "1" }), + json!([]), + ), + ), + ( + "part-2".to_string(), + auto_group_addon( + "part-2", + "city escape PART 2/2", + json!({}), + json!([{ "tag": "Campaigns" }]), + ), + ), + ( + "script".to_string(), + auto_group_addon( + "script", + "City Escape Part 3/3", + json!({ + "addoncontent_script": "1", + "addondescription": "shared campaign description" + }), + json!([{ "tag": "Scripts" }]), + ), + ), + ( + "manual-part".to_string(), + auto_group_addon( + "manual-part", + "City Escape Part 4/4", + json!({ "addoncontent_campaign": "1" }), + json!([]), + ), + ), + ]), + groups: vec![ + Group { + id: "old-auto".to_string(), + name: "Campaign Pack".to_string(), + addons: vec!["script".to_string()], + tags: None, + workshop_collection_id: None, + master_collection_ids: None, + source: Some("auto-group".to_string()), + }, + Group { + id: "manual".to_string(), + name: "Manual Group".to_string(), + addons: vec!["manual-part".to_string()], + tags: None, + workshop_collection_id: None, + master_collection_ids: None, + source: Some("manual".to_string()), + }, + ], + master_collections: vec![MasterCollection { + id: "campaigns".to_string(), + name: "Campaigns".to_string(), + name_key: Some("masterCollections.systemCampaignAuto".to_string()), + group_ids: vec!["old-auto".to_string()], + is_system: true, + icon: None, + }], + ..Database::default() + }; + + auto_group_internal(&mut db); + + let auto_groups = db + .groups + .iter() + .filter(|group| group.source.as_deref() == Some("auto-group")) + .collect::>(); + assert_eq!(auto_groups.len(), 1); + assert_eq!(auto_groups[0].name, "City Escape"); + assert_eq!(auto_groups[0].addons, vec!["base", "part-1", "part-2"]); + assert!(db.groups.iter().any(|group| group.id == "manual")); + assert!(!db.groups.iter().any(|group| group.id == "old-auto")); + assert!(db.master_collections[0].group_ids.is_empty()); + } + + #[test] + fn auto_group_supports_pack_suffixes_without_including_similarly_named_content() { + let mut db = Database { + addons: HashMap::from([ + ( + "base".to_string(), + auto_group_addon( + "base", + "Harbor", + json!({ "addoncontent_campaign": "1" }), + json!([]), + ), + ), + ( + "pack-1".to_string(), + auto_group_addon( + "pack-1", + "Harbor Pack1", + json!({ "addoncontent_campaign": "1" }), + json!([]), + ), + ), + ( + "pack-2".to_string(), + auto_group_addon( + "pack-2", + "Harbor Pack 2", + json!({ "addoncontent_map": "1" }), + json!([]), + ), + ), + ( + "texture".to_string(), + auto_group_addon( + "texture", + "Harbor Texture", + json!({ "addoncontent_campaign": "1" }), + json!([]), + ), + ), + ]), + ..Database::default() + }; + + auto_group_internal(&mut db); + + assert_eq!(db.groups.len(), 1); + assert_eq!(db.groups[0].name, "Harbor"); + assert_eq!(db.groups[0].addons, vec!["base", "pack-1", "pack-2"]); + } + + #[test] + fn auto_group_prefers_addoninfo_title_over_workshop_title() { + let mut first = auto_group_addon( + "first", + "Workshop Title One", + json!({ + "addoncontent_campaign": "1", + "addontitle": "Internal Campaign Part 1" + }), + json!([]), + ); + let mut second = auto_group_addon( + "second", + "Workshop Title Two", + json!({ + "addoncontent_campaign": "1", + "addontitle": "Internal Campaign Part 2" + }), + json!([]), + ); + first.steam_details = Some(json!({ "title": "Workshop Title One" })); + second.steam_details = Some(json!({ "title": "Workshop Title Two" })); + let mut db = Database { + addons: HashMap::from([(first.id.clone(), first), (second.id.clone(), second)]), + ..Database::default() + }; + + auto_group_internal(&mut db); + + assert_eq!(db.groups.len(), 1); + assert_eq!(db.groups[0].name, "Internal Campaign"); + assert_eq!(db.groups[0].addons, vec!["first", "second"]); + } + + #[test] + fn auto_group_attaches_versioned_campaign_root_to_workshop_tagged_parts() { + let root = auto_group_addon( + "root", + "Unrelated Workshop Title", + json!({ + "addoncontent_campaign": "1", + "addoncontent_map": "1", + "addontitle": "广西-南宁 V1.15.1", + "addonauthorsteamid": "HerobrineAce" + }), + json!([]), + ); + let part_one = auto_group_addon( + "part-one", + "Another Workshop Title", + json!({ + "addoncontent_campaign": "0", + "addoncontent_map": "0", + "addontitle": "广西-南宁 V1.15.1 Part 1", + "addonauthorsteamid": "HerobrineAce" + }), + json!([{ "tag": "Campaigns" }]), + ); + let part_two = auto_group_addon( + "part-two", + "Third Workshop Title", + json!({ + "addoncontent_campaign": "0", + "addoncontent_map": "0", + "addontitle": "广西-南宁 V1.15.1 Part 2", + "addonauthorsteamid": "HerobrineAce" + }), + json!([{ "tag": "Campaigns" }]), + ); + let mut db = Database { + addons: HashMap::from([ + (root.id.clone(), root), + (part_one.id.clone(), part_one), + (part_two.id.clone(), part_two), + ]), + ..Database::default() + }; + + auto_group_internal(&mut db); + + assert_eq!(db.groups.len(), 1); + assert_eq!(db.groups[0].name, "广西-南宁"); + assert_eq!(db.groups[0].addons, vec!["part-one", "part-two", "root"]); + } + + #[test] + fn auto_group_rejects_part_groups_with_conflicting_known_authors() { + let mut first = auto_group_addon( + "first", + "Conflict Part 1", + json!({ "addoncontent_campaign": "1" }), + json!([]), + ); + first.steam_details = Some(json!({ + "title": "Conflict Part 1", + "creator": "76561198000000001" + })); + let mut second = auto_group_addon( + "second", + "Conflict Part 2", + json!({ "addoncontent_campaign": "1" }), + json!([]), + ); + second.steam_details = Some(json!({ + "title": "Conflict Part 2", + "creator": "76561198000000002" + })); + let mut db = Database { + addons: HashMap::from([(first.id.clone(), first), (second.id.clone(), second)]), + ..Database::default() + }; + + auto_group_internal(&mut db); + + assert!(db.groups.is_empty()); + } + + #[test] + fn auto_group_accepts_identical_titles_only_with_matching_author_and_description() { + let mut first = auto_group_addon( + "first", + "Shared Campaign", + json!({ "addoncontent_campaign": "1" }), + json!([]), + ); + first.steam_details = Some(json!({ + "title": "Shared Campaign", + "creator": "76561198000000001", + "description": "Two files for the same campaign" + })); + let mut second = first.clone(); + second.id = "second".to_string(); + second.vpk_name = "second.vpk".to_string(); + let mut unrelated = second.clone(); + unrelated.id = "unrelated".to_string(); + unrelated.vpk_name = "unrelated.vpk".to_string(); + unrelated.steam_details = Some(json!({ + "title": "Shared Campaign", + "creator": "76561198000000002", + "description": "Two files for the same campaign" + })); + let mut db = Database { + addons: HashMap::from([ + (first.id.clone(), first), + (second.id.clone(), second), + (unrelated.id.clone(), unrelated), + ]), + ..Database::default() + }; + + auto_group_internal(&mut db); + + assert_eq!(db.groups.len(), 1); + assert_eq!(db.groups[0].name, "Shared Campaign"); + assert_eq!(db.groups[0].addons, vec!["first", "second"]); + } + + #[test] + fn auto_group_attaches_campaign_root_to_identical_texture_parts() { + let mut root = auto_group_addon( + "root", + "Workshop Root", + json!({ + "addoncontent_campaign": "1", + "addontitle": "Northshore" + }), + json!([]), + ); + root.steam_details = Some(json!({ + "title": "Workshop Root", + "creator": "76561198000000001", + "description": "Campaign root package" + })); + + let mut texture_one = auto_group_addon( + "texture-one", + "Workshop Texture One", + json!({ + "addoncontent_campaign": "1", + "addontitle": "Northshore Texture" + }), + json!([]), + ); + texture_one.steam_details = Some(json!({ + "title": "Workshop Texture One", + "creator": "76561198000000001", + "description": "Shared texture package for Northshore" + })); + let mut texture_two = texture_one.clone(); + texture_two.id = "texture-two".to_string(); + texture_two.vpk_name = "texture-two.vpk".to_string(); + + let mut foreign_root = root.clone(); + foreign_root.id = "foreign-root".to_string(); + foreign_root.vpk_name = "foreign-root.vpk".to_string(); + foreign_root.steam_details = Some(json!({ + "title": "Foreign Workshop Root", + "creator": "76561198000000002", + "description": "Another campaign with the same internal title" + })); + + let mut db = Database { + addons: HashMap::from([ + (root.id.clone(), root), + (texture_one.id.clone(), texture_one), + (texture_two.id.clone(), texture_two), + (foreign_root.id.clone(), foreign_root), + ]), + ..Database::default() + }; + + auto_group_internal(&mut db); + + assert_eq!(db.groups.len(), 1); + assert_eq!(db.groups[0].name, "Northshore"); + assert_eq!( + db.groups[0].addons, + vec!["root", "texture-one", "texture-two"] + ); + } + + #[test] + fn author_name_is_treated_as_a_placeholder() { + assert!(looks_like_placeholder_author_name("AUTHOR_NAME", &[])); + assert!(looks_like_placeholder_author_name("author_name", &[])); + assert!(!looks_like_placeholder_author_name("Actual Author", &[])); + } + + #[test] + fn author_placeholder_is_removed_from_existing_cache_entries() { + let mut cache = HashMap::from([( + "123".to_string(), + json!({ + "creatorName": "AUTHOR_NAME", + "authorName": "AUTHOR_NAME", + "creatorSteamId": "76561198000000001" + }), + )]); + + propagate_author_names(&mut cache); + + let entry = cache.get("123").unwrap(); + assert!(entry.get("creatorName").is_none()); + assert!(entry.get("authorName").is_none()); + } + + #[test] + fn detail_sources_can_fetch_html_without_hybrid_mode() { + let policy = SourcePolicy { + allow_steamworks_sdk: true, + allow_steam_web_api: true, + allow_steam_community_html: true, + sdk_html_scope: "disabled".to_string(), + source_order: vec![ + "steamworks-sdk".to_string(), + "steam-web-api".to_string(), + "steamcommunity-html".to_string(), + ], + }; + + assert!(policy.allow_html("addon-detail", true)); + assert!(policy.allow_html("workshop-detail", true)); + assert!(!policy.allow_html("workshop-home", true)); + assert!(!policy.allow_html("workshop-browse", true)); + } + + #[test] + fn sdk_html_scope_controls_search_navigation_and_background_sources() { + let search_policy = SourcePolicy { + allow_steamworks_sdk: true, + allow_steam_web_api: true, + allow_steam_community_html: true, + sdk_html_scope: "search".to_string(), + source_order: vec![], + }; + assert!(search_policy.allow_html("workshop-search", true)); + assert!(search_policy.allow_html("workshop-creator", true)); + assert!(!search_policy.allow_html("workshop-home", true)); + assert!(!search_policy.allow_html("background-refresh", true)); + + let navigation_policy = SourcePolicy { + sdk_html_scope: "navigation".to_string(), + ..search_policy.clone() + }; + assert!(navigation_policy.allow_html("workshop-home", true)); + assert!(navigation_policy.allow_html("workshop-browse", true)); + assert!(!navigation_policy.allow_html("startup-auto", true)); + + let all_policy = SourcePolicy { + sdk_html_scope: "all".to_string(), + ..search_policy + }; + assert!(all_policy.allow_html("startup-auto", true)); + assert!(all_policy.allow_html("background-refresh", true)); + } + + #[test] + fn removes_only_dummy_workshop_targets() { + let temp_dir = std::env::temp_dir().join(format!( + "left4addons-{}-{}", + "remove-dummy-workshop-targets", + std::process::id() + )); + fs::remove_dir_all(&temp_dir).ok(); + fs::create_dir_all(&temp_dir).unwrap(); + + let source_vpk = temp_dir.join("source.vpk"); + create_source_vpk(&source_vpk); + + let dummy_vpk = temp_dir.join("12345.vpk"); + let dummy_disabled_vpk = temp_dir.join("12345.vpk.disabled"); + let real_vpk = temp_dir.join("67890.vpk"); + + generate_dummy_vpk(&source_vpk, &dummy_vpk).unwrap(); + generate_dummy_vpk(&source_vpk, &dummy_disabled_vpk).unwrap(); + fs::write(&real_vpk, b"real-addon").unwrap(); + + remove_dummy_workshop_targets(&dummy_vpk).unwrap(); + + assert!(!dummy_vpk.exists()); + assert!(!dummy_disabled_vpk.exists()); + assert!(real_vpk.exists()); + + fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn background_fetch_only_allows_known_addons() { + let path = write_known_addons_file( + "allowed", + json!({ + "12345": { + "id": "12345", + "vpkName": "12345.vpk", + "workshopId": "12345", + "addonInfo": {}, + "hasImage": false, + "imagePath": null, + "steamDetails": null + } + }), + ); + let groups_path = write_groups_file("allowed", json!([])); + + let allowed = + ensure_background_workshop_fetch_allowed("startup-auto", "12345", &path, &groups_path); + let blocked = + ensure_background_workshop_fetch_allowed("startup-auto", "99999", &path, &groups_path); + let manual = ensure_background_workshop_fetch_allowed( + "workshop-detail", + "99999", + &path, + &groups_path, + ); + + fs::remove_file(&path).ok(); + fs::remove_file(&groups_path).ok(); + + assert!(allowed.is_ok()); + assert!(blocked.is_err()); + assert!(manual.is_ok()); + } + + #[test] + fn normalizes_known_workshop_entries_to_raw_ids() { + let path = write_known_addons_file( + "normalize-workshop-id", + json!({ + "12345.vpk": { + "id": "12345.vpk", + "vpkName": "12345.vpk", + "workshopId": "12345", + "addonInfo": {}, + "hasImage": false, + "imagePath": null, + "steamDetails": null + } + }), + ); + + let entries = load_known_addons(&path); + fs::remove_file(&path).ok(); + + assert_eq!(entries.len(), 1); + assert!(!entries.contains_key("12345.vpk")); + assert_eq!(entries["12345"].id, "12345"); + } + + #[test] + fn background_fetch_allows_known_collection_ids() { + let known_addons_path = write_known_addons_file("collection-allowed", json!({})); + let groups_path = write_groups_file( + "collection-allowed", + json!([ + { + "id": "group-1", + "name": "Known Collection", + "addons": [], + "workshopCollectionId": "54321", + "source": "workshop-import" + } + ]), + ); + + let allowed = ensure_background_workshop_fetch_allowed( + "startup-auto", + "54321", + &known_addons_path, + &groups_path, + ); + + fs::remove_file(&known_addons_path).ok(); + fs::remove_file(&groups_path).ok(); + + assert!(allowed.is_ok()); + } + + #[test] + fn extracts_friendly_workshop_error_message_from_html() { + let html = r#" +
+
+
+

Too many requests, please try again later.

+
+
+
+ "#; + + let message = extract_steamcommunity_error_message(html); + assert_eq!( + message.as_deref(), + Some("Too many requests, please try again later.") + ); + } + + #[test] + fn unknown_seen_items_do_not_persist_images_or_descriptions() { + let mut obj = json!({ + "previewUrl": "https://example.com/old-preview.jpg", + "imagePath": "https://example.com/old-preview.jpg", + "shortDescription": "old description", + "galleryPreviewUrls": ["https://example.com/old-gallery.jpg"], + "description": "old page description" + }) + .as_object() + .cloned() + .unwrap(); + + persist_seen_workshop_item_entry( + &mut obj, + &WorkshopSeenItem { + workshop_id: "99999".to_string(), + title: "Unknown item".to_string(), + image_path: "https://example.com/preview.jpg".to_string(), + short_description: Some("new description".to_string()), + gallery_preview_urls: Some(vec!["https://example.com/gallery.jpg".to_string()]), + ..WorkshopSeenItem::default() + }, + "workshop-browser", + "2026-07-09T00:00:00Z", + false, + ); + + assert_eq!( + obj.get("title").and_then(|v| v.as_str()), + Some("Unknown item") + ); + assert!(obj.get("previewUrl").is_none()); + assert!(obj.get("imagePath").is_none()); + assert!(obj.get("shortDescription").is_none()); + assert!(obj.get("galleryPreviewUrls").is_none()); + assert!(obj.get("description").is_none()); + } + + #[test] + fn known_seen_items_still_persist_preview_fields() { + let mut obj = serde_json::Map::new(); + + persist_seen_workshop_item_entry( + &mut obj, + &WorkshopSeenItem { + workshop_id: "12345".to_string(), + title: "Known item".to_string(), + image_path: "https://example.com/preview.jpg".to_string(), + short_description: Some("description".to_string()), + gallery_preview_urls: Some(vec!["https://example.com/gallery.jpg".to_string()]), + ..WorkshopSeenItem::default() + }, + "workshop-browser", + "2026-07-09T00:00:00Z", + true, + ); + + assert_eq!( + obj.get("previewUrl").and_then(|v| v.as_str()), + Some("https://example.com/preview.jpg") + ); + assert_eq!( + obj.get("imagePath").and_then(|v| v.as_str()), + Some("https://example.com/preview.jpg") + ); + assert_eq!( + obj.get("shortDescription").and_then(|v| v.as_str()), + Some("description") + ); + assert_eq!( + obj.get("galleryPreviewUrls") + .and_then(|v| v.as_array()) + .map(|v| v.len()), + Some(1) + ); + } + + #[test] + fn unknown_page_details_do_not_persist_rich_content() { + let mut obj = json!({ + "previewUrl": "https://example.com/old-preview.jpg", + "imagePath": "https://example.com/old-preview.jpg", + "description": "old description", + "descriptionHtml": "

old

", + "imageGallery": ["https://example.com/old-gallery.jpg"], + "galleryUrls": ["https://example.com/old-gallery.jpg"], + "backgroundImageUrl": "https://example.com/old-bg.jpg" + }) + .as_object() + .cloned() + .unwrap(); + + persist_workshop_page_details_entry( + &mut obj, + "99999", + &json!({ + "title": "Unknown detail item", + "previewUrl": "https://example.com/preview.jpg", + "description": "new description", + "descriptionHtml": "

new

", + "imageGallery": ["https://example.com/gallery.jpg"], + "backgroundImageUrl": "https://example.com/bg.jpg" + }), + "workshop-detail", + "2026-07-09T00:00:00Z", + false, + ); + + assert_eq!( + obj.get("title").and_then(|v| v.as_str()), + Some("Unknown detail item") + ); + assert!(obj.get("previewUrl").is_none()); + assert!(obj.get("imagePath").is_none()); + assert!(obj.get("description").is_none()); + assert!(obj.get("descriptionHtml").is_none()); + assert!(obj.get("imageGallery").is_none()); + assert!(obj.get("galleryUrls").is_none()); + assert!(obj.get("backgroundImageUrl").is_none()); + } + + #[test] + fn known_addons_are_exposed_as_workshop_snapshots() { + let known_addons_path = write_known_addons_file( + "snapshot", + json!({ + "3560883926": { + "id": "3560883926", + "vpkName": "Early Days PART 1/6", + "workshopId": "3560883926", + "addonInfo": null, + "hasImage": true, + "imagePath": "https://example.com/preview.jpg", + "steamDetails": { + "publishedfileid": "3560883926", + "title": "Early Days PART 1/6", + "creator": "76561198012020581", + "description": "[b]UPDATE:[/b]\n\n[h2]Gameplay[/h2]", + "preview_url": "https://example.com/preview.jpg", + "tags": [{ "tag": "Campaigns" }] + } + } + }), + ); + let cache_path = std::env::temp_dir().join(format!( + "left4addons-{}-{}-workshop_cache.json", + "snapshot", + std::process::id() + )); + fs::remove_file(&cache_path).ok(); + + let cache = workshop_cache_with_known_addons(&cache_path, &known_addons_path); + let entry = cache.get("3560883926").unwrap(); + + fs::remove_file(&known_addons_path).ok(); + fs::remove_file(&cache_path).ok(); + + assert_eq!( + entry.get("description").and_then(|v| v.as_str()), + Some("[b]UPDATE:[/b]\n\n[h2]Gameplay[/h2]") + ); + assert_eq!( + entry.get("creatorSteamId").and_then(|v| v.as_str()), + Some("76561198012020581") + ); + } + + #[test] + fn learned_author_name_updates_known_snapshots_with_matching_steam_id() { + let known_addons_path = write_known_addons_file( + "author-propagation", + json!({ + "3560886114": { + "id": "3560886114", + "vpkName": "Early Days PART 2/6", + "workshopId": "3560886114", + "addonInfo": null, + "hasImage": false, + "imagePath": null, + "steamDetails": { + "publishedfileid": "3560886114", + "title": "Early Days PART 2/6", + "creator": "76561198012020581", + "creator_name": "76561198012020581" + } + } + }), + ); + let mut cache = std::collections::HashMap::from([( + "3560883926".to_string(), + json!({ + "workshopId": "3560883926", + "creatorName": "perfect_buddy", + "creatorSteamId": "76561198012020581", + "creatorAccountId": "51754853", + "creatorProfileUrl": "https://steamcommunity.com/id/perfectbuddy" + }), + )]); + + merge_known_addon_snapshots_into_cache(&mut cache, &known_addons_path); + propagate_author_names(&mut cache); + + fs::remove_file(&known_addons_path).ok(); + + let related = cache.get("3560886114").unwrap(); + assert_eq!( + related.get("creatorName").and_then(|v| v.as_str()), + Some("perfect_buddy") + ); + assert_eq!( + related.get("creatorProfileUrl").and_then(|v| v.as_str()), + Some("https://steamcommunity.com/id/perfectbuddy") + ); + } + + #[test] + fn workshop_cache_loader_skips_wrapper_keys_and_repairs_nested_items() { + let cache_path = std::env::temp_dir().join(format!( + "left4addons-{}-{}-workshop_cache.json", + "nested-wrapper", + std::process::id() + )); + fs::write( + &cache_path, + serde_json::to_string(&json!({ + "schemaVersion": 3, + "authors": { + "76561198012020581": { + "name": "perfect_buddy", + "profileUrl": "https://steamcommunity.com/id/perfectbuddy", + "steamId": "76561198012020581", + "vanityId": "perfectbuddy" + } + }, + "items": { + "3560883926": { + "workshopId": "3560883926", + "authorKey": "76561198012020581", + "description": "full description", + "imageGallery": ["https://example.com/1.jpg"] + }, + "items": { + "3560886114": { + "workshopId": "3560886114", + "authorKey": "76561198012020581" + } + }, + "schemaVersion": 2 + } + })) + .unwrap(), + ) + .unwrap(); + + let cache = load_workshop_cache(&cache_path); + fs::remove_file(&cache_path).ok(); + + assert_eq!(cache.len(), 2); + assert!(!cache.contains_key("items")); + assert!(!cache.contains_key("schemaVersion")); + let item = cache.get("3560883926").unwrap(); + assert_eq!( + item.get("creatorName").and_then(|v| v.as_str()), + Some("perfect_buddy") + ); + assert_eq!( + item.get("shortDescription").and_then(|v| v.as_str()), + Some("full description") + ); + assert_eq!( + item.get("galleryUrls") + .and_then(|v| v.as_array()) + .map(|v| v.len()), + Some(1) + ); + } + + #[test] + fn workshop_cache_save_uses_author_directory_and_compacts_alias_fields() { + let cache_path = std::env::temp_dir().join(format!( + "left4addons-{}-{}-workshop_cache.json", + "author-directory", + std::process::id() + )); + fs::remove_file(&cache_path).ok(); + let cache = std::collections::HashMap::from([( + "3560883926".to_string(), + json!({ + "workshopId": "3560883926", + "creatorName": "perfect_buddy", + "authorName": "perfect_buddy", + "creatorProfileUrl": "https://steamcommunity.com/id/perfectbuddy", + "authorUrl": "https://steamcommunity.com/id/perfectbuddy", + "creatorSteamId": "76561198012020581", + "creatorVanityId": "perfectbuddy", + "description": "full description", + "shortDescription": "full description", + "imageGallery": ["https://example.com/1.jpg"], + "galleryUrls": ["https://example.com/1.jpg"] + }), + )]); + + save_workshop_cache(&cache_path, &cache).unwrap(); + let saved: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&cache_path).unwrap()).unwrap(); + let item = saved + .get("items") + .and_then(|v| v.get("3560883926")) + .and_then(|v| v.as_object()) + .unwrap(); + + assert_eq!(saved.get("schemaVersion").and_then(|v| v.as_u64()), Some(3)); + assert_eq!( + saved + .get("authors") + .and_then(|v| v.get("76561198012020581")) + .and_then(|v| v.get("name")) + .and_then(|v| v.as_str()), + Some("perfect_buddy") + ); + assert_eq!( + item.get("authorKey").and_then(|v| v.as_str()), + Some("76561198012020581") + ); + assert!(item.get("creatorName").is_none()); + assert!(item.get("authorName").is_none()); + assert!(item.get("creatorProfileUrl").is_none()); + assert!(item.get("authorUrl").is_none()); + assert!(item.get("shortDescription").is_none()); + assert!(item.get("galleryUrls").is_none()); + + let expanded = load_workshop_cache(&cache_path); + fs::remove_file(&cache_path).ok(); + let expanded_item = expanded.get("3560883926").unwrap(); + assert_eq!( + expanded_item.get("creatorName").and_then(|v| v.as_str()), + Some("perfect_buddy") + ); + assert_eq!( + expanded_item + .get("shortDescription") + .and_then(|v| v.as_str()), + Some("full description") + ); + assert_eq!( + expanded_item + .get("galleryUrls") + .and_then(|v| v.as_array()) + .map(|v| v.len()), + Some(1) + ); + } + + #[test] + fn parse_content_range_start_extracts_start_and_total() { + assert_eq!( + parse_content_range_start("bytes 1024-2047/3492143"), + Some((1024, 3492143)) + ); + assert_eq!(parse_content_range_start("invalid"), None); + } + + #[test] + fn settings_download_concurrency_defaults_to_two() { + let settings: crate::commands::Settings = + serde_json::from_value(json!({"workshopDir": "/w", "loadingDir": "/l"})).unwrap(); + assert_eq!(settings.download_concurrency, 2); + } + + #[test] + fn move_addons_skips_items_already_in_target_dir() { + let addon = Addon { + id: "local-addon".to_string(), + vpk_name: "local-addon.vpk".to_string(), + workshop_id: None, + workshop_id_candidate: None, + workshop_id_source: None, + workshop_id_validation_status: None, + workshop_id_last_attempt_at: None, + addon_info: json!({}), + has_image: false, + image_path: None, + files_count: 0, + file_size: 0, + parsed_at: String::new(), + current_path: "/tmp/local-addon.vpk".to_string(), + dir_type: "loading".to_string(), + is_enabled: true, + steam_details: None, + workshop_details: None, + is_dummy: false, + }; + + assert!(!move_requires_dir_change(&addon, "loading")); + assert!(move_requires_dir_change(&addon, "workshop")); + } + + #[test] + fn toggle_addons_skips_items_already_in_requested_state() { + let addon = Addon { + id: "enabled-addon".to_string(), + vpk_name: "enabled-addon.vpk".to_string(), + workshop_id: None, + workshop_id_candidate: None, + workshop_id_source: None, + workshop_id_validation_status: None, + workshop_id_last_attempt_at: None, + addon_info: json!({}), + has_image: false, + image_path: None, + files_count: 0, + file_size: 0, + parsed_at: String::new(), + current_path: "/tmp/enabled-addon.vpk".to_string(), + dir_type: "workshop".to_string(), + is_enabled: true, + steam_details: None, + workshop_details: None, + is_dummy: false, + }; + + assert!(!toggle_requires_state_change(&addon, true)); + assert!(toggle_requires_state_change(&addon, false)); + } + + #[test] + fn rename_addons_skips_items_already_using_requested_name() { + let addon = Addon { + id: "renamed-addon".to_string(), + vpk_name: "[12345]Renamed Addon.vpk".to_string(), + workshop_id: Some("12345".to_string()), + workshop_id_candidate: None, + workshop_id_source: None, + workshop_id_validation_status: None, + workshop_id_last_attempt_at: None, + addon_info: json!({}), + has_image: false, + image_path: None, + files_count: 0, + file_size: 0, + parsed_at: String::new(), + current_path: "/tmp/[12345]Renamed Addon.vpk".to_string(), + dir_type: "loading".to_string(), + is_enabled: true, + steam_details: None, + workshop_details: None, + is_dummy: false, + }; + + assert!(!rename_requires_name_change( + &addon, + "[12345]Renamed Addon.vpk" + )); + assert!(rename_requires_name_change( + &addon, + "[12345]Renamed Addon_1.vpk" + )); + } + + #[test] + fn move_or_copy_file_falls_back_to_copy() { + let temp_dir = std::env::temp_dir().join(format!( + "left4addons-{}-{}-move-or-copy", + std::process::id(), + chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() + )); + fs::create_dir_all(&temp_dir).unwrap(); + let src = temp_dir.join("source.tmp"); + let dst = temp_dir.join("nested").join("target.tmp"); + + fs::write(&src, b"hello").unwrap(); + move_or_copy_file(&src, &dst).unwrap(); + + assert!(!src.exists()); + assert_eq!(fs::read(&dst).unwrap(), b"hello"); + + fs::remove_file(&dst).ok(); + fs::remove_dir_all(&temp_dir).ok(); + } +} diff --git a/src-tauri/src/commands/handlers/settings.rs b/src-tauri/src/commands/handlers/settings.rs new file mode 100644 index 0000000..10adf7d --- /dev/null +++ b/src-tauri/src/commands/handlers/settings.rs @@ -0,0 +1,90 @@ +use super::*; +use serde::Deserialize; +use tauri::{AppHandle, State}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SaveSettingsPayload { + loading_dir: String, + download_concurrency: u32, + enable_dummy_bypass: bool, + suppress_sdk_unavailable_warning: bool, + disable_steamworks_sdk: bool, + force_steamworks_sdk_download: bool, + max_download_retries: u32, + #[serde(default = "crate::commands::types::default_dependency_missing_behavior")] + dependency_missing_behavior: String, + workshop_source_settings: Option, +} + +#[tauri::command] +pub async fn get_settings(state: State<'_, crate::AppState>) -> Result { + let db = state.db.lock().await; + Ok(db.settings.clone()) +} + +#[tauri::command] +pub async fn save_settings( + payload: SaveSettingsPayload, + state: State<'_, crate::AppState>, + app_handle: AppHandle, +) -> Result { + let SaveSettingsPayload { + loading_dir, + download_concurrency, + enable_dummy_bypass, + suppress_sdk_unavailable_warning, + disable_steamworks_sdk, + force_steamworks_sdk_download, + max_download_retries, + dependency_missing_behavior, + workshop_source_settings, + } = payload; + + let mut db = state.db.lock().await; + let loading_path = PathBuf::from(&loading_dir); + let workshop_dir = loading_path.join("workshop").to_string_lossy().to_string(); + + db.settings.workshop_dir = workshop_dir; + db.settings.loading_dir = loading_dir; + db.settings.download_concurrency = download_concurrency.clamp(1, 8); + db.settings.enable_dummy_bypass = enable_dummy_bypass; + db.settings.suppress_sdk_unavailable_warning = suppress_sdk_unavailable_warning; + db.settings.disable_steamworks_sdk = disable_steamworks_sdk; + db.settings.force_steamworks_sdk_download = force_steamworks_sdk_download; + db.settings.max_download_retries = max_download_retries; + db.settings.dependency_missing_behavior = match dependency_missing_behavior.as_str() { + "always" | "ask" | "ignore" => dependency_missing_behavior, + _ => "ask".to_string(), + }; + if let Some(workshop_source_settings) = workshop_source_settings { + db.settings.workshop_source_settings = workshop_source_settings; + } + if disable_steamworks_sdk { + state.workshop_service.shutdown(); + } + save_db_internal( + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &db, + ); + + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + if let Err(err) = crate::watcher::rebind_addon_watcher(&app_handle, &loading_path) { + crate::watcher::emit_watch_error(&app_handle, &err); + } + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} diff --git a/src-tauri/src/commands/handlers/tasks.rs b/src-tauri/src/commands/handlers/tasks.rs new file mode 100644 index 0000000..6d966a7 --- /dev/null +++ b/src-tauri/src/commands/handlers/tasks.rs @@ -0,0 +1,45 @@ +use super::*; +use tauri::State; + +#[tauri::command] +pub async fn get_background_tasks( + state: State<'_, crate::AppState>, +) -> Result { + if !state.background_tasks_path.exists() { + return Ok(serde_json::json!([])); + } + let content = fs::read_to_string(&state.background_tasks_path) + .map_err(|e| format!("Failed to read background tasks: {}", e))?; + serde_json::from_str(&content).map_err(|e| format!("Failed to parse background tasks: {}", e)) +} + +#[tauri::command] +pub async fn save_background_task_snapshot( + tasks: serde_json::Value, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + if let Some(parent) = state.background_tasks_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create task directory: {}", e))?; + } + let json = serde_json::to_string_pretty(&tasks) + .map_err(|e| format!("Failed to serialize background tasks: {}", e))?; + fs::write(&state.background_tasks_path, json) + .map_err(|e| format!("Failed to write background tasks: {}", e)) +} + +#[tauri::command] +pub async fn cancel_download( + workshop_id: String, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + request_download_cancellation(&state, &workshop_id) +} + +#[tauri::command] +pub async fn append_workshop_crawl_log( + record: serde_json::Value, + state: State<'_, crate::AppState>, +) -> Result<(), String> { + append_workshop_crawl_log_internal(&state.workshop_crawl_log_path, record) +} diff --git a/src-tauri/src/commands/handlers/workshop.rs b/src-tauri/src/commands/handlers/workshop.rs new file mode 100644 index 0000000..6114894 --- /dev/null +++ b/src-tauri/src/commands/handlers/workshop.rs @@ -0,0 +1,927 @@ +use super::*; +use crate::mirrors::MirrorClientExt; +use tauri::State; + +#[tauri::command] +pub async fn open_workshop(workshop_id: String) -> Result<(), String> { + let url = format!( + "https://steamcommunity.com/sharedfiles/filedetails/?id={}", + workshop_id + ); + open::that(&url).map_err(|e| format!("Failed to open workshop URL: {}", e))?; + Ok(()) +} + +#[tauri::command] +pub async fn open_url(url: String) -> Result<(), String> { + let parsed = validate_open_url(&url)?; + open::that(parsed.as_str()).map_err(|e| format!("Failed to open URL: {}", e))?; + Ok(()) +} + +#[tauri::command] +pub async fn get_workshop_capabilities( + state: State<'_, crate::AppState>, +) -> Result { + let db = state.db.lock().await; + if !SourcePolicy::from_settings(&db.settings).allow_bridge() { + return Ok(WorkshopCapabilities { + bridge_available: false, + bridge_loaded: false, + bridge_initialized: false, + provider: "web-fallback".to_string(), + bridge_version: None, + last_error: Some("Steamworks SDK workshop source is disabled by settings".to_string()), + current_user_steam_id: None, + current_user_account_id: None, + can_query_items: false, + can_query_home: false, + can_download: false, + can_enumerate_installed: false, + can_enumerate_subscribed: false, + }); + } + drop(db); + Ok(state.workshop_service.capabilities()) +} + +#[tauri::command] +pub async fn query_workshop_home( + state: State<'_, crate::AppState>, +) -> Result { + let db = state.db.lock().await; + if !SourcePolicy::from_settings(&db.settings).allow_bridge() { + return Err("Steamworks SDK workshop source is disabled".to_string()); + } + drop(db); + state.workshop_service.bridge_query_home() +} + +#[tauri::command] +pub async fn query_workshop_items( + query: WorkshopBrowseQuery, + state: State<'_, crate::AppState>, +) -> Result { + let db = state.db.lock().await; + if !SourcePolicy::from_settings(&db.settings).allow_bridge() { + return Err("Steamworks SDK workshop source is disabled".to_string()); + } + drop(db); + state.workshop_service.bridge_query_items(&query) +} + +#[tauri::command] +pub async fn query_workshop_item( + workshop_id: String, + state: State<'_, crate::AppState>, +) -> Result { + let db = state.db.lock().await; + if !SourcePolicy::from_settings(&db.settings).allow_bridge() { + return Err("Steamworks SDK workshop source is disabled".to_string()); + } + drop(db); + state.workshop_service.bridge_query_item(&workshop_id) +} + +#[tauri::command] +pub async fn query_workshop_details( + workshop_ids: Vec, + state: State<'_, crate::AppState>, +) -> Result { + let db = state.db.lock().await; + if !SourcePolicy::from_settings(&db.settings).allow_bridge() { + return Err("Steamworks SDK workshop source is disabled".to_string()); + } + drop(db); + + Ok(WorkshopItemsResponse { + source: "steam-sdk".to_string(), + items: state.workshop_service.bridge_fetch_details(&workshop_ids)?, + warnings: Vec::new(), + }) +} + +#[tauri::command] +pub async fn query_workshop_collection( + workshop_id: String, + state: State<'_, crate::AppState>, +) -> Result { + let db = state.db.lock().await; + if !SourcePolicy::from_settings(&db.settings).allow_bridge() { + return Err("Steamworks SDK workshop source is disabled".to_string()); + } + drop(db); + state.workshop_service.bridge_query_collection(&workshop_id) +} + +#[tauri::command] +pub async fn steam_sync(state: State<'_, crate::AppState>) -> Result { + let (mut ids, source_policy, can_enumerate_subscribed) = { + let mut db = state.db.lock().await; + + // First, scan addons to populate database with any new/removed files + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + validate_workshop_id_candidates(&mut db, &state.workshop_service, true).await; + + let mut ids = Vec::new(); + for addon in db.addons.values() { + if let Some(ref w_id) = addon.workshop_id { + ids.push(w_id.clone()); + } + } + for addon in db.known_uninstalled_addons.values() { + if let Some(ref w_id) = addon.workshop_id { + ids.push(w_id.clone()); + } + } + ids.sort(); + ids.dedup(); + let source_policy = SourcePolicy::from_settings(&db.settings); + let can_enumerate_subscribed = source_policy.allow_bridge() + && state + .workshop_service + .capabilities() + .can_enumerate_subscribed; + + (ids, source_policy, can_enumerate_subscribed) + }; + + let mut subscribed_ids = Vec::new(); + if can_enumerate_subscribed { + match state.workshop_service.bridge_get_subscribed_items() { + Ok(response) => { + subscribed_ids = response + .items + .into_iter() + .map(|item| item.workshop_id) + .filter(|id| !id.trim().is_empty()) + .collect(); + } + Err(err) => { + eprintln!( + "Failed to enumerate subscribed workshop items via Steam SDK: {}", + err + ); + } + } + } + + ids.extend(subscribed_ids.iter().cloned()); + ids.sort(); + ids.dedup(); + + if ids.is_empty() { + let db = state.db.lock().await; + return Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )); + } + + if !source_policy.allow_bridge() && !source_policy.allow_web_api() { + let db = state.db.lock().await; + return Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )); + } + + println!("Syncing Steam details manually for {} items...", ids.len()); + let steam_details_list = fetch_steam_details_hybrid( + &state.workshop_service, + &ids, + source_policy.allow_bridge(), + source_policy.allow_web_api(), + source_policy.source_order(), + ) + .await?; + let mut details_by_id = HashMap::new(); + for details in steam_details_list { + if let Some(w_id) = details.get("publishedfileid").and_then(|id| id.as_str()) { + details_by_id.insert(w_id.to_string(), details); + } + } + + let mut favorited_collection_ids = Vec::new(); + if source_policy.allow_bridge() { + match state.workshop_service.bridge_get_favorited_collections() { + Ok(response) => { + for item in response.items { + if let Some(collection_id) = + item.get("publishedfileid").and_then(|id| id.as_str()) + { + favorited_collection_ids.push(collection_id.to_string()); + details_by_id + .entry(collection_id.to_string()) + .or_insert(item); + } + } + } + Err(err) => { + eprintln!( + "Failed to enumerate favorited workshop collections via Steam SDK: {}", + err + ); + } + } + } + favorited_collection_ids.sort(); + favorited_collection_ids.dedup(); + + let mut collection_children: HashMap> = HashMap::new(); + let mut extra_detail_ids = Vec::new(); + for collection_id in &favorited_collection_ids { + match state + .workshop_service + .bridge_query_collection(collection_id) + { + Ok(payload) => { + if let Some(w_id) = payload + .collection + .get("publishedfileid") + .and_then(|id| id.as_str()) + { + details_by_id.insert(w_id.to_string(), payload.collection.clone()); + } + let mut child_ids = Vec::new(); + for item in payload.items { + if let Some(child_id) = item.get("publishedfileid").and_then(|id| id.as_str()) { + child_ids.push(child_id.to_string()); + if !details_by_id.contains_key(child_id) { + extra_detail_ids.push(child_id.to_string()); + } + details_by_id.insert(child_id.to_string(), item); + } + } + child_ids.sort(); + child_ids.dedup(); + if !child_ids.is_empty() { + collection_children.insert(collection_id.clone(), child_ids); + } + } + Err(err) => { + eprintln!( + "Failed to query favorited collection {} via Steam SDK: {}", + collection_id, err + ); + if let Ok(child_ids) = fetch_collection_children_hybrid( + &state.workshop_service, + collection_id, + source_policy.allow_bridge(), + source_policy.allow_web_api(), + source_policy.source_order(), + ) + .await + { + let mut child_ids = child_ids; + child_ids.sort(); + child_ids.dedup(); + for child_id in &child_ids { + if !details_by_id.contains_key(child_id) { + extra_detail_ids.push(child_id.clone()); + } + } + if !child_ids.is_empty() { + collection_children.insert(collection_id.clone(), child_ids); + } + } + } + } + } + + if !extra_detail_ids.is_empty() { + extra_detail_ids.sort(); + extra_detail_ids.dedup(); + if let Ok(extra_details) = fetch_steam_details_hybrid( + &state.workshop_service, + &extra_detail_ids, + source_policy.allow_bridge(), + source_policy.allow_web_api(), + source_policy.source_order(), + ) + .await + { + for details in extra_details { + if let Some(w_id) = details.get("publishedfileid").and_then(|id| id.as_str()) { + details_by_id.insert(w_id.to_string(), details); + } + } + } + } + + let mut db = state.db.lock().await; + let mut known_addons = load_known_addons(&state.known_addons_path); + for (workshop_id, details) in &details_by_id { + if !is_collection_detail(details) { + upsert_known_addon_entry(&mut known_addons, workshop_id, Some(details)); + } + } + for workshop_id in &subscribed_ids { + if details_by_id + .get(workshop_id) + .map(|details| !is_collection_detail(details)) + .unwrap_or(true) + { + upsert_known_addon_entry( + &mut known_addons, + workshop_id, + details_by_id.get(workshop_id), + ); + } + } + for child_ids in collection_children.values() { + for workshop_id in child_ids { + upsert_known_addon_entry( + &mut known_addons, + workshop_id, + details_by_id.get(workshop_id), + ); + } + } + + fs::write( + &state.known_addons_path, + serde_json::to_string_pretty(&known_addons).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + + if !collection_children.is_empty() { + let ws_mc_id = ensure_workshop_import_master_collection(&mut db); + for (collection_id, child_ids) in collection_children { + let title = details_by_id + .get(&collection_id) + .and_then(|details| details.get("title")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("Workshop Collection") + .to_string(); + + if let Some(group) = db.groups.iter_mut().find(|group| { + group.workshop_collection_id.as_deref() == Some(collection_id.as_str()) + }) { + group.addons = child_ids.clone(); + if !title.is_empty() { + group.name = title.clone(); + } + group.workshop_collection_id = Some(collection_id.clone()); + ensure_group_in_master_collection(group, &ws_mc_id); + } else { + let group_id = format!( + "group_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + db.groups.push(Group { + id: group_id, + name: title, + addons: child_ids, + tags: None, + workshop_collection_id: Some(collection_id), + master_collection_ids: Some(vec![ws_mc_id.clone()]), + source: Some("workshop-import".to_string()), + }); + } + } + + let ws_group_ids: Vec = db + .groups + .iter() + .filter(|group| { + group + .master_collection_ids + .as_ref() + .is_some_and(|ids| ids.iter().any(|id| id == &ws_mc_id)) + }) + .map(|group| group.id.clone()) + .collect(); + if let Some(master_collection) = db + .master_collections + .iter_mut() + .find(|mc| mc.id == ws_mc_id) + { + master_collection.group_ids = ws_group_ids; + } + } + + save_db_internal( + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &db, + ); + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +pub async fn fetch_workshop_html( + url: String, + source: Option, + state: State<'_, crate::AppState>, +) -> Result { + let started_at = chrono::Utc::now().to_rfc3339(); + let source = source.unwrap_or_else(|| "unknown".to_string()); + let parsed = match validate_steamcommunity_url(&url) { + Ok(parsed) => parsed, + Err(err) => { + let _ = append_workshop_crawl_log_internal( + &state.workshop_crawl_log_path, + serde_json::json!({ + "at": started_at, + "source": source, + "url": url, + "ok": false, + "error": err, + }), + ); + return Err(err); + } + }; + + let (allow_html, sdk_query_available) = { + let db = state.db.lock().await; + let source_policy = SourcePolicy::from_settings(&db.settings); + let sdk_query_available = if source_policy.allow_bridge() { + let capabilities = state.workshop_service.capabilities(); + capabilities.can_query_items || capabilities.can_query_home + } else { + false + }; + ( + source_policy.allow_html(&source, sdk_query_available), + sdk_query_available, + ) + }; + if !allow_html { + let err = if sdk_query_available { + "Steam Community HTML fetching is disabled for this scope while Steamworks SDK is available. Adjust the allowed HTML fetch scope in settings to allow it.".to_string() + } else { + "Steam Community HTML fetching is disabled by source settings".to_string() + }; + let _ = append_workshop_crawl_log_internal( + &state.workshop_crawl_log_path, + serde_json::json!({ + "at": started_at, + "source": source, + "url": url, + "ok": false, + "error": err, + }), + ); + return Err(err); + } + + if is_background_workshop_fetch_source(&source) { + let workshop_id = extract_workshop_id_from_url(parsed.as_str()) + .ok_or_else(|| "Background workshop fetch requires a workshop item URL".to_string())?; + if let Err(err) = ensure_background_workshop_fetch_allowed( + &source, + &workshop_id, + &state.known_addons_path, + &state.groups_path, + ) { + let _ = append_workshop_crawl_log_internal( + &state.workshop_crawl_log_path, + serde_json::json!({ + "at": started_at, + "source": source, + "url": url, + "ok": false, + "error": err, + }), + ); + return Err(err); + } + } + let wait = match reserve_workshop_html_fetch_slot() { + Ok(wait) => wait, + Err(err) => { + let _ = append_workshop_crawl_log_internal( + &state.workshop_crawl_log_path, + serde_json::json!({ + "at": started_at, + "source": source, + "url": url, + "ok": false, + "error": err, + }), + ); + return Err(err); + } + }; + if let Some(wait) = wait { + std::thread::sleep(wait); + } + let client = reqwest::Client::builder() + .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .build() + .map_err(|e| e.to_string())?; + + let res = match client.get_mirrored(parsed.as_str()).send().await { + Ok(res) => res, + Err(e) => { + let err = format!("Failed to fetch URL: {}", e); + let _ = append_workshop_crawl_log_internal( + &state.workshop_crawl_log_path, + serde_json::json!({ + "at": started_at, + "source": source, + "url": url, + "ok": false, + "error": err, + }), + ); + return Err(err); + } + }; + + let status = res.status().as_u16(); + let body = match res.text().await { + Ok(body) => body, + Err(e) => { + let err = format!("Failed to get response text: {}", e); + let _ = append_workshop_crawl_log_internal( + &state.workshop_crawl_log_path, + serde_json::json!({ + "at": started_at, + "source": source, + "url": url, + "ok": false, + "status": status, + "error": err, + }), + ); + return Err(err); + } + }; + + if let Some(message) = extract_steamcommunity_error_message(&body) { + if message.to_lowercase().contains("too many request") { + pause_workshop_html_fetches(&message); + } + let _ = append_workshop_crawl_log_internal( + &state.workshop_crawl_log_path, + serde_json::json!({ + "at": started_at, + "source": source, + "url": url, + "ok": false, + "status": status, + "error": message, + }), + ); + return Err(message); + } + + let _ = append_workshop_crawl_log_internal( + &state.workshop_crawl_log_path, + serde_json::json!({ + "at": started_at, + "source": source, + "url": url, + "ok": true, + "status": status, + "bytes": body.len(), + }), + ); + + Ok(body) +} + +#[tauri::command] +pub async fn fetch_collection( + collection_id: String, + state: State<'_, crate::AppState>, +) -> Result { + let source_policy = { + let db = state.db.lock().await; + SourcePolicy::from_settings(&db.settings) + }; + + if source_policy.allow_bridge() { + if let Ok(payload) = state + .workshop_service + .bridge_query_collection(&collection_id) + { + return Ok(serde_json::json!({ + "collection": payload.collection, + "items": payload.items, + })); + } + } + + let child_ids = fetch_collection_children_hybrid( + &state.workshop_service, + &collection_id, + source_policy.allow_bridge(), + source_policy.allow_web_api(), + source_policy.source_order(), + ) + .await?; + + let mut query_ids = vec![collection_id.clone()]; + query_ids.extend(child_ids.clone()); + + let details = fetch_steam_details_hybrid( + &state.workshop_service, + &query_ids, + source_policy.allow_bridge(), + source_policy.allow_web_api(), + source_policy.source_order(), + ) + .await?; + + let mut collection_details = serde_json::Value::Null; + let mut items = Vec::new(); + + for detail in details { + if let Some(id) = detail.get("publishedfileid").and_then(|id| id.as_str()) { + if id == collection_id { + collection_details = detail; + } else { + items.push(detail); + } + } + } + + Ok(serde_json::json!({ + "collection": collection_details, + "items": items, + })) +} + +#[tauri::command] +pub async fn add_known_addon( + workshop_id: String, + state: State<'_, crate::AppState>, +) -> Result { + let source_policy = { + let db = state.db.lock().await; + SourcePolicy::from_settings(&db.settings) + }; + + let details = fetch_steam_details_hybrid( + &state.workshop_service, + std::slice::from_ref(&workshop_id), + source_policy.allow_bridge(), + source_policy.allow_web_api(), + source_policy.source_order(), + ) + .await + .ok() + .and_then(|mut details| details.drain(..).next()); + + let mut known_addons = load_known_addons(&state.known_addons_path); + + let dest_filename = format!("{}.vpk", workshop_id); + let has_image = details + .as_ref() + .and_then(|details| details.get("preview_url")) + .is_some(); + let image_path = details + .as_ref() + .and_then(|details| details.get("preview_url")) + .and_then(|u| u.as_str()) + .map(|s| s.to_string()); + + let id = workshop_id.clone(); + known_addons.insert( + id.clone(), + KnownAddonEntry { + id: id.clone(), + vpk_name: dest_filename.clone(), + workshop_id: Some(workshop_id.clone()), + workshop_id_candidate: None, + workshop_id_source: None, + workshop_id_validation_status: None, + workshop_id_last_attempt_at: None, + addon_info: serde_json::Value::Null, + has_image, + image_path, + steam_details: details.clone(), + }, + ); + + let _ = fs::write( + &state.known_addons_path, + serde_json::to_string_pretty(&known_addons).unwrap_or_default(), + ); + + let mut db = state.db.lock().await; + scan_addons_internal( + &mut db, + &state.settings_path, + &state.groups_path, + &state.known_addons_path, + &state.cache_dir, + &state.workshop_service, + ) + .await?; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +pub async fn get_workshop_cache( + state: State<'_, crate::AppState>, +) -> Result, String> { + Ok(workshop_cache_with_known_addons( + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +pub async fn record_workshop_items_seen( + items: Vec, + source: Option, + state: State<'_, crate::AppState>, +) -> Result { + // Browse snapshots and dependency details both replace the cache file. Keep + // their read-modify-write sections atomic with respect to one another. + let cache_write_guard = state + .workshop_cache_write_lock + .lock() + .map_err(|_| "Failed to acquire workshop cache write lock".to_string())?; + let mut cache = load_workshop_cache(&state.workshop_cache_path); + let now = chrono::Utc::now().to_rfc3339(); + let source = source.unwrap_or_else(|| "unknown".to_string()); + + for item in items { + if item.workshop_id.trim().is_empty() { + continue; + } + + let allow_rich_content = is_known_workshop_id( + &state.known_addons_path, + &state.groups_path, + &item.workshop_id, + ); + let obj = cache_entry_object(&mut cache, &item.workshop_id); + persist_seen_workshop_item_entry(obj, &item, &source, &now, allow_rich_content); + } + + merge_known_addon_snapshots_into_cache(&mut cache, &state.known_addons_path); + propagate_author_names(&mut cache); + save_workshop_cache(&state.workshop_cache_path, &cache)?; + drop(cache_write_guard); + let db = state.db.lock().await; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} + +#[tauri::command] +pub async fn persist_workshop_page_details( + workshop_id: String, + details: serde_json::Value, + source: Option, + state: State<'_, crate::AppState>, +) -> Result { + if workshop_id.trim().is_empty() { + return Err("Missing workshop id".to_string()); + } + + // See record_workshop_items_seen: avoid a late seen-item write erasing this + // item's requiredItems relation. + let cache_write_guard = state + .workshop_cache_write_lock + .lock() + .map_err(|_| "Failed to acquire workshop cache write lock".to_string())?; + let mut cache = load_workshop_cache(&state.workshop_cache_path); + let now = chrono::Utc::now().to_rfc3339(); + let source = source.unwrap_or_else(|| "unknown".to_string()); + ensure_background_workshop_fetch_allowed( + &source, + &workshop_id, + &state.known_addons_path, + &state.groups_path, + )?; + let allow_rich_content = + is_known_workshop_id(&state.known_addons_path, &state.groups_path, &workshop_id); + + { + let obj = cache_entry_object(&mut cache, &workshop_id); + persist_workshop_page_details_entry( + obj, + &workshop_id, + &details, + &source, + &now, + allow_rich_content, + ); + } + + if let Some(required_items) = details.get("requiredItems").and_then(|v| v.as_array()) { + for item in required_items { + let Some(child_id) = item.get("workshopId").and_then(|v| v.as_str()) else { + continue; + }; + let child_obj = cache_entry_object(&mut cache, child_id); + insert_non_empty_string(child_obj, "workshopId", child_id); + if let Some(title) = item.get("title").and_then(|v| v.as_str()) { + insert_non_empty_string(child_obj, "title", title); + } + insert_non_empty_string(child_obj, "lastSeenSource", &source); + insert_non_empty_string(child_obj, "lastSeenAt", &now); + } + } + + if let Some(collection_items) = details.get("collectionItems").and_then(|v| v.as_array()) { + for item in collection_items { + let Some(child_id) = item.get("workshopId").and_then(|v| v.as_str()) else { + continue; + }; + let child_obj = cache_entry_object(&mut cache, child_id); + insert_non_empty_string(child_obj, "workshopId", child_id); + if let Some(title) = item.get("title").and_then(|v| v.as_str()) { + insert_non_empty_string(child_obj, "title", title); + } + if let Some(author_name) = item.get("authorName").and_then(|v| v.as_str()) { + let author_ids = [ + item.get("authorSteamId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + item.get("authorAccountId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + item.get("authorVanityId") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + ]; + insert_author_name_if_useful(child_obj, author_name, &author_ids); + } + if let Some(author_url) = item.get("authorUrl").and_then(|v| v.as_str()) { + insert_non_empty_string(child_obj, "creatorProfileUrl", author_url); + insert_non_empty_string(child_obj, "authorUrl", author_url); + } + if let Some(author_steam_id) = item.get("authorSteamId").and_then(|v| v.as_str()) { + insert_non_empty_string(child_obj, "creatorSteamId", author_steam_id); + insert_non_empty_string(child_obj, "creatorId", author_steam_id); + } + if let Some(author_vanity_id) = item.get("authorVanityId").and_then(|v| v.as_str()) { + insert_non_empty_string(child_obj, "creatorVanityId", author_vanity_id); + } + insert_non_empty_string(child_obj, "lastSeenSource", &source); + insert_non_empty_string(child_obj, "lastSeenAt", &now); + } + } + + if let Some(parent_collections) = details.get("parentCollections").and_then(|v| v.as_array()) { + for item in parent_collections { + let Some(collection_id) = item.get("workshopId").and_then(|v| v.as_str()) else { + continue; + }; + let collection_obj = cache_entry_object(&mut cache, collection_id); + insert_non_empty_string(collection_obj, "workshopId", collection_id); + if let Some(title) = item.get("title").and_then(|v| v.as_str()) { + insert_non_empty_string(collection_obj, "title", title); + } + insert_non_empty_string(collection_obj, "lastSeenSource", &source); + insert_non_empty_string(collection_obj, "lastSeenAt", &now); + } + } + + merge_known_addon_snapshots_into_cache(&mut cache, &state.known_addons_path); + propagate_author_names(&mut cache); + save_workshop_cache(&state.workshop_cache_path, &cache)?; + drop(cache_write_guard); + let db = state.db.lock().await; + Ok(database_with_workshop_cache( + &db, + &state.workshop_cache_path, + &state.known_addons_path, + )) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs new file mode 100644 index 0000000..097923f --- /dev/null +++ b/src-tauri/src/commands/mod.rs @@ -0,0 +1,14 @@ +pub mod handlers; +mod types; + +pub use handlers::{ + add_known_addon, append_workshop_crawl_log, cache_remote_image, cancel_download, delete_addons, + download_addon, fetch_collection, fetch_workshop_html, get_addons, get_background_tasks, + get_cache_image, get_settings, get_workshop_cache, get_workshop_capabilities, group_action, + move_addons, open_url, open_workshop, persist_workshop_page_details, query_workshop_collection, + query_workshop_details, query_workshop_home, query_workshop_item, query_workshop_items, + record_workshop_items_seen, rename_addon, rename_addons, save_background_task_snapshot, + save_settings, steam_sync, toggle_addons, +}; +pub use handlers::{load_db, save_db_internal}; +pub use types::*; diff --git a/src-tauri/src/commands/types.rs b/src-tauri/src/commands/types.rs new file mode 100644 index 0000000..04e52b2 --- /dev/null +++ b/src-tauri/src/commands/types.rs @@ -0,0 +1,388 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct Settings { + #[serde(rename = "workshopDir")] + pub workshop_dir: String, + #[serde(rename = "loadingDir")] + pub loading_dir: String, + #[serde( + rename = "downloadConcurrency", + default = "default_download_concurrency" + )] + pub download_concurrency: u32, + #[serde(rename = "enableDummyBypass", default)] + pub enable_dummy_bypass: bool, + #[serde(rename = "suppressSdkUnavailableWarning", default)] + pub suppress_sdk_unavailable_warning: bool, + #[serde(rename = "disableSteamworksSdk", default)] + pub disable_steamworks_sdk: bool, + #[serde(rename = "forceSteamworksSdkDownload", default)] + pub force_steamworks_sdk_download: bool, + #[serde( + rename = "maxDownloadRetries", + default = "default_max_download_retries" + )] + pub max_download_retries: u32, + #[serde( + rename = "dependencyMissingBehavior", + default = "default_dependency_missing_behavior" + )] + pub dependency_missing_behavior: String, + #[serde(rename = "workshopSourceSettings", default)] + pub workshop_source_settings: WorkshopSourceSettings, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopSourceSettings { + #[serde(default = "default_workshop_source_preset")] + pub preset: String, + #[serde(default = "default_true")] + pub allow_steamworks_sdk: bool, + #[serde(default = "default_true")] + pub allow_steam_web_api: bool, + #[serde(default = "default_true")] + pub allow_steam_community_html: bool, + #[serde(default)] + pub allow_sdk_html_hybrid: bool, + #[serde(rename = "sdkHtmlScope", default = "default_sdk_html_scope")] + pub sdk_html_scope: String, + #[serde( + rename = "dependencySdkRefresh", + default = "default_dependency_sdk_refresh" + )] + pub dependency_sdk_refresh: String, + #[serde( + rename = "dependencyHtmlRefresh", + default = "default_dependency_html_refresh" + )] + pub dependency_html_refresh: String, + #[serde(default = "default_source_order")] + pub source_order: Vec, + #[serde(default = "default_cache_retention")] + pub cache_retention: String, +} + +fn default_true() -> bool { + true +} + +fn default_download_concurrency() -> u32 { + 2 +} + +fn default_max_download_retries() -> u32 { + 3 +} + +pub fn default_dependency_missing_behavior() -> String { + "ask".to_string() +} + +fn default_workshop_source_preset() -> String { + "conservative".to_string() +} + +fn default_source_order() -> Vec { + vec![ + "steamworks-sdk".to_string(), + "steam-web-api".to_string(), + "steamcommunity-html".to_string(), + ] +} + +fn default_sdk_html_scope() -> String { + "search".to_string() +} + +fn default_dependency_sdk_refresh() -> String { + "always".to_string() +} + +fn default_dependency_html_refresh() -> String { + "cache-missing".to_string() +} + +fn default_cache_retention() -> String { + "keep".to_string() +} + +impl Default for WorkshopSourceSettings { + fn default() -> Self { + Self { + preset: default_workshop_source_preset(), + allow_steamworks_sdk: true, + allow_steam_web_api: true, + allow_steam_community_html: true, + allow_sdk_html_hybrid: false, + sdk_html_scope: default_sdk_html_scope(), + dependency_sdk_refresh: default_dependency_sdk_refresh(), + dependency_html_refresh: default_dependency_html_refresh(), + source_order: default_source_order(), + cache_retention: default_cache_retention(), + } + } +} + +#[cfg(test)] +mod tests { + use super::{is_dummy_addon_info, Settings}; + use serde_json::json; + + #[test] + fn dependency_missing_behavior_defaults_and_serializes() { + let mut settings: Settings = serde_json::from_value(serde_json::json!({ + "workshopDir": "workshop", + "loadingDir": "loading" + })) + .unwrap(); + + assert_eq!(settings.dependency_missing_behavior, "ask"); + + settings.dependency_missing_behavior = "always".to_string(); + assert_eq!( + serde_json::to_value(settings) + .unwrap() + .get("dependencyMissingBehavior") + .and_then(|value| value.as_str()), + Some("always"), + ); + } + + #[test] + fn recognizes_new_and_legacy_dummy_markers() { + assert!(is_dummy_addon_info(&json!({ "l4adummy": "1" }))); + assert!(is_dummy_addon_info(&json!({ + "addonDescription": "A dummy addon generated by Left 4 Addons" + }))); + assert!(!is_dummy_addon_info(&json!({ "l4adummy": "0" }))); + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Group { + #[serde(default)] + pub id: String, + pub name: String, + pub addons: Vec, + pub tags: Option>, + #[serde(rename = "workshopCollectionId")] + pub workshop_collection_id: Option, + #[serde(rename = "masterCollectionIds", default)] + pub master_collection_ids: Option>, + #[serde(default)] + pub source: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct MasterCollection { + #[serde(default)] + pub id: String, + pub name: String, + #[serde(rename = "nameKey", default)] + pub name_key: Option, + #[serde(rename = "groupIds", default)] + pub group_ids: Vec, + #[serde(rename = "isSystem", default)] + pub is_system: bool, + #[serde(default)] + pub icon: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Addon { + #[serde(default)] + pub id: String, + #[serde(rename = "vpkName")] + pub vpk_name: String, + #[serde(rename = "workshopId")] + pub workshop_id: Option, + #[serde(rename = "workshopIdCandidate", default)] + pub workshop_id_candidate: Option, + #[serde(rename = "workshopIdSource", default)] + pub workshop_id_source: Option, + #[serde(rename = "workshopIdValidationStatus", default)] + pub workshop_id_validation_status: Option, + #[serde(rename = "workshopIdLastAttemptAt", default)] + pub workshop_id_last_attempt_at: Option, + #[serde(rename = "addonInfo")] + pub addon_info: serde_json::Value, + #[serde(rename = "hasImage")] + pub has_image: bool, + #[serde(rename = "imagePath")] + pub image_path: Option, + #[serde(rename = "filesCount")] + pub files_count: usize, + #[serde(rename = "fileSize")] + pub file_size: u64, + #[serde(rename = "parsedAt")] + pub parsed_at: String, + #[serde(rename = "currentPath")] + pub current_path: String, + #[serde(rename = "dirType")] + pub dir_type: String, + #[serde(rename = "isEnabled")] + pub is_enabled: bool, + #[serde(rename = "steamDetails")] + pub steam_details: Option, + #[serde(rename = "workshopDetails", default)] + pub workshop_details: Option, + #[serde(rename = "isDummy", default)] + pub is_dummy: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct LocalAddon { + #[serde(default)] + pub id: String, + #[serde(rename = "vpkName")] + pub vpk_name: String, + #[serde(rename = "workshopId")] + pub workshop_id: Option, + #[serde(rename = "workshopIdCandidate", default)] + pub workshop_id_candidate: Option, + #[serde(rename = "workshopIdSource", default)] + pub workshop_id_source: Option, + #[serde(rename = "workshopIdValidationStatus", default)] + pub workshop_id_validation_status: Option, + #[serde(rename = "workshopIdLastAttemptAt", default)] + pub workshop_id_last_attempt_at: Option, + #[serde(rename = "filesCount")] + pub files_count: usize, + #[serde(rename = "fileSize")] + pub file_size: u64, + #[serde(rename = "parsedAt")] + pub parsed_at: String, + #[serde(rename = "currentPath")] + pub current_path: String, + #[serde(rename = "dirType")] + pub dir_type: String, + #[serde(rename = "isEnabled")] + pub is_enabled: bool, + #[serde(rename = "isDummy", default)] + pub is_dummy: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct KnownAddonEntry { + #[serde(default)] + pub id: String, + #[serde(rename = "vpkName")] + pub vpk_name: String, + #[serde(rename = "workshopId")] + pub workshop_id: Option, + #[serde(rename = "workshopIdCandidate", default)] + pub workshop_id_candidate: Option, + #[serde(rename = "workshopIdSource", default)] + pub workshop_id_source: Option, + #[serde(rename = "workshopIdValidationStatus", default)] + pub workshop_id_validation_status: Option, + #[serde(rename = "workshopIdLastAttemptAt", default)] + pub workshop_id_last_attempt_at: Option, + #[serde(rename = "addonInfo")] + pub addon_info: serde_json::Value, + #[serde(rename = "hasImage")] + pub has_image: bool, + #[serde(rename = "imagePath")] + pub image_path: Option, + #[serde(rename = "steamDetails")] + pub steam_details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct WorkshopSeenItem { + #[serde(rename = "workshopId")] + pub workshop_id: String, + #[serde(default)] + pub title: String, + #[serde(rename = "imagePath", default)] + pub image_path: String, + #[serde(rename = "authorName", default)] + pub author_name: String, + #[serde(rename = "authorId", default)] + pub author_id: String, + #[serde(rename = "authorUrl", default)] + pub author_url: String, + #[serde(rename = "authorSteamId", default)] + pub author_steam_id: Option, + #[serde(rename = "authorVanityId", default)] + pub author_vanity_id: Option, + #[serde(rename = "authorAccountId", default)] + pub author_account_id: Option, + #[serde(rename = "shortDescription", default)] + pub short_description: Option, + #[serde(rename = "fileSize", default)] + pub file_size: Option, + #[serde(default)] + pub tags: Option>, + #[serde(default)] + pub subscriptions: Option, + #[serde(default)] + pub favorites: Option, + #[serde(rename = "lifetimeSubscriptions", default)] + pub lifetime_subscriptions: Option, + #[serde(rename = "lifetimeFavorites", default)] + pub lifetime_favorites: Option, + #[serde(default)] + pub views: Option, + #[serde(default)] + pub comments: Option, + #[serde(rename = "totalVotes", default)] + pub total_votes: Option, + #[serde(rename = "timeCreated", default)] + pub time_created: Option, + #[serde(rename = "timeUpdated", default)] + pub time_updated: Option, + #[serde(rename = "childCount", default)] + pub child_count: Option, + #[serde(rename = "previewCount", default)] + pub preview_count: Option, + #[serde(rename = "childItemIds", default)] + pub child_item_ids: Option>, + #[serde(rename = "galleryPreviewUrls", default)] + pub gallery_preview_urls: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct SettingsStore { + pub settings: Settings, + #[serde(rename = "masterCollections", default)] + pub master_collections: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] +pub struct Database { + pub settings: Settings, + pub addons: HashMap, + pub groups: Vec, + #[serde(rename = "knownUninstalledAddons")] + pub known_uninstalled_addons: HashMap, + #[serde(rename = "masterCollections", default)] + pub master_collections: Vec, +} + +#[derive(Deserialize)] +pub struct RenameItem { + pub id: String, + #[serde(rename = "newVpkName")] + pub new_vpk_name: String, +} + +pub(crate) fn is_dummy_addon_info(addon_info: &serde_json::Value) -> bool { + let has_marker = addon_info + .get("l4adummy") + .or_else(|| addon_info.get("l4aDummy")) + .and_then(|v| v.as_str()) + .map(|v| v.trim() == "1") + .unwrap_or(false); + let has_legacy_description = addon_info + .get("addondescription") + .or_else(|| addon_info.get("addonDescription")) + .and_then(|v| v.as_str()) + .map(|v| v == "A dummy addon generated by Left 4 Addons") + .unwrap_or(false); + + has_marker || has_legacy_description +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b1e4ea6..0d91f0a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,21 +1,43 @@ -use std::path::PathBuf; -use tauri::Manager; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Mutex as StdMutex; use tauri::async_runtime::Mutex; +use tauri::Manager; -pub mod vpk; pub mod commands; +pub mod mirrors; +pub mod steam; +pub mod vpk; +pub mod watcher; pub struct AppState { - pub db_path: PathBuf, + pub settings_path: PathBuf, + pub groups_path: PathBuf, + pub known_addons_path: PathBuf, + pub workshop_cache_path: PathBuf, + pub workshop_crawl_log_path: PathBuf, + pub background_tasks_path: PathBuf, pub cache_dir: PathBuf, + pub download_cache_dir: PathBuf, + pub workshop_service: steam::WorkshopService, pub db: Mutex, + pub workshop_cache_write_lock: StdMutex<()>, + pub addon_watcher: StdMutex, + pub cancelled_downloads: StdMutex>, + pub runtime_dir: PathBuf, +} + +impl Drop for AppState { + fn drop(&mut self) { + self.workshop_service.shutdown(); + } } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - tauri::Builder::default() + let app = tauri::Builder::default() .setup(|app| { - let runtime_dir = std::env::current_exe() + let mut host_runtime_dir = std::env::current_exe() .ok() .and_then(|p| { p.parent().map(|d| { @@ -29,28 +51,76 @@ pub fn run() { }) .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - if !runtime_dir.exists() { - let _ = std::fs::create_dir_all(&runtime_dir); + if cfg!(debug_assertions) && host_runtime_dir.ends_with("src-tauri") { + if let Some(parent) = host_runtime_dir.parent() { + host_runtime_dir = parent.to_path_buf(); + } } - if let Some(app_data_dir) = app.path().app_data_dir().ok() { - migrate_data(&app_data_dir, &runtime_dir); - } + let runtime_dir = if host_runtime_dir.ends_with("l4a") { + host_runtime_dir.clone() + } else { + host_runtime_dir.join("l4a") + }; + crate::mirrors::RUNTIME_DIR.set(runtime_dir.clone()).ok(); + let config_dir = runtime_dir.join("config"); + let cache_root_dir = runtime_dir.join("cache"); + let cache_dir = cache_root_dir.join("images"); + let download_cache_dir = cache_root_dir.join("downloading"); - let db_path = runtime_dir.join("db.json"); - let cache_dir = runtime_dir.join("cache"); - if !cache_dir.exists() { - let _ = std::fs::create_dir_all(&cache_dir); - } + let _ = std::fs::create_dir_all(&config_dir); + let _ = std::fs::create_dir_all(&cache_root_dir); + let _ = std::fs::create_dir_all(&cache_dir); + let _ = std::fs::create_dir_all(&download_cache_dir); + + migrate_data( + app.path().app_data_dir().ok().as_deref(), + &host_runtime_dir, + &runtime_dir, + ); + + let settings_path = config_dir.join("settings.json"); + let groups_path = config_dir.join("groups.json"); + let known_addons_path = config_dir.join("known_addons.json"); + let workshop_cache_path = cache_root_dir.join("workshop_cache.json"); + let workshop_crawl_log_path = cache_root_dir.join("workshop_crawl_log.jsonl"); + let background_tasks_path = cache_root_dir.join("background_tasks.json"); + let bridge_base_dir = std::env::current_exe() + .ok() + .and_then(|path| path.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| host_runtime_dir.clone()); + let db = commands::load_db( + &settings_path, + &groups_path, + &known_addons_path, + &runtime_dir, + app.handle(), + ); + let initial_loading_dir = db.settings.loading_dir.clone(); - let db = commands::load_db(&db_path, app.handle()); - app.manage(AppState { - db_path, + settings_path, + groups_path, + known_addons_path, + workshop_cache_path, + workshop_crawl_log_path, + background_tasks_path, cache_dir, + download_cache_dir, + workshop_service: steam::WorkshopService::new(&bridge_base_dir), db: Mutex::new(db), + workshop_cache_write_lock: StdMutex::new(()), + addon_watcher: StdMutex::new(watcher::AddonWatcherController::default()), + cancelled_downloads: StdMutex::new(HashSet::new()), + runtime_dir: runtime_dir.clone(), }); + if let Err(err) = + watcher::rebind_addon_watcher(app.handle(), Path::new(&initial_loading_dir)) + { + watcher::emit_watch_error(app.handle(), &err); + } + if cfg!(debug_assertions) { app.handle().plugin( tauri_plugin_log::Builder::default() @@ -60,92 +130,230 @@ pub fn run() { } Ok(()) }) - .register_uri_scheme_protocol("cache", |ctx, request| { - let uri = request.uri(); - let path = uri.path(); - let filename = path.trim_start_matches('/'); - let decoded_filename = url_decode(filename); - - let state = ctx.app_handle().state::(); - - let file_path = state.cache_dir.join(decoded_filename); - - let response = if file_path.exists() { - if let Ok(bytes) = std::fs::read(&file_path) { - tauri::http::Response::builder() - .header("content-type", "image/jpeg") - .header("access-control-allow-origin", "*") - .body(bytes) - } else { - tauri::http::Response::builder() - .status(500) - .body(Vec::new()) - } - } else { - tauri::http::Response::builder() - .status(404) - .body(Vec::new()) - }; - - response.unwrap() - - }) .invoke_handler(tauri::generate_handler![ - commands::get_settings, - commands::save_settings, - commands::get_addons, - commands::move_addons, - commands::toggle_addons, - commands::rename_addon, - commands::rename_addons, - commands::group_action, - commands::open_workshop, - commands::open_url, - commands::steam_sync, + commands::handlers::settings::get_settings, + commands::handlers::settings::save_settings, + commands::handlers::db::get_addons, + commands::handlers::cache::get_cache_image, + commands::handlers::cache::cache_remote_image, + commands::handlers::addons::move_addons, + commands::handlers::addons::toggle_addons, + commands::handlers::addons::rename_addon, + commands::handlers::addons::rename_addons, + commands::handlers::addons::group_action, + commands::handlers::workshop::open_workshop, + commands::handlers::workshop::open_url, + commands::handlers::workshop::steam_sync, + commands::handlers::workshop::get_workshop_capabilities, + commands::handlers::workshop::query_workshop_home, + commands::handlers::workshop::query_workshop_items, + commands::handlers::workshop::query_workshop_item, + commands::handlers::workshop::query_workshop_details, + commands::handlers::workshop::query_workshop_collection, + commands::handlers::workshop::fetch_workshop_html, + commands::handlers::addons::delete_addons, + commands::handlers::addons::download_addon, + commands::handlers::workshop::fetch_collection, + commands::handlers::workshop::add_known_addon, + commands::handlers::workshop::get_workshop_cache, + commands::handlers::workshop::record_workshop_items_seen, + commands::handlers::workshop::persist_workshop_page_details, + commands::handlers::tasks::get_background_tasks, + commands::handlers::tasks::save_background_task_snapshot, + commands::handlers::tasks::cancel_download, + commands::handlers::tasks::append_workshop_crawl_log, ]) - .run(tauri::generate_context!()) - .expect("error while running tauri application"); + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + app.run(|app_handle, event| { + if matches!( + event, + tauri::RunEvent::Exit | tauri::RunEvent::ExitRequested { .. } + ) { + app_handle.state::().workshop_service.shutdown(); + } + }); } +fn migrate_data(app_data_dir: Option<&Path>, legacy_runtime_dir: &Path, runtime_dir: &Path) { + if let Some(app_data_dir) = app_data_dir { + if app_data_dir != runtime_dir { + migrate_from_root(app_data_dir, runtime_dir); + } + } + if legacy_runtime_dir != runtime_dir { + migrate_from_root(legacy_runtime_dir, runtime_dir); + } +} -fn migrate_data(app_data_dir: &std::path::Path, runtime_dir: &std::path::Path) { - if app_data_dir == runtime_dir { +fn migrate_from_root(src_root: &Path, runtime_dir: &Path) { + if src_root == runtime_dir { return; } - let old_db = app_data_dir.join("db.json"); - let new_db = runtime_dir.join("db.json"); + let target_config_dir = runtime_dir.join("config"); + let target_cache_dir = runtime_dir.join("cache"); + let target_image_cache_dir = target_cache_dir.join("images"); + let target_loading_dir = runtime_dir.join("addons-loading"); - if !new_db.exists() && old_db.exists() { - if std::fs::copy(&old_db, &new_db).is_ok() { - let _ = std::fs::remove_file(&old_db); + let _ = std::fs::create_dir_all(&target_config_dir); + let _ = std::fs::create_dir_all(&target_cache_dir); + + migrate_legacy_db_file( + &src_root.join("db.json"), + &target_config_dir.join("settings.json"), + &target_config_dir.join("groups.json"), + &target_config_dir.join("known_addons.json"), + ); + move_file_if_missing( + &src_root.join("settings.json"), + &target_config_dir.join("settings.json"), + ); + move_file_if_missing( + &src_root.join("groups.json"), + &target_config_dir.join("groups.json"), + ); + move_file_if_missing( + &src_root.join("known_addons.json"), + &target_config_dir.join("known_addons.json"), + ); + move_file_if_missing( + &src_root.join("workshop_cache.json"), + &target_cache_dir.join("workshop_cache.json"), + ); + move_file_if_missing( + &src_root.join("background_tasks.json"), + &target_cache_dir.join("background_tasks.json"), + ); + move_file_if_missing( + &src_root.join("workshop_crawl_log.jsonl"), + &target_cache_dir.join("workshop_crawl_log.jsonl"), + ); + + let old_cache = src_root.join("cache"); + if old_cache.exists() { + let _ = move_or_copy_dir(&old_cache, &target_image_cache_dir); + } + + let old_loading = src_root.join("addons-loading"); + if old_loading.exists() { + let _ = move_or_copy_dir(&old_loading, &target_loading_dir); + } +} + +fn move_file_if_missing(src: &Path, dst: &Path) { + if !src.exists() || dst.exists() { + return; + } + + if let Some(parent) = dst.parent() { + let _ = std::fs::create_dir_all(parent); + } + + if std::fs::rename(src, dst).is_ok() { + return; + } + + if std::fs::copy(src, dst).is_ok() { + let _ = std::fs::remove_file(src); + } +} + +fn migrate_legacy_db_file( + legacy_db_path: &Path, + settings_path: &Path, + groups_path: &Path, + known_addons_path: &Path, +) { + if !legacy_db_path.exists() { + return; + } + + let Ok(content) = std::fs::read_to_string(legacy_db_path) else { + return; + }; + let Ok(value) = serde_json::from_str::(&content) else { + return; + }; + + if !settings_path.exists() { + let settings = commands::SettingsStore { + settings: serde_json::from_value(value.get("settings").cloned().unwrap_or_default()) + .unwrap_or_default(), + master_collections: serde_json::from_value( + value.get("masterCollections").cloned().unwrap_or_default(), + ) + .unwrap_or_default(), + }; + if let Ok(json) = serde_json::to_string_pretty(&settings) { + let _ = std::fs::write(settings_path, json); + } + } + + if !groups_path.exists() { + let groups: Vec = + serde_json::from_value(value.get("groups").cloned().unwrap_or_default()) + .unwrap_or_default(); + if let Ok(json) = serde_json::to_string_pretty(&groups) { + let _ = std::fs::write(groups_path, json); } } - let old_cache = app_data_dir.join("cache"); - let new_cache = runtime_dir.join("cache"); - if !new_cache.exists() && old_cache.exists() { - let _ = move_or_copy_dir(&old_cache, &new_cache); + if !known_addons_path.exists() { + let mut known_addons = std::collections::HashMap::new(); + if let Some(addons) = value.get("addons").and_then(|v| v.as_object()) { + for item in addons.values() { + if let Ok(mut addon) = serde_json::from_value::(item.clone()) { + if addon.id.is_empty() { + addon.id = addon + .workshop_id + .clone() + .unwrap_or_else(|| addon.vpk_name.clone()); + } + if addon.is_dummy || commands::is_dummy_addon_info(&addon.addon_info) { + continue; + } + let id = addon.id.clone(); + known_addons.insert( + id.clone(), + commands::KnownAddonEntry { + id, + vpk_name: addon.vpk_name.clone(), + workshop_id: addon.workshop_id.clone(), + workshop_id_candidate: addon.workshop_id_candidate, + workshop_id_source: addon.workshop_id_source, + workshop_id_validation_status: addon.workshop_id_validation_status, + workshop_id_last_attempt_at: addon.workshop_id_last_attempt_at, + addon_info: addon.addon_info, + has_image: addon.has_image, + image_path: addon.image_path, + steam_details: addon.steam_details, + }, + ); + } + } + } + if let Ok(json) = serde_json::to_string_pretty(&known_addons) { + let _ = std::fs::write(known_addons_path, json); + } } - let old_loading = app_data_dir.join("addons-loading"); - let new_loading = runtime_dir.join("addons-loading"); - if !new_loading.exists() && old_loading.exists() { - let _ = move_or_copy_dir(&old_loading, &new_loading); + if settings_path.exists() && groups_path.exists() && known_addons_path.exists() { + let _ = std::fs::remove_file(legacy_db_path); } } -fn move_or_copy_dir(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { +fn move_or_copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> { if !src.exists() { return Ok(()); } - if dst.exists() { - return Ok(()); - } - if std::fs::rename(src, dst).is_ok() { + + if !dst.exists() && std::fs::rename(src, dst).is_ok() { return Ok(()); } + std::fs::create_dir_all(dst)?; for entry in std::fs::read_dir(src)? { let entry = entry?; @@ -154,40 +362,10 @@ fn move_or_copy_dir(src: &std::path::Path, dst: &std::path::Path) -> std::io::Re let dst_path = dst.join(entry.file_name()); if file_type.is_dir() { move_or_copy_dir(&src_path, &dst_path)?; - } else { + } else if !dst_path.exists() { std::fs::copy(&src_path, &dst_path)?; } } let _ = std::fs::remove_dir_all(src); Ok(()) } - -fn url_decode(input: &str) -> String { - let mut bytes = Vec::with_capacity(input.len()); - let mut chars = input.chars().peekable(); - while let Some(c) = chars.next() { - if c == '%' { - let mut hex = String::new(); - if let Some(&h1) = chars.peek() { - hex.push(h1); - chars.next(); - } - if let Some(&h2) = chars.peek() { - hex.push(h2); - chars.next(); - } - if hex.len() == 2 { - if let Ok(byte) = u8::from_str_radix(&hex, 16) { - bytes.push(byte); - continue; - } - } - bytes.push(b'%'); - bytes.extend_from_slice(hex.as_bytes()); - } else { - let mut buf = [0; 4]; - bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); - } - } - String::from_utf8_lossy(&bytes).into_owned() -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ad5fe83..69c3a72 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::run(); + app_lib::run(); } diff --git a/src-tauri/src/mirrors.rs b/src-tauri/src/mirrors.rs new file mode 100644 index 0000000..08b8ecc --- /dev/null +++ b/src-tauri/src/mirrors.rs @@ -0,0 +1,286 @@ +use regex::Regex; +use reqwest::{Client, RequestBuilder}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::OnceLock; + +pub static RUNTIME_DIR: OnceLock = OnceLock::new(); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MirrorRule { + #[serde(default)] + pub rules: Option, + #[serde(default)] + pub domain: Option, + #[serde(default)] + pub subdomains: Option>, + #[serde(rename = "allow-www", default)] + pub allow_www: bool, + #[serde(default)] + pub regex: Option, + pub target: String, + #[serde(rename = "keep-host", default)] + pub keep_host: bool, + #[serde(default)] + pub headers: Option>, +} + +pub struct MirrorManager; + +impl MirrorManager { + pub fn resolve(url: &str) -> (String, Option, Option>) { + let rules = RUNTIME_DIR + .get() + .and_then(|runtime_dir| fs::read_to_string(runtime_dir.join("mirrors.json")).ok()) + .and_then(|content| serde_json::from_str::>(&content).ok()) + .unwrap_or_default(); + + resolve_with_rules(url, &rules) + } +} + +fn resolve_with_rules( + url: &str, + rules: &[MirrorRule], +) -> (String, Option, Option>) { + if rules.is_empty() { + return (url.to_string(), None, None); + } + + let Ok(mut parsed_url) = reqwest::Url::parse(url) else { + return (url.to_string(), None, None); + }; + let Some(host) = parsed_url + .host_str() + .map(|hostname| match parsed_url.port() { + Some(port) => format!("{hostname}:{port}"), + None => hostname.to_string(), + }) + else { + return (url.to_string(), None, None); + }; + + for rule in rules { + let Some(pattern) = rule_pattern(rule) else { + continue; + }; + let Ok(regex) = Regex::new(&pattern) else { + continue; + }; + let Some(captures) = regex.captures(&host) else { + continue; + }; + + let target = replace_captures(&rule.target, &captures); + if set_target_host(&mut parsed_url, &target) { + return ( + parsed_url.to_string(), + rule.keep_host.then(|| host.clone()), + rule.headers.clone(), + ); + } + } + + (url.to_string(), None, None) +} + +fn rule_pattern(rule: &MirrorRule) -> Option { + if let Some(regex) = &rule.regex { + return Some(regex.clone()); + } + + if let Some(domain) = &rule.domain { + let mut pattern = String::from("^"); + if rule.allow_www { + pattern.push_str(r"(?:www\.)?"); + } + if let Some(subdomains) = &rule.subdomains { + if subdomains.len() == 1 && subdomains[0] == "*" { + pattern.push_str(r"(.*)\."); + } else if !subdomains.is_empty() { + let alternatives = subdomains + .iter() + .map(|subdomain| regex::escape(subdomain)) + .collect::>(); + pattern.push_str(&format!("({})\\.", alternatives.join("|"))); + } + } + pattern.push_str(®ex::escape(domain)); + pattern.push('$'); + return Some(pattern); + } + + rule.rules.as_deref().map(string_rule_pattern) +} + +fn string_rule_pattern(rule: &str) -> String { + let mut pattern = String::new(); + let mut in_group = false; + let mut group = String::new(); + let chars: Vec = rule.chars().collect(); + let mut index = 0; + + while index < chars.len() { + match chars[index] { + '[' => { + in_group = true; + pattern.push('('); + } + ']' => { + in_group = false; + pattern.push_str( + &group + .split(',') + .map(regex::escape) + .collect::>() + .join("|"), + ); + group.clear(); + pattern.push(')'); + if chars.get(index + 1) == Some(&'?') { + pattern.push('?'); + index += 1; + } + } + character if in_group => group.push(character), + '*' => pattern.push_str("(.*?)"), + character => pattern.push_str(®ex::escape(&character.to_string())), + } + index += 1; + } + + format!("^{pattern}$") +} + +fn replace_captures(target: &str, captures: ®ex::Captures<'_>) -> String { + let mut replaced = target.to_string(); + for (index, capture) in captures.iter().enumerate().skip(1) { + if let Some(capture) = capture { + replaced = replaced.replace(&format!("${index}"), capture.as_str()); + } + } + if replaced.contains('*') { + let first_capture = captures.get(1).map_or("", |capture| capture.as_str()); + replaced = replaced.replace('*', first_capture); + } + replaced +} + +fn set_target_host(url: &mut reqwest::Url, target: &str) -> bool { + let Ok(target_url) = reqwest::Url::parse(&format!("http://{target}")) else { + return false; + }; + let Some(target_host) = target_url.host_str() else { + return false; + }; + if url.set_host(Some(target_host)).is_err() { + return false; + } + if let Some(port) = target_url.port() { + if url.set_port(Some(port)).is_err() { + return false; + } + } + true +} + +pub trait MirrorClientExt { + fn get_mirrored(&self, url: &str) -> RequestBuilder; + fn post_mirrored(&self, url: &str) -> RequestBuilder; +} + +impl MirrorClientExt for Client { + fn get_mirrored(&self, url: &str) -> RequestBuilder { + let (resolved_url, original_host, headers) = MirrorManager::resolve(url); + mirrored_request(self.get(resolved_url), original_host, headers) + } + + fn post_mirrored(&self, url: &str) -> RequestBuilder { + let (resolved_url, original_host, headers) = MirrorManager::resolve(url); + mirrored_request(self.post(resolved_url), original_host, headers) + } +} + +fn mirrored_request( + request: RequestBuilder, + original_host: Option, + headers: Option>, +) -> RequestBuilder { + let mut request = request; + if let Some(host) = original_host { + request = request.header(reqwest::header::HOST, host); + } + if let Some(headers) = headers { + for (key, value) in headers { + request = request.header(key, value); + } + } + request +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rule(target: &str) -> MirrorRule { + MirrorRule { + rules: None, + domain: None, + subdomains: None, + allow_www: false, + regex: None, + target: target.to_string(), + keep_host: false, + headers: None, + } + } + + #[test] + fn object_rule_replaces_subdomain_and_preserves_headers() { + let mut rule = rule("$1.mirror.example.com"); + rule.domain = Some("steamstatic.com".to_string()); + rule.subdomains = Some(vec!["store".to_string(), "media".to_string()]); + rule.allow_www = true; + rule.keep_host = true; + rule.headers = Some(HashMap::from([( + "X-Mirror".to_string(), + "enabled".to_string(), + )])); + + let (url, host, headers) = + resolve_with_rules("https://www.media.steamstatic.com/path?q=1", &[rule]); + + assert_eq!(url, "https://media.mirror.example.com/path?q=1"); + assert_eq!(host.as_deref(), Some("www.media.steamstatic.com")); + assert_eq!( + headers.unwrap().get("X-Mirror").map(String::as_str), + Some("enabled") + ); + } + + #[test] + fn string_rule_uses_capture_groups_and_target_port() { + let mut rule = rule("$2.proxy.example.com:8443"); + rule.rules = Some("[www.]?*.steamcommunity.com".to_string()); + + let (url, host, _) = resolve_with_rules( + "https://www.workshop.steamcommunity.com/files?id=42", + &[rule], + ); + + assert_eq!(url, "https://workshop.proxy.example.com:8443/files?id=42"); + assert_eq!(host, None); + } + + #[test] + fn invalid_or_unmatched_rules_leave_url_unchanged() { + let mut invalid = rule("mirror.example.com"); + invalid.regex = Some("[".to_string()); + let source = "https://api.steampowered.com/ISteamNews/GetNewsForApp/v2/"; + + assert_eq!(resolve_with_rules(source, &[invalid]).0, source); + assert_eq!(resolve_with_rules(source, &[]).0, source); + } +} diff --git a/src-tauri/src/steam/bridge.rs b/src-tauri/src/steam/bridge.rs new file mode 100644 index 0000000..3312d58 --- /dev/null +++ b/src-tauri/src/steam/bridge.rs @@ -0,0 +1,260 @@ +use super::types::{BridgeInitResponse, BridgeRequest, BridgeResponse, WorkshopCapabilities}; +use libloading::Library; +use serde::Serialize; +use serde_json::Value; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +type InitFn = unsafe extern "C" fn() -> *mut c_char; +type RequestFn = unsafe extern "C" fn(*const c_char) -> *mut c_char; +type FreeStringFn = unsafe extern "C" fn(*mut c_char); +type ShutdownFn = unsafe extern "C" fn(); + +struct BridgeApi { + _dependencies: Vec, + _library: Library, + init: InitFn, + request_json: RequestFn, + free_string: FreeStringFn, + shutdown: ShutdownFn, +} + +impl Drop for BridgeApi { + fn drop(&mut self) { + unsafe { + (self.shutdown)(); + } + } +} + +#[derive(Clone)] +pub struct WorkshopBridge { + api: Arc, + invocation_gate: Arc>, + capabilities: WorkshopCapabilities, +} + +impl WorkshopBridge { + pub fn load_near(exe_dir: &Path) -> Result { + let bridge_path = bridge_library_path(exe_dir); + if !bridge_path.exists() { + return Err(format!( + "Steam bridge library not found at {}", + bridge_path.display() + )); + } + + unsafe { Self::load_from_path(&bridge_path) } + .map_err(|err| format!("failed to load {}: {}", bridge_path.display(), err)) + } + + unsafe fn load_from_path(path: &Path) -> Result { + let dependencies = load_support_libraries(path)?; + let library = load_library(path)?; + let init = *library + .get::(b"l4a_steam_bridge_init\0") + .map_err(|e| e.to_string())?; + let request_json = *library + .get::(b"l4a_steam_bridge_request_json\0") + .map_err(|e| e.to_string())?; + let free_string = *library + .get::(b"l4a_steam_bridge_free_string\0") + .map_err(|e| e.to_string())?; + let shutdown = *library + .get::(b"l4a_steam_bridge_shutdown\0") + .map_err(|e| e.to_string())?; + + let api = Arc::new(BridgeApi { + _dependencies: dependencies, + _library: library, + init, + request_json, + free_string, + shutdown, + }); + + let init_value = invoke_raw_string(&api, None)?; + let init_response: BridgeInitResponse = + serde_json::from_str(&init_value).map_err(|e| e.to_string())?; + if !init_response.ok { + return Err(init_response + .error + .unwrap_or_else(|| "Steam bridge initialization failed".to_string())); + } + + let capabilities = WorkshopCapabilities { + bridge_available: true, + bridge_loaded: true, + bridge_initialized: true, + provider: "steam-sdk".to_string(), + bridge_version: init_response.version.clone(), + last_error: None, + current_user_steam_id: init_response.current_user_steam_id.clone(), + current_user_account_id: init_response.current_user_account_id.clone(), + can_query_items: true, + can_query_home: true, + can_download: true, + can_enumerate_installed: true, + can_enumerate_subscribed: true, + }; + + Ok(Self { + api, + invocation_gate: Arc::new(Mutex::new(())), + capabilities, + }) + } + + pub fn capabilities(&self) -> WorkshopCapabilities { + self.capabilities.clone() + } + + pub fn call(&self, method: &str, payload: &T) -> Result { + let request = BridgeRequest { + method: method.to_string(), + payload: serde_json::to_value(payload).map_err(|e| e.to_string())?, + }; + let encoded = serde_json::to_string(&request).map_err(|e| e.to_string())?; + let result = { + let _guard = self + .invocation_gate + .lock() + .map_err(|_| "Steam bridge invocation mutex poisoned".to_string())?; + invoke_raw_string(&self.api, Some(&encoded)) + }?; + let response: BridgeResponse = serde_json::from_str(&result).map_err(|e| e.to_string())?; + if response.ok { + Ok(response.payload) + } else { + Err(response + .error + .unwrap_or_else(|| format!("Steam bridge call {} failed", method))) + } + } + + pub fn shutdown(&self) { + if let Ok(_guard) = self.invocation_gate.lock() { + unsafe { + (self.api.shutdown)(); + } + } + } +} + +unsafe fn load_library(path: &Path) -> Result { + #[cfg(target_os = "windows")] + { + use libloading::os::windows::{ + Library as WindowsLibrary, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, + }; + + let flags = LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS; + let library = WindowsLibrary::load_with_flags(path, flags) + .map(Library::from) + .map_err(|e| format_windows_load_error(path, &e.to_string()))?; + return Ok(library); + } + + #[cfg(not(target_os = "windows"))] + { + Library::new(path).map_err(|e| e.to_string()) + } +} + +#[cfg(target_os = "windows")] +fn format_windows_load_error(path: &Path, source: &str) -> String { + let hint = if path + .parent() + .is_some_and(|parent| parent.file_name().is_some_and(|name| name == "steam")) + { + " The bridge DLL was found, but one of its dependent DLLs could not be loaded from the same directory." + } else { + "" + }; + + format!( + "{}. This usually means a dependent DLL is missing or the DLL architecture does not match. Required runtime files include steam_api64.dll, and GNU builds may also require MinGW runtime DLLs such as libgcc_s_seh-1.dll and libwinpthread-1.dll.{}", + source, hint + ) +} + +fn bridge_library_path(exe_dir: &Path) -> PathBuf { + exe_dir.join("steam").join(bridge_library_name()) +} + +fn bridge_library_name() -> &'static str { + if cfg!(target_os = "windows") { + "l4a-steam-bridge.dll" + } else if cfg!(target_os = "macos") { + "libl4a-steam-bridge.dylib" + } else { + "libl4a-steam-bridge.so" + } +} + +#[cfg_attr(target_os = "windows", allow(dead_code))] +fn steam_api_library_name() -> &'static str { + if cfg!(target_os = "windows") { + "steam_api64.dll" + } else if cfg!(target_os = "macos") { + "libsteam_api.dylib" + } else { + "libsteam_api.so" + } +} + +unsafe fn load_support_libraries(path: &Path) -> Result, String> { + #[cfg(target_os = "windows")] + { + let _ = path; + Ok(Vec::new()) + } + + #[cfg(not(target_os = "windows"))] + { + use libloading::os::unix::{Library as UnixLibrary, RTLD_GLOBAL, RTLD_NOW}; + + let parent = path + .parent() + .ok_or_else(|| format!("failed to resolve parent directory for {}", path.display()))?; + let steam_api_path = parent.join(steam_api_library_name()); + if !steam_api_path.exists() { + return Err(format!( + "required Steamworks runtime not found at {}", + steam_api_path.display() + )); + } + + let steam_api = UnixLibrary::open(Some(&steam_api_path), RTLD_NOW | RTLD_GLOBAL) + .map(Library::from) + .map_err(|e| format!("failed to preload {}: {}", steam_api_path.display(), e))?; + + Ok(vec![steam_api]) + } +} + +fn invoke_raw_string(api: &BridgeApi, request: Option<&str>) -> Result { + let ptr = if let Some(request) = request { + let encoded = CString::new(request).map_err(|e| e.to_string())?; + unsafe { (api.request_json)(encoded.as_ptr()) } + } else { + unsafe { (api.init)() } + }; + + if ptr.is_null() { + return Err("Steam bridge returned a null response".to_string()); + } + + let result = unsafe { CStr::from_ptr(ptr) } + .to_string_lossy() + .into_owned(); + + unsafe { + (api.free_string)(ptr); + } + + Ok(result) +} diff --git a/src-tauri/src/steam/mod.rs b/src-tauri/src/steam/mod.rs new file mode 100644 index 0000000..d3526b1 --- /dev/null +++ b/src-tauri/src/steam/mod.rs @@ -0,0 +1,6 @@ +mod bridge; +mod service; +mod types; + +pub use service::{fetch_collection_children_web, fetch_steam_details_web, WorkshopService}; +pub use types::*; diff --git a/src-tauri/src/steam/service.rs b/src-tauri/src/steam/service.rs new file mode 100644 index 0000000..a34854a --- /dev/null +++ b/src-tauri/src/steam/service.rs @@ -0,0 +1,274 @@ +use super::bridge::WorkshopBridge; +use super::types::{ + BridgeDownloadStatus, SubscribedWorkshopItemsResponse, WorkshopBrowseQuery, + WorkshopCapabilities, WorkshopCollectionResponse, WorkshopHomeResponse, WorkshopItemResponse, + WorkshopItemsResponse, +}; +use crate::mirrors::MirrorClientExt; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +#[derive(Default)] +struct WorkshopServiceState { + bridge: Option, + bridge_error: Option, + load_attempted: bool, +} + +#[derive(Default)] +pub struct WorkshopService { + exe_dir: PathBuf, + state: Mutex, +} + +impl WorkshopService { + pub fn new(exe_dir: &Path) -> Self { + Self { + exe_dir: exe_dir.to_path_buf(), + state: Mutex::new(WorkshopServiceState::default()), + } + } + + fn unavailable_capabilities(last_error: Option) -> WorkshopCapabilities { + WorkshopCapabilities { + bridge_available: false, + bridge_loaded: false, + bridge_initialized: false, + provider: "web-fallback".to_string(), + bridge_version: None, + last_error, + current_user_steam_id: None, + current_user_account_id: None, + can_query_items: false, + can_query_home: false, + can_download: false, + can_enumerate_installed: false, + can_enumerate_subscribed: false, + } + } + + fn ensure_bridge(&self) -> Result { + let mut state = self + .state + .lock() + .map_err(|_| "Steam bridge state mutex poisoned".to_string())?; + + if let Some(bridge) = &state.bridge { + return Ok(bridge.clone()); + } + + if state.load_attempted { + return Err(state + .bridge_error + .clone() + .unwrap_or_else(|| "Steam bridge unavailable".to_string())); + } + + match WorkshopBridge::load_near(&self.exe_dir) { + Ok(bridge) => { + state.bridge = Some(bridge.clone()); + state.bridge_error = None; + state.load_attempted = true; + Ok(bridge) + } + Err(err) => { + state.bridge = None; + state.bridge_error = Some(err.clone()); + state.load_attempted = true; + Err(err) + } + } + } + + pub fn capabilities(&self) -> WorkshopCapabilities { + match self.ensure_bridge() { + Ok(bridge) => bridge.capabilities(), + Err(err) => Self::unavailable_capabilities(Some(err)), + } + } + + pub fn has_bridge(&self) -> bool { + self.ensure_bridge().is_ok() + } + + pub fn shutdown(&self) { + if let Ok(mut state) = self.state.lock() { + if let Some(bridge) = state.bridge.take() { + bridge.shutdown(); + } + state.bridge_error = None; + state.load_attempted = false; + } + } + + pub fn bridge_fetch_details(&self, workshop_ids: &[String]) -> Result, String> { + let bridge = self.ensure_bridge()?; + + let payload = bridge.call("get_details", &json!({ "ids": workshop_ids }))?; + serde_json::from_value(payload).map_err(|e| e.to_string()) + } + + pub fn bridge_query_home(&self) -> Result { + let bridge = self.ensure_bridge()?; + + let payload = bridge.call("query_home", &json!({}))?; + serde_json::from_value(payload).map_err(|e| e.to_string()) + } + + pub fn bridge_query_items( + &self, + query: &WorkshopBrowseQuery, + ) -> Result { + let bridge = self.ensure_bridge()?; + + let payload = bridge.call("query_items", query)?; + serde_json::from_value(payload).map_err(|e| e.to_string()) + } + + pub fn bridge_query_item(&self, workshop_id: &str) -> Result { + let bridge = self.ensure_bridge()?; + + let payload = bridge.call("query_item", &json!({ "workshopId": workshop_id }))?; + serde_json::from_value(payload).map_err(|e| e.to_string()) + } + + pub fn bridge_query_collection( + &self, + workshop_id: &str, + ) -> Result { + let bridge = self.ensure_bridge()?; + + let payload = bridge.call("query_collection", &json!({ "workshopId": workshop_id }))?; + serde_json::from_value(payload).map_err(|e| e.to_string()) + } + + pub fn bridge_request_download(&self, workshop_id: &str) -> Result<(), String> { + let bridge = self.ensure_bridge()?; + + let payload = bridge.call("request_download", &json!({ "workshopId": workshop_id }))?; + let accepted = payload + .get("accepted") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if accepted { + Ok(()) + } else { + Err(payload + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Steam bridge rejected download request") + .to_string()) + } + } + + pub fn bridge_download_status( + &self, + workshop_id: &str, + ) -> Result { + let bridge = self.ensure_bridge()?; + + let payload = bridge.call("get_download_status", &json!({ "workshopId": workshop_id }))?; + serde_json::from_value(payload).map_err(|e| e.to_string()) + } + + pub fn bridge_get_subscribed_items(&self) -> Result { + let bridge = self.ensure_bridge()?; + + let payload = bridge.call("get_subscribed_items", &json!({}))?; + serde_json::from_value(payload).map_err(|e| e.to_string()) + } + + pub fn bridge_get_favorited_collections(&self) -> Result { + let bridge = self.ensure_bridge()?; + + let payload = bridge.call("get_favorited_collections", &json!({}))?; + serde_json::from_value(payload).map_err(|e| e.to_string()) + } +} + +pub async fn fetch_steam_details_web(workshop_ids: &[String]) -> Result, String> { + if workshop_ids.is_empty() { + return Ok(Vec::new()); + } + + let url = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"; + let mut params = Vec::new(); + params.push(("itemcount".to_string(), workshop_ids.len().to_string())); + for (index, id) in workshop_ids.iter().enumerate() { + params.push((format!("publishedfileids[{}]", index), id.clone())); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let res = client + .post_mirrored(url) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Failed to send Steam API request: {}", e))?; + + if !res.status().is_success() { + return Err(format!("Steam API responded with status {}", res.status())); + } + + let json: Value = res + .json() + .await + .map_err(|e| format!("Failed to parse Steam API response: {}", e))?; + + Ok(json["response"]["publishedfiledetails"] + .as_array() + .cloned() + .unwrap_or_default()) +} + +pub async fn fetch_collection_children_web(collection_id: &str) -> Result, String> { + let url = "https://api.steampowered.com/ISteamRemoteStorage/GetCollectionDetails/v1/"; + let params = vec![ + ("collectioncount".to_string(), "1".to_string()), + ("publishedfileids[0]".to_string(), collection_id.to_string()), + ]; + + let client = reqwest::Client::new(); + let res = client + .post_mirrored(url) + .form(¶ms) + .send() + .await + .map_err(|e| format!("Failed to query collection details: {}", e))?; + + if !res.status().is_success() { + return Err(format!( + "Steam Collection API responded with status {}", + res.status() + )); + } + + let json: Value = res + .json() + .await + .map_err(|e| format!("Failed to parse collection JSON: {}", e))?; + + let details_list = json["response"]["collectiondetails"] + .as_array() + .ok_or_else(|| "Invalid collectiondetails format".to_string())?; + + if details_list.is_empty() { + return Err("Collection not found".to_string()); + } + + let mut ids = Vec::new(); + if let Some(children) = details_list[0]["children"].as_array() { + for child in children { + if let Some(id) = child["publishedfileid"].as_str() { + ids.push(id.to_string()); + } + } + } + + Ok(ids) +} diff --git a/src-tauri/src/steam/types.rs b/src-tauri/src/steam/types.rs new file mode 100644 index 0000000..3efeda6 --- /dev/null +++ b/src-tauri/src/steam/types.rs @@ -0,0 +1,179 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopCapabilities { + pub bridge_available: bool, + pub bridge_loaded: bool, + pub bridge_initialized: bool, + pub provider: String, + #[serde(default)] + pub bridge_version: Option, + #[serde(default)] + pub last_error: Option, + #[serde(default)] + pub current_user_steam_id: Option, + #[serde(default)] + pub current_user_account_id: Option, + #[serde(default)] + pub can_query_items: bool, + #[serde(default)] + pub can_query_home: bool, + #[serde(default)] + pub can_download: bool, + #[serde(default)] + pub can_enumerate_installed: bool, + #[serde(default)] + pub can_enumerate_subscribed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopBrowseParams { + pub sort: String, + pub section: String, + #[serde(default)] + pub days: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopHomeSection { + pub id: String, + pub title_key: String, + pub subtitle_key: String, + pub icon: String, + pub items: Vec, + pub browse_params: WorkshopBrowseParams, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopHomeResponse { + pub source: String, + #[serde(default)] + pub sections: Vec, + #[serde(default)] + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopBrowseQuery { + #[serde(default)] + pub query: Option, + #[serde(default)] + pub sort: Option, + #[serde(default)] + pub section: Option, + #[serde(default)] + pub page: Option, + #[serde(default)] + pub creator_id: Option, + #[serde(default)] + pub active_tag: Option, + #[serde(default)] + pub active_tag_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopItemsResponse { + pub source: String, + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopItemResponse { + pub source: String, + #[serde(default)] + pub item: Value, + #[serde(default)] + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct WorkshopCollectionResponse { + pub source: String, + #[serde(default)] + pub collection: Value, + #[serde(default)] + pub items: Vec, + #[serde(default)] + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BridgeRequest { + pub method: String, + #[serde(default)] + pub payload: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct BridgeResponse { + pub ok: bool, + #[serde(default)] + pub payload: Value, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct BridgeInitResponse { + pub ok: bool, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub error: Option, + #[serde(default)] + pub current_user_steam_id: Option, + #[serde(default)] + pub current_user_account_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct BridgeDownloadStatus { + #[serde(default)] + pub subscribed: bool, + #[serde(default)] + pub installed: bool, + #[serde(default)] + pub downloaded: Option, + #[serde(default)] + pub total: Option, + #[serde(default)] + pub install_folder: Option, + #[serde(default)] + pub item_state: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SubscribedWorkshopItem { + pub workshop_id: String, + #[serde(default)] + pub item_state: Vec, + #[serde(default)] + pub install_folder: Option, + #[serde(default)] + pub size_on_disk: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct SubscribedWorkshopItemsResponse { + pub source: String, + #[serde(default)] + pub items: Vec, +} diff --git a/src-tauri/src/vpk.rs b/src-tauri/src/vpk/core.rs similarity index 67% rename from src-tauri/src/vpk.rs rename to src-tauri/src/vpk/core.rs index b5fe1d7..33d197c 100644 --- a/src-tauri/src/vpk.rs +++ b/src-tauri/src/vpk/core.rs @@ -1,37 +1,12 @@ +use md5::{Digest, Md5}; use std::collections::HashMap; use std::fs::File; use std::io::{Read, Seek, SeekFrom, Write}; use std::path::Path; -use serde::{Serialize, Deserialize}; -use md5::{Md5, Digest}; - -#[derive(Debug, Clone)] -pub struct VpkEntry { - pub crc: u32, - pub preload_bytes: u16, - pub archive_index: u16, - pub entry_offset: u32, - pub entry_length: u32, - pub preload_data: Vec, - pub header_size: u32, - pub tree_size: u32, -} -#[derive(Serialize, Deserialize, Debug, Clone, Default)] -pub struct AddonMetadata { - #[serde(rename = "addonInfo")] - pub addon_info: serde_json::Value, - #[serde(rename = "hasImage")] - pub has_image: bool, - #[serde(rename = "imagePath")] - pub image_path: Option, - #[serde(rename = "filesCount")] - pub files_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} +use super::types::{AddonMetadata, VpkEntry, VpkFileToWrite}; -fn read_string(buf: &[u8], offset: &mut usize) -> String { +pub(crate) fn read_string(buf: &[u8], offset: &mut usize) -> String { let start = *offset; while *offset < buf.len() && buf[*offset] != 0 { *offset += 1; @@ -41,10 +16,13 @@ fn read_string(buf: &[u8], offset: &mut usize) -> String { s } -pub fn parse_vpk>(file_path: P) -> Result<(HashMap, File), String> { +pub fn parse_vpk>( + file_path: P, +) -> Result<(HashMap, File), String> { let mut file = File::open(&file_path).map_err(|e| e.to_string())?; let mut header_buf = vec![0; 28]; - file.read_exact(&mut header_buf).map_err(|e| e.to_string())?; + file.read_exact(&mut header_buf) + .map_err(|e| e.to_string())?; let signature = u32::from_le_bytes(header_buf[0..4].try_into().unwrap()); if signature != 0x55aa1234 { @@ -65,7 +43,8 @@ pub fn parse_vpk>(file_path: P) -> Result<(HashMap>(file_path: P) -> Result<(HashMap 0 { - if offset + preload_bytes as usize <= tree_buf.len() { - preload_data.extend_from_slice(&tree_buf[offset..offset + preload_bytes as usize]); - offset += preload_bytes as usize; - } + if preload_bytes > 0 && offset + preload_bytes as usize <= tree_buf.len() { + preload_data + .extend_from_slice(&tree_buf[offset..offset + preload_bytes as usize]); + offset += preload_bytes as usize; } let norm_path = path_str.trim(); @@ -142,7 +120,8 @@ pub fn parse_vpk>(file_path: P) -> Result<(HashMap std::io::Result> { let mut data = entry.preload_data.clone(); if entry.entry_length > 0 { - let file_offset = entry.header_size as u64 + entry.tree_size as u64 + entry.entry_offset as u64; + let file_offset = + entry.header_size as u64 + entry.tree_size as u64 + entry.entry_offset as u64; file.seek(SeekFrom::Start(file_offset))?; let mut file_buf = vec![0; entry.entry_length as usize]; file.read_exact(&mut file_buf)?; @@ -158,7 +137,7 @@ pub fn parse_key_values(text: &str) -> serde_json::Value { let mut chars = line.chars().peekable(); let mut in_quote = false; let mut escaped = false; - + while let Some(c) = chars.next() { if escaped { clean_line.push(c); @@ -203,7 +182,7 @@ pub fn parse_key_values(text: &str) -> serde_json::Value { let mut s = String::new(); s.push(chars.next().unwrap()); let mut escaped = false; - while let Some(nc) = chars.next() { + for nc in chars.by_ref() { s.push(nc); if escaped { escaped = false; @@ -238,7 +217,7 @@ pub fn parse_key_values(text: &str) -> serde_json::Value { } let mut index = 0; - + fn parse_object(tokens: &[String], index: &mut usize) -> HashMap { let mut obj = HashMap::new(); while *index < tokens.len() { @@ -251,14 +230,14 @@ pub fn parse_key_values(text: &str) -> serde_json::Value { *index += 1; continue; } - + let key = clean_token(tok).to_lowercase(); *index += 1; - + if *index >= tokens.len() { break; } - + let next_tok = &tokens[*index]; if next_tok == "{" { *index += 1; @@ -279,10 +258,12 @@ pub fn parse_key_values(text: &str) -> serde_json::Value { let tok = &tokens[index]; index += 1; if tok == "{" { - return serde_json::to_value(parse_object(&tokens, &mut index)).unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + return serde_json::to_value(parse_object(&tokens, &mut index)) + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); } else if index < tokens.len() && tokens[index] == "{" { index += 1; - return serde_json::to_value(parse_object(&tokens, &mut index)).unwrap_or(serde_json::Value::Object(serde_json::Map::new())); + return serde_json::to_value(parse_object(&tokens, &mut index)) + .unwrap_or(serde_json::Value::Object(serde_json::Map::new())); } } @@ -294,7 +275,7 @@ pub fn extract_addon_metadata, Q: AsRef>( cache_dir: Q, ) -> AddonMetadata { let mut result = AddonMetadata::default(); - + let (files, mut file) = match parse_vpk(&vpk_path) { Ok(val) => val, Err(err) => { @@ -306,29 +287,51 @@ pub fn extract_addon_metadata, Q: AsRef>( result.files_count = files.len(); // Find addoninfo.txt + let mut addoninfo_content = String::new(); let addoninfo_key = files.keys().find(|k| { let lower = k.to_lowercase(); - lower == "addoninfo.txt" || lower.ends_with("/addoninfo.txt") || lower.ends_with("\\addoninfo.txt") + lower == "addoninfo.txt" + || lower.ends_with("/addoninfo.txt") + || lower.ends_with("\\addoninfo.txt") }); if let Some(key) = addoninfo_key { if let Some(entry) = files.get(key) { if let Ok(content_bytes) = get_file_content(&mut file, entry) { let text = String::from_utf8_lossy(&content_bytes); result.addon_info = parse_key_values(&text); + addoninfo_content = text.to_string(); } } } + // Fast hash based on addoninfo and directory structure + let mut hasher = Md5::new(); + hasher.update(addoninfo_content.as_bytes()); + let mut paths: Vec<&String> = files.keys().collect(); + paths.sort(); + for p in paths { + hasher.update(p.as_bytes()); + } + result.hash = format!("{:x}", hasher.finalize()); + // Find addonimage.jpg or addonimage.vtf let addonimage_jpg_key = files.keys().find(|k| { let lower = k.to_lowercase(); - lower == "addonimage.jpg" || lower.ends_with("/addonimage.jpg") || lower.ends_with("\\addonimage.jpg") || - lower == "addonimage.jpeg" || lower.ends_with("/addonimage.jpeg") || lower.ends_with("\\addonimage.jpeg") || - lower == "addonimage.png" || lower.ends_with("/addonimage.png") || lower.ends_with("\\addonimage.png") + lower == "addonimage.jpg" + || lower.ends_with("/addonimage.jpg") + || lower.ends_with("\\addonimage.jpg") + || lower == "addonimage.jpeg" + || lower.ends_with("/addonimage.jpeg") + || lower.ends_with("\\addonimage.jpeg") + || lower == "addonimage.png" + || lower.ends_with("/addonimage.png") + || lower.ends_with("\\addonimage.png") }); let addonimage_vtf_key = files.keys().find(|k| { let lower = k.to_lowercase(); - lower == "addonimage.vtf" || lower.ends_with("/addonimage.vtf") || lower.ends_with("\\addonimage.vtf") + lower == "addonimage.vtf" + || lower.ends_with("/addonimage.vtf") + || lower.ends_with("\\addonimage.vtf") }); let vpk_name = vpk_path @@ -336,14 +339,12 @@ pub fn extract_addon_metadata, Q: AsRef>( .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); - let clean_vpk_name = vpk_name - .replace(".disabled", "") - .replace(".vpk", ""); - + let clean_vpk_name = vpk_name.replace(".disabled", "").replace(".vpk", ""); + let mut hasher = Md5::new(); hasher.update(clean_vpk_name.as_bytes()); let hash_result = hasher.finalize(); - + let cache_filename = format!("{:x}_image.jpg", hash_result); let full_cache_path = cache_dir.as_ref().join(&cache_filename); let mut image_saved = false; @@ -376,7 +377,10 @@ pub fn extract_addon_metadata, Q: AsRef>( // Try to decode VTF to JPG if let Ok(vtf) = vtf::from_bytes(&vtf_bytes) { if let Ok(decoded) = vtf.highres_image.decode(0) { - if decoded.save_with_format(&full_cache_path, image::ImageFormat::Jpeg).is_ok() { + if decoded + .save_with_format(&full_cache_path, image::ImageFormat::Jpeg) + .is_ok() + { result.has_image = true; result.image_path = Some(format!("/cache/{}", cache_filename)); } @@ -390,14 +394,6 @@ pub fn extract_addon_metadata, Q: AsRef>( result } -#[derive(Debug, Clone)] -pub struct VpkFileToWrite { - pub ext: String, - pub path: String, - pub filename: String, - pub content: Vec, -} - pub fn crc32(data: &[u8]) -> u32 { let mut c = 0xffffffffu32; for &b in data { @@ -419,7 +415,8 @@ pub fn write_vpk>(file_path: P, files: &[VpkFileToWrite]) -> Resu let mut ext_map: HashMap>> = HashMap::new(); for f in files { - ext_map.entry(f.ext.clone()) + ext_map + .entry(f.ext.clone()) .or_default() .entry(f.path.clone()) .or_default() @@ -434,27 +431,31 @@ pub fn write_vpk>(file_path: P, files: &[VpkFileToWrite]) -> Resu let ext_str = if ext.is_empty() { " " } else { ext }; tree_buf.extend_from_slice(ext_str.as_bytes()); tree_buf.push(0); - + for (path, file_list) in paths { let path_str = if path.is_empty() { " " } else { path }; tree_buf.extend_from_slice(path_str.as_bytes()); tree_buf.push(0); - + for f in file_list { - let filename_str = if f.filename.is_empty() { " " } else { &f.filename }; + let filename_str = if f.filename.is_empty() { + " " + } else { + &f.filename + }; tree_buf.extend_from_slice(filename_str.as_bytes()); tree_buf.push(0); - + let crc_val = crc32(&f.content); let entry_length = f.content.len() as u32; - + tree_buf.extend_from_slice(&crc_val.to_le_bytes()); tree_buf.extend_from_slice(&0u16.to_le_bytes()); tree_buf.extend_from_slice(&0x7fffu16.to_le_bytes()); tree_buf.extend_from_slice(&data_offset.to_le_bytes()); tree_buf.extend_from_slice(&entry_length.to_le_bytes()); tree_buf.extend_from_slice(&0xffffu16.to_le_bytes()); - + data_offset += entry_length; files_to_write_data.push(&f.content); } @@ -466,9 +467,12 @@ pub fn write_vpk>(file_path: P, files: &[VpkFileToWrite]) -> Resu let tree_size = tree_buf.len() as u32; - file.write_all(&0x55aa1234u32.to_le_bytes()).map_err(|e| e.to_string())?; - file.write_all(&1u32.to_le_bytes()).map_err(|e| e.to_string())?; - file.write_all(&tree_size.to_le_bytes()).map_err(|e| e.to_string())?; + file.write_all(&0x55aa1234u32.to_le_bytes()) + .map_err(|e| e.to_string())?; + file.write_all(&1u32.to_le_bytes()) + .map_err(|e| e.to_string())?; + file.write_all(&tree_size.to_le_bytes()) + .map_err(|e| e.to_string())?; file.write_all(&tree_buf).map_err(|e| e.to_string())?; @@ -487,12 +491,15 @@ pub fn generate_dummy_vpk, Q: AsRef>( let addoninfo_key = files.keys().find(|k| { let lower = k.to_lowercase(); - lower == "addoninfo.txt" || lower.ends_with("/addoninfo.txt") || lower.ends_with("\\addoninfo.txt") + lower == "addoninfo.txt" + || lower.ends_with("/addoninfo.txt") + || lower.ends_with("\\addoninfo.txt") }); let mut steam_app_id = "550".to_string(); let mut addon_version = "1.0".to_string(); - let mut addon_title = original_vpk_path.as_ref() + let mut addon_title = original_vpk_path + .as_ref() .file_stem() .unwrap_or_default() .to_string_lossy() @@ -528,7 +535,9 @@ pub fn generate_dummy_vpk, Q: AsRef>( // Find and extract addonimage.jpg let addonimage_jpg_key = files.keys().find(|k| { let lower = k.to_lowercase(); - lower == "addonimage.jpg" || lower.ends_with("/addonimage.jpg") || lower.ends_with("\\addonimage.jpg") + lower == "addonimage.jpg" + || lower.ends_with("/addonimage.jpg") + || lower.ends_with("\\addonimage.jpg") }); if let Some(key) = addonimage_jpg_key { if let Some(entry) = files.get(key) { @@ -546,7 +555,9 @@ pub fn generate_dummy_vpk, Q: AsRef>( // Find and extract addonimage.vtf let addonimage_vtf_key = files.keys().find(|k| { let lower = k.to_lowercase(); - lower == "addonimage.vtf" || lower.ends_with("/addonimage.vtf") || lower.ends_with("\\addonimage.vtf") + lower == "addonimage.vtf" + || lower.ends_with("/addonimage.vtf") + || lower.ends_with("\\addonimage.vtf") }); if let Some(key) = addonimage_vtf_key { if let Some(entry) = files.get(key) { @@ -568,6 +579,7 @@ pub fn generate_dummy_vpk, Q: AsRef>( "addonversion" "{}" "addontitle" "{} (L4A Dummy)" "addonDescription" "A dummy addon generated by Left 4 Addons" + "l4aDummy" "1" }} "#, steam_app_id, addon_version, addon_title @@ -584,131 +596,3 @@ pub fn generate_dummy_vpk, Q: AsRef>( Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn test_read_string() { - let buf = b"hello\0world\0"; - let mut offset = 0; - let s1 = read_string(buf, &mut offset); - assert_eq!(s1, "hello"); - assert_eq!(offset, 6); - - let s2 = read_string(buf, &mut offset); - assert_eq!(s2, "world"); - assert_eq!(offset, 12); - } - - #[test] - fn test_parse_key_values_simple() { - let kv = r#" - "AddonInfo" - { - "addonTitle" "Test Addon" - "addonVersion" "1.0" - "addonAuthor" "Test Author" - } - "#; - let parsed = parse_key_values(kv); - assert!(parsed.is_object()); - let obj = parsed.as_object().unwrap(); - - assert_eq!(obj.get("addontitle").unwrap().as_str().unwrap(), "Test Addon"); - assert_eq!(obj.get("addonversion").unwrap().as_str().unwrap(), "1.0"); - assert_eq!(obj.get("addonauthor").unwrap().as_str().unwrap(), "Test Author"); - } - - #[test] - fn test_parse_key_values_nested() { - let kv = r#" - "AddonInfo" - { - "addonTitle" "Nested Addon" - "addonContent_Campaign" "1" - "NestedObject" - { - "Key1" "Value1" - } - } - "#; - let parsed = parse_key_values(kv); - assert!(parsed.is_object()); - let obj = parsed.as_object().unwrap(); - assert_eq!(obj.get("addontitle").unwrap().as_str().unwrap(), "Nested Addon"); - assert_eq!(obj.get("addoncontent_campaign").unwrap().as_str().unwrap(), "1"); - - let nested = obj.get("nestedobject").unwrap().as_object().unwrap(); - assert_eq!(nested.get("key1").unwrap().as_str().unwrap(), "Value1"); - } - - #[test] - fn test_parse_key_values_comments() { - let kv = r#" - // This is a comment - "AddonInfo" - { - "addonTitle" "Commented Addon" // inline comment - "addonVersion" "2.0" - } - "#; - let parsed = parse_key_values(kv); - assert!(parsed.is_object()); - let obj = parsed.as_object().unwrap(); - assert_eq!(obj.get("addontitle").unwrap().as_str().unwrap(), "Commented Addon"); - assert_eq!(obj.get("addonversion").unwrap().as_str().unwrap(), "2.0"); - } - - #[test] - fn test_extract_addon_metadata_mock_vpk() { - let temp_dir = PathBuf::from("../target/test_temp_dir"); - if !temp_dir.exists() { - let _ = std::fs::create_dir_all(&temp_dir); - } - - let vpk_path = temp_dir.join("mock_addon.vpk"); - let temp_cache_dir = temp_dir.join("cache"); - - { - use std::io::Write; - let mut file = File::create(&vpk_path).unwrap(); - let content = b"\"addoninfo\"\n{\n\"addontitle\" \"Mock Addon\"\n}"; - - let mut tree = Vec::new(); - tree.extend_from_slice(b"txt\0"); - tree.extend_from_slice(b"my_folder\0"); - tree.extend_from_slice(b"addoninfo\0"); - tree.extend_from_slice(&0u32.to_le_bytes()); - tree.extend_from_slice(&0u16.to_le_bytes()); - tree.extend_from_slice(&0x7fffu16.to_le_bytes()); - tree.extend_from_slice(&0u32.to_le_bytes()); - tree.extend_from_slice(&(content.len() as u32).to_le_bytes()); - tree.extend_from_slice(&0xffffu16.to_le_bytes()); - - tree.extend_from_slice(b"\0"); - tree.extend_from_slice(b"\0"); - tree.extend_from_slice(b"\0"); - - let tree_size = tree.len() as u32; - - file.write_all(&0x55aa1234u32.to_le_bytes()).unwrap(); - file.write_all(&1u32.to_le_bytes()).unwrap(); - file.write_all(&tree_size.to_le_bytes()).unwrap(); - file.write_all(&tree).unwrap(); - file.write_all(content).unwrap(); - } - - let metadata = extract_addon_metadata(&vpk_path, &temp_cache_dir); - assert!(metadata.error.is_none()); - assert_eq!(metadata.files_count, 1); - - let addon_title = metadata.addon_info.get("addontitle").and_then(|t| t.as_str()); - assert_eq!(addon_title, Some("Mock Addon")); - - let _ = std::fs::remove_dir_all(&temp_dir); - } -} - diff --git a/src-tauri/src/vpk/mod.rs b/src-tauri/src/vpk/mod.rs new file mode 100644 index 0000000..9aaee26 --- /dev/null +++ b/src-tauri/src/vpk/mod.rs @@ -0,0 +1,10 @@ +mod core; +#[cfg(test)] +mod tests; +mod types; + +pub use core::{ + crc32, extract_addon_metadata, generate_dummy_vpk, get_file_content, parse_key_values, + parse_vpk, write_vpk, +}; +pub use types::{AddonMetadata, VpkEntry, VpkFileToWrite}; diff --git a/src-tauri/src/vpk/tests.rs b/src-tauri/src/vpk/tests.rs new file mode 100644 index 0000000..f45c0c9 --- /dev/null +++ b/src-tauri/src/vpk/tests.rs @@ -0,0 +1,144 @@ +use std::fs::File; +use std::path::PathBuf; + +use super::core::read_string; +use super::{extract_addon_metadata, parse_key_values}; + +#[test] +fn test_read_string() { + let buf = b"hello\0world\0"; + let mut offset = 0; + let s1 = read_string(buf, &mut offset); + assert_eq!(s1, "hello"); + assert_eq!(offset, 6); + + let s2 = read_string(buf, &mut offset); + assert_eq!(s2, "world"); + assert_eq!(offset, 12); +} + +#[test] +fn test_parse_key_values_simple() { + let kv = r#" + "AddonInfo" + { + "addonTitle" "Test Addon" + "addonVersion" "1.0" + "addonAuthor" "Test Author" + } + "#; + let parsed = parse_key_values(kv); + assert!(parsed.is_object()); + let obj = parsed.as_object().unwrap(); + + assert_eq!( + obj.get("addontitle").unwrap().as_str().unwrap(), + "Test Addon" + ); + assert_eq!(obj.get("addonversion").unwrap().as_str().unwrap(), "1.0"); + assert_eq!( + obj.get("addonauthor").unwrap().as_str().unwrap(), + "Test Author" + ); +} + +#[test] +fn test_parse_key_values_nested() { + let kv = r#" + "AddonInfo" + { + "addonTitle" "Nested Addon" + "addonContent_Campaign" "1" + "NestedObject" + { + "Key1" "Value1" + } + } + "#; + let parsed = parse_key_values(kv); + assert!(parsed.is_object()); + let obj = parsed.as_object().unwrap(); + assert_eq!( + obj.get("addontitle").unwrap().as_str().unwrap(), + "Nested Addon" + ); + assert_eq!( + obj.get("addoncontent_campaign").unwrap().as_str().unwrap(), + "1" + ); + + let nested = obj.get("nestedobject").unwrap().as_object().unwrap(); + assert_eq!(nested.get("key1").unwrap().as_str().unwrap(), "Value1"); +} + +#[test] +fn test_parse_key_values_comments() { + let kv = r#" + // This is a comment + "AddonInfo" + { + "addonTitle" "Commented Addon" // inline comment + "addonVersion" "2.0" + } + "#; + let parsed = parse_key_values(kv); + assert!(parsed.is_object()); + let obj = parsed.as_object().unwrap(); + assert_eq!( + obj.get("addontitle").unwrap().as_str().unwrap(), + "Commented Addon" + ); + assert_eq!(obj.get("addonversion").unwrap().as_str().unwrap(), "2.0"); +} + +#[test] +fn test_extract_addon_metadata_mock_vpk() { + let temp_dir = PathBuf::from("../target/test_temp_dir"); + if !temp_dir.exists() { + let _ = std::fs::create_dir_all(&temp_dir); + } + + let vpk_path = temp_dir.join("mock_addon.vpk"); + let temp_cache_dir = temp_dir.join("cache"); + + { + use std::io::Write; + let mut file = File::create(&vpk_path).unwrap(); + let content = b"\"addoninfo\"\n{\n\"addontitle\" \"Mock Addon\"\n}"; + + let mut tree = Vec::new(); + tree.extend_from_slice(b"txt\0"); + tree.extend_from_slice(b"my_folder\0"); + tree.extend_from_slice(b"addoninfo\0"); + tree.extend_from_slice(&0u32.to_le_bytes()); + tree.extend_from_slice(&0u16.to_le_bytes()); + tree.extend_from_slice(&0x7fffu16.to_le_bytes()); + tree.extend_from_slice(&0u32.to_le_bytes()); + tree.extend_from_slice(&(content.len() as u32).to_le_bytes()); + tree.extend_from_slice(&0xffffu16.to_le_bytes()); + + tree.extend_from_slice(b"\0"); + tree.extend_from_slice(b"\0"); + tree.extend_from_slice(b"\0"); + + let tree_size = tree.len() as u32; + + file.write_all(&0x55aa1234u32.to_le_bytes()).unwrap(); + file.write_all(&1u32.to_le_bytes()).unwrap(); + file.write_all(&tree_size.to_le_bytes()).unwrap(); + file.write_all(&tree).unwrap(); + file.write_all(content).unwrap(); + } + + let metadata = extract_addon_metadata(&vpk_path, &temp_cache_dir); + assert!(metadata.error.is_none()); + assert_eq!(metadata.files_count, 1); + + let addon_title = metadata + .addon_info + .get("addontitle") + .and_then(|t| t.as_str()); + assert_eq!(addon_title, Some("Mock Addon")); + + let _ = std::fs::remove_dir_all(&temp_dir); +} diff --git a/src-tauri/src/vpk/types.rs b/src-tauri/src/vpk/types.rs new file mode 100644 index 0000000..f661aef --- /dev/null +++ b/src-tauri/src/vpk/types.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone)] +pub struct VpkEntry { + pub crc: u32, + pub preload_bytes: u16, + pub archive_index: u16, + pub entry_offset: u32, + pub entry_length: u32, + pub preload_data: Vec, + pub header_size: u32, + pub tree_size: u32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct AddonMetadata { + #[serde(rename = "addonInfo")] + pub addon_info: serde_json::Value, + #[serde(rename = "hasImage")] + pub has_image: bool, + #[serde(rename = "imagePath")] + pub image_path: Option, + #[serde(rename = "filesCount")] + pub files_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default)] + pub hash: String, +} + +#[derive(Debug, Clone)] +pub struct VpkFileToWrite { + pub ext: String, + pub path: String, + pub filename: String, + pub content: Vec, +} diff --git a/src-tauri/src/watcher.rs b/src-tauri/src/watcher.rs new file mode 100644 index 0000000..99ed419 --- /dev/null +++ b/src-tauri/src/watcher.rs @@ -0,0 +1,360 @@ +use notify::{Event, EventKind as EventType, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tauri::{AppHandle, Emitter, Manager}; + +const WATCHER_DEBOUNCE_MS: u64 = 400; +const INTERNAL_REFRESH_SUPPRESS_MS: u64 = 1_500; +const DB_UPDATED_EVENT: &str = "addons-db-updated"; +const WATCH_ERROR_EVENT: &str = "addons-watch-error"; + +#[derive(Serialize, Clone)] +struct AddonsDbUpdatedEvent { + data: crate::commands::Database, + external: bool, +} + +#[derive(Serialize, Clone)] +struct AddonsWatchErrorEvent { + message: String, +} + +enum WatcherSignal { + Event, + Error(String), +} + +#[derive(Default)] +struct RefreshQueueState { + running: bool, + pending: bool, + pending_external: bool, +} + +#[derive(Default)] +struct RefreshCoordinator { + queue: Mutex, + suppress_until: Mutex>, +} + +impl RefreshCoordinator { + fn suppress_internal_refresh(&self) { + self.suppress_internal_refresh_for(Duration::from_millis(INTERNAL_REFRESH_SUPPRESS_MS)); + } + + fn suppress_internal_refresh_for(&self, duration: Duration) { + if let Ok(mut guard) = self.suppress_until.lock() { + *guard = Some(Instant::now() + duration); + } + } + + fn is_suppressed(&self) -> bool { + let Ok(mut guard) = self.suppress_until.lock() else { + return false; + }; + + match *guard { + Some(deadline) if Instant::now() < deadline => true, + Some(_) => { + *guard = None; + false + } + None => false, + } + } + + fn request_refresh(self: &Arc, app_handle: AppHandle, external: bool) { + let Ok(mut queue) = self.queue.lock() else { + emit_watch_error(&app_handle, "Failed to acquire watcher refresh queue"); + return; + }; + + if queue.running { + queue.pending = true; + queue.pending_external |= external; + return; + } + + queue.running = true; + drop(queue); + + spawn_refresh_loop(app_handle, self.clone(), external); + } +} + +pub struct AddonWatcherController { + watcher: Option, + watched_paths: Vec, + coordinator: Arc, +} + +impl Default for AddonWatcherController { + fn default() -> Self { + Self { + watcher: None, + watched_paths: Vec::new(), + coordinator: Arc::new(RefreshCoordinator::default()), + } + } +} + +impl AddonWatcherController { + pub fn suppress_internal_refresh(&self) { + self.coordinator.suppress_internal_refresh(); + } + + pub fn suppress_internal_refresh_for(&self, duration: Duration) { + self.coordinator.suppress_internal_refresh_for(duration); + } + + pub fn rebind(&mut self, app_handle: &AppHandle, loading_dir: &Path) -> Result<(), String> { + self.watcher = None; + self.watched_paths.clear(); + + let watched_paths = watched_paths_from_loading_dir(loading_dir); + for path in &watched_paths { + fs::create_dir_all(path).map_err(|err| { + format!( + "Failed to create watched directory {}: {}", + path.display(), + err + ) + })?; + } + + let (tx, rx) = mpsc::channel::(); + let app_handle_for_thread = app_handle.clone(); + let coordinator = self.coordinator.clone(); + std::thread::spawn(move || run_event_loop(rx, app_handle_for_thread, coordinator)); + + let watched_paths_for_callback = watched_paths.clone(); + let mut watcher = + notify::recommended_watcher(move |result: Result| match result { + Ok(event) => { + if is_relevant_event(&event, &watched_paths_for_callback) { + let _ = tx.send(WatcherSignal::Event); + } + } + Err(err) => { + let _ = tx.send(WatcherSignal::Error(err.to_string())); + } + }) + .map_err(|err| format!("Failed to create file watcher: {}", err))?; + + for path in &watched_paths { + watcher + .watch(path, RecursiveMode::NonRecursive) + .map_err(|err| format!("Failed to watch {}: {}", path.display(), err))?; + } + + self.watched_paths = watched_paths; + self.watcher = Some(watcher); + Ok(()) + } +} + +fn run_event_loop( + rx: Receiver, + app_handle: AppHandle, + coordinator: Arc, +) { + loop { + let signal = match rx.recv() { + Ok(signal) => signal, + Err(_) => return, + }; + + match signal { + WatcherSignal::Error(message) => emit_watch_error(&app_handle, &message), + WatcherSignal::Event => { + loop { + match rx.recv_timeout(Duration::from_millis(WATCHER_DEBOUNCE_MS)) { + Ok(WatcherSignal::Event) => continue, + Ok(WatcherSignal::Error(message)) => { + emit_watch_error(&app_handle, &message) + } + Err(RecvTimeoutError::Timeout) => break, + Err(RecvTimeoutError::Disconnected) => return, + } + } + + let external = !coordinator.is_suppressed(); + coordinator.request_refresh(app_handle.clone(), external); + } + } + } +} + +fn spawn_refresh_loop( + app_handle: AppHandle, + coordinator: Arc, + initial_external: bool, +) { + tauri::async_runtime::spawn(async move { + let mut next_external = initial_external; + + loop { + match crate::commands::handlers::rescan_database_snapshot(&app_handle).await { + Ok((data, changed)) => { + if changed { + let _ = app_handle.emit( + DB_UPDATED_EVENT, + AddonsDbUpdatedEvent { + data, + external: next_external, + }, + ); + } + } + Err(message) => emit_watch_error(&app_handle, &message), + } + + let Ok(mut queue) = coordinator.queue.lock() else { + emit_watch_error(&app_handle, "Failed to finalize watcher refresh queue"); + return; + }; + + if queue.pending { + next_external = queue.pending_external; + queue.pending = false; + queue.pending_external = false; + drop(queue); + continue; + } + + queue.running = false; + break; + } + }); +} + +pub fn rebind_addon_watcher(app_handle: &AppHandle, loading_dir: &Path) -> Result<(), String> { + let state = app_handle.state::(); + let mut controller = state + .addon_watcher + .lock() + .map_err(|_| "Failed to acquire addon watcher controller".to_string())?; + controller.rebind(app_handle, loading_dir) +} + +pub fn suppress_internal_refresh(state: &crate::AppState) { + if let Ok(controller) = state.addon_watcher.lock() { + controller.suppress_internal_refresh(); + } +} + +pub fn suppress_internal_refresh_for(state: &crate::AppState, duration: Duration) { + if let Ok(controller) = state.addon_watcher.lock() { + controller.suppress_internal_refresh_for(duration); + } +} + +pub fn emit_watch_error(app_handle: &AppHandle, message: &str) { + log::warn!("addon watcher: {}", message); + let _ = app_handle.emit( + WATCH_ERROR_EVENT, + AddonsWatchErrorEvent { + message: message.to_string(), + }, + ); +} + +fn is_relevant_event(event: &Event, watched_paths: &[PathBuf]) -> bool { + match event.kind { + EventType::Create(_) | EventType::Modify(_) | EventType::Remove(_) | EventType::Any => {} + _ => return false, + } + + event + .paths + .iter() + .any(|path| is_relevant_addon_path(path, watched_paths)) +} + +fn is_relevant_addon_path(path: &Path, watched_paths: &[PathBuf]) -> bool { + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + + if !(file_name.ends_with(".vpk") || file_name.ends_with(".vpk.disabled")) { + return false; + } + + watched_paths.iter().any(|watched| { + path.parent() + .map(|parent| parent == watched) + .unwrap_or(false) + }) +} + +fn watched_paths_from_loading_dir(loading_dir: &Path) -> Vec { + vec![loading_dir.to_path_buf(), loading_dir.join("workshop")] +} + +#[cfg(test)] +mod tests { + use super::{ + is_relevant_addon_path, watched_paths_from_loading_dir, AddonWatcherController, + INTERNAL_REFRESH_SUPPRESS_MS, + }; + use notify::{event::ModifyKind, Event, EventKind as EventType}; + use std::path::{Path, PathBuf}; + use std::thread; + use std::time::Duration; + + #[test] + fn watched_paths_match_loading_and_workshop_roots() { + let loading_dir = Path::new("/tmp/l4a-loading"); + let paths = watched_paths_from_loading_dir(loading_dir); + assert_eq!( + paths, + vec![ + PathBuf::from("/tmp/l4a-loading"), + PathBuf::from("/tmp/l4a-loading/workshop"), + ] + ); + } + + #[test] + fn addon_path_filter_only_accepts_direct_vpk_entries() { + let watched = watched_paths_from_loading_dir(Path::new("/tmp/l4a-loading")); + + assert!(is_relevant_addon_path( + Path::new("/tmp/l4a-loading/test.vpk"), + &watched + )); + assert!(is_relevant_addon_path( + Path::new("/tmp/l4a-loading/workshop/test.vpk.disabled"), + &watched + )); + assert!(!is_relevant_addon_path( + Path::new("/tmp/l4a-loading/workshop/subdir/test.vpk"), + &watched + )); + assert!(!is_relevant_addon_path( + Path::new("/tmp/l4a-loading/readme.txt"), + &watched + )); + + let event = Event { + kind: EventType::Modify(ModifyKind::Name(notify::event::RenameMode::Both)), + paths: vec![PathBuf::from("/tmp/l4a-loading/test.vpk")], + attrs: Default::default(), + }; + assert!(super::is_relevant_event(&event, &watched)); + } + + #[test] + fn suppression_window_marks_internal_refreshes_temporarily() { + let controller = AddonWatcherController::default(); + assert!(!controller.coordinator.is_suppressed()); + controller.suppress_internal_refresh(); + assert!(controller.coordinator.is_suppressed()); + thread::sleep(Duration::from_millis(INTERNAL_REFRESH_SUPPRESS_MS + 100)); + assert!(!controller.coordinator.is_suppressed()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0d11106..dbc4dbe 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -5,7 +5,7 @@ "identifier": "com.left4addons.manager", "build": { "frontendDist": "../dist", - "devUrl": "http://localhost:5173", + "devUrl": "http://127.0.0.1:5173", "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build" }, @@ -24,7 +24,7 @@ } }, "bundle": { - "active": true, + "active": false, "targets": "all", "icon": [ "icons/32x32.png", diff --git a/src/App.tsx b/src/App.tsx index 9d2ca91..73ba881 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,30 +20,48 @@ import { WorkshopActionWarningModal } from './components/WorkshopActionWarningMo import { StatsBar } from './components/StatsBar'; import { GroupHeader } from './components/GroupHeader'; import { BatchActionBar } from './components/BatchActionBar'; +import { KnownUninstalledView } from './components/KnownUninstalledView'; +import { WorkshopBrowser } from './components/WorkshopBrowser'; +import { MasterCollectionModal } from './components/MasterCollectionModal'; +import { DeleteConfirmModal } from './components/DeleteConfirmModal'; +import { MasterCollectionHeader } from './components/MasterCollectionHeader'; +import { AddToGroupModal } from './components/AddToGroupModal'; +import { PromptModal } from './components/PromptModal'; +import { TaskCenterModal } from './components/TaskCenterModal'; +import { DependencyPromptContainer } from './components/DependencyPromptContainer'; +import { useState } from 'react'; +import { sortAddonsDownloadedFirst } from './utils/addonHelpers'; +import { findKnownWorkshopEntry } from './utils/workshopKnown'; function App() { const { t } = useTranslation(); const { addons, + knownUninstalledAddons, groups, + masterCollections, settings, loading, searchQuery, setSearchQuery, currentFilterTab, selectedGroupId, + selectedMasterCollectionId, selectedCategory, setSelectedCategory, sortBy, setSortBy, toasts, + addToast, syncingSteam, autoGrouping, - selectedVpkNames, + selectedIds, + selectedGroupIds, isSelectMode, setIsSelectMode, isSubmitting, setIsSubmitting, + downloadProgress, // Modals state detailModal, @@ -65,6 +83,7 @@ function App() { // Handlers fetchData, + applyDatabaseUpdate, handleOpenLink, toggleAddon, toggleGroupAddons, @@ -78,18 +97,37 @@ function App() { handleDeleteGroup, handleRenameGroup, groupActionBatch, - removeAddonFromGroup, addAddonToGroup, triggerRenameModal, handleSelectToggle, handleSelectGroupToggle, handleSelectAll, + handleSelectAllGroups, handleClearSelection, handleBatchToggle, handleBatchMove, handleBatchRename, handleBatchAddToGroup, handleFilterTabChange, + downloadAddon, + downloadAddons, + deleteAddons, + executeRealDelete, + deleteConfirmModal, + setDeleteConfirmModal, + handleBatchDownload, + recordSeenItems, + backgroundTasks, + cancelTask, + retryTask, + removeTask, + clearFinishedTasks, + + // Master Collection handlers + handleCreateMasterCollection, + handleDeleteMasterCollection, + handleRenameMasterCollection, + handleBatchAddGroupsToMasterCollection, // Derived values filteredItems, @@ -99,16 +137,50 @@ function App() { disabledCount, totalStorageSize, currentGroup, + currentMasterCollection, renameAddonObj, renameGroupObj, } = useAddonManager(); + const [masterCollectionModal, setMasterCollectionModal] = useState(false); + const [taskCenterOpen, setTaskCenterOpen] = useState(false); + const [addToGroupModal, setAddToGroupModal] = useState(false); + const [singleAddToGroupAddonId, setSingleAddToGroupAddonId] = useState(null); + const [addToMcModal, setAddToMcModal] = useState(false); + const [renameCollectionModal, setRenameCollectionModal] = useState<{ open: boolean; collectionId: string; currentName: string }>({ + open: false, + collectionId: '', + currentName: '' + }); + // Available categories list const categoriesList = [ - 'All', 'Campaign', 'Survivor', 'Weapon Model', 'Script', + 'All', 'Campaign', 'Survivor', 'Weapon Model', 'Script', 'Map', 'Skin', 'Sound/Music', 'Infected', 'UI/Textures', 'Other' ]; + // Get all addons (installed + uninstalled) for group rendering + const getAllGroupAddons = (groupAddonIds: string[]) => { + return sortAddonsDownloadedFirst( + groupAddonIds + .map(id => addons[id] || knownUninstalledAddons[id]) + .filter(Boolean) + ); + }; + + // Get groups in current master collection + const groupsInMasterCollection = currentMasterCollection + ? groups.filter(g => currentMasterCollection.groupIds.includes(g.id)) + : []; + + const resolvedDetailModalAddon = detailModal.addon + ? addons[detailModal.addon.id] + || knownUninstalledAddons[detailModal.addon.id] + || findKnownWorkshopEntry(addons, detailModal.addon.workshopId) + || findKnownWorkshopEntry(knownUninstalledAddons, detailModal.addon.workshopId) + || detailModal.addon + : null; + return (
{/* Toast Notification Container */} @@ -124,14 +196,18 @@ function App() { setGroupModal({ open: true })} + onOpenMasterCollectionModal={() => setMasterCollectionModal(true)} onAutoGrouping={runAutoGrouping} autoGrouping={autoGrouping} disabledCount={disabledCount} totalAddonsCount={totalAddonsCount} + knownUninstalledCount={Object.keys(knownUninstalledAddons).length} /> {/* Main Panel */} @@ -142,6 +218,56 @@ function App() { isSubmitting={isSubmitting} onConfirm={saveSettings} /> + ) : currentFilterTab === 'known-uninstalled' ? ( + { + if (isSelectMode) { + handleClearSelection(); + } else { + setIsSelectMode(true); + } + }} + selectedIds={selectedIds} + onSelectToggle={handleSelectToggle} + onSelectAll={handleSelectAll} + onBatchDownload={handleBatchDownload} + searchQuery={searchQuery} + onSearchQueryChange={setSearchQuery} + onDetailClick={(addon) => setDetailModal({ open: true, addon })} + backgroundTasks={backgroundTasks} + syncingSteam={syncingSteam} + onOpenTaskCenter={() => setTaskCenterOpen(true)} + onWarning={(message: string) => { + addToast(message, 'error'); + }} + /> + ) : currentFilterTab === 'workshop-browser' ? ( + { + await handleCreateGroup(name, itemIds, undefined, undefined, 'workshop-import'); + }} + onRecordSeenItems={recordSeenItems} + onDatabaseUpdate={applyDatabaseUpdate} + isSubmitting={isSubmitting} + groups={groups} + backgroundTasks={backgroundTasks} + syncingSteam={syncingSteam} + onOpenTaskCenter={() => setTaskCenterOpen(true)} + workshopSourceSettings={settings.workshopSourceSettings} + /> ) : ( <> { @@ -162,22 +287,49 @@ function App() { setIsSelectMode(true); } }} + backgroundTasks={backgroundTasks} + onOpenTaskCenter={() => setTaskCenterOpen(true)} /> {/* Content body */}
- {/* Group details headers */} + {/* Master Collection header */} + {currentMasterCollection && ( + { + setRenameCollectionModal({ + open: true, + collectionId: currentMasterCollection.id, + currentName: currentMasterCollection.name + }); + }} + onDeleteCollection={() => handleDeleteMasterCollection(currentMasterCollection.id)} + /> + )} + + {/* Group details header */} {currentGroup && ( setEditGroupModal({ open: true, groupId: currentGroup.id, name: currentGroup.name })} + groupAddons={getAllGroupAddons(currentGroup.addons)} + onRenameGroup={() => setEditGroupModal({ + open: true, + groupId: currentGroup.id, + name: currentGroup.name, + tags: currentGroup.tags || [], + workshopCollectionId: currentGroup.workshopCollectionId || '' + })} onDeleteGroup={() => handleDeleteGroup(currentGroup.id)} onGroupActionBatch={(actionType) => groupActionBatch(currentGroup.id, actionType)} + onDownloadUninstalled={handleBatchDownload} + downloadProgress={downloadProgress} /> )} {/* Stats bar */} - {currentFilterTab !== 'groups' && ( + {currentFilterTab !== 'groups' && currentFilterTab !== 'master-collection' && (

{t('common.parsing')}

- ) : filteredItems.length === 0 ? ( + ) : renderedItems.length === 0 ? (
{t('common.emptyStateTitle')}
@@ -209,20 +361,25 @@ function App() { {renderedItems.map(renderedItem => { if (renderedItem.type === 'group') { const groupAddons = renderedItem.addons; - const isGroupSelected = groupAddons.every(ad => selectedVpkNames.includes(ad.vpkName)); + const allGroupAddons = getAllGroupAddons(renderedItem.groupObj.addons); + const isGroupSelected = currentFilterTab === 'master-collection' + ? selectedGroupIds.includes(renderedItem.groupObj.id) + : groupAddons.every(ad => selectedIds.includes(ad.id)); return ( { handleFilterTabChange('groups', groupId); }} + onDownloadUninstalled={handleBatchDownload} isSelectMode={isSelectMode} isGroupSelected={isGroupSelected} onSelectGroupToggle={handleSelectGroupToggle} isSubmitting={isSubmitting} + downloadProgress={downloadProgress} /> ); } else { @@ -233,16 +390,21 @@ function App() { addon={addonData} groups={groups} onToggle={toggleAddon} - onAddToGroup={addAddonToGroup} - onRemoveFromGroup={removeAddonFromGroup} + onAddToGroup={(addonId) => { + setSingleAddToGroupAddonId(addonId); + setAddToGroupModal(true); + }} onOpenLink={handleOpenLink} onMoveClick={handleMoveClick} onRenameClick={triggerRenameModal} onDetailClick={(addon) => setDetailModal({ open: true, addon })} + onDeleteClick={(addon) => setDeleteConfirmModal({ open: true, addons: [addon], removeFromKnown: false })} + onDownload={downloadAddon} isSelectMode={isSelectMode} - isSelected={selectedVpkNames.includes(addonData.vpkName)} + isSelected={selectedIds.includes(addonData.id)} onSelectToggle={handleSelectToggle} isSubmitting={isSubmitting} + downloadProgress={downloadProgress} /> ); } @@ -257,29 +419,55 @@ function App() { {/* Modals */} setDetailModal({ open: false, addon: null })} onToggle={toggleAddon} onMove={handleMoveClick} onOpenLink={handleOpenLink} + addons={addons} + knownUninstalledAddons={knownUninstalledAddons} + onDatabaseUpdate={applyDatabaseUpdate} + onDownload={downloadAddon} + downloadProgress={downloadProgress} + isSubmitting={isSubmitting} + onItemNavigate={(workshopId) => { + const foundAddon = Object.values(addons).find((a) => a.workshopId === workshopId) || + Object.values(knownUninstalledAddons).find((a) => a.workshopId === workshopId); + if (foundAddon) { + setDetailModal({ open: true, addon: foundAddon }); + } else { + setDetailModal({ + open: true, + addon: { + id: workshopId, + vpkName: workshopId, + workshopId: workshopId, + dirType: 'none', + isEnabled: false, + fileSize: 0, + filesCount: 0, + } + }); + } + }} /> { - setMoveWarningModal({ open: false, vpkName: '', currentDirType: '', workshopId: '' }); + setMoveWarningModal({ open: false, id: '', currentDirType: '', workshopId: '' }); setIsSubmitting(false); }} - onConfirm={async (vpkName, currentDirType, unsubscribe) => { + onConfirm={async (id, currentDirType, unsubscribe) => { if (unsubscribe && moveWarningModal.workshopId) { await invoke('open_url', { url: `steam://url/CommunityFilePage/${moveWarningModal.workshopId}` }); } - await moveAddon(vpkName, currentDirType); - setMoveWarningModal({ open: false, vpkName: '', currentDirType: '', workshopId: '' }); + await moveAddon(id, currentDirType); + setMoveWarningModal({ open: false, id: '', currentDirType: '', workshopId: '' }); }} /> @@ -315,22 +503,24 @@ function App() { { setGroupModal({ open: false }); setIsSubmitting(false); }} - onConfirm={handleCreateGroup} + onConfirm={(name) => handleCreateGroup(name)} /> { - setEditGroupModal({ open: false, groupId: '', name: '' }); + setEditGroupModal({ open: false, groupId: '', name: '', tags: [], workshopCollectionId: '' }); setIsSubmitting(false); }} onConfirm={handleRenameGroup} @@ -347,6 +537,19 @@ function App() { }} /> + { + setDeleteConfirmModal({ open: false, addons: [], removeFromKnown: false }); + setIsSubmitting(false); + }} + onConfirm={(deleteMode) => { + executeRealDelete(deleteMode, deleteConfirmModal.removeFromKnown, deleteConfirmModal.addons.map(a => a.id)); + }} + /> + + {/* Master Collection Modal */} + setMasterCollectionModal(false)} + onConfirm={async (name) => { + await handleCreateMasterCollection(name); + setMasterCollectionModal(false); + }} + /> + + {/* Rename Master Collection Prompt Modal */} + setRenameCollectionModal({ open: false, collectionId: '', currentName: '' })} + onConfirm={async (newName) => { + if (newName.trim()) { + await handleRenameMasterCollection(renameCollectionModal.collectionId, newName.trim()); + } + setRenameCollectionModal({ open: false, collectionId: '', currentName: '' }); + }} + /> + + {/* Add to Group Modal */} + { + setAddToGroupModal(false); + setSingleAddToGroupAddonId(null); + }} + onConfirm={async (groupId) => { + if (singleAddToGroupAddonId) { + await addAddonToGroup(singleAddToGroupAddonId, groupId); + } else { + await handleBatchAddToGroup(groupId); + } + setAddToGroupModal(false); + setSingleAddToGroupAddonId(null); + }} + /> + + {/* Add to Master Collection Modal */} + ({ + id: mc.id, + name: mc.nameKey ? t(mc.nameKey, mc.name) : mc.name, + addons: [], + masterCollectionIds: [], + }))} + isSubmitting={isSubmitting} + onCancel={() => setAddToMcModal(false)} + onConfirm={async (mcId) => { + await handleBatchAddGroupsToMasterCollection(mcId); + setAddToMcModal(false); + }} + /> + + {/* Task Center Modal */} + setTaskCenterOpen(false)} + backgroundTasks={backgroundTasks} + downloadProgress={downloadProgress} + syncingSteam={syncingSteam} + onSyncSteam={syncSteamDetails} + onCancelTask={cancelTask} + onRetryTask={retryTask} + onRemoveTask={removeTask} + onClearFinishedTasks={clearFinishedTasks} + workshopSourceSettings={settings.workshopSourceSettings} + /> + + {/* Dependency Prompt Container */} + { + handleFilterTabChange('settings', null); + }} + /> + {/* Floating Batch Action Bar */} {isSelectMode && ( setAddToGroupModal(true)} + onBatchAddToMasterCollection={() => setAddToMcModal(true)} + onBatchDownload={handleBatchDownload} + onBatchDelete={(ids) => { + const items = ids.map(id => addons[id] || knownUninstalledAddons[id]).filter(Boolean); + setDeleteConfirmModal({ open: true, addons: items, removeFromKnown: false }); + }} onClearSelection={handleClearSelection} + isSubmitting={isSubmitting} /> )}
diff --git a/src/components/AddToGroupModal.tsx b/src/components/AddToGroupModal.tsx new file mode 100644 index 0000000..3b959af --- /dev/null +++ b/src/components/AddToGroupModal.tsx @@ -0,0 +1,147 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Group } from '../types/addon'; +import { FolderPlus } from 'lucide-react'; + +interface AddToGroupModalProps { + open: boolean; + groups: Group[]; + onCancel: () => void; + onConfirm: (groupId: string) => void; + isSubmitting?: boolean; +} + +export const AddToGroupModal: React.FC = ({ + open, + groups, + onCancel, + onConfirm, + isSubmitting = false, +}) => { + const { t } = useTranslation(); + const [search, setSearch] = useState(''); + const [selectedGroupId, setSelectedGroupId] = useState(null); + + useEffect(() => { + if (open) { + setSearch(''); + setSelectedGroupId(null); + } + }, [open]); + + const filteredGroups = useMemo(() => { + if (!search.trim()) return groups; + const q = search.toLowerCase(); + return groups.filter(g => g.name.toLowerCase().includes(q)); + }, [groups, search]); + + if (!open) return null; + + const handleSubmit = () => { + if (!selectedGroupId || isSubmitting) return; + onConfirm(selectedGroupId); + }; + + return ( +
+
e.stopPropagation()} + > +

{t('batchActionBar.addToGroupTitle')}

+

+ {t('batchActionBar.addToGroupDesc')} +

+ +
+ setSearch(e.target.value)} + disabled={isSubmitting} + autoFocus + style={{ marginBottom: '8px' }} + /> +
+ {filteredGroups.length === 0 ? ( +
+ {t('batchActionBar.noGroups')} +
+ ) : ( + filteredGroups.map(group => { + const isSelected = selectedGroupId === group.id; + return ( + + ); + }) + )} +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/AddonCard.tsx b/src/components/AddonCard.tsx index 11c252e..fe50eb2 100644 --- a/src/components/AddonCard.tsx +++ b/src/components/AddonCard.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { FolderPlus, ExternalLink, Move, Edit3, FileText } from 'lucide-react'; +import { FolderPlus, ExternalLink, Move, Edit3, FileText, Download, Trash2 } from 'lucide-react'; import { Addon, Group } from '../types/addon'; import { formatBytes, getAddonCategories, getAddonUrl, getAddonAuthor, getAddonInfoValue } from '../utils/addonHelpers'; import { CacheImage } from './CacheImage'; @@ -8,17 +8,19 @@ import { useTranslation } from 'react-i18next'; interface AddonCardProps { addon: Addon; groups: Group[]; - onToggle: (vpkName: string, isEnabled: boolean) => void; - onAddToGroup: (vpkName: string, groupId: string) => void; - onRemoveFromGroup: (vpkName: string, groupId: string) => void; + onToggle: (id: string, isEnabled: boolean) => void; + onAddToGroup: (id: string) => void; onOpenLink: (url: string) => void; onMoveClick: (addon: Addon) => void; onRenameClick: (addon: Addon) => void; onDetailClick: (addon: Addon) => void; + onDeleteClick?: (addon: Addon) => void; + onDownload?: (workshopId: string, title?: string, imagePath?: string) => void; isSelectMode: boolean; isSelected: boolean; - onSelectToggle: (vpkName: string) => void; + onSelectToggle: (id: string) => void; isSubmitting?: boolean; + downloadProgress?: Record; } export const AddonCard: React.FC = ({ @@ -26,24 +28,29 @@ export const AddonCard: React.FC = ({ groups, onToggle, onAddToGroup, - onRemoveFromGroup, onOpenLink, onMoveClick, onRenameClick, onDetailClick, + onDeleteClick, + onDownload, isSelectMode, isSelected, onSelectToggle, isSubmitting = false, + downloadProgress = {}, }) => { const { t } = useTranslation(); const categories = getAddonCategories(addon); const title = addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName; const author = getAddonAuthor(addon); - const desc = addon.steamDetails?.description || getAddonInfoValue(addon, 'addondescription') || getAddonInfoValue(addon, 'addontagline') || 'No description provided.'; - - const itemGroup = groups.find(g => g.addons.includes(addon.vpkName)); + const desc = addon.steamDetails?.description || getAddonInfoValue(addon, 'addondescription') || getAddonInfoValue(addon, 'addontagline') || ''; + + const itemGroup = groups.find(g => g.addons.includes(addon.id)); const addonUrl = getAddonUrl(addon); + const isUninstalled = addon.dirType === 'none'; + const isDownloading = addon.workshopId ? downloadProgress[addon.workshopId] !== undefined : false; + const downloadPercent = addon.workshopId ? (downloadProgress[addon.workshopId] ?? 0) : 0; const handleLinkClick = (e: React.MouseEvent, url: string) => { e.preventDefault(); @@ -51,13 +58,14 @@ export const AddonCard: React.FC = ({ }; return ( -
+
{/* Checkbox Wrapper */} -
{ e.stopPropagation(); - onSelectToggle(addon.vpkName); + if (isSubmitting) return; + onSelectToggle(addon.id); }} title={isSelected ? t('addonCard.deselect') : t('addonCard.select')} > @@ -67,12 +75,13 @@ export const AddonCard: React.FC = ({
{/* Clickable Area for Detail Modal or Selection Toggle */} -
{ + if (isSubmitting) return; if (isSelectMode) { e.stopPropagation(); - onSelectToggle(addon.vpkName); + onSelectToggle(addon.id); } else { onDetailClick(addon); } @@ -80,9 +89,10 @@ export const AddonCard: React.FC = ({ >
{addon.imagePath ? ( - @@ -98,20 +108,30 @@ export const AddonCard: React.FC = ({
- - {addon.isEnabled ? t('addonCard.enabled') : t('addonCard.disabled')} - - - {addon.dirType === 'loading' ? t('addonCard.manualInstall') : t('addonCard.workshop')} - + {isUninstalled ? ( + + {t('addonCard.uninstalled')} + + ) : ( + <> + + {addon.isEnabled ? t('addonCard.enabled') : t('addonCard.disabled')} + + + {addon.dirType === 'loading' ? t('addonCard.manualInstall') : t('addonCard.workshop')} + + + )}

{title}

- -
- {t('addonCard.author', { author })} -
+ + {author && author !== 'Unknown Author' && ( +
+ {t('addonCard.author', { author })} +
+ )} {itemGroup && (
@@ -120,12 +140,14 @@ export const AddonCard: React.FC = ({
)} -

{desc}

- -
- {t('addonCard.fileSize', { size: formatBytes(addon.fileSize) })} - {addon.filesCount > 0 && {t('addonCard.containsFiles', { count: addon.filesCount })}} -
+ {desc &&

{desc}

} + + {(addon.fileSize > 0 || addon.filesCount > 0) && ( +
+ {addon.fileSize > 0 && {t('addonCard.fileSize', { size: formatBytes(addon.fileSize) })}} + {addon.filesCount > 0 && {t('addonCard.containsFiles', { count: addon.filesCount })}} +
+ )}
{categories.map(c => ( @@ -142,99 +164,115 @@ export const AddonCard: React.FC = ({ {!isSelectMode && (
- {/* Enable/Disable Toggle */} - - -
- {/* Group assign dropdown */} -
- -
- {groups.map(g => ( - - ))} - {itemGroup && ( - )} - {groups.length === 0 && !itemGroup && ( - - )}
-
+ + ) : ( + // Installed: normal footer + <> + - {/* Open Link with Fallbacks (Dropdown or direct link) */} - {addon.workshopId ? ( -
- -
- - - -
-
- ) : addonUrl ? ( - - ) : null} - - {/* Move between load and workshop dirs (Only from workshop to loading, no moving back) */} - {addon.dirType === 'workshop' && ( - - )} + )} - {/* Rename */} - -
+ + + {onDeleteClick && ( + + )} +
+ + )}
)}
diff --git a/src/components/AlertModal.tsx b/src/components/AlertModal.tsx new file mode 100644 index 0000000..f403bae --- /dev/null +++ b/src/components/AlertModal.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { AlertCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface AlertModalProps { + open: boolean; + title: string; + message: string; + onClose: () => void; +} + +export const AlertModal: React.FC = ({ + open, + title, + message, + onClose, +}) => { + const { t } = useTranslation(); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +

+ + {title} +

+ +
+ {message} +
+ +
+ +
+
+
+ ); +}; diff --git a/src/components/BatchActionBar.test.tsx b/src/components/BatchActionBar.test.tsx index 5f3867b..5bb60cd 100644 --- a/src/components/BatchActionBar.test.tsx +++ b/src/components/BatchActionBar.test.tsx @@ -5,8 +5,8 @@ import { Addon, Group } from '../types/addon'; describe('BatchActionBar', () => { const mockAddons: Record = { - 'addon1.vpk': { vpkName: 'addon1.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, filesCount: 1 }, - 'addon2.vpk': { vpkName: 'addon2.vpk', dirType: 'workshop', isEnabled: false, fileSize: 200, filesCount: 2 }, + 'addon1.vpk': { id: 'addon1.vpk', vpkName: 'addon1.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, filesCount: 1 }, + 'addon2.vpk': { id: 'addon2.vpk', vpkName: 'addon2.vpk', dirType: 'workshop', isEnabled: false, fileSize: 200, filesCount: 2 }, }; const mockGroups: Group[] = [ @@ -15,7 +15,7 @@ describe('BatchActionBar', () => { const filteredItems = Object.values(mockAddons); - test('renders batch actions and fires callbacks', () => { + test('renders batch actions and fires actionable callbacks', () => { const onSelectAll = vi.fn(); const onBatchToggle = vi.fn(); const onBatchMove = vi.fn(); @@ -25,7 +25,7 @@ describe('BatchActionBar', () => { render( { fireEvent.click(screen.getByText('启用')); expect(onBatchToggle).toHaveBeenCalledWith(true); - // Trigger batch disable - fireEvent.click(screen.getByText('禁用')); - expect(onBatchToggle).toHaveBeenCalledWith(false); - // Trigger batch rename fireEvent.click(screen.getByText('自动重命名')); expect(onBatchRename).toHaveBeenCalledTimes(1); @@ -67,7 +63,7 @@ describe('BatchActionBar', () => { render( { fireEvent.click(moveBtn); expect(onBatchMove).toHaveBeenCalledTimes(1); }); + + test('fires disable callback for actionable loading selection', () => { + const onBatchToggle = vi.fn(); + const onBatchRename = vi.fn(); + const onClearSelection = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('禁用')); + expect(onBatchToggle).toHaveBeenCalledWith(false); + + fireEvent.click(screen.getByText('自动重命名')); + expect(onBatchRename).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText('退出批量')); + expect(onClearSelection).toHaveBeenCalledTimes(1); + }); + + test('disables no-op enable or disable actions based on actual addon state', () => { + render( + + ); + + expect(screen.queryByText('启用')).toBeNull(); + expect((screen.getByText('禁用').closest('button') as HTMLButtonElement).disabled).toBe(false); + }); }); diff --git a/src/components/BatchActionBar.tsx b/src/components/BatchActionBar.tsx index 614b1fb..e6f4772 100644 --- a/src/components/BatchActionBar.tsx +++ b/src/components/BatchActionBar.tsx @@ -1,120 +1,245 @@ -import { CheckSquare, Unlock, Lock, FolderOpen, Edit3, FolderPlus, X } from 'lucide-react'; -import { Addon, Group } from '../types/addon'; +import { CheckSquare, Square, Unlock, Lock, FolderOpen, Edit3, FolderPlus, Library, X, Download, HardDrive, Globe, Trash2 } from 'lucide-react'; +import { Addon, Group, MasterCollection } from '../types/addon'; import { useTranslation } from 'react-i18next'; interface BatchActionBarProps { - selectedVpkNames: string[]; + selectedIds: string[]; filteredItems: Addon[]; addons: Record; + knownUninstalledAddons?: Record; groups: Group[]; + masterCollections?: MasterCollection[]; + selectedGroupIds?: string[]; onSelectAll: (visibleItems: Addon[]) => void; + onSelectAllGroups?: (groups: Group[]) => void; onBatchToggle: (enabled: boolean) => void; onBatchMove: () => void; onBatchRename: () => void; - onBatchAddToGroup: (groupId: string) => void; + onBatchAddToGroup: () => void; + onBatchAddToMasterCollection?: () => void; + onBatchDownload?: (ids: string[]) => void; + onBatchDelete?: (ids: string[]) => void; onClearSelection: () => void; + isSubmitting?: boolean; } export function BatchActionBar({ - selectedVpkNames, + selectedIds, filteredItems, addons, + knownUninstalledAddons = {}, groups, + masterCollections = [], + selectedGroupIds = [], onSelectAll, + onSelectAllGroups, onBatchToggle, onBatchMove, onBatchRename, onBatchAddToGroup, + onBatchAddToMasterCollection, + onBatchDownload, + onBatchDelete, onClearSelection, + isSubmitting = false, }: BatchActionBarProps) { const { t } = useTranslation(); - const isAllSelected = filteredItems.every(item => selectedVpkNames.includes(item.vpkName)); - const hasWorkshopSelected = selectedVpkNames.some(name => addons[name]?.dirType === 'workshop'); + + // Determine what's selected + const hasAddonSelection = selectedIds.length > 0; + const hasGroupSelection = selectedGroupIds.length > 0; + const totalSelected = selectedIds.length + selectedGroupIds.length; + + const selectedAddons = selectedIds + .map((id) => addons[id] || knownUninstalledAddons[id]) + .filter((addon): addon is Addon => Boolean(addon)); + + // If any uninstalled addon is selected, certain actions shouldn't be available + const allInstalled = selectedAddons.every((addon) => addon.dirType !== 'none'); + + const canBatchEnable = allInstalled && selectedAddons.some((addon) => !addon.isEnabled); + const canBatchDisable = allInstalled && selectedAddons.some((addon) => addon.isEnabled); + const canBatchMove = allInstalled && selectedAddons.some((addon) => addon.dirType === 'workshop'); + const canBatchRename = allInstalled && selectedAddons.length > 0; + const canBatchDelete = allInstalled && selectedAddons.length > 0; + + const uninstalledIds = selectedAddons.filter(a => a.dirType === 'none').map(a => a.id); + const installedIds = selectedAddons.filter(a => a.dirType !== 'none').map(a => a.id); + const canBatchDownload = uninstalledIds.length > 0; + + // Check if we're in master collection view (only groups selected) + const isGroupOnlySelection = hasGroupSelection && !hasAddonSelection; + + const hasDownloaded = filteredItems.some((item) => item.dirType !== 'none'); + const hasUndownloaded = filteredItems.some((item) => item.dirType === 'none'); + const showSelectiveSelection = selectedIds.length === 0 && hasDownloaded && hasUndownloaded; return (
- {t('batchActionBar.selectedCount', { count: selectedVpkNames.length })} + {totalSelected === 0 ? t('batchActionBar.noneSelected') : t('batchActionBar.selectedCount', { count: totalSelected })}
- + {/* Select All button */} + {!isGroupOnlySelection && ( + <> + - + {showSelectiveSelection && ( + <> + - + + + )} + + )} - {hasWorkshopSelected && ( - )} - + {/* Addon-specific actions */} + {hasAddonSelection && ( + <> + {canBatchEnable && ( + + )} -
- + )} + + {canBatchMove && ( + + )} + + {canBatchRename && ( + + )} + + {canBatchDelete && onBatchDelete && ( + + )} + + + + {canBatchDownload && onBatchDownload && ( + + )} + + )} + + {/* Group-specific actions */} + {hasGroupSelection && onBatchAddToMasterCollection && ( + -
- {groups.map(g => ( - - ))} - {groups.length === 0 && ( - - )} -
-
+ )} - + + {nonWorkshopItems.length > 0 && workshopItems.length > 0 && ( + + )} + + +
+
+
+ ); +}; diff --git a/src/components/DependencyPromptContainer.test.tsx b/src/components/DependencyPromptContainer.test.tsx new file mode 100644 index 0000000..304254f --- /dev/null +++ b/src/components/DependencyPromptContainer.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { DependencyPromptContainer } from './DependencyPromptContainer'; +import type { BackgroundTask, Settings } from '../types/addon'; + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +const settings: Settings = { + workshopDir: '', + loadingDir: '', + downloadConcurrency: 2, + enableDummyBypass: false, + suppressSdkUnavailableWarning: false, + disableSteamworksSdk: false, + dependencyMissingBehavior: 'ask', +}; + +const runningCheck: BackgroundTask = { + id: 'dependency-check-root', + kind: 'dependency-check', + status: 'running', + targetIds: ['root'], + progress: 50, + createdAt: '2026-07-12T00:00:00Z', + dependencyCheck: { + rootIds: ['root'], + discoveredCount: 2, + completedCount: 1, + failedNodes: [], + discoveredDependencies: [], + }, +}; + +describe('DependencyPromptContainer', () => { + test('prompts for dependencies discovered for an unknown workshop item', async () => { + const onDownload = vi.fn(); + const { rerender } = render( + , + ); + + rerender( + , + ); + + await waitFor(() => { + expect(screen.getByText('Dependency addon')).toBeDefined(); + expect(screen.getByText('Dependency author')).toBeDefined(); + }); + + fireEvent.click(screen.getByText('下载选中项')); + expect(onDownload).toHaveBeenCalledWith([{ + workshopId: 'dependency', + title: 'Dependency addon', + imagePath: 'https://example.com/dependency.jpg', + }]); + }); +}); diff --git a/src/components/DependencyPromptContainer.tsx b/src/components/DependencyPromptContainer.tsx new file mode 100644 index 0000000..b6ff6c3 --- /dev/null +++ b/src/components/DependencyPromptContainer.tsx @@ -0,0 +1,257 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { Addon, BackgroundTask, Settings } from '../types/addon'; +import { DependencyDownloadItem, DependencyPromptModal } from './DependencyPromptModal'; + +type DiscoveredDependency = NonNullable< + NonNullable['discoveredDependencies'] +>[number]; + +interface DependencyPromptContainerProps { + addons: Record; + knownUninstalledAddons: Record; + backgroundTasks: BackgroundTask[]; + settings: Settings; + onDownload: (items: DependencyDownloadItem[]) => void; + onGoToSettings: () => void; +} + +export const DependencyPromptContainer: React.FC = ({ + addons, + knownUninstalledAddons, + backgroundTasks, + settings, + onDownload, + onGoToSettings, +}) => { + const behavior = settings.dependencyMissingBehavior || 'ask'; + + const [modalOpen, setModalOpen] = useState(false); + const [missingDependencies, setMissingDependencies] = useState([]); + const [isScanning, setIsScanning] = useState(false); + + const toDownloadItems = (items: Addon[]): DependencyDownloadItem[] => ( + items.map((addon) => ({ + workshopId: addon.workshopId || addon.id, + title: addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName, + imagePath: addon.imagePath || addon.workshopDetails?.previewUrl, + })) + ); + + // Track previous task counts to detect completion + const prevDownloadCountRef = useRef(0); + const prevScanCountRef = useRef(0); + const pendingDependencyEvaluationRef = useRef(false); + const pendingDependencyCandidatesRef = useRef>(new Map()); + + // To avoid repeatedly prompting for the same missing dependencies if user clicks "Cancel" or "Ignore", + // we can maintain a Set of ignored workshop IDs per session. + const ignoredDependencyIdsRef = useRef>(new Set()); + + // Determine current active task counts + const activeDownloadTasks = backgroundTasks.filter(t => t.kind === 'download' && (t.status === 'queued' || t.status === 'running')); + const activeScanTasks = backgroundTasks.filter(t => t.kind === 'dependency-check' && (t.status === 'queued' || t.status === 'running')); + + const currentDownloadCount = activeDownloadTasks.length; + const currentScanCount = activeScanTasks.length; + const currentlyScanning = currentScanCount > 0; + + // Compute missing dependencies globally + const calculateMissingDependencies = (taskDependencies: DiscoveredDependency[] = []) => { + const missingIds = new Set(); + + // Check requiredItems of all addons we have (installed, disabled, downloading, or in knownUninstalled) + const ownedAddons = new Set(); + + // 1. Addons that are installed + Object.values(addons).forEach(addon => { + if (addon.dirType !== 'none') { + ownedAddons.add(addon); + } + }); + + // Helper to find addon by workshopId + const findAddon = (id: string) => { + const normalizedId = String(id || '').trim(); + if (!normalizedId) return undefined; + + const direct = addons[normalizedId] || knownUninstalledAddons[normalizedId]; + if (direct) return direct; + + return Object.values(addons).find(a => + a.workshopId === normalizedId || a.id === normalizedId + ) || Object.values(knownUninstalledAddons).find(a => + a.workshopId === normalizedId || a.id === normalizedId + ); + }; + + // 2. Addons that are in download queue + activeDownloadTasks.forEach(task => { + task.targetIds.forEach(id => { + const addon = findAddon(id); + if (addon) { + ownedAddons.add(addon); + } + }); + }); + + // Gather their required items + const requiredItems = new Map(); + + ownedAddons.forEach(addon => { + if (addon.workshopDetails?.requiredItems) { + addon.workshopDetails.requiredItems.forEach(req => { + requiredItems.set(req.workshopId, req); + }); + } + }); + + taskDependencies.forEach((dependency) => { + const workshopId = dependency.workshopId?.trim(); + if (workshopId) { + requiredItems.set(workshopId, { ...dependency, workshopId }); + } + }); + + // Filter out dependencies that are already installed or in download queue + requiredItems.forEach((_, reqId) => { + const isInstalled = Object.values(addons).some(a => (a.workshopId === reqId || a.id === reqId) && a.dirType !== 'none'); + const isDownloading = activeDownloadTasks.some(t => t.targetIds.includes(reqId)); + const isIgnored = ignoredDependencyIdsRef.current.has(reqId); + + if (!isInstalled && !isDownloading && !isIgnored) { + missingIds.add(reqId); + } + }); + + // Map ids back to full Addon objects for the UI + return Array.from(missingIds).map(id => { + const existing = findAddon(id); + if (existing) return existing; + + // Fallback if not found in db yet + const reqInfo = requiredItems.get(id); + return { + id, + vpkName: id, + dirType: 'none', + isEnabled: false, + fileSize: 0, + filesCount: 0, + workshopId: id, + imagePath: reqInfo?.previewUrl, + addonInfo: { + addontitle: reqInfo?.title || id + }, + steamDetails: { + title: reqInfo?.title || id, + creator_name: reqInfo?.creatorName, + }, + workshopDetails: { + title: reqInfo?.title || id, + previewUrl: reqInfo?.previewUrl, + creatorName: reqInfo?.creatorName, + authorName: reqInfo?.creatorName, + }, + } as Addon; + }); + }; + + useEffect(() => { + // Check for transition from >0 to 0 + const downloadsFinished = prevDownloadCountRef.current > 0 && currentDownloadCount === 0; + const scansFinished = prevScanCountRef.current > 0 && currentScanCount === 0; + + if (downloadsFinished || scansFinished) { + pendingDependencyEvaluationRef.current = true; + if (scansFinished) { + backgroundTasks + .filter((task) => ( + task.kind === 'dependency-check' && + (task.status === 'completed' || task.status === 'failed') + )) + .flatMap((task) => task.dependencyCheck?.discoveredDependencies || []) + .forEach((dependency) => { + const workshopId = dependency.workshopId?.trim(); + if (workshopId) { + pendingDependencyCandidatesRef.current.set(workshopId, { + workshopId, + title: dependency.title, + previewUrl: dependency.previewUrl, + creatorName: dependency.creatorName, + }); + } + }); + } + } + + if (pendingDependencyEvaluationRef.current) { + if (behavior === 'ignore') { + pendingDependencyEvaluationRef.current = false; + } else { + const missing = calculateMissingDependencies([...pendingDependencyCandidatesRef.current.values()]); + + if (missing.length > 0) { + pendingDependencyEvaluationRef.current = false; + if (behavior === 'always') { + // Automatically download them + onDownload(toDownloadItems(missing)); + } else if (behavior === 'ask') { + // Check if we are already showing the modal. + // We can update the missing dependencies list or open the modal. + setMissingDependencies(missing); + setIsScanning(currentlyScanning); + setModalOpen(true); + } + } else if (!currentlyScanning && currentDownloadCount === 0) { + // The dependency task can complete before its database payload reaches + // this component. Keep evaluating when addon state changes. + setModalOpen(false); + } + } + } else if (modalOpen) { + // Update modal state dynamically if it's open (e.g. scanning state changes) + setIsScanning(currentlyScanning); + // We can also dynamically update the missing list if new ones are found or some started downloading + const missing = calculateMissingDependencies([...pendingDependencyCandidatesRef.current.values()]); + if (missing.length === 0) { + setModalOpen(false); + } else { + setMissingDependencies(missing); + } + } + + prevDownloadCountRef.current = currentDownloadCount; + prevScanCountRef.current = currentScanCount; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentDownloadCount, currentScanCount, behavior, addons, knownUninstalledAddons, backgroundTasks]); + + const handleDownload = (items: DependencyDownloadItem[]) => { + onDownload(items); + setModalOpen(false); + }; + + const handleCancel = () => { + // If not scanning and user cancels, we add these to ignored list so they aren't repeatedly prompted + if (!currentlyScanning) { + missingDependencies.forEach(m => ignoredDependencyIdsRef.current.add(m.workshopId || m.id)); + } + setModalOpen(false); + }; + + const handleGoToSettings = () => { + setModalOpen(false); + onGoToSettings(); + }; + + return ( + + ); +}; diff --git a/src/components/DependencyPromptModal.tsx b/src/components/DependencyPromptModal.tsx new file mode 100644 index 0000000..2495225 --- /dev/null +++ b/src/components/DependencyPromptModal.tsx @@ -0,0 +1,240 @@ +import React, { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Search, X, AlertTriangle, Package, Check } from 'lucide-react'; +import { Addon } from '../types/addon'; +import { CacheImage } from './CacheImage'; + +export interface DependencyDownloadItem { + workshopId: string; + title?: string; + imagePath?: string; +} + +interface DependencyPromptModalProps { + open: boolean; + missingDependencies: Addon[]; + isScanning: boolean; + onDownload: (items: DependencyDownloadItem[]) => void; + onCancel: () => void; + onGoToSettings: () => void; +} + +export const DependencyPromptModal: React.FC = ({ + open, + missingDependencies, + isScanning, + onDownload, + onCancel, + onGoToSettings, +}) => { + const { t } = useTranslation(); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [showSecondaryWarning, setShowSecondaryWarning] = useState(false); + + // Initialize selectedIds when modal opens or missingDependencies change + React.useEffect(() => { + if (open) { + setSelectedIds(new Set(missingDependencies.map(a => a.workshopId || a.id))); + setShowSecondaryWarning(false); + setSearchQuery(''); + } + }, [open, missingDependencies]); + + const filteredDependencies = useMemo(() => { + if (!searchQuery.trim()) return missingDependencies; + const lowerQuery = searchQuery.toLowerCase(); + return missingDependencies.filter(addon => { + const title = addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName || addon.id; + const author = addon.steamDetails?.creator_name || addon.addonInfo?.addonauthor || addon.workshopDetails?.authorName || ''; + const id = addon.workshopId || addon.id; + return title.toLowerCase().includes(lowerQuery) || + author.toLowerCase().includes(lowerQuery) || + id.includes(lowerQuery); + }); + }, [missingDependencies, searchQuery]); + + if (!open) return null; + + const handleSelectAll = (checked: boolean) => { + if (checked) { + setSelectedIds(new Set(filteredDependencies.map(a => a.workshopId || a.id))); + } else { + setSelectedIds(new Set()); + } + }; + + const handleSelect = (id: string, checked: boolean) => { + const next = new Set(selectedIds); + if (checked) { + next.add(id); + } else { + next.delete(id); + } + setSelectedIds(next); + }; + + const allFilteredSelected = filteredDependencies.length > 0 && filteredDependencies.every(a => selectedIds.has(a.workshopId || a.id)); + const someFilteredSelected = filteredDependencies.some(a => selectedIds.has(a.workshopId || a.id)) && !allFilteredSelected; + + const handleDownload = () => { + if (selectedIds.size > 0) { + onDownload( + missingDependencies + .filter((addon) => selectedIds.has(addon.workshopId || addon.id)) + .map((addon) => ({ + workshopId: addon.workshopId || addon.id, + title: addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName, + imagePath: addon.imagePath || addon.workshopDetails?.previewUrl, + })), + ); + } + }; + + if (showSecondaryWarning) { + return ( +
+
+
+ +

{t('dependencyPrompt.warningTitle', '您确定吗?')}

+
+

+ {t('dependencyPrompt.warningDesc', '虽然一些少数附件确实有一些可选的依赖组件. 但请确认您有充足的理由不会下载它们其中任何一个组件, 和这么做对组件还有游戏的影响.')} +

+
+ + +
+
+
+ ); + } + + return ( +
+
+
+

{t('dependencyPrompt.title', '发现缺失的依赖项')}

+
+ {t('dependencyPrompt.desc', '这些组件是您已安装或正在下载的附件所需的依赖。')} + + {t('dependencyPrompt.goToSettings', '前往设置修改默认行为')} + +
+
+ +
+
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + setSearchQuery('')} /> + )} +
+
+ {t('dependencyPrompt.selectedCount', '已选: {{count}}', { count: selectedIds.size })} +
+
+ +
+
handleSelectAll(!allFilteredSelected)} + > +
+ {(allFilteredSelected || someFilteredSelected) && } +
+ {t('dependencyPrompt.selectAll', '全选 (本页)')} +
+
+ +
+ {filteredDependencies.length === 0 ? ( +
+ {t('dependencyPrompt.noResults', '没有找到匹配的依赖项')} +
+ ) : ( +
+ {filteredDependencies.map(addon => { + const id = addon.workshopId || addon.id; + const title = addon.steamDetails?.title || addon.addonInfo?.addontitle || addon.vpkName || id; + const author = addon.steamDetails?.creator_name || addon.addonInfo?.addonauthor || addon.workshopDetails?.authorName || t('common.unknownAuthor'); + const imageSrc = addon.imagePath || addon.workshopDetails?.previewUrl; + const isSelected = selectedIds.has(id); + + return ( +
handleSelect(id, !isSelected)} + style={{ + display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 12px', + borderRadius: '8px', + background: isSelected ? 'var(--md-sys-color-secondary-container)' : 'var(--md-sys-color-surface-container)', + outline: isSelected ? '2px solid var(--md-sys-color-primary)' : '1px solid transparent', + cursor: 'pointer', transition: 'all 0.2s' + }} + > +
+ {imageSrc ? ( + + ) : ( + + )} +
+
+
+ {title} +
+
+ {author} + + ID: {id} +
+
+
+ ); + })} +
+ )} +
+ +
+ {isScanning ? ( + + ) : ( + + )} + + {selectedIds.size > 0 && ( + + )} +
+
+
+ ); +}; diff --git a/src/components/DetailModal.test.tsx b/src/components/DetailModal.test.tsx new file mode 100644 index 0000000..e37ecc9 --- /dev/null +++ b/src/components/DetailModal.test.tsx @@ -0,0 +1,123 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { DetailModal } from './DetailModal'; +import type { Addon } from '../types/addon'; +import type { WorkshopPageDetails } from './workshop/types'; + +const { + mockFetchWorkshopPageDetails, + mockGetWorkshopPageSnapshot, + mockPersistWorkshopPageDetails, +} = vi.hoisted(() => ({ + mockFetchWorkshopPageDetails: vi.fn(), + mockGetWorkshopPageSnapshot: vi.fn(), + mockPersistWorkshopPageDetails: vi.fn(), +})); + +vi.mock('../services/workshopClient', () => ({ + fetchWorkshopPageDetails: (...args: unknown[]) => mockFetchWorkshopPageDetails(...args), + getWorkshopPageSnapshot: (...args: unknown[]) => mockGetWorkshopPageSnapshot(...args), + persistWorkshopPageDetails: (...args: unknown[]) => mockPersistWorkshopPageDetails(...args), +})); + +function createAddon(id: string, workshopId: string): Addon { + return { + id, + vpkName: `${id}.vpk`, + workshopId, + dirType: 'none', + isEnabled: false, + fileSize: 0, + filesCount: 0, + }; +} + +function createPageDetails(title: string): WorkshopPageDetails { + return { + title, + imageGallery: [], + tags: [], + requiredItems: [], + parentCollections: [], + }; +} + +describe('DetailModal', () => { + beforeEach(() => { + mockFetchWorkshopPageDetails.mockReset(); + mockGetWorkshopPageSnapshot.mockReset(); + mockPersistWorkshopPageDetails.mockReset(); + mockGetWorkshopPageSnapshot.mockResolvedValue(null); + mockPersistWorkshopPageDetails.mockResolvedValue({}); + }); + + test('ignores stale workshop detail responses after navigating to another dependency item', async () => { + let resolveFirst: ((value: WorkshopPageDetails) => void) | undefined; + let resolveSecond: ((value: WorkshopPageDetails) => void) | undefined; + + mockFetchWorkshopPageDetails.mockImplementation((workshopId: string) => { + if (workshopId === '100') { + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + if (workshopId === '200') { + return new Promise((resolve) => { + resolveSecond = resolve; + }); + } + throw new Error(`Unexpected workshop id: ${workshopId}`); + }); + + const baseProps = { + open: true, + groups: [], + onCancel: vi.fn(), + onToggle: vi.fn(), + onMove: vi.fn(), + onOpenLink: vi.fn(), + addons: {}, + knownUninstalledAddons: {}, + onItemNavigate: vi.fn(), + onDatabaseUpdate: vi.fn(), + onDownload: vi.fn(), + downloadProgress: {}, + isSubmitting: false, + }; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(mockFetchWorkshopPageDetails).toHaveBeenCalledWith('100', 'addon-detail'); + }); + + rerender( + , + ); + + await waitFor(() => { + expect(mockFetchWorkshopPageDetails).toHaveBeenCalledWith('200', 'addon-detail'); + }); + + await act(async () => { + resolveSecond?.(createPageDetails('Second dependency')); + }); + + expect(screen.getByText('Second dependency')).toBeDefined(); + + await act(async () => { + resolveFirst?.(createPageDetails('First dependency')); + }); + + expect(screen.queryByText('First dependency')).toBeNull(); + expect(screen.getByText('Second dependency')).toBeDefined(); + }); +}); diff --git a/src/components/DetailModal.tsx b/src/components/DetailModal.tsx index 39e5874..a82ff58 100644 --- a/src/components/DetailModal.tsx +++ b/src/components/DetailModal.tsx @@ -1,18 +1,28 @@ -import React from 'react'; -import { X, ExternalLink, FileText, Move, FolderPlus } from 'lucide-react'; -import { Addon, Group } from '../types/addon'; -import { formatBytes, getAddonCategories, getAddonUrl, getAddonAuthor, getAddonInfoValue } from '../utils/addonHelpers'; -import { CacheImage } from './CacheImage'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { X, ExternalLink, Move, FolderPlus, Loader2, Download } from 'lucide-react'; +import { Addon, DatabasePayload, Group } from '../types/addon'; +import { formatBytes, getAddonCategories, getAddonUrl, getAddonAuthor, getAddonInfoValue, isPlaceholderAuthorName } from '../utils/addonHelpers'; import { useTranslation } from 'react-i18next'; +import { WorkshopPageDetails } from './workshop/types'; +import { Gallery } from './Gallery'; +import { RequiredItems } from './WorkshopCommon'; +import { fetchWorkshopPageDetails, getWorkshopPageSnapshot, persistWorkshopPageDetails } from '../services/workshopClient'; interface DetailModalProps { open: boolean; addon: Addon | null; groups: Group[]; onCancel: () => void; - onToggle: (vpkName: string, isEnabled: boolean) => void; + onToggle: (id: string, isEnabled: boolean) => void; onMove: (addon: Addon) => void; onOpenLink: (url: string) => void; + addons: Record; + knownUninstalledAddons: Record; + onItemNavigate: (workshopId: string) => void; + onDatabaseUpdate?: (data: DatabasePayload) => void; + onDownload?: (workshopId: string, title?: string, imagePath?: string) => void; + downloadProgress?: Record; + isSubmitting?: boolean; } export const DetailModal: React.FC = ({ @@ -23,21 +33,111 @@ export const DetailModal: React.FC = ({ onToggle, onMove, onOpenLink, + addons, + knownUninstalledAddons, + onItemNavigate, + onDatabaseUpdate, + onDownload, + downloadProgress = {}, + isSubmitting = false, }) => { const { t } = useTranslation(); + const [pageDetails, setPageDetails] = useState(null); + const [pageDetailsLoading, setPageDetailsLoading] = useState(false); + const pageDetailsRequestRef = useRef(0); + + const fetchPageDetails = useCallback(async (workshopId: string) => { + const requestId = ++pageDetailsRequestRef.current; + setPageDetailsLoading(true); + setPageDetails(null); + + const snapshot = await getWorkshopPageSnapshot(workshopId); + if (pageDetailsRequestRef.current !== requestId) return; + + if (snapshot) { + setPageDetails(snapshot); + try { + const data: DatabasePayload = await persistWorkshopPageDetails(workshopId, snapshot, 'addon-detail') as DatabasePayload; + if (pageDetailsRequestRef.current === requestId) { + onDatabaseUpdate?.(data); + } + } catch (err) { + console.error('Failed to persist workshop snapshot details:', err); + } + } + + try { + const details = await fetchWorkshopPageDetails(workshopId, 'addon-detail'); + if (pageDetailsRequestRef.current !== requestId) return; + setPageDetails(details); + const data: DatabasePayload = await persistWorkshopPageDetails(workshopId, details, 'addon-detail') as DatabasePayload; + if (pageDetailsRequestRef.current === requestId) { + onDatabaseUpdate?.(data); + } + } catch (err) { + if (pageDetailsRequestRef.current === requestId) { + console.error('Failed to fetch workshop page details:', err); + } + } finally { + if (pageDetailsRequestRef.current === requestId) { + setPageDetailsLoading(false); + } + } + }, [onDatabaseUpdate]); + + useEffect(() => { + if (open && addon?.workshopId) { + void fetchPageDetails(addon.workshopId); + } else { + pageDetailsRequestRef.current += 1; + setPageDetails(null); + setPageDetailsLoading(false); + } + }, [open, addon?.workshopId, fetchPageDetails]); + if (!open || !addon) return null; - const title = addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName; - const author = getAddonAuthor(addon); - const desc = addon.steamDetails?.description || getAddonInfoValue(addon, 'addondescription') || getAddonInfoValue(addon, 'addontagline') || 'No description provided.'; + const title = pageDetails?.title + || addon.workshopDetails?.title + || addon.steamDetails?.title + || getAddonInfoValue(addon, 'addontitle') + || addon.vpkName; + const author = [ + pageDetails?.creatorName, + addon.workshopDetails?.creatorName, + addon.workshopDetails?.authorName, + ].find((value) => !isPlaceholderAuthorName(value)) || getAddonAuthor(addon); + const desc = pageDetails?.description + || addon.workshopDetails?.description + || addon.steamDetails?.description + || getAddonInfoValue(addon, 'addondescription') + || getAddonInfoValue(addon, 'addontagline') + || 'No description provided.'; const categories = getAddonCategories(addon); - const itemGroup = groups.find(g => g.addons.includes(addon.vpkName)); + const itemGroup = groups.find(g => g.addons.includes(addon.id)); const addonUrl = getAddonUrl(addon); + const cachedGallery = addon.workshopDetails?.imageGallery || addon.workshopDetails?.galleryUrls || []; + const galleryDetails = pageDetails?.imageGallery || cachedGallery; + const requiredItems = pageDetails?.requiredItems || addon.workshopDetails?.requiredItems || []; - const addonauthorSteamID = getAddonInfoValue(addon, 'addonauthorsteamid'); + const addonauthorSteamID = pageDetails?.creatorSteamId + || addon.workshopDetails?.creatorSteamId + || getAddonInfoValue(addon, 'addonauthorsteamid'); const addonversion = getAddonInfoValue(addon, 'addonversion'); + const isUninstalled = addon.dirType === 'none'; + const isDownloading = addon.workshopId ? (downloadProgress[addon.workshopId] !== undefined) : false; + const downloadPercent = addon.workshopId ? (downloadProgress[addon.workshopId] ?? 0) : 0; + + const displaySize = addon.fileSize + ? formatBytes(addon.fileSize) + : addon.steamDetails?.file_size + ? formatBytes(parseInt(addon.steamDetails.file_size)) + : addon.workshopDetails?.fileSizeDisplay + ? addon.workshopDetails.fileSizeDisplay + : null; + const handleOpenLinkClick = (e: React.MouseEvent, url: string) => { e.preventDefault(); onOpenLink(url); @@ -58,32 +158,33 @@ export const DetailModal: React.FC = ({
-
- {addon.imagePath ? ( - - -
- } - /> - ) : ( - - )} -
+ u !== addon.imagePath)] + : galleryDetails + } + title={title} + fallbackImage={addon.imagePath} + cacheRemote + fallbackIcon={ +
+ +
+ } + />
{t('detailModal.fileName')} {addon.vpkName}
-
- {t('detailModal.addonSize')} - {formatBytes(addon.fileSize)} -
+ {displaySize && ( +
+ {t('detailModal.addonSize')} + {displaySize} +
+ )} {addon.filesCount > 0 && (
{t('detailModal.filesInVpk')} @@ -93,14 +194,24 @@ export const DetailModal: React.FC = ({
{t('detailModal.directory')} - {addon.dirType === 'loading' ? t('detailModal.manualInstall') : t('detailModal.workshop')} + {isUninstalled + ? t('addonCard.uninstalled') + : addon.dirType === 'loading' + ? t('detailModal.manualInstall') + : t('detailModal.workshop')}
{t('detailModal.currentStatus')} - - {addon.isEnabled ? t('detailModal.enabled') : t('detailModal.disabled')} - + {isUninstalled ? ( + + {t('addonCard.uninstalled')} + + ) : ( + + {addon.isEnabled ? t('detailModal.enabled') : t('detailModal.disabled')} + + )}
{addon.workshopId && (
@@ -167,6 +278,22 @@ export const DetailModal: React.FC = ({
{t('detailModal.descriptionLabel')}
{desc}
+ + {/* Required items */} + + + {/* Loading indicator for page details */} + {pageDetailsLoading && ( +
+ + {t('workshop.detail.loadingExtra')} +
+ )}
@@ -198,14 +325,6 @@ export const DetailModal: React.FC = ({ > {t('detailModal.webViewOfficial')} - handleOpenLinkClick(e, `https://steamcommunity.net/sharedfiles/filedetails/?id=${addon.workshopId}`)} - className="btn btn-text" - style={{ display: 'inline-flex', gap: '6px' }} - > - {t('detailModal.mirrorWebView')} - )} {!addon.workshopId && addonUrl && ( @@ -222,17 +341,37 @@ export const DetailModal: React.FC = ({
- + {isUninstalled ? ( + addon.workshopId && onDownload && ( + + ) + ) : ( + + )} {addon.dirType === 'workshop' && ( + +
+
+
+ ); + } + return (
-
e.stopPropagation()}> -

{t('editGroupModal.title')}

+ e.stopPropagation()} style={{ maxWidth: '480px', borderRadius: '28px', padding: '24px' }}> +

编辑分组

-
- + {/* Group Name */} +
+ = ({ onChange={(e) => setName(e.target.value)} required disabled={isSubmitting} + style={{ borderRadius: '12px' }} />
-
+ {/* Group Tags */} +
+
+ + +
+ setTagsInput(e.target.value)} + placeholder="例如: 战役, 武器模型, 皮肤" + disabled={isSubmitting} + style={{ borderRadius: '12px' }} + /> +
+ + {/* Steam Workshop Collection ID */} +
+ + setCollectionId(e.target.value)} + placeholder="例如: 3560901999" + disabled={isSubmitting} + style={{ borderRadius: '12px' }} + /> +
+ + {/* Warning Alert */} + {isCollectionChanged && collectionId.trim() && ( +
+ +
+ 安全警告:设置合集 ID 后,保存将自动覆盖该分组的文件列表,所有不属于该合集的自定义归类组件将被移出当前分组。 +
+
+ )} + +
-
diff --git a/src/components/ErrorBoundary.test.tsx b/src/components/ErrorBoundary.test.tsx new file mode 100644 index 0000000..b04e4e6 --- /dev/null +++ b/src/components/ErrorBoundary.test.tsx @@ -0,0 +1,82 @@ +import { describe, test, expect, vi, beforeAll, afterEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ErrorBoundary } from './ErrorBoundary'; +// import React from 'react'; + +const ThrowError = () => { + throw new Error('Test Render Error'); +}; + +describe('ErrorBoundary', () => { + const reloadMock = vi.fn(); + + beforeAll(() => { + // Suppress console.error output from expected React errors in test logs + vi.spyOn(console, 'error').mockImplementation(() => {}); + + Object.defineProperty(window, 'location', { + configurable: true, + value: { reload: reloadMock }, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + test('renders children if no error is thrown', () => { + render( + +
Safe Component
+
+ ); + + expect(screen.getByText('Safe Component')).toBeDefined(); + }); + + test('renders fallback UI and details when a child component throws', () => { + render( + + + + ); + + expect(screen.getByText('应用遇到意外错误 (Frontend Crash)')).toBeDefined(); + expect(screen.getByText('Error: Error: Test Render Error')).toBeDefined(); + expect(screen.getByText('尝试恢复界面')).toBeDefined(); + expect(screen.getByText('重新加载应用')).toBeDefined(); + }); + + test('resets error state when clicking recover button', () => { + const { rerender } = render( + + + + ); + + expect(screen.getByText('应用遇到意外错误 (Frontend Crash)')).toBeDefined(); + + // Rerender with a safe component first (but still in error state) + rerender( + +
Recovered Component
+
+ ); + + // Click recover/reset button + fireEvent.click(screen.getByText('尝试恢复界面')); + + expect(screen.getByText('Recovered Component')).toBeDefined(); + }); + + test('reloads page when clicking reload button', () => { + render( + + + + ); + + fireEvent.click(screen.getByText('重新加载应用')); + expect(reloadMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx new file mode 100644 index 0000000..99d22ee --- /dev/null +++ b/src/components/Gallery.tsx @@ -0,0 +1,334 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { ChevronLeft, ChevronRight, X, FileText } from 'lucide-react'; +import { CacheImage } from './CacheImage'; + +interface GalleryProps { + gallery: string[]; + title: string; + fallbackImage?: string; + fallbackIcon?: React.ReactNode; + cacheRemote?: boolean; +} + +export const Gallery: React.FC = ({ + gallery, + title, + fallbackImage, + fallbackIcon, + cacheRemote = false, +}) => { + const [galleryIndex, setGalleryIndex] = useState(0); + const [fullscreenImageIndex, setFullscreenImageIndex] = useState(null); + const thumbnailContainerRef = useRef(null); + const hasGallery = gallery.length > 0; + + // Scroll thumbnail into view when galleryIndex changes + useEffect(() => { + if (thumbnailContainerRef.current) { + const container = thumbnailContainerRef.current; + const activeThumb = container.children[galleryIndex] as HTMLElement; + if (activeThumb) { + const containerRect = container.getBoundingClientRect(); + const thumbRect = activeThumb.getBoundingClientRect(); + if (thumbRect.left < containerRect.left || thumbRect.right > containerRect.right) { + activeThumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); + } + } + } + }, [galleryIndex]); + + // Reset indices if gallery changes (e.g. switching items) + useEffect(() => { + setGalleryIndex(0); + setFullscreenImageIndex(null); + }, [gallery]); + + return ( + <> +
+ {hasGallery ? ( + <> +
+ {gallery.map((url, i) => ( +
setFullscreenImageIndex(i)} + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + opacity: i === galleryIndex ? 1 : 0, + transition: 'opacity 0.3s ease-in-out', + pointerEvents: i === galleryIndex ? 'auto' : 'none', + zIndex: i === galleryIndex ? 1 : 0, + cursor: 'zoom-in', + }} + > + +
+ ))} +
+ {gallery.length > 1 && ( + <> + + +
+ {galleryIndex + 1} / {gallery.length} +
+ + )} + + ) : fallbackImage ? ( + } + /> + ) : ( + fallbackIcon || + )} +
+ + {/* Gallery thumbnails */} + {hasGallery && gallery.length > 1 && ( +
+ {gallery.map((url, i) => ( + setGalleryIndex(i)} + style={{ + width: 48, + height: 48, + objectFit: 'cover', + borderRadius: 4, + cursor: 'pointer', + border: i === galleryIndex ? '2px solid var(--md-sys-color-primary)' : '2px solid transparent', + opacity: i === galleryIndex ? 1 : 0.6, + flexShrink: 0, + }} + fallback={ +
+ +
+ } + /> + ))} +
+ )} + + {/* Fullscreen image viewer */} + {fullscreenImageIndex !== null && gallery[fullscreenImageIndex] && ( +
setFullscreenImageIndex(null)} + > + e.stopPropagation()} + fallback={ +
+ + Failed to load image +
+ } + /> + + + {gallery.length > 1 && ( + <> + + +
+ {fullscreenImageIndex + 1} / {gallery.length} +
+ + )} +
+ )} + + ); +}; diff --git a/src/components/GroupCard.test.tsx b/src/components/GroupCard.test.tsx new file mode 100644 index 0000000..99ba268 --- /dev/null +++ b/src/components/GroupCard.test.tsx @@ -0,0 +1,169 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { GroupCard } from './GroupCard'; +import { Addon, Group } from '../types/addon'; + +describe('GroupCard', () => { + const mockGroup: Group = { + id: 'group-1', + name: 'My Group', + addons: ['addon1.vpk', 'addon2.vpk'], + tags: ['Map', 'Skin'], + }; + + const mockAddons: Addon[] = [ + { + id: 'addon1.vpk', vpkName: 'addon1.vpk', + dirType: 'loading', + isEnabled: true, + fileSize: 1024 * 1024 * 10, // 10 MB + filesCount: 5, + addonInfo: { + addonAuthor: 'Author A', + addontitle: 'Addon One', + }, + }, + { + id: 'addon2.vpk', vpkName: 'addon2.vpk', + dirType: 'loading', + isEnabled: true, + fileSize: 1024 * 1024 * 5, // 5 MB + filesCount: 3, + addonInfo: { + addonAuthor: 'Author A', + addontitle: 'Addon Two', + }, + }, + ]; + + test('renders group information correctly', () => { + const onToggleGroup = vi.fn(); + const onViewGroupDetails = vi.fn(); + + render( + + ); + + // Group name + expect(screen.getByText('My Group')).toBeDefined(); + // Tags + expect(screen.getByText('Map')).toBeDefined(); + expect(screen.getByText('Skin')).toBeDefined(); + // Author (single author for both) + expect(screen.getByText('作者: Author A')).toBeDefined(); + // Size formatted (15 MB) + expect(screen.getByText('总大小: 15 MB')).toBeDefined(); + // Enabled status badge + expect(screen.getByText('已启用')).toBeDefined(); + }); + + test('renders partially enabled status and multiple authors correctly', () => { + const mixedAddons = [ + { ...mockAddons[0], isEnabled: true, addonInfo: { addonAuthor: 'Author A' } }, + { ...mockAddons[1], isEnabled: false, addonInfo: { addonAuthor: 'Author B' } }, + ]; + + render( + + ); + + expect(screen.getByText('部分启用')).toBeDefined(); + expect(screen.getByText('作者: Author A 等 1 位作者')).toBeDefined(); + }); + + test('renders disabled status when all addons disabled', () => { + const disabledAddons = [ + { ...mockAddons[0], isEnabled: false }, + { ...mockAddons[1], isEnabled: false }, + ]; + + render( + + ); + + expect(screen.getByText('已禁用')).toBeDefined(); + }); + + test('renders empty group status without addons', () => { + render( + + ); + + expect(screen.getByText('Empty Group')).toBeDefined(); + expect(screen.getByText('空组')).toBeDefined(); + expect(screen.getByText('分组 (0)')).toBeDefined(); + }); + + test('calls onViewGroupDetails when card is clicked and select mode is false', () => { + const onViewGroupDetails = vi.fn(); + render( + + ); + + // Click the clickable area + const clickable = screen.getByText('My Group'); + fireEvent.click(clickable); + expect(onViewGroupDetails).toHaveBeenCalledWith('group-1'); + }); + + test('calls onSelectGroupToggle when card is clicked and select mode is true', () => { + const onSelectGroupToggle = vi.fn(); + render( + + ); + + // Click the clickable area + const clickable = screen.getByText('My Group'); + fireEvent.click(clickable); + expect(onSelectGroupToggle).toHaveBeenCalledWith(mockAddons, 'group-1'); + }); + + test('calls onToggleGroup when the switch is toggled', () => { + const onToggleGroup = vi.fn(); + render( + + ); + + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + // Since mockAddons are allEnabled=true, clicking the checkbox triggers onChange which does onToggleGroup(addons, false) + expect(onToggleGroup).toHaveBeenCalledWith(mockAddons, false); + }); +}); diff --git a/src/components/GroupCard.tsx b/src/components/GroupCard.tsx index d04731f..915a931 100644 --- a/src/components/GroupCard.tsx +++ b/src/components/GroupCard.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Folder, FolderOpen } from 'lucide-react'; +import { Folder, FolderOpen, Download } from 'lucide-react'; import { Addon, Group } from '../types/addon'; import { formatBytes, getAddonAuthor, getAddonInfoValue } from '../utils/addonHelpers'; import { CacheImage } from './CacheImage'; @@ -8,12 +8,15 @@ import { useTranslation } from 'react-i18next'; interface GroupCardProps { group: Group; addons: Addon[]; + allGroupAddons?: Addon[]; onToggleGroup: (addonsList: Addon[], enabled: boolean) => void; onViewGroupDetails: (groupId: string) => void; + onDownloadUninstalled?: (workshopIds: string[]) => void; isSelectMode?: boolean; isGroupSelected?: boolean; - onSelectGroupToggle?: (addonsList: Addon[]) => void; + onSelectGroupToggle?: (addonsList: Addon[], groupId?: string) => void; isSubmitting?: boolean; + downloadProgress?: Record; } export const GroupCard: React.FC = ({ @@ -21,20 +24,36 @@ export const GroupCard: React.FC = ({ addons, onToggleGroup, onViewGroupDetails, + onDownloadUninstalled, isSelectMode = false, isGroupSelected = false, onSelectGroupToggle, isSubmitting = false, + downloadProgress = {}, }) => { const { t } = useTranslation(); - const firstAddonWithImage = addons.find(ad => ad.imagePath); + const isEmptyGroup = group.addons.length === 0 && addons.length === 0; + const installedAddons = addons.filter(ad => ad.dirType !== 'none'); + const uninstalledAddons = addons.filter(ad => ad.dirType === 'none'); + const displayAddons = installedAddons.length > 0 ? installedAddons : addons; + + const firstAddonWithImage = displayAddons.find(ad => ad.imagePath); const imagePath = firstAddonWithImage ? firstAddonWithImage.imagePath : null; - - const allEnabled = addons.every(ad => ad.isEnabled); - const noneEnabled = addons.every(ad => !ad.isEnabled); + + const allEnabled = installedAddons.length > 0 && installedAddons.every(ad => ad.isEnabled); + const noneEnabled = installedAddons.length === 0 || installedAddons.every(ad => !ad.isEnabled); let statusText = t('groupCard.partiallyEnabled'); let badgeClass = 'badge-disabled'; - if (allEnabled) { + let badgeStyle: React.CSSProperties = {}; + if (isEmptyGroup) { + statusText = t('groupCard.empty'); + badgeClass = ''; + badgeStyle = { background: 'var(--md-sys-color-surface-container-highest)', color: 'var(--md-sys-color-on-surface-variant)' }; + } else if (installedAddons.length === 0) { + statusText = t('addonCard.uninstalled'); + badgeClass = ''; + badgeStyle = { background: 'var(--md-sys-color-tertiary)', color: 'var(--md-sys-color-on-tertiary)' }; + } else if (allEnabled) { statusText = t('groupCard.enabled'); badgeClass = 'badge-enabled'; } else if (noneEnabled) { @@ -42,24 +61,28 @@ export const GroupCard: React.FC = ({ badgeClass = 'badge-disabled'; } - const authors = Array.from(new Set(addons.map(ad => getAddonAuthor(ad)))); - const authorText = authors.length === 1 - ? t('groupCard.author', { author: authors[0] }) - : t('groupCard.authorMultiple', { first: authors[0], count: authors.length - 1, total: authors.length }); - - const groupDesc = t('groupCard.containsAddons', { count: addons.length }) + '\n' + - addons.map(ad => `• ${ad.steamDetails?.title || getAddonInfoValue(ad, 'addontitle') || ad.vpkName}`).join('\n'); - const groupSize = addons.reduce((sum, ad) => sum + (ad.fileSize || 0), 0); + const authors = Array.from(new Set(displayAddons.map(ad => getAddonAuthor(ad)).filter(a => a !== 'Unknown Author'))); + const authorText = authors.length === 0 ? '' : + authors.length === 1 + ? t('groupCard.author', { author: authors[0] }) + : t('groupCard.authorMultiple', { first: authors[0], count: authors.length - 1, total: authors.length }); + + const groupDesc = t('groupCard.containsAddons', { count: addons.length }) + '\n' + + addons.map(ad => `• ${ad.steamDetails?.title || getAddonInfoValue(ad, 'addontitle') || ad.vpkName.replace('.vpk', '')}`).join('\n'); + const groupSize = installedAddons.reduce((sum, ad) => sum + (ad.fileSize || 0), 0); + + const hasUninstalled = uninstalledAddons.length > 0; + const uninstalledWithWorkshopId = uninstalledAddons.filter(ad => ad.workshopId); + const isAnyDownloading = uninstalledWithWorkshopId.some(ad => downloadProgress[ad.workshopId!] !== undefined); return ( -
- {/* Checkbox Wrapper */} +
0 ? 'disabled' : ''} ${isGroupSelected ? 'card-selected' : ''} ${isSelectMode ? 'select-mode-active' : ''}`}> {isSelectMode && ( -
{ e.stopPropagation(); - onSelectGroupToggle?.(addons); + onSelectGroupToggle?.(addons, group.id); }} title={isGroupSelected ? t('groupCard.deselectGroup') : t('groupCard.selectGroup')} > @@ -69,11 +92,11 @@ export const GroupCard: React.FC = ({
)} -
{ if (isSelectMode) { - onSelectGroupToggle?.(addons); + onSelectGroupToggle?.(addons, group.id); } else { onViewGroupDetails(group.id); } @@ -81,10 +104,16 @@ export const GroupCard: React.FC = ({ >
{imagePath ? ( - + +
+ } /> ) : (
@@ -94,34 +123,39 @@ export const GroupCard: React.FC = ({
- - {statusText} - + {statusText} {t('groupCard.groupBadge', { count: addons.length })} + {hasUninstalled && installedAddons.length > 0 && ( + + {t('groupHeader.uninstalledCount', { count: uninstalledAddons.length })} + + )}

- + {group.name}

- +
{authorText}

{groupDesc}

- + + {group.tags && group.tags.length > 0 && ( +
+ {group.tags.map(tag => ( + + {tag} + + ))} +
+ )} +
{t('groupCard.totalSize', { size: formatBytes(groupSize) })}
@@ -130,19 +164,34 @@ export const GroupCard: React.FC = ({ {!isSelectMode && (
- + {installedAddons.length > 0 ? ( + + ) :
}
- + )} + - -
-
- - + + {workshopInstalledAddons.length > 0 && ( + + )} + + )} + {uninstalledWithWorkshopId.length > 0 && onDownloadUninstalled && ( + + )} +
+ -
diff --git a/src/components/GroupModal.test.tsx b/src/components/GroupModal.test.tsx new file mode 100644 index 0000000..c54014c --- /dev/null +++ b/src/components/GroupModal.test.tsx @@ -0,0 +1,74 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { GroupModal } from './GroupModal'; + +describe('GroupModal', () => { + test('renders nothing when open is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test('renders name input and handles cancel', () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + + render( + + ); + + expect(screen.getByText('创建新附件分组')).toBeDefined(); + expect(screen.getByPlaceholderText('例如:Early Days 战役地图包')).toBeDefined(); + + // Check cancel button + fireEvent.click(screen.getByText('取消')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + test('submitting form with name triggers onConfirm', () => { + const onConfirm = vi.fn(); + + render( + + ); + + const nameInput = screen.getByPlaceholderText('例如:Early Days 战役地图包') as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: 'My Campaign Pack' } }); + + const form = nameInput.closest('form'); + if (form) { + fireEvent.submit(form); + expect(onConfirm).toHaveBeenCalledWith('My Campaign Pack'); + } + }); + + test('submit button is disabled if name is empty', () => { + render( + + ); + + const submitButton = screen.getByText('创建分组') as HTMLButtonElement; + expect(submitButton.disabled).toBe(true); + + const nameInput = screen.getByPlaceholderText('例如:Early Days 战役地图包') as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: 'My Campaign Pack' } }); + expect(submitButton.disabled).toBe(false); + }); +}); diff --git a/src/components/GroupModal.tsx b/src/components/GroupModal.tsx index 4e4848b..5a1c121 100644 --- a/src/components/GroupModal.tsx +++ b/src/components/GroupModal.tsx @@ -1,31 +1,25 @@ import React, { useState, useEffect } from 'react'; -import { Addon } from '../types/addon'; import { useTranslation } from 'react-i18next'; -import { getAddonInfoValue } from '../utils/addonHelpers'; interface GroupModalProps { open: boolean; - addons: Record; onCancel: () => void; - onConfirm: (name: string, selectedAddons: string[]) => void; + onConfirm: (name: string) => void; isSubmitting?: boolean; } export const GroupModal: React.FC = ({ open, - addons, onCancel, onConfirm, isSubmitting = false, }) => { const { t } = useTranslation(); const [name, setName] = useState(''); - const [selectedAddons, setSelectedAddons] = useState([]); useEffect(() => { if (open) { setName(''); - setSelectedAddons([]); } }, [open]); @@ -33,24 +27,16 @@ export const GroupModal: React.FC = ({ const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (!name || selectedAddons.length === 0 || isSubmitting) return; - onConfirm(name, selectedAddons); - }; - - const handleCheckboxChange = (vpkName: string, checked: boolean) => { - if (checked) { - setSelectedAddons(prev => [...prev, vpkName]); - } else { - setSelectedAddons(prev => prev.filter(item => item !== vpkName)); - } + if (!name.trim() || isSubmitting) return; + onConfirm(name.trim()); }; return (
- e.stopPropagation()} >

{t('groupModal.title')}

@@ -60,68 +46,28 @@ export const GroupModal: React.FC = ({
- setName(e.target.value)} required disabled={isSubmitting} + autoFocus />
-
- -
- {Object.values(addons).map(addon => { - const title = addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName; - const isChecked = selectedAddons.includes(addon.vpkName); - return ( - - ); - })} -
-
-
- -
diff --git a/src/components/KnownUninstalledView.tsx b/src/components/KnownUninstalledView.tsx new file mode 100644 index 0000000..d9c3aa2 --- /dev/null +++ b/src/components/KnownUninstalledView.tsx @@ -0,0 +1,284 @@ +import React from 'react'; +import { Download, Globe, Trash2, Search, RefreshCw, AlertCircle, FileText, CheckSquare, X } from 'lucide-react'; +import { Addon, BackgroundTask } from '../types/addon'; +import { getAddonAuthor, getAddonCategories, formatBytes, getAddonInfoValue } from '../utils/addonHelpers'; +import { CacheImage } from './CacheImage'; +import { useTranslation } from 'react-i18next'; +import { TaskCenterButton } from './TaskCenterButton'; + +interface KnownUninstalledViewProps { + knownUninstalledAddons: Record; + downloadProgress: Record; + onDownload: (workshopId: string, title?: string, imagePath?: string) => void; + onDelete: (ids: string[], deleteFile: boolean, removeFromKnown: boolean) => void; + isSubmitting: boolean; + onOpenLink: (url: string) => void; + isSelectMode?: boolean; + onToggleSelectMode?: () => void; + selectedIds?: string[]; + onSelectToggle?: (id: string) => void; + onSelectAll?: (items: Addon[]) => void; + onBatchDownload?: (workshopIds: string[]) => void; + searchQuery?: string; + onSearchQueryChange?: (query: string) => void; + onDetailClick?: (addon: Addon) => void; + backgroundTasks: BackgroundTask[]; + syncingSteam: boolean; + onOpenTaskCenter: () => void; + onWarning?: (message: string) => void; +} + +export const KnownUninstalledView: React.FC = ({ + knownUninstalledAddons, + downloadProgress, + onDownload, + onDelete, + isSubmitting, + onOpenLink, + isSelectMode = false, + onToggleSelectMode, + selectedIds = [], + onSelectToggle, + onSelectAll: _onSelectAll, + onBatchDownload: _onBatchDownload, + searchQuery: externalSearchQuery, + onSearchQueryChange, + onDetailClick, + backgroundTasks, + syncingSteam, + onOpenTaskCenter, +}) => { + const { t } = useTranslation(); + const searchQuery = externalSearchQuery || ''; + const setSearchQuery = onSearchQueryChange || (() => {}); + const addonsList = Object.values(knownUninstalledAddons); + + const filteredAddons = addonsList.filter((addon) => { + const q = searchQuery.toLowerCase(); + const title = (addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName || '').toLowerCase(); + const desc = (addon.steamDetails?.description || getAddonInfoValue(addon, 'addondescription') || '').toLowerCase(); + const author = getAddonAuthor(addon).toLowerCase(); + const wId = (addon.workshopId || ''); + return title.includes(q) || desc.includes(q) || author.includes(q) || wId.includes(q); + }); + + return ( +
+
+
+

+ {t('sidebar.disabledAddons') === '被禁用的组件 (.disabled)' ? '未安装但已知的 Addon' : 'Known Uninstalled Addons'} +

+

+ {t('sidebar.disabledAddons') === '被禁用的组件 (.disabled)' + ? '管理之前扫描过、在合集中或手动加入但当前未在本地文件夹中的组件。' + : 'Manage addons previously scanned, from collections, or manually added but not currently on disk.'} +

+
+ +
+ + {/* Search */} +
+
+ + setSearchQuery(e.target.value)} + style={{ + width: '100%', + padding: '12px 16px 12px 48px', + borderRadius: '28px', + border: '1px solid var(--md-sys-color-outline-variant)', + backgroundColor: 'var(--md-sys-color-surface-container-high)', + color: 'var(--md-sys-color-on-surface)', + outline: 'none', + fontSize: '14px' + }} + /> +
+ +
+ + {/* Grid */} + {filteredAddons.length === 0 ? ( +
+ +

{t('common.emptyStateTitle')}

+
+ ) : ( +
+ {filteredAddons.map((addon) => { + const isDownloading = addon.workshopId ? downloadProgress[addon.workshopId] !== undefined : false; + const progress = addon.workshopId ? (downloadProgress[addon.workshopId] || 0) : 0; + const title = addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName; + const author = getAddonAuthor(addon); + const categories = getAddonCategories(addon); + const isSelected = selectedIds.includes(addon.id); + + return ( +
+ {/* Checkbox Wrapper */} + {isSelectMode && onSelectToggle && ( +
{ + e.stopPropagation(); + if (isSubmitting) return; + onSelectToggle(addon.id); + }} + title={isSelected ? t('addonCard.deselect') : t('addonCard.select')} + > + {isSelected ? ( + + ) : null} +
+ )} + +
{ + if (isSubmitting) return; + if (isSelectMode && onSelectToggle) { + e.stopPropagation(); + onSelectToggle(addon.id); + } else if (onDetailClick) { + onDetailClick(addon); + } + }} + > +
+ {addon.imagePath ? ( + + +
+ } + /> + ) : ( +
+ +
+ )} +
+ +
+ + {t('sidebar.disabledAddons') === '被禁用的组件 (.disabled)' ? '未安装' : 'Uninstalled'} + +
+ +
+

{title}

+ +
+ {t('addonCard.author', { author })} +
+ +

+ {addon.steamDetails?.description || ''} +

+ +
+ {addon.steamDetails?.file_size && ( + {t('addonCard.fileSize', { size: formatBytes(parseInt(addon.steamDetails.file_size)) })} + )} +
+ +
+ {categories.map(c => ( + {t(`categories.${c}`, c)} + ))} + {addon.workshopId && ( + + ID: {addon.workshopId} + + )} +
+
+
+ + {!isSelectMode && ( +
+
+
+ {addon.workshopId ? ( + + ) : null} + + {addon.workshopId && ( + + )} + + +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/src/components/LinkConfirmModal.test.tsx b/src/components/LinkConfirmModal.test.tsx new file mode 100644 index 0000000..a7c8a22 --- /dev/null +++ b/src/components/LinkConfirmModal.test.tsx @@ -0,0 +1,68 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { LinkConfirmModal } from './LinkConfirmModal'; + +describe('LinkConfirmModal', () => { + test('renders nothing when open is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test('renders title, description, url and buttons when open is true', () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + + render( + + ); + + expect(screen.getByText('即将访问外部网站')).toBeDefined(); + expect(screen.getByText('https://example.com')).toBeDefined(); + expect(screen.getByText('取消')).toBeDefined(); + expect(screen.getByText('继续访问')).toBeDefined(); + }); + + test('calls onCancel when cancel is clicked', () => { + const onCancel = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('取消')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + test('calls onConfirm with url when continue is clicked', () => { + const onConfirm = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('继续访问')); + expect(onConfirm).toHaveBeenCalledWith('https://example.com'); + }); +}); diff --git a/src/components/MasterCollectionHeader.tsx b/src/components/MasterCollectionHeader.tsx new file mode 100644 index 0000000..1875653 --- /dev/null +++ b/src/components/MasterCollectionHeader.tsx @@ -0,0 +1,84 @@ +import { Edit3, Trash2 } from 'lucide-react'; +import { MasterCollection, Group } from '../types/addon'; +import { useTranslation } from 'react-i18next'; + +interface MasterCollectionHeaderProps { + currentMasterCollection: MasterCollection; + groupsInCollection: Group[]; + onRenameCollection: () => void; + onDeleteCollection: () => void; +} + +export function MasterCollectionHeader({ + currentMasterCollection, + groupsInCollection, + onRenameCollection, + onDeleteCollection, +}: MasterCollectionHeaderProps) { + const { t } = useTranslation(); + + const displayName = currentMasterCollection.nameKey + ? t(currentMasterCollection.nameKey, currentMasterCollection.name) + : currentMasterCollection.name; + + const totalAddons = groupsInCollection.reduce((sum, g) => sum + (g.addons?.length || 0), 0); + + return ( +
+
+
+

{displayName}

+

+ {t('masterCollections.desc', { count: groupsInCollection.length })} + {totalAddons > 0 && ` · ${t('masterCollections.totalAddons', { count: totalAddons })}`} +

+
+ {!currentMasterCollection.isSystem && ( +
+ + +
+ )} +
+ + {groupsInCollection.length > 0 && ( +
+ {groupsInCollection.map(g => ( + + {g.name} ({g.addons?.length || 0}) + + ))} +
+ )} +
+ ); +} diff --git a/src/components/MasterCollectionModal.tsx b/src/components/MasterCollectionModal.tsx new file mode 100644 index 0000000..4fcf824 --- /dev/null +++ b/src/components/MasterCollectionModal.tsx @@ -0,0 +1,77 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface MasterCollectionModalProps { + open: boolean; + onCancel: () => void; + onConfirm: (name: string) => void; + isSubmitting?: boolean; +} + +export const MasterCollectionModal: React.FC = ({ + open, + onCancel, + onConfirm, + isSubmitting = false, +}) => { + const { t } = useTranslation(); + const [name, setName] = useState(''); + + useEffect(() => { + if (open) { + setName(''); + } + }, [open]); + + if (!open) return null; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || isSubmitting) return; + onConfirm(name.trim()); + }; + + return ( +
+ e.stopPropagation()} + > +

{t('masterCollections.createTitle')}

+

+ {t('masterCollections.createDesc')} +

+ +
+ + setName(e.target.value)} + required + disabled={isSubmitting} + autoFocus + /> +
+ +
+ + +
+ +
+ ); +}; diff --git a/src/components/MoveWarningModal.test.tsx b/src/components/MoveWarningModal.test.tsx new file mode 100644 index 0000000..de15d3f --- /dev/null +++ b/src/components/MoveWarningModal.test.tsx @@ -0,0 +1,111 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MoveWarningModal } from './MoveWarningModal'; + +describe('MoveWarningModal', () => { + test('renders nothing when open is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test('renders warning messages and action buttons when open is true and workshopId is provided', () => { + const onCancel = vi.fn(); + const onConfirm = vi.fn(); + + render( + + ); + + expect(screen.getByText('移动创意工坊附件提示')).toBeDefined(); + expect(screen.getByText('非常重要:')).toBeDefined(); + expect(screen.getByText('取消')).toBeDefined(); + expect(screen.getByText('取消订阅并移动')).toBeDefined(); + expect(screen.getByText('直接移动')).toBeDefined(); + }); + + test('does not render unsubscribe button when workshopId is empty', () => { + render( + + ); + + expect(screen.queryByText('取消订阅并移动')).toBeNull(); + expect(screen.getByText('直接移动')).toBeDefined(); + }); + + test('calls onCancel when cancel is clicked', () => { + const onCancel = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('取消')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + test('calls onConfirm with unsubscribe=true when unsubscribe and move is clicked', () => { + const onConfirm = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('取消订阅并移动')); + expect(onConfirm).toHaveBeenCalledWith('test.vpk', 'workshop', true); + }); + + test('calls onConfirm with unsubscribe=false when move directly is clicked', () => { + const onConfirm = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('直接移动')); + expect(onConfirm).toHaveBeenCalledWith('test.vpk', 'workshop', false); + }); +}); diff --git a/src/components/MoveWarningModal.tsx b/src/components/MoveWarningModal.tsx index 9ab93b9..4db90ef 100644 --- a/src/components/MoveWarningModal.tsx +++ b/src/components/MoveWarningModal.tsx @@ -1,20 +1,20 @@ import React from 'react'; import { AlertTriangle, ExternalLink } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { TransHTML } from '../i18n'; +import { TransHTML } from './TransHTML'; interface MoveWarningModalProps { open: boolean; - vpkName: string; + id: string; currentDirType: string; workshopId: string; onCancel: () => void; - onConfirm: (vpkName: string, currentDirType: string, unsubscribe: boolean) => void; + onConfirm: (id: string, currentDirType: string, unsubscribe: boolean) => void; } export const MoveWarningModal: React.FC = ({ open, - vpkName, + id, currentDirType, workshopId, onCancel, @@ -54,7 +54,7 @@ export const MoveWarningModal: React.FC = ({ diff --git a/src/components/PromptModal.tsx b/src/components/PromptModal.tsx new file mode 100644 index 0000000..5b3bf40 --- /dev/null +++ b/src/components/PromptModal.tsx @@ -0,0 +1,93 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface PromptModalProps { + open: boolean; + title: string; + message?: string; + placeholder?: string; + defaultValue?: string; + onCancel: () => void; + onConfirm: (value: string) => void; + isSubmitting?: boolean; +} + +export const PromptModal: React.FC = ({ + open, + title, + message, + placeholder, + defaultValue = '', + onCancel, + onConfirm, + isSubmitting = false, +}) => { + const { t } = useTranslation(); + const [value, setValue] = useState(''); + + useEffect(() => { + if (open) { + setValue(defaultValue); + } + }, [open, defaultValue]); + + if (!open) return null; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + onConfirm(value); + }; + + return ( +
+
e.stopPropagation()} + > +

{title}

+ {message && ( +

+ {message} +

+ )} + +
+ setValue(e.target.value)} + required + disabled={isSubmitting} + autoFocus + style={{ width: '100%', borderRadius: '12px' }} + /> +
+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/RenameModal.test.tsx b/src/components/RenameModal.test.tsx new file mode 100644 index 0000000..21a7556 --- /dev/null +++ b/src/components/RenameModal.test.tsx @@ -0,0 +1,142 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { RenameModal } from './RenameModal'; +import { Addon, Group } from '../types/addon'; + +describe('RenameModal', () => { + const mockAddon: Addon = { + id: 'old_name.vpk', vpkName: 'old_name.vpk', + dirType: 'loading', + isEnabled: true, + fileSize: 100, + filesCount: 1, + workshopId: '12345', + steamDetails: { title: 'My Awesome Addon' }, + }; + + const mockGroup: Group = { + id: 'g1', + name: 'Campaign Group', + addons: ['old_name.vpk'], + }; + + const mockAddons: Record = { + 'old_name.vpk': mockAddon, + }; + + test('renders nothing when open is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test('renders current name, input field and action buttons when open is true', () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + + render( + + ); + + expect(screen.getByText('重命名附加组件文件')).toBeDefined(); + expect(screen.getByText('old_name.vpk')).toBeDefined(); + + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input.value).toBe('new_name.vpk'); + + expect(screen.getByText('取消')).toBeDefined(); + expect(screen.getByText('重命名')).toBeDefined(); + }); + + test('clicking cancel calls onCancel', () => { + const onCancel = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('取消')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + test('submitting form calls onConfirm with input value', () => { + const onConfirm = vi.fn(); + + render( + + ); + + const input = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'custom_name.vpk' } }); + + const form = input.closest('form'); + if (form) { + fireEvent.submit(form); + expect(onConfirm).toHaveBeenCalledWith('old_name.vpk', 'custom_name.vpk'); + } + }); + + test('clicking apply workshop title updates input to suggestion from helper', () => { + render( + + ); + + const applyButton = screen.getByText('应用创意工坊标题作为文件名'); + fireEvent.click(applyButton); + + const input = screen.getByRole('textbox') as HTMLInputElement; + // getSuggestedVpkName should return '[12345][Campaign Group]My Awesome Addon.vpk' + expect(input.value).toBe('[12345][Campaign Group]My Awesome Addon.vpk'); + }); +}); diff --git a/src/components/SettingsModal.test.tsx b/src/components/SettingsModal.test.tsx new file mode 100644 index 0000000..79c80f9 --- /dev/null +++ b/src/components/SettingsModal.test.tsx @@ -0,0 +1,78 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { SettingsModal } from './SettingsModal'; + +describe('SettingsModal', () => { + test('renders nothing when open is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test('renders form fields when open is true', () => { + const onConfirm = vi.fn(); + const onCancel = vi.fn(); + + const initialLoadingDir = "C:\\Games\\L4D2\\left4dead2\\addons"; + render( + + ); + + expect(screen.getByText('设置附加组件加载路径')).toBeDefined(); + + const input = screen.getByRole('textbox') as HTMLInputElement; + expect(input.value).toBe(initialLoadingDir); + + expect(screen.getByText('取消')).toBeDefined(); + expect(screen.getByText('保存并重新扫描')).toBeDefined(); + }); + + test('submitting form calls onConfirm with input path', () => { + const onConfirm = vi.fn(); + + render( + + ); + + const input = screen.getByRole('textbox') as HTMLInputElement; + fireEvent.change(input, { target: { value: ' D:\\Steam\\steamapps\\common\\Left 4 Dead 2\\left4dead2\\addons ' } }); + + const form = input.closest('form'); + if (form) { + fireEvent.submit(form); + expect(onConfirm).toHaveBeenCalledWith('D:\\Steam\\steamapps\\common\\Left 4 Dead 2\\left4dead2\\addons'); + } + }); + + test('clicking cancel calls onCancel', () => { + const onCancel = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('取消')); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/SettingsView.test.tsx b/src/components/SettingsView.test.tsx new file mode 100644 index 0000000..b465c2c --- /dev/null +++ b/src/components/SettingsView.test.tsx @@ -0,0 +1,130 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { SettingsView } from './SettingsView'; +import type { Settings } from '../types/addon'; + +describe('SettingsView', () => { + const baseSettings: Settings = { + workshopDir: '/game/addons/workshop', + loadingDir: '/game/addons', + downloadConcurrency: 2, + enableDummyBypass: false, + suppressSdkUnavailableWarning: false, + disableSteamworksSdk: false, + forceSteamworksSdkDownload: false, + maxDownloadRetries: 3, + workshopSourceSettings: { + preset: 'conservative', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + allowSdkHtmlHybrid: false, + sdkHtmlScope: 'search', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', + sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], + cacheRetention: 'keep', + }, + }; + + test('submits the Steamworks SDK disable toggle from the SDK tab', async () => { + const onConfirm = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + fireEvent.click(screen.getByText('SDK')); + + const disableSdkRow = screen.getByText('禁用 Steamworks SDK').closest('div')?.parentElement; + const disableSdkCheckbox = disableSdkRow?.querySelector('input[type="checkbox"]'); + expect(disableSdkCheckbox).toBeTruthy(); + fireEvent.click(disableSdkCheckbox as HTMLInputElement); + fireEvent.click(screen.getByText('保存并重新扫描')); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + '/game/addons', + 2, + false, + false, + true, + false, + 3, + 'ask', + baseSettings.workshopSourceSettings, + ); + }); + }); + + test('submits clamped download concurrency from the download tab', async () => { + const onConfirm = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + fireEvent.click(screen.getByText('下载')); + + const input = screen.getAllByRole('spinbutton')[0] as HTMLInputElement; + fireEvent.change(input, { target: { value: '99' } }); + fireEvent.blur(input); + fireEvent.click(screen.getByText('保存并重新扫描')); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + '/game/addons', + 8, + false, + false, + false, + false, + 3, + 'ask', + baseSettings.workshopSourceSettings, + ); + }); + }); + + test('submits sdkHtmlScope changes from the sources tab', async () => { + const onConfirm = vi.fn().mockResolvedValue(undefined); + + render( + + ); + + fireEvent.click(screen.getByText('数据来源')); + fireEvent.click(screen.getByText('创意工坊搜索')); + fireEvent.click(screen.getByText('允许所有')); + fireEvent.click(screen.getByText('保存并重新扫描')); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith( + '/game/addons', + 2, + false, + false, + false, + false, + 3, + 'ask', + expect.objectContaining({ + preset: 'hybrid', + sdkHtmlScope: 'all', + }), + ); + }); + }); +}); diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 3e14acf..7954c6d 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -1,13 +1,44 @@ import React, { useState, useEffect } from 'react'; -import { FolderOpen, Info, RefreshCw, FlaskConical, Languages, Check } from 'lucide-react'; -import { Settings } from '../types/addon'; +import { FolderOpen, Info, RefreshCw, FlaskConical, Languages, Check, Cpu, Database, Download, AlertTriangle } from 'lucide-react'; +import { DependencyRefreshMode, Settings, WorkshopSdkHtmlScope, WorkshopSourceSettings, DependencyMissingBehavior } from '../types/addon'; import { useTranslation } from 'react-i18next'; -import { TransHTML } from '../i18n'; +import { TransHTML } from './TransHTML'; +import { DEFAULT_WORKSHOP_SOURCE_SETTINGS, normalizeWorkshopSourceSettings } from '../utils/workshopSourceSettings'; +import { CustomSelect } from './CustomSelect'; + +const MIN_DOWNLOAD_CONCURRENCY = 1; +const MAX_DOWNLOAD_CONCURRENCY = 8; interface SettingsViewProps { settings: Settings; isSubmitting: boolean; - onConfirm: (loadingDir: string, enableDummyBypass: boolean) => Promise; + onConfirm: ( + loadingDir: string, + downloadConcurrency: number, + enableDummyBypass: boolean, + suppressSdkUnavailableWarning: boolean, + disableSteamworksSdk: boolean, + forceSteamworksSdkDownload: boolean, + maxDownloadRetries: number, + dependencyMissingBehavior: DependencyMissingBehavior, + workshopSourceSettings: WorkshopSourceSettings, + ) => Promise; +} + +const SOURCE_LABEL_KEYS: Record = { + 'steamworks-sdk': 'settings.sourceSteamworksSdk', + 'steam-web-api': 'settings.sourceSteamWebApi', + 'steamcommunity-html': 'settings.sourceSteamCommunityHtml', +}; + +const SCOPE_OPTIONS: WorkshopSdkHtmlScope[] = ['disabled', 'search', 'navigation', 'all']; +const DEPENDENCY_REFRESH_OPTIONS: DependencyRefreshMode[] = ['always', 'cache-missing']; + +function clampDownloadConcurrency(value: number) { + if (!Number.isFinite(value)) { + return 2; + } + return Math.min(MAX_DOWNLOAD_CONCURRENCY, Math.max(MIN_DOWNLOAD_CONCURRENCY, Math.trunc(value))); } export const SettingsView: React.FC = ({ @@ -16,19 +47,51 @@ export const SettingsView: React.FC = ({ onConfirm, }) => { const { t, i18n } = useTranslation(); - const [activeTab, setActiveTab] = useState<'path' | 'language' | 'experimental' | 'about'>('path'); + const [activeTab, setActiveTab] = useState<'path' | 'download' | 'language' | 'experimental' | 'sdk' | 'sources' | 'about'>('path'); const [loadingDir, setLoadingDir] = useState(''); + const [downloadConcurrencyInput, setDownloadConcurrencyInput] = useState('2'); const [enableDummyBypass, setEnableDummyBypass] = useState(false); + const [suppressSdkUnavailableWarning, setSuppressSdkUnavailableWarning] = useState(false); + const [disableSteamworksSdk, setDisableSteamworksSdk] = useState(false); + const [forceSteamworksSdkDownload, setForceSteamworksSdkDownload] = useState(false); + const [maxDownloadRetriesInput, setMaxDownloadRetriesInput] = useState('3'); + const [dependencyMissingBehavior, setDependencyMissingBehavior] = useState('ask'); + const [workshopSourceSettings, setWorkshopSourceSettings] = useState(DEFAULT_WORKSHOP_SOURCE_SETTINGS); useEffect(() => { setLoadingDir(settings.loadingDir || ''); + setDownloadConcurrencyInput(String(clampDownloadConcurrency(settings.downloadConcurrency))); setEnableDummyBypass(settings.enableDummyBypass || false); + setSuppressSdkUnavailableWarning(settings.suppressSdkUnavailableWarning || false); + setDisableSteamworksSdk(settings.disableSteamworksSdk || false); + setForceSteamworksSdkDownload(settings.forceSteamworksSdkDownload || false); + setMaxDownloadRetriesInput(String(settings.maxDownloadRetries ?? 3)); + setDependencyMissingBehavior(settings.dependencyMissingBehavior || 'ask'); + setWorkshopSourceSettings(normalizeWorkshopSourceSettings(settings.workshopSourceSettings)); }, [settings]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!loadingDir.trim() || isSubmitting) return; - await onConfirm(loadingDir.trim(), enableDummyBypass); + const downloadConcurrency = clampDownloadConcurrency(Number.parseInt(downloadConcurrencyInput, 10)); + setDownloadConcurrencyInput(String(downloadConcurrency)); + + let maxRetries = Number.parseInt(maxDownloadRetriesInput, 10); + if (Number.isNaN(maxRetries) || maxRetries < 0) maxRetries = 3; + if (maxRetries > 20) maxRetries = 20; + setMaxDownloadRetriesInput(String(maxRetries)); + + await onConfirm( + loadingDir.trim(), + downloadConcurrency, + enableDummyBypass, + suppressSdkUnavailableWarning, + disableSteamworksSdk, + forceSteamworksSdkDownload, + maxRetries, + dependencyMissingBehavior, + workshopSourceSettings, + ); }; const changeLanguage = (lng: string) => { @@ -36,8 +99,84 @@ export const SettingsView: React.FC = ({ localStorage.setItem('i18n_lang', lng); }; + const handleDownloadConcurrencyBlur = () => { + setDownloadConcurrencyInput(String(clampDownloadConcurrency(Number.parseInt(downloadConcurrencyInput, 10)))); + }; + + const handleMaxRetriesBlur = () => { + let maxRetries = Number.parseInt(maxDownloadRetriesInput, 10); + if (Number.isNaN(maxRetries) || maxRetries < 0) maxRetries = 3; + if (maxRetries > 20) maxRetries = 20; + setMaxDownloadRetriesInput(String(maxRetries)); + }; + const featuresList = t('settings.features', { returnObjects: true }) as string[]; + const updateSourceSettings = (patch: Partial) => { + setWorkshopSourceSettings((prev) => normalizeWorkshopSourceSettings({ + ...prev, + ...patch, + })); + }; + + const moveSource = (source: string, direction: -1 | 1) => { + setWorkshopSourceSettings((prev) => { + const order = [...prev.sourceOrder]; + const index = order.indexOf(source); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= order.length) { + return prev; + } + [order[index], order[nextIndex]] = [order[nextIndex], order[index]]; + return { + ...normalizeWorkshopSourceSettings(prev), + sourceOrder: order, + }; + }); + }; + + const applyPreset = (preset: WorkshopSourceSettings['preset']) => { + if (preset === 'offline') { + updateSourceSettings({ + preset, + allowSteamworksSdk: false, + allowSteamWebApi: false, + allowSteamCommunityHtml: false, + sdkHtmlScope: 'disabled', + }); + return; + } + if (preset === 'sdk-only') { + updateSourceSettings({ + preset, + allowSteamworksSdk: true, + allowSteamWebApi: false, + allowSteamCommunityHtml: false, + sdkHtmlScope: 'disabled', + }); + return; + } + if (preset === 'hybrid') { + updateSourceSettings({ + preset, + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + sdkHtmlScope: 'all', + }); + return; + } + updateSourceSettings({ + preset, + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + sdkHtmlScope: 'search', + }); + }; + + const downloadCacheDir = '/l4a/cache/downloading'; + return (
{/* Settings Navigation Sidebar */} @@ -50,6 +189,14 @@ export const SettingsView: React.FC = ({ {t('settings.pathSettings')} + + +
)} + {activeTab === 'download' && ( +
+

{t('settings.downloadTitle')}

+

+ {t('settings.downloadDesc')} +

+
+
+
+ + setDownloadConcurrencyInput(e.target.value)} + onBlur={handleDownloadConcurrencyBlur} + min={MIN_DOWNLOAD_CONCURRENCY} + max={MAX_DOWNLOAD_CONCURRENCY} + inputMode="numeric" + disabled={isSubmitting} + style={{ width: '180px' }} + /> + + {t('settings.downloadConcurrencyHelp', { + min: MIN_DOWNLOAD_CONCURRENCY, + max: MAX_DOWNLOAD_CONCURRENCY, + })} + +
+ +
+ + setMaxDownloadRetriesInput(e.target.value)} + onBlur={handleMaxRetriesBlur} + min={0} + max={20} + inputMode="numeric" + disabled={isSubmitting} + style={{ width: '180px' }} + /> + + {t('settings.maxDownloadRetriesHelp')} + +
+ +
+ + setDependencyMissingBehavior(val as DependencyMissingBehavior)} + minWidth="100%" + style={{ width: '100%', pointerEvents: isSubmitting ? 'none' : 'auto', opacity: isSubmitting ? 0.6 : 1 }} + /> + {dependencyMissingBehavior === 'ignore' && ( +
+ + {t('settings.behaviorIgnoreWarning', 'Warning: Ignoring dependencies may cause addons to not work properly. Dependency checks will be skipped.')} +
+ )} +
+ +
+ + + + {t('settings.downloadTempDirHelp')} + +
+
+ +
+ +
+
+
+ )} + {activeTab === 'experimental' && (

{t('settings.experimentalTitle')}

@@ -248,6 +526,293 @@ export const SettingsView: React.FC = ({
)} + + {activeTab === 'sdk' && ( +
+

{t('settings.sdkTitle')}

+

+ {t('settings.sdkDesc')} +

+
+
+
+
+ +
+ {t('settings.disableSteamworksSdkDesc')} +
+
+ +
+
+
+ +
+ {t('settings.forceSteamworksSdkDownloadDesc')} +
+
+ +
+
+
+ +
+ {t('settings.suppressSdkWarningDesc')} +
+
+ +
+
+ +
+ +
+
+
+ )} + + {activeTab === 'sources' && ( +
+

{t('settings.sourcesTitle')}

+

+ {t('settings.sourcesDesc')} +

+
+
+
+ + applyPreset(val as WorkshopSourceSettings['preset'])} + minWidth="100%" + style={{ width: '100%', pointerEvents: isSubmitting ? 'none' : 'auto', opacity: isSubmitting ? 0.6 : 1 }} + /> +
+ + {workshopSourceSettings.allowSteamworksSdk && !workshopSourceSettings.allowSteamCommunityHtml && ( +
+ +
+ {t('settings.sdkSearchWarningTitle', '搜索结果可能不准确')}
+ {t('settings.sdkSearchWarningDesc', '当前未启用 Steam Community 网页抓取,纯 SDK 模式下搜索功能将受限。具体限制请查阅相关文档。')} +
+
+ )} + + {workshopSourceSettings.allowSteamworksSdk && !workshopSourceSettings.allowSteamWebApi && ( +
+ +
+ {t('settings.sdkDownloadWarningTitle', '下载可靠性受限')}
+ {t('settings.sdkDownloadWarningDesc', '当前未启用 Steam Web API,下载和详情获取将仅依赖 SDK。此下载方式极其不可靠,具体限制请查阅相关文档。')} +
+
+ )} + + {workshopSourceSettings.sdkHtmlScope !== 'disabled' && ( +
+ {t('settings.sourceHybridWarning')} +
+ )} + + {[ + ['allowSteamworksSdk', 'settings.sourceSteamworksSdk'], + ['allowSteamWebApi', 'settings.sourceSteamWebApi'], + ['allowSteamCommunityHtml', 'settings.sourceSteamCommunityHtml'], + ].map(([key, labelKey]) => ( +
+
+ +
+ +
+ ))} + +
+ +
+ {workshopSourceSettings.sourceOrder.map((source, index) => ( +
+ {index + 1}. {t(SOURCE_LABEL_KEYS[source] || source)} +
+ + +
+
+ ))} +
+
+ +
+ +

+ {t('settings.dependencyRefreshDesc')} +

+
+
+ + ({ value: mode, label: t(`settings.dependencyRefresh.${mode}`) }))} + value={workshopSourceSettings.dependencySdkRefresh} + onChange={(value) => updateSourceSettings({ dependencySdkRefresh: value as DependencyRefreshMode })} + minWidth="100%" + style={{ width: '100%', pointerEvents: isSubmitting ? 'none' : 'auto', opacity: isSubmitting ? 0.6 : 1 }} + /> +
+
+ + ({ value: mode, label: t(`settings.dependencyRefresh.${mode}`) }))} + value={workshopSourceSettings.dependencyHtmlRefresh} + onChange={(value) => updateSourceSettings({ dependencyHtmlRefresh: value as DependencyRefreshMode })} + minWidth="100%" + style={{ width: '100%', pointerEvents: isSubmitting ? 'none' : 'auto', opacity: isSubmitting ? 0.6 : 1 }} + /> +
+
+
+ + {workshopSourceSettings.allowSteamCommunityHtml && ( +
+ + ({ + value: scope, + label: t(`settings.sdkHtmlScope.${scope}.label`), + }))} + value={workshopSourceSettings.sdkHtmlScope} + onChange={(val) => updateSourceSettings({ + preset: val === 'all' && workshopSourceSettings.preset === 'conservative' + ? 'hybrid' + : val !== 'all' && workshopSourceSettings.preset === 'hybrid' + ? 'conservative' + : workshopSourceSettings.preset, + sdkHtmlScope: val as WorkshopSdkHtmlScope, + })} + minWidth="100%" + style={{ width: '100%', marginBottom: '10px', pointerEvents: isSubmitting ? 'none' : 'auto', opacity: isSubmitting ? 0.6 : 1 }} + /> +
+ {t(`settings.sdkHtmlScope.${workshopSourceSettings.sdkHtmlScope}.desc`)} +
+
+ )} +
+ +
+ +
+
+
+ )}
); diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx new file mode 100644 index 0000000..aff83e3 --- /dev/null +++ b/src/components/Sidebar.test.tsx @@ -0,0 +1,121 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Sidebar } from './Sidebar'; +import { Addon, Group } from '../types/addon'; + +describe('Sidebar', () => { + const mockAddons: Record = { + 'addon1.vpk': { + id: 'addon1.vpk', vpkName: 'addon1.vpk', + dirType: 'loading', + isEnabled: true, + fileSize: 100, + filesCount: 1, + }, + 'addon2.vpk': { + id: 'addon2.vpk', vpkName: 'addon2.vpk', + dirType: 'workshop', + isEnabled: false, + fileSize: 200, + filesCount: 2, + }, + }; + + const mockGroups: Group[] = [ + { + id: 'g1', + name: 'Custom Group Alpha', + addons: ['addon1.vpk'], + }, + ]; + + const defaultProps = { + addons: mockAddons, + groups: mockGroups, + masterCollections: [], + currentFilterTab: 'all', + selectedGroupId: null, + selectedMasterCollectionId: null, + onFilterTabChange: vi.fn(), + onOpenGroupModal: vi.fn(), + onOpenMasterCollectionModal: vi.fn(), + onAutoGrouping: vi.fn(), + autoGrouping: false, + disabledCount: 1, + totalAddonsCount: 2, + knownUninstalledCount: 3, + }; + + test('renders menu items and badges correctly', () => { + render(); + + expect(screen.getByText('全部组件')).toBeDefined(); + expect(screen.getByText('全部组件').closest('button')?.querySelector('.menu-item-badge')?.textContent).toBe('2'); + + expect(screen.getByText('手动安装 (Addons)')).toBeDefined(); + expect(screen.getByText('手动安装 (Addons)').closest('button')?.querySelector('.menu-item-badge')?.textContent).toBe('1'); + + expect(screen.getByText('创意工坊目录 (Workshop)')).toBeDefined(); + expect(screen.getByText('创意工坊目录 (Workshop)').closest('button')?.querySelector('.menu-item-badge')?.textContent).toBe('1'); + + expect(screen.getByText('被禁用的组件 (.disabled)')).toBeDefined(); + expect(screen.getByText('被禁用的组件 (.disabled)').closest('button')?.querySelector('.menu-item-badge')?.textContent).toBe('1'); + + expect(screen.getByText('未安装的已知组件')).toBeDefined(); + expect(screen.getByText('未安装的已知组件').closest('button')?.querySelector('.menu-item-badge')?.textContent).toBe('3'); + + // Groups rendering + expect(screen.getByText('Custom Group Alpha')).toBeDefined(); + }); + + test('renders noGroups text when groups list is empty', () => { + render(); + + expect(screen.getByText('暂无分组,可自动归类或手动创建。')).toBeDefined(); + }); + + test('renders empty manual groups', () => { + render( + + ); + + expect(screen.getByText('Empty Group')).toBeDefined(); + expect(screen.getByText('Empty Group').closest('button')?.querySelector('.menu-item-badge')?.textContent).toBe('0'); + }); + + test('calls onFilterTabChange when menu items are clicked', () => { + const onFilterTabChange = vi.fn(); + + render(); + + fireEvent.click(screen.getByText('手动安装 (Addons)')); + expect(onFilterTabChange).toHaveBeenCalledWith('loading', null); + + fireEvent.click(screen.getByText('Custom Group Alpha')); + expect(onFilterTabChange).toHaveBeenCalledWith('groups', 'g1'); + }); + + test('calls actions on button click', () => { + const onOpenGroupModal = vi.fn(); + const onAutoGrouping = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByText('新建手动分组')); + expect(onOpenGroupModal).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText('自动识别战役并归类')); + expect(onAutoGrouping).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1719513..24e889f 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,37 +1,52 @@ import React from 'react'; -import { Layers, Grid, FolderOpen, Folder, Lock, FolderPlus, Plus, Sparkles, Settings } from 'lucide-react'; -import { Addon, Group } from '../types/addon'; +import { Layers, Grid, FolderOpen, Folder, Lock, FolderPlus, Plus, Sparkles, Settings, Download, Globe, Library } from 'lucide-react'; +import { Addon, Group, MasterCollection } from '../types/addon'; import { useTranslation } from 'react-i18next'; interface SidebarProps { addons: Record; groups: Group[]; + masterCollections: MasterCollection[]; currentFilterTab: string; selectedGroupId: string | null; + selectedMasterCollectionId: string | null; onFilterTabChange: (tab: string, groupId: string | null) => void; onOpenGroupModal: () => void; + onOpenMasterCollectionModal: () => void; onAutoGrouping: () => void; autoGrouping: boolean; disabledCount: number; totalAddonsCount: number; + knownUninstalledCount: number; } export const Sidebar: React.FC = ({ addons, groups, + masterCollections, currentFilterTab, selectedGroupId, + selectedMasterCollectionId, onFilterTabChange, onOpenGroupModal, + onOpenMasterCollectionModal, onAutoGrouping, autoGrouping, disabledCount, totalAddonsCount, + knownUninstalledCount, }) => { const { t } = useTranslation(); const loadingCount = Object.values(addons).filter(a => a.dirType === 'loading').length; const workshopCount = Object.values(addons).filter(a => a.dirType === 'workshop').length; + const getMasterCollectionDisplayName = (mc: MasterCollection) => { + if (mc.nameKey) { + return t(mc.nameKey, mc.name); + } + return mc.name; + }; + return (
@@ -45,7 +60,7 @@ export const Sidebar: React.FC = ({
- - - - + + + + + {/* Master Collections Section */} +
{t('sidebar.masterCollections')}
+ +
+ {masterCollections.length === 0 ? ( +
+ {t('sidebar.noMasterCollections')} +
+ ) : ( + masterCollections.map(mc => ( + + )) + )} +
+ + {/* Groups Section */}
{t('sidebar.myGroups')}
- +
- {groups.length === 0 ? ( + {groups.filter(g => !g.masterCollectionIds || g.masterCollectionIds.length === 0).length === 0 ? (
{t('sidebar.noGroups')}
) : ( - groups.map(group => ( + groups.filter(g => !g.masterCollectionIds || g.masterCollectionIds.length === 0).map(group => (
- - - + + - + ); +}; diff --git a/src/components/TaskCenterModal.tsx b/src/components/TaskCenterModal.tsx new file mode 100644 index 0000000..2a2710f --- /dev/null +++ b/src/components/TaskCenterModal.tsx @@ -0,0 +1,513 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { X, RotateCw, Trash2, AlertTriangle, Loader2, RefreshCw, ClipboardCheck, Network } from 'lucide-react'; +import { BackgroundTask, WorkshopSourceSettings } from '../types/addon'; +import { CacheImage } from './CacheImage'; + +interface TaskCenterModalProps { + open: boolean; + onCancel: () => void; + backgroundTasks: BackgroundTask[]; + downloadProgress: Record; + syncingSteam: boolean; + onSyncSteam: () => void; + onCancelTask: (id: string) => void; + onRetryTask: (id: string) => void; + onRemoveTask: (id: string) => void; + onClearFinishedTasks: () => void; + workshopSourceSettings?: WorkshopSourceSettings; +} + +export const TaskCenterModal: React.FC = ({ + open, + onCancel, + backgroundTasks, + downloadProgress, + syncingSteam, + onSyncSteam, + onCancelTask, + onRetryTask, + onRemoveTask, + onClearFinishedTasks, + workshopSourceSettings, +}) => { + const { t } = useTranslation(); + + if (!open) return null; + + // Filter tasks into categories + const activeTasks = backgroundTasks.filter( + (t) => t.status === 'running' || t.status === 'queued' + ); + + const failedTasks = backgroundTasks.filter((t) => ( + t.status === 'failed' || + (t.kind === 'dependency-check' && (t.dependencyCheck?.failedNodes.length || 0) > 0) + )); + + const finishedTasks = backgroundTasks.filter( + (t) => t.status === 'completed' || t.status === 'cancelled' + ); + + const hasFinishedTasks = finishedTasks.length > 0 || failedTasks.length > 0; + + return ( +
+
e.stopPropagation()} + style={{ + width: '600px', + maxWidth: '90%', + maxHeight: '85vh', + display: 'flex', + flexDirection: 'column', + padding: '24px', + borderRadius: '24px', + backgroundColor: 'var(--md-sys-surface-container)', + border: '1px solid var(--md-sys-color-outline-variant)', + boxShadow: '0 12px 36px rgba(0, 0, 0, 0.5)', + overflow: 'hidden', + }} + > + {/* Header */} +
+
+

+ {t('taskCenter.title', '任务中心')} +

+

+ {t('taskCenter.subtitle', '管理和查看组件的下载与同步任务')} +

+
+
+ {hasFinishedTasks && ( + + )} + + +
+
+ + {/* Scrollable list area */} +
+ {/* SDK Only Download Warning */} + {workshopSourceSettings?.allowSteamworksSdk && !workshopSourceSettings?.allowSteamWebApi && ( +
+ +
+
+ {t('taskCenter.sdkOnlyDownloadWarningTitle', '下载功能可能受限')} +
+
+ {t('taskCenter.sdkOnlyDownloadWarningDesc', '当前仅允许通过 Steamworks SDK 下载,此下载方式极其不可靠。具体细节请查阅相关文档。')} +
+
+
+ )} + + {/* Steam Sync State (Separate active item if running) */} + {syncingSteam && ( +
+ +
+
+ {t('taskCenter.syncing', '正在同步创意工坊信息...')} +
+
+ {t('taskCenter.syncDesc', '正在获取和更新所有组件的最新 Steam 创意工坊元数据')} +
+
+
+ )} + + {/* Active Tasks (Downloads, crawls) */} + {activeTasks.length > 0 && ( +
+

+ {t('taskCenter.activeTasks', '正在运行的队列')} ({activeTasks.length}) +

+
+ {activeTasks.map((task) => { + const isDownload = task.kind === 'download'; + const isDependencyCheck = task.kind === 'dependency-check'; + const workshopId = task.targetIds[0]; + // If download, read current progress percent + const percent = isDownload ? (downloadProgress[workshopId] ?? task.progress ?? 0) : 0; + const isRunning = task.status === 'running'; + + return ( +
+ {/* Image Preview */} + {isDownload ? ( + + ) : ( +
+ {isDependencyCheck + ? (isRunning ? : ) + : } +
+ )} + + {/* Content */} +
+
+ {task.title || `Workshop Item ${workshopId}`} +
+
+ + {isDownload + ? isRunning + ? `${t('taskCenter.downloading', '正在下载')}...` + : t('taskCenter.inQueue', '等待下载') + : isDependencyCheck + ? isRunning + ? t('taskCenter.dependencyChecking', '正在检查依赖...') + : t('taskCenter.dependencyQueued', '等待检查依赖') + : isRunning + ? `${t('taskCenter.syncing', '正在同步')}...` + : t('taskCenter.inQueue', '等待同步')} + + {isDependencyCheck && {task.dependencyCheck?.completedCount || 0} / {task.dependencyCheck?.discoveredCount || 0}} + {isDownload && isRunning && {percent}%} +
+ + {/* Progress Bar */} + {isDownload && isRunning && ( +
+
+
+ )} +
+ + {/* Actions */} + +
+ ); + })} +
+
+ )} + + {/* Failed / Warning Tasks */} + {failedTasks.length > 0 && ( +
+

+ {t('taskCenter.errors', '警告通知 / 失败的任务')} ({failedTasks.length}) +

+
+ {failedTasks.map((task) => { + const workshopId = task.targetIds[0]; + const isWarningTask = task.kind === 'warning'; + const isPartialDependencyCheck = task.kind === 'dependency-check' && (task.dependencyCheck?.failedNodes.length || 0) > 0; + const taskTitle = task.title || ( + isWarningTask + ? t('taskCenter.steamworksUnavailableTitle', 'Steamworks SDK 不可用') + : `Workshop Item ${workshopId}` + ); + return ( +
+
+ +
+
+ {taskTitle} +
+
+ {isWarningTask + ? t('taskCenter.warningTitle', '功能受限警告') + : task.kind === 'download' + ? t('taskCenter.downloadFailed', '下载失败') + : isPartialDependencyCheck + ? t('taskCenter.dependencyPartial', '依赖检查已完成,但部分节点失败') + : t('taskCenter.syncFailed', '同步元数据失败')} +
+
+ + {/* Actions */} + {!isWarningTask && ( +
+ + +
+ )} +
+ + {/* Error details */} + {task.error && ( +
+ {t('taskCenter.errorDetails', { err: task.error })} +
+ )} + {isPartialDependencyCheck && task.dependencyCheck?.failedNodes.map((node) => ( +
+ {node.workshopId}: {node.error} +
+ ))} +
+ ); + })} +
+
+ )} + + {/* Empty State */} + {!syncingSteam && activeTasks.length === 0 && failedTasks.length === 0 && ( +
+ +

+ {t('taskCenter.noTasks', '暂无任何任务')} +

+

+ {t('taskCenter.noTasksDesc', '下载组件或同步创意工坊时将会在此处显示')} +

+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ ); +}; diff --git a/src/components/TopBar.test.tsx b/src/components/TopBar.test.tsx new file mode 100644 index 0000000..ca1c9ae --- /dev/null +++ b/src/components/TopBar.test.tsx @@ -0,0 +1,97 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TopBar } from './TopBar'; + +describe('TopBar', () => { + const categoriesList = ['All', 'Campaign', 'Survivor']; + + test('renders search query, categories, sort select, and action buttons', () => { + const onSearchQueryChange = vi.fn(); + const onCategoryChange = vi.fn(); + const onSortByChange = vi.fn(); + const onToggleSelectMode = vi.fn(); + const onOpenTaskCenter = vi.fn(); + + render( + + ); + + // Search query input + const searchInput = screen.getByPlaceholderText('名称, 描述, 作者或创意工坊ID') as HTMLInputElement; + expect(searchInput.value).toBe('tank'); + + // Search query input change + fireEvent.change(searchInput, { target: { value: 'witch' } }); + expect(onSearchQueryChange).toHaveBeenCalledWith('witch'); + + // Category list render + expect(screen.getByText('全部')).toBeDefined(); + expect(screen.getByText('战役')).toBeDefined(); + expect(screen.getByText('幸存者')).toBeDefined(); + + // Clicking category + fireEvent.click(screen.getByText('战役')); + expect(onCategoryChange).toHaveBeenCalledWith('Campaign'); + + // Sort select trigger + const selectTrigger = screen.getByText('大小'); + expect(selectTrigger).toBeDefined(); + + // Open dropdown + fireEvent.click(selectTrigger); + + // Click option + const titleOption = screen.getByText('名称'); + fireEvent.click(titleOption); + expect(onSortByChange).toHaveBeenCalledWith('title'); + + // Batch Manage button + expect(screen.getByText('批量管理')).toBeDefined(); + fireEvent.click(screen.getByText('批量管理')); + expect(onToggleSelectMode).toHaveBeenCalledTimes(1); + + // Task Center button + const buttons = screen.getAllByRole('button'); + const taskCenterBtn = buttons[buttons.length - 1]; // last button is Task Center + fireEvent.click(taskCenterBtn); + expect(onOpenTaskCenter).toHaveBeenCalledTimes(1); + }); + + test('renders batch management correctly in active select mode', () => { + const onToggleSelectMode = vi.fn(); + + render( + + ); + + expect(screen.getByText('退出批量')).toBeDefined(); + fireEvent.click(screen.getByText('退出批量')); + expect(onToggleSelectMode).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 44bb0d4..c133cf8 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,6 +1,9 @@ import React from 'react'; -import { Search, RefreshCw, CheckSquare, X } from 'lucide-react'; +import { Search, CheckSquare, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { CustomSelect } from './CustomSelect'; +import { TaskCenterButton } from './TaskCenterButton'; +import { BackgroundTask } from '../types/addon'; interface TopBarProps { searchQuery: string; @@ -10,10 +13,11 @@ interface TopBarProps { sortBy: string; onSortByChange: (sortBy: string) => void; syncingSteam: boolean; - onSyncSteam: () => void; categoriesList: string[]; isSelectMode: boolean; onToggleSelectMode: () => void; + backgroundTasks: BackgroundTask[]; + onOpenTaskCenter: () => void; } export const TopBar: React.FC = ({ @@ -24,10 +28,11 @@ export const TopBar: React.FC = ({ sortBy, onSortByChange, syncingSteam, - onSyncSteam, categoriesList, isSelectMode, onToggleSelectMode, + backgroundTasks, + onOpenTaskCenter, }) => { const { t } = useTranslation(); @@ -44,39 +49,37 @@ export const TopBar: React.FC = ({ />
-
-
- {categoriesList.map(cat => ( - - ))} -
- -
+
+ {categoriesList.map(cat => ( + + ))} +
- + onChange={onSortByChange} + minWidth="120px" + /> - +
); diff --git a/src/components/TransHTML.test.tsx b/src/components/TransHTML.test.tsx new file mode 100644 index 0000000..15107cf --- /dev/null +++ b/src/components/TransHTML.test.tsx @@ -0,0 +1,58 @@ +import { describe, test, expect, vi } from 'vitest'; +import { render } from '@testing-library/react'; +import { TransHTML, TransParagraphs } from './TransHTML'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => { + if (key === 'array-key') return ['Line 1 bold', 'Line 2']; + if (key === 'string-key') return 'Hello world'; + return key; + }, + }), +})); + +describe('TransHTML & TransParagraphs', () => { + describe('TransHTML', () => { + test('renders string translations with HTML structure', () => { + const { container } = render( + + ); + + const span = container.querySelector('.custom-class'); + expect(span).toBeDefined(); + expect(span?.innerHTML).toBe('Hello world'); + }); + + test('renders array translations as separate divs with HTML structure', () => { + const { container } = render( + + ); + + const wrapper = container.firstElementChild; + expect(wrapper?.children.length).toBe(2); + expect(wrapper?.children[0].innerHTML).toBe('Line 1 bold'); + expect(wrapper?.children[1].innerHTML).toBe('Line 2'); + }); + }); + + describe('TransParagraphs', () => { + test('renders string translations directly', () => { + const { container } = render( + + ); + + expect(container.textContent).toBe('Hello world'); + }); + + test('renders array translations joined by br tags', () => { + const { container } = render( + + ); + + expect(container.textContent).toBe('Line 1 boldLine 2'); + const br = container.querySelector('br'); + expect(br).not.toBeNull(); + }); + }); +}); diff --git a/src/components/TransHTML.tsx b/src/components/TransHTML.tsx new file mode 100644 index 0000000..f644dc0 --- /dev/null +++ b/src/components/TransHTML.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +// i18n Helper component to render multi-line descriptions/warnings securely using array notation (without using \n) +interface TransHTMLProps { + i18nKey: string; + values?: Record; + className?: string; + style?: React.CSSProperties; +} + +export const TransHTML: React.FC = ({ i18nKey, values, className, style }) => { + const { t } = useTranslation(); + const text = t(i18nKey, values); + + if (Array.isArray(text)) { + return ( +
+ {text.map((line: string, idx: number) => ( +
+ ))} +
+ ); + } + + return ; +}; + +interface TransParagraphsProps { + i18nKey: string; + values?: Record; +} + +export const TransParagraphs: React.FC = ({ i18nKey, values }) => { + const { t } = useTranslation(); + const text = t(i18nKey, values); + + if (Array.isArray(text)) { + return ( + <> + {text.map((line: string, idx: number) => ( + + {line} + {idx < text.length - 1 &&
} +
+ ))} + + ); + } + + return <>{text}; +}; diff --git a/src/components/WorkshopActionWarningModal.test.tsx b/src/components/WorkshopActionWarningModal.test.tsx new file mode 100644 index 0000000..72cd71c --- /dev/null +++ b/src/components/WorkshopActionWarningModal.test.tsx @@ -0,0 +1,116 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { WorkshopActionWarningModal } from './WorkshopActionWarningModal'; +import { Addon } from '../types/addon'; + +const mockInvoke = vi.fn(); +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (cmd: string, args?: Record) => mockInvoke(cmd, args), +})); + +describe('WorkshopActionWarningModal', () => { + beforeEach(() => { + mockInvoke.mockClear(); + }); + + const mockAddons: Addon[] = [ + { + id: 'addon1.vpk', vpkName: 'addon1.vpk', + dirType: 'workshop', + isEnabled: true, + fileSize: 100, + filesCount: 1, + workshopId: '12345678', + }, + ]; + + test('renders nothing when open is false', () => { + const { container } = render( + + ); + expect(container.firstChild).toBeNull(); + }); + + test('renders warning content and buttons when open is true', () => { + render( + + ); + + expect(screen.getByText('创意工坊附件已移动')).toBeDefined(); + expect(screen.getByText('自动转移成功')).toBeDefined(); + expect(screen.getByText('当前会话不再提醒此警告,并静默执行移动')).toBeDefined(); + expect(screen.getByText('我知道了')).toBeDefined(); + expect(screen.getByText('前往创意工坊')).toBeDefined(); + }); + + test('calls onConfirm with skipWarning=false when "我知道了" is clicked', () => { + const onConfirm = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByText('我知道了')); + expect(onConfirm).toHaveBeenCalledWith(false); + }); + + test('toggles skipWarning and calls onConfirm with true', () => { + const onConfirm = vi.fn(); + render( + + ); + + const checkbox = screen.getByRole('checkbox') as HTMLInputElement; + fireEvent.click(checkbox); + expect(checkbox.checked).toBe(true); + + fireEvent.click(screen.getByText('我知道了')); + expect(onConfirm).toHaveBeenCalledWith(true); + }); + + test('calls handleOpenWorkshop which invokes open_url and confirms', async () => { + const onConfirm = vi.fn(); + render( + + ); + + const button = screen.getByRole('button', { name: '前往创意工坊' }); + fireEvent.click(button); + expect(mockInvoke).toHaveBeenCalledWith('open_url', { + url: 'steam://url/CommunityFilePage/12345678', + }); + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/src/components/WorkshopActionWarningModal.tsx b/src/components/WorkshopActionWarningModal.tsx index 1409dfa..4b79125 100644 --- a/src/components/WorkshopActionWarningModal.tsx +++ b/src/components/WorkshopActionWarningModal.tsx @@ -3,7 +3,7 @@ import { AlertTriangle, ExternalLink, CheckCircle2 } from 'lucide-react'; import { Addon } from '../types/addon'; import { invoke } from '@tauri-apps/api/core'; import { useTranslation } from 'react-i18next'; -import { TransHTML } from '../i18n'; +import { TransHTML } from './TransHTML'; interface WorkshopActionWarningModalProps { open: boolean; diff --git a/src/components/WorkshopBrowser.tsx b/src/components/WorkshopBrowser.tsx new file mode 100644 index 0000000..cb76d92 --- /dev/null +++ b/src/components/WorkshopBrowser.tsx @@ -0,0 +1,582 @@ +/** + * WorkshopBrowser — main container for the built-in Steam Workshop browser. + * + * Splits into two views: + * • Homepage — section carousels (trending, most-subscribed, …) + * • Browse — filterable grid with tag sidebar + */ + +import React, { useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Search, ChevronLeft, ChevronRight, Loader2, + Home, Compass, User, Tag, AlertTriangle, +} from 'lucide-react'; + +import { + WorkshopItem, + HomepageSection, + TagCategory, + WorkshopBrowserProps, +} from './workshop/types'; +import { ItemCard } from './workshop/ItemCard'; +import { TagBrowserModal } from './workshop/TagBrowserModal'; +import { SectionCarousel } from './workshop/SectionCarousel'; +import { WorkshopDetailModal } from './workshop/WorkshopDetailModal'; +import { TaskCenterButton } from './TaskCenterButton'; +import { AlertModal } from './AlertModal'; +import { CustomSelect } from './CustomSelect'; +import { + fetchWorkshopCollection, + fetchWorkshopHome, + fetchWorkshopItem, + fetchWorkshopItems, + mapSteamDetailToWorkshopItem, + setWorkshopWarningReporter, +} from '../services/workshopClient'; + +// ── Browse sort options ─────────────────────────────────────────────────────── + +const SORT_OPTIONS = [ + { value: 'trend', labelKey: 'workshop.browse.sortTrend' }, + { value: 'textsearch', labelKey: 'workshop.browse.sortTextSearch' }, + { value: 'totalprofiles', labelKey: 'workshop.browse.sortTotalProfiles' }, + { value: 'mostrecent', labelKey: 'workshop.browse.sortMostRecent' }, + { value: 'toprated', labelKey: 'workshop.browse.sortTopRated' }, + { value: 'lastupdated', labelKey: 'workshop.browse.sortLastUpdated' }, +] as const; + +// ── Main Component ──────────────────────────────────────────────────────────── + +export const WorkshopBrowser: React.FC = ({ + addons, + knownUninstalledAddons, + downloadProgress, + onDownload, + onDownloadMany, + onOpenLink, + onImportCollection, + onRecordSeenItems, + onDatabaseUpdate, + isSubmitting, + groups, + backgroundTasks, + syncingSteam, + onOpenTaskCenter, + onWarning, + workshopSourceSettings, +}) => { + const { t } = useTranslation(); + const scrollIdleTimerRef = useRef(null); + const knownCollectionIds = new Set( + (groups || []) + .map((group) => group.workshopCollectionId?.trim()) + .filter((id): id is string => !!id), + ); + + // View mode + const [viewMode, setViewMode] = useState<'home' | 'browse' | 'search'>('home'); + const [query, setQuery] = useState(''); // input field value + const [committedQuery, setCommittedQuery] = useState(''); // actually fetched + const [sort, setSort] = useState('trend'); + const [section, setSection] = useState('readytouseitems'); + const [page, setPage] = useState(1); + const [creatorId, setCreatorId] = useState(null); + const [creatorName, setCreatorName] = useState(null); + const [activeTag, setActiveTag] = useState(null); + const [activeTagName, setActiveTagName] = useState(null); + + // Data + const [loading, setLoading] = useState(false); + const [items, setItems] = useState([]); + const [error, setError] = useState(null); + const [homepageSections, setHomepageSections] = useState([]); + const [homepageLoading, setHomepageLoading] = useState(false); + const [homepageError, setHomepageError] = useState(null); + const [tagCategories, setTagCategories] = useState([]); + + // Detail modal + const [selectedItem, setSelectedItem] = useState(null); + const [selectedCollection, setSelectedCollection] = useState<{ + title: string; + description: string; + imagePath: string; + creatorName: string; + creatorId: string; + items: WorkshopItem[]; + workshopId?: string; + } | null>(null); + const [tagModalOpen, setTagModalOpen] = useState(false); + const [loadingDetailId, setLoadingDetailId] = useState(null); + const [alertInfo, setAlertInfo] = useState<{ open: boolean; title: string; message: string }>({ + open: false, + title: '', + message: '', + }); + const [isScrollInteracting, setIsScrollInteracting] = useState(false); + + const markScrollInteraction = useCallback(() => { + setIsScrollInteracting(true); + if (scrollIdleTimerRef.current !== null) { + window.clearTimeout(scrollIdleTimerRef.current); + } + scrollIdleTimerRef.current = window.setTimeout(() => { + setIsScrollInteracting(false); + scrollIdleTimerRef.current = null; + }, 140); + }, []); + + useEffect(() => { + setWorkshopWarningReporter(onWarning || null); + return () => { + setWorkshopWarningReporter(null); + }; + }, [onWarning]); + + useEffect(() => { + return () => { + if (scrollIdleTimerRef.current !== null) { + window.clearTimeout(scrollIdleTimerRef.current); + } + }; + }, []); + + // ── Fetch homepage ───────────────────────────────────────────────────────── + + const fetchHomepage = useCallback(async () => { + setHomepageLoading(true); + setHomepageError(null); + try { + const data = await fetchWorkshopHome(); + setHomepageSections(data.sections); + onRecordSeenItems?.(data.sections.flatMap((sec: { items: WorkshopItem[] }) => sec.items), 'workshop-home'); + setTagCategories(data.tagCategories); + } catch (err) { + console.error('Failed to fetch homepage:', err); + setHomepageSections([]); + setTagCategories([]); + setHomepageError(String(err)); + } finally { + setHomepageLoading(false); + } + }, [onRecordSeenItems]); + + useEffect(() => { + if (viewMode === 'home') fetchHomepage(); + }, [viewMode, fetchHomepage]); + + // ── Fetch browse items ───────────────────────────────────────────────────── + + const fetchItems = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchWorkshopItems({ + query: committedQuery, + sort, + section, + page, + creatorId, + activeTag, + activeTagName, + }); + setItems(data.items); + onRecordSeenItems?.(data.items, creatorId ? 'workshop-creator' : committedQuery ? 'workshop-search' : 'workshop-browse'); + } catch (err) { + console.error(err); + setError(`${t('common.error')}: ${err}`); + } finally { + setLoading(false); + } + }, [committedQuery, sort, section, page, creatorId, activeTag, activeTagName, onRecordSeenItems, t]); + + useEffect(() => { + if (viewMode === 'browse' || viewMode === 'search') fetchItems(); + }, [viewMode, fetchItems]); + + // ── Navigation helpers ───────────────────────────────────────────────────── + + const handleSearchSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!query.trim()) { + setCommittedQuery(''); + if (viewMode === 'search') { + setSort('trend'); + setSection('readytouseitems'); + setViewMode('browse'); + setPage(1); + } + return; + } + setCreatorId(null); + setCreatorName(null); + setActiveTag(null); + setActiveTagName(null); + setSection('readytouseitems'); + setSort('textsearch'); + setPage(1); + setCommittedQuery(query.trim()); + setViewMode('search'); + }; + + + const handleClearCreator = () => { setCreatorId(null); setCreatorName(null); setPage(1); }; + const handleClearTag = () => { setActiveTag(null); setActiveTagName(null); setPage(1); }; + + const handleTagClick = (tagId: string, tagName: string) => { + setActiveTag(tagId); + setActiveTagName(tagName); + setCreatorId(null); + setCreatorName(null); + setPage(1); + setViewMode('browse'); + }; + + const handleViewAllSection = (sec: HomepageSection) => { + setSort(sec.browseParams.sort); + setSection(sec.browseParams.section); + setPage(1); + setCreatorId(null); setCreatorName(null); + setActiveTag(null); setActiveTagName(null); + setViewMode('browse'); + }; + + const enterBrowseMode = () => { + setViewMode('browse'); + setCreatorId(null); setCreatorName(null); + setActiveTag(null); setActiveTagName(null); + setPage(1); setSort('trend'); setSection('readytouseitems'); + }; + + // ── Detail helpers ───────────────────────────────────────────────────────── + + const viewItemDetails = async (workshopId: string) => { + setLoadingDetailId(workshopId); + try { + const data = await fetchWorkshopItem(workshopId); + if (data.item && data.item.workshopId) { + const item = data.item; + setSelectedItem(item); + onRecordSeenItems?.([item], 'workshop-item-detail'); + setSelectedCollection(null); + } + } catch (err) { + setAlertInfo({ + open: true, + title: t('common.error') || '错误', + message: t('workshop.detail.fetchFailed', { err: String(err) }), + }); + } finally { + setLoadingDetailId(null); + } + }; + + const viewCollectionDetails = async (collectionId: string) => { + setLoadingDetailId(collectionId); + try { + const data = await fetchWorkshopCollection(collectionId); + const raw = data.collection; + const collectionItems = data.items; + if (raw && raw.publishedfileid) { + const collectionItem = mapSteamDetailToWorkshopItem(raw, data.source); + setSelectedCollection({ + title: collectionItem.title, + description: raw.description || '', + imagePath: collectionItem.imagePath, + creatorName: collectionItem.authorName, + creatorId: collectionItem.authorId, + items: collectionItems, + workshopId: raw.publishedfileid, + }); + onRecordSeenItems?.([collectionItem, ...collectionItems], 'workshop-collection'); + setSelectedItem(null); + } + } catch (err) { + setAlertInfo({ + open: true, + title: t('common.error') || '错误', + message: t('workshop.detail.fetchFailed', { err: String(err) }), + }); + } finally { + setLoadingDetailId(null); + } + }; + + // ── Render: Homepage ─────────────────────────────────────────────────────── + + const renderHomepage = () => { + if (homepageLoading) { + return ( +
+ +

{t('workshop.home.loading')}

+
+ ); + } + if (homepageSections.length === 0) { + return ( +
+

{t('workshop.home.loadFailed')}

+ {homepageError && ( +

{homepageError}

+ )} + +
+ ); + } + return ( +
+ {homepageSections.map((sec) => ( + viewItemDetails(item.workshopId)} + onViewAll={handleViewAllSection} + loadingDetailId={loadingDetailId} + /> + ))} +
+ ); + }; + + // ── Render: Browse view ──────────────────────────────────────────────────── + + const renderBrowseView = () => ( +
+ {/* SDK Only Search Warning */} + {workshopSourceSettings?.allowSteamworksSdk && !workshopSourceSettings?.allowSteamCommunityHtml && ( +
+ +
+ {t('workshop.browse.sdkSearchWarningTitle', '搜索与浏览功能受限')}
+ {t('workshop.browse.sdkSearchWarningDesc', '当前网页抓取功能已被禁用,所有数据仅通过 SDK 获取。搜索与分类浏览将受到影响,具体限制请查阅相关文档。')} +
+
+ )} + + {/* Active filter chips */} + {(creatorName || activeTagName) && ( +
+ {creatorName && ( +
+ + {t('workshop.item.author', { author: creatorName })} + +
+ )} + {activeTagName && ( +
+ + {t('workshop.tags.label', { name: activeTagName })} + +
+ )} +
+ )} + + {/* Loading / Error / Items */} + {loading ? ( +
+ +

{t('workshop.browse.loading')}

+
+ ) : error ? ( +
+

{error}

+ +
+ ) : items.length === 0 ? ( +
+

{t('workshop.browse.empty')}

+
+ ) : ( + <> +
+ {items.map((item) => ( + section === 'collections' ? viewCollectionDetails(item.workshopId) : viewItemDetails(item.workshopId)} + isLoading={loadingDetailId === item.workshopId} + /> + ))} +
+ {/* Pagination */} +
+ + {t('workshop.browse.page', { page })} + +
+ + )} +
+ ); + + // ── Main render ──────────────────────────────────────────────────────────── + + return ( +
+ {/* Top navigation bar */} +
+
+ + +
+ +
+
+ + { + const val = e.target.value; + setQuery(val); + if (val.trim() === '' && viewMode === 'search') { + setCommittedQuery(''); + setViewMode('browse'); + setPage(1); + } + }} + onFocus={() => { + if (viewMode === 'home') { + enterBrowseMode(); + } + }} + style={{ width: '100%', padding: '8px 14px 8px 40px', borderRadius: '100px', border: '1px solid var(--md-sys-color-outline-variant)', backgroundColor: 'var(--md-sys-color-surface-container-high)', color: 'var(--md-sys-color-on-surface)', outline: 'none', fontSize: '13px' }} + /> +
+ +
+ + {(viewMode === 'browse' || viewMode === 'search') && !creatorId && ( + <> + ({ + value: opt.value, + label: t(opt.labelKey), + }))} + value={sort} + onChange={(val) => { setSort(val); setPage(1); }} + minWidth="140px" + style={{ height: '36px' }} + /> +
+ + +
+ + )} + + {/* Tag browser button */} + {tagCategories.length > 0 && ( + + )} + +
0 ? '0' : 'auto' }}> + +
+
+ + {/* Content area */} +
+ {viewMode === 'home' ? renderHomepage() : renderBrowseView()} +
+ + {/* Detail modal */} + { setSelectedItem(null); setSelectedCollection(null); }} + onDownload={onDownload} + onDownloadMany={onDownloadMany} + onOpenLink={onOpenLink} + onImportCollection={onImportCollection} + onItemNavigate={viewItemDetails} + onCollectionNavigate={viewCollectionDetails} + addons={addons} + knownUninstalledAddons={knownUninstalledAddons} + downloadProgress={downloadProgress} + isSubmitting={isSubmitting} + groups={groups} + isLoading={!!loadingDetailId} + onDatabaseUpdate={onDatabaseUpdate} + /> + + {/* Tag browser modal */} + setTagModalOpen(false)} + onTagClick={handleTagClick} + /> + + {/* Alert modal */} + setAlertInfo({ open: false, title: '', message: '' })} + /> +
+ ); +}; diff --git a/src/components/WorkshopCommon.test.tsx b/src/components/WorkshopCommon.test.tsx new file mode 100644 index 0000000..4cab52b --- /dev/null +++ b/src/components/WorkshopCommon.test.tsx @@ -0,0 +1,26 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; +import { RequiredItems } from './WorkshopCommon'; + +describe('WorkshopCommon', () => { + test('dependency link click does not bubble to parent click handlers', () => { + const onParentClick = vi.fn(); + const onItemNavigate = vi.fn(); + + render( +
+ +
, + ); + + fireEvent.click(screen.getByText('Dependency Addon')); + + expect(onItemNavigate).toHaveBeenCalledWith('12345'); + expect(onParentClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/WorkshopCommon.tsx b/src/components/WorkshopCommon.tsx new file mode 100644 index 0000000..df71ae8 --- /dev/null +++ b/src/components/WorkshopCommon.tsx @@ -0,0 +1,161 @@ +import React from 'react'; +import { Package, Link2, CheckCircle, FolderPlus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { findKnownWorkshopEntry, isKnownWorkshopItem } from '../utils/workshopKnown'; + +function resolveRequiredItemTitle( + title: string | undefined, + workshopId: string, + addons: Record, + knownUninstalledAddons: Record, +): string { + const normalizedTitle = String(title || '').trim(); + if (normalizedTitle) { + return normalizedTitle; + } + + const knownEntry = findKnownWorkshopEntry(addons, workshopId) + || findKnownWorkshopEntry(knownUninstalledAddons, workshopId); + if (knownEntry) { + const knownTitle = String( + knownEntry.workshopDetails?.title + || knownEntry.steamDetails?.title + || knownEntry.addonInfo?.addontitle + || knownEntry.vpkName + || '', + ).trim(); + if (knownTitle) { + return knownTitle; + } + } + + return workshopId; +} + +interface RequiredItemsProps { + requiredItems: { title: string; workshopId: string }[]; + addons: Record; + knownUninstalledAddons: Record; + onItemNavigate: (workshopId: string) => void; +} + +export const RequiredItems: React.FC = ({ + requiredItems, + addons, + knownUninstalledAddons, + onItemNavigate, +}) => { + const { t } = useTranslation(); + + if (!requiredItems || requiredItems.length === 0) return null; + + return ( +
+
+ {t('workshop.detail.requiredItems')} ({requiredItems.length}) +
+
+ {requiredItems.map((req) => { + const reqIsKnown = isKnownWorkshopItem(knownUninstalledAddons, req.workshopId); + const reqIsDownloaded = isKnownWorkshopItem(addons, req.workshopId); + const reqTitle = resolveRequiredItemTitle(req.title, req.workshopId, addons, knownUninstalledAddons); + return ( + + ); + })} +
+
+ ); +}; + +interface ParentCollectionsProps { + parentCollections: { title: string; workshopId: string }[]; + onCollectionNavigate: (workshopId: string) => void; +} + +export const ParentCollections: React.FC = ({ + parentCollections, + onCollectionNavigate, +}) => { + const { t } = useTranslation(); + + if (!parentCollections || parentCollections.length === 0) return null; + + return ( + + ); +}; diff --git a/src/components/workshop/DetailDrawer.tsx b/src/components/workshop/DetailDrawer.tsx new file mode 100644 index 0000000..0fe7cac --- /dev/null +++ b/src/components/workshop/DetailDrawer.tsx @@ -0,0 +1,599 @@ +/** Workshop item/collection detail drawer */ + +import React, { useState } from 'react'; +import { Download, Globe, FolderPlus, Loader2, FileText } from 'lucide-react'; +import { CacheImage } from '../CacheImage'; +import { useTranslation } from 'react-i18next'; +import { CollectionData } from './types'; +import { AlertModal } from '../AlertModal'; + +interface DetailDrawerProps { + selectedItem: any | null; + selectedCollection: CollectionData | null; + selectedItemLoading: boolean; + selectedCollectionLoading: boolean; + downloadProgress: Record; + isSubmitting: boolean; + onClose: () => void; + onDownload: (workshopId: string, title?: string, imagePath?: string) => void; + onOpenLink: (url: string) => void; + onCreatorClick: (id: string, name: string) => void; + onTagClick: (tagId: string, tagName: string) => void; + onAddToKnownList: (item: any) => void; + onImportCollection: (name: string, itemIds: string[]) => void; +} + +export const DetailDrawer: React.FC = ({ + selectedItem, + selectedCollection, + selectedItemLoading, + selectedCollectionLoading, + downloadProgress, + isSubmitting, + onClose, + onDownload, + onOpenLink, + onCreatorClick, + onTagClick, + onAddToKnownList, + onImportCollection, +}) => { + const { t } = useTranslation(); + const [alertInfo, setAlertInfo] = useState<{ open: boolean; title: string; message: string }>({ + open: false, + title: '', + message: '', + }); + + const handleImportCollectionGroup = () => { + if (!selectedCollection) return; + const name = selectedCollection.collection.title || t('workshop.detail.defaultCollectionName'); + const itemIds = selectedCollection.items.map((item) => item.publishedfileid); + onImportCollection(name, itemIds); + setAlertInfo({ + open: true, + title: t('common.success') || '成功', + message: t('workshop.detail.importSuccess', { count: itemIds.length, name }), + }); + }; + + const handleDownloadAllCollection = () => { + if (!selectedCollection) return; + selectedCollection.items.forEach((item) => { + onDownload(item.publishedfileid, item.title, item.preview_url); + }); + setAlertInfo({ + open: true, + title: t('common.success') || '成功', + message: t('workshop.detail.downloadAllSuccess', { count: selectedCollection.items.length }), + }); + }; + + if (!selectedItem && !selectedCollection && !selectedItemLoading && !selectedCollectionLoading) { + return null; + } + + return ( + <> +
+ + + {selectedItemLoading || selectedCollectionLoading ? ( +
+ +

{t('workshop.detail.loading')}

+
+ ) : selectedItem ? ( + + ) : selectedCollection ? ( + + ) : null} +
+ setAlertInfo({ open: false, title: '', message: '' })} + /> + +); +}; + +// ── Item detail sub-component ────────────────────────────────────────────────── + +interface ItemDetailProps { + item: any; + downloadProgress: Record; + isSubmitting: boolean; + onDownload: (workshopId: string, title?: string, imagePath?: string) => void; + onOpenLink: (url: string) => void; + onCreatorClick: (id: string, name: string) => void; + onTagClick: (tagId: string, tagName: string) => void; + onAddToKnownList: (item: any) => void; +} + +const ItemDetail: React.FC = ({ + item, + downloadProgress, + isSubmitting, + onDownload, + onOpenLink, + onCreatorClick, + onTagClick, + onAddToKnownList, +}) => { + const { t } = useTranslation(); + + return ( +
+ + +
+ } + /> +

+ {item.title} +

+
+ + {t('workshop.detail.author')}: + + +
+
+ {item.tags && + item.tags.map((t: any) => ( + onTagClick(t.tag, t.tag)} + style={{ + padding: '2px 8px', + borderRadius: '8px', + fontSize: '11px', + fontWeight: 500, + backgroundColor: 'var(--md-sys-color-secondary-container)', + color: 'var(--md-sys-color-on-secondary-container)', + cursor: 'pointer', + }} + > + {t.tag} + + ))} +
+
+

+ {t('workshop.detail.description')} +

+

+ {item.description} +

+
+
+ + +
+ + +
+
+
+ ); +}; + +// ── Collection detail sub-component ──────────────────────────────────────────── + +interface CollectionDetailProps { + collection: CollectionData; + onOpenLink: (url: string) => void; + onImportGroup: () => void; + onDownloadAll: () => void; +} + +const CollectionDetail: React.FC = ({ + collection, + onOpenLink, + onImportGroup, + onDownloadAll, +}) => { + const { t } = useTranslation(); + + return ( +
+ + +
+ } + /> +

+ {collection.collection.title} +

+ + {t('workshop.detail.collectionAuthor', { + author: collection.collection.creator_name || 'Steam', + })} + +
+

+ {collection.collection.description} +

+
+
+

+ {t('workshop.detail.collectionItems', { count: collection.items.length })} +

+
+ {collection.items.map((item) => ( +
+ + +
+ } + /> +
+
+ {item.title} +
+
+ ID: {item.publishedfileid} +
+
+
+ ))} +
+
+
+ + +
+ + +
+
+
+ ); +}; diff --git a/src/components/workshop/ItemCard.tsx b/src/components/workshop/ItemCard.tsx new file mode 100644 index 0000000..73b83ea --- /dev/null +++ b/src/components/workshop/ItemCard.tsx @@ -0,0 +1,147 @@ +/** Workshop item card component */ + +import React from 'react'; +import { Star, Loader2, Package, FileText } from 'lucide-react'; +import { CacheImage } from '../CacheImage'; +import { useTranslation } from 'react-i18next'; +import { WorkshopItem } from './types'; +import { isKnownWorkshopItem } from '../../utils/workshopKnown'; + +interface ItemCardProps { + item: WorkshopItem; + section: string; + addons: Record; + knownUninstalledAddons: Record; + knownCollectionIds?: Set; + onClick: () => void; + isLoading?: boolean; +} + +export const ItemCard: React.FC = ({ + item, + section, + addons, + knownUninstalledAddons, + knownCollectionIds, + onClick, + isLoading, +}) => { + const { t } = useTranslation(); + const isDownloaded = isKnownWorkshopItem(addons, item.workshopId); + const isKnown = isKnownWorkshopItem(knownUninstalledAddons, item.workshopId); + const isKnownCollection = section === 'collections' && !!knownCollectionIds?.has(item.workshopId); + const isKnownEntry = isKnown || isKnownCollection; + const shouldCacheRemote = isDownloaded || isKnownEntry; + + return ( +
+ {isLoading && ( +
+ +
+ )} +
+
+ + +
+ } + /> +
+
+ {isDownloaded && ( + {t('workshop.badges.downloaded')} + )} + {!isDownloaded && isKnownEntry && ( + + {t('workshop.badges.known')} + + )} + + {section === 'collections' ? t('workshop.badges.collection') : t('workshop.badges.addon')} + +
+
+

+ {item.title} +

+
+ {t('workshop.item.author', { author: item.authorName })} +
+ {item.shortDescription && ( +

+ {item.shortDescription} +

+ )} +
+ {item.fileSize && {t('workshop.item.size', { size: item.fileSize })}} + {item.childCount !== undefined && item.childCount > 0 && ( + + {t('workshop.item.childCount', { count: item.childCount })} + + )} + {item.stars > 0 && ( +
+ + {item.stars} +
+ )} + {item.subscriptions !== undefined && item.subscriptions > 0 && ( + {t('workshop.item.subscriptions', { count: item.subscriptions.toLocaleString() })} + )} +
+ {item.tags && item.tags.length > 0 && ( +
+ {item.tags.slice(0, 3).map((t) => ( + + {t} + + ))} +
+ )} +
+
+
+ ); +}; diff --git a/src/components/workshop/SectionCarousel.tsx b/src/components/workshop/SectionCarousel.tsx new file mode 100644 index 0000000..97cf856 --- /dev/null +++ b/src/components/workshop/SectionCarousel.tsx @@ -0,0 +1,119 @@ +/** Homepage section carousel component */ + +import React from 'react'; +import { ChevronRight, Clock, Flame, Package, Users, RefreshCw, Star } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { HomepageSection, WorkshopItem } from './types'; +import { ItemCard } from './ItemCard'; + +const ICON_MAP: Record = { + Clock: , + Flame: , + Package: , + Users: , + RefreshCw: , + Star: , +}; + +interface SectionCarouselProps { + section: HomepageSection; + sectionType: string; + addons: Record; + knownUninstalledAddons: Record; + knownCollectionIds?: Set; + onItemClick: (item: WorkshopItem) => void; + onViewAll: (section: HomepageSection) => void; + loadingDetailId?: string | null; +} + +export const SectionCarousel: React.FC = ({ + section, + sectionType, + addons, + knownUninstalledAddons, + knownCollectionIds, + onItemClick, + onViewAll, + loadingDetailId, +}) => { + const { t } = useTranslation(); + + if (section.items.length === 0) return null; + + return ( +
+
+
+
+ {ICON_MAP[section.icon as string] || } +
+
+
+

+ {t(section.title)} +

+ +
+

+ {t(section.subtitle)} +

+
+
+
+
+ {section.items.slice(0, 6).map((item) => ( + onItemClick(item)} + isLoading={loadingDetailId === item.workshopId} + /> + ))} +
+
+ ); +}; diff --git a/src/components/workshop/TagBrowserModal.tsx b/src/components/workshop/TagBrowserModal.tsx new file mode 100644 index 0000000..6c4855c --- /dev/null +++ b/src/components/workshop/TagBrowserModal.tsx @@ -0,0 +1,80 @@ +/** Tag browser modal — replaces the sidebar tag panel */ + +import React from 'react'; +import { X, Tag } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { TagCategory } from './types'; + +interface TagBrowserModalProps { + open: boolean; + categories: TagCategory[]; + activeTag: string | null; + onClose: () => void; + onTagClick: (tagId: string, tagName: string) => void; +} + +export const TagBrowserModal: React.FC = ({ + open, + categories, + activeTag, + onClose, + onTagClick, +}) => { + const { t } = useTranslation(); + if (!open || categories.length === 0) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ {t('workshop.tags.browse')} +

+ +
+ + {categories.map((cat) => ( +
+
+ {cat.name} +
+
+ {cat.tags.map((tag) => ( + + ))} +
+
+ ))} +
+
+ ); +}; diff --git a/src/components/workshop/TagSidebar.tsx b/src/components/workshop/TagSidebar.tsx new file mode 100644 index 0000000..296acee --- /dev/null +++ b/src/components/workshop/TagSidebar.tsx @@ -0,0 +1,91 @@ +/** Tag category sidebar for filtering workshop items */ + +import React from 'react'; +import { Tag } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { TagCategory } from './types'; + +interface TagSidebarProps { + categories: TagCategory[]; + activeTag: string | null; + onTagClick: (tagId: string, tagName: string) => void; +} + +export const TagSidebar: React.FC = ({ + categories, + activeTag, + onTagClick, +}) => { + const { t } = useTranslation(); + + if (categories.length === 0) return null; + + return ( +
+

+ {t('workshop.tags.browse')} +

+ {categories.map((cat) => ( +
+
+ {cat.name} +
+
+ {cat.tags.map((tag) => ( + + ))} +
+
+ ))} +
+ ); +}; diff --git a/src/components/workshop/WorkshopDetailModal.test.tsx b/src/components/workshop/WorkshopDetailModal.test.tsx new file mode 100644 index 0000000..6059600 --- /dev/null +++ b/src/components/workshop/WorkshopDetailModal.test.tsx @@ -0,0 +1,132 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { WorkshopDetailModal } from './WorkshopDetailModal'; +import type { WorkshopItem, WorkshopPageDetails } from './types'; + +const { + mockFetchWorkshopPageDetails, + mockGetWorkshopPageSnapshot, + mockPersistWorkshopPageDetails, +} = vi.hoisted(() => ({ + mockFetchWorkshopPageDetails: vi.fn(), + mockGetWorkshopPageSnapshot: vi.fn(), + mockPersistWorkshopPageDetails: vi.fn(), +})); + +vi.mock('../../services/workshopClient', () => ({ + fetchWorkshopPageDetails: (...args: unknown[]) => mockFetchWorkshopPageDetails(...args), + getWorkshopPageSnapshot: (...args: unknown[]) => mockGetWorkshopPageSnapshot(...args), + persistWorkshopPageDetails: (...args: unknown[]) => mockPersistWorkshopPageDetails(...args), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(), +})); + +function createWorkshopItem(): WorkshopItem { + return { + workshopId: '3560883926', + title: 'Early Days PART 1/6', + imagePath: '', + authorName: 'perfect_buddy', + authorId: '76561198012020581', + authorUrl: 'https://steamcommunity.com/id/perfectbuddy', + stars: 5, + shortDescription: 'Subscribe to ALL 6 PART', + }; +} + +function createPageDetails(): WorkshopPageDetails { + return { + title: 'Early Days PART 1/6', + description: 'Subscribe to ALL 6 PARTS.\n\nPlease rate part 1.', + imageGallery: [], + tags: [], + requiredItems: [], + parentCollections: [], + }; +} + +describe('WorkshopDetailModal', () => { + beforeEach(() => { + mockFetchWorkshopPageDetails.mockReset(); + mockGetWorkshopPageSnapshot.mockReset(); + mockPersistWorkshopPageDetails.mockReset(); + mockPersistWorkshopPageDetails.mockResolvedValue({}); + }); + + test('prefers full page description over item short description', async () => { + mockGetWorkshopPageSnapshot.mockResolvedValue(createPageDetails()); + mockFetchWorkshopPageDetails.mockResolvedValue(createPageDetails()); + + const { container } = render( + , + ); + + await waitFor(() => { + expect(mockFetchWorkshopPageDetails).toHaveBeenCalledWith('3560883926', 'workshop-detail'); + }); + + const description = container.querySelector('.description-block')?.textContent; + expect(description).toBe('Subscribe to ALL 6 PARTS.\n\nPlease rate part 1.'); + expect(description).not.toBe('Subscribe to ALL 6 PART'); + }); + + test('uses the scraped cover for a no-gallery item and its download task', async () => { + const pageDetails = { + ...createPageDetails(), + previewUrl: 'https://images.steamusercontent.com/ugc/cover-image/', + }; + const onDownload = vi.fn(); + mockGetWorkshopPageSnapshot.mockResolvedValue(pageDetails); + mockFetchWorkshopPageDetails.mockResolvedValue(pageDetails); + + render( + , + ); + + const cover = await screen.findByAltText('Early Days PART 1/6') as HTMLImageElement; + expect(cover.src).toBe('https://images.steamusercontent.com/ugc/cover-image/'); + + fireEvent.click(screen.getByRole('button', { name: /下载/i })); + expect(onDownload).toHaveBeenCalledWith( + '3560883926', + 'Early Days PART 1/6', + 'https://images.steamusercontent.com/ugc/cover-image/', + ); + }); +}); diff --git a/src/components/workshop/WorkshopDetailModal.tsx b/src/components/workshop/WorkshopDetailModal.tsx new file mode 100644 index 0000000..324c3e6 --- /dev/null +++ b/src/components/workshop/WorkshopDetailModal.tsx @@ -0,0 +1,474 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { X, Download, ExternalLink, PlusCircle, FolderPlus, Loader2, FileText, CheckCircle } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { WorkshopItem, WorkshopBrowserProps, WorkshopPageDetails } from './types'; +import { CacheImage } from '../CacheImage'; +import { DatabasePayload, Group } from '../../types/addon'; +import { Gallery } from '../Gallery'; +import { RequiredItems, ParentCollections } from '../WorkshopCommon'; +import { fetchWorkshopPageDetails, getWorkshopPageSnapshot, persistWorkshopPageDetails } from '../../services/workshopClient'; +import { resolveWorkshopItemAuthor } from './authorDirectory'; +import { isKnownWorkshopItem } from '../../utils/workshopKnown'; + +interface WorkshopDetailModalProps { + open: boolean; + item: WorkshopItem | null; + collection: { + title: string; + description: string; + imagePath: string; + creatorName: string; + creatorId: string; + items: WorkshopItem[]; + workshopId?: string; + } | null; + onClose: () => void; + onDownload: WorkshopBrowserProps['onDownload']; + onDownloadMany?: WorkshopBrowserProps['onDownloadMany']; + onOpenLink: WorkshopBrowserProps['onOpenLink']; + onImportCollection: WorkshopBrowserProps['onImportCollection']; + /** Navigate to an addon detail within the app */ + onItemNavigate: (workshopId: string) => void; + /** Navigate to a collection detail within the app */ + onCollectionNavigate: (workshopId: string) => void; + addons: Record; + knownUninstalledAddons: Record; + downloadProgress: Record; + isSubmitting: boolean; + groups?: Group[]; + isLoading?: boolean; + onDatabaseUpdate?: (data: DatabasePayload) => void; +} + +export const WorkshopDetailModal: React.FC = ({ + open, + item, + collection, + onClose, + onDownload, + onDownloadMany, + onOpenLink, + onImportCollection, + onItemNavigate, + onCollectionNavigate, + addons, + knownUninstalledAddons, + downloadProgress, + isSubmitting, + groups, + isLoading, + onDatabaseUpdate, +}) => { + const { t } = useTranslation(); + const [pageDetails, setPageDetails] = useState(null); + const [pageDetailsLoading, setPageDetailsLoading] = useState(false); + + + // Fetch extra details by scraping the workshop page HTML + const fetchPageDetails = useCallback(async (workshopId: string) => { + setPageDetailsLoading(true); + const snapshot = await getWorkshopPageSnapshot(workshopId); + if (snapshot) { + setPageDetails(snapshot); + } else { + setPageDetails(null); + } + try { + const details = await fetchWorkshopPageDetails(workshopId, 'workshop-detail'); + setPageDetails(details); + const data: DatabasePayload = await persistWorkshopPageDetails(workshopId, details, 'workshop-detail') as DatabasePayload; + onDatabaseUpdate?.(data); + } catch (err) { + console.error('Failed to fetch workshop page details:', err); + } finally { + setPageDetailsLoading(false); + } + }, [onDatabaseUpdate]); + + const firstItemWorkshopId = collection?.items?.[0]?.workshopId; + + useEffect(() => { + if (open) { + const id = item?.workshopId || collection?.workshopId || firstItemWorkshopId; + if (id) { + fetchPageDetails(id); + } + } else { + setPageDetails(null); + } + }, [open, item?.workshopId, collection?.workshopId, firstItemWorkshopId, fetchPageDetails]); + + if (!open || (!item && !collection)) return null; + + // ── Collection detail ──────────────────────────────────────────────────────── + if (collection) { + const displayCollectionItems = pageDetails?.collectionItems?.length + ? pageDetails.collectionItems + : collection.items; + const allWorkshopIds = displayCollectionItems.map((i) => i.workshopId); + const collectionId = collection.workshopId || allWorkshopIds[0]; + const isKnownCollection = !!collectionId && !!groups?.some( + (group) => group.workshopCollectionId?.trim() === collectionId, + ); + // Use the large background image from the scraped page if available + const heroImage = pageDetails?.backgroundImageUrl || collection.imagePath; + const collectionCreatorName = pageDetails?.creatorName || collection.creatorName; + const collectionDescription = pageDetails?.description || collection.description; + + return ( +
+
e.stopPropagation()} style={{ position: 'relative' }}> + {isLoading && ( +
+ +
+ )} + {/* Header */} +
+

{t('workshop.detail.collectionTitle')}

+ +
+ +
+ {/* Left: preview + metadata */} +
+
+ {heroImage ? ( + + ) : ( + + )} +
+ +
+
+ {t('workshop.detail.collectionAuthor')} + {collectionCreatorName} +
+
+ {t('workshop.detail.collectionItems')} + {t('workshop.detail.collectionItemsCount', { count: displayCollectionItems.length })} +
+ {collectionId && ( +
+ {t('workshop.detail.workshopId')} + {collectionId} +
+ )} + {/* Tags from scraped page */} + {pageDetails && pageDetails.tags.length > 0 && ( +
+ {t('workshop.detail.type')} +
+ {pageDetails.tags.map((tag, i) => ( + + {tag.name} + + ))} +
+
+ )} +
+
+ + {/* Right: description + items */} +
+

{collection.title}

+ {collectionDescription && ( + <> +
{t('workshop.detail.descriptionLabel')}
+
{collectionDescription}
+ + )} + +
+ {t('workshop.detail.itemsInCollection')} +
+
+ {displayCollectionItems.map((ci) => { + const isKnown = isKnownWorkshopItem(knownUninstalledAddons, ci.workshopId); + const isDownloaded = isKnownWorkshopItem(addons, ci.workshopId); + const shouldCacheRemote = isDownloaded || isKnown; + return ( +
onItemNavigate(ci.workshopId)} + style={{ + display: 'flex', alignItems: 'center', gap: '10px', + padding: '6px 10px', borderRadius: '8px', cursor: 'pointer', + background: 'var(--md-sys-color-surface-container-low)', + }} + > + {ci.imagePath ? ( + + ) : ( +
+ +
+ )} +
+
{ci.title}
+
{ci.authorName}
+
+ {isDownloaded && } + {!isDownloaded && isKnown && {t('workshop.badges.known')}} +
+ ); + })} +
+
+
+ + {/* Bottom actions */} +
+
+ + +
+ +
+
+
+ ); + } + + // ── Item detail ────────────────────────────────────────────────────────────── + if (!item) return null; + + const isKnown = isKnownWorkshopItem(knownUninstalledAddons, item.workshopId); + const isDownloaded = isKnownWorkshopItem(addons, item.workshopId); + const downloading = downloadProgress[item.workshopId] !== undefined; + const progress = downloadProgress[item.workshopId]; + const shouldCacheRemote = isDownloaded || isKnown; + const resolvedItem = resolveWorkshopItemAuthor(item); + const displayAuthorName = pageDetails?.creatorName || resolvedItem.authorName; + const displayAuthorUrl = pageDetails?.creatorProfileUrl || resolvedItem.authorUrl; + const displayDescription = pageDetails?.description || item.shortDescription; + + // Find group this addon belongs to + const itemGroup = groups?.find(g => ( + g.addons.includes(item.workshopId) || + g.addons.some((addonId) => { + const addon = addons[addonId] || knownUninstalledAddons[addonId]; + return addon?.workshopId === item.workshopId; + }) + )); + + // Build gallery: cover image first, then scraped screenshots + const scrapedGallery = pageDetails?.imageGallery || []; + const coverUrl = pageDetails?.previewUrl || item.imagePath || ''; + // Deduplicate: if cover is already in scraped gallery, don't add it twice + const gallery = coverUrl + ? [coverUrl, ...scrapedGallery.filter((u) => u !== coverUrl)] + : scrapedGallery; + + return ( +
+
e.stopPropagation()} style={{ position: 'relative' }}> + {isLoading && ( +
+ +
+ )} + {/* Header */} +
+

{t('workshop.detail.title')}

+ +
+ +
+ {/* Left: preview + metadata */} +
+ + +
+ {item.fileSize && ( +
+ {t('workshop.detail.itemSize')} + {item.fileSize} +
+ )} + {item.workshopId && ( +
+ {t('workshop.detail.workshopId')} + {item.workshopId} +
+ )} +
+ {t('workshop.detail.status')} + + {isDownloaded ? t('workshop.badges.downloaded') : isKnown ? t('workshop.badges.known') : t('workshop.badges.notDownloaded')} + +
+ {/* Tags from scraped page */} + {pageDetails && pageDetails.tags.length > 0 && ( +
+ {t('workshop.detail.type')} +
+ {pageDetails.tags.map((tag, i) => ( + + {tag.name} + + ))} +
+
+ )} +
+
+ + {/* Right: title, author, tags, description, required items, parent collections */} +
+

{item.title}

+ + + + {/* Group info */} + {itemGroup && ( +
+ + {t('detailModal.belongsToGroup', { name: '' })}{itemGroup.name} +
+ )} + + {item.tags && item.tags.length > 0 && ( +
+ {item.tags.map((tag, i) => ( + {tag} + ))} +
+ )} + + {displayDescription && ( + <> +
{t('workshop.detail.descriptionLabel')}
+
{displayDescription}
+ + )} + + {/* Required items — navigate within the app */} + + + {/* Parent collections — navigate within the app */} + + + {/* Loading indicator for page details */} + {pageDetailsLoading && ( +
+ {t('workshop.detail.loadingExtra')} +
+ )} +
+
+ + {/* Bottom actions */} +
+
+ + {!isDownloaded && ( + + )} + { e.preventDefault(); onOpenLink(`steam://url/CommunityFilePage/${item.workshopId}`); }} className="btn btn-text" style={{ display: 'inline-flex', gap: '6px' }}> + + {t('workshop.detail.openInSteam')} + + { e.preventDefault(); onOpenLink(`https://steamcommunity.com/sharedfiles/filedetails/?id=${item.workshopId}`); }} className="btn btn-text" style={{ display: 'inline-flex', gap: '6px' }}> + {t('workshop.detail.viewOnSteam')} + +
+
+
+ +
+ ); +}; diff --git a/src/components/workshop/authorDirectory.test.ts b/src/components/workshop/authorDirectory.test.ts new file mode 100644 index 0000000..69ae4c0 --- /dev/null +++ b/src/components/workshop/authorDirectory.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, test } from 'vitest'; +import type { WorkshopItem, WorkshopPageDetails } from './types'; +import { + __resetWorkshopAuthorDirectoryForTests, + rememberWorkshopItems, + rememberWorkshopPageDetails, + resolveWorkshopItemAuthor, +} from './authorDirectory'; + +function createItem(overrides: Partial): WorkshopItem { + return { + workshopId: '100', + title: 'Test Item', + imagePath: '', + authorName: '', + authorId: '', + authorUrl: '', + stars: 0, + ...overrides, + }; +} + +afterEach(() => { + __resetWorkshopAuthorDirectoryForTests(); +}); + +describe('authorDirectory', () => { + test('reuses author name across pages when another page only has steam id', () => { + const namedItem = createItem({ + workshopId: '101', + authorName: 'Real Author', + authorId: '76561198000000001', + authorSteamId: '76561198000000001', + authorUrl: 'https://steamcommunity.com/profiles/76561198000000001', + }); + + rememberWorkshopItems([namedItem]); + + const idOnlyItem = createItem({ + workshopId: '102', + authorName: '76561198000000001', + authorId: '76561198000000001', + authorSteamId: '76561198000000001', + }); + + const resolved = resolveWorkshopItemAuthor(idOnlyItem); + expect(resolved.authorName).toBe('Real Author'); + expect(resolved.authorUrl).toBe('https://steamcommunity.com/profiles/76561198000000001'); + }); + + test('does not let numeric placeholder overwrite a known author name', () => { + rememberWorkshopItems([ + createItem({ + workshopId: '101', + authorName: 'Real Author', + authorId: '76561198000000001', + authorSteamId: '76561198000000001', + }), + ]); + + rememberWorkshopItems([ + createItem({ + workshopId: '102', + authorName: '76561198000000001', + authorId: '76561198000000001', + authorSteamId: '76561198000000001', + }), + ]); + + const resolved = resolveWorkshopItemAuthor(createItem({ + workshopId: '103', + authorName: '76561198000000001', + authorId: '76561198000000001', + authorSteamId: '76561198000000001', + })); + + expect(resolved.authorName).toBe('Real Author'); + }); + + test('does not store or return AUTHOR_NAME as an author', () => { + const unresolved = resolveWorkshopItemAuthor(createItem({ + authorName: 'AUTHOR_NAME', + authorId: '76561198000000003', + authorSteamId: '76561198000000003', + })); + expect(unresolved.authorName).toBe(''); + + rememberWorkshopItems([ + createItem({ + authorName: 'Real Author', + authorId: '76561198000000003', + authorSteamId: '76561198000000003', + }), + ]); + + const resolved = resolveWorkshopItemAuthor(createItem({ + authorName: 'AUTHOR_NAME', + authorId: '76561198000000003', + authorSteamId: '76561198000000003', + })); + expect(resolved.authorName).toBe('Real Author'); + }); + + test('learns author identity from workshop page details', () => { + const details: WorkshopPageDetails = { + creatorName: 'Page Author', + creatorProfileUrl: 'https://steamcommunity.com/profiles/76561198000000002', + creatorSteamId: '76561198000000002', + imageGallery: [], + tags: [], + requiredItems: [], + parentCollections: [], + }; + + rememberWorkshopPageDetails(details); + + const resolved = resolveWorkshopItemAuthor(createItem({ + workshopId: '104', + authorName: '76561198000000002', + authorId: '76561198000000002', + authorSteamId: '76561198000000002', + })); + + expect(resolved.authorName).toBe('Page Author'); + expect(resolved.authorUrl).toBe('https://steamcommunity.com/profiles/76561198000000002'); + }); + + test('matches Steam account id to SteamID64 for page author mappings', () => { + rememberWorkshopPageDetails({ + creatorName: 'perfect_buddy', + creatorProfileUrl: 'https://steamcommunity.com/id/perfectbuddy', + creatorAccountId: '51754853', + imageGallery: [], + tags: [], + requiredItems: [], + parentCollections: [], + }); + + const resolved = resolveWorkshopItemAuthor(createItem({ + workshopId: '105', + authorName: '76561198012020581', + authorId: '76561198012020581', + authorSteamId: '76561198012020581', + })); + + expect(resolved.authorName).toBe('perfect_buddy'); + expect(resolved.authorUrl).toBe('https://steamcommunity.com/id/perfectbuddy'); + }); +}); diff --git a/src/components/workshop/authorDirectory.ts b/src/components/workshop/authorDirectory.ts new file mode 100644 index 0000000..c4f320e --- /dev/null +++ b/src/components/workshop/authorDirectory.ts @@ -0,0 +1,211 @@ +import { WorkshopItem, WorkshopPageDetails } from './types'; +import { isPlaceholderAuthorName } from '../../utils/addonHelpers'; + +interface AuthorDirectoryEntry { + name?: string; + url?: string; + steamId?: string; + vanityId?: string; + accountId?: string; +} + +type AuthorIdentity = { + authorName?: string; + authorUrl?: string; + authorSteamId?: string; + authorVanityId?: string; + authorAccountId?: string; + authorId?: string; + ownerSteamId?: string; + ownerAccountId?: string; +}; + +const authorDirectory = new Map(); + +function normalizeValue(value?: string | null): string { + return String(value || '').trim(); +} + +function normalizeKey(type: string, value?: string | null): string | null { + const trimmed = normalizeValue(value); + if (!trimmed) return null; + return `${type}:${trimmed.toLowerCase()}`; +} + +function parseProfileIdentifiers(url?: string | null): { + steamId?: string; + vanityId?: string; +} { + const trimmed = normalizeValue(url); + if (!trimmed) return {}; + return { + steamId: trimmed.match(/\/profiles\/(\d+)/)?.[1], + vanityId: trimmed.match(/\/id\/([^/?#]+)/)?.[1], + }; +} + +function isNumericIdentifier(value?: string | null): boolean { + return /^\d+$/.test(normalizeValue(value)); +} + +function isSteamId64(value?: string | null): boolean { + return /^\d{17,20}$/.test(normalizeValue(value)); +} + +function accountIdToSteamId(accountId?: string | null): string { + const normalized = normalizeValue(accountId); + if (!/^\d+$/.test(normalized)) return ''; + try { + return (BigInt(normalized) + 76561197960265728n).toString(); + } catch { + return ''; + } +} + +function looksLikePlaceholderName(name?: string | null, ids: string[] = []): boolean { + return isPlaceholderAuthorName(name, ids); +} + +function collectIdentity(input: AuthorIdentity): Required { + const parsedFromUrl = parseProfileIdentifiers(input.authorUrl); + const authorId = normalizeValue(input.authorId); + const accountId = normalizeValue(input.authorAccountId || input.ownerAccountId); + const authorSteamId = normalizeValue( + input.authorSteamId + || input.ownerSteamId + || (isSteamId64(authorId) ? authorId : '') + || accountIdToSteamId(accountId), + ); + const authorVanityId = normalizeValue( + input.authorVanityId || (!isNumericIdentifier(authorId) ? authorId : '') || parsedFromUrl.vanityId, + ); + + return { + authorName: normalizeValue(input.authorName), + authorUrl: normalizeValue(input.authorUrl), + authorSteamId: authorSteamId || normalizeValue(parsedFromUrl.steamId), + authorVanityId, + authorAccountId: accountId, + authorId, + ownerSteamId: normalizeValue(input.ownerSteamId), + ownerAccountId: normalizeValue(input.ownerAccountId), + }; +} + +function buildKeys(identity: Required): string[] { + const keys = [ + normalizeKey('steam', identity.authorSteamId), + normalizeKey('account', identity.authorAccountId), + normalizeKey('vanity', identity.authorVanityId), + normalizeKey('id', identity.authorId), + normalizeKey('url', identity.authorUrl), + ].filter((key): key is string => Boolean(key)); + + return [...new Set(keys)]; +} + +function lookupAuthor(identity: AuthorIdentity): AuthorDirectoryEntry | null { + const normalized = collectIdentity(identity); + for (const key of buildKeys(normalized)) { + const entry = authorDirectory.get(key); + if (entry) return entry; + } + return null; +} + +function rememberAuthor(identity: AuthorIdentity): void { + const normalized = collectIdentity(identity); + const keys = buildKeys(normalized); + if (keys.length === 0) return; + + const existing = keys + .map((key) => authorDirectory.get(key)) + .find(Boolean); + + const candidateIds = [ + normalized.authorSteamId, + normalized.authorAccountId, + normalized.authorVanityId, + normalized.authorId, + ].filter(Boolean); + + const merged: AuthorDirectoryEntry = { + name: looksLikePlaceholderName(normalized.authorName, candidateIds) + ? existing?.name + : normalized.authorName || existing?.name, + url: normalized.authorUrl || existing?.url, + steamId: normalized.authorSteamId || existing?.steamId, + vanityId: normalized.authorVanityId || existing?.vanityId, + accountId: normalized.authorAccountId || existing?.accountId, + }; + + for (const key of keys) { + authorDirectory.set(key, merged); + } +} + +export function rememberWorkshopItems(items: WorkshopItem[]): WorkshopItem[] { + items.forEach((item) => rememberAuthor(item)); + return items.map((item) => resolveWorkshopItemAuthor(item)); +} + +export function rememberWorkshopPageDetails(details: WorkshopPageDetails): void { + rememberAuthor({ + authorName: details.creatorName, + authorUrl: details.creatorProfileUrl, + authorSteamId: details.creatorSteamId, + authorVanityId: details.creatorVanityId, + authorAccountId: details.creatorAccountId, + authorId: details.creatorSteamId || details.creatorVanityId || details.creatorAccountId, + }); +} + +export function resolveWorkshopItemAuthor(item: WorkshopItem): WorkshopItem { + const normalized = collectIdentity(item); + const known = lookupAuthor(item); + const candidateIds = [ + normalized.authorSteamId, + normalized.authorAccountId, + normalized.authorVanityId, + normalized.authorId, + ].filter(Boolean); + + const authorName = looksLikePlaceholderName(item.authorName, candidateIds) + ? known?.name || '' + : item.authorName; + const authorSteamId = item.authorSteamId || known?.steamId; + const authorVanityId = item.authorVanityId || known?.vanityId; + const authorAccountId = item.authorAccountId || known?.accountId; + const authorUrl = item.authorUrl + || known?.url + || (authorVanityId ? `https://steamcommunity.com/id/${authorVanityId}` : '') + || (authorSteamId ? `https://steamcommunity.com/profiles/${authorSteamId}` : ''); + const authorId = item.authorId || authorVanityId || authorSteamId || authorAccountId || ''; + + if ( + authorName === item.authorName + && authorUrl === item.authorUrl + && authorSteamId === item.authorSteamId + && authorVanityId === item.authorVanityId + && authorAccountId === item.authorAccountId + && authorId === item.authorId + ) { + return item; + } + + return { + ...item, + authorName, + authorUrl, + authorSteamId, + authorVanityId, + authorAccountId, + authorId, + ownerSteamId: item.ownerSteamId || authorSteamId, + ownerAccountId: item.ownerAccountId || authorAccountId, + }; +} + +export function __resetWorkshopAuthorDirectoryForTests(): void { + authorDirectory.clear(); +} diff --git a/src/components/workshop/index.ts b/src/components/workshop/index.ts new file mode 100644 index 0000000..f966190 --- /dev/null +++ b/src/components/workshop/index.ts @@ -0,0 +1,8 @@ +/** Workshop component exports */ + +export { ItemCard } from './ItemCard'; +export { TagBrowserModal } from './TagBrowserModal'; +export { SectionCarousel } from './SectionCarousel'; +export { WorkshopDetailModal } from './WorkshopDetailModal'; +export { parseSSRItems, parseHomepageSections, parseTagCategories } from './ssrParser'; +export * from './types'; diff --git a/src/components/workshop/ssrParser.test.ts b/src/components/workshop/ssrParser.test.ts new file mode 100644 index 0000000..780ae64 --- /dev/null +++ b/src/components/workshop/ssrParser.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'vitest'; +import { parseWorkshopPageDetails } from './ssrParser'; + +describe('ssrParser', () => { + test('preserves Steam description line breaks from rendered HTML', () => { + const details = parseWorkshopPageDetails(` +
Early Days PART 1/6
+
+ +
perfect_buddy
Offline
+
+
+ UPDATE: Fixed things.

+
Gameplay
+ First line.
Second line. +
  • Residential
  • Downtown
+
+ `); + + expect(details.description).toContain('UPDATE: Fixed things.'); + expect(details.description).toContain('\n\nGameplay\n'); + expect(details.description).toContain('First line.\nSecond line.'); + expect(details.description).toContain('* Residential'); + expect(details.creatorName).toBe('perfect_buddy'); + expect(details.creatorSteamId).toBe('76561198012020581'); + }); + + test('parses collection children from Steam collection page DOM', () => { + const details = parseWorkshopPageDetails(` +
Early Days Campaign
+
+ Subscribe to all 6 parts.

Please rate part 1. +
+
+
+ +
+ +
Early Days PART 1/6
+
+
+ Created by + + perfect_buddy + +
+ +
UPDATE: Some performance improvements...
+
+
+
+
+ +
Early Days PART 2/6
+
+
+ Created by + + perfect_buddy + +
+
+
+
+ `); + + expect(details.description).toContain('Subscribe to all 6 parts.'); + expect(details.childItemIds).toEqual(['3560883926', '3560886114']); + expect(details.collectionItems?.map((item) => item.title)).toEqual([ + 'Early Days PART 1/6', + 'Early Days PART 2/6', + ]); + expect(details.collectionItems?.[0].authorName).toBe('perfect_buddy'); + expect(details.collectionItems?.[0].authorVanityId).toBe('perfectbuddy'); + expect(details.collectionItems?.[0].stars).toBe(5); + }); + + test('uses the current Steam preview image as the cover when no gallery exists', () => { + const details = parseWorkshopPageDetails(` +
Lingshan-Guangxi V2.9
+
+ +
+ + `); + + expect(details.previewUrl).toBe( + 'https://images.steamusercontent.com/ugc/10303821298862575212/4771C25DD397BD581B244C8A37355A1AB7603C04/', + ); + expect(details.imageGallery).toEqual([]); + }); +}); diff --git a/src/components/workshop/ssrParser.ts b/src/components/workshop/ssrParser.ts new file mode 100644 index 0000000..c563325 --- /dev/null +++ b/src/components/workshop/ssrParser.ts @@ -0,0 +1,731 @@ +/** SSR parsing utilities for Steam Workshop pages */ + +import { formatBytes } from '../../utils/addonHelpers'; +import { WorkshopItem, HomepageSection, TagCategory, WorkshopPageDetails } from './types'; + +interface AuthorInfo { + authorName: string; + authorUrl: string; + stars: number; + authorSteamId?: string; + authorVanityId?: string; + authorAccountId?: string; +} + +function parseCount(text: string): number | undefined { + const normalized = (text || '').replace(/,/g, '').trim(); + const match = normalized.match(/\d+/); + if (!match) return undefined; + const value = parseInt(match[0], 10); + return Number.isFinite(value) ? value : undefined; +} + +function parseProfileIdentifiers(url: string): { + profileUrl: string; + steamId?: string; + vanityId?: string; +} { + const trimmed = (url || '').trim(); + if (!trimmed) { + return { profileUrl: '' }; + } + + const steamId = trimmed.match(/\/profiles\/(\d+)/)?.[1]; + const vanityId = trimmed.match(/\/id\/([^/?#]+)/)?.[1]; + return { + profileUrl: trimmed, + steamId, + vanityId, + }; +} + +function accountIdToSteamId(accountId?: string): string | undefined { + const normalized = (accountId || '').trim(); + if (!/^\d+$/.test(normalized)) return undefined; + try { + return (BigInt(normalized) + 76561197960265728n).toString(); + } catch { + return undefined; + } +} + +// ── Helper: find balanced {…} from a start position in HTML ─────────────────── + +function extractBalancedJson(html: string, startIdx: number): string | null { + if (html[startIdx] !== '{') return null; + let depth = 0; + let inStr = false; + let escaped = false; + for (let i = startIdx; i < html.length; i++) { + const ch = html[i]; + if (escaped) { escaped = false; continue; } + if (ch === '\\') { escaped = true; continue; } + if (ch === '"') { inStr = !inStr; continue; } + if (inStr) continue; + if (ch === '{') depth++; + if (ch === '}') { depth--; if (depth === 0) return html.slice(startIdx, i + 1); } + } + return null; +} + +// ── Helper: extract SSR renderContext & queryData ───────────────────────────── + +function extractSSRQueryData(html: string): any | null { + try { + // Pattern 1: window.SSR.renderContext=JSON.parse("…"); + // Steam Workshop uses this format. The JSON string contains escaped quotes. + const jpPrefix = 'window.SSR.renderContext=JSON.parse("'; + const jpIdx = html.indexOf(jpPrefix); + if (jpIdx !== -1) { + const jsonStart = jpIdx + jpPrefix.length; + // Find closing "); — scan for unescaped " followed by ); + let jsonEnd = -1; + let esc = false; + for (let i = jsonStart; i < html.length; i++) { + if (esc) { esc = false; continue; } + if (html[i] === '\\') { esc = true; continue; } + if (html[i] === '"' && html[i + 1] === ')' && html[i + 2] === ';') { + jsonEnd = i; + break; + } + } + if (jsonEnd !== -1) { + const rawJson = html.slice(jsonStart, jsonEnd); + // Unescape JS string escapes: \" → ", \\ → \ + const rcJson = rawJson.replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + const rc = JSON.parse(rcJson); + const qd = rc?.queryData; + return typeof qd === 'string' ? JSON.parse(qd) : qd; + } + } + + // Pattern 2: window.SSR.renderContext={…}; + const directPrefix = 'window.SSR.renderContext='; + const directIdx = html.indexOf(directPrefix); + if (directIdx !== -1) { + const braceStart = directIdx + directPrefix.length; + if (html[braceStart] === '{') { + const jsonStr = extractBalancedJson(html, braceStart); + if (jsonStr) { + const rc = new Function(`return ${jsonStr}`)(); + const qd = rc?.queryData; + return typeof qd === 'string' ? JSON.parse(qd) : qd; + } + } + } + + return null; + } catch { + return null; + } +} + +// ── Helper: extract items from a single SSR query entry ─────────────────────── + +function extractItemsFromQuery(query: any, domMap: Map): WorkshopItem[] { + const items: WorkshopItem[] = []; + const data = query?.state?.data; + if (!data) return items; + + // Two data formats: + // Homepage (workshop_query): numeric keys → { "0": {publishedfileid, ...}, "1": ... } + // Browse (workshop_browse): data.results → [{publishedfileid, ...}, ...] + const rawItems: any[] = []; + if (Array.isArray(data.results)) { + rawItems.push(...data.results); + } else { + const keys = Object.keys(data).filter((k) => !isNaN(Number(k))); + for (const k of keys) { + if (data[k]) rawItems.push(data[k]); + } + } + + for (const item of rawItems) { + if (!item?.publishedfileid) continue; + const workshopId = item.publishedfileid; + const domData = domMap.get(workshopId) || { authorName: '', authorUrl: '', stars: 0 }; + let authorId = domData.authorVanityId || domData.authorSteamId || domData.authorAccountId || ''; + let authorSteamId = domData.authorSteamId || ''; + let authorVanityId = domData.authorVanityId || ''; + let authorAccountId = domData.authorAccountId || ''; + // Prefer SSR creator_player_link_details for author info (browse page) + let authorName = domData.authorName; + let authorUrl = domData.authorUrl; + if (item.creator) { + authorSteamId = String(item.creator); + if (!authorUrl) { + authorUrl = `https://steamcommunity.com/profiles/${item.creator}`; + } + if (!authorId) { + authorId = authorSteamId; + } + } + // Check query-level creator_player_link_details + const creatorDetails = query?.state?.data?.creator_player_link_details; + if (creatorDetails && item.creator) { + const cd = creatorDetails[item.creator]; + const publicData = cd?.public_data || cd; + const privateData = cd?.private_data || {}; + if (!authorName && publicData?.persona_name) { + authorName = String(publicData.persona_name); + } + if (!authorSteamId && publicData?.steamid) { + authorSteamId = String(publicData.steamid); + } + if (!authorVanityId && publicData?.profile_url) { + authorVanityId = String(publicData.profile_url); + } + if (!authorAccountId && privateData?.account_name) { + authorAccountId = String(privateData.account_name); + } + if (authorVanityId) { + authorUrl = `https://steamcommunity.com/id/${authorVanityId}`; + } else if (authorSteamId) { + authorUrl = `https://steamcommunity.com/profiles/${authorSteamId}`; + } + } + items.push({ + workshopId, + title: (item.title || '').trim(), + imagePath: item.preview_url || '', + authorName, + authorId, + authorUrl, + authorSteamId: authorSteamId || undefined, + authorVanityId: authorVanityId || undefined, + authorAccountId: authorAccountId || undefined, + stars: item.star_rating !== undefined ? item.star_rating : domData.stars, + shortDescription: item.short_description || '', + fileSize: item.file_size ? formatBytes(parseInt(item.file_size)) : undefined, + tags: item.tags ? item.tags.map((t: any) => t.display_name || t.tag) : [], + subscriptions: item.subscriptions, + favorites: item.favorited, + lifetimeSubscriptions: item.lifetime_subscriptions, + lifetimeFavorites: item.lifetime_favorited, + views: item.views, + comments: item.num_comments_public, + totalVotes: item.total_votes, + timeCreated: item.time_created, + timeUpdated: item.time_updated, + childCount: item.num_children !== undefined ? parseInt(item.num_children) : undefined, + previewCount: Array.isArray(item.previews) ? item.previews.length : undefined, + childItemIds: Array.isArray(item.children) + ? item.children.map((child: any) => String(child?.publishedfileid || child?.publishedfile_id || '')).filter(Boolean) + : undefined, + galleryPreviewUrls: Array.isArray(item.previews) + ? item.previews.map((preview: any) => preview?.url || preview?.preview_url || '').filter(Boolean) + : undefined, + }); + } + return items; +} + +// ── Helper: parse DOM cards into a lookup map ───────────────────────────────── + +function parseDOMCardMap(html: string): Map { + const map = new Map(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const cardXPath = + "//div[contains(@class, ' Panel') and starts-with(./div/a/@href, 'https://steamcommunity.com/sharedfiles/filedetails/?id=')]"; + const snapshot = doc.evaluate(cardXPath, doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + + for (let i = 0; i < snapshot.snapshotLength; i++) { + const card = snapshot.snapshotItem(i) as HTMLElement; + const linkVal = doc.evaluate( + "(.//a[contains(@href, 'filedetails/?id=')])[1]/@href", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue; + const m = linkVal.match(/id=(\d+)/); + if (!m) continue; + const workshopId = m[1]; + const authorUrl = doc.evaluate( + ".//a[contains(@href, 'myworkshopfiles')]/@href", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue; + let authorName = doc.evaluate( + ".//a[contains(@href, 'myworkshopfiles')]/text()", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue.trim(); + if (/^by\s+/i.test(authorName)) authorName = authorName.replace(/^by\s+/i, ''); + const { steamId, vanityId } = parseProfileIdentifiers(authorUrl); + const stars = parseInt( + doc.evaluate("count(.//svg[contains(@class, 'SVGIcon_Star_Filled')])", + card, null, XPathResult.NUMBER_TYPE, null, + ).numberValue.toString(), + ) || 0; + map.set(workshopId, { + authorName, + authorUrl, + stars, + authorSteamId: steamId, + authorVanityId: vanityId, + }); + } + return map; +} + +// ── Helper: parse workshop items from a single DOM card ─────────────────────── + +function parseDOMFallbackItems(html: string): WorkshopItem[] { + const items: WorkshopItem[] = []; + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const cardXPath = + "//div[contains(@class, ' Panel') and starts-with(./div/a/@href, 'https://steamcommunity.com/sharedfiles/filedetails/?id=')]"; + const snapshot = doc.evaluate(cardXPath, doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); + + for (let i = 0; i < snapshot.snapshotLength; i++) { + const card = snapshot.snapshotItem(i) as HTMLElement; + const linkVal = doc.evaluate( + "(.//a[contains(@href, 'filedetails/?id=')])[1]/@href", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue; + const m = linkVal.match(/id=(\d+)/); + if (!m) continue; + const workshopId = m[1]; + let title = doc.evaluate( + "(.//a[contains(@href, 'filedetails/?id=')])[2]", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue.trim(); + if (!title) { + title = doc.evaluate( + "(.//a[contains(@href, 'filedetails/?id=')])[1]", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue.trim(); + } + const imagePath = doc.evaluate(".//img/@src", card, null, XPathResult.STRING_TYPE, null).stringValue; + const authorUrl = doc.evaluate( + ".//a[contains(@href, 'myworkshopfiles')]/@href", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue; + let authorName = doc.evaluate( + ".//a[contains(@href, 'myworkshopfiles')]/text()", + card, null, XPathResult.STRING_TYPE, null, + ).stringValue.trim(); + if (/^by\s+/i.test(authorName)) authorName = authorName.replace(/^by\s+/i, ''); + const { steamId, vanityId } = parseProfileIdentifiers(authorUrl); + const authorId = vanityId || steamId || ''; + const stars = parseInt( + doc.evaluate("count(.//svg[contains(@class, 'SVGIcon_Star_Filled')])", + card, null, XPathResult.NUMBER_TYPE, null, + ).numberValue.toString(), + ) || 0; + items.push({ + workshopId, + title: title || '', + imagePath, + authorName: authorName || '', + authorId, + authorUrl, + authorSteamId: steamId, + authorVanityId: vanityId, + stars, + }); + } + return items; +} + +// ── Main: parse SSR workshop items (for browse page) ───────────────────────── + +export function parseSSRItems( + html: string, + queryKeyPrefix: string, +): WorkshopItem[] { + const domMap = parseDOMCardMap(html); + + // Try SSR queryData — also try 'workshop_browse' as fallback + const queryDataObj = extractSSRQueryData(html); + if (queryDataObj?.queries) { + for (const prefix of [queryKeyPrefix, 'workshop_browse', 'workshop_query']) { + const matchingQueries = queryDataObj.queries.filter( + (q: any) => q?.queryKey?.[0] === prefix, + ); + if (matchingQueries.length > 0) { + const items = extractItemsFromQuery(matchingQueries[0], domMap); + if (items.length > 0) return items; + } + } + } + + // Fallback to pure DOM + return parseDOMFallbackItems(html); +} + +/** Parse the homepage SSR to extract all homepage sections. + * Parses the SSR queryData once, then distributes items to each section. */ +export function parseHomepageSections(html: string): HomepageSection[] { + const sectionDefs: Array<{ + id: string; + titleKey: string; + subtitleKey: string; + icon: string; + queryIndex: number; + browseParams: HomepageSection['browseParams']; + }> = [ + { + id: 'past-week', + titleKey: 'workshop.home.pastWeek', + subtitleKey: 'workshop.home.pastWeekDesc', + icon: 'Clock', + queryIndex: 0, + browseParams: { sort: 'trend', section: 'readytouseitems', days: 7 }, + }, + { + id: 'trending', + titleKey: 'workshop.home.trending', + subtitleKey: 'workshop.home.trendingDesc', + icon: 'Flame', + queryIndex: 1, + browseParams: { sort: 'trend', section: 'readytouseitems', days: 90 }, + }, + { + id: 'most-required', + titleKey: 'workshop.home.mostRequired', + subtitleKey: 'workshop.home.mostRequiredDesc', + icon: 'Package', + queryIndex: 4, + browseParams: { sort: 'trend', section: 'readytouseitems' }, + }, + { + id: 'most-subscribed', + titleKey: 'workshop.home.mostSubscribed', + subtitleKey: 'workshop.home.mostSubscribedDesc', + icon: 'Users', + queryIndex: 2, + browseParams: { sort: 'totalprofiles', section: 'readytouseitems' }, + }, + { + id: 'recently-updated', + titleKey: 'workshop.home.recentlyUpdated', + subtitleKey: 'workshop.home.recentlyUpdatedDesc', + icon: 'RefreshCw', + queryIndex: 3, + browseParams: { sort: 'mostrecent', section: 'readytouseitems' }, + }, + { + id: 'newest', + titleKey: 'workshop.home.newest', + subtitleKey: 'workshop.home.newestDesc', + icon: 'Star', + queryIndex: 5, + browseParams: { sort: 'mostrecent', section: 'readytouseitems' }, + }, + ]; + + // Parse SSR data once + const domMap = parseDOMCardMap(html); + const queryDataObj = extractSSRQueryData(html); + + // Collect all workshop_query entries from SSR + const allQueries: any[] = []; + if (queryDataObj?.queries) { + for (const q of queryDataObj.queries) { + if (q?.queryKey?.[0] === 'workshop_query') { + allQueries.push(q); + } + } + } + + // Distribute items to sections by queryIndex + const sections: HomepageSection[] = []; + for (const def of sectionDefs) { + const query = allQueries[def.queryIndex]; + const items = query ? extractItemsFromQuery(query, domMap) : []; + // Skip sections with no data + if (items.length === 0) continue; + sections.push({ + id: def.id, + title: def.titleKey, + subtitle: def.subtitleKey, + icon: def.icon, + items, + browseParams: def.browseParams, + }); + } + + return sections; +} + +/** Parse declared_tags_v5 from homepage SSR to get the category tree */ +export function parseTagCategories(html: string): TagCategory[] { + try { + const queryDataObj = extractSSRQueryData(html); + if (!queryDataObj?.queries) return []; + + const tagsQuery = queryDataObj.queries.find( + (q: any) => q?.queryKey?.[0] === 'declared_tags_v5', + ); + if (!tagsQuery?.state?.data?.readytouse_tags) return []; + + return tagsQuery.state.data.readytouse_tags.map((cat: any) => ({ + name: cat.name, + tags: cat.tags.map((t: any) => ({ + id: t.id, + name: t.name, + display_name: t.display_name, + })), + })); + } catch { + return []; + } +} + +/** Convert a Steam thumbnail URL to a full-size image URL by removing size/fit params */ +function toFullSizeUrl(thumbUrl: string): string { + if (!thumbUrl) return thumbUrl; + // Remove imw/imh/ima/impolicy/imcolor/letterbox params to get the full-size image + return thumbUrl.replace(/\?imw=.*$/, ''); +} + +function appendLineBreak(output: string[]): void { + const last = output[output.length - 1] || ''; + if (!last.endsWith('\n')) { + output.push('\n'); + } +} + +function appendBlockBreak(output: string[]): void { + const text = output.join(''); + if (!text.endsWith('\n\n')) { + if (!text.endsWith('\n')) output.push('\n'); + output.push('\n'); + } +} + +function descriptionNodeToText(node: Element): string { + const blockTags = new Set(['DIV', 'P', 'UL', 'OL', 'TABLE', 'TR', 'H1', 'H2', 'H3', 'H4']); + const output: string[] = []; + + const walk = (current: Node) => { + if (current.nodeType === Node.TEXT_NODE) { + output.push(current.textContent || ''); + return; + } + if (current.nodeType !== Node.ELEMENT_NODE) { + return; + } + + const element = current as Element; + const tag = element.tagName; + + if (tag === 'BR') { + appendLineBreak(output); + return; + } + + if (tag === 'LI') { + appendLineBreak(output); + output.push('* '); + } else if (blockTags.has(tag) && output.join('').trim()) { + appendBlockBreak(output); + } + + element.childNodes.forEach(walk); + + if (tag === 'LI') { + appendLineBreak(output); + } else if (blockTags.has(tag)) { + appendBlockBreak(output); + } + }; + + node.childNodes.forEach(walk); + + return output + .join('') + .replace(/\u00a0/g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n[ \t]+/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** Parse extra details from a Steam Workshop item/collection page HTML */ +export function parseWorkshopPageDetails(html: string): WorkshopPageDetails { + const result: WorkshopPageDetails = { + imageGallery: [], + tags: [], + requiredItems: [], + parentCollections: [], + }; + + try { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + const text = (selector: string) => doc.querySelector(selector)?.textContent?.trim() || ''; + const attr = (selector: string, name: string) => doc.querySelector(selector)?.getAttribute(name)?.trim() || ''; + const all = (selector: string) => Array.from(doc.querySelectorAll(selector)); + + result.title = text('.workshopItemTitle'); + // Steam currently uses #previewImage. Keep the older selector for cached + // or legacy page markup so a no-screenshot item still has its cover. + result.previewUrl = toFullSizeUrl(attr('#previewImage, #previewImageMain', 'src')); + + const descriptionNode = doc.querySelector('.workshopItemDescription'); + if (descriptionNode) { + result.description = descriptionNodeToText(descriptionNode); + result.descriptionHtml = descriptionNode.innerHTML.trim(); + } + + result.creatorProfileUrl = attr('.friendBlockLinkOverlay', 'href'); + if (result.creatorProfileUrl) { + const ids = parseProfileIdentifiers(result.creatorProfileUrl); + result.creatorSteamId = ids.steamId; + result.creatorVanityId = ids.vanityId; + } + const creatorNameNode = doc.querySelector('.friendBlockContent'); + if (creatorNameNode?.childNodes?.[0]?.textContent) { + result.creatorName = creatorNameNode.childNodes[0].textContent.trim(); + } + result.creatorAccountId = attr('.friendBlock', 'data-miniprofile') || undefined; + result.creatorSteamId = result.creatorSteamId || accountIdToSteamId(result.creatorAccountId); + + const detailLabels = all('.detailsStatsContainerLeft .detailsStatLeft').map((el) => el.textContent?.trim().toLowerCase() || ''); + const detailValues = all('.detailsStatsContainerRight .detailsStatRight').map((el) => el.textContent?.trim() || ''); + detailLabels.forEach((label, index) => { + const value = detailValues[index] || ''; + if (!value) return; + if (label.includes('file size')) result.fileSizeDisplay = value; + if (label.includes('posted')) result.postedDateText = value; + if (label.includes('updated')) result.updatedDateText = value; + }); + result.changeNoteCount = parseCount(text('.detailsStatNumChangeNotes')); + result.ratingCount = parseCount(text('.numRatings')); + if (doc.querySelector('.fileRatingDetails img')?.getAttribute('src')?.includes('5-star')) { + result.ratingStars = 5; + } + + all('.stats_table tr').forEach((row) => { + const cells = Array.from(row.querySelectorAll('td')).map((td) => td.textContent?.trim() || ''); + if (cells.length < 2) return; + const value = parseCount(cells[0]); + const label = cells[1].toLowerCase(); + if (value === undefined) return; + if (label.includes('unique visitor')) result.uniqueVisitors = value; + if (label.includes('current subscriber')) result.currentSubscribers = value; + if (label.includes('current favorite')) result.currentFavorites = value; + }); + + const fullGalleryMatch = html.match(/var\s+rgFullScreenshotURLs\s*=\s*(\[[\s\S]*?\]);/); + if (fullGalleryMatch) { + try { + const normalized = fullGalleryMatch[1] + .replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)/g, '$1"$2"$3') + .replace(/'/g, '"'); + const gallery = JSON.parse(normalized) as Array<{ url?: string }>; + gallery.forEach((entry) => { + if (entry?.url) { + result.imageGallery.push(toFullSizeUrl(entry.url)); + } + }); + } catch { + // Fallback to DOM below. + } + } + if (result.imageGallery.length === 0) { + const galleryImgs = doc.evaluate( + '//div[@id="highlight_strip_scroll"]/div[@role="button"]/img', + doc, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null, + ); + for (let i = 0; i < galleryImgs.snapshotLength; i++) { + const img = galleryImgs.snapshotItem(i) as HTMLImageElement; + const src = img.getAttribute('src') || ''; + if (src) { + result.imageGallery.push(toFullSizeUrl(src)); + } + } + } + + all('.rightDetailsBlock .workshopTags').forEach((node) => { + const category = (node.querySelector('.workshopTagsTitle')?.textContent || '').replace(/:$/, '').trim(); + node.querySelectorAll('a').forEach((a) => { + const name = (a.textContent || '').trim(); + if (name) { + result.tags.push({ category, name }); + } + }); + }); + + // 3. Required items + const requiredContainer = doc.querySelector('.requiredItemsContainer'); + if (requiredContainer) { + const links = requiredContainer.querySelectorAll('a'); + links.forEach((a) => { + const title = (a.textContent || '').trim(); + const href = a.getAttribute('href') || ''; + const idMatch = href.match(/id=(\d+)/); + if (title && idMatch) { + result.requiredItems.push({ title, workshopId: idMatch[1] }); + } + }); + } + + const collectionItems: WorkshopItem[] = []; + all('.collectionChildren .collectionItem').forEach((node) => { + const itemLink = node.querySelector('a[href*="filedetails/?id="]') as HTMLAnchorElement | null; + const workshopId = itemLink?.href?.match(/id=(\d+)/)?.[1] || node.id?.match(/sharedfile_(\d+)/)?.[1] || ''; + if (!workshopId) return; + + const title = (node.querySelector('.workshopItemTitle')?.textContent || '').trim(); + const imagePath = toFullSizeUrl((node.querySelector('.workshopItemPreviewImage') as HTMLImageElement | null)?.src || ''); + const authorLink = node.querySelector('.workshopItemAuthorName a') as HTMLAnchorElement | null; + const authorName = (authorLink?.textContent || '').trim(); + const authorUrl = authorLink?.href || ''; + const authorIds = parseProfileIdentifiers(authorUrl); + const shortDescription = (node.querySelector('.workshopItemShortDesc')?.textContent || '').trim(); + const ratingSrc = (node.querySelector('.fileRating') as HTMLImageElement | null)?.src || ''; + const ratingStars = parseInt(ratingSrc.match(/(\d+)-star/)?.[1] || '0', 10) || 0; + + collectionItems.push({ + workshopId, + title, + imagePath, + authorName, + authorId: authorIds.vanityId || authorIds.steamId || '', + authorUrl, + authorSteamId: authorIds.steamId, + authorVanityId: authorIds.vanityId, + stars: ratingStars, + shortDescription, + }); + }); + if (collectionItems.length > 0) { + result.collectionItems = collectionItems; + result.childItemIds = collectionItems.map((item) => item.workshopId); + } + + // 4. Parent collections + const parentCollectionsDiv = doc.querySelector('.parentCollections'); + if (parentCollectionsDiv) { + const collectionDivs = parentCollectionsDiv.querySelectorAll(':scope > div'); + collectionDivs.forEach((div) => { + const title = (div.querySelector('.parentCollectionTitle')?.textContent || '').trim(); + const onclick = div.getAttribute('onclick') || ''; + const idMatch = onclick.match(/id=(\d+)/); + if (title && idMatch) { + result.parentCollections.push({ title, workshopId: idMatch[1] }); + } + }); + } + + // 5. Collection background image (large hero image) + const bgImg = doc.querySelector('img.collectionBackgroundImage') as HTMLImageElement | null; + if (bgImg) { + const src = bgImg.getAttribute('src') || ''; + if (src) { + result.backgroundImageUrl = toFullSizeUrl(src); + } + } + + result.imageGallery = [...new Set(result.imageGallery.filter(Boolean))]; + result.tags = result.tags.filter((tag, index, tags) => ( + !!tag.name && tags.findIndex((candidate) => candidate.category === tag.category && candidate.name === tag.name) === index + )); + } catch (e) { + console.error('Failed to parse workshop page details:', e); + } + + return result; +} diff --git a/src/components/workshop/types.ts b/src/components/workshop/types.ts new file mode 100644 index 0000000..25fda3f --- /dev/null +++ b/src/components/workshop/types.ts @@ -0,0 +1,128 @@ +/** Shared types for the Workshop browser */ + +import type { DatabasePayload } from '../../types/addon'; + +export interface WorkshopItem { + workshopId: string; + title: string; + imagePath: string; + authorName: string; + authorId: string; + authorUrl: string; + authorSteamId?: string; + authorVanityId?: string; + authorAccountId?: string; + ownerSteamId?: string; + ownerAccountId?: string; + stars: number; + shortDescription?: string; + fileSize?: string; + tags?: string[]; + subscriptions?: number; + favorites?: number; + lifetimeSubscriptions?: number; + lifetimeFavorites?: number; + views?: number; + comments?: number; + totalVotes?: number; + timeCreated?: number; + timeUpdated?: number; + childCount?: number; + previewCount?: number; + childItemIds?: string[]; + galleryPreviewUrls?: string[]; + source?: string; + isSubscribed?: boolean; + isInstalled?: boolean; + installState?: string[]; +} + +export interface CollectionData { + collection: any; + items: any[]; +} + +/** Extra details scraped from the Steam Community workshop page */ +export interface WorkshopPageDetails { + fileType?: string; + title?: string; + previewUrl?: string; + description?: string; + descriptionHtml?: string; + creatorName?: string; + creatorProfileUrl?: string; + creatorSteamId?: string; + creatorVanityId?: string; + creatorAccountId?: string; + imageGallery: string[]; + tags: { category: string; name: string }[]; + requiredItems: { title: string; workshopId: string }[]; + collectionItems?: WorkshopItem[]; + childItemIds?: string[]; + parentCollections: { title: string; workshopId: string }[]; + fileSizeDisplay?: string; + postedDateText?: string; + updatedDateText?: string; + changeNoteCount?: number; + ratingStars?: number; + ratingCount?: number; + uniqueVisitors?: number; + currentSubscribers?: number; + currentFavorites?: number; + backgroundImageUrl?: string; +} + +/** A single homepage section (e.g. "一周内物品", "最热门") */ +export interface HomepageSection { + id: string; + title: string; + subtitle: string; + icon: React.ReactNode; + items: WorkshopItem[]; + browseParams: { + sort: string; + section: string; + days?: number; + }; +} + +/** Tag category from declared_tags_v5 */ +export interface TagCategory { + name: string; + tags: { id: string; name: string; display_name: string }[]; +} + +export interface WorkshopBrowserProps { + addons: Record; + knownUninstalledAddons: Record; + downloadProgress: Record; + onDownload: (workshopId: string, title?: string, imagePath?: string) => void; + onDownloadMany?: (items: { workshopId: string; title?: string; imagePath?: string }[]) => void; + onOpenLink: (url: string) => void; + onImportCollection: (name: string, itemIds: string[]) => void; + onRecordSeenItems?: (items: WorkshopItem[], source?: string) => void; + onDatabaseUpdate?: (data: DatabasePayload) => void; + isSubmitting: boolean; + groups?: import('../../types/addon').Group[]; + backgroundTasks: import('../../types/addon').BackgroundTask[]; + syncingSteam: boolean; + onOpenTaskCenter: () => void; + onWarning?: (message: string) => void; + workshopSourceSettings?: import('../../types/addon').WorkshopSourceSettings; +} + +export interface WorkshopCapabilities { + bridgeAvailable: boolean; + bridgeLoaded: boolean; + bridgeInitialized: boolean; + provider: string; + bridgeVersion?: string; + lastError?: string; + currentUserSteamId?: string; + currentUserAccountId?: string; + canQueryItems: boolean; + canQueryHome: boolean; + canDownload: boolean; + canEnumerateInstalled: boolean; + canEnumerateSubscribed: boolean; +} diff --git a/src/hooks/useAddonManager.test.tsx b/src/hooks/useAddonManager.test.tsx index e4d714f..ed1d168 100644 --- a/src/hooks/useAddonManager.test.tsx +++ b/src/hooks/useAddonManager.test.tsx @@ -1,18 +1,30 @@ import { describe, test, expect, vi, beforeEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useAddonManager } from './useAddonManager'; -import { Addon, Group, Settings } from '../types/addon'; +import { Addon, DatabasePayload, Group, MasterCollection, Settings } from '../types/addon'; // Mock Tauri invoke function const mockInvoke = vi.fn(); +const mockListen = vi.fn(); +const eventListeners = new Map void>(); + vi.mock('@tauri-apps/api/core', () => ({ invoke: (cmd: string, args?: Record) => mockInvoke(cmd, args), })); +vi.mock('@tauri-apps/api/event', () => ({ + listen: (eventName: string, callback: (event: { payload: any }) => void) => { + mockListen(eventName, callback); + eventListeners.set(eventName, callback); + return Promise.resolve(() => { + eventListeners.delete(eventName); + }); + }, +})); describe('useAddonManager', () => { const mockAddons: Record = { 'addon1.vpk': { - vpkName: 'addon1.vpk', + id: 'addon1.vpk', vpkName: 'addon1.vpk', dirType: 'loading', isEnabled: true, fileSize: 1024 * 1024, // 1 MB @@ -20,7 +32,7 @@ describe('useAddonManager', () => { addonInfo: { addontitle: 'Addon One', addonAuthor: 'Author One' }, }, 'addon2.vpk': { - vpkName: 'addon2.vpk', + id: 'addon2.vpk', vpkName: 'addon2.vpk', dirType: 'workshop', isEnabled: false, fileSize: 2 * 1024 * 1024, // 2 MB @@ -37,11 +49,29 @@ describe('useAddonManager', () => { const mockSettings: Settings = { workshopDir: '/path/to/workshop', loadingDir: '/path/to/loading', + downloadConcurrency: 2, + enableDummyBypass: false, + suppressSdkUnavailableWarning: false, + disableSteamworksSdk: false, + forceSteamworksSdkDownload: false, + workshopSourceSettings: { + preset: 'conservative', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + allowSdkHtmlHybrid: false, + sdkHtmlScope: 'search', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', + sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], + cacheRetention: 'keep', + }, }; beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); + eventListeners.clear(); // Default mock response for get_addons mockInvoke.mockImplementation((cmd) => { @@ -52,6 +82,26 @@ describe('useAddonManager', () => { settings: mockSettings, }); } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } return Promise.resolve({}); }); }); @@ -117,6 +167,108 @@ describe('useAddonManager', () => { expect(result.current.filteredItems[0].vpkName).toBe('addon2.vpk'); }); + test('should render empty groups in all view without requiring addons', async () => { + const emptyGroup: Group = { id: 'group-empty', name: 'Empty Group', addons: [] }; + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: [...mockGroups, emptyGroup], + settings: mockSettings, + }); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.renderedItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'group', id: 'group-empty' }), + ]) + ); + + act(() => { + result.current.setSearchQuery('addon one'); + }); + + expect(result.current.renderedItems).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'group', id: 'group-empty' }), + ]) + ); + }); + + test('should render empty groups inside master collections', async () => { + const emptyGroup: Group = { + id: 'group-empty', + name: 'Empty Group', + addons: [], + masterCollectionIds: ['mc-1'], + }; + const masterCollections: MasterCollection[] = [{ + id: 'mc-1', + name: 'Master Collection', + groupIds: ['group-empty'], + isSystem: false, + }]; + + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: [...mockGroups, emptyGroup], + masterCollections, + settings: mockSettings, + }); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + result.current.handleFilterTabChange('master-collection', 'mc-1'); + }); + + expect(result.current.filteredItems).toHaveLength(0); + expect(result.current.renderedItems).toEqual([ + expect.objectContaining({ type: 'group', id: 'group-empty' }), + ]); + }); + test('should toggle selection correctly', async () => { const { result } = renderHook(() => useAddonManager()); @@ -125,20 +277,20 @@ describe('useAddonManager', () => { }); expect(result.current.isSelectMode).toBe(false); - expect(result.current.selectedVpkNames).toEqual([]); + expect(result.current.selectedIds).toEqual([]); // Toggle select addon1 act(() => { result.current.handleSelectToggle('addon1.vpk'); }); expect(result.current.isSelectMode).toBe(true); - expect(result.current.selectedVpkNames).toEqual(['addon1.vpk']); + expect(result.current.selectedIds).toEqual(['addon1.vpk']); // Toggle select addon1 again (deselects it) act(() => { result.current.handleSelectToggle('addon1.vpk'); }); - expect(result.current.selectedVpkNames).toEqual([]); + expect(result.current.selectedIds).toEqual([]); }); test('should perform batch enable and disable', async () => { @@ -147,11 +299,23 @@ describe('useAddonManager', () => { if (cmd === 'get_addons') { return Promise.resolve({ addons: mockAddons, groups: mockGroups, settings: mockSettings }); } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } if (cmd === 'move_addons') { return Promise.resolve({}); } if (cmd === 'toggle_addons') { - expect(args).toEqual({ vpkNames: ['addon1.vpk', 'addon2.vpk'], enabled: true }); + expect(args).toEqual({ ids: ['addon2.vpk'], enabled: true }); const updatedAddons = { ...mockAddons }; updatedAddons['addon1.vpk'].isEnabled = true; updatedAddons['addon2.vpk'].isEnabled = true; @@ -179,15 +343,75 @@ describe('useAddonManager', () => { expect(result.current.isSelectMode).toBe(false); // cleared selection }); + test('should only move selected workshop addons during batch move', async () => { + mockInvoke.mockImplementation((cmd, args) => { + if (cmd === 'get_addons') { + return Promise.resolve({ addons: mockAddons, groups: mockGroups, settings: mockSettings }); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } + if (cmd === 'move_addons') { + expect(args).toEqual({ ids: ['addon2.vpk'], targetDirType: 'loading' }); + return Promise.resolve({ addons: mockAddons, groups: mockGroups, settings: mockSettings }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + result.current.handleSelectToggle('addon1.vpk'); + result.current.handleSelectToggle('addon2.vpk'); + }); + + await act(async () => { + await result.current.handleBatchMove(); + }); + + await act(async () => { + if (result.current.confirmModal.onConfirm) { + await result.current.confirmModal.onConfirm(); + } + }); + + expect(mockInvoke).toHaveBeenCalledWith('move_addons', { ids: ['addon2.vpk'], targetDirType: 'loading' }); + }); + test('should handle group operations: create, delete, and rename', async () => { mockInvoke.mockImplementation((cmd, args) => { if (cmd === 'get_addons') { return Promise.resolve({ addons: mockAddons, groups: mockGroups, settings: mockSettings }); } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } if (cmd === 'group_action') { if (args?.action === 'create') { expect(args.name).toBe('New Group'); - expect(args.vpkNames).toEqual(['addon2.vpk']); + expect(args.ids).toEqual(['addon2.vpk']); const newGroups = [...mockGroups, { id: 'group-2', name: 'New Group', addons: ['addon2.vpk'] }]; return Promise.resolve({ addons: mockAddons, groups: newGroups }); } @@ -230,17 +454,102 @@ describe('useAddonManager', () => { expect(result.current.groups).toHaveLength(0); }); + test('should navigate to a newly created empty group', async () => { + const emptyGroup: Group = { id: 'group-empty', name: 'Empty Group', addons: [] }; + mockInvoke.mockImplementation((cmd, args) => { + if (cmd === 'get_addons') { + return Promise.resolve({ addons: mockAddons, groups: mockGroups, settings: mockSettings }); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } + if (cmd === 'group_action' && args?.action === 'create') { + expect(args.ids).toEqual([]); + return Promise.resolve({ + addons: mockAddons, + groups: [...mockGroups, emptyGroup], + settings: mockSettings, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + result.current.setSearchQuery('will be cleared'); + result.current.setSelectedCategory('Campaign'); + result.current.setCurrentFilterTab('workshop'); + }); + + await act(async () => { + await result.current.handleCreateGroup('Empty Group'); + }); + + expect(result.current.groups).toEqual(expect.arrayContaining([emptyGroup])); + expect(result.current.currentFilterTab).toBe('groups'); + expect(result.current.selectedGroupId).toBe('group-empty'); + expect(result.current.selectedCategory).toBe('All'); + expect(result.current.searchQuery).toBe(''); + expect(result.current.currentGroup).toEqual(emptyGroup); + }); + test('should set isSubmitting to true during async actions and reset to false afterwards', async () => { let resolvePromise: (value: unknown) => void = () => {}; const tauriPromise = new Promise((resolve) => { resolvePromise = resolve; }); - mockInvoke.mockImplementation((cmd) => { + mockInvoke.mockImplementation((cmd, args) => { if (cmd === 'get_addons') { return Promise.resolve({ addons: mockAddons, groups: mockGroups, settings: mockSettings }); } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } if (cmd === 'save_settings') { + expect(args).toEqual({ + payload: { + loadingDir: '/new/loading/dir', + downloadConcurrency: 2, + enableDummyBypass: false, + suppressSdkUnavailableWarning: false, + disableSteamworksSdk: false, + forceSteamworksSdkDownload: false, + maxDownloadRetries: 3, + workshopSourceSettings: undefined, + }, + }); return tauriPromise; } return Promise.resolve({}); @@ -257,7 +566,7 @@ describe('useAddonManager', () => { // Call saveSettings let savePromise: Promise | null = null; act(() => { - savePromise = result.current.saveSettings('/new/loading/dir'); + savePromise = result.current.saveSettings('/new/loading/dir', 2, false, false, false, false, 3); }); // isSubmitting should be true immediately after invoking @@ -272,4 +581,504 @@ describe('useAddonManager', () => { // isSubmitting should reset to false expect(result.current.isSubmitting).toBe(false); }); + + test('should apply watcher database updates and show toast for external changes', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const nextPayload: DatabasePayload = { + addons: { + ...mockAddons, + 'addon3.vpk': { + id: 'addon3.vpk', + vpkName: 'addon3.vpk', + dirType: 'loading', + isEnabled: true, + fileSize: 123, + filesCount: 1, + addonInfo: { addontitle: 'Addon Three' }, + }, + }, + knownUninstalledAddons: {}, + groups: mockGroups, + masterCollections: [], + settings: mockSettings, + }; + + await act(async () => { + eventListeners.get('addons-db-updated')?.({ + payload: { data: nextPayload, external: true }, + }); + }); + + expect(result.current.addons['addon3.vpk']).toBeDefined(); + expect(result.current.toasts.some((toast) => toast.message.includes('自动刷新'))).toBe(true); + }); + + test('should apply internal watcher refreshes without success toast', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + const nextPayload: DatabasePayload = { + addons: { + 'addon2.vpk': mockAddons['addon2.vpk'], + }, + knownUninstalledAddons: {}, + groups: mockGroups, + masterCollections: [], + settings: mockSettings, + }; + + act(() => { + result.current.handleSelectToggle('addon1.vpk'); + }); + + await act(async () => { + eventListeners.get('addons-db-updated')?.({ + payload: { data: nextPayload, external: false }, + }); + }); + + expect(result.current.addons['addon1.vpk']).toBeUndefined(); + expect(result.current.knownUninstalledAddons['addon1.vpk']).toBeUndefined(); + expect(result.current.selectedIds).toEqual([]); + expect(result.current.toasts.some((toast) => toast.message.includes('自动刷新'))).toBe(false); + }); + + test('should keep detail modal open for unresolved workshop placeholder entries after database updates', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + result.current.setDetailModal({ + open: true, + addon: { + id: '99999', + vpkName: '99999', + workshopId: '99999', + dirType: 'none', + isEnabled: false, + fileSize: 0, + filesCount: 0, + }, + }); + }); + + const nextPayload: DatabasePayload = { + addons: mockAddons, + knownUninstalledAddons: {}, + groups: mockGroups, + masterCollections: [], + settings: mockSettings, + }; + + act(() => { + result.current.applyDatabaseUpdate(nextPayload); + }); + + expect(result.current.detailModal.open).toBe(true); + expect(result.current.detailModal.addon?.workshopId).toBe('99999'); + }); + + test('should resolve detail modal placeholder entries by workshop id after database updates', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + result.current.setDetailModal({ + open: true, + addon: { + id: '99999', + vpkName: '99999', + workshopId: '99999', + dirType: 'none', + isEnabled: false, + fileSize: 0, + filesCount: 0, + }, + }); + }); + + const knownEntry: Addon = { + id: 'workshop_99999.vpk', + vpkName: 'workshop_99999.vpk', + workshopId: '99999', + dirType: 'none', + isEnabled: false, + fileSize: 123, + filesCount: 1, + workshopDetails: { title: 'Resolved Entry' }, + }; + + const nextPayload: DatabasePayload = { + addons: mockAddons, + knownUninstalledAddons: { + [knownEntry.id]: knownEntry, + }, + groups: mockGroups, + masterCollections: [], + settings: mockSettings, + }; + + act(() => { + result.current.applyDatabaseUpdate(nextPayload); + }); + + expect(result.current.detailModal.open).toBe(true); + expect(result.current.detailModal.addon?.id).toBe('workshop_99999.vpk'); + expect(result.current.detailModal.addon?.workshopDetails?.title).toBe('Resolved Entry'); + }); + + test('should surface watcher errors through toast notifications', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + await act(async () => { + eventListeners.get('addons-watch-error')?.({ + payload: { message: 'watcher failed' }, + }); + }); + + expect(result.current.toasts.some((toast) => toast.message.includes('watcher failed'))).toBe(true); + }); + + test('should track download progress from progress events', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + eventListeners.get('download-progress')?.({ + payload: { + workshopId: '12345', + percent: 50, + downloaded: 8 * 1024 * 1024, + total: 16 * 1024 * 1024, + source: 'web-fallback', + phase: 'fallback-download', + }, + }); + }); + + expect(result.current.downloadProgress['12345']).toBe(50); + }); + + test('should add a warning task when sdk-download-warning is emitted', async () => { + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + act(() => { + eventListeners.get('sdk-download-warning')?.({ + payload: { + workshopId: '12345', + title: 'Addon Two', + reason: 'web-download-failed', + source: 'steam-sdk', + }, + }); + }); + + await waitFor(() => { + expect(result.current.backgroundTasks.some((task) => task.id === 'warning_steam_sdk_download_12345')).toBe(true); + }); + + expect(result.current.backgroundTasks.find((task) => task.id === 'warning_steam_sdk_download_12345')).toMatchObject({ + kind: 'warning', + status: 'failed', + source: 'steam-sdk-download-warning', + targetIds: ['12345'], + title: 'Addon Two (12345)', + }); + }); + + test('should add a frontend warning task when Steamworks SDK is unavailable', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: mockGroups, + settings: mockSettings, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: false, + bridgeLoaded: false, + bridgeInitialized: false, + provider: 'web-fallback', + lastError: 'Steam bridge DLL not found', + canQueryItems: false, + canQueryHome: false, + canDownload: false, + canEnumerateInstalled: false, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.backgroundTasks).toHaveLength(1); + }); + + expect(result.current.backgroundTasks[0]).toMatchObject({ + id: 'steamworks-sdk-unavailable', + kind: 'warning', + status: 'failed', + source: 'frontend-warning', + title: 'Steamworks SDK 不可用', + }); + expect(result.current.backgroundTasks[0].error).toContain('Steam bridge DLL not found'); + }); + + test('should suppress the frontend SDK warning task when the setting is enabled', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: mockGroups, + settings: { + ...mockSettings, + suppressSdkUnavailableWarning: true, + }, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: false, + bridgeLoaded: false, + bridgeInitialized: false, + provider: 'web-fallback', + lastError: 'Steam bridge DLL not found', + canQueryItems: false, + canQueryHome: false, + canDownload: false, + canEnumerateInstalled: false, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.backgroundTasks).toHaveLength(0); + }); + + test('should suppress the frontend SDK warning task when Steamworks SDK is manually disabled', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: mockGroups, + settings: { + ...mockSettings, + disableSteamworksSdk: true, + }, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: false, + bridgeLoaded: false, + bridgeInitialized: false, + provider: 'web-fallback', + lastError: 'Steam bridge DLL not found', + canQueryItems: false, + canQueryHome: false, + canDownload: false, + canEnumerateInstalled: false, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(result.current.backgroundTasks).toHaveLength(0); + }); + + test('should not probe Steamworks capabilities when SDK source is disabled in source settings', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: mockGroups, + settings: { + ...mockSettings, + workshopSourceSettings: { + preset: 'conservative', + allowSteamworksSdk: false, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + allowSdkHtmlHybrid: false, + sdkHtmlScope: 'search', + sourceOrder: ['steam-web-api', 'steamcommunity-html', 'steamworks-sdk'], + cacheRetention: 'keep', + }, + }, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([]); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + }); + + expect(mockInvoke).not.toHaveBeenCalledWith('get_workshop_capabilities', undefined); + expect(result.current.backgroundTasks).toHaveLength(0); + }); + + test('should request backend download cancellation when cancelling a running download task', async () => { + let resolveDownload: ((value: DatabasePayload) => void) | undefined; + const downloadPromise = new Promise((resolve) => { + resolveDownload = resolve; + }); + + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_addons') { + return Promise.resolve({ + addons: mockAddons, + groups: mockGroups, + settings: mockSettings, + }); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + bridgeLoaded: true, + bridgeInitialized: true, + provider: 'steam-sdk', + canQueryItems: true, + canQueryHome: true, + canDownload: true, + canEnumerateInstalled: true, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { lastPageFetchedAt: new Date().toISOString() }, + }); + } + if (cmd === 'get_background_tasks') { + return Promise.resolve([ + { + id: 'download_12345_test', + kind: 'download', + status: 'running', + targetIds: ['12345'], + progress: 42, + createdAt: new Date().toISOString(), + title: 'Addon Two (12345)', + }, + ]); + } + if (cmd === 'cancel_download') { + return Promise.resolve(null); + } + if (cmd === 'download_addon') { + return downloadPromise; + } + return Promise.resolve({}); + }); + + const { result } = renderHook(() => useAddonManager()); + + await waitFor(() => { + expect(result.current.loading).toBe(false); + expect(result.current.backgroundTasks).toHaveLength(1); + expect(result.current.backgroundTasks[0].status).toBe('running'); + }); + + act(() => { + result.current.cancelTask('download_12345_test'); + }); + + expect(mockInvoke).toHaveBeenCalledWith('cancel_download', { workshopId: '12345' }); + expect(result.current.backgroundTasks[0].status).toBe('cancelled'); + + resolveDownload?.({ + addons: mockAddons, + knownUninstalledAddons: {}, + groups: mockGroups, + masterCollections: [], + settings: mockSettings, + }); + }); }); diff --git a/src/hooks/useAddonManager.ts b/src/hooks/useAddonManager.ts index 4afd16e..ef47c9a 100644 --- a/src/hooks/useAddonManager.ts +++ b/src/hooks/useAddonManager.ts @@ -1,72 +1,283 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { invoke } from '@tauri-apps/api/core'; -import { Addon, Group, Settings, Toast } from '../types/addon'; -import { getAddonAuthor, getAddonCategories, getSuggestedVpkName, getAddonInfoValue } from '../utils/addonHelpers'; +import { listen } from '@tauri-apps/api/event'; +import { Addon, Group, Settings, Toast, DatabasePayload, MasterCollection, WorkshopSourceSettings } from '../types/addon'; +import { getAddonAuthor, getAddonCategories, getSuggestedVpkName, getAddonInfoValue, sortAddonsDownloadedFirst } from '../utils/addonHelpers'; +import { findKnownWorkshopEntry } from '../utils/workshopKnown'; import { useTranslation } from 'react-i18next'; +import { useBackgroundTasks } from './useBackgroundTasks'; +import type { BackgroundTask } from '../types/addon'; +import type { WorkshopCapabilities } from '../components/workshop/types'; +import { setSteamworksSdkDisabled, setWorkshopSourceSettings } from '../services/workshopClient'; +import { DEFAULT_WORKSHOP_SOURCE_SETTINGS, normalizeWorkshopSourceSettings } from '../utils/workshopSourceSettings'; export type RenderedItem = | { type: 'group'; id: string; name: string; addons: Addon[]; groupObj: Group } | { type: 'addon'; id: string; data: Addon }; +interface AddonsDbUpdatedEvent { + data: DatabasePayload; + external: boolean; +} + +interface AddonsWatchErrorEvent { + message: string; +} + +interface DownloadProgressEvent { + workshopId: string; + percent: number; +} + +interface SdkDownloadWarningEvent { + workshopId: string; + title?: string; + reason: 'forced-sdk' | 'web-download-failed' | 'web-download-unavailable'; + source: 'steam-sdk'; +} + export function useAddonManager() { const { t } = useTranslation(); const [addons, setAddons] = useState>({}); + const [knownUninstalledAddons, setKnownUninstalledAddons] = useState>({}); const [groups, setGroups] = useState([]); - const [settings, setSettings] = useState({ workshopDir: '', loadingDir: '', enableDummyBypass: false }); + const [masterCollections, setMasterCollections] = useState([]); + const [settings, setSettings] = useState({ + workshopDir: '', + loadingDir: '', + downloadConcurrency: 2, + enableDummyBypass: false, + suppressSdkUnavailableWarning: false, + disableSteamworksSdk: false, + forceSteamworksSdkDownload: false, + dependencyMissingBehavior: 'ask', + workshopSourceSettings: DEFAULT_WORKSHOP_SOURCE_SETTINGS, + }); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); - const [currentFilterTab, setCurrentFilterTab] = useState('all'); // all, workshop, loading, disabled, groups + const [currentFilterTab, setCurrentFilterTab] = useState('all'); // all, workshop, loading, disabled, groups, known-uninstalled, workshop-browser, master-collection const [selectedGroupId, setSelectedGroupId] = useState(null); + const [selectedMasterCollectionId, setSelectedMasterCollectionId] = useState(null); const [selectedCategory, setSelectedCategory] = useState('All'); const [sortBy, setSortBy] = useState('title'); // title, size, id const [toasts, setToasts] = useState([]); const [syncingSteam, setSyncingSteam] = useState(false); const [autoGrouping, setAutoGrouping] = useState(false); - const [selectedVpkNames, setSelectedVpkNames] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [selectedGroupIds, setSelectedGroupIds] = useState([]); const [isSelectMode, setIsSelectMode] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + const [downloadProgress, setDownloadProgress] = useState>({}); + const [workshopCapabilities, setWorkshopCapabilities] = useState(null); + const [workshopCapabilitiesCheckedAt, setWorkshopCapabilitiesCheckedAt] = useState(null); + const [workshopCapabilitiesError, setWorkshopCapabilitiesError] = useState(null); + + const resolveDetailModalAddon = useCallback(( + addon: Addon | null, + nextAddons: Record, + nextKnownUninstalledAddons: Record, + ) => { + if (!addon) { + return null; + } + + return nextAddons[addon.id] + || nextKnownUninstalledAddons[addon.id] + || findKnownWorkshopEntry(nextAddons, addon.workshopId) + || findKnownWorkshopEntry(nextKnownUninstalledAddons, addon.workshopId) + || null; + }, []); + + const clearDownloadProgress = useCallback((workshopId: string) => { + setDownloadProgress((prev) => { + if (prev[workshopId] === undefined) { + return prev; + } + const next = { ...prev }; + delete next[workshopId]; + return next; + }); + }, []); // Modals state const [detailModal, setDetailModal] = useState<{ open: boolean; addon: Addon | null }>({ open: false, addon: null }); - const [moveWarningModal, setMoveWarningModal] = useState<{ open: boolean; vpkName: string; currentDirType: string; workshopId: string }>({ open: false, vpkName: '', currentDirType: '', workshopId: '' }); + const [moveWarningModal, setMoveWarningModal] = useState<{ open: boolean; id: string; currentDirType: string; workshopId: string }>({ open: false, id: '', currentDirType: '', workshopId: '' }); const [renameModal, setRenameModal] = useState<{ open: boolean; currentName: string; title: string; suggestedName: string }>({ open: false, currentName: '', title: '', suggestedName: '' }); const [groupModal, setGroupModal] = useState<{ open: boolean }>({ open: false }); - const [editGroupModal, setEditGroupModal] = useState<{ open: boolean; groupId: string; name: string }>({ open: false, groupId: '', name: '' }); + const [editGroupModal, setEditGroupModal] = useState<{ open: boolean; groupId: string; name: string; tags: string[]; workshopCollectionId: string }>({ open: false, groupId: '', name: '', tags: [], workshopCollectionId: '' }); const [settingsModal, setSettingsModal] = useState<{ open: boolean; loadingDir: string }>({ open: false, loadingDir: '' }); const [linkConfirmModal, setLinkConfirmModal] = useState<{ open: boolean; url: string }>({ open: false, url: '' }); const [confirmModal, setConfirmModal] = useState<{ open: boolean; title: string; message: string; onConfirm: (() => void) | null }>({ open: false, title: '', message: '', onConfirm: null }); + const [deleteConfirmModal, setDeleteConfirmModal] = useState<{ open: boolean; addons: Addon[]; removeFromKnown: boolean }>({ open: false, addons: [], removeFromKnown: false }); const [workshopActionModal, setWorkshopActionModal] = useState<{ open: boolean; actionName: string; addons: Addon[]; onProceed: (() => void) | null }>({ open: false, actionName: '', addons: [], onProceed: null }); // Add toast helper - const addToast = (message: string, type: 'success' | 'error' = 'success') => { + const addToast = useCallback((message: string, type: 'success' | 'error' = 'success') => { const id = Math.random().toString(36).substring(2, 11); setToasts((prev) => [...prev, { id, message, type }]); setTimeout(() => { setToasts((prev) => prev.filter((t) => t.id !== id)); }, 4000); - }; + }, []); + + // Update local states from DatabasePayload + const updateLocalState = useCallback((data: DatabasePayload) => { + const nextAddons = data.addons || {}; + const nextKnownUninstalledAddons = data.knownUninstalledAddons || {}; + const nextGroups = data.groups || []; + const nextMasterCollections = data.masterCollections || []; + + setAddons(nextAddons); + setGroups(nextGroups); + setKnownUninstalledAddons(nextKnownUninstalledAddons); + setMasterCollections(nextMasterCollections); + if (data.settings) { + setSettings({ + ...data.settings, + workshopSourceSettings: normalizeWorkshopSourceSettings(data.settings.workshopSourceSettings), + }); + } + + setSelectedIds((prev) => prev.filter((id) => nextAddons[id] || nextKnownUninstalledAddons[id])); + setSelectedGroupIds((prev) => prev.filter((id) => nextGroups.some((group) => group.id === id))); + setDetailModal((prev) => { + if (!prev.open || !prev.addon) { + return prev; + } + + const nextAddon = resolveDetailModalAddon(prev.addon, nextAddons, nextKnownUninstalledAddons); + if (nextAddon) { + return { ...prev, addon: nextAddon }; + } + + const isWorkshopPlaceholder = prev.addon.id === prev.addon.workshopId; + return isWorkshopPlaceholder + ? prev + : { open: false, addon: null }; + }); + }, [resolveDetailModalAddon]); + + const { + backgroundTasks: managedBackgroundTasks, + enqueueDownloads, + enqueueWorkshopCrawl, + enqueueDependencyCheck, + recordSeenItems, + cancelTask, + retryTask, + removeTask, + clearFinishedTasks, + upsertWarningTask, + } = useBackgroundTasks({ + enabled: !loading, + downloadConcurrency: settings.downloadConcurrency || 2, + addons, + knownUninstalledAddons, + updateLocalState, + onDownloadSuccess: () => { + addToast(t('toasts.downloadSuccess'), 'success'); + }, + onDownloadCancelled: (workshopId) => { + clearDownloadProgress(workshopId); + }, + onTaskError: (message, workshopId) => { + if (workshopId) { + clearDownloadProgress(workshopId); + } + addToast(t('toasts.downloadFailed', { err: message }), 'error'); + }, + }); + + // Listen to backend events + useEffect(() => { + let unlistenDownload: (() => void) | undefined; + let unlistenDbUpdate: (() => void) | undefined; + let unlistenWatchError: (() => void) | undefined; + let unlistenSdkDownloadWarning: (() => void) | undefined; + + const setupListeners = async () => { + unlistenDownload = await listen('download-progress', (event) => { + const { workshopId, percent } = event.payload; + + setDownloadProgress((prev) => ({ + ...prev, + [workshopId]: percent, + })); + + if (percent === 100) { + setTimeout(() => { + clearDownloadProgress(workshopId); + }, 3000); + } + }); + + unlistenDbUpdate = await listen('addons-db-updated', (event) => { + updateLocalState(event.payload.data); + if (event.payload.external) { + addToast(t('toasts.autoRefreshSuccess'), 'success'); + } + }); + + unlistenWatchError = await listen('addons-watch-error', (event) => { + addToast(t('toasts.autoRefreshFailed', { err: event.payload.message }), 'error'); + }); + + unlistenSdkDownloadWarning = await listen('sdk-download-warning', (event) => { + const workshopId = event.payload.workshopId?.trim(); + if (!workshopId) { + return; + } + + const warningKey = event.payload.reason === 'forced-sdk' + ? 'taskCenter.sdkDownloadWarningForced' + : 'taskCenter.sdkDownloadWarningFallback'; + const title = event.payload.title?.trim(); + + upsertWarningTask({ + id: `warning_steam_sdk_download_${workshopId}`, + source: 'steam-sdk-download-warning', + targetIds: [workshopId], + title: title && title !== workshopId ? `${title} (${workshopId})` : workshopId, + error: t(warningKey, { workshopId }), + }); + }); + }; + + setupListeners(); + + return () => { + if (unlistenDownload) unlistenDownload(); + if (unlistenDbUpdate) unlistenDbUpdate(); + if (unlistenWatchError) unlistenWatchError(); + if (unlistenSdkDownloadWarning) unlistenSdkDownloadWarning(); + }; + }, [addToast, clearDownloadProgress, t, updateLocalState, upsertWarningTask]); const executeWithWorkshopCheck = async (addonsList: Addon[], actionName: string, proceed: () => void) => { const workshopAddons = addonsList.filter(ad => ad.dirType === 'workshop'); - if (workshopAddons.length > 0) { + const moveWorkshopAddonsAndProceed = async () => { try { - const vpkNames = workshopAddons.map(a => a.vpkName); - await invoke('move_addons', { vpkNames, targetDirType: 'loading' }); + const ids = workshopAddons.map(a => a.id); + const data: DatabasePayload = await invoke('move_addons', { ids, targetDirType: 'loading' }); + updateLocalState(data); } catch (err) { addToast(t('toasts.autoMoveFailed', { err: String(err) }), 'error'); setIsSubmitting(false); - return; // Stop if move fails + return; } + proceed(); + }; + if (workshopAddons.length > 0) { if (sessionStorage.getItem('skipWorkshopWarning') !== 'true' && !settings.enableDummyBypass) { setWorkshopActionModal({ open: true, actionName, addons: workshopAddons, - onProceed: proceed + onProceed: moveWorkshopAddonsAndProceed }); } else { - proceed(); + moveWorkshopAddonsAndProceed(); } } else { proceed(); @@ -74,13 +285,11 @@ export function useAddonManager() { }; // Fetch all data - const fetchData = async (showToastMessage = false) => { + const fetchData = useCallback(async (showToastMessage = false) => { setLoading(true); try { - const data: { addons?: Record; groups?: Group[]; settings?: Settings } = await invoke('get_addons'); - setAddons(data.addons || {}); - setGroups(data.groups || []); - setSettings(data.settings || { workshopDir: '', loadingDir: '', enableDummyBypass: false }); + const data: DatabasePayload = await invoke('get_addons'); + updateLocalState(data); if (showToastMessage) { addToast(t('toasts.dbRefreshSuccess'), 'success'); } @@ -90,15 +299,83 @@ export function useAddonManager() { } finally { setLoading(false); } - }; + }, [updateLocalState, addToast, t]); useEffect(() => { fetchData(); - }, []); + }, [fetchData]); + + useEffect(() => { + setSteamworksSdkDisabled(settings.disableSteamworksSdk); + setWorkshopSourceSettings(normalizeWorkshopSourceSettings(settings.workshopSourceSettings)); + }, [settings.disableSteamworksSdk, settings.workshopSourceSettings]); + + useEffect(() => { + if (loading) { + return; + } + + const shouldProbeCapabilities = !settings.disableSteamworksSdk + && (settings.workshopSourceSettings?.allowSteamworksSdk ?? true); + + if (!shouldProbeCapabilities) { + setWorkshopCapabilities(null); + setWorkshopCapabilitiesError(null); + setWorkshopCapabilitiesCheckedAt(null); + return; + } + + let cancelled = false; + + void invoke('get_workshop_capabilities') + .then((capabilities) => { + if (cancelled) return; + setWorkshopCapabilities(capabilities); + setWorkshopCapabilitiesError(null); + setWorkshopCapabilitiesCheckedAt(new Date().toISOString()); + }) + .catch((err) => { + if (cancelled) return; + setWorkshopCapabilities(null); + setWorkshopCapabilitiesError(String(err)); + setWorkshopCapabilitiesCheckedAt(new Date().toISOString()); + }); + + return () => { + cancelled = true; + }; + }, [loading, settings.disableSteamworksSdk, settings.workshopSourceSettings?.allowSteamworksSdk]); + + const steamworksUnavailableTask: BackgroundTask | null = (() => { + if (loading || settings.suppressSdkUnavailableWarning || settings.disableSteamworksSdk) { + return null; + } + const bridgeUnavailable = workshopCapabilities !== null && !workshopCapabilities.bridgeAvailable; + if (!bridgeUnavailable && !workshopCapabilitiesError) { + return null; + } + + const detail = workshopCapabilities?.lastError || workshopCapabilitiesError || ''; + + return { + id: 'steamworks-sdk-unavailable', + kind: 'warning', + status: 'failed', + source: 'frontend-warning', + targetIds: [], + progress: 100, + createdAt: workshopCapabilitiesCheckedAt || new Date().toISOString(), + title: t('taskCenter.steamworksUnavailableTitle'), + error: detail || undefined, + }; + })(); + + const backgroundTasks = steamworksUnavailableTask + ? [steamworksUnavailableTask, ...managedBackgroundTasks] + : managedBackgroundTasks; const handleOpenLink = async (url: string) => { if (!url) return; - if (url.startsWith('http://') || url.startsWith('https://')) { setLinkConfirmModal({ open: true, url }); } else { @@ -111,20 +388,25 @@ export function useAddonManager() { }; // Toggle addon enable/disable status - const toggleAddon = async (vpkName: string, currentStatus: boolean) => { + const toggleAddon = async (id: string, currentStatus: boolean) => { if (isSubmitting) return; - const addon = addons[vpkName]; + const addon = addons[id] || knownUninstalledAddons[id]; if (!addon) return; + + // If it's not installed, we can't toggle it (needs download) + if (addon.dirType === 'none') { + addToast('该组件未安装,请先下载安装', 'error'); + return; + } + setIsSubmitting(true); executeWithWorkshopCheck([addon], currentStatus ? '禁用组件' : '启用组件', async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('toggle_addons', { vpkNames: [vpkName], enabled: !currentStatus }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('toggle_addons', { ids: [id], enabled: !currentStatus }); + updateLocalState(data); addToast(currentStatus ? t('toasts.addonDisabled') : t('toasts.addonEnabled'), 'success'); - // Update detail modal state in-place if open - if (detailModal.open && detailModal.addon?.vpkName === vpkName) { + if (detailModal.open && detailModal.addon?.id === id) { setDetailModal(prev => ({ ...prev, addon: prev.addon ? { ...prev.addon, isEnabled: !currentStatus } : null @@ -140,13 +422,16 @@ export function useAddonManager() { const toggleGroupAddons = async (addonsList: Addon[], enabled: boolean) => { if (isSubmitting) return; + // Filter out uninstalled addons in the group + const actionableAddons = addonsList.filter(ad => ad.dirType !== 'none' && ad.isEnabled !== enabled); + if (actionableAddons.length === 0) return; + setIsSubmitting(true); - executeWithWorkshopCheck(addonsList, enabled ? '批量启用组件' : '批量禁用组件', async () => { + executeWithWorkshopCheck(actionableAddons, enabled ? '批量启用组件' : '批量禁用组件', async () => { try { - const vpkNames = addonsList.map(ad => ad.vpkName); - const data: { addons?: Record; groups?: Group[] } = await invoke('toggle_addons', { vpkNames, enabled }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const ids = actionableAddons.map(ad => ad.id); + const data: DatabasePayload = await invoke('toggle_addons', { ids, enabled }); + updateLocalState(data); addToast(enabled ? t('toasts.groupEnabled') : t('toasts.groupDisabled'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); @@ -157,14 +442,13 @@ export function useAddonManager() { }; // Move addon to load or workshop directory - const moveAddon = async (vpkName: string, currentDirType: string) => { + const moveAddon = async (id: string, currentDirType: string) => { if (isSubmitting) return; setIsSubmitting(true); const targetDirType = currentDirType === 'workshop' ? 'loading' : 'workshop'; try { - const data: { addons?: Record; groups?: Group[] } = await invoke('move_addons', { vpkNames: [vpkName], targetDirType }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('move_addons', { ids: [id], targetDirType }); + updateLocalState(data); addToast(targetDirType === 'loading' ? t('toasts.moveSuccessLoading') : t('toasts.moveSuccessWorkshop'), 'success'); } catch (err) { addToast(t('toasts.moveFailed', { err: String(err) }), 'error'); @@ -176,17 +460,17 @@ export function useAddonManager() { const handleMoveClick = (addon: Addon) => { if (addon.dirType === 'workshop') { if (settings.enableDummyBypass) { - moveAddon(addon.vpkName, addon.dirType); + moveAddon(addon.id, addon.dirType); } else { setMoveWarningModal({ open: true, - vpkName: addon.vpkName, + id: addon.id, currentDirType: addon.dirType, workshopId: addon.workshopId || '' }); } } else { - moveAddon(addon.vpkName, addon.dirType); + moveAddon(addon.id, addon.dirType); } }; @@ -194,9 +478,8 @@ export function useAddonManager() { const syncSteamDetails = async () => { setSyncingSteam(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('steam_sync'); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('steam_sync'); + updateLocalState(data); addToast(t('toasts.steamSyncSuccess'), 'success'); } catch (err) { addToast(t('toasts.steamSyncFailed', { err: String(err) }), 'error'); @@ -209,9 +492,8 @@ export function useAddonManager() { const runAutoGrouping = async () => { setAutoGrouping(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { action: 'auto-group' }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('group_action', { action: 'auto-group' }); + updateLocalState(data); addToast(t('toasts.autoClassifySuccess'), 'success'); } catch (err) { addToast(t('toasts.autoClassifyFailed', { err: String(err) }), 'error'); @@ -221,17 +503,34 @@ export function useAddonManager() { }; // Save Settings - const saveSettings = async (loadingDir: string, enableDummyBypass: boolean) => { + const saveSettings = async ( + loadingDir: string, + downloadConcurrency: number, + enableDummyBypass: boolean, + suppressSdkUnavailableWarning: boolean, + disableSteamworksSdk: boolean, + forceSteamworksSdkDownload: boolean, + maxDownloadRetries: number, + dependencyMissingBehavior?: string, + workshopSourceSettings?: WorkshopSourceSettings, + ) => { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[]; settings?: Settings } = await invoke('save_settings', { - loadingDir, - enableDummyBypass + const data: DatabasePayload = await invoke('save_settings', { + payload: { + loadingDir, + downloadConcurrency, + enableDummyBypass, + suppressSdkUnavailableWarning, + disableSteamworksSdk, + forceSteamworksSdkDownload, + maxDownloadRetries, + dependencyMissingBehavior, + workshopSourceSettings, + }, }); - setAddons(data.addons || {}); - setGroups(data.groups || []); - setSettings(data.settings || { workshopDir: '', loadingDir: '', enableDummyBypass: false }); + updateLocalState(data); setSettingsModal({ open: false, loadingDir: '' }); addToast(t('toasts.settingsSaveSuccess'), 'success'); } catch (err) { @@ -245,17 +544,16 @@ export function useAddonManager() { // Save Rename Addon const submitRename = async (currentName: string, newVpkName: string) => { if (isSubmitting) return; - const addon = addons[currentName]; + const addon = addons[currentName] || knownUninstalledAddons[currentName]; if (!addon) return; setIsSubmitting(true); executeWithWorkshopCheck([addon], t('common.rename'), async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('rename_addon', { - vpkName: currentName, + const data: DatabasePayload = await invoke('rename_addon', { + id: currentName, newVpkName }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); setRenameModal({ open: false, currentName: '', title: '', suggestedName: '' }); addToast(t('toasts.renameSuccess'), 'success'); } catch (err) { @@ -267,17 +565,29 @@ export function useAddonManager() { }; // Group Management APIs - const handleCreateGroup = async (name: string, selectedAddons: string[]) => { + const handleCreateGroup = async (name: string, selectedAddons?: string[], tags?: string[], workshopCollectionId?: string, source?: string) => { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { + const existingGroupIds = new Set(groups.map(group => group.id)); + const data: DatabasePayload = await invoke('group_action', { action: 'create', name, - vpkNames: selectedAddons + ids: selectedAddons || [], + tags, + workshopCollectionId, + source }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); + const createdGroup = (data.groups || []).find(group => !existingGroupIds.has(group.id)) + || [...(data.groups || [])].reverse().find(group => group.name === name); + if (createdGroup) { + setSearchQuery(''); + setSelectedCategory('All'); + setCurrentFilterTab('groups'); + setSelectedGroupId(createdGroup.id); + setSelectedMasterCollectionId(null); + } setGroupModal({ open: false }); addToast(t('toasts.createGroupSuccess'), 'success'); } catch (err) { @@ -296,9 +606,8 @@ export function useAddonManager() { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { action: 'delete', groupId }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const data: DatabasePayload = await invoke('group_action', { action: 'delete', groupId }); + updateLocalState(data); if (selectedGroupId === groupId) { setSelectedGroupId(null); setCurrentFilterTab('all'); @@ -313,19 +622,21 @@ export function useAddonManager() { }); }; - const handleRenameGroup = async (groupId: string, name: string) => { + const handleRenameGroup = async (groupId: string, name: string, tags?: string[], workshopCollectionId?: string, ids?: string[]) => { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { - action: 'rename-group', + const data: DatabasePayload = await invoke('group_action', { + action: 'update-group', groupId, - name + name, + tags, + workshopCollectionId, + ids }); - setAddons(data.addons || {}); - setGroups(data.groups || []); - setEditGroupModal({ open: false, groupId: '', name: '' }); - addToast(t('toasts.renameGroupSuccess'), 'success'); + updateLocalState(data); + setEditGroupModal({ open: false, groupId: '', name: '', tags: [], workshopCollectionId: '' }); + addToast(t('toasts.renameGroupSuccess') || '修改分组成功', 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); } finally { @@ -336,19 +647,23 @@ export function useAddonManager() { const groupActionBatch = async (groupId: string, actionType: 'enable' | 'disable' | 'move-load') => { const group = groups.find(g => g.id === groupId); if (!group) return; - if (isSubmitting) return; setIsSubmitting(true); try { if (actionType === 'enable' || actionType === 'disable') { const enabled = actionType === 'enable'; - const groupAddons = group.addons.map(name => addons[name]).filter(Boolean); - executeWithWorkshopCheck(groupAddons, enabled ? t('batchActionBar.enable') : t('batchActionBar.disable'), async () => { + const groupAddons = group.addons.map(name => addons[name] || knownUninstalledAddons[name]).filter(Boolean); + const actionableAddons = groupAddons.filter(ad => ad.dirType !== 'none' && ad.isEnabled !== enabled); + if (actionableAddons.length === 0) { + setIsSubmitting(false); + return; + } + executeWithWorkshopCheck(actionableAddons, enabled ? t('batchActionBar.enable') : t('batchActionBar.disable'), async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('toggle_addons', { vpkNames: group.addons, enabled }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const ids = actionableAddons.map(ad => ad.id); + const data: DatabasePayload = await invoke('toggle_addons', { ids, enabled }); + updateLocalState(data); addToast(enabled ? t('toasts.groupEnabled') : t('toasts.groupDisabled'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); @@ -358,13 +673,20 @@ export function useAddonManager() { }); } else if (actionType === 'move-load') { const targetDirType = 'loading'; - const isFromWorkshop = group.addons.some(name => addons[name]?.dirType === 'workshop'); + const movableAddons = group.addons + .map(name => addons[name]) + .filter((addon): addon is Addon => Boolean(addon)) + .filter(ad => ad.dirType === 'workshop'); + if (movableAddons.length === 0) { + setIsSubmitting(false); + return; + } const executeMove = async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('move_addons', { vpkNames: group.addons, targetDirType }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + const ids = movableAddons.map(ad => ad.id); + const data: DatabasePayload = await invoke('move_addons', { ids, targetDirType }); + updateLocalState(data); addToast(t('toasts.batchMoveSuccess'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); @@ -373,19 +695,15 @@ export function useAddonManager() { } }; - if (isFromWorkshop) { - if (settings.enableDummyBypass) { - await executeMove(); - } else { - setConfirmModal({ - open: true, - title: t('moveWarningModal.title'), - message: (t('confirmModal.batchMoveMsg', { count: group.addons.filter(name => addons[name]?.dirType === 'workshop').length }) as string[]).join('\n\n'), - onConfirm: executeMove - }); - } - } else { + if (settings.enableDummyBypass) { await executeMove(); + } else { + setConfirmModal({ + open: true, + title: t('moveWarningModal.title'), + message: (t('confirmModal.batchMoveMsg', { count: movableAddons.length }) as unknown as string[]).join('\n\n'), + onConfirm: executeMove + }); } } } catch (err) { @@ -394,17 +712,16 @@ export function useAddonManager() { } }; - const removeAddonFromGroup = async (vpkName: string, groupId: string) => { + const removeAddonFromGroup = async (id: string, groupId: string) => { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { + const data: DatabasePayload = await invoke('group_action', { action: 'remove-addons', groupId, - vpkNames: [vpkName] + ids: [id] }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.removeFromGroupSuccess'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); @@ -413,17 +730,16 @@ export function useAddonManager() { } }; - const addAddonToGroup = async (vpkName: string, groupId: string) => { + const addAddonToGroup = async (id: string, groupId: string) => { if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { + const data: DatabasePayload = await invoke('group_action', { action: 'add-addons', groupId, - vpkNames: [vpkName] + ids: [id] }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.addToGroupSuccess'), 'success'); } catch (err) { addToast(t('toasts.operationFailed', { err: (err as Error).message }), 'error'); @@ -434,24 +750,108 @@ export function useAddonManager() { // Helper to suggest workshop title for rename const triggerRenameModal = (addon: Addon) => { - const itemGroup = groups.find(g => g.addons.includes(addon.vpkName)); + const itemGroup = groups.find(g => g.addons.includes(addon.id)); const suggestedVpkName = getSuggestedVpkName(addon, itemGroup?.name, addons); const steamTitle = addon.steamDetails?.title || getAddonInfoValue(addon, 'addontitle') || addon.vpkName; setRenameModal({ open: true, - currentName: addon.vpkName, + currentName: addon.id, suggestedName: suggestedVpkName, title: steamTitle }); }; + const downloadAddons = async ( + items: (string | { workshopId: string; title?: string; imagePath?: string })[], + source = 'batch-download', + ) => { + const workshopIds = items + .map((item) => typeof item === 'string' ? item : item.workshopId) + .map((id) => id.trim()) + .filter(Boolean); + if (workshopIds.length === 0) return; + setDownloadProgress((prev) => { + const next = { ...prev }; + workshopIds.forEach((workshopId) => { + next[workshopId] = next[workshopId] ?? 0; + }); + return next; + }); + enqueueDownloads(items, source); + }; + + // Download addon + const downloadAddon = async (workshopId: string, title?: string, imagePath?: string) => { + await downloadAddons([{ workshopId, title, imagePath }], 'single-download'); + }; + + // Delete addon(s) + const deleteAddons = async (ids: string[], deleteFile: boolean, removeFromKnown: boolean) => { + if (isSubmitting) return; + + // Check if any of these ids are local non-workshop items + const selectedAddons = ids.map(id => addons[id] || knownUninstalledAddons[id]).filter(Boolean); + + if (deleteFile) { + setDeleteConfirmModal({ + open: true, + addons: selectedAddons, + removeFromKnown, + }); + } else { + const executeDelete = async () => { + setIsSubmitting(true); + try { + const data: DatabasePayload = await invoke('delete_addons', { ids, deleteFile, removeFromKnown }); + updateLocalState(data); + addToast('删除组件记录成功', 'success'); + handleClearSelection(); + } catch (err) { + addToast(`删除记录失败: ${err}`, 'error'); + } finally { + setIsSubmitting(false); + } + }; + + setConfirmModal({ + open: true, + title: '删除组件记录', + message: `确定要删除选中的 ${ids.length} 个组件记录吗?`, + onConfirm: executeDelete + }); + } + }; + + const executeRealDelete = async (deleteMode: 'all' | 'workshop-only', removeFromKnown: boolean, ids: string[]) => { + setIsSubmitting(true); + + let targetIds = ids; + if (deleteMode === 'workshop-only') { + targetIds = ids.filter(id => { + const ad = addons[id] || knownUninstalledAddons[id]; + return ad && ad.workshopId; + }); + } + + try { + const data: DatabasePayload = await invoke('delete_addons', { ids: targetIds, deleteFile: true, removeFromKnown }); + updateLocalState(data); + addToast('删除组件成功', 'success'); + handleClearSelection(); + } catch (err) { + addToast(`删除失败: ${err}`, 'error'); + } finally { + setIsSubmitting(false); + } + }; + // Selection management - const handleSelectToggle = (vpkName: string) => { - setSelectedVpkNames((prev) => { - const next = prev.includes(vpkName) - ? prev.filter((name) => name !== vpkName) - : [...prev, vpkName]; + const handleSelectToggle = (id: string) => { + setSelectedIds((prev) => { + const next = prev.includes(id) + ? prev.filter((name) => name !== id) + : [...prev, id]; if (next.length > 0 && !isSelectMode) { setIsSelectMode(true); } @@ -459,18 +859,33 @@ export function useAddonManager() { }); }; - const handleSelectGroupToggle = (groupAddons: Addon[]) => { - const addonNames = groupAddons.map((ad) => ad.vpkName); - const allSelected = addonNames.every((name) => selectedVpkNames.includes(name)); + const handleSelectGroupToggle = (groupAddons: Addon[], groupId?: string) => { + // In master collection view, toggle the group ID + if (groupId && currentFilterTab === 'master-collection') { + setSelectedGroupIds((prev) => { + const next = prev.includes(groupId) + ? prev.filter((id) => id !== groupId) + : [...prev, groupId]; + if (next.length > 0 && !isSelectMode) { + setIsSelectMode(true); + } + return next; + }); + return; + } + + // Default: toggle all addons in the group + const addonIds = groupAddons.map((ad) => ad.id); + const allSelected = addonIds.every((id) => selectedIds.includes(id)); if (allSelected) { - setSelectedVpkNames((prev) => prev.filter((name) => !addonNames.includes(name))); + setSelectedIds((prev) => prev.filter((id) => !addonIds.includes(id))); } else { - setSelectedVpkNames((prev) => { + setSelectedIds((prev) => { const next = [...prev]; - addonNames.forEach((name) => { - if (!next.includes(name)) { - next.push(name); + addonIds.forEach((id) => { + if (!next.includes(id)) { + next.push(id); } }); if (next.length > 0 && !isSelectMode) { @@ -482,17 +897,17 @@ export function useAddonManager() { }; const handleSelectAll = (visibleItems: Addon[]) => { - const visibleNames = visibleItems.map(item => item.vpkName); - const allSelected = visibleNames.every(name => selectedVpkNames.includes(name)); - + const visibleIds = visibleItems.map(item => item.id); + const allSelected = visibleIds.every(id => selectedIds.includes(id)); + if (allSelected) { - setSelectedVpkNames((prev) => prev.filter(name => !visibleNames.includes(name))); + setSelectedIds((prev) => prev.filter(id => !visibleIds.includes(id))); } else { - setSelectedVpkNames((prev) => { + setSelectedIds((prev) => { const next = [...prev]; - visibleNames.forEach(name => { - if (!next.includes(name)) { - next.push(name); + visibleIds.forEach(id => { + if (!next.includes(id)) { + next.push(id); } }); return next; @@ -501,24 +916,68 @@ export function useAddonManager() { }; const handleClearSelection = () => { - setSelectedVpkNames([]); + setSelectedIds([]); + setSelectedGroupIds([]); setIsSelectMode(false); }; + const handleSelectAllGroups = (visibleGroups: Group[]) => { + const visibleIds = visibleGroups.map(g => g.id); + const allSelected = visibleIds.every(id => selectedGroupIds.includes(id)); + + if (allSelected) { + setSelectedGroupIds((prev) => prev.filter(id => !visibleIds.includes(id))); + } else { + setSelectedGroupIds((prev) => { + const next = [...prev]; + visibleIds.forEach(id => { + if (!next.includes(id)) { + next.push(id); + } + }); + return next; + }); + } + }; + + const handleBatchAddGroupsToMasterCollection = async (mcId: string) => { + if (selectedGroupIds.length === 0) return; + if (isSubmitting) return; + setIsSubmitting(true); + try { + const data: DatabasePayload = await invoke('group_action', { + action: 'add-to-master-collection', + groupId: mcId, + ids: selectedGroupIds + }); + updateLocalState(data); + addToast(t('toasts.addToMasterCollectionSuccess'), 'success'); + handleClearSelection(); + } catch (err) { + addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); + } finally { + setIsSubmitting(false); + } + }; + // Batch actions const handleBatchToggle = async (enabled: boolean) => { - if (selectedVpkNames.length === 0) return; + if (selectedIds.length === 0) return; if (isSubmitting) return; + + const selectedAddonsList = selectedIds.map(name => addons[name] || knownUninstalledAddons[name]).filter(Boolean); + const actionableAddons = selectedAddonsList.filter(ad => ad.dirType !== 'none' && ad.isEnabled !== enabled); + if (actionableAddons.length === 0) return; + setIsSubmitting(true); - const selectedAddonsList = selectedVpkNames.map(name => addons[name]).filter(Boolean); - executeWithWorkshopCheck(selectedAddonsList, enabled ? t('toasts.addonEnabled') : t('toasts.addonDisabled'), async () => { + executeWithWorkshopCheck(actionableAddons, enabled ? t('toasts.addonEnabled') : t('toasts.addonDisabled'), async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('toggle_addons', { - vpkNames: selectedVpkNames, + const ids = actionableAddons.map(ad => ad.id); + const data: DatabasePayload = await invoke('toggle_addons', { + ids, enabled }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(enabled ? t('toasts.batchToggleSuccessEnable') : t('toasts.batchToggleSuccessDisable'), 'success'); handleClearSelection(); } catch (err) { @@ -530,20 +989,25 @@ export function useAddonManager() { }; const handleBatchMove = async () => { - if (selectedVpkNames.length === 0) return; + if (selectedIds.length === 0) return; if (isSubmitting) return; + + const movableAddons = selectedIds + .map(name => addons[name]) + .filter((addon): addon is Addon => Boolean(addon)) + .filter(ad => ad.dirType === 'workshop'); + if (movableAddons.length === 0) return; + setIsSubmitting(true); - const selectedAddonsList = selectedVpkNames.map(name => addons[name]).filter(Boolean); - const workshopAddons = selectedAddonsList.filter(ad => ad.dirType === 'workshop'); const executeMove = async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('move_addons', { - vpkNames: selectedVpkNames, + const ids = movableAddons.map(ad => ad.id); + const data: DatabasePayload = await invoke('move_addons', { + ids, targetDirType: 'loading' }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.batchMoveSuccess'), 'success'); handleClearSelection(); } catch (err) { @@ -553,44 +1017,40 @@ export function useAddonManager() { } }; - if (workshopAddons.length > 0) { - if (settings.enableDummyBypass) { - await executeMove(); - } else { - setConfirmModal({ - open: true, - title: t('moveWarningModal.title'), - message: (t('confirmModal.batchMoveMsg', { count: workshopAddons.length }) as string[]).join('\n\n'), - onConfirm: executeMove - }); - } - } else { + if (settings.enableDummyBypass) { await executeMove(); + } else { + setConfirmModal({ + open: true, + title: t('moveWarningModal.title'), + message: (t('confirmModal.batchMoveMsg', { count: movableAddons.length }) as unknown as string[]).join('\n\n'), + onConfirm: executeMove + }); } }; const handleBatchRename = async () => { - if (selectedVpkNames.length === 0) return; + if (selectedIds.length === 0) return; if (isSubmitting) return; setIsSubmitting(true); - const renamesList: { vpkName: string; newVpkName: string }[] = []; + const renamesList: { id: string; newVpkName: string }[] = []; const currentAddonsMap = { ...addons }; - for (const vpkName of selectedVpkNames) { - const addon = addons[vpkName]; + for (const id of selectedIds) { + const addon = addons[id]; if (!addon) continue; - const itemGroup = groups.find(g => g.addons.includes(vpkName)); + const itemGroup = groups.find(g => g.addons.includes(id)); const suggestedVpkName = getSuggestedVpkName(addon, itemGroup?.name, currentAddonsMap); - if (suggestedVpkName !== vpkName) { + if (suggestedVpkName !== addon.vpkName) { renamesList.push({ - vpkName, + id, newVpkName: suggestedVpkName }); - delete currentAddonsMap[vpkName]; - currentAddonsMap[suggestedVpkName] = { + delete currentAddonsMap[id]; + currentAddonsMap[id] = { ...addon, vpkName: suggestedVpkName }; @@ -608,14 +1068,15 @@ export function useAddonManager() { title: t('confirmModal.batchRenameTitle'), message: t('confirmModal.batchRenameMsg', { count: renamesList.length }), onConfirm: async () => { - const selectedAddonsList = selectedVpkNames.map(name => addons[name]).filter(Boolean); - executeWithWorkshopCheck(selectedAddonsList, t('common.rename'), async () => { + const renameTargetAddons = renamesList + .map(({ id }) => addons[id]) + .filter((addon): addon is Addon => Boolean(addon)); + executeWithWorkshopCheck(renameTargetAddons, t('common.rename'), async () => { try { - const data: { addons?: Record; groups?: Group[] } = await invoke('rename_addons', { + const data: DatabasePayload = await invoke('rename_addons', { renames: renamesList }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.batchRenameSuccess', { count: renamesList.length }), 'success'); handleClearSelection(); } catch (err) { @@ -629,17 +1090,16 @@ export function useAddonManager() { }; const handleBatchAddToGroup = async (groupId: string) => { - if (selectedVpkNames.length === 0) return; + if (selectedIds.length === 0) return; if (isSubmitting) return; setIsSubmitting(true); try { - const data: { addons?: Record; groups?: Group[] } = await invoke('group_action', { + const data: DatabasePayload = await invoke('group_action', { action: 'add-addons', groupId, - vpkNames: selectedVpkNames + ids: selectedIds }); - setAddons(data.addons || {}); - setGroups(data.groups || []); + updateLocalState(data); addToast(t('toasts.batchAddGroupSuccess'), 'success'); handleClearSelection(); } catch (err) { @@ -649,9 +1109,134 @@ export function useAddonManager() { } }; + // Master Collection Management APIs + const handleCreateMasterCollection = async (name: string, selectedGroupIds?: string[]) => { + if (isSubmitting) return; + setIsSubmitting(true); + try { + const data: DatabasePayload = await invoke('group_action', { + action: 'create-master-collection', + name, + ids: selectedGroupIds || [] + }); + updateLocalState(data); + addToast(t('toasts.createMasterCollectionSuccess'), 'success'); + } catch (err) { + addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); + } finally { + setIsSubmitting(false); + } + }; + + const handleDeleteMasterCollection = (mcId: string) => { + setConfirmModal({ + open: true, + title: t('masterCollections.deleteCollection'), + message: t('confirmModal.deleteGroupMsg'), + onConfirm: async () => { + if (isSubmitting) return; + setIsSubmitting(true); + try { + const data: DatabasePayload = await invoke('group_action', { + action: 'delete-master-collection', + groupId: mcId + }); + updateLocalState(data); + if (selectedMasterCollectionId === mcId) { + setSelectedMasterCollectionId(null); + setCurrentFilterTab('all'); + } + addToast(t('toasts.deleteMasterCollectionSuccess'), 'success'); + } catch (err) { + addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); + } finally { + setIsSubmitting(false); + } + } + }); + }; + + const handleRenameMasterCollection = async (mcId: string, newName: string) => { + if (isSubmitting) return; + setIsSubmitting(true); + try { + const data: DatabasePayload = await invoke('group_action', { + action: 'rename-master-collection', + groupId: mcId, + name: newName + }); + updateLocalState(data); + addToast(t('toasts.renameMasterCollectionSuccess'), 'success'); + } catch (err) { + addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); + } finally { + setIsSubmitting(false); + } + }; + + const handleAddGroupToMasterCollection = async (mcId: string, groupIds: string[]) => { + if (isSubmitting) return; + setIsSubmitting(true); + try { + const data: DatabasePayload = await invoke('group_action', { + action: 'add-to-master-collection', + groupId: mcId, + ids: groupIds + }); + updateLocalState(data); + addToast(t('toasts.addToMasterCollectionSuccess'), 'success'); + } catch (err) { + addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); + } finally { + setIsSubmitting(false); + } + }; + + const handleRemoveGroupFromMasterCollection = async (mcId: string, groupIds: string[]) => { + if (isSubmitting) return; + setIsSubmitting(true); + try { + const data: DatabasePayload = await invoke('group_action', { + action: 'remove-from-master-collection', + groupId: mcId, + ids: groupIds + }); + updateLocalState(data); + addToast(t('toasts.removeFromMasterCollectionSuccess'), 'success'); + } catch (err) { + addToast(t('toasts.operationFailed', { err: String(err) }), 'error'); + } finally { + setIsSubmitting(false); + } + }; + + const ensureSystemMasterCollections = async () => { + try { + const data: DatabasePayload = await invoke('group_action', { + action: 'ensure-system-master-collections' + }); + updateLocalState(data); + } catch (err) { + console.error('Failed to ensure system master collections:', err); + } + }; + + // Batch download uninstalled addons + const handleBatchDownload = async (workshopIds: string[]) => { + if (workshopIds.length === 0) return; + await downloadAddons(workshopIds, 'batch-download'); + addToast(t('toasts.batchDownloadSuccess', { count: workshopIds.length }), 'success'); + }; + // Filter and sort addons const getFilteredAddons = () => { - let items = Object.values(addons).filter(item => !item.isDummy); + // For master-collection and groups views, include uninstalled addons so groups with only uninstalled addons are visible + const includeUninstalled = currentFilterTab === 'master-collection' || currentFilterTab === 'groups'; + let items = currentFilterTab === 'known-uninstalled' + ? Object.values(knownUninstalledAddons) + : includeUninstalled + ? [...Object.values(addons).filter(item => !item.isDummy), ...Object.values(knownUninstalledAddons)] + : Object.values(addons).filter(item => !item.isDummy); // Search query filter if (searchQuery) { @@ -660,7 +1245,7 @@ export function useAddonManager() { const title = (item.steamDetails?.title || getAddonInfoValue(item, 'addontitle') || '').toLowerCase(); const desc = (item.steamDetails?.description || getAddonInfoValue(item, 'addondescription') || getAddonInfoValue(item, 'addontagline') || '').toLowerCase(); const author = getAddonAuthor(item).toLowerCase(); - return title.includes(q) || desc.includes(q) || author.includes(q) || item.vpkName.toLowerCase().includes(q) || (item.workshopId || '').includes(q); + return title.includes(q) || desc.includes(q) || author.includes(q) || item.id.toLowerCase().includes(q) || (item.workshopId || '').includes(q); }); } @@ -675,19 +1260,35 @@ export function useAddonManager() { }); } - // Tab filter - if (currentFilterTab === 'workshop') { - items = items.filter(item => item.dirType === 'workshop'); - } else if (currentFilterTab === 'loading') { - items = items.filter(item => item.dirType === 'loading'); - } else if (currentFilterTab === 'disabled') { - items = items.filter(item => !item.isEnabled); - } else if (currentFilterTab === 'groups' && selectedGroupId) { - const group = groups.find(g => g.id === selectedGroupId); - if (group) { - items = items.filter(item => group.addons.includes(item.vpkName)); - } else { - items = []; + // Tab filter (if not known-uninstalled) + if (currentFilterTab !== 'known-uninstalled') { + if (currentFilterTab === 'workshop') { + items = items.filter(item => item.dirType === 'workshop'); + } else if (currentFilterTab === 'loading') { + items = items.filter(item => item.dirType === 'loading'); + } else if (currentFilterTab === 'disabled') { + items = items.filter(item => !item.isEnabled); + } else if (currentFilterTab === 'groups' && selectedGroupId) { + const group = groups.find(g => g.id === selectedGroupId); + if (group) { + items = items.filter(item => group.addons.includes(item.id)); + } else { + items = []; + } + } else if (currentFilterTab === 'master-collection' && selectedMasterCollectionId) { + const mc = masterCollections.find(mc => mc.id === selectedMasterCollectionId); + if (mc) { + const groupAddons = new Set(); + for (const gid of mc.groupIds) { + const group = groups.find(g => g.id === gid); + if (group) { + group.addons.forEach(id => groupAddons.add(id)); + } + } + items = items.filter(item => groupAddons.has(item.id)); + } else { + items = []; + } } } @@ -709,17 +1310,65 @@ export function useAddonManager() { }; const getRenderedItems = (filteredAddons: Addon[]): RenderedItem[] => { - if (currentFilterTab === 'groups') { - return filteredAddons.map(item => ({ type: 'addon' as const, id: item.vpkName, data: item })); + if (currentFilterTab === 'groups' || currentFilterTab === 'known-uninstalled') { + const orderedAddons = currentFilterTab === 'groups' + ? sortAddonsDownloadedFirst(filteredAddons) + : filteredAddons; + return orderedAddons.map(item => ({ type: 'addon' as const, id: item.id, data: item })); + } + + const emptyGroupMatchesFilters = (group: Group) => { + if (group.addons.length > 0 || selectedCategory !== 'All') { + return false; + } + + if (!searchQuery) { + return true; + } + + const q = searchQuery.toLowerCase(); + return group.name.toLowerCase().includes(q) + || (group.workshopCollectionId || '').includes(q) + || (group.tags || []).some(tag => tag.toLowerCase().includes(q)); + }; + + // For master collection view, show groups within the collection + if (currentFilterTab === 'master-collection' && selectedMasterCollectionId) { + const mc = masterCollections.find(mc => mc.id === selectedMasterCollectionId); + if (mc) { + const rendered: RenderedItem[] = []; + const groupedVpkNames = new Set(); + for (const gid of mc.groupIds) { + const group = groups.find(g => g.id === gid); + if (group) { + const matchingAddons = sortAddonsDownloadedFirst( + filteredAddons.filter(item => group.addons.includes(item.id)) + ); + if (matchingAddons.length > 0 || emptyGroupMatchesFilters(group)) { + matchingAddons.forEach(item => groupedVpkNames.add(item.id)); + rendered.push({ + type: 'group', + id: group.id, + name: group.name, + addons: matchingAddons, + groupObj: group + }); + } + } + } + return rendered; + } } const rendered: RenderedItem[] = []; const groupedVpkNames = new Set(); - + groups.forEach(group => { - const matchingAddons = filteredAddons.filter(item => group.addons.includes(item.vpkName)); - if (matchingAddons.length > 0) { - matchingAddons.forEach(item => groupedVpkNames.add(item.vpkName)); + const matchingAddons = sortAddonsDownloadedFirst( + filteredAddons.filter(item => group.addons.includes(item.id)) + ); + if (matchingAddons.length > 0 || (currentFilterTab === 'all' && emptyGroupMatchesFilters(group))) { + matchingAddons.forEach(item => groupedVpkNames.add(item.id)); rendered.push({ type: 'group', id: group.id, @@ -731,10 +1380,10 @@ export function useAddonManager() { }); filteredAddons.forEach(item => { - if (!groupedVpkNames.has(item.vpkName)) { + if (!groupedVpkNames.has(item.id)) { rendered.push({ type: 'addon', - id: item.vpkName, + id: item.id, data: item }); } @@ -790,17 +1439,32 @@ export function useAddonManager() { ? groups.find(g => g.id === selectedGroupId) : null; - const renameAddonObj = renameModal.currentName ? addons[renameModal.currentName] : undefined; + const currentMasterCollection = currentFilterTab === 'master-collection' && selectedMasterCollectionId + ? masterCollections.find(mc => mc.id === selectedMasterCollectionId) + : null; + + const renameAddonObj = renameModal.currentName ? (addons[renameModal.currentName] || knownUninstalledAddons[renameModal.currentName]) : undefined; const renameGroupObj = renameModal.currentName ? groups.find(g => g.addons.includes(renameModal.currentName)) : undefined; const handleFilterTabChange = (tab: string, groupId: string | null) => { setCurrentFilterTab(tab); setSelectedGroupId(groupId); + if (tab === 'master-collection') { + setSelectedMasterCollectionId(groupId); + } else { + setSelectedMasterCollectionId(null); + } + // Clear selections when switching tabs + setSelectedIds([]); + setSelectedGroupIds([]); + setIsSelectMode(false); }; return { addons, + knownUninstalledAddons, groups, + masterCollections, settings, loading, searchQuery, @@ -809,6 +1473,8 @@ export function useAddonManager() { setCurrentFilterTab, selectedGroupId, setSelectedGroupId, + selectedMasterCollectionId, + setSelectedMasterCollectionId, selectedCategory, setSelectedCategory, sortBy, @@ -817,12 +1483,16 @@ export function useAddonManager() { setToasts, syncingSteam, autoGrouping, - selectedVpkNames, + selectedIds, + selectedGroupIds, + setSelectedGroupIds, isSelectMode, setIsSelectMode, isSubmitting, setIsSubmitting, - + downloadProgress, + backgroundTasks, + // Modals state detailModal, setDetailModal, @@ -840,11 +1510,14 @@ export function useAddonManager() { setLinkConfirmModal, confirmModal, setConfirmModal, + deleteConfirmModal, + setDeleteConfirmModal, workshopActionModal, setWorkshopActionModal, // Handlers fetchData, + applyDatabaseUpdate: updateLocalState, addToast, handleOpenLink, toggleAddon, @@ -865,12 +1538,34 @@ export function useAddonManager() { handleSelectToggle, handleSelectGroupToggle, handleSelectAll, + handleSelectAllGroups, handleClearSelection, handleBatchToggle, handleBatchMove, handleBatchRename, handleBatchAddToGroup, handleFilterTabChange, + downloadAddon, + downloadAddons, + deleteAddons, + executeRealDelete, + handleBatchDownload, + enqueueWorkshopCrawl, + enqueueDependencyCheck, + recordSeenItems, + cancelTask, + retryTask, + removeTask, + clearFinishedTasks, + + // Master Collection handlers + handleCreateMasterCollection, + handleDeleteMasterCollection, + handleRenameMasterCollection, + handleAddGroupToMasterCollection, + handleRemoveGroupFromMasterCollection, + handleBatchAddGroupsToMasterCollection, + ensureSystemMasterCollections, // Derived values filteredItems, @@ -880,6 +1575,7 @@ export function useAddonManager() { disabledCount, totalStorageSize, currentGroup, + currentMasterCollection, renameAddonObj, renameGroupObj, }; diff --git a/src/hooks/useBackgroundTasks.test.tsx b/src/hooks/useBackgroundTasks.test.tsx new file mode 100644 index 0000000..14cbb83 --- /dev/null +++ b/src/hooks/useBackgroundTasks.test.tsx @@ -0,0 +1,176 @@ +import React, { StrictMode } from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { useBackgroundTasks } from './useBackgroundTasks'; + +const { mockInvoke, mockFetchDependencySnapshot } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), + mockFetchDependencySnapshot: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (command: string, args?: Record) => mockInvoke(command, args), +})); + +vi.mock('../services/workshopClient', () => ({ + fetchWorkshopDependencySnapshot: (workshopId: string) => mockFetchDependencySnapshot(workshopId), + fetchWorkshopItem: vi.fn(), +})); + +describe('useBackgroundTasks dependency checks', () => { + beforeEach(() => { + mockInvoke.mockReset(); + mockFetchDependencySnapshot.mockReset(); + mockInvoke.mockImplementation((command) => { + if (command === 'get_background_tasks') return Promise.resolve([]); + if (command === 'persist_workshop_page_details') { + return Promise.resolve({ addons: {}, knownUninstalledAddons: {}, groups: [], settings: {} }); + } + return Promise.resolve(); + }); + }); + + test('visits a dependency chain once and terminates a cycle', async () => { + const relations: Record = { + A: ['B'], + B: ['C'], + C: ['A'], + }; + mockFetchDependencySnapshot.mockImplementation(async (workshopId: string) => ({ + imageGallery: [], + tags: [], + parentCollections: [], + requiredItems: relations[workshopId].map((id) => ({ workshopId: id, title: id })), + })); + const updateLocalState = vi.fn(); + const { result } = renderHook(() => useBackgroundTasks({ + enabled: true, + downloadConcurrency: 1, + addons: {}, + knownUninstalledAddons: {}, + updateLocalState, + onDownloadSuccess: vi.fn(), + onDownloadCancelled: vi.fn(), + onTaskError: vi.fn(), + })); + + await waitFor(() => expect(mockInvoke).toHaveBeenCalledWith('get_background_tasks', undefined)); + act(() => { + result.current.enqueueDependencyCheck(['A'], 'test'); + }); + + await waitFor(() => { + const task = result.current.backgroundTasks.find((candidate) => candidate.kind === 'dependency-check'); + expect(task?.status).toBe('completed'); + expect(task?.dependencyCheck?.completedCount).toBe(3); + expect(task?.dependencyCheck?.discoveredDependencies).toEqual(expect.arrayContaining([ + expect.objectContaining({ workshopId: 'B', title: 'B' }), + expect.objectContaining({ workshopId: 'C', title: 'C' }), + expect.objectContaining({ workshopId: 'A', title: 'A' }), + ])); + }); + + expect(mockFetchDependencySnapshot.mock.calls.map(([id]) => id)).toEqual(['A', 'B', 'C']); + expect(mockInvoke.mock.calls.filter(([command]) => command === 'persist_workshop_page_details')).toHaveLength(3); + expect(updateLocalState).toHaveBeenCalledTimes(1); + }); + + test('retries and removes a failed dependency check', async () => { + let attempts = 0; + mockFetchDependencySnapshot.mockImplementation(async () => { + attempts += 1; + if (attempts === 1) { + throw new Error('temporary dependency lookup failure'); + } + return { + imageGallery: [], + tags: [], + parentCollections: [], + requiredItems: [], + }; + }); + + const { result } = renderHook(() => useBackgroundTasks({ + enabled: true, + downloadConcurrency: 1, + addons: {}, + knownUninstalledAddons: {}, + updateLocalState: vi.fn(), + onDownloadSuccess: vi.fn(), + onDownloadCancelled: vi.fn(), + onTaskError: vi.fn(), + })); + + await waitFor(() => expect(mockInvoke).toHaveBeenCalledWith('get_background_tasks', undefined)); + act(() => { + result.current.enqueueDependencyCheck(['A'], 'test'); + }); + + let taskId = ''; + await waitFor(() => { + const task = result.current.backgroundTasks.find((candidate) => candidate.kind === 'dependency-check'); + expect(task?.status).toBe('failed'); + expect(task?.dependencyCheck?.failedNodes).toEqual([ + expect.objectContaining({ workshopId: 'A' }), + ]); + taskId = task!.id; + }); + + act(() => { + result.current.retryTask(taskId); + }); + + await waitFor(() => { + const task = result.current.backgroundTasks.find((candidate) => candidate.id === taskId); + expect(task?.status).toBe('completed'); + expect(task?.dependencyCheck?.failedNodes).toEqual([]); + }); + expect(mockFetchDependencySnapshot).toHaveBeenCalledTimes(2); + + act(() => { + result.current.removeTask(taskId); + }); + + expect(result.current.backgroundTasks).not.toContainEqual(expect.objectContaining({ id: taskId })); + }); + + test('restores a persisted download only once under StrictMode', async () => { + let resolveDownload: (() => void) | undefined; + const downloadPromise = new Promise((resolve) => { + resolveDownload = resolve; + }); + const restoredTask = { + id: 'download_A_restored', + kind: 'download' as const, + status: 'running' as const, + targetIds: ['A'], + progress: 42, + createdAt: new Date().toISOString(), + }; + + mockInvoke.mockImplementation((command) => { + if (command === 'get_background_tasks') return Promise.resolve([restoredTask]); + if (command === 'download_addon') return downloadPromise; + return Promise.resolve(); + }); + + renderHook(() => useBackgroundTasks({ + enabled: true, + downloadConcurrency: 2, + addons: {}, + knownUninstalledAddons: {}, + updateLocalState: vi.fn(), + onDownloadSuccess: vi.fn(), + onDownloadCancelled: vi.fn(), + onTaskError: vi.fn(), + }), { + wrapper: ({ children }) => {children}, + }); + + await waitFor(() => { + expect(mockInvoke.mock.calls.filter(([command]) => command === 'download_addon')).toHaveLength(1); + }); + + resolveDownload?.(); + }); +}); diff --git a/src/hooks/useBackgroundTasks.ts b/src/hooks/useBackgroundTasks.ts new file mode 100644 index 0000000..7e699e1 --- /dev/null +++ b/src/hooks/useBackgroundTasks.ts @@ -0,0 +1,650 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { Addon, BackgroundTask, DatabasePayload, Settings } from '../types/addon'; +import { fetchWorkshopDependencySnapshot, fetchWorkshopItem } from '../services/workshopClient'; + +const WORKSHOP_CRAWL_COOLDOWN_MS = 6000; + +interface UseBackgroundTasksArgs { + enabled: boolean; + downloadConcurrency: number; + addons: Record; + knownUninstalledAddons: Record; + updateLocalState: (data: DatabasePayload) => void; + onDownloadSuccess: (workshopId: string) => void; + onDownloadCancelled: (workshopId: string) => void; + onTaskError: (message: string, workshopId?: string) => void; +} + +const nowIso = () => new Date().toISOString(); +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const isNetworkError = (msg: string) => msg.includes('error decoding body') || + msg.includes('Download request failed') || + msg.includes('Download chunk failed') || + msg.includes('error sending request'); + +const taskId = (kind: BackgroundTask['kind'], targetId: string) => + `${kind}_${targetId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + +const isDatabasePayload = (value: unknown): value is DatabasePayload => ( + !!value && + typeof value === 'object' && + ( + 'addons' in value || + 'knownUninstalledAddons' in value || + 'groups' in value || + 'settings' in value + ) +); + +function trimTasks(tasks: BackgroundTask[]) { + return tasks.slice(-500); +} + +export function useBackgroundTasks({ + enabled, + downloadConcurrency, + addons, + knownUninstalledAddons, + updateLocalState, + onDownloadSuccess, + onDownloadCancelled, + onTaskError, +}: UseBackgroundTasksArgs) { + const [backgroundTasks, setBackgroundTasks] = useState([]); + const tasksRef = useRef([]); + const activeDownloadsRef = useRef(0); + const runningDownloadTaskIdsRef = useRef>(new Set()); + const crawlRunningRef = useRef(false); + const dependencyRunningRef = useRef(false); + const lastCrawlAtRef = useRef(0); + const processDownloadQueueRef = useRef<() => void>(() => {}); + const processCrawlQueueRef = useRef<() => void>(() => {}); + const processDependencyQueueRef = useRef<() => void>(() => {}); + const enqueueDependencyCheckRef = useRef<(workshopIds: string[], source?: string) => string | null>(() => null); + + const commitTasks = useCallback((tasks: BackgroundTask[]) => { + const next = trimTasks(tasks); + tasksRef.current = next; + setBackgroundTasks(next); + void invoke('save_background_task_snapshot', { tasks: next }).catch((err) => { + console.error('Failed to save background task snapshot:', err); + }); + }, []); + + const patchTask = useCallback((id: string, patch: Partial) => { + commitTasks(tasksRef.current.map((task) => { + if (task.id !== id) return task; + if (task.status === 'cancelled' && patch.status && patch.status !== 'cancelled') { + return task; + } + return { ...task, ...patch }; + })); + }, [commitTasks]); + + useEffect(() => { + let active = true; + + void invoke('get_background_tasks') + .then((tasks) => { + if (!active) return; + + const safeTasks = Array.isArray(tasks) ? tasks : []; + const restored = safeTasks.map((task) => ( + task.status === 'running' ? { ...task, status: 'queued' as const } : task + )); + commitTasks(restored); + setTimeout(() => { + if (!active) return; + processDownloadQueueRef.current(); + processCrawlQueueRef.current(); + processDependencyQueueRef.current(); + }, 0); + }) + .catch((err) => console.error('Failed to load background tasks:', err)); + + return () => { + active = false; + }; + }, [commitTasks]); + + const runDownloadTask = useCallback(async (task: BackgroundTask) => { + const workshopId = task.targetIds[0]; + if (!workshopId || runningDownloadTaskIdsRef.current.has(task.id)) return; + + runningDownloadTaskIdsRef.current.add(task.id); + activeDownloadsRef.current += 1; + patchTask(task.id, { status: 'running', progress: 0, startedAt: nowIso(), error: undefined }); + + try { + const data = await invoke('download_addon', { workshopId }); + if (isDatabasePayload(data)) { + updateLocalState(data); + } + const currentTask = tasksRef.current.find((t) => t.id === task.id); + if (currentTask?.status === 'cancelled') { + onDownloadCancelled(workshopId); + return; + } + patchTask(task.id, { status: 'completed', progress: 100, finishedAt: nowIso() }); + onDownloadSuccess(workshopId); + } catch (err) { + const message = String(err); + const currentTask = tasksRef.current.find((t) => t.id === task.id); + if (currentTask?.status === 'cancelled') { + onDownloadCancelled(workshopId); + } else { + let maxRetries = 3; + try { + const settings = await invoke('get_settings'); + maxRetries = settings.maxDownloadRetries ?? 3; + } catch (e) { + console.warn('Failed to fetch settings for retry count', e); + } + const currentRetryCount = currentTask?.retryCount || 0; + + if (isNetworkError(message) && currentRetryCount < maxRetries) { + patchTask(task.id, { + status: 'queued', + retryCount: currentRetryCount + 1, + error: undefined, + progress: 0, + startedAt: undefined, + finishedAt: undefined, + }); + } else { + patchTask(task.id, { status: 'failed', error: message, finishedAt: nowIso() }); + onTaskError(message, workshopId); + } + } + } finally { + runningDownloadTaskIdsRef.current.delete(task.id); + activeDownloadsRef.current = Math.max(0, activeDownloadsRef.current - 1); + processDownloadQueueRef.current(); + } + }, [onDownloadCancelled, onDownloadSuccess, onTaskError, patchTask, updateLocalState]); + + const processDownloadQueue = useCallback(() => { + const concurrency = Math.max(1, Math.trunc(downloadConcurrency || 1)); + while (activeDownloadsRef.current < concurrency) { + const next = tasksRef.current.find((task) => ( + task.kind === 'download' && + task.status === 'queued' && + !runningDownloadTaskIdsRef.current.has(task.id) + )); + if (!next) return; + void runDownloadTask(next); + } + }, [downloadConcurrency, runDownloadTask]); + + useEffect(() => { + processDownloadQueueRef.current = processDownloadQueue; + }, [processDownloadQueue]); + + useEffect(() => { + processDownloadQueueRef.current(); + }, [downloadConcurrency]); + + const enqueueDownloads = useCallback(( + items: (string | { workshopId: string; title?: string; imagePath?: string })[], + source = 'user' + ) => { + const normalized = items.map((item) => { + if (typeof item === 'string') { + return { workshopId: item.trim() }; + } + return { + workshopId: item.workshopId.trim(), + title: item.title, + imagePath: item.imagePath, + }; + }).filter((x) => !!x.workshopId); + + if (normalized.length === 0) return; + + const existing = new Set( + tasksRef.current + .filter((task) => task.kind === 'download' && ['queued', 'running'].includes(task.status)) + .map((task) => task.targetIds[0]) + ); + const createdAt = nowIso(); + const newTasks = normalized + .filter((item) => !existing.has(item.workshopId)) + .map((item) => { + const id = item.workshopId; + const ad = Object.values(addons).find((a) => a.workshopId === id) || + Object.values(knownUninstalledAddons).find((a) => a.workshopId === id); + const resolvedTitle = item.title || ad?.steamDetails?.title || ad?.addonInfo?.addontitle || ad?.vpkName; + const taskTitle = resolvedTitle && resolvedTitle !== id ? `${resolvedTitle} (${id})` : id; + return { + id: taskId('download', id), + kind: 'download', + status: 'queued', + source, + targetIds: [id], + progress: 0, + createdAt, + title: taskTitle, + imagePath: item.imagePath || ad?.imagePath || undefined, + }; + }); + + if (newTasks.length === 0) return; + commitTasks([...tasksRef.current, ...newTasks]); + enqueueDependencyCheckRef.current(newTasks.map((task) => task.targetIds[0]), 'download-dependency-check'); + processDownloadQueueRef.current(); + }, [addons, knownUninstalledAddons, commitTasks]); + + const runCrawlTask = useCallback(async (task: BackgroundTask) => { + const workshopId = task.targetIds[0]; + if (!workshopId) return; + + crawlRunningRef.current = true; + patchTask(task.id, { status: 'running', progress: 0, startedAt: nowIso(), error: undefined }); + + const waitMs = Math.max(0, WORKSHOP_CRAWL_COOLDOWN_MS - (Date.now() - lastCrawlAtRef.current)); + if (waitMs > 0) { + await wait(waitMs); + } + + try { + lastCrawlAtRef.current = Date.now(); + const itemResult = await fetchWorkshopItem(workshopId); + const data = await invoke('record_workshop_items_seen', { + items: [itemResult.item], + source: task.source || 'background-refresh', + }); + if (isDatabasePayload(data)) { + updateLocalState(data); + } + patchTask(task.id, { status: 'completed', progress: 100, finishedAt: nowIso() }); + } catch (err) { + const message = String(err); + patchTask(task.id, { status: 'failed', error: message, finishedAt: nowIso() }); + console.error('Background workshop crawl failed:', message); + } finally { + crawlRunningRef.current = false; + processCrawlQueueRef.current(); + } + }, [patchTask, updateLocalState]); + + const processCrawlQueue = useCallback(() => { + if (crawlRunningRef.current) return; + const next = tasksRef.current.find((task) => task.kind === 'workshop-crawl' && task.status === 'queued'); + if (!next) return; + void runCrawlTask(next); + }, [runCrawlTask]); + + useEffect(() => { + processCrawlQueueRef.current = processCrawlQueue; + }, [processCrawlQueue]); + + useEffect(() => { + if (!enabled) return; + + const knownWorkshopIds = new Set( + [...Object.values(addons), ...Object.values(knownUninstalledAddons)] + .map((addon) => addon.workshopId?.trim()) + .filter((id): id is string => !!id) + ); + + const filteredTasks = tasksRef.current.filter((task) => ( + task.kind !== 'workshop-crawl' || + task.status === 'running' || + knownWorkshopIds.has(task.targetIds[0]?.trim() || '') + )); + + if (filteredTasks.length !== tasksRef.current.length) { + commitTasks(filteredTasks); + } + }, [addons, commitTasks, enabled, knownUninstalledAddons]); + + const enqueueWorkshopCrawl = useCallback((workshopIds: string[], source = 'background-refresh') => { + const cleanIds = [...new Set(workshopIds.map((id) => id.trim()).filter(Boolean))]; + if (cleanIds.length === 0) return; + + const existing = new Set( + tasksRef.current + .filter((task) => task.kind === 'workshop-crawl' && ['queued', 'running'].includes(task.status)) + .map((task) => task.targetIds[0]) + ); + const createdAt = nowIso(); + const newTasks = cleanIds + .filter((id) => !existing.has(id)) + .map((id) => { + const ad = Object.values(addons).find((a) => a.workshopId === id) || + Object.values(knownUninstalledAddons).find((a) => a.workshopId === id); + const resolvedTitle = ad?.steamDetails?.title || ad?.addonInfo?.addontitle || ad?.vpkName; + const taskTitle = resolvedTitle && resolvedTitle !== id ? `${resolvedTitle} (${id})` : id; + return { + id: taskId('workshop-crawl', id), + kind: 'workshop-crawl', + status: 'queued', + source, + targetIds: [id], + progress: 0, + createdAt, + title: taskTitle, + imagePath: ad?.imagePath || undefined, + }; + }); + + if (newTasks.length === 0) return; + commitTasks([...tasksRef.current, ...newTasks]); + processCrawlQueueRef.current(); + }, [addons, knownUninstalledAddons, commitTasks]); + + const runDependencyCheckTask = useCallback(async (task: BackgroundTask) => { + const rootIds = task.dependencyCheck?.rootIds?.length + ? task.dependencyCheck.rootIds + : task.targetIds; + const seedIds = task.dependencyCheck?.seedIds?.length + ? task.dependencyCheck.seedIds + : rootIds; + if (seedIds.length === 0) return; + + dependencyRunningRef.current = true; + const pending = [...new Set(seedIds.map((id) => id.trim()).filter(Boolean))]; + const visited = new Set(); + const failedNodes: { workshopId: string; error: string }[] = []; + const discoveredDependencies = new Map(); + let completedCount = 0; + let latestData: DatabasePayload | null = null; + patchTask(task.id, { + status: 'running', + progress: 0, + startedAt: nowIso(), + finishedAt: undefined, + error: undefined, + dependencyCheck: { + rootIds, + seedIds, + discoveredCount: pending.length, + completedCount: 0, + failedNodes: [], + discoveredDependencies: [], + }, + }); + + try { + while (pending.length > 0) { + const currentTask = tasksRef.current.find((candidate) => candidate.id === task.id); + if (currentTask?.status === 'cancelled') return; + + const workshopId = pending.shift()!; + if (visited.has(workshopId)) continue; + visited.add(workshopId); + + try { + const details = await fetchWorkshopDependencySnapshot(workshopId); + const data = await invoke('persist_workshop_page_details', { + workshopId, + details, + source: 'dependency-check', + }); + if (isDatabasePayload(data)) { + latestData = data; + } + + const currentItem = discoveredDependencies.get(workshopId); + discoveredDependencies.set(workshopId, { + workshopId, + title: details.title?.trim() || currentItem?.title, + previewUrl: details.previewUrl?.trim() || currentItem?.previewUrl, + creatorName: details.creatorName?.trim() || currentItem?.creatorName, + }); + + for (const dependency of details.requiredItems || []) { + const dependencyId = dependency.workshopId?.trim(); + if (dependencyId) { + const existing = discoveredDependencies.get(dependencyId); + discoveredDependencies.set(dependencyId, { + workshopId: dependencyId, + title: existing?.title || dependency.title?.trim() || undefined, + previewUrl: existing?.previewUrl, + creatorName: existing?.creatorName, + }); + } + if (dependencyId && !visited.has(dependencyId) && !pending.includes(dependencyId)) { + pending.push(dependencyId); + } + } + } catch (err) { + failedNodes.push({ workshopId, error: String(err) }); + } + + completedCount += 1; + const discoveredCount = visited.size + pending.length; + const currentProgress = tasksRef.current.find((candidate) => candidate.id === task.id)?.progress || 0; + patchTask(task.id, { + progress: Math.max(currentProgress, Math.min(99, Math.ceil((completedCount / Math.max(discoveredCount, 1)) * 100))), + dependencyCheck: { + rootIds, + seedIds, + discoveredCount, + completedCount, + failedNodes: [...failedNodes], + discoveredDependencies: [...discoveredDependencies.values()], + }, + }); + } + + if (latestData) { + updateLocalState(latestData); + } + const currentTask = tasksRef.current.find((candidate) => candidate.id === task.id); + if (currentTask?.status !== 'cancelled') { + patchTask(task.id, { + status: failedNodes.length > 0 ? 'failed' : 'completed', + progress: 100, + finishedAt: nowIso(), + error: failedNodes.length > 0 ? `${failedNodes.length} dependency node(s) could not be resolved` : undefined, + dependencyCheck: { + rootIds, + seedIds, + discoveredCount: visited.size, + completedCount, + failedNodes, + discoveredDependencies: [...discoveredDependencies.values()], + }, + }); + } + } finally { + dependencyRunningRef.current = false; + processDependencyQueueRef.current(); + } + }, [patchTask, updateLocalState]); + + const processDependencyQueue = useCallback(() => { + if (dependencyRunningRef.current) return; + const next = tasksRef.current.find((task) => task.kind === 'dependency-check' && task.status === 'queued'); + if (next) { + void runDependencyCheckTask(next); + } + }, [runDependencyCheckTask]); + + useEffect(() => { + processDependencyQueueRef.current = processDependencyQueue; + }, [processDependencyQueue]); + + const enqueueDependencyCheck = useCallback((workshopIds: string[], source = 'manual-dependency-check'): string | null => { + const rootIds = [...new Set(workshopIds.map((id) => id.trim()).filter(Boolean))]; + if (rootIds.length === 0) return null; + + const activeRootIds = new Set( + tasksRef.current + .filter((task) => task.kind === 'dependency-check' && ['queued', 'running'].includes(task.status)) + .flatMap((task) => task.dependencyCheck?.rootIds || task.targetIds), + ); + const newRootIds = rootIds.filter((id) => !activeRootIds.has(id)); + if (newRootIds.length === 0) return null; + + const id = taskId('dependency-check', newRootIds[0]); + const task: BackgroundTask = { + id, + kind: 'dependency-check', + status: 'queued', + source, + targetIds: newRootIds, + progress: 0, + createdAt: nowIso(), + title: newRootIds.length === 1 + ? `Dependency check (${newRootIds[0]})` + : `Dependency check (${newRootIds.length} items)`, + dependencyCheck: { + rootIds: newRootIds, + seedIds: newRootIds, + discoveredCount: newRootIds.length, + completedCount: 0, + failedNodes: [], + discoveredDependencies: [], + }, + }; + commitTasks([...tasksRef.current, task]); + processDependencyQueueRef.current(); + return id; + }, [commitTasks]); + + useEffect(() => { + enqueueDependencyCheckRef.current = enqueueDependencyCheck; + }, [enqueueDependencyCheck]); + + const recordSeenItems = useCallback(async (items: any[], source = 'workshop-browser') => { + if (items.length === 0) return; + try { + const data = await invoke('record_workshop_items_seen', { items, source }); + if (isDatabasePayload(data)) { + updateLocalState(data); + } + } catch (err) { + console.error('Failed to record seen workshop items:', err); + } + }, [updateLocalState]); + + const upsertWarningTask = useCallback((warning: { + id: string; + source?: string; + targetIds?: string[]; + title?: string; + error?: string; + imagePath?: string; + }) => { + const existing = tasksRef.current.find((task) => task.id === warning.id); + const nextTask: BackgroundTask = { + id: warning.id, + kind: 'warning', + status: 'failed', + source: warning.source, + targetIds: warning.targetIds || [], + progress: 100, + createdAt: existing?.createdAt || nowIso(), + finishedAt: nowIso(), + title: warning.title, + error: warning.error, + imagePath: warning.imagePath, + }; + + if (existing) { + commitTasks(tasksRef.current.map((task) => task.id === warning.id ? nextTask : task)); + return; + } + + commitTasks([...tasksRef.current, nextTask]); + }, [commitTasks]); + + const cancelTask = useCallback((id: string) => { + const task = tasksRef.current.find((t) => t.id === id); + if (!task) return; + + patchTask(id, { status: 'cancelled', finishedAt: nowIso() }); + + if (task.kind === 'download') { + const workshopId = task.targetIds[0]; + if (workshopId) { + onDownloadCancelled(workshopId); + if (task.status === 'running') { + void invoke('cancel_download', { workshopId }).catch((err) => { + console.error('Failed to cancel download:', err); + }); + } + } + } + + if (task.status !== 'queued') return; + + if (task.kind === 'download') { + setTimeout(() => processDownloadQueueRef.current(), 0); + } else if (task.kind === 'workshop-crawl') { + setTimeout(() => processCrawlQueueRef.current(), 0); + } else if (task.kind === 'dependency-check') { + setTimeout(() => processDependencyQueueRef.current(), 0); + } + }, [onDownloadCancelled, patchTask]); + + const retryTask = useCallback((id: string) => { + const task = tasksRef.current.find((t) => t.id === id); + if (!task) return; + + const retryIds = task.kind === 'dependency-check' + ? task.dependencyCheck?.failedNodes.map((node) => node.workshopId).filter(Boolean) + : undefined; + patchTask(id, { + status: 'queued', + progress: 0, + error: undefined, + createdAt: nowIso(), + startedAt: undefined, + finishedAt: undefined, + dependencyCheck: task.kind === 'dependency-check' + ? { + rootIds: task.dependencyCheck?.rootIds || task.targetIds, + seedIds: retryIds?.length ? retryIds : task.dependencyCheck?.rootIds || task.targetIds, + discoveredCount: retryIds?.length || task.dependencyCheck?.rootIds?.length || task.targetIds.length, + completedCount: 0, + failedNodes: [], + discoveredDependencies: [], + } + : task.dependencyCheck, + }); + + if (task.kind === 'download') { + setTimeout(() => processDownloadQueueRef.current(), 0); + } else if (task.kind === 'workshop-crawl') { + setTimeout(() => processCrawlQueueRef.current(), 0); + } else if (task.kind === 'dependency-check') { + setTimeout(() => processDependencyQueueRef.current(), 0); + } + }, [patchTask]); + + const removeTask = useCallback((id: string) => { + const task = tasksRef.current.find((candidate) => candidate.id === id); + if (!task) return; + + commitTasks(tasksRef.current.filter((candidate) => candidate.id !== id)); + }, [commitTasks]); + + const clearFinishedTasks = useCallback(() => { + const activeTasks = tasksRef.current.filter( + (task) => task.status === 'queued' || task.status === 'running' + ); + commitTasks(activeTasks); + }, [commitTasks]); + + return { + backgroundTasks, + enqueueDownloads, + enqueueWorkshopCrawl, + enqueueDependencyCheck, + recordSeenItems, + upsertWarningTask, + cancelTask, + retryTask, + removeTask, + clearFinishedTasks, + }; +} diff --git a/src/i18n.tsx b/src/i18n.tsx index 11ce356..6847324 100644 --- a/src/i18n.tsx +++ b/src/i18n.tsx @@ -1,646 +1,20 @@ -import React from 'react'; import i18n from 'i18next'; -import { initReactI18next, useTranslation } from 'react-i18next'; +import { initReactI18next } from 'react-i18next'; -const zh = { - common: { - confirm: "确定", - cancel: "取消", - save: "保存", - saving: "正在保存...", - create: "创建", - creating: "正在创建...", - rename: "重命名", - renaming: "正在重命名...", - loading: "加载中...", - parsing: "正在解析 VPK 文件和加载数据中...", - all: "全部", - other: "其他", - vpkCount: "{{count}} 个文件", - emptyStateTitle: "未发现匹配的附加组件", - emptyStateDesc: "在此筛选条件下未找到任何文件。请确认你的目录中已经放有 L4D2 附件文件。", - rescan: "重新扫描", - scanning: "正在扫描...", - error: "错误", - success: "成功" - }, - categories: { - All: "全部", - Campaign: "战役", - Survivor: "幸存者", - "Weapon Model": "武器模型", - Script: "脚本", - Map: "地图", - Skin: "皮肤", - "Sound/Music": "声音/音乐", - Infected: "感染者", - "UI/Textures": "UI/贴图", - Other: "其他" - }, - sidebar: { - title: "求生之路2附加组件管理", - allAddons: "全部组件", - manualInstall: "手动安装 (Addons)", - workshopDir: "创意工坊目录 (Workshop)", - disabledAddons: "被禁用的组件 (.disabled)", - myGroups: "我的分组", - noGroups: "暂无分组,可自动归类或手动创建。", - newGroup: "新建手动分组", - classifying: "归类中...", - autoClassify: "自动识别战役并归类", - settings: "设置" - }, - topbar: { - searchPlaceholder: "搜索组件名称, 描述, 作者或创意工坊 ID...", - sortByTitle: "按名称排序", - sortBySize: "按大小排序", - sortById: "按创意工坊ID排序", - exitBatch: "退出批量", - batchManage: "批量管理", - batchManageTooltip: "批量管理附件组件", - refreshTooltip: "刷新所有组件并同步创意工坊信息" - }, - addonCard: { - deselect: "取消选择", - select: "选中此组件", - enabled: "已启用", - disabled: "已禁用", - manualInstall: "手动安装", - workshop: "创意工坊", - author: "作者: {{author}}", - group: "分组: {{group}}", - fileSize: "文件大小: {{size}}", - containsFiles: "内含 {{count}} 个文件", - clickToDisable: "点击禁用该附件", - clickToEnable: "点击启用该附件", - addOrRemoveGroup: "加入或移出群组", - removeFromGroup: "从当前分组移出 ({{name}})", - noGroupsTooltip: "无分组 (在侧栏创建)", - openLink: "打开链接", - openInSteam: "在 Steam 客户端打开", - openInBrowser: "在浏览器中打开 (官方)", - openInMirror: "在浏览器中打开 (国内镜像)", - openBuiltInSource: "打开组件内置来源网页", - moveToManual: "移动到手动安装目录 (Addons)", - renameAddon: "重命名附件文件" - }, - batchActionBar: { - selectedCount: "已选择 {{count}} 个组件", - deselectAll: "取消全选", - selectAll: "全选", - enable: "启用", - disable: "禁用", - moveToManual: "移动至手动安装", - autoRename: "自动重命名", - addToGroup: "加入分组", - noGroups: "无分组", - exitBatch: "退出批量" - }, - groupCard: { - partiallyEnabled: "部分启用", - enabled: "已启用", - disabled: "已禁用", - groupBadge: "分组 ({{count}})", - author: "作者: {{author}}", - authorMultiple: "作者: {{first}} 等 {{count}} 位作者", - containsAddons: "包含 {{count}} 个组件:", - totalSize: "总大小: {{size}}", - toggleAll: "启用/禁用此分组内所有组件", - viewDetails: "查看分组详情", - deselectGroup: "取消选择整个分组", - selectGroup: "选中整个分组" - }, - groupHeader: { - desc: "此分组包含 {{count}} 个附件文件,可进行批量管理。", - renameGroup: "重命名分组", - deleteGroup: "解散分组", - enableAll: "全部启用", - disableAll: "全部禁用", - moveToManualAll: "全部移动至手动安装" - }, - groupModal: { - title: "创建新附件分组", - desc: "将多个 VPK 文件打包到同一个群组(例如一张地图的 Part 1、2、3),从而一键批量启用、禁用或移动。", - groupNameLabel: "分组名称:", - groupNamePlaceholder: "例如:Early Days 战役地图包", - selectAddonsLabel: "选择要加入群组的组件 (可多选):", - createGroupBtn: "创建分组" - }, - editGroupModal: { - title: "重命名分组", - newNameLabel: "分组新名称:" - }, - linkConfirmModal: { - title: "即将访问外部网站", - desc: [ - "您点击的链接指向第三方外部网页:", - "请确保您信任该网站,以防止钓鱼和恶意软件威胁。是否继续前往?" - ], - continue: "继续访问" - }, - renameModal: { - title: "重命名附加组件文件", - desc: "修改底层的 VPK 文件名称。建议使用有意义的标题,防止由于一堆数字无法辨认。", - workshopTitle: "创意工坊标题:", - applyWorkshopTitle: "应用创意工坊标题作为文件名", - currentNameLabel: "当前文件名:", - newNameLabel: "新文件名 (.vpk):" - }, - confirmModal: { - deleteGroupTitle: "确认删除分组", - deleteGroupMsg: "确定要删除这个分组吗?这不会删除文件,只会解散群组。", - batchRenameTitle: "批量自动重命名", - batchRenameMsg: "确定要自动重命名选中的 {{count}} 个附件吗?系统将根据创意工坊标题 and 分组信息自动为它们命名。", - batchMoveTitle: "移动创意工坊附件提示", - batchMoveMsg: [ - "确定要将选中的 {{count}} 个创意工坊附件移动到手动安装目录吗?移动后建议前往 Steam 创意工坊取消订阅这些附件,否则 Steam 会在下次启动游戏时重新下载。", - "...或者 前往 设置 > 实验性 > 创意工坊检测绕过", - "在不取消订阅的情况下移动创意工坊物品。" - ] - }, - detailModal: { - title: "附加组件详情", - fileName: "文件名称", - addonSize: "组件大小", - filesInVpk: "包内文件", - directory: "所在目录", - manualInstall: "手动安装 (Addons)", - workshop: "创意工坊 (Workshop)", - currentStatus: "当前状态", - enabled: "已启用 (加载中)", - disabled: "已禁用", - workshopId: "创意工坊 ID", - relatedLink: "相关链接", - clickToVisit: "点击访问", - author: "作者: {{author}}", - viewSteamProfile: "查看 Steam 档案", - version: "版本: {{version}}", - belongsToGroup: "所属分组: {{name}}", - descriptionLabel: "组件描述:", - openInSteam: "在 Steam 打开", - webViewOfficial: "网页查看 (官方)", - mirrorWebView: "国内网页镜像", - visitSource: "访问来源网页", - disableAddon: "禁用组件", - enableAddon: "启用组件", - moveToManual: "移动到手动安装" - }, - moveWarningModal: { - title: "移动创意工坊附件提示", - veryImportant: "非常重要:", - warningDesc: [ - "您正在将组件从 创意工坊目录 移动到 手动安装目录 (Addons) 。", - "移动后,请务必在 Steam 客户端或网页中“取消订阅”该组件!", - "如果不取消订阅,每次您启动游戏时,Steam 客户端都可能会重新下载该组件,导致加载目录和创意工坊目录下同时存在两个相同的文件,引发资源冲突或重复加载。", - "...或者 前往 设置 > 实验性 > 创意工坊检测绕过", - "在不取消订阅的情况下移动创意工坊物品。" - ], - unsubscribeAndMove: "取消订阅并移动", - moveDirectly: "直接移动" - }, - workshopWarningModal: { - title: "创意工坊附件已移动", - successTitle: "自动转移成功", - warningDesc: [ - "为了防止游戏重新下载覆盖,执行 {{actionName}} 操作前,已自动将相关的创意工坊附件移动至手动安装目录 (Addons)。", - "请注意:", - "您必须前往 Steam 创意工坊取消订阅此组件,否则游戏下次启动时仍会重复下载该附件并导致冲突!" - ], - dontShowAgain: "当前会话不再提醒此警告,并静默执行移动", - iUnderstand: "我知道了", - goToWorkshop: "前往创意工坊" - }, - settings: { - pathSettings: "路径设置", - experimental: "实验性", - about: "关于软件", - language: "语言设置", - languageTitle: "界面语言设置", - languageDesc: "选择应用程序的用户界面显示语言。", - title: "游戏与目录配置", - desc: "请配置求生之路2的游戏附加组件目录(即 `addons` 文件夹)。程序将自动访问该文件夹及其下的 `workshop` 创意工坊文件夹,并解析和管理所有的 VPK 附件文件。", - addonsPathLabel: "附加组件目录 (Addons 路径):", - addonsPathPlaceholder: "例如: C:\\Program Files (x86)\\Steam\\steamapps\\common\\Left 4 Dead 2\\left4dead2\\addons", - addonsPathHelp: "请选择游戏目录下的 `left4dead2/addons` 文件夹。保存后程序将自动开始扫描该目录。", - saveAndRescan: "保存并重新扫描", - savingAndScanning: "正在保存并扫描...", - experimentalTitle: "实验性功能", - experimentalDesc: "此处的选项处于实验性阶段。启用可能会对文件目录结构做出调整,请谨慎开启。", - dummyBypassTitle: "创意工坊检测绕过", - dummyBypassDesc: [ - "开启后,将 addon 从 workshop 移出时,将在 workshop 目录下自动生成一个 dummy addon(仅保留附件图片、原始 AppID 及版本号,将标题标记为原名 (L4A Dummy) 且说明改为由 L4A 生成),以试图绕过 L4D2 创意工坊订阅同步检测。", - "请注意:创意工坊更新可能会导致新旧版本的 addon 同时加载并冲突。" - ], - aboutTitle: "关于 Left 4 Addons", - aboutDesc: "一个专为《求生之路2》(Left 4 Dead 2)设计的附加组件(Addons)管理器。采用 Tauri + React + Rust 驱动,旨在为玩家提供极速、优雅的 VPK 文件管理体验。", - featuresTitle: "主要功能", - features: [ - "一键启用/禁用:快速重命名 `.vpk` 文件以在游戏中生效或失效。", - "创意工坊同步:自动拉取并缓存创意工坊组件的封面图、标题 and 作者详情。", - "分组管理:将多 Part 地图或关联组件组合,实现一键批量操作。", - "自动识别:内置战役/地图包识别算法,自动检测并对关联附件进行重组。", - "物理隔离:一键将工坊文件转移到本地加载文件夹,防止游戏联机订阅冲突。" - ], - licenseTitle: "开源许可", - licenseDesc: "本项目遵循 MIT 协议开源。", - languageLabel: "界面语言 (Language):" - }, - settingsModal: { - title: "设置附加组件加载路径" - }, - statsBar: { - totalCount: "总附件数", - enabled: "已启用", - disabled: "已禁用", - totalDisk: "组件总大小" - }, - toasts: { - autoMoveFailed: "自动移动附件失败: {{err}}", - dbRefreshSuccess: "数据库刷新成功", - dataLoadFailed: "数据加载失败: {{err}}", - addonEnabled: "附加组件已启用", - addonDisabled: "附加组件已禁用", - operationFailed: "操作失败: {{err}}", - groupEnabled: "分组内所有组件已启用", - groupDisabled: "分组内所有组件已禁用", - moveSuccessLoading: "已移动到手动安装目录", - moveSuccessWorkshop: "已移动到创意工坊目录", - moveFailed: "移动失败: {{err}}", - steamSyncSuccess: "Steam 同步成功", - steamSyncFailed: "同步失败: {{err}}", - autoClassifySuccess: "自动归类完成!已发现并组合了战役/地图包。", - autoClassifyFailed: "自动归类失败: {{err}}", - settingsSaveSuccess: "设置保存并扫描成功", - settingsSaveFailed: "保存失败: {{err}}", - renameSuccess: "重命名成功", - renameFailed: "重命名失败: {{err}}", - createGroupSuccess: "新建分组成功", - createGroupFailed: "创建分组失败: {{err}}", - deleteGroupSuccess: "分组已删除", - deleteGroupFailed: "删除失败: {{err}}", - renameGroupSuccess: "分组已重命名", - removeFromGroupSuccess: "已移出该分组", - addToGroupSuccess: "已加入分组", - batchToggleSuccessEnable: "批量启用成功", - batchToggleSuccessDisable: "批量禁用成功", - batchMoveSuccess: "已成功批量移动到手动安装目录", - batchRenameNoNeed: "选中的组件已是推荐文件名,无需重命名", - batchRenameSuccess: "批量重命名成功 (重命名了 {{count}} 个文件)", - batchAddGroupSuccess: "批量加入分组成功" - } -}; +import en from './locales/en.json'; +import zh from './locales/zh.json'; -const en = { - common: { - confirm: "Confirm", - cancel: "Cancel", - save: "Save", - saving: "Saving...", - create: "Create", - creating: "Creating...", - rename: "Rename", - renaming: "Renaming...", - loading: "Loading...", - parsing: "Parsing VPK files and loading data...", - all: "All", - other: "Other", - vpkCount: "{{count}} files", - emptyStateTitle: "No matching addons found", - emptyStateDesc: "No files found under this filter. Please make sure you have L4D2 addon files in your folder.", - rescan: "Rescan", - scanning: "Scanning...", - error: "Error", - success: "Success" - }, - categories: { - All: "All", - Campaign: "Campaign", - Survivor: "Survivor", - "Weapon Model": "Weapon Model", - Script: "Script", - Map: "Map", - Skin: "Skin", - "Sound/Music": "Sound/Music", - Infected: "Infected", - "UI/Textures": "UI/Textures", - Other: "Other" - }, - sidebar: { - title: "Left 4 Addons", - allAddons: "All Addons", - manualInstall: "Manual (Addons)", - workshopDir: "Workshop (Workshop)", - disabledAddons: "Disabled (.disabled)", - myGroups: "My Groups", - noGroups: "No groups yet. You can auto-classify or create manually.", - newGroup: "New Manual Group", - classifying: "Classifying...", - autoClassify: "Auto-Identify & Classify", - settings: "Settings" - }, - topbar: { - searchPlaceholder: "Search addon name, description, author or workshop ID...", - sortByTitle: "Sort by Name", - sortBySize: "Sort by Size", - sortById: "Sort by Workshop ID", - exitBatch: "Exit Batch", - batchManage: "Batch Manage", - batchManageTooltip: "Batch manage addon components", - refreshTooltip: "Refresh all addons and sync workshop info" - }, - addonCard: { - deselect: "Deselect", - select: "Select this addon", - enabled: "Enabled", - disabled: "Disabled", - manualInstall: "Manual", - workshop: "Workshop", - author: "Author: {{author}}", - group: "Group: {{group}}", - fileSize: "Size: {{size}}", - containsFiles: "Contains {{count}} files", - clickToDisable: "Click to disable", - clickToEnable: "Click to enable", - addOrRemoveGroup: "Add to or Remove from Group", - removeFromGroup: "Remove from Group ({{name}})", - noGroupsTooltip: "No groups (create in sidebar)", - openLink: "Open Link", - openInSteam: "Open in Steam Client", - openInBrowser: "Open in Browser (Official)", - openInMirror: "Open in Browser (Mirror)", - openBuiltInSource: "Open built-in source page", - moveToManual: "Move to Manual (Addons)", - renameAddon: "Rename Addon File" +i18n.use(initReactI18next).init({ + resources: { + zh: { translation: zh }, + en: { translation: en }, }, - batchActionBar: { - selectedCount: "{{count}} addons selected", - deselectAll: "Deselect All", - selectAll: "Select All", - enable: "Enable", - disable: "Disable", - moveToManual: "Move to Manual", - autoRename: "Auto Rename", - addToGroup: "Add to Group", - noGroups: "No Groups", - exitBatch: "Exit Batch" + lng: localStorage.getItem('i18n_lang') || 'zh', + fallbackLng: 'zh', + interpolation: { + escapeValue: false, }, - groupCard: { - partiallyEnabled: "Partially Enabled", - enabled: "Enabled", - disabled: "Disabled", - groupBadge: "Group ({{count}})", - author: "Author: {{author}}", - authorMultiple: "Author: {{first}} and {{count}} others", - containsAddons: "Contains {{count}} addons:", - totalSize: "Total Size: {{size}}", - toggleAll: "Enable/Disable all addons in group", - viewDetails: "View Group Details", - deselectGroup: "Deselect whole group", - selectGroup: "Select whole group" - }, - groupHeader: { - desc: "This group contains {{count}} addon files, allowing batch management.", - renameGroup: "Rename Group", - deleteGroup: "Delete Group", - enableAll: "Enable All", - disableAll: "Disable All", - moveToManualAll: "Move All to Manual" - }, - groupModal: { - title: "Create New Addon Group", - desc: "Bundle multiple VPK files into the same group (e.g. Part 1, 2, 3 of a campaign map) for one-click batch operations.", - groupNameLabel: "Group Name:", - groupNamePlaceholder: "e.g. Early Days Campaign Pack", - selectAddonsLabel: "Select addons to add (multi-select):", - createGroupBtn: "Create Group" - }, - editGroupModal: { - title: "Rename Group", - newNameLabel: "New Group Name:" - }, - linkConfirmModal: { - title: "About to visit external website", - desc: [ - "The link you clicked points to a third-party external webpage:", - "Please ensure you trust this website to prevent phishing and malware threats. Do you want to continue?" - ], - continue: "Continue" - }, - renameModal: { - title: "Rename Addon File", - desc: "Modify the underlying VPK file name. Using meaningful titles is recommended to prevent unrecognizable filenames with long numbers.", - workshopTitle: "Workshop Title:", - applyWorkshopTitle: "Apply Workshop Title as Filename", - currentNameLabel: "Current Filename:", - newNameLabel: "New Filename (.vpk):" - }, - confirmModal: { - deleteGroupTitle: "Confirm Delete Group", - deleteGroupMsg: "Are you sure you want to delete this group? This will not delete the files, only dissolve the group.", - batchRenameTitle: "Batch Auto Rename", - batchRenameMsg: "Are you sure you want to auto rename the selected {{count}} addons? The system will name them based on workshop title and group info.", - batchMoveTitle: "Move Workshop Addon Warning", - batchMoveMsg: [ - "Are you sure you want to move the selected {{count}} workshop addons to the manual folder? After moving, it is recommended to unsubscribe from them in Steam, otherwise Steam will redownload them on next startup.", - "...or go to Settings > Experimental > Workshop Detection Bypass", - "to move workshop items without unsubscribing." - ] - }, - detailModal: { - title: "Addon Details", - fileName: "File Name", - addonSize: "Addon Size", - filesInVpk: "Files in VPK", - directory: "Directory", - manualInstall: "Manual (Addons)", - workshop: "Workshop (Workshop)", - currentStatus: "Current Status", - enabled: "Enabled (Loading)", - disabled: "Disabled", - workshopId: "Workshop ID", - relatedLink: "Related Link", - clickToVisit: "Click to visit", - author: "Author: {{author}}", - viewSteamProfile: "View Steam Profile", - version: "Version: {{version}}", - belongsToGroup: "Group: {{name}}", - descriptionLabel: "Description:", - openInSteam: "Open in Steam", - webViewOfficial: "Web View (Official)", - mirrorWebView: "Mirror Web View", - visitSource: "Visit Source Webpage", - disableAddon: "Disable Addon", - enableAddon: "Enable Addon", - moveToManual: "Move to Manual" - }, - moveWarningModal: { - title: "Move Workshop Addon Warning", - veryImportant: "Very Important:", - warningDesc: [ - "You are moving the addon from Workshop Directory to Manual Installation Directory (Addons).", - "After moving, please make sure to unsubscribe from this addon in Steam!", - "If you do not unsubscribe, Steam may redownload this addon every time you launch the game, resulting in identical files existing in both directories, causing resource conflicts or duplicate loading.", - "...or go to Settings > Experimental > Workshop Detection Bypass", - "to move workshop items without unsubscribing." - ], - unsubscribeAndMove: "Unsubscribe & Move", - moveDirectly: "Move Directly" - }, - workshopWarningModal: { - title: "Workshop Addon Moved", - successTitle: "Auto Transfer Successful", - warningDesc: [ - "To prevent the game from re-downloading and overwriting, before executing {{actionName}}, the related workshop addon has been automatically moved to Manual Installation Directory (Addons).", - "Please note:", - "You must go to the Steam Workshop to unsubscribe from this addon, otherwise the game will duplicate download the addon on next startup, causing conflicts!" - ], - dontShowAgain: "Do not show this warning again in the current session, and silently move", - iUnderstand: "I Understand", - goToWorkshop: "Go to Workshop" - }, - settings: { - pathSettings: "Path Settings", - experimental: "Experimental", - about: "About", - language: "Language", - languageTitle: "Interface Language Settings", - languageDesc: "Choose the display language for the application user interface.", - title: "Game & Directory Configuration", - desc: "Please configure your Left 4 Dead 2 game addons folder. The program will automatically access the addons folder and its workshop folder to parse and manage all VPK files.", - addonsPathLabel: "Addon Folder Path (Addons path):", - addonsPathPlaceholder: "e.g., C:\\Program Files (x86)\\Steam\\steamapps\\common\\Left 4 Dead 2\\left4dead2\\addons", - addonsPathHelp: "Please select the `left4dead2/addons` directory. The program will scan the directory immediately after saving.", - saveAndRescan: "Save and Rescan", - savingAndScanning: "Saving and Scanning...", - experimentalTitle: "Experimental Features", - experimentalDesc: "The options here are in experimental phase. Enabling them may adjust the directory structure. Please use with caution.", - dummyBypassTitle: "Workshop Detection Bypass", - dummyBypassDesc: [ - "When enabled, moving an addon out of the workshop folder will automatically generate a dummy addon in the workshop directory (keeping only thumbnail, original AppID, version, prefixing name with (L4A Dummy) and marking generated by L4A) to attempt to bypass Steam Workshop synchronization checks.", - "Please note: Workshop updates might cause duplicate loads and conflicts between old and new versions." - ], - aboutTitle: "About Left 4 Addons", - aboutDesc: "A dedicated addons manager designed for Left 4 Dead 2. Powered by Tauri + React + Rust, it aims to provide players with a super fast and elegant VPK file management experience.", - featuresTitle: "Key Features", - features: [ - "One-click Enable/Disable: Quickly rename `.vpk` files to enable/disable them in game.", - "Workshop Sync: Auto fetch and cache workshop cover photos, titles, and author details.", - "Group Management: Combine multi-part maps or associated addons for one-click batch operations.", - "Auto Identification: Built-in algorithm to identify campaigns/map packs and regroup them.", - "Physical Isolation: One-click transfer of workshop files to local addons folder to prevent subscription conflicts." - ], - licenseTitle: "Open Source License", - licenseDesc: "This project is open source under the MIT License.", - languageLabel: "Interface Language:" - }, - settingsModal: { - title: "Configure Addon Directory Path" - }, - statsBar: { - totalCount: "Total Addons", - enabled: "Enabled", - disabled: "Disabled", - totalDisk: "Total Addons Size" - }, - toasts: { - autoMoveFailed: "Auto move failed: {{err}}", - dbRefreshSuccess: "Database refreshed successfully", - dataLoadFailed: "Data load failed: {{err}}", - addonEnabled: "Addon enabled", - addonDisabled: "Addon disabled", - operationFailed: "Operation failed: {{err}}", - groupEnabled: "All addons in the group enabled", - groupDisabled: "All addons in the group disabled", - moveSuccessLoading: "Moved to manual addons directory", - moveSuccessWorkshop: "Moved to workshop directory", - moveFailed: "Move failed: {{err}}", - steamSyncSuccess: "Steam sync successful", - steamSyncFailed: "Steam sync failed: {{err}}", - autoClassifySuccess: "Auto classification complete! Campaigns/map packs regrouped.", - autoClassifyFailed: "Auto classification failed: {{err}}", - settingsSaveSuccess: "Settings saved and directory rescanned successfully", - settingsSaveFailed: "Save failed: {{err}}", - renameSuccess: "Rename successful", - renameFailed: "Rename failed: {{err}}", - createGroupSuccess: "Group created successfully", - createGroupFailed: "Create group failed: {{err}}", - deleteGroupSuccess: "Group deleted", - deleteGroupFailed: "Delete failed: {{err}}", - renameGroupSuccess: "Group renamed successfully", - removeFromGroupSuccess: "Removed from group", - addToGroupSuccess: "Added to group", - batchToggleSuccessEnable: "Batch enable successful", - batchToggleSuccessDisable: "Batch disable successful", - batchMoveSuccess: "Batch moved to manual addons directory successfully", - batchRenameNoNeed: "Selected addons are already in recommended filename format, no rename needed", - batchRenameSuccess: "Batch rename successful (renamed {{count}} files)", - batchAddGroupSuccess: "Batch added to group successfully" - } -}; - -i18n - .use(initReactI18next) - .init({ - resources: { - zh: { translation: zh }, - en: { translation: en } - }, - lng: localStorage.getItem('i18n_lang') || 'zh', - fallbackLng: 'zh', - interpolation: { - escapeValue: false - }, - returnObjects: true - }); + returnObjects: true, +}); export default i18n; - -// i18n Helper component to render multi-line descriptions/warnings securely using array notation (without using \n) -interface TransHTMLProps { - i18nKey: string; - values?: Record; - className?: string; - style?: React.CSSProperties; -} - -export const TransHTML: React.FC = ({ i18nKey, values, className, style }) => { - const { t } = useTranslation(); - const text = t(i18nKey, values); - - if (Array.isArray(text)) { - return ( -
- {text.map((line: string, idx: number) => ( -
- ))} -
- ); - } - - return ; -}; - -interface TransParagraphsProps { - i18nKey: string; - values?: Record; -} - -export const TransParagraphs: React.FC = ({ i18nKey, values }) => { - const { t } = useTranslation(); - const text = t(i18nKey, values); - - if (Array.isArray(text)) { - return ( - <> - {text.map((line: string, idx: number) => ( - - {line} - {idx < text.length - 1 &&
} -
- ))} - - ); - } - - return <>{text}; -}; diff --git a/src/index.css b/src/index.css index 4cf16e8..3d57687 100644 --- a/src/index.css +++ b/src/index.css @@ -45,6 +45,7 @@ background-color: var(--md-sys-color-background); color: var(--md-sys-color-on-background); box-sizing: border-box; + color-scheme: dark; } body { @@ -80,7 +81,7 @@ body { .app-layout { display: flex; height: 100vh; - width: 100vw; + width: 100%; overflow: hidden; } @@ -208,14 +209,17 @@ body { display: flex; flex-direction: column; height: 100%; + min-width: 0; + min-height: 0; overflow: hidden; background-color: var(--md-sys-color-background); } /* Top Action Bar */ .top-bar { - height: 80px; - padding: 0 32px; + min-height: 80px; + height: auto; + padding: 16px 32px; border-bottom: 1px solid var(--md-sys-color-outline-variant); display: flex; align-items: center; @@ -233,7 +237,8 @@ body { border: 1px solid var(--md-sys-color-outline-variant); border-radius: 100px; padding: 8px 20px; - width: 280px; + width: 320px; + flex-shrink: 0; gap: 12px; transition: all 0.3s cubic-bezier(0.2, 0.8, 0.2, 1); } @@ -257,10 +262,20 @@ body { color: var(--md-sys-color-outline); } +.categories-container { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex: 1; + padding: 0 16px; +} + .top-bar-actions { display: flex; align-items: center; gap: 12px; + flex-shrink: 0; } /* Cards Grid Scrollable Container */ @@ -289,16 +304,43 @@ body { } .addon-card:hover { - transform: translateY(-6px) scale(1.01); + transform: scale(1.02); border-color: var(--md-sys-color-primary); - box-shadow: 0 12px 24px rgba(0, 0, 0, 0.4); + box-shadow: 0 0 0 1px var(--md-sys-color-primary), 0 12px 24px rgba(0, 0, 0, 0.4); background-color: var(--md-sys-surface-container-high); + z-index: 10; } .addon-card.disabled { opacity: 0.65; } +.addon-card.submitting { + opacity: 0.5; + pointer-events: none; + filter: grayscale(0.3); +} + +.addon-card.uninstalled::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.45); + border-radius: 28px; + pointer-events: none; + transition: opacity 0.3s ease; + z-index: 3; +} + +.addon-card.uninstalled:hover::after, +.addon-card.uninstalled:focus-within::after, +.addon-card.uninstalled.card-selected::after { + opacity: 0; +} + .addon-card-image-wrapper { height: 180px; background: linear-gradient(135deg, #1d222e 0%, #0f131a 100%); @@ -335,7 +377,7 @@ body { display: flex; flex-direction: column; gap: 4px; - z-index: 2; + z-index: 4; } .badge { @@ -407,6 +449,7 @@ body { -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; + white-space: pre-line; margin-top: 4px; } @@ -701,6 +744,19 @@ body { border-color: var(--md-sys-color-primary); } +.form-input[type="number"]::-webkit-inner-spin-button, +.form-input[type="number"]::-webkit-outer-spin-button { + height: 24px; + cursor: pointer; + opacity: 0.6; + transition: opacity 0.2s; +} + +.form-input[type="number"]::-webkit-inner-spin-button:hover, +.form-input[type="number"]::-webkit-outer-spin-button:hover { + opacity: 1; +} + .modal-actions { display: flex; justify-content: flex-end; @@ -1025,12 +1081,25 @@ input:checked + .slider:before { font-weight: 600; margin-right: 8px; white-space: nowrap; + flex-shrink: 0; } .batch-action-buttons { display: flex; align-items: center; gap: 8px; + overflow-x: auto; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ +} + +.batch-action-buttons::-webkit-scrollbar { + display: none; /* Chrome, Safari and Opera */ +} + +.batch-action-buttons .btn { + white-space: nowrap; + flex-shrink: 0; } .batch-action-bar .dropdown-content { @@ -1205,3 +1274,154 @@ input:checked + .slider:before { } } + +/* Spinner animation for Loader2 */ +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +.animate-spin { + animation: spin 1s linear infinite; +} + +/* Overlay scrollbars to prevent layout shift */ +.workshop-browser * { + scrollbar-width: thin; + scrollbar-color: var(--md-sys-color-outline-variant) transparent; +} +.workshop-browser *::-webkit-scrollbar { + width: 6px; + height: 6px; +} +.workshop-browser *::-webkit-scrollbar-track { + background: transparent; +} +.workshop-browser *::-webkit-scrollbar-thumb { + background: var(--md-sys-color-outline-variant); + border-radius: 3px; +} +.workshop-browser *::-webkit-scrollbar-thumb:hover { + background: var(--md-sys-color-outline); +} + +.workshop-browser-scroll-root { + min-height: 0; + min-width: 0; + overflow-y: auto; + overflow-x: hidden; + scrollbar-gutter: stable; +} + +.workshop-browser .addon-card { + contain: paint; + will-change: transform; + transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), border-color 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), background-color 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 0.18s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.workshop-browser .addon-card-image { + transform: translateZ(0); + backface-visibility: hidden; + will-change: transform; +} + +.workshop-browser .addon-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28); +} + +.workshop-browser .addon-card:hover .addon-card-image { + transform: scale(1.02); +} + +.workshop-browser.is-scroll-interacting .addon-card:hover { + transform: none; + border-color: var(--md-sys-color-outline-variant); + box-shadow: none; + background-color: var(--md-sys-surface-container-low); + z-index: auto; +} + +.workshop-browser.is-scroll-interacting .addon-card:hover .addon-card-image { + transform: none; +} + +/* Custom Select Styling */ +select { + appearance: none !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + background-color: var(--md-sys-surface-container-low) !important; + border: 1px solid var(--md-sys-color-outline-variant) !important; + color: var(--md-sys-color-on-surface) !important; + font-family: var(--font-family) !important; + outline: none !important; + cursor: pointer !important; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238d9199' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e") !important; + background-repeat: no-repeat !important; + background-position: right 12px center !important; + background-size: 14px !important; + transition: border-color 0.2s, box-shadow 0.2s !important; +} + +select:focus { + border-color: var(--md-sys-color-primary) !important; + box-shadow: 0 0 0 2px rgba(179, 197, 255, 0.2) !important; +} + +select option { + background-color: var(--md-sys-surface-container-high) !important; + color: var(--md-sys-color-on-surface) !important; + padding: 8px !important; +} + +/* Custom React Select Component Styling */ +.custom-select-trigger:hover { + border-color: var(--md-sys-color-primary) !important; + background-color: var(--md-sys-surface-container) !important; +} + +.custom-select-option:hover { + background-color: var(--md-sys-surface-container-highest) !important; + color: #fff !important; +} + +.custom-select-dropdown::-webkit-scrollbar { + width: 4px; +} +.custom-select-dropdown::-webkit-scrollbar-track { + background: transparent; +} +.custom-select-dropdown::-webkit-scrollbar-thumb { + background: var(--md-sys-color-outline-variant); + border-radius: 4px; +} +.custom-select-dropdown::-webkit-scrollbar-thumb:hover { + background: var(--md-sys-color-outline); +} + +/* Task Center Styling */ +.btn-warning { + background-color: #fcc015 !important; + color: #1e1b04 !important; + border: none !important; +} + +.btn-warning:hover { + background-color: #ffd040 !important; + color: #1e1b04 !important; + box-shadow: 0 4px 12px rgba(252, 192, 21, 0.4) !important; +} + +@keyframes task-download-bounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(4px); + } +} + +.task-download-bounce { + display: inline-block; + animation: task-download-bounce 1.2s infinite ease-in-out; +} diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 0000000..6155c4c --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,554 @@ +{ + "common": { + "confirm": "Confirm", + "cancel": "Cancel", + "save": "Save", + "saving": "Saving...", + "create": "Create", + "creating": "Creating...", + "rename": "Rename", + "renaming": "Renaming...", + "loading": "Loading...", + "parsing": "Parsing VPK files and loading data...", + "all": "All", + "other": "Other", + "vpkCount": "{{count}} files", + "emptyStateTitle": "No matching addons found", + "emptyStateDesc": "No files found under this filter. Please make sure you have L4D2 addon files in your folder.", + "rescan": "Rescan", + "scanning": "Scanning...", + "error": "Error", + "success": "Success", + "unknownAuthor": "Unknown author", + "search": "Search" + }, + "categories": { + "All": "All", + "Campaign": "Campaign", + "Survivor": "Survivor", + "Weapon Model": "Weapon Model", + "Script": "Script", + "Map": "Map", + "Skin": "Skin", + "Sound/Music": "Sound/Music", + "Infected": "Infected", + "UI/Textures": "UI/Textures", + "Other": "Other" + }, + "sidebar": { + "title": "Left 4 Addons", + "allAddons": "All Addons", + "manualInstall": "Manual (Addons)", + "workshopDir": "Workshop (Workshop)", + "disabledAddons": "Disabled (.disabled)", + "myGroups": "Groups", + "noGroups": "No groups yet. You can auto-classify or create manually.", + "newGroup": "New Manual Group", + "classifying": "Classifying...", + "autoClassify": "Auto-Identify & Classify", + "settings": "Settings", + "masterCollections": "Master Collections", + "noMasterCollections": "No master collections yet", + "newMasterCollection": "New Master Collection" + }, + "topbar": { + "searchPlaceholder": "Name, description, author or workshop ID", + "sortByTitle": "Name", + "sortBySize": "Size", + "sortById": "Workshop ID", + "exitBatch": "Exit Batch", + "batchManage": "Batch Manage", + "batchManageTooltip": "Batch manage addon components", + "refreshTooltip": "Refresh all addons and sync workshop info" + }, + "taskCenter": { + "title": "Task Center", + "subtitle": "Manage and monitor addon downloads and sync tasks", + "noTasks": "No active tasks", + "noTasksDesc": "Active downloads or workshop crawls will appear here", + "syncing": "Syncing workshop info...", + "dependencyChecking": "Checking dependencies...", + "dependencyQueued": "Waiting to check dependencies", + "dependencyPartial": "Dependency check completed with failed nodes", + "syncDesc": "Fetching and updating Steam Workshop metadata for addons", + "downloading": "Downloading addons...", + "warning": "Task warnings", + "activeTasks": "Active Queue", + "inQueue": "Queued", + "errors": "Warning Notifications / Failed Tasks", + "clearFinished": "Clear History", + "cancel": "Cancel", + "retry": "Retry", + "clear": "Clear", + "clearTooltip": "Clear all non-active tasks", + "startSync": "Sync Workshop", + "errorDetails": "Error Details: {{err}}", + "calculatingSpeed": "Calculating speed...", + "speedValue": "{{speed}}", + "transferProgress": "{{downloaded}} / {{total}}", + "transferDownloaded": "{{downloaded}} downloaded", + "close": "Close", + "history": "Completed Task History", + "statusCompleted": "Completed", + "statusCancelled": "Cancelled", + "sync": "Metadata Sync", + "warningTitle": "Limited Functionality Warning", + "steamworksUnavailableTitle": "Steamworks SDK Unavailable", + "sdkDownloadWarningForced": "This task is using Steamworks SDK downloads because the current settings force that path. Per the known limits documented in docs/steamworks-sdk-details.md, progress reporting, redownloads, cache cleanup, and Steam-managed duplicate copies can all be unreliable on L4D2.", + "sdkDownloadWarningFallback": "This task fell back to Steamworks SDK downloads because the direct Web download path was unavailable. Per the known limits documented in docs/steamworks-sdk-details.md, progress reporting, redownloads, cache cleanup, and Steam-managed duplicate copies can all be unreliable on L4D2." + }, + "addonCard": { + "deselect": "Deselect", + "select": "Select this addon", + "enabled": "Enabled", + "disabled": "Disabled", + "uninstalled": "Not Installed", + "manualInstall": "Manual", + "workshop": "Workshop", + "author": "Author: {{author}}", + "group": "Group: {{group}}", + "fileSize": "Size: {{size}}", + "containsFiles": "Contains {{count}} files", + "clickToDisable": "Click to disable", + "clickToEnable": "Click to enable", + "addOrRemoveGroup": "Add to or Remove from Group", + "removeFromGroup": "Remove from Group ({{name}})", + "noGroupsTooltip": "No groups (create in sidebar)", + "openLink": "Open Link", + "openInSteam": "Open in Steam Client", + "openInBrowser": "Open in Browser (Official)", + "openInMirror": "Open in Browser (Mirror)", + "openBuiltInSource": "Open built-in source page", + "moveToManual": "Move to Manual (Addons)", + "renameAddon": "Rename Addon File", + "deleteAddon": "Delete this Addon" + }, + "batchActionBar": { + "selectedCount": "{{count}} addons selected", + "noneSelected": "No addons selected", + "deselectAll": "Deselect All", + "selectAll": "Select All", + "selectDownloaded": "Select Downloaded", + "selectUndownloaded": "Select Undownloaded", + "enable": "Enable", + "disable": "Disable", + "moveToManual": "Move to Manual", + "autoRename": "Auto Rename", + "addToGroup": "Add to Group", + "addToMasterCollection": "Add to Master Collection", + "addToGroupTitle": "Select Group", + "addToGroupDesc": "Choose a group to add the selected addons to", + "searchGroups": "Search groups...", + "noGroups": "No Groups", + "exitBatch": "Exit Batch", + "download": "Download", + "batchDelete": "Batch Delete" + }, + "groupCard": { + "partiallyEnabled": "Partially Enabled", + "enabled": "Enabled", + "disabled": "Disabled", + "empty": "Empty", + "groupBadge": "Group ({{count}})", + "author": "Author: {{author}}", + "authorMultiple": "Author: {{first}} and {{count}} others", + "containsAddons": "Contains {{count}} addons:", + "totalSize": "Total Size: {{size}}", + "toggleAll": "Enable/Disable all addons in group", + "viewDetails": "View Group Details", + "deselectGroup": "Deselect whole group", + "selectGroup": "Select whole group" + }, + "groupHeader": { + "desc": "This group contains {{count}} addon files, allowing batch management.", + "renameGroup": "Rename Group", + "deleteGroup": "Delete Group", + "enableAll": "Enable All", + "disableAll": "Disable All", + "moveToManualAll": "Move All to Manual", + "downloadAllUninstalled": "Download All Uninstalled ({{count}})", + "uninstalledCount": "{{count}} uninstalled" + }, + "groupModal": { + "title": "Create New Addon Group", + "desc": "Bundle multiple VPK files into the same group (e.g. Part 1, 2, 3 of a campaign map) for one-click batch operations.", + "groupNameLabel": "Group Name:", + "groupNamePlaceholder": "e.g. Early Days Campaign Pack", + "selectAddonsLabel": "Select addons to add (multi-select):", + "createGroupBtn": "Create Group" + }, + "editGroupModal": { + "title": "Rename Group", + "newNameLabel": "New Group Name:" + }, + "linkConfirmModal": { + "title": "About to visit external website", + "desc": [ + "The link you clicked points to a third-party external webpage:", + "Please ensure you trust this website to prevent phishing and malware threats. Do you want to continue?" + ], + "continue": "Continue" + }, + "renameModal": { + "title": "Rename Addon File", + "desc": "Modify the underlying VPK file name. Using meaningful titles is recommended to prevent unrecognizable filenames with long numbers.", + "workshopTitle": "Workshop Title:", + "applyWorkshopTitle": "Apply Workshop Title as Filename", + "currentNameLabel": "Current Filename:", + "newNameLabel": "New Filename (.vpk):" + }, + "confirmModal": { + "deleteGroupTitle": "Confirm Delete Group", + "deleteGroupMsg": "Are you sure you want to delete this group? This will not delete the files, only dissolve the group.", + "batchRenameTitle": "Batch Auto Rename", + "batchRenameMsg": "Are you sure you want to auto rename the selected {{count}} addons? The system will name them based on workshop title and group info.", + "batchMoveTitle": "Move Workshop Addon Warning", + "batchMoveMsg": [ + "Are you sure you want to move the selected {{count}} workshop addons to the manual folder? After moving, it is recommended to unsubscribe from them in Steam, otherwise Steam will redownload them on next startup.", + "...or go to Settings > Experimental > Workshop Detection Bypass", + "to move workshop items without unsubscribing." + ] + }, + "detailModal": { + "title": "Addon Details", + "fileName": "File Name", + "addonSize": "Addon Size", + "filesInVpk": "Files in VPK", + "directory": "Directory", + "manualInstall": "Manual (Addons)", + "workshop": "Workshop (Workshop)", + "currentStatus": "Current Status", + "enabled": "Enabled (Loading)", + "disabled": "Disabled", + "workshopId": "Workshop ID", + "relatedLink": "Related Link", + "clickToVisit": "Click to visit", + "author": "Author: {{author}}", + "viewSteamProfile": "View Steam Profile", + "version": "Version: {{version}}", + "belongsToGroup": "Group: {{name}}", + "descriptionLabel": "Description:", + "openInSteam": "Open in Steam", + "webViewOfficial": "Web View (Official)", + "mirrorWebView": "Mirror Web View", + "visitSource": "Visit Source Webpage", + "disableAddon": "Disable Addon", + "enableAddon": "Enable Addon", + "moveToManual": "Move to Manual" + }, + "moveWarningModal": { + "title": "Move Workshop Addon Warning", + "veryImportant": "Very Important:", + "warningDesc": [ + "You are moving the addon from Workshop Directory to Manual Installation Directory (Addons).", + "After moving, please make sure to unsubscribe from this addon in Steam!", + "If you do not unsubscribe, Steam may redownload this addon every time you launch the game, resulting in identical files existing in both directories, causing resource conflicts or duplicate loading.", + "...or go to Settings > Experimental > Workshop Detection Bypass", + "to move workshop items without unsubscribing." + ], + "unsubscribeAndMove": "Unsubscribe & Move", + "moveDirectly": "Move Directly" + }, + "workshopWarningModal": { + "title": "Workshop Addon Moved", + "successTitle": "Auto Transfer Successful", + "warningDesc": [ + "To prevent the game from re-downloading and overwriting, before executing {{actionName}}, the related workshop addon has been automatically moved to Manual Installation Directory (Addons).", + "Please note:", + "You must go to the Steam Workshop to unsubscribe from this addon, otherwise the game will duplicate download the addon on next startup, causing conflicts!" + ], + "dontShowAgain": "Do not show this warning again in the current session, and silently move", + "iUnderstand": "I Understand", + "goToWorkshop": "Go to Workshop" + }, + "settings": { + "pathSettings": "Path Settings", + "download": "Downloads", + "experimental": "Experimental", + "sdk": "SDK", + "sources": "Data Sources", + "about": "About", + "language": "Language", + "languageTitle": "Interface Language Settings", + "languageDesc": "Choose the display language for the application user interface.", + "title": "Game & Directory Configuration", + "desc": "Please configure your Left 4 Dead 2 game addons folder. The program will automatically access the addons folder and its workshop folder to parse and manage all VPK files.", + "addonsPathLabel": "Addon Folder Path (Addons path):", + "addonsPathPlaceholder": "e.g., C:\\Program Files (x86)\\Steam\\steamapps\\common\\Left 4 Dead 2\\left4dead2\\addons", + "addonsPathHelp": "Please select the `left4dead2/addons` directory. The program will scan the directory immediately after saving.", + "saveAndRescan": "Save and Rescan", + "savingAndScanning": "Saving and Scanning...", + "experimentalTitle": "Experimental Features", + "experimentalDesc": "The options here are in experimental phase. Enabling them may adjust the directory structure. Please use with caution.", + "downloadTitle": "Download Settings", + "downloadDesc": "Configure concurrency for the background download queue. Temporary files are written into the cache directory first and moved into the final workshop directory only after completion.", + "downloadConcurrencyLabel": "Concurrent Downloads", + "downloadConcurrencyHelp": "Number of add-ons to download simultaneously ({{min}} - {{max}}). Set to 1 for slow networks.", + "maxDownloadRetriesLabel": "Max Download Retries", + "maxDownloadRetriesHelp": "Number of times to retry a failed download (due to network errors like chunk error or error decoding body).", + "downloadTempDirLabel": "Temporary Download Directory", + "downloadTempDirHelp": "Failed web-direct downloads keep resumable partial files here for retry. Manual cancellation cleans them up.", + "sdkTitle": "SDK Settings", + "sdkDesc": "Manage Steamworks SDK query and download behavior. The Steam download path is currently recommended only as a fallback when direct URLs are unavailable.", + "disableSteamworksSdkTitle": "Disable Steamworks SDK", + "disableSteamworksSdkDesc": "When enabled, both frontend and backend stop using Steamworks SDK workshop queries and fall back to web-based fetching. It takes effect for subsequent requests immediately after Save and Rescan, with no app restart required; however, Steam may not clear the “playing L4D2” state immediately, and in some cases you may need to fully close Left 4 Addons first.", + "forceSteamworksSdkDownloadTitle": "Force Steamworks SDK Downloads", + "forceSteamworksSdkDownloadDesc": "Disabled by default. When off, downloads prefer the Steam Web API `file_url` direct link and only fall back to Steam client installation when no direct URL exists or the direct request fails. When on, downloads prefer Steam client installation through the SDK. This path is fragile on L4D2: progress, redownload behavior, and cache cleanup are all unreliable, and Steam-managed duplicate copies may remain on disk.", + "suppressSdkWarningTitle": "Suppress SDK Unavailable Warning", + "suppressSdkWarningDesc": "When enabled, the frontend will not add a warning to the task center if Steamworks SDK is unavailable.", + "sourcesTitle": "Workshop Data Sources", + "sourcesDesc": "Configure workshop metadata sources. Basic addon management always uses local files and local cache and does not depend on remote services.", + "sourcePresetLabel": "Source mode", + "sourcePresetConservative": "Conservative Auto: only search-related HTML while SDK is available", + "sourcePresetSdkOnly": "Steam/SDK Only: no Steam Web API or web crawling", + "sourcePresetOffline": "Offline Mode: local files and cache only", + "sourcePresetHybrid": "Allow all HTML fetch scopes", + "dependencyRefreshTitle": "Dependency check refresh policy", + "dependencyRefreshDesc": "SDK queries can usually refresh dependency relations safely; Steam Community page fetches remain protected by the global rate limit.", + "dependencySdkRefreshLabel": "Steamworks SDK", + "dependencyHtmlRefreshLabel": "Steam Community HTML", + "dependencyRefresh": { + "always": "Refresh every check", + "cache-missing": "Fetch only when missing from cache" + }, + "sourceHybridWarning": "Using Steamworks SDK and steamcommunity crawling at the same time may make Steam Community IP rate limiting more likely. Enable this only when you need web-only author, tag, or page detail enrichment.", + "sourceSteamworksSdk": "Steamworks SDK", + "sourceSteamWebApi": "Steam Web API", + "sourceSteamCommunityHtml": "steamcommunity HTML crawling", + "sourceOrderTitle": "Advanced source order", + "sourceMoveUp": "Up", + "sourceMoveDown": "Down", + "sdkHtmlScopeTitle": "Allowed HTML fetch scope while SDK is available", + "sdkHtmlScope": { + "disabled": { + "label": "Disabled", + "desc": "When the SDK is available, steamcommunity pages stay off except for single-item detail pages." + }, + "search": { + "label": "Workshop Search", + "desc": "Default. Allow steamcommunity HTML for keyword searches and creator pages to compensate for poor SDK search quality." + }, + "navigation": { + "label": "Home / Navigation", + "desc": "In addition to search, allow steamcommunity HTML for the workshop home page, browse views, and tag navigation." + }, + "all": { + "label": "Allow All", + "desc": "Best workshop experience, but highest rate-limit risk. Even background silent refreshes may fetch steamcommunity HTML." + } + }, + "dummyBypassTitle": "Workshop Detection Bypass", + "dummyBypassDesc": [ + "When enabled, moving an addon out of the workshop folder will automatically generate a dummy addon in the workshop directory (keeping only thumbnail, original AppID, version, prefixing name with (L4A Dummy) and marking generated by L4A) to attempt to bypass Steam Workshop synchronization checks.", + "Please note: Workshop updates might cause duplicate loads and conflicts between old and new versions." + ], + "aboutTitle": "About Left 4 Addons", + "aboutDesc": "A dedicated addons manager designed for Left 4 Dead 2. Powered by Tauri + React + Rust, it aims to provide players with a super fast and elegant VPK file management experience.", + "featuresTitle": "Key Features", + "features": [ + "One-click Enable/Disable: Quickly rename `.vpk` files to enable/disable them in game.", + "Workshop Sync: Auto fetch and cache workshop cover photos, titles, and author details.", + "Group Management: Combine multi-part maps or associated addons for one-click batch operations.", + "Auto Identification: Built-in algorithm to identify campaigns/map packs and regroup them.", + "Physical Isolation: One-click transfer of workshop files to local addons folder to prevent subscription conflicts." + ], + "licenseTitle": "Open Source License", + "licenseDesc": "This project is open source under the MIT License.", + "languageLabel": "Interface Language:", + "dependencyMissingBehaviorLabel": "Behavior when dependencies are missing", + "behaviorAlways": "Always download", + "behaviorAsk": "Ask [Default]", + "behaviorIgnore": "Ignore (Not recommended)", + "behaviorIgnoreWarning": "Warning: Ignoring dependencies may cause addons to not work properly. Dependency checks will be skipped." + }, + "settingsModal": { + "title": "Configure Addon Directory Path" + }, + "statsBar": { + "totalCount": "Total Addons", + "enabled": "Enabled", + "disabled": "Disabled", + "totalDisk": "Total Addons Size" + }, + "masterCollections": { + "systemCampaignAuto": "Campaign (Auto-Identified)", + "systemWorkshopImport": "Imported from Workshop", + "title": "Master Collections", + "desc": "This master collection contains {{count}} groups.", + "renameCollection": "Rename Master Collection", + "deleteCollection": "Delete Master Collection", + "createTitle": "Create New Master Collection", + "createDesc": "Create a master collection to organize multiple groups. A group can belong to multiple master collections at the same time.", + "collectionNameLabel": "Master Collection Name:", + "collectionNamePlaceholder": "e.g. My Campaign Collection", + "selectGroupsLabel": "Select groups to add (multi-select):", + "createCollectionBtn": "Create Master Collection", + "containsGroups": "Contains {{count}} groups:", + "totalAddons": "{{count}} addons total" + }, + "toasts": { + "autoMoveFailed": "Auto move failed: {{err}}", + "dbRefreshSuccess": "Database refreshed successfully", + "autoRefreshSuccess": "External addon changes detected and refreshed automatically", + "autoRefreshFailed": "Automatic refresh failed: {{err}}", + "dataLoadFailed": "Data load failed: {{err}}", + "addonEnabled": "Addon enabled", + "addonDisabled": "Addon disabled", + "operationFailed": "Operation failed: {{err}}", + "groupEnabled": "All addons in the group enabled", + "groupDisabled": "All addons in the group disabled", + "moveSuccessLoading": "Moved to manual addons directory", + "moveSuccessWorkshop": "Moved to workshop directory", + "moveFailed": "Move failed: {{err}}", + "steamSyncSuccess": "Steam sync successful", + "steamSyncFailed": "Steam sync failed: {{err}}", + "autoClassifySuccess": "Auto classification complete! Campaigns/map packs regrouped.", + "autoClassifyFailed": "Auto classification failed: {{err}}", + "settingsSaveSuccess": "Settings saved and directory rescanned successfully", + "settingsSaveFailed": "Save failed: {{err}}", + "renameSuccess": "Rename successful", + "renameFailed": "Rename failed: {{err}}", + "createGroupSuccess": "Group created successfully", + "createGroupFailed": "Create group failed: {{err}}", + "deleteGroupSuccess": "Group deleted", + "deleteGroupFailed": "Delete failed: {{err}}", + "renameGroupSuccess": "Group renamed successfully", + "removeFromGroupSuccess": "Removed from group", + "addToGroupSuccess": "Added to group", + "batchToggleSuccessEnable": "Batch enable successful", + "batchToggleSuccessDisable": "Batch disable successful", + "batchMoveSuccess": "Batch moved to manual addons directory successfully", + "batchRenameNoNeed": "Selected addons are already in recommended filename format, no rename needed", + "batchRenameSuccess": "Batch rename successful (renamed {{count}} files)", + "batchAddGroupSuccess": "Batch added to group successfully", + "downloadSuccess": "Addon downloaded successfully", + "downloadFailed": "Download failed: {{err}}", + "batchDownloadSuccess": "Batch download started ({{count}} addons)", + "createMasterCollectionSuccess": "Master collection created", + "deleteMasterCollectionSuccess": "Master collection deleted", + "renameMasterCollectionSuccess": "Master collection renamed", + "addToMasterCollectionSuccess": "Added to master collection", + "removeFromMasterCollectionSuccess": "Removed from master collection" + }, + "workshop": { + "nav": { + "home": "Home", + "browse": "Browse", + "searchPlaceholder": "Search Workshop..." + }, + "home": { + "pastWeek": "Items This Week", + "pastWeekDesc": "Past 1 week", + "trending": "Trending", + "trendingDesc": "Top rated in past 3 months", + "mostRequired": "Most Required by Others", + "mostRequiredDesc": "Items most needed by other items to function", + "mostSubscribed": "Most Subscribed", + "mostSubscribedDesc": "Most subscribed items", + "recentlyUpdated": "Recently Updated", + "recentlyUpdatedDesc": "Items with recent updates", + "newest": "Newest", + "newestDesc": "Most recently published items", + "viewAll": "View All", + "loading": "Loading Workshop homepage...", + "loadFailed": "Unable to load Workshop homepage data." + }, + "browse": { + "sortTrend": "Trending", + "sortTextSearch": "Relevance", + "sortTotalProfiles": "Most Subscribed", + "sortMostRecent": "Most Recent", + "sortTopRated": "Top Rated", + "sortLastUpdated": "Last Updated", + "sectionAddons": "Addons", + "sectionCollections": "Collections", + "loading": "Loading Workshop items...", + "retry": "Retry", + "empty": "No Workshop items found, try different keywords.", + "page": "Page {{page}}" + }, + "tags": { + "browse": "Browse by Category", + "filter": "Filter by Tags", + "label": "Tag: {{name}}" + }, + "badges": { + "downloaded": "Downloaded", + "known": "Known", + "notDownloaded": "Not Downloaded", + "addon": "Addon", + "collection": "Collection" + }, + "item": { + "author": "By: {{author}}", + "size": "Size: {{size}}", + "subscriptions": "Subs: {{count}}", + "childCount": "{{count}} items" + }, + "detail": { + "loading": "Loading details...", + "title": "Item Details", + "author": "Author", + "authorPage": "Author Page", + "description": "Description", + "descriptionLabel": "Description", + "download": "Download & Install", + "downloaded": "Downloaded", + "downloading": "Downloading ({{progress}}%)", + "addToKnown": "Add to Known Items", + "addToKnownList": "Add to Known List", + "steamProtocol": "Open in Steam", + "openInSteam": "Open in Steam", + "openInBrowser": "Open in Browser", + "viewOnSteam": "View on Steam", + "defaultCollectionName": "Workshop Collection", + "importSuccess": "Imported {{count}} items from collection as group \"{{name}}\".", + "downloadAllSuccess": "Started downloading {{count}} items from collection!", + "collectionTitle": "Collection Details", + "collectionAuthor": "Collection Author", + "collectionItems": "Items in Collection", + "collectionItemsCount": "{{count}} items", + "itemsInCollection": "Items in Collection", + "itemSize": "File Size", + "workshopId": "Workshop ID", + "status": "Status", + "type": "Type", + "requiredItems": "Required Items", + "parentCollections": "Parent Collections", + "loadingExtra": "Loading extra details...", + "importAsGroup": "Import as Group", + "downloadAll": "Download All ({{count}})", + "addedToKnown": "Added to known items list!", + "addFailed": "Failed to add: {{err}}", + "fetchFailed": "Unable to fetch details: {{err}}" + } + }, + "deleteConfirmModal": { + "title": "Confirm Deletion", + "subtitle": "You are about to delete {{count}} addons. Please read the following notices carefully:", + "workshopTitle": "Workshop Addons ({{count}})", + "workshopDesc": "If the original Workshop item is updated after deletion, you will permanently lose the currently installed version. These files must be re-downloaded via the Workshop to be used again.", + "localTitle": "External/Non-Workshop Addons ({{count}})", + "localDesc": "The system cannot automatically download these addons via the Workshop. Even if they contain external links, deleting them may result in permanent loss, and they cannot be automatically restored!", + "confirmQuestion": "Are you sure you want to proceed with the deletion?", + "btnWorkshopOnly": "Delete Workshop Content Only", + "btnDeleteAll": "Delete All" + }, + "dependencyPrompt": { + "warningTitle": "Are you sure?", + "warningDesc": "While a few addons may have optional dependencies, please ensure you have a good reason not to download any of them and understand the impact on the addons and the game.", + "goBack": "Go Back", + "confirmCancel": "Confirm", + "title": "Missing Dependencies Found", + "desc": "These items are required dependencies for addons you have installed or are currently downloading.", + "goToSettings": "Go to settings to change default behavior", + "search": "Search dependencies...", + "selectedCount": "Selected: {{count}}", + "selectAll": "Select All (This Page)", + "noResults": "No matching dependencies found", + "remindLater": "Remind me later", + "cancelAll": "I don't want to download any", + "downloadFirst": "Download these first", + "download": "Download Selected" + } +} diff --git a/src/locales/zh.json b/src/locales/zh.json new file mode 100644 index 0000000..6e21c2e --- /dev/null +++ b/src/locales/zh.json @@ -0,0 +1,554 @@ +{ + "common": { + "confirm": "确定", + "cancel": "取消", + "save": "保存", + "saving": "正在保存...", + "create": "创建", + "creating": "正在创建...", + "rename": "重命名", + "renaming": "正在重命名...", + "loading": "加载中...", + "parsing": "正在解析 VPK 文件和加载数据中...", + "all": "全部", + "other": "其他", + "vpkCount": "{{count}} 个文件", + "emptyStateTitle": "未发现匹配的附加组件", + "emptyStateDesc": "在此筛选条件下未找到任何文件。请确认你的目录中已经放有 L4D2 附件文件。", + "rescan": "重新扫描", + "scanning": "正在扫描...", + "error": "错误", + "success": "成功", + "unknownAuthor": "未知作者", + "search": "搜索" + }, + "categories": { + "All": "全部", + "Campaign": "战役", + "Survivor": "幸存者", + "Weapon Model": "武器模型", + "Script": "脚本", + "Map": "地图", + "Skin": "皮肤", + "Sound/Music": "声音/音乐", + "Infected": "感染者", + "UI/Textures": "UI/贴图", + "Other": "其他" + }, + "sidebar": { + "title": "求生之路2附加组件管理", + "allAddons": "全部组件", + "manualInstall": "手动安装 (Addons)", + "workshopDir": "创意工坊目录 (Workshop)", + "disabledAddons": "被禁用的组件 (.disabled)", + "myGroups": "分组", + "noGroups": "暂无分组,可自动归类或手动创建。", + "newGroup": "新建手动分组", + "classifying": "归类中...", + "autoClassify": "自动识别战役并归类", + "settings": "设置", + "masterCollections": "大合集", + "noMasterCollections": "暂无大合集", + "newMasterCollection": "新建大合集" + }, + "topbar": { + "searchPlaceholder": "名称, 描述, 作者或创意工坊ID", + "sortByTitle": "名称", + "sortBySize": "大小", + "sortById": "创意工坊ID", + "exitBatch": "退出批量", + "batchManage": "批量管理", + "batchManageTooltip": "批量管理附件组件", + "refreshTooltip": "刷新所有组件并同步创意工坊信息" + }, + "taskCenter": { + "title": "任务中心", + "subtitle": "管理和查看组件的下载与同步任务", + "noTasks": "暂无活动任务", + "noTasksDesc": "下载组件或同步创意工坊时将会在此处显示", + "syncing": "正在同步创意工坊信息...", + "dependencyChecking": "正在检查依赖...", + "dependencyQueued": "等待检查依赖", + "dependencyPartial": "依赖检查已完成,但部分节点失败", + "syncDesc": "正在获取和更新所有组件的最新 Steam 创意工坊元数据", + "downloading": "正在下载组件...", + "warning": "任务出现警告", + "activeTasks": "正在运行的队列", + "inQueue": "等待中", + "errors": "警告通知 / 失败的任务", + "clearFinished": "清空历史", + "cancel": "取消", + "retry": "重新尝试", + "clear": "清除", + "clearTooltip": "清除所有非运行中的任务", + "startSync": "开始同步", + "errorDetails": "错误详情: {{err}}", + "calculatingSpeed": "计算速度中...", + "speedValue": "{{speed}}", + "transferProgress": "{{downloaded}} / {{total}}", + "transferDownloaded": "已下载 {{downloaded}}", + "close": "关闭", + "history": "已完成的历史任务", + "statusCompleted": "已完成", + "statusCancelled": "已取消", + "sync": "元数据同步", + "warningTitle": "功能受限警告", + "steamworksUnavailableTitle": "Steamworks SDK 不可用", + "sdkDownloadWarningForced": "该任务按当前设置直接改走 Steamworks SDK 下载。根据 docs/steamworks-sdk-details.md 中记录的已知限制,这条链路在 L4D2 上的进度显示、重复下载、缓存清理和 Steam 管理副本都可能不稳定。", + "sdkDownloadWarningFallback": "该任务的 Web 直链不可用,已退化为 Steamworks SDK 下载。根据 docs/steamworks-sdk-details.md 中记录的已知限制,这条链路在 L4D2 上的进度显示、重复下载、缓存清理和 Steam 管理副本都可能不稳定。" + }, + "addonCard": { + "deselect": "取消选择", + "select": "选中此组件", + "enabled": "已启用", + "disabled": "已禁用", + "uninstalled": "未安装", + "manualInstall": "手动安装", + "workshop": "创意工坊", + "author": "作者: {{author}}", + "group": "分组: {{group}}", + "fileSize": "文件大小: {{size}}", + "containsFiles": "内含 {{count}} 个文件", + "clickToDisable": "点击禁用该附件", + "clickToEnable": "点击启用该附件", + "addOrRemoveGroup": "加入或移出群组", + "removeFromGroup": "从当前分组移出 ({{name}})", + "noGroupsTooltip": "无分组 (在侧栏创建)", + "openLink": "打开链接", + "openInSteam": "在 Steam 客户端打开", + "openInBrowser": "在浏览器中打开 (官方)", + "openInMirror": "在浏览器中打开 (国内镜像)", + "openBuiltInSource": "打开组件内置来源网页", + "moveToManual": "移动到手动安装目录 (Addons)", + "renameAddon": "重命名附件文件", + "deleteAddon": "删除此组件" + }, + "batchActionBar": { + "selectedCount": "已选择 {{count}} 个组件", + "noneSelected": "未选择任何组件", + "deselectAll": "取消全选", + "selectAll": "全选", + "selectDownloaded": "选择已下载", + "selectUndownloaded": "选择未下载", + "enable": "启用", + "disable": "禁用", + "moveToManual": "移动至手动安装", + "autoRename": "自动重命名", + "addToGroup": "加入分组", + "addToMasterCollection": "加入大合集", + "addToGroupTitle": "选择分组", + "addToGroupDesc": "选择要将选中组件加入的分组", + "searchGroups": "搜索分组...", + "noGroups": "无分组", + "exitBatch": "退出批量", + "download": "下载", + "batchDelete": "批量删除" + }, + "groupCard": { + "partiallyEnabled": "部分启用", + "enabled": "已启用", + "disabled": "已禁用", + "empty": "空组", + "groupBadge": "分组 ({{count}})", + "author": "作者: {{author}}", + "authorMultiple": "作者: {{first}} 等 {{count}} 位作者", + "containsAddons": "包含 {{count}} 个组件:", + "totalSize": "总大小: {{size}}", + "toggleAll": "启用/禁用此分组内所有组件", + "viewDetails": "查看分组详情", + "deselectGroup": "取消选择整个分组", + "selectGroup": "选中整个分组" + }, + "groupHeader": { + "desc": "此分组包含 {{count}} 个附件文件,可进行批量管理。", + "renameGroup": "重命名分组", + "deleteGroup": "解散分组", + "enableAll": "全部启用", + "disableAll": "全部禁用", + "moveToManualAll": "全部移动至手动安装", + "downloadAllUninstalled": "下载全部未安装 ({{count}})", + "uninstalledCount": "{{count}} 个未安装" + }, + "groupModal": { + "title": "创建新附件分组", + "desc": "将多个 VPK 文件打包到同一个群组(例如一张地图的 Part 1、2、3),从而一键批量启用、禁用或移动。", + "groupNameLabel": "分组名称:", + "groupNamePlaceholder": "例如:Early Days 战役地图包", + "selectAddonsLabel": "选择要加入群组的组件 (可多选):", + "createGroupBtn": "创建分组" + }, + "editGroupModal": { + "title": "重命名分组", + "newNameLabel": "分组新名称:" + }, + "linkConfirmModal": { + "title": "即将访问外部网站", + "desc": [ + "您点击的链接指向第三方外部网页:", + "请确保您信任该网站,以防止钓鱼和恶意软件威胁。是否继续前往?" + ], + "continue": "继续访问" + }, + "renameModal": { + "title": "重命名附加组件文件", + "desc": "修改底层的 VPK 文件名称。建议使用有意义的标题,防止由于一堆数字无法辨认。", + "workshopTitle": "创意工坊标题:", + "applyWorkshopTitle": "应用创意工坊标题作为文件名", + "currentNameLabel": "当前文件名:", + "newNameLabel": "新文件名 (.vpk):" + }, + "confirmModal": { + "deleteGroupTitle": "确认删除分组", + "deleteGroupMsg": "确定要删除这个分组吗?这不会删除文件,只会解散群组。", + "batchRenameTitle": "批量自动重命名", + "batchRenameMsg": "确定要自动重命名选中的 {{count}} 个附件吗?系统将根据创意工坊标题 and 分组信息自动为它们命名。", + "batchMoveTitle": "移动创意工坊附件提示", + "batchMoveMsg": [ + "确定要将选中的 {{count}} 个创意工坊附件移动到手动安装目录吗?移动后建议前往 Steam 创意工坊取消订阅这些附件,否则 Steam 会在下次启动游戏时重新下载。", + "...或者 前往 设置 > 实验性 > 创意工坊检测绕过", + "在不取消订阅的情况下移动创意工坊物品。" + ] + }, + "detailModal": { + "title": "附加组件详情", + "fileName": "文件名称", + "addonSize": "组件大小", + "filesInVpk": "包内文件", + "directory": "所在目录", + "manualInstall": "手动安装 (Addons)", + "workshop": "创意工坊 (Workshop)", + "currentStatus": "当前状态", + "enabled": "已启用 (加载中)", + "disabled": "已禁用", + "workshopId": "创意工坊 ID", + "relatedLink": "相关链接", + "clickToVisit": "点击访问", + "author": "作者: {{author}}", + "viewSteamProfile": "查看 Steam 档案", + "version": "版本: {{version}}", + "belongsToGroup": "所属分组: {{name}}", + "descriptionLabel": "组件描述:", + "openInSteam": "在 Steam 打开", + "webViewOfficial": "网页查看 (官方)", + "mirrorWebView": "国内网页镜像", + "visitSource": "访问来源网页", + "disableAddon": "禁用组件", + "enableAddon": "启用组件", + "moveToManual": "移动到手动安装" + }, + "moveWarningModal": { + "title": "移动创意工坊附件提示", + "veryImportant": "非常重要:", + "warningDesc": [ + "您正在将组件从 创意工坊目录 移动到 手动安装目录 (Addons) 。", + "移动后,请务必在 Steam 客户端或网页中“取消订阅”该组件!", + "如果不取消订阅,每次您启动游戏时,Steam 客户端都可能会重新下载该组件,导致加载目录和创意工坊目录下同时存在两个相同的文件,引发资源冲突或重复加载。", + "...或者 前往 设置 > 实验性 > 创意工坊检测绕过", + "在不取消订阅的情况下移动创意工坊物品。" + ], + "unsubscribeAndMove": "取消订阅并移动", + "moveDirectly": "直接移动" + }, + "workshopWarningModal": { + "title": "创意工坊附件已移动", + "successTitle": "自动转移成功", + "warningDesc": [ + "为了防止游戏重新下载覆盖,执行 {{actionName}} 操作前,已自动将相关的创意工坊附件移动至手动安装目录 (Addons)。", + "请注意:", + "您必须前往 Steam 创意工坊取消订阅此组件,否则游戏下次启动时仍会重复下载该附件并导致冲突!" + ], + "dontShowAgain": "当前会话不再提醒此警告,并静默执行移动", + "iUnderstand": "我知道了", + "goToWorkshop": "前往创意工坊" + }, + "settings": { + "pathSettings": "路径设置", + "download": "下载", + "experimental": "实验性", + "sdk": "SDK", + "sources": "数据来源", + "about": "关于软件", + "language": "语言设置", + "languageTitle": "界面语言设置", + "languageDesc": "选择应用程序的用户界面显示语言。", + "title": "游戏与目录配置", + "desc": "请配置求生之路2的游戏附加组件目录(即 `addons` 文件夹)。程序将自动访问该文件夹及其下的 `workshop` 创意工坊文件夹,并解析和管理所有的 VPK 附件文件。", + "addonsPathLabel": "附加组件目录 (Addons 路径):", + "addonsPathPlaceholder": "例如: C:\\Program Files (x86)\\Steam\\steamapps\\common\\Left 4 Dead 2\\left4dead2\\addons", + "addonsPathHelp": "请选择游戏目录下的 `left4dead2/addons` 文件夹。保存后程序将自动开始扫描该目录。", + "saveAndRescan": "保存并重新扫描", + "savingAndScanning": "正在保存并扫描...", + "experimentalTitle": "实验性功能", + "experimentalDesc": "此处的选项处于实验性阶段。启用可能会对文件目录结构做出调整,请谨慎开启。", + "downloadTitle": "下载设置", + "downloadDesc": "配置后台下载队列的并发数。下载过程中的临时文件会先写入缓存目录,只有完成后才移动到最终的创意工坊目录。", + "downloadConcurrencyLabel": "并发下载数", + "downloadConcurrencyHelp": "同时下载的创意工坊物品数量 ({{min}} - {{max}})。网络不稳定时建议设置为 1。", + "maxDownloadRetriesLabel": "网络异常自动重试次数", + "maxDownloadRetriesHelp": "下载过程中因网络原因 (如 error decoding body 等) 失败时的最大重新排队/重试次数。", + "downloadTempDirLabel": "下载缓存目录", + "downloadTempDirHelp": "失败的 Web 直连下载会在这里保留断点文件,方便重试时继续下载;手动取消会清理这些临时文件。", + "sdkTitle": "SDK 设置", + "sdkDesc": "管理 Steamworks SDK 的查询与下载行为。Steam 下载链路目前仅建议作为直链不可用时的备选。", + "disableSteamworksSdkTitle": "禁用 Steamworks SDK", + "disableSteamworksSdkDesc": "开启后,前端与后端将不再调用 Steamworks SDK 查询创意工坊内容,而是回退网页抓取流程。保存并重新扫描后对后续请求立即生效,无需重启应用程序;但 Steam 侧显示的“正在游玩 L4D2”状态未必会立刻消失,某些情况下需要完全关闭 Left 4 Addons 才会解除。", + "forceSteamworksSdkDownloadTitle": "强制使用 Steamworks SDK 下载", + "forceSteamworksSdkDownloadDesc": "默认关闭。关闭时会优先使用 Steam Web API 提供的 `file_url` 直链下载,仅在没有直链或直链失败时才回退 Steam 客户端下载。开启后则优先让 Steam 客户端经由 SDK 安装工坊物品;该链路在 L4D2 上较脆弱,进度、重复下载和缓存清理行为都不稳定,且可能产生 Steam 管理副本。", + "suppressSdkWarningTitle": "抑制 SDK 不可用警告", + "suppressSdkWarningDesc": "开启后,当 Steamworks SDK 不可用时,不再向任务中心添加前端警告。", + "sourcesTitle": "创意工坊数据来源", + "sourcesDesc": "配置创意工坊元数据的来源。基础附件管理始终使用本地文件和本地缓存,不依赖远程服务。", + "sourcePresetLabel": "来源模式", + "sourcePresetConservative": "保守自动:SDK 可用时仅放开搜索相关网页", + "sourcePresetSdkOnly": "仅 Steam/SDK:不使用 Steam Web API 或网页爬取", + "sourcePresetOffline": "离线模式:只使用本地文件和缓存", + "sourcePresetHybrid": "允许所有网页抓取作用域", + "dependencyRefreshTitle": "依赖检查刷新策略", + "dependencyRefreshDesc": "SDK 查询通常可以刷新最新依赖关系;网页抓取会受到 Steam Community 全局限速保护。", + "dependencySdkRefreshLabel": "Steamworks SDK", + "dependencyHtmlRefreshLabel": "Steam Community 网页", + "dependencyRefresh": { + "always": "每次检查时刷新", + "cache-missing": "仅缓存缺失时抓取" + }, + "sourceHybridWarning": "同时使用 Steamworks SDK 和 steamcommunity 网页爬取可能更容易触发 Steam 社区的 IP 请求速率限制。仅在确实需要网页补全作者、标签或页面详情时开启。", + "sourceSteamworksSdk": "Steamworks SDK", + "sourceSteamWebApi": "Steam Web API", + "sourceSteamCommunityHtml": "steamcommunity 网页爬取", + "sourceOrderTitle": "高级来源顺序", + "sourceMoveUp": "上移", + "sourceMoveDown": "下移", + "sdkHtmlScopeTitle": "SDK 可用时允许的网页抓取范围", + "sdkHtmlScope": { + "disabled": { + "label": "禁用", + "desc": "SDK 可用时,除单个物品详情页外,不再抓取 steamcommunity 页面。" + }, + "search": { + "label": "创意工坊搜索", + "desc": "默认。允许关键词搜索和创作者页使用 steamcommunity 结果,以修正 SDK 搜索结果过差的问题。" + }, + "navigation": { + "label": "创意工坊首页/导航", + "desc": "在搜索基础上,再允许创意工坊首页、分类浏览和标签导航使用 steamcommunity 页面。" + }, + "all": { + "label": "允许所有", + "desc": "最佳体验,但风险最高。连后台静默刷新也允许抓取 steamcommunity,更容易触发速率限制。" + } + }, + "dummyBypassTitle": "创意工坊检测绕过", + "dummyBypassDesc": [ + "开启后,将 addon 从 workshop 移出时,将在 workshop 目录下自动生成一个 dummy addon(仅保留附件图片、原始 AppID 及版本号,将标题标记为原名 (L4A Dummy) 且说明改为由 L4A 生成),以试图绕过 L4D2 创意工坊订阅同步检测。", + "请注意:创意工坊更新可能会导致新旧版本的 addon 同时加载并冲突。" + ], + "aboutTitle": "关于 Left 4 Addons", + "aboutDesc": "一个专为《求生之路2》(Left 4 Dead 2)设计的附加组件(Addons)管理器。采用 Tauri + React + Rust 驱动,旨在为玩家提供极速、优雅的 VPK 文件管理体验。", + "featuresTitle": "主要功能", + "features": [ + "一键启用/禁用:快速重命名 `.vpk` 文件以在游戏中生效或失效。", + "创意工坊同步:自动拉取并缓存创意工坊组件的封面图、标题 and 作者详情。", + "分组管理:将多 Part 地图或关联组件组合,实现一键批量操作。", + "自动识别:内置战役/地图包识别算法,自动检测并对关联附件进行重组。", + "物理隔离:一键将工坊文件转移到本地加载文件夹,防止游戏联机订阅冲突。" + ], + "licenseTitle": "开源许可", + "licenseDesc": "本项目遵循 MIT 协议开源。", + "languageLabel": "界面语言 (Language):", + "dependencyMissingBehaviorLabel": "依赖项缺失时行为", + "behaviorAlways": "始终下载", + "behaviorAsk": "询问 [默认]", + "behaviorIgnore": "忽略 (不推荐)", + "behaviorIgnoreWarning": "警告: 忽略依赖项可能会导致附件无法正常工作。将会跳过依赖检查。" + }, + "settingsModal": { + "title": "设置附加组件加载路径" + }, + "statsBar": { + "totalCount": "总附件数", + "enabled": "已启用", + "disabled": "已禁用", + "totalDisk": "组件总大小" + }, + "masterCollections": { + "systemCampaignAuto": "战役 (自动识别)", + "systemWorkshopImport": "从创意工坊导入", + "title": "大合集", + "desc": "此大合集包含 {{count}} 个分组。", + "renameCollection": "重命名大合集", + "deleteCollection": "删除大合集", + "createTitle": "创建新大合集", + "createDesc": "创建一个大合集来组织多个分组。一个分组可以同时属于多个大合集。", + "collectionNameLabel": "大合集名称:", + "collectionNamePlaceholder": "例如: 我的战役合集", + "selectGroupsLabel": "选择要加入的分组 (可多选):", + "createCollectionBtn": "创建大合集", + "containsGroups": "包含 {{count}} 个分组:", + "totalAddons": "共 {{count}} 个组件" + }, + "toasts": { + "autoMoveFailed": "自动移动附件失败: {{err}}", + "dbRefreshSuccess": "数据库刷新成功", + "autoRefreshSuccess": "检测到外部组件变更,已自动刷新", + "autoRefreshFailed": "自动刷新失败: {{err}}", + "dataLoadFailed": "数据加载失败: {{err}}", + "addonEnabled": "附加组件已启用", + "addonDisabled": "附加组件已禁用", + "operationFailed": "操作失败: {{err}}", + "groupEnabled": "分组内所有组件已启用", + "groupDisabled": "分组内所有组件已禁用", + "moveSuccessLoading": "已移动到手动安装目录", + "moveSuccessWorkshop": "已移动到创意工坊目录", + "moveFailed": "移动失败: {{err}}", + "steamSyncSuccess": "Steam 同步成功", + "steamSyncFailed": "同步失败: {{err}}", + "autoClassifySuccess": "自动归类完成!已发现并组合了战役/地图包。", + "autoClassifyFailed": "自动归类失败: {{err}}", + "settingsSaveSuccess": "设置保存并扫描成功", + "settingsSaveFailed": "保存失败: {{err}}", + "renameSuccess": "重命名成功", + "renameFailed": "重命名失败: {{err}}", + "createGroupSuccess": "新建分组成功", + "createGroupFailed": "创建分组失败: {{err}}", + "deleteGroupSuccess": "分组已删除", + "deleteGroupFailed": "删除失败: {{err}}", + "renameGroupSuccess": "分组已重命名", + "removeFromGroupSuccess": "已移出该分组", + "addToGroupSuccess": "已加入分组", + "batchToggleSuccessEnable": "批量启用成功", + "batchToggleSuccessDisable": "批量禁用成功", + "batchMoveSuccess": "已成功批量移动到手动安装目录", + "batchRenameNoNeed": "选中的组件已是推荐文件名,无需重命名", + "batchRenameSuccess": "批量重命名成功 (重命名了 {{count}} 个文件)", + "batchAddGroupSuccess": "批量加入分组成功", + "downloadSuccess": "下载组件成功", + "downloadFailed": "下载失败: {{err}}", + "batchDownloadSuccess": "批量下载已启动 ({{count}} 个组件)", + "createMasterCollectionSuccess": "大合集创建成功", + "deleteMasterCollectionSuccess": "大合集已删除", + "renameMasterCollectionSuccess": "大合集已重命名", + "addToMasterCollectionSuccess": "已加入大合集", + "removeFromMasterCollectionSuccess": "已从大合集移出" + }, + "workshop": { + "nav": { + "home": "首页", + "browse": "浏览", + "searchPlaceholder": "搜索创意工坊..." + }, + "home": { + "pastWeek": "一周内物品", + "pastWeekDesc": "过去 1 周内", + "trending": "最热门", + "trendingDesc": "过去 3 个月内评价最高", + "mostRequired": "被最多其他项目需要", + "mostRequiredDesc": "其他项目正常运行需要得最多的项目", + "mostSubscribed": "最多人订阅", + "mostSubscribedDesc": "订阅人数最多的物品", + "recentlyUpdated": "最近更新", + "recentlyUpdatedDesc": "最近有更新的物品", + "newest": "最新", + "newestDesc": "最新发布的物品", + "viewAll": "查看全部", + "loading": "正在加载创意工坊首页...", + "loadFailed": "无法加载创意工坊首页数据。" + }, + "browse": { + "sortTrend": "最热门", + "sortTextSearch": "相关度", + "sortTotalProfiles": "最多订阅", + "sortMostRecent": "最新发布", + "sortTopRated": "最受好评", + "sortLastUpdated": "最新更新", + "sectionAddons": "组件", + "sectionCollections": "合集", + "loading": "正在加载创意工坊列表...", + "retry": "重试", + "empty": "未找到创意工坊项目,请尝试更换关键词。", + "page": "第 {{page}} 页" + }, + "tags": { + "browse": "分类浏览", + "filter": "分类筛选", + "label": "标签: {{name}}" + }, + "badges": { + "downloaded": "已下载", + "known": "已知", + "notDownloaded": "未下载", + "addon": "组件", + "collection": "合集" + }, + "item": { + "author": "作者: {{author}}", + "size": "大小: {{size}}", + "subscriptions": "订阅: {{count}}", + "childCount": "{{count}} 个组件" + }, + "detail": { + "loading": "正在加载详细数据...", + "title": "组件详情", + "author": "作者", + "authorPage": "作者页", + "description": "描述", + "descriptionLabel": "描述", + "download": "下载组件并安装", + "downloaded": "已下载", + "downloading": "正在下载 ({{progress}}%)", + "addToKnown": "加入已知组件列表", + "addToKnownList": "加入已知列表", + "steamProtocol": "Steam 协议打开", + "openInSteam": "在 Steam 中打开", + "openInBrowser": "浏览器打开", + "viewOnSteam": "在 Steam 中查看", + "defaultCollectionName": "创意工坊合集", + "importSuccess": "已将合集中的 {{count}} 个组件导入为分组 \"{{name}}\"。", + "downloadAllSuccess": "已启动下载合集中的 {{count}} 个组件!", + "collectionTitle": "合集详情", + "collectionAuthor": "合集作者", + "collectionItems": "合集内组件", + "collectionItemsCount": "{{count}} 个组件", + "itemsInCollection": "合集内组件", + "itemSize": "文件大小", + "workshopId": "创意工坊 ID", + "status": "状态", + "type": "类型", + "requiredItems": "所需组件", + "parentCollections": "所属合集", + "loadingExtra": "正在加载附加信息...", + "importAsGroup": "导入该合集为分组", + "downloadAll": "批量下载 ({{count}})", + "addedToKnown": "成功加入已知组件列表!", + "addFailed": "添加失败: {{err}}", + "fetchFailed": "无法获取详细信息: {{err}}" + } + }, + "deleteConfirmModal": { + "title": "删除组件确认", + "subtitle": "您正准备删除 {{count}} 个组件,请仔细阅读以下提示:", + "workshopTitle": "创意工坊组件 ({{count}} 个)", + "workshopDesc": "删除后,如果创意工坊的原作更新,您将永久丢失当前安装的版本。这些文件必须重新通过创意工坊下载才能再次使用。", + "localTitle": "外部/非创意工坊组件 ({{count}} 个)", + "localDesc": "系统无法通过创意工坊自动下载这些组件。即使包含外部链接,删除后也可能永久丢失这些文件,且无法自动恢复!", + "confirmQuestion": "确定要继续删除操作吗?", + "btnWorkshopOnly": "仅删除创意工坊内容", + "btnDeleteAll": "删除所有" + }, + "dependencyPrompt": { + "warningTitle": "您确定吗?", + "warningDesc": "虽然一些少数附件确实有一些可选的依赖组件. 但请确认您有充足的理由不会下载它们其中任何一个组件, 和这么做对组件还有游戏的影响.", + "goBack": "返回", + "confirmCancel": "确定", + "title": "发现缺失的依赖项", + "desc": "这些组件是您已安装或正在下载的附件所需的依赖。", + "goToSettings": "前往设置修改默认行为", + "search": "搜索依赖项...", + "selectedCount": "已选: {{count}}", + "selectAll": "全选 (本页)", + "noResults": "没有找到匹配的依赖项", + "remindLater": "稍后提醒我", + "cancelAll": "我都不想下载", + "downloadFirst": "先下载这些", + "download": "下载选中项" + } +} diff --git a/src/services/workshopClient.test.ts b/src/services/workshopClient.test.ts new file mode 100644 index 0000000..06f50c1 --- /dev/null +++ b/src/services/workshopClient.test.ts @@ -0,0 +1,657 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const { + mockInvoke, + mockParseHomepageSections, + mockParseSSRItems, + mockParseTagCategories, + mockParseWorkshopPageDetails, + mockRememberWorkshopItems, +} = vi.hoisted(() => ({ + mockInvoke: vi.fn(), + mockParseHomepageSections: vi.fn(), + mockParseSSRItems: vi.fn(), + mockParseTagCategories: vi.fn(), + mockParseWorkshopPageDetails: vi.fn(), + mockRememberWorkshopItems: vi.fn((items: unknown) => items), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (cmd: string, args?: Record) => mockInvoke(cmd, args), +})); + +vi.mock('../components/workshop/ssrParser', () => ({ + parseHomepageSections: (html: string) => mockParseHomepageSections(html), + parseSSRItems: (html: string, source: string) => mockParseSSRItems(html, source), + parseTagCategories: (html: string) => mockParseTagCategories(html), + parseWorkshopPageDetails: (html: string) => mockParseWorkshopPageDetails(html), +})); + +vi.mock('../components/workshop/authorDirectory', () => ({ + rememberWorkshopItems: (items: unknown) => mockRememberWorkshopItems(items), + rememberWorkshopPageDetails: vi.fn(), + resolveWorkshopItemAuthor: (item: unknown) => item, +})); + +describe('workshopClient', () => { + beforeEach(() => { + vi.resetModules(); + mockInvoke.mockReset(); + mockParseHomepageSections.mockReset(); + mockParseSSRItems.mockReset(); + mockParseTagCategories.mockReset(); + mockParseWorkshopPageDetails.mockReset(); + mockRememberWorkshopItems.mockClear(); + localStorage.clear(); + }); + + test('fetchWorkshopItems skips Steamworks SDK queries when disabled in frontend settings', async () => { + mockParseSSRItems.mockReturnValue([ + { + workshopId: '12345', + title: 'Fallback Item', + imagePath: '/cache/item.png', + authorName: 'Author', + authorId: '42', + authorUrl: 'https://steamcommunity.com/id/author', + stars: 0, + }, + ]); + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'fetch_workshop_html') { + return Promise.resolve(''); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_items') { + return Promise.resolve({ source: 'steam-sdk', items: [] }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + workshopClient.setSteamworksSdkDisabled(true); + + const result = await workshopClient.fetchWorkshopItems({ query: 'tank' }); + + expect(result.source).toBe('web-fallback'); + expect(result.items).toHaveLength(1); + expect(mockInvoke).not.toHaveBeenCalledWith('get_workshop_capabilities', undefined); + expect(mockInvoke).not.toHaveBeenCalledWith('query_workshop_items', expect.anything()); + expect(mockInvoke).toHaveBeenCalledWith('fetch_workshop_html', expect.objectContaining({ + source: 'workshop-search', + })); + }); + + test('fetchWorkshopHome skips Steamworks SDK queries when disabled in frontend settings', async () => { + mockParseHomepageSections.mockReturnValue([ + { + id: 'recent', + title: 'recent', + subtitle: 'recent desc', + icon: null, + items: [], + browseParams: { + sort: 'trend', + section: 'readytouseitems', + }, + }, + ]); + mockParseTagCategories.mockReturnValue([]); + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'fetch_workshop_html') { + return Promise.resolve(''); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_home') { + return Promise.resolve({ source: 'steam-sdk', sections: [] }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + workshopClient.setSteamworksSdkDisabled(true); + + const result = await workshopClient.fetchWorkshopHome(); + + expect(result.source).toBe('web-fallback'); + expect(result.sections).toHaveLength(1); + expect(mockInvoke).not.toHaveBeenCalledWith('get_workshop_capabilities', undefined); + expect(mockInvoke).not.toHaveBeenCalledWith('query_workshop_home', undefined); + expect(mockInvoke).toHaveBeenCalledWith('fetch_workshop_html', expect.objectContaining({ + source: 'workshop-home', + })); + }); + + test('fetchWorkshopItems enriches Steam SDK items with HTML author data when SDK lacks persona names', async () => { + mockParseSSRItems.mockReturnValue([ + { + workshopId: '12345', + title: 'SDK Item', + imagePath: '/cache/item.png', + authorName: 'Visible Author', + authorId: '76561198000000001', + authorUrl: 'https://steamcommunity.com/profiles/76561198000000001', + authorSteamId: '76561198000000001', + stars: 5, + }, + ]); + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_items') { + return Promise.resolve({ + source: 'steam-sdk', + items: [{ + publishedfileid: '12345', + title: 'SDK Item', + creator: '76561198000000001', + creator_steam_id: '76561198000000001', + creator_account_id: '39734273', + creator_name: '', + score: 0.96, + }], + }); + } + if (cmd === 'fetch_workshop_html') { + return Promise.resolve(''); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + workshopClient.setWorkshopSourceSettings({ + preset: 'hybrid', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + allowSdkHtmlHybrid: true, + sdkHtmlScope: 'all', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', + sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], + cacheRetention: 'keep', + }); + const result = await workshopClient.fetchWorkshopItems({ query: 'tank' }); + + expect(result.source).toBe('steam-sdk'); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toMatchObject({ + authorName: 'Visible Author', + authorId: '76561198000000001', + authorUrl: 'https://steamcommunity.com/profiles/76561198000000001', + stars: 5, + }); + expect(mockInvoke).toHaveBeenCalledWith('fetch_workshop_html', expect.objectContaining({ + source: 'workshop-search', + })); + }); + + test('fetchWorkshopHome does not HTML-enrich SDK home by default when SDK is available', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_home') { + return Promise.resolve({ + source: 'steam-sdk', + sections: [{ + id: 'recent', + titleKey: 'recent', + subtitleKey: 'recent desc', + icon: 'Clock', + items: [], + browseParams: { + sort: 'trend', + section: 'readytouseitems', + }, + }], + }); + } + if (cmd === 'fetch_workshop_html') { + throw new Error('Home HTML should not be fetched by default'); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const result = await workshopClient.fetchWorkshopHome(); + + expect(result.source).toBe('steam-sdk'); + expect(result.sections).toHaveLength(1); + expect(mockInvoke).not.toHaveBeenCalledWith('fetch_workshop_html', expect.anything()); + }); + + test('fetchWorkshopHome allows HTML enrichment when sdkHtmlScope is navigation', async () => { + mockParseHomepageSections.mockReturnValue([ + { + id: 'recent', + title: 'recent', + subtitle: 'recent desc', + icon: null, + items: [], + browseParams: { + sort: 'trend', + section: 'readytouseitems', + }, + }, + ]); + mockParseTagCategories.mockReturnValue([]); + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_home') { + return Promise.resolve({ + source: 'steam-sdk', + sections: [], + }); + } + if (cmd === 'fetch_workshop_html') { + return Promise.resolve(''); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + workshopClient.setWorkshopSourceSettings({ + preset: 'conservative', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + sdkHtmlScope: 'navigation', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', + sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], + cacheRetention: 'keep', + }); + + const result = await workshopClient.fetchWorkshopHome(); + + expect(result.source).toBe('web-fallback'); + expect(mockInvoke).toHaveBeenCalledWith('fetch_workshop_html', expect.objectContaining({ + source: 'workshop-home', + })); + }); + + test('fetchWorkshopItems enriches SDK placeholder author from snapshot cache', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '12345': { + workshopId: '12345', + title: 'Cached Item', + creatorName: 'Cached Author', + creatorSteamId: '76561198000000001', + authorUrl: 'https://steamcommunity.com/profiles/76561198000000001', + }, + }); + } + if (cmd === 'query_workshop_items') { + return Promise.resolve({ + source: 'steam-sdk', + items: [{ + publishedfileid: '12345', + title: 'SDK Item', + creator: '76561198000000001', + creator_steam_id: '76561198000000001', + creator_name: '76561198000000001', + }], + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const result = await workshopClient.fetchWorkshopItems({ query: 'tank' }); + + expect(result.source).toBe('steam-sdk'); + expect(result.items[0]).toMatchObject({ + authorName: 'Cached Author', + authorUrl: 'https://steamcommunity.com/profiles/76561198000000001', + }); + }); + + test('mapSteamDetailToWorkshopItem keeps 64-bit creator id and converts score to stars', async () => { + const workshopClient = await import('./workshopClient'); + const item = workshopClient.mapSteamDetailToWorkshopItem({ + publishedfileid: '12345', + title: 'SDK Item', + creator: '76561198000000001', + creator_steam_id: '76561198000000001', + creator_account_id: '39734273', + creator_name: '', + score: 0.81, + }); + + expect(item.authorId).toBe('76561198000000001'); + expect(item.authorSteamId).toBe('76561198000000001'); + expect(item.authorAccountId).toBe('39734273'); + expect(item.authorName).toBe(''); + expect(item.stars).toBe(4); + }); + + test('fetchWorkshopItems defaults text queries to textsearch sort when none is provided', async () => { + mockInvoke.mockImplementation((cmd, args) => { + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_items') { + expect(args).toEqual(expect.objectContaining({ + query: expect.objectContaining({ + query: 'Early Days', + sort: 'textsearch', + }), + })); + return Promise.resolve({ source: 'steam-sdk', items: [] }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const result = await workshopClient.fetchWorkshopItems({ query: 'Early Days' }); + + expect(result.source).toBe('steam-sdk'); + expect(result.items).toEqual([]); + }); + + test('fetchWorkshopItems reports Steam SDK warnings to the configured reporter', async () => { + const reporter = vi.fn(); + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_items') { + return Promise.resolve({ + source: 'steam-sdk', + items: [], + warnings: ['Steamworks SDK creator persona lookup failed for 1 author(s): 76561198000000001'], + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + workshopClient.setWorkshopWarningReporter(reporter); + + await workshopClient.fetchWorkshopItems({ query: 'test' }); + + expect(reporter).toHaveBeenCalledWith( + 'Steamworks SDK creator persona lookup failed for 1 author(s): 76561198000000001', + ); + + workshopClient.setWorkshopWarningReporter(null); + }); + + test('getWorkshopPageSnapshot derives requiredItems from SDK childItemIds for non-collection items', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({}); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_item') { + return Promise.resolve({ + source: 'steam-sdk', + item: { + publishedfileid: '100', + title: 'Parent Addon', + file_type: 'item', + child_item_ids: ['200', '300'], + }, + }); + } + if (cmd === 'query_workshop_details') { + return Promise.resolve({ + source: 'steam-sdk', + items: [ + { publishedfileid: '200', title: 'Child One' }, + { publishedfileid: '300', title: 'Child Two' }, + ], + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const snapshot = await workshopClient.getWorkshopPageSnapshot('100'); + + expect(snapshot?.requiredItems).toEqual([ + { title: 'Child One', workshopId: '200' }, + { title: 'Child Two', workshopId: '300' }, + ]); + expect(snapshot?.childItemIds).toEqual(['200', '300']); + }); + + test('getWorkshopPageSnapshot does not derive requiredItems from children for collections', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({}); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_item') { + return Promise.resolve({ + source: 'steam-sdk', + item: { + publishedfileid: '100', + title: 'Collection', + file_type: 'collection', + child_item_ids: ['200', '300'], + }, + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const snapshot = await workshopClient.getWorkshopPageSnapshot('100'); + + expect(snapshot?.requiredItems).toEqual([]); + expect(snapshot?.childItemIds).toEqual(['200', '300']); + }); + + test('getWorkshopPageSnapshot derives requiredItems from cached childItemIds when legacy cache stored an empty requiredItems array', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '100': { + workshopId: '100', + title: 'Cached Parent', + fileType: 'item', + requiredItems: [], + childItemIds: ['200', '300'], + }, + '200': { + workshopId: '200', + title: 'Child One', + }, + '300': { + workshopId: '300', + title: 'Child Two', + }, + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const snapshot = await workshopClient.getWorkshopPageSnapshot('100'); + + expect(snapshot?.requiredItems).toEqual([ + { title: 'Child One', workshopId: '200' }, + { title: 'Child Two', workshopId: '300' }, + ]); + expect(snapshot?.childItemIds).toEqual(['200', '300']); + }); + + test('getWorkshopPageSnapshot uses SDK detail enrichment to fill missing cached required item titles', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '100': { + workshopId: '100', + title: 'Cached Parent', + fileType: 'item', + requiredItems: [ + { title: '', workshopId: '200' }, + { title: '', workshopId: '300' }, + ], + childItemIds: ['200', '300'], + }, + }); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ + bridgeAvailable: true, + canQueryItems: true, + canQueryHome: true, + }); + } + if (cmd === 'query_workshop_details') { + return Promise.resolve({ + source: 'steam-sdk', + items: [ + { publishedfileid: '200', title: 'Child One' }, + { publishedfileid: '300', title: 'Child Two' }, + ], + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + const snapshot = await workshopClient.getWorkshopPageSnapshot('100'); + + expect(snapshot?.requiredItems).toEqual([ + { title: 'Child One', workshopId: '200' }, + { title: 'Child Two', workshopId: '300' }, + ]); + }); + + test('fetchWorkshopDependencySnapshot refreshes SDK dependency relations even when a cache entry exists', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '100': { + workshopId: '100', + lastPageFetchedAt: '2026-01-01T00:00:00Z', + requiredItems: [{ title: 'Old dependency', workshopId: '200' }], + }, + '300': { workshopId: '300', title: 'Fresh dependency' }, + }); + } + if (cmd === 'get_workshop_capabilities') { + return Promise.resolve({ bridgeAvailable: true, canQueryItems: true, canQueryHome: true }); + } + if (cmd === 'query_workshop_item') { + return Promise.resolve({ + source: 'steam-sdk', + item: { publishedfileid: '100', file_type: 'item', child_item_ids: ['300'] }, + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + workshopClient.setWorkshopSourceSettings({ + preset: 'conservative', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: false, + sdkHtmlScope: 'search', + sourceOrder: ['steamworks-sdk', 'steam-web-api'], + cacheRetention: 'keep', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', + }); + + const snapshot = await workshopClient.fetchWorkshopDependencySnapshot('100'); + + expect(snapshot.requiredItems).toEqual([{ title: 'Fresh dependency', workshopId: '300' }]); + expect(mockInvoke).toHaveBeenCalledWith('query_workshop_item', { workshopId: '100' }); + }); + + test('fetchWorkshopDependencySnapshot reuses a cached page relation when SDK is unavailable', async () => { + mockInvoke.mockImplementation((cmd) => { + if (cmd === 'get_workshop_cache') { + return Promise.resolve({ + '100': { + workshopId: '100', + lastPageFetchedAt: '2026-01-01T00:00:00Z', + requiredItems: [{ title: 'Cached dependency', workshopId: '200' }], + }, + }); + } + throw new Error(`Unexpected command: ${cmd}`); + }); + + const workshopClient = await import('./workshopClient'); + workshopClient.setSteamworksSdkDisabled(true); + workshopClient.setWorkshopSourceSettings({ + preset: 'conservative', + allowSteamworksSdk: false, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + sdkHtmlScope: 'search', + sourceOrder: ['steamworks-sdk', 'steam-web-api'], + cacheRetention: 'keep', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', + }); + + const snapshot = await workshopClient.fetchWorkshopDependencySnapshot('100'); + + expect(snapshot.requiredItems).toEqual([{ title: 'Cached dependency', workshopId: '200' }]); + expect(mockInvoke).not.toHaveBeenCalledWith('fetch_workshop_html', expect.anything()); + }); +}); diff --git a/src/services/workshopClient.ts b/src/services/workshopClient.ts new file mode 100644 index 0000000..dc1722b --- /dev/null +++ b/src/services/workshopClient.ts @@ -0,0 +1,1143 @@ +import { invoke } from '@tauri-apps/api/core'; +import { + HomepageSection, + TagCategory, + WorkshopCapabilities, + WorkshopItem, + WorkshopPageDetails, +} from '../components/workshop/types'; +import type { WorkshopSourceSettings } from '../types/addon'; +import { isPlaceholderAuthorName as isPlaceholderAuthorValue } from '../utils/addonHelpers'; +import { + parseHomepageSections, + parseSSRItems, + parseTagCategories, + parseWorkshopPageDetails, +} from '../components/workshop/ssrParser'; +import { + rememberWorkshopItems, + rememberWorkshopPageDetails, + resolveWorkshopItemAuthor, +} from '../components/workshop/authorDirectory'; +import { + DEFAULT_WORKSHOP_SOURCE_SETTINGS, + normalizeWorkshopSourceSettings, + shouldAllowSteamCommunityHtmlSource, +} from '../utils/workshopSourceSettings'; + +interface WorkshopHomeResponse { + source: string; + warnings?: string[]; + sections: Array<{ + id: string; + titleKey: string; + subtitleKey: string; + icon: string; + items: any[]; + browseParams: { + sort: string; + section: string; + days?: number; + }; + }>; +} + +interface WorkshopItemsResponse { + source: string; + warnings?: string[]; + items: any[]; +} + +interface WorkshopItemResponse { + source: string; + warnings?: string[]; + item: any; +} + +interface WorkshopCollectionResponse { + source: string; + warnings?: string[]; + collection: any; + items: any[]; +} + +export interface FetchWorkshopItemsInput { + query?: string; + sort?: string; + section?: string; + page?: number; + creatorId?: string | null; + activeTag?: string | null; + activeTagName?: string | null; +} + +let capabilitiesPromise: Promise | null = null; +let steamworksSdkDisabled = false; +let workshopWarningReporter: ((message: string) => void) | null = null; +let workshopSourceSettings: WorkshopSourceSettings = DEFAULT_WORKSHOP_SOURCE_SETTINGS; + +export function setSteamworksSdkDisabled(disabled: boolean) { + steamworksSdkDisabled = disabled; +} + +export function setWorkshopSourceSettings(settings: WorkshopSourceSettings | undefined) { + workshopSourceSettings = normalizeWorkshopSourceSettings(settings); +} + +export function setWorkshopWarningReporter(reporter: ((message: string) => void) | null) { + workshopWarningReporter = reporter; +} + +function shouldUseSteamworksSdk() { + return !steamworksSdkDisabled && + workshopSourceSettings.preset !== 'offline' && + workshopSourceSettings.allowSteamworksSdk; +} + +function shouldUseSteamCommunityHtmlFor(source: string, capabilities: WorkshopCapabilities | null) { + const sdkAvailable = shouldUseSteamworksSdk() && !!(capabilities?.canQueryHome || capabilities?.canQueryItems); + return shouldAllowSteamCommunityHtmlSource(workshopSourceSettings, source, sdkAvailable); +} + +function listCacheKey(kind: string, input: unknown) { + return `l4a.workshop.${kind}.${JSON.stringify(input)}`; +} + +function readListCache(key: string): T | null { + try { + const raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) as T : null; + } catch { + return null; + } +} + +function writeListCache(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify({ savedAt: new Date().toISOString(), value })); + } catch (err) { + console.warn('Failed to save workshop list cache:', err); + } +} + +function readCachedValue(key: string): T | null { + const cached = readListCache<{ value: T }>(key); + return cached?.value || null; +} + +async function readWorkshopItemCache(): Promise> { + try { + return await invoke>('get_workshop_cache'); + } catch { + return {}; + } +} + +function cachedDetailToWorkshopItem(workshopId: string, detail: any): WorkshopItem { + return mapSteamDetailToWorkshopItem({ + ...detail, + publishedfileid: detail?.publishedfileid || detail?.workshopId || workshopId, + preview_url: detail?.preview_url || detail?.previewUrl || detail?.imagePath, + creator_name: detail?.creator_name || detail?.creatorName || detail?.authorName, + creator: detail?.creator || detail?.creatorId || detail?.authorId, + creator_steam_id: detail?.creator_steam_id || detail?.creatorSteamId, + creator_account_id: detail?.creator_account_id || detail?.creatorAccountId, + }, detail?.lastSeenSource || detail?.lastPageSource || 'cache'); +} + +function normalizeCachedTags(tags: any): { category: string; name: string }[] { + if (!Array.isArray(tags)) return []; + return tags + .map((tag) => { + if (typeof tag === 'string') { + return { category: '', name: tag }; + } + return { + category: normalizeText(tag?.category), + name: normalizeText(tag?.name || tag?.display_name || tag?.tag), + }; + }) + .filter((tag) => tag.name); +} + +function normalizeCachedRelations(value: any): { title: string; workshopId: string }[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => ({ + title: normalizeText(item?.title), + workshopId: normalizeText(item?.workshopId || item?.publishedfileid), + })) + .filter((item) => item.workshopId); +} + +function normalizeCachedWorkshopItems(value: any): WorkshopItem[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => { + if (!item?.workshopId && !item?.publishedfileid) return null; + return { + workshopId: normalizeText(item.workshopId || item.publishedfileid), + title: normalizeText(item.title), + imagePath: normalizeText(item.imagePath || item.previewUrl || item.preview_url), + authorName: normalizeText(item.authorName || item.creatorName || item.creator_name), + authorId: normalizeText(item.authorId || item.creatorId || item.creator), + authorUrl: normalizeText(item.authorUrl || item.creatorProfileUrl), + authorSteamId: normalizeText(item.authorSteamId || item.creatorSteamId) || undefined, + authorVanityId: normalizeText(item.authorVanityId || item.creatorVanityId) || undefined, + authorAccountId: normalizeText(item.authorAccountId || item.creatorAccountId) || undefined, + stars: Number(item.stars || item.ratingStars || 0), + shortDescription: normalizeText(item.shortDescription || item.description) || undefined, + }; + }) + .filter(Boolean) as WorkshopItem[]; +} + +function normalizeChildItemIds(value: any): string[] | undefined { + if (!Array.isArray(value)) return undefined; + const ids = value.map(String).filter(Boolean); + return ids.length > 0 ? ids : undefined; +} + +function deriveRequiredItemsFromChildren( + childItemIds: string[] | undefined, + fileType: unknown, + cache?: Record, +): { title: string; workshopId: string }[] { + if (!Array.isArray(childItemIds) || childItemIds.length === 0) { + return []; + } + if (normalizeText(fileType).toLowerCase() === 'collection') { + return []; + } + return childItemIds.map((workshopId) => ({ + title: normalizeText(cache?.[workshopId]?.title), + workshopId, + })); +} + +function mapSteamDetailToPageDetails(detail: any, cache?: Record): WorkshopPageDetails { + const childItemIds = normalizeChildItemIds(detail?.child_item_ids || detail?.childItemIds); + const fileType = normalizeText(detail?.file_type || detail?.fileType) || undefined; + return { + fileType, + title: normalizeText(detail?.title) || undefined, + previewUrl: normalizeText(detail?.preview_url || detail?.previewUrl || detail?.imagePath) || undefined, + description: normalizeText(detail?.description || detail?.short_description || detail?.shortDescription) || undefined, + creatorName: normalizeText(detail?.creator_name || detail?.creatorName || detail?.authorName) || undefined, + creatorProfileUrl: normalizeText(detail?.creatorProfileUrl || detail?.authorUrl) || undefined, + creatorSteamId: normalizeText(detail?.creator_steam_id || detail?.creatorSteamId || detail?.creator) || undefined, + creatorVanityId: normalizeText(detail?.creator_vanity_id || detail?.creatorVanityId) || undefined, + creatorAccountId: normalizeText(detail?.creator_account_id || detail?.creatorAccountId) || undefined, + imageGallery: Array.isArray(detail?.galleryUrls || detail?.galleryPreviewUrls) + ? (detail.galleryUrls || detail.galleryPreviewUrls).map(String).filter(Boolean) + : [], + tags: normalizeCachedTags(detail?.pageTags || detail?.tags), + requiredItems: normalizeCachedRelations(detail?.requiredItems).length > 0 + ? normalizeCachedRelations(detail?.requiredItems) + : deriveRequiredItemsFromChildren(childItemIds, fileType, cache), + collectionItems: normalizeCachedWorkshopItems(detail?.collectionItems), + childItemIds, + parentCollections: normalizeCachedRelations(detail?.parentCollections), + fileSizeDisplay: normalizeText(detail?.fileSizeDisplay || detail?.file_size || detail?.fileSize) || undefined, + postedDateText: normalizeText(detail?.postedDateText) || undefined, + updatedDateText: normalizeText(detail?.updatedDateText) || undefined, + changeNoteCount: toNumericOrUndefined(detail?.changeNoteCount), + ratingStars: toNumericOrUndefined(detail?.ratingStars), + ratingCount: toNumericOrUndefined(detail?.ratingCount), + uniqueVisitors: toNumericOrUndefined(detail?.uniqueVisitors), + currentSubscribers: toNumericOrUndefined(detail?.currentSubscribers || detail?.subscriptions), + currentFavorites: toNumericOrUndefined(detail?.currentFavorites || detail?.favorites || detail?.favorited), + backgroundImageUrl: normalizeText(detail?.backgroundImageUrl) || undefined, + }; +} + +function mapCachedDetailToPageDetails(detail: any, cache?: Record): WorkshopPageDetails { + const steamDetails = detail?.steamDetails || {}; + const pageDetails = mapSteamDetailToPageDetails({ + ...steamDetails, + ...detail, + title: detail?.title || steamDetails?.title, + preview_url: detail?.previewUrl || detail?.imagePath || detail?.preview_url || steamDetails?.preview_url, + description: detail?.description || steamDetails?.description || detail?.shortDescription, + creator_name: detail?.creatorName || detail?.authorName || detail?.creator_name || steamDetails?.creator_name, + creatorProfileUrl: detail?.creatorProfileUrl || detail?.authorUrl, + creator_steam_id: detail?.creatorSteamId || detail?.creator_steam_id || steamDetails?.creator, + creator_vanity_id: detail?.creatorVanityId || steamDetails?.creator_vanity_id, + creator_account_id: detail?.creatorAccountId || detail?.creator_account_id || steamDetails?.creator_account_id, + galleryUrls: detail?.imageGallery || detail?.galleryUrls || detail?.galleryPreviewUrls, + pageTags: detail?.pageTags || detail?.tags || steamDetails?.tags, + requiredItems: detail?.requiredItems, + collectionItems: detail?.collectionItems, + childItemIds: detail?.childItemIds, + parentCollections: detail?.parentCollections, + fileSizeDisplay: detail?.fileSizeDisplay || detail?.fileSize || steamDetails?.file_size, + file_type: detail?.fileType || detail?.file_type || steamDetails?.file_type, + }, cache); + return { + ...pageDetails, + descriptionHtml: normalizeText(detail?.descriptionHtml) || undefined, + }; +} + +function normalizePageDetails(details: Partial | null | undefined): WorkshopPageDetails { + const childItemIds = normalizeChildItemIds(details?.childItemIds); + const cachedRequiredItems = Array.isArray(details?.requiredItems) + ? details.requiredItems.filter((item) => normalizeText(item?.workshopId)) + : []; + const requiredItems = cachedRequiredItems.length > 0 + ? cachedRequiredItems + : deriveRequiredItemsFromChildren(childItemIds, details?.fileType); + return { + ...details, + fileType: normalizeText(details?.fileType) || undefined, + imageGallery: Array.isArray(details?.imageGallery) ? details.imageGallery.filter(Boolean) : [], + tags: Array.isArray(details?.tags) ? details.tags.filter((tag) => normalizeText(tag?.name)) : [], + requiredItems, + collectionItems: Array.isArray(details?.collectionItems) + ? details.collectionItems.filter((item) => normalizeText(item?.workshopId)) + : [], + childItemIds, + parentCollections: Array.isArray(details?.parentCollections) + ? details.parentCollections.filter((item) => normalizeText(item?.workshopId)) + : [], + }; +} + +function chooseDescription(current?: string, incoming?: string): string | undefined { + const existing = normalizeText(current); + const next = normalizeText(incoming); + return existing || next || undefined; +} + +function chooseArray(current: T[] | undefined, incoming: T[] | undefined): T[] { + const existing = Array.isArray(current) ? current : []; + const next = Array.isArray(incoming) ? incoming : []; + return next.length > 0 ? next : existing; +} + +function chooseNumber(current: number | undefined, incoming: number | undefined): number | undefined { + return incoming !== undefined ? incoming : current; +} + +function resolvePageDetailsAuthor(details: WorkshopPageDetails): WorkshopPageDetails { + const resolved = resolveWorkshopItemAuthor({ + workshopId: '', + title: details.title || '', + imagePath: details.previewUrl || '', + authorName: details.creatorName || '', + authorId: details.creatorSteamId || details.creatorVanityId || details.creatorAccountId || '', + authorUrl: details.creatorProfileUrl || '', + authorSteamId: details.creatorSteamId, + authorVanityId: details.creatorVanityId, + authorAccountId: details.creatorAccountId, + stars: 0, + }); + + return { + ...details, + creatorName: resolved.authorName || details.creatorName, + creatorProfileUrl: resolved.authorUrl || details.creatorProfileUrl, + creatorSteamId: resolved.authorSteamId || details.creatorSteamId, + creatorVanityId: resolved.authorVanityId || details.creatorVanityId, + creatorAccountId: resolved.authorAccountId || details.creatorAccountId, + }; +} + +export function mergeWorkshopPageDetails( + current: Partial | null | undefined, + incoming: Partial | null | undefined, +): WorkshopPageDetails { + const existing = normalizePageDetails(current); + const next = normalizePageDetails(incoming); + const merged = resolvePageDetailsAuthor(normalizePageDetails({ + fileType: normalizeText(next.fileType) || existing.fileType, + title: normalizeText(next.title) || existing.title, + previewUrl: normalizeText(next.previewUrl) || existing.previewUrl, + description: chooseDescription(existing.description, next.description), + descriptionHtml: normalizeText(next.descriptionHtml) || existing.descriptionHtml, + creatorName: normalizeText(next.creatorName) || existing.creatorName, + creatorProfileUrl: normalizeText(next.creatorProfileUrl) || existing.creatorProfileUrl, + creatorSteamId: normalizeText(next.creatorSteamId) || existing.creatorSteamId, + creatorVanityId: normalizeText(next.creatorVanityId) || existing.creatorVanityId, + creatorAccountId: normalizeText(next.creatorAccountId) || existing.creatorAccountId, + imageGallery: chooseArray(existing.imageGallery, next.imageGallery), + tags: chooseArray(existing.tags, next.tags), + requiredItems: chooseArray(existing.requiredItems, next.requiredItems), + collectionItems: chooseArray(existing.collectionItems, next.collectionItems), + childItemIds: chooseArray(existing.childItemIds, next.childItemIds), + parentCollections: chooseArray(existing.parentCollections, next.parentCollections), + fileSizeDisplay: normalizeText(next.fileSizeDisplay) || existing.fileSizeDisplay, + postedDateText: normalizeText(next.postedDateText) || existing.postedDateText, + updatedDateText: normalizeText(next.updatedDateText) || existing.updatedDateText, + changeNoteCount: chooseNumber(existing.changeNoteCount, next.changeNoteCount), + ratingStars: chooseNumber(existing.ratingStars, next.ratingStars), + ratingCount: chooseNumber(existing.ratingCount, next.ratingCount), + uniqueVisitors: chooseNumber(existing.uniqueVisitors, next.uniqueVisitors), + currentSubscribers: chooseNumber(existing.currentSubscribers, next.currentSubscribers), + currentFavorites: chooseNumber(existing.currentFavorites, next.currentFavorites), + backgroundImageUrl: normalizeText(next.backgroundImageUrl) || existing.backgroundImageUrl, + })); + rememberWorkshopPageDetails(merged); + return merged; +} + +function hasUsefulPageSnapshot(details: WorkshopPageDetails): boolean { + return Boolean( + details.title || + details.creatorName || + details.description || + details.imageGallery.length > 0 || + details.requiredItems.length > 0 || + (details.collectionItems?.length || 0) > 0 || + (details.childItemIds?.length || 0) > 0 || + details.parentCollections.length > 0 || + details.tags.length > 0, + ); +} + +function cachedCollectionResponse(workshopId: string, collection: any, cache: Record, source: string) { + const resolvedCollection = cachedDetailToWorkshopItem(workshopId, collection); + const childIds = collection.childItemIds || []; + const cachedItems = collection.collectionItems || []; + return { + source, + collection: { + ...collection, + publishedfileid: workshopId, + title: resolvedCollection.title, + preview_url: resolvedCollection.imagePath, + creator_name: resolvedCollection.authorName, + creator: resolvedCollection.authorId, + creator_steam_id: resolvedCollection.authorSteamId, + creator_account_id: resolvedCollection.authorAccountId, + }, + items: rememberWorkshopItems( + (cachedItems.length > 0 + ? cachedItems.map((item: any) => ({ + ...cachedDetailToWorkshopItem(item.workshopId || item.publishedfileid, item), + ...item, + })) + : childIds.map((childId: string) => cache[childId] ? cachedDetailToWorkshopItem(childId, cache[childId]) : null)) + .filter(Boolean), + ), + }; +} + +async function enrichItemsFromSnapshot(items: WorkshopItem[]): Promise { + if (items.length === 0) return items; + const cache = await readWorkshopItemCache(); + if (Object.keys(cache).length === 0) return items; + const cachedItems = items + .map((item) => cache[item.workshopId] ? cachedDetailToWorkshopItem(item.workshopId, cache[item.workshopId]) : null) + .filter((item): item is WorkshopItem => Boolean(item)); + return enrichWorkshopItems(items, cachedItems); +} + +function resolveBrowseSort(input: FetchWorkshopItemsInput): string | undefined { + if (input.query?.trim()) { + return input.sort || 'textsearch'; + } + return input.sort || undefined; +} + +function reportWorkshopWarnings(warnings?: string[]) { + if (!Array.isArray(warnings) || warnings.length === 0) { + return; + } + + const uniqueWarnings = [...new Set(warnings.map((warning) => normalizeText(warning)).filter(Boolean))]; + for (const warning of uniqueWarnings) { + if (workshopWarningReporter) { + workshopWarningReporter(warning); + } else { + console.error('Workshop warning:', warning); + } + } +} + +function normalizeText(value: unknown): string { + return String(value || '').trim(); +} + +function hasMissingRequiredItemTitles(details: Partial | null | undefined): boolean { + return Array.isArray(details?.requiredItems) + && details.requiredItems.some((item) => normalizeText(item?.workshopId) && !normalizeText(item?.title)); +} + +async function enrichRequiredItemsWithSdkDetails( + details: WorkshopPageDetails, + cache?: Record, +): Promise { + if (!shouldUseSteamworksSdk() || !hasMissingRequiredItemTitles(details)) { + return details; + } + + const capabilities = await getWorkshopCapabilities().catch(() => null); + if (!capabilities?.canQueryItems) { + return details; + } + + const missingIds = [...new Set( + (details.requiredItems || []) + .filter((item) => normalizeText(item?.workshopId) && !normalizeText(item?.title)) + .map((item) => normalizeText(item.workshopId)), + )]; + if (missingIds.length === 0) { + return details; + } + + try { + const data = await invoke('query_workshop_details', { workshopIds: missingIds }); + reportWorkshopWarnings(data.warnings); + const titleById = new Map(); + for (const item of data.items || []) { + const workshopId = normalizeText(item?.publishedfileid || item?.workshopId); + if (!workshopId) continue; + const title = normalizeText(item?.title || cache?.[workshopId]?.title); + if (title) { + titleById.set(workshopId, title); + } + } + + return mergeWorkshopPageDetails(details, { + requiredItems: (details.requiredItems || []).map((item) => ({ + ...item, + title: normalizeText(item.title) || titleById.get(normalizeText(item.workshopId)) || item.title, + })), + }); + } catch (err) { + console.warn('Steam SDK required item title enrichment failed:', err); + return details; + } +} + +function accountIdToSteamId(accountId: unknown): string { + const normalized = normalizeText(accountId); + if (!/^\d+$/.test(normalized)) return ''; + try { + return (BigInt(normalized) + 76561197960265728n).toString(); + } catch { + return ''; + } +} + +function isPlaceholderAuthorName(name: unknown, ids: string[]): boolean { + return isPlaceholderAuthorValue(name, ids); +} + +function toNumericOrUndefined(value: unknown): number | undefined { + const normalized = normalizeText(value); + if (!normalized) return undefined; + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function clampStars(value: number): number { + return Math.max(0, Math.min(5, value)); +} + +function mapSteamScoreToStars(detail: any): number { + const explicitStars = toNumericOrUndefined(detail.star_rating ?? detail.ratingStars); + if (explicitStars !== undefined) { + return clampStars(explicitStars); + } + + const score = toNumericOrUndefined(detail.score); + if (score === undefined) { + return 0; + } + + if (score <= 1) { + return clampStars(Math.round(score * 5)); + } + + return clampStars(Math.round(score)); +} + +function needsHtmlAuthorEnrichment(item: WorkshopItem): boolean { + return isPlaceholderAuthorName(item.authorName, [ + item.authorSteamId || '', + item.authorAccountId || '', + item.authorId || '', + item.ownerSteamId || '', + item.ownerAccountId || '', + ]) || !normalizeText(item.authorUrl); +} + +function enrichWorkshopItems(primary: WorkshopItem[], fallback: WorkshopItem[]): WorkshopItem[] { + const fallbackMap = new Map(fallback.map((item) => [item.workshopId, item])); + + return primary.map((item) => { + const fallbackItem = fallbackMap.get(item.workshopId); + if (!fallbackItem) { + return item; + } + + const primaryAuthorIsPlaceholder = isPlaceholderAuthorName(item.authorName, [ + item.authorSteamId || '', + item.authorAccountId || '', + item.authorId || '', + item.ownerSteamId || '', + item.ownerAccountId || '', + ]); + const fallbackAuthorIsUsable = !isPlaceholderAuthorName(fallbackItem.authorName, [ + fallbackItem.authorSteamId || '', + fallbackItem.authorAccountId || '', + fallbackItem.authorId || '', + fallbackItem.ownerSteamId || '', + fallbackItem.ownerAccountId || '', + ]); + + return { + ...item, + authorName: primaryAuthorIsPlaceholder && fallbackAuthorIsUsable + ? fallbackItem.authorName + : normalizeText(item.authorName) || fallbackItem.authorName, + authorId: item.authorId || fallbackItem.authorId, + authorUrl: normalizeText(item.authorUrl) || fallbackItem.authorUrl, + authorSteamId: item.authorSteamId || fallbackItem.authorSteamId, + authorVanityId: item.authorVanityId || fallbackItem.authorVanityId, + authorAccountId: item.authorAccountId || fallbackItem.authorAccountId, + ownerSteamId: item.ownerSteamId || fallbackItem.ownerSteamId, + ownerAccountId: item.ownerAccountId || fallbackItem.ownerAccountId, + stars: item.stars > 0 ? item.stars : fallbackItem.stars, + imagePath: item.imagePath || fallbackItem.imagePath, + shortDescription: chooseDescription(item.shortDescription, fallbackItem.shortDescription), + }; + }); +} + +function enrichHomepageSections(primary: HomepageSection[], fallback: HomepageSection[]) { + const primaryMap = new Map(primary.map((section) => [section.id, section])); + const merged: HomepageSection[] = []; + + for (const section of fallback) { + const primarySection = primaryMap.get(section.id); + if (primarySection) { + merged.push({ + ...primarySection, + items: enrichWorkshopItems(primarySection.items, section.items), + }); + primaryMap.delete(section.id); + continue; + } + merged.push(section); + } + + for (const section of primaryMap.values()) { + merged.push(section); + } + + return merged; +} + +export async function getWorkshopCapabilities(): Promise { + capabilitiesPromise ??= invoke('get_workshop_capabilities').catch((err) => { + capabilitiesPromise = null; + throw err; + }); + return capabilitiesPromise; +} + +export async function fetchWorkshopHome() { + const capabilities = shouldUseSteamworksSdk() + ? await getWorkshopCapabilities().catch(() => null) + : null; + const cacheKey = listCacheKey('home', { version: 1 }); + let sdkSections: HomepageSection[] = []; + let source = 'web-fallback'; + + if (capabilities?.canQueryHome) { + try { + const data = await invoke('query_workshop_home'); + reportWorkshopWarnings(data.warnings); + sdkSections = data.sections.map((section) => ({ + id: section.id, + title: section.titleKey, + subtitle: section.subtitleKey, + icon: section.icon, + items: rememberWorkshopItems( + section.items.map((item) => mapSteamDetailToWorkshopItem(item, data.source)), + ), + browseParams: section.browseParams, + })); + sdkSections = await Promise.all(sdkSections.map(async (section) => ({ + ...section, + items: rememberWorkshopItems(await enrichItemsFromSnapshot(section.items)), + }))); + source = data.source; + } catch (err) { + console.warn('Steam SDK home query failed, falling back to HTML:', err); + } + } + + const allowHtml = shouldUseSteamCommunityHtmlFor('workshop-home', capabilities); + + if (sdkSections.length === 0) { + if (!allowHtml) { + const cached = readCachedValue(cacheKey); + if (cached) return { ...cached, source: 'cache' }; + return { + source: 'cache', + sections: [], + tagCategories: [], + }; + } + const html = await invoke('fetch_workshop_html', { + url: 'https://steamcommunity.com/app/550/workshop/', + source: 'workshop-home', + }); + const htmlSections = await Promise.all(parseHomepageSections(html).map(async (section) => ({ + ...section, + items: rememberWorkshopItems(await enrichItemsFromSnapshot(section.items)), + }))); + const tagCategories = parseTagCategories(html); + const result = { + source: 'web-fallback', + sections: htmlSections, + tagCategories, + }; + writeListCache(cacheKey, result); + return result; + } + + let tagCategories: TagCategory[] = []; + let htmlSections: HomepageSection[] = []; + if (allowHtml) try { + const html = await invoke('fetch_workshop_html', { + url: 'https://steamcommunity.com/app/550/workshop/', + source: 'workshop-home', + }); + htmlSections = await Promise.all(parseHomepageSections(html).map(async (section) => ({ + ...section, + items: rememberWorkshopItems(await enrichItemsFromSnapshot(section.items)), + }))); + tagCategories = parseTagCategories(html); + } catch (err) { + console.warn('Workshop home HTML enrichment failed:', err); + } + + const result = { + source: htmlSections.length > 0 && source === 'steam-sdk' ? 'hybrid' : source, + sections: enrichHomepageSections(sdkSections, htmlSections), + tagCategories, + }; + writeListCache(cacheKey, result); + return result; +} + +export async function fetchWorkshopItems(input: FetchWorkshopItemsInput) { + const capabilities = shouldUseSteamworksSdk() + ? await getWorkshopCapabilities().catch(() => null) + : null; + const resolvedSort = resolveBrowseSort(input); + const cacheKey = listCacheKey('items', { ...input, sort: resolvedSort || '' }); + if (capabilities?.canQueryItems) { + const creatorId = input.creatorId?.trim(); + const creatorNumeric = !creatorId || /^\d+$/.test(creatorId); + if (creatorNumeric) { + try { + const data = await invoke('query_workshop_items', { + query: { + query: input.query || undefined, + sort: resolvedSort, + section: input.section || undefined, + page: input.page || 1, + creatorId: creatorId || undefined, + activeTag: input.activeTag || undefined, + activeTagName: input.activeTagName || undefined, + }, + }); + reportWorkshopWarnings(data.warnings); + let items = data.items.map((item) => mapSteamDetailToWorkshopItem(item, data.source)); + items = await enrichItemsFromSnapshot(items); + if (items.some(needsHtmlAuthorEnrichment) && shouldUseSteamCommunityHtmlFor( + input.creatorId ? 'workshop-creator' : input.query ? 'workshop-search' : 'workshop-browse', + capabilities, + )) { + try { + const html: string = await invoke('fetch_workshop_html', { + url: buildBrowseUrl(input), + source: input.creatorId ? 'workshop-creator' : input.query ? 'workshop-search' : 'workshop-browse', + }); + items = enrichWorkshopItems(items, parseSSRItems(html, 'workshop_query')); + } catch (err) { + console.warn('Steam SDK browse HTML enrichment failed:', err); + } + } + const result = { + source: data.source, + items: rememberWorkshopItems(items), + }; + writeListCache(cacheKey, result); + return result; + } catch (err) { + console.warn('Steam SDK browse query failed, falling back to HTML:', err); + } + } + } + + if (!shouldUseSteamCommunityHtmlFor( + input.creatorId ? 'workshop-creator' : input.query ? 'workshop-search' : 'workshop-browse', + capabilities, + )) { + const cached = readCachedValue(cacheKey); + if (cached) return { ...cached, source: 'cache' }; + return { source: 'cache', items: [] }; + } + + const url = buildBrowseUrl(input); + try { + const html: string = await invoke('fetch_workshop_html', { + url, + source: input.creatorId ? 'workshop-creator' : input.query ? 'workshop-search' : 'workshop-browse', + }); + const items = await enrichItemsFromSnapshot(parseSSRItems(html, 'workshop_query')); + const result = { + source: 'web-fallback', + items: rememberWorkshopItems(items), + }; + writeListCache(cacheKey, result); + return result; + } catch (err) { + const cached = readCachedValue(cacheKey); + if (cached) return { ...cached, source: 'cache' }; + throw err; + } +} + +export async function fetchWorkshopItem(workshopId: string) { + const cache = await readWorkshopItemCache(); + if (cache[workshopId]) { + return { + source: 'snapshot', + item: rememberWorkshopItems([cachedDetailToWorkshopItem(workshopId, cache[workshopId])])[0], + }; + } + + const capabilities = shouldUseSteamworksSdk() + ? await getWorkshopCapabilities().catch(() => null) + : null; + if (capabilities?.canQueryItems) { + try { + const data = await invoke('query_workshop_item', { workshopId }); + reportWorkshopWarnings(data.warnings); + return { + source: data.source, + item: rememberWorkshopItems([mapSteamDetailToWorkshopItem(data.item, data.source)])[0], + }; + } catch (err) { + console.warn('Steam SDK item query failed, falling back to existing collection command:', err); + } + } + + try { + const data: any = await invoke('fetch_collection', { collectionId: workshopId }); + return { + source: 'web-fallback', + item: rememberWorkshopItems([mapSteamDetailToWorkshopItem(data.collection, 'web-fallback')])[0], + }; + } catch (err) { + if (cache[workshopId]) { + return { + source: 'cache', + item: rememberWorkshopItems([cachedDetailToWorkshopItem(workshopId, cache[workshopId])])[0], + }; + } + throw err; + } +} + +export async function fetchWorkshopCollection(workshopId: string) { + const cache = await readWorkshopItemCache(); + const cachedCollection = cache[workshopId]; + if (cachedCollection && (cachedCollection.childItemIds?.length || cachedCollection.collectionItems?.length)) { + return cachedCollectionResponse(workshopId, cachedCollection, cache, 'snapshot'); + } + + const capabilities = shouldUseSteamworksSdk() + ? await getWorkshopCapabilities().catch(() => null) + : null; + if (capabilities?.canQueryItems) { + try { + const data = await invoke('query_workshop_collection', { workshopId }); + reportWorkshopWarnings(data.warnings); + if (!data?.collection?.publishedfileid) { + throw new Error('Steam SDK returned empty collection details'); + } + const resolvedCollection = rememberWorkshopItems([ + mapSteamDetailToWorkshopItem(data.collection, data.source), + ])[0]; + return { + source: data.source, + collection: { + ...data.collection, + title: resolvedCollection.title, + preview_url: resolvedCollection.imagePath, + creator_name: resolvedCollection.authorName, + creator: resolvedCollection.authorId, + creator_steam_id: resolvedCollection.authorSteamId, + creator_account_id: resolvedCollection.authorAccountId, + }, + items: rememberWorkshopItems( + await enrichItemsFromSnapshot(data.items.map((item) => mapSteamDetailToWorkshopItem(item, data.source))), + ), + }; + } catch (err) { + console.warn('Steam SDK collection query failed, falling back to existing collection command:', err); + } + } + + try { + const data: any = await invoke('fetch_collection', { collectionId: workshopId }); + if (!data?.collection?.publishedfileid && cachedCollection) { + return cachedCollectionResponse(workshopId, cachedCollection, cache, 'cache'); + } + const resolvedCollection = rememberWorkshopItems([ + mapSteamDetailToWorkshopItem(data.collection, 'web-fallback'), + ])[0]; + return { + source: 'web-fallback', + collection: { + ...data.collection, + title: resolvedCollection.title, + preview_url: resolvedCollection.imagePath, + creator_name: resolvedCollection.authorName, + creator: resolvedCollection.authorId, + creator_steam_id: resolvedCollection.authorSteamId, + creator_account_id: resolvedCollection.authorAccountId, + }, + items: rememberWorkshopItems( + await enrichItemsFromSnapshot((data.items || []).map((item: any) => mapSteamDetailToWorkshopItem(item, 'web-fallback'))), + ), + }; + } catch (err) { + const collection = cache[workshopId]; + if (collection) { + return cachedCollectionResponse(workshopId, collection, cache, 'cache'); + } + throw err; + } +} + +export async function fetchWorkshopHtml(url: string, source: string) { + return invoke('fetch_workshop_html', { url, source }); +} + +export async function fetchWorkshopPageDetails(workshopId: string, source: string) { + const url = `https://steamcommunity.com/sharedfiles/filedetails/?id=${workshopId}`; + const snapshot = await getWorkshopPageSnapshot(workshopId); + try { + const html = await fetchWorkshopHtml(url, source); + const details = mergeWorkshopPageDetails(snapshot, parseWorkshopPageDetails(html)); + return enrichRequiredItemsWithSdkDetails(details); + } catch (err) { + if (snapshot) return snapshot; + throw err; + } +} + +/** + * Resolve the dependency relation for one item without letting an item snapshot + * hide a newer SDK relation. HTML is intentionally cache-first to respect the + * Community fetch gate. + */ +export async function fetchWorkshopDependencySnapshot(workshopId: string): Promise { + const cache = await readWorkshopItemCache(); + const cachedEntry = cache[workshopId]; + const cachedDetails = cachedEntry + ? mapCachedDetailToPageDetails(cachedEntry, cache) + : null; + const hasCachedPage = Boolean(cachedEntry?.lastPageFetchedAt); + const hasCachedDependencyRelation = hasCachedPage || + Array.isArray(cachedEntry?.requiredItems) || + Array.isArray(cachedEntry?.childItemIds); + const capabilities = shouldUseSteamworksSdk() + ? await getWorkshopCapabilities().catch(() => null) + : null; + const canUseSdk = Boolean(capabilities?.canQueryItems); + const shouldRefreshSdk = workshopSourceSettings.dependencySdkRefresh === 'always' || !hasCachedDependencyRelation; + let details = cachedDetails; + let sdkProvidedRelation = false; + + if (canUseSdk && shouldRefreshSdk) { + try { + const data = await invoke('query_workshop_item', { workshopId }); + reportWorkshopWarnings(data.warnings); + const sdkDetails = await enrichRequiredItemsWithSdkDetails( + mapSteamDetailToPageDetails(data.item, cache), + cache, + ); + sdkProvidedRelation = Array.isArray(data.item?.child_item_ids) || + Array.isArray(data.item?.childItemIds) || + Boolean(normalizeText(data.item?.file_type || data.item?.fileType)); + details = mergeWorkshopPageDetails(cachedDetails, sdkDetails); + if (sdkProvidedRelation) { + details = { + ...details, + fileType: sdkDetails.fileType || details.fileType, + childItemIds: sdkDetails.childItemIds, + requiredItems: sdkDetails.requiredItems, + }; + } + } catch (err) { + console.warn('Steam SDK dependency query failed:', err); + } + } + + const shouldFetchHtml = shouldUseSteamCommunityHtmlFor('dependency-check', capabilities) && ( + workshopSourceSettings.dependencyHtmlRefresh === 'always' || !hasCachedPage + ); + if (shouldFetchHtml) { + try { + const url = `https://steamcommunity.com/sharedfiles/filedetails/?id=${workshopId}`; + const html = await fetchWorkshopHtml(url, 'dependency-check'); + const htmlDetails = parseWorkshopPageDetails(html); + details = { + ...mergeWorkshopPageDetails(details, htmlDetails), + requiredItems: htmlDetails.requiredItems, + }; + } catch (err) { + if (!sdkProvidedRelation && !hasCachedDependencyRelation) { + throw err; + } + console.warn('Steam Community dependency page fetch failed:', err); + } + } + + if (!details || (!sdkProvidedRelation && !hasCachedDependencyRelation && !shouldFetchHtml)) { + throw new Error('No enabled source can determine this item\'s dependencies'); + } + + return enrichRequiredItemsWithSdkDetails(details, cache); +} + +export async function getWorkshopPageSnapshot(workshopId: string): Promise { + const cache = await readWorkshopItemCache(); + const detail = cache[workshopId]; + if (detail) { + const snapshot = await enrichRequiredItemsWithSdkDetails( + mergeWorkshopPageDetails(null, mapCachedDetailToPageDetails(detail, cache)), + cache, + ); + if (hasUsefulPageSnapshot(snapshot)) { + return snapshot; + } + } + + const capabilities = shouldUseSteamworksSdk() + ? await getWorkshopCapabilities().catch(() => null) + : null; + if (!capabilities?.canQueryItems) { + return null; + } + + try { + const data = await invoke('query_workshop_item', { workshopId }); + reportWorkshopWarnings(data.warnings); + const snapshot = await enrichRequiredItemsWithSdkDetails( + mergeWorkshopPageDetails(null, mapSteamDetailToPageDetails(data.item, cache)), + cache, + ); + return hasUsefulPageSnapshot(snapshot) ? snapshot : null; + } catch (err) { + console.warn('Steam SDK snapshot query failed:', err); + return null; + } +} + +export async function persistWorkshopPageDetails(workshopId: string, details: any, source: string) { + return invoke('persist_workshop_page_details', { + workshopId, + details, + source, + }); +} + +function buildBrowseUrl(input: FetchWorkshopItemsInput) { + let url = 'https://steamcommunity.com/workshop/browse/?appid=550'; + if (input.creatorId) { + url += `&browsesort=myfiles&creatorid=${input.creatorId}&p=${input.page || 1}`; + } else { + const resolvedSort = resolveBrowseSort(input) || 'trend'; + if (input.query) { + url += `&searchtext=${encodeURIComponent(input.query)}`; + } + url += `&browsesort=${resolvedSort}§ion=${input.section || 'readytouseitems'}&p=${input.page || 1}`; + if (input.activeTag) { + url += `&requiredtags[]=${encodeURIComponent(input.activeTagName || input.activeTag)}`; + } + } + return url; +} + +export function mapSteamDetailToWorkshopItem(detail: any, source = 'steam-sdk'): WorkshopItem { + const ownerSteamId = normalizeText( + detail.creator_steam_id || + detail.creatorSteamId || + detail.owner_steam_id || + detail.ownerSteamId || + (detail.creator && /^\d{17,20}$/.test(String(detail.creator)) ? detail.creator : '') || + accountIdToSteamId(detail.creator_account_id || detail.creatorAccountId || detail.owner_account_id || detail.ownerAccountId) || + '', + ); + const ownerAccountId = normalizeText( + detail.creator_account_id || + detail.creatorAccountId || + detail.owner_account_id || + detail.ownerAccountId || + '', + ); + const authorSteamId = ownerSteamId || undefined; + const authorAccountId = ownerAccountId || undefined; + const authorId = ownerSteamId || ownerAccountId || normalizeText(detail.creator); + const rawAuthorName = normalizeText(detail.creator_name || detail.creatorName || detail.authorName); + const authorName = isPlaceholderAuthorName(rawAuthorName, [ownerSteamId, ownerAccountId, normalizeText(detail.creator)]) + ? '' + : rawAuthorName; + const authorUrl = authorSteamId + ? `https://steamcommunity.com/profiles/${authorSteamId}` + : ''; + + return { + workshopId: String(detail.publishedfileid || detail.workshopId || ''), + title: String(detail.title || '').trim(), + imagePath: String(detail.preview_url || detail.previewUrl || detail.imagePath || ''), + authorName, + authorId, + authorUrl, + authorSteamId, + authorAccountId, + ownerSteamId: authorSteamId, + ownerAccountId: authorAccountId, + stars: mapSteamScoreToStars(detail), + shortDescription: detail.short_description || detail.shortDescription || detail.description || '', + fileSize: detail.file_size + ? `${(parseInt(String(detail.file_size), 10) / 1024 / 1024).toFixed(1)} MB` + : detail.fileSizeDisplay, + tags: Array.isArray(detail.tags) + ? detail.tags.map((tag: any) => String(tag.display_name || tag.tag || tag.name || '')).filter(Boolean) + : [], + subscriptions: detail.subscriptions ? parseInt(String(detail.subscriptions), 10) : undefined, + favorites: (detail.favorites ?? detail.favorited) ? parseInt(String(detail.favorites ?? detail.favorited), 10) : undefined, + lifetimeSubscriptions: detail.lifetime_subscriptions ? parseInt(String(detail.lifetime_subscriptions), 10) : undefined, + lifetimeFavorites: (detail.lifetime_favorites ?? detail.lifetime_favorited) + ? parseInt(String(detail.lifetime_favorites ?? detail.lifetime_favorited), 10) + : undefined, + views: detail.views ? parseInt(String(detail.views), 10) : undefined, + comments: (detail.comments ?? detail.num_comments_public) + ? parseInt(String(detail.comments ?? detail.num_comments_public), 10) + : undefined, + totalVotes: detail.total_votes ? parseInt(String(detail.total_votes), 10) : undefined, + timeCreated: detail.time_created ? parseInt(String(detail.time_created), 10) : undefined, + timeUpdated: detail.time_updated ? parseInt(String(detail.time_updated), 10) : undefined, + childCount: detail.num_children !== undefined ? parseInt(String(detail.num_children), 10) : undefined, + previewCount: detail.preview_count !== undefined ? parseInt(String(detail.preview_count), 10) : undefined, + childItemIds: detail.child_item_ids || detail.childItemIds || undefined, + galleryPreviewUrls: detail.gallery_preview_urls || detail.galleryPreviewUrls || undefined, + source, + isSubscribed: detail.isSubscribed, + isInstalled: detail.isInstalled, + installState: detail.installState || detail.itemState, + }; +} diff --git a/src/test/setup.ts b/src/test/setup.ts index 9229fd7..9f6bf3c 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,7 @@ +import { afterEach } from 'vitest'; +import { cleanup } from '@testing-library/react'; import '../i18n'; + +afterEach(() => { + cleanup(); +}); diff --git a/src/types/addon.ts b/src/types/addon.ts index 08f5dd8..3627c2b 100644 --- a/src/types/addon.ts +++ b/src/types/addon.ts @@ -41,31 +41,159 @@ export interface SteamDetails { creator_name?: string; creator?: string; tags?: SteamTag[]; + file_size?: string; +} + +export interface WorkshopDetails { + workshopId?: string; + title?: string; + previewUrl?: string; + imagePath?: string; + description?: string; + descriptionHtml?: string; + creatorName?: string; + authorName?: string; + creatorId?: string; + authorId?: string; + creatorSteamId?: string; + creatorVanityId?: string; + creatorAccountId?: string; + creatorProfileUrl?: string; + authorUrl?: string; + shortDescription?: string; + fileSizeDisplay?: string; + postedDateText?: string; + updatedDateText?: string; + changeNoteCount?: number; + ratingStars?: number; + ratingCount?: number; + uniqueVisitors?: number; + currentSubscribers?: number; + currentFavorites?: number; + subscriptions?: number; + favorites?: number; + lifetimeSubscriptions?: number; + lifetimeFavorites?: number; + views?: number; + comments?: number; + totalVotes?: number; + previewCount?: number; + childItemIds?: string[]; + tags?: string[]; + pageTags?: { category: string; name: string }[]; + imageGallery?: string[]; + galleryUrls?: string[]; + galleryPreviewUrls?: string[]; + requiredItems?: { title: string; workshopId: string }[]; + parentCollections?: { title: string; workshopId: string }[]; + backgroundImageUrl?: string; + lastSeenAt?: string; + lastPageFetchedAt?: string; } export interface Addon { + id: string; vpkName: string; - dirType: 'workshop' | 'loading'; + dirType: 'workshop' | 'loading' | 'none'; isEnabled: boolean; fileSize: number; filesCount: number; imagePath?: string; workshopId?: string; + workshopIdCandidate?: string; + workshopIdSource?: 'filename' | 'addonUrl'; + workshopIdValidationStatus?: 'pending' | 'verified' | 'rejected'; + workshopIdLastAttemptAt?: string; addonInfo?: AddonInfo; steamDetails?: SteamDetails; + workshopDetails?: WorkshopDetails; isDummy?: boolean; } +export interface BackgroundTask { + id: string; + kind: 'download' | 'workshop-crawl' | 'dependency-check' | 'warning'; + status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; + source?: string; + targetIds: string[]; + progress: number; + error?: string; + retryCount?: number; + createdAt: string; + startedAt?: string; + finishedAt?: string; + title?: string; + imagePath?: string; + dependencyCheck?: { + rootIds: string[]; + seedIds?: string[]; + discoveredCount: number; + completedCount: number; + failedNodes: { workshopId: string; error: string }[]; + discoveredDependencies?: { + workshopId: string; + title?: string; + previewUrl?: string; + creatorName?: string; + }[]; + }; +} + export interface Group { id: string; name: string; addons: string[]; + tags?: string[]; + workshopCollectionId?: string; + masterCollectionIds?: string[]; + source?: 'auto-group' | 'workshop-import' | 'manual' | 'user'; +} + +export interface MasterCollection { + id: string; + name: string; + nameKey?: string; // i18n key for system collections + groupIds: string[]; + isSystem: boolean; + icon?: string; } +export type DependencyMissingBehavior = 'always' | 'ask' | 'ignore'; +export type WorkshopSdkHtmlScope = 'disabled' | 'search' | 'navigation' | 'all'; +export type DependencyRefreshMode = 'always' | 'cache-missing'; + export interface Settings { workshopDir: string; loadingDir: string; + downloadConcurrency: number; enableDummyBypass: boolean; + suppressSdkUnavailableWarning: boolean; + disableSteamworksSdk: boolean; + forceSteamworksSdkDownload?: boolean; + maxDownloadRetries?: number; + dependencyMissingBehavior?: DependencyMissingBehavior; + workshopSourceSettings?: WorkshopSourceSettings; +} + +export interface WorkshopSourceSettings { + preset: 'conservative' | 'sdk-only' | 'offline' | 'hybrid'; + allowSteamworksSdk: boolean; + allowSteamWebApi: boolean; + allowSteamCommunityHtml: boolean; + allowSdkHtmlHybrid?: boolean; + sdkHtmlScope: WorkshopSdkHtmlScope; + dependencySdkRefresh: DependencyRefreshMode; + dependencyHtmlRefresh: DependencyRefreshMode; + sourceOrder: string[]; + cacheRetention: 'keep'; +} + +export interface DatabasePayload { + settings: Settings; + addons: Record; + groups: Group[]; + knownUninstalledAddons: Record; + masterCollections: MasterCollection[]; } export interface Toast { diff --git a/src/utils/addonHelpers.test.ts b/src/utils/addonHelpers.test.ts index 5f2e672..85b8481 100644 --- a/src/utils/addonHelpers.test.ts +++ b/src/utils/addonHelpers.test.ts @@ -6,6 +6,7 @@ import { getAddonUrl, getAddonAuthor, getSuggestedVpkName, + sortAddonsDownloadedFirst, } from './addonHelpers'; import { Addon } from '../types/addon'; @@ -26,7 +27,7 @@ describe('addonHelpers', () => { describe('getAddonCategories', () => { test('should return Campaign when addonContent_Campaign is 1', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -40,7 +41,7 @@ describe('addonHelpers', () => { test('should match category keys case-insensitively', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -48,7 +49,7 @@ describe('addonHelpers', () => { addonInfo: { addoncontent_campaign: '1', ADDONCONTENT_SURVIVOR: '1', - }, + } as any, }; const cats = getAddonCategories(addon); expect(cats).toContain('Campaign'); @@ -57,7 +58,7 @@ describe('addonHelpers', () => { test('should ignore keys with value 0', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -76,7 +77,7 @@ describe('addonHelpers', () => { test('should fall back to Other when no categories are present', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -88,7 +89,7 @@ describe('addonHelpers', () => { test('should extract categories from steam details tags', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -113,17 +114,17 @@ describe('addonHelpers', () => { expect(getImageUrl('http://example.com/test.jpg')).toBe('http://example.com/test.jpg'); }); - test('should convert cache path to localhost cache url', () => { + test('should return cache path unchanged for IPC image loading', () => { const cachePath = '/cache/abc_image.jpg'; const result = getImageUrl(cachePath); - expect(result).toSatisfy((val: string) => val.includes('localhost/abc_image.jpg')); + expect(result).toBe(cachePath); }); }); describe('getAddonUrl', () => { test('should return correct URL when available', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -137,7 +138,7 @@ describe('addonHelpers', () => { test('should prepend https to url if it has domain structure', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -151,7 +152,7 @@ describe('addonHelpers', () => { test('should return null when URL is invalid or empty', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -167,7 +168,7 @@ describe('addonHelpers', () => { describe('getAddonAuthor', () => { test('should return addonAuthor if present in addonInfo', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -181,7 +182,7 @@ describe('addonHelpers', () => { test('should return steamDetails creator_name if addonInfo has no author', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -193,9 +194,23 @@ describe('addonHelpers', () => { expect(getAddonAuthor(addon)).toBe('Creator Name'); }); + test('skips AUTHOR_NAME from addoninfo and falls back to workshop metadata', () => { + const addon: Addon = { + id: 'test.vpk', vpkName: 'test.vpk', + dirType: 'loading', + isEnabled: true, + fileSize: 100, + filesCount: 1, + addonInfo: { addonAuthor: 'AUTHOR_NAME' }, + steamDetails: { creator_name: 'Actual Creator' }, + }; + + expect(getAddonAuthor(addon)).toBe('Actual Creator'); + }); + test('should return Unknown Author if no author info is present', () => { const addon: Addon = { - vpkName: 'test.vpk', + id: 'test.vpk', vpkName: 'test.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -208,7 +223,7 @@ describe('addonHelpers', () => { describe('getSuggestedVpkName', () => { test('should strip brackets and suggest clean vpk name for non-workshop item', () => { const addon: Addon = { - vpkName: '[OldGroup]my_addon.vpk', + id: '[OldGroup]my_addon.vpk', vpkName: '[OldGroup]my_addon.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -220,7 +235,7 @@ describe('addonHelpers', () => { test('should suggest name with workshopId prefix for workshop items', () => { const addon: Addon = { - vpkName: '2938529557.vpk', + id: '2938529557.vpk', vpkName: '2938529557.vpk', dirType: 'workshop', isEnabled: true, fileSize: 100, @@ -236,7 +251,7 @@ describe('addonHelpers', () => { test('should avoid name conflicts by appending a counter', () => { const addon: Addon = { - vpkName: 'temp.vpk', + id: 'temp.vpk', vpkName: 'temp.vpk', dirType: 'workshop', isEnabled: true, fileSize: 100, @@ -248,7 +263,7 @@ describe('addonHelpers', () => { }; const existing: Record = { '[123]Cool Map.vpk': { - vpkName: '[123]Cool Map.vpk', + id: '[123]Cool Map.vpk', vpkName: '[123]Cool Map.vpk', dirType: 'loading', isEnabled: true, fileSize: 100, @@ -259,4 +274,50 @@ describe('addonHelpers', () => { expect(suggested).toBe('[123]Cool Map_1.vpk'); }); }); + + describe('sortAddonsDownloadedFirst', () => { + test('should move downloaded addons ahead of uninstalled ones while preserving relative order', () => { + const addons: Addon[] = [ + { + id: 'missing-1', + vpkName: 'missing-1.vpk', + dirType: 'none', + isEnabled: false, + fileSize: 0, + filesCount: 0, + }, + { + id: 'installed-1', + vpkName: 'installed-1.vpk', + dirType: 'loading', + isEnabled: true, + fileSize: 1, + filesCount: 1, + }, + { + id: 'installed-2', + vpkName: 'installed-2.vpk', + dirType: 'workshop', + isEnabled: true, + fileSize: 1, + filesCount: 1, + }, + { + id: 'missing-2', + vpkName: 'missing-2.vpk', + dirType: 'none', + isEnabled: false, + fileSize: 0, + filesCount: 0, + }, + ]; + + expect(sortAddonsDownloadedFirst(addons).map((addon) => addon.id)).toEqual([ + 'installed-1', + 'installed-2', + 'missing-1', + 'missing-2', + ]); + }); + }); }); diff --git a/src/utils/addonHelpers.ts b/src/utils/addonHelpers.ts index 88e5704..dfec675 100644 --- a/src/utils/addonHelpers.ts +++ b/src/utils/addonHelpers.ts @@ -23,6 +23,18 @@ export function getAddonInfoValue(addon: Addon, key: string): any { return foundKey ? info[foundKey as keyof typeof info] : undefined; } +export function isPlaceholderAuthorName(value: unknown, identities: string[] = []): boolean { + const name = typeof value === 'string' ? value.trim() : ''; + if (!name) return true; + + const normalized = name.toLowerCase(); + if (/^\d+$/.test(name) || normalized === 'author_name' || normalized === '[unknown]') { + return true; + } + + return identities.some((identity) => normalized === String(identity || '').trim().toLowerCase()); +} + // Category mappings from keys export function getAddonCategories(addon: Addon): string[] { const categories = new Set(); @@ -81,11 +93,29 @@ export function getAddonCategories(addon: Addon): string[] { if (tag.includes('ui') || tag.includes('hud') || tag.includes('icon')) categories.add('UI/Textures'); }); } + + const cachedTags = [ + ...(addon.workshopDetails?.tags || []), + ...(addon.workshopDetails?.pageTags || []).map(t => t.name), + ]; + cachedTags.forEach((tagStr) => { + if (typeof tagStr !== 'string' || !tagStr) return; + const tag = tagStr.toLowerCase(); + if (tag.includes('campaign') || tag.includes('map')) categories.add('Campaign'); + if (tag.includes('survivor') || tag.includes('character')) categories.add('Survivor'); + if (tag.includes('weapon') || tag.includes('melee') || tag.includes('gun')) categories.add('Weapon Model'); + if (tag.includes('skin') || tag.includes('texture') || tag.includes('material')) categories.add('Skin'); + if (tag.includes('script') || tag.includes('mod')) categories.add('Script'); + if (tag.includes('sound') || tag.includes('music') || tag.includes('voice')) categories.add('Sound/Music'); + if (tag.includes('infected') || tag.includes('monster')) categories.add('Infected'); + if (tag.includes('ui') || tag.includes('hud') || tag.includes('icon')) categories.add('UI/Textures'); + }); - if (categories.size === 0) { + // Don't show "Other" for uninstalled items with no metadata + if (categories.size === 0 && addon.dirType !== 'none') { categories.add('Other'); } - + return Array.from(categories); } @@ -94,14 +124,6 @@ export const getImageUrl = (path?: string): string => { if (path.startsWith('http://') || path.startsWith('https://')) { return path; } - if (path.startsWith('/cache/')) { - const filename = path.slice(7); - const isApple = navigator.userAgent.includes('Mac OS X') || navigator.userAgent.includes('iPhone') || navigator.userAgent.includes('iPad'); - if (!isApple) { - return `http://cache.localhost/${filename}`; - } - return `cache://localhost/${filename}`; - } return path; }; @@ -123,18 +145,39 @@ export const getAddonUrl = (addon: Addon): string | null => { export const getAddonAuthor = (addon: Addon): string => { if (!addon) return 'Unknown Author'; const author = getAddonInfoValue(addon, 'addonauthor') || getAddonInfoValue(addon, 'author'); - if (author && typeof author === 'string' && author.trim()) { + if (typeof author === 'string' && !isPlaceholderAuthorName(author)) { return author.trim(); } - if (addon.steamDetails?.creator_name) { - return addon.steamDetails.creator_name; + const workshopAuthor = addon.workshopDetails?.creatorName || addon.workshopDetails?.authorName; + if (typeof workshopAuthor === 'string' && !isPlaceholderAuthorName(workshopAuthor)) { + return workshopAuthor.trim(); + } + const steamAuthor = addon.steamDetails?.creator_name; + if (typeof steamAuthor === 'string' && !isPlaceholderAuthorName(steamAuthor)) { + return steamAuthor; } - if (addon.steamDetails?.creator && addon.steamDetails.creator !== '0') { - return addon.steamDetails.creator; + const steamCreator = addon.steamDetails?.creator; + if (typeof steamCreator === 'string' && !isPlaceholderAuthorName(steamCreator)) { + return steamCreator; } return 'Unknown Author'; }; +export function sortAddonsDownloadedFirst(addons: Addon[]): Addon[] { + const installed: Addon[] = []; + const uninstalled: Addon[] = []; + + addons.forEach((addon) => { + if (addon.dirType === 'none') { + uninstalled.push(addon); + } else { + installed.push(addon); + } + }); + + return [...installed, ...uninstalled]; +} + /** * Helper to suggest a unique VPK name for an addon, avoiding name conflicts. * Workshop items use the Steam title / addonInfo title. @@ -183,7 +226,7 @@ export function getSuggestedVpkName( let counter = 1; let finalName = baseSuggestedName; while ( - Object.keys(addons).some(key => key.toLowerCase() === finalName.toLowerCase() && key !== addon.vpkName) + Object.keys(addons).some(key => key.toLowerCase() === finalName.toLowerCase() && key !== addon.id) ) { const dotIndex = baseSuggestedName.lastIndexOf('.vpk'); const nameWithoutExt = baseSuggestedName.slice(0, dotIndex); @@ -193,4 +236,3 @@ export function getSuggestedVpkName( return finalName; } - diff --git a/src/utils/workshopKnown.test.ts b/src/utils/workshopKnown.test.ts new file mode 100644 index 0000000..7f84f84 --- /dev/null +++ b/src/utils/workshopKnown.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from 'vitest'; +import { findKnownWorkshopEntry, isKnownWorkshopItem } from './workshopKnown'; + +describe('workshop known entries', () => { + test('uses the raw workshop ID as the identifier', () => { + const entry = { id: '12345', workshopId: '12345' }; + const entries = { '12345': entry }; + + expect(findKnownWorkshopEntry(entries, '12345')).toBe(entry); + expect(isKnownWorkshopItem(entries, '12345')).toBe(true); + }); + + test('does not treat a VPK filename as a workshop identifier', () => { + const entries = { + '12345.vpk': { id: '12345.vpk', workshopId: undefined }, + }; + + expect(findKnownWorkshopEntry(entries, '12345')).toBeUndefined(); + expect(isKnownWorkshopItem(entries, '12345')).toBe(false); + }); +}); diff --git a/src/utils/workshopKnown.ts b/src/utils/workshopKnown.ts new file mode 100644 index 0000000..6b9c710 --- /dev/null +++ b/src/utils/workshopKnown.ts @@ -0,0 +1,31 @@ +type WorkshopLike = { + id?: string; + workshopId?: string; +}; + +function normalizeId(value: unknown): string { + return String(value || '').trim(); +} + +export function findKnownWorkshopEntry( + entries: Record | undefined, + workshopId: string | undefined, +): T | undefined { + const id = normalizeId(workshopId); + if (!entries || !id) return undefined; + + const direct = entries[id]; + if (direct) return direct; + + return Object.values(entries).find((entry) => ( + normalizeId(entry?.workshopId) === id || + normalizeId(entry?.id) === id + )); +} + +export function isKnownWorkshopItem( + entries: Record | undefined, + workshopId: string | undefined, +): boolean { + return Boolean(findKnownWorkshopEntry(entries, workshopId)); +} diff --git a/src/utils/workshopSourceSettings.ts b/src/utils/workshopSourceSettings.ts new file mode 100644 index 0000000..4022e74 --- /dev/null +++ b/src/utils/workshopSourceSettings.ts @@ -0,0 +1,97 @@ +import type { DependencyRefreshMode, WorkshopSdkHtmlScope, WorkshopSourceSettings } from '../types/addon'; + +export const DEFAULT_WORKSHOP_SOURCE_SETTINGS: WorkshopSourceSettings = { + preset: 'conservative', + allowSteamworksSdk: true, + allowSteamWebApi: true, + allowSteamCommunityHtml: true, + allowSdkHtmlHybrid: false, + sdkHtmlScope: 'search', + dependencySdkRefresh: 'always', + dependencyHtmlRefresh: 'cache-missing', + sourceOrder: ['steamworks-sdk', 'steam-web-api', 'steamcommunity-html'], + cacheRetention: 'keep', +}; + +export function resolveWorkshopSdkHtmlScope( + scope: WorkshopSdkHtmlScope | string | null | undefined, + preset?: string | null, + allowSdkHtmlHybrid?: boolean | null, +): WorkshopSdkHtmlScope { + if (scope === 'disabled' || scope === 'search' || scope === 'navigation' || scope === 'all') { + return scope; + } + if (preset === 'hybrid' || allowSdkHtmlHybrid) { + return 'all'; + } + return 'search'; +} + +function resolveDependencyRefreshMode(value: DependencyRefreshMode | string | null | undefined, fallback: DependencyRefreshMode): DependencyRefreshMode { + return value === 'always' || value === 'cache-missing' ? value : fallback; +} + +export function normalizeWorkshopSourceSettings( + settings?: Partial | null, +): WorkshopSourceSettings { + const normalizedPreset = (settings as any)?.preset === 'sdkOnly' ? 'sdk-only' : (settings?.preset || DEFAULT_WORKSHOP_SOURCE_SETTINGS.preset); + const sdkHtmlScope = resolveWorkshopSdkHtmlScope( + settings?.sdkHtmlScope, + normalizedPreset, + settings?.allowSdkHtmlHybrid, + ); + + return { + ...DEFAULT_WORKSHOP_SOURCE_SETTINGS, + ...(settings || {}), + preset: normalizedPreset as WorkshopSourceSettings['preset'], + allowSdkHtmlHybrid: sdkHtmlScope === 'all', + sdkHtmlScope, + dependencySdkRefresh: resolveDependencyRefreshMode( + settings?.dependencySdkRefresh, + DEFAULT_WORKSHOP_SOURCE_SETTINGS.dependencySdkRefresh, + ), + dependencyHtmlRefresh: resolveDependencyRefreshMode( + settings?.dependencyHtmlRefresh, + DEFAULT_WORKSHOP_SOURCE_SETTINGS.dependencyHtmlRefresh, + ), + sourceOrder: settings?.sourceOrder?.length + ? settings.sourceOrder + : DEFAULT_WORKSHOP_SOURCE_SETTINGS.sourceOrder, + cacheRetention: 'keep', + }; +} + +export function shouldAllowSteamCommunityHtmlSource( + settings: WorkshopSourceSettings, + source: string, + sdkAvailable: boolean, +): boolean { + if (settings.preset === 'offline' || !settings.allowSteamCommunityHtml) { + return false; + } + + if (!sdkAvailable) { + return true; + } + + if (source === 'addon-detail' || source === 'workshop-detail' || source === 'dependency-check') { + return true; + } + + if (source === 'workshop-search' || source === 'workshop-creator') { + return settings.sdkHtmlScope === 'search' + || settings.sdkHtmlScope === 'navigation' + || settings.sdkHtmlScope === 'all'; + } + + if (source === 'workshop-home' || source === 'workshop-browse') { + return settings.sdkHtmlScope === 'navigation' || settings.sdkHtmlScope === 'all'; + } + + if (source === 'startup-auto' || source === 'background-refresh') { + return settings.sdkHtmlScope === 'all'; + } + + return false; +} diff --git a/steam-bridge/Cargo.lock b/steam-bridge/Cargo.lock new file mode 100644 index 0000000..28ae744 --- /dev/null +++ b/steam-bridge/Cargo.lock @@ -0,0 +1,158 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "l4a-steam-bridge" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "steamworks", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "steamworks" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ff29921f6a7e8c85b0c971ec3c42002b9f535e3b8f4358cb22911d43709739a" +dependencies = [ + "bitflags", + "paste", + "steamworks-sys", + "thiserror", +] + +[[package]] +name = "steamworks-sys" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae139f051204a4af015d4f0ed3a1f7a51020d03a44c7cf249168c543d88131e9" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/steam-bridge/Cargo.toml b/steam-bridge/Cargo.toml new file mode 100644 index 0000000..0184194 --- /dev/null +++ b/steam-bridge/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "l4a-steam-bridge" +version = "0.1.0" +edition = "2021" + +[lib] +name = "l4a_steam_bridge" +crate-type = ["cdylib"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +steamworks = "0.13.1" diff --git a/steam-bridge/src/lib.rs b/steam-bridge/src/lib.rs new file mode 100644 index 0000000..5d2b656 --- /dev/null +++ b/steam-bridge/src/lib.rs @@ -0,0 +1,1093 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::sync::{mpsc, Mutex, OnceLock}; +use std::time::{Duration, Instant}; +use steamworks::{ + AccountId, AppIDs, AppId, Client, FileType, ItemState, PublishedFileId, SteamId, UGCQueryType, + UGCStatisticType, UGCType, UserList, UserListOrder, +}; + +const APP_ID: u32 = 550; +const BRIDGE_VERSION: &str = "0.1.0"; +const QUERY_TIMEOUT: Duration = Duration::from_secs(10); +const PERSONA_LOOKUP_TIMEOUT: Duration = Duration::from_secs(2); + +static RUNTIME: OnceLock>> = OnceLock::new(); + +struct BridgeRuntime { + client: Client, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct InitResponse { + ok: bool, + version: String, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + current_user_steam_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + current_user_account_id: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonResponse { + ok: bool, + payload: Value, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct BridgeRequest { + method: String, + #[serde(default)] + payload: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DetailsPayload { + ids: Vec, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +struct QueryItemsPayload { + #[serde(default)] + query: Option, + #[serde(default)] + sort: Option, + #[serde(default)] + section: Option, + #[serde(default)] + page: Option, + #[serde(default)] + creator_id: Option, + #[serde(default)] + active_tag: Option, + #[serde(default)] + active_tag_name: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct WorkshopIdPayload { + workshop_id: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct SubscribedItemPayload { + workshop_id: String, + item_state: Vec<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + install_folder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + size_on_disk: Option, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct HomeSection { + id: String, + title_key: String, + subtitle_key: String, + icon: String, + items: Vec, + browse_params: BrowseParams, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +struct BrowseParams { + sort: String, + section: String, + #[serde(skip_serializing_if = "Option::is_none")] + days: Option, +} + +#[derive(Debug, Clone)] +struct HomeSectionDef { + id: &'static str, + title_key: &'static str, + subtitle_key: &'static str, + icon: &'static str, + sort: &'static str, + section: &'static str, + days: Option, +} + +struct QueryExecution { + items: Vec, + child_ids: Vec, + total_results: u32, + warnings: Vec, +} + +type QueryItemsWithChildren = (Vec, Vec, Vec); + +#[no_mangle] +pub extern "C" fn l4a_steam_bridge_init() -> *mut c_char { + let response = match ensure_runtime() { + Ok(runtime) => { + let user = runtime.as_ref().map(|runtime| runtime.client.user()); + let Some(user) = user else { + return into_c_string(&InitResponse { + ok: false, + version: BRIDGE_VERSION.to_string(), + error: Some("Steam bridge runtime unavailable".to_string()), + current_user_steam_id: None, + current_user_account_id: None, + }); + }; + let steam_id = user.steam_id(); + InitResponse { + ok: true, + version: BRIDGE_VERSION.to_string(), + error: None, + current_user_steam_id: Some(steam_id.raw().to_string()), + current_user_account_id: Some(steam_id.account_id().raw().to_string()), + } + } + Err(err) => InitResponse { + ok: false, + version: BRIDGE_VERSION.to_string(), + error: Some(err), + current_user_steam_id: None, + current_user_account_id: None, + }, + }; + + into_c_string(&response) +} + +#[no_mangle] +pub extern "C" fn l4a_steam_bridge_request_json(request: *const c_char) -> *mut c_char { + let result = handle_request(request) + .map(|payload| JsonResponse { + ok: true, + payload, + error: None, + }) + .unwrap_or_else(|err| JsonResponse { + ok: false, + payload: Value::Null, + error: Some(err), + }); + + into_c_string(&result) +} + +#[no_mangle] +/// # Safety +/// +/// `ptr` must be either null or a pointer returned by this bridge's string-returning functions. +/// A non-null pointer must be passed exactly once and must not be used after this call. +pub unsafe extern "C" fn l4a_steam_bridge_free_string(ptr: *mut c_char) { + if ptr.is_null() { + return; + } + + // The caller must pass a pointer returned by `into_c_string` exactly once. + drop(unsafe { CString::from_raw(ptr) }); +} + +#[no_mangle] +pub extern "C" fn l4a_steam_bridge_shutdown() { + if let Some(lock) = RUNTIME.get() { + if let Ok(mut runtime) = lock.lock() { + runtime.take(); + } + } + std::env::remove_var("SteamAppId"); + std::env::remove_var("SteamGameId"); +} + +fn handle_request(request: *const c_char) -> Result { + if request.is_null() { + return Err("Steam bridge request pointer was null".to_string()); + } + + let request = unsafe { CStr::from_ptr(request) } + .to_string_lossy() + .into_owned(); + let parsed: BridgeRequest = serde_json::from_str(&request).map_err(|e| e.to_string())?; + with_runtime(|runtime| match parsed.method.as_str() { + "get_details" => { + let payload: DetailsPayload = + serde_json::from_value(parsed.payload).map_err(|e| e.to_string())?; + query_details(runtime, &payload.ids) + } + "query_home" => query_home(runtime), + "query_items" => { + let payload: QueryItemsPayload = + serde_json::from_value(parsed.payload).map_err(|e| e.to_string())?; + query_items(runtime, payload) + } + "query_item" => { + let payload: WorkshopIdPayload = + serde_json::from_value(parsed.payload).map_err(|e| e.to_string())?; + query_item(runtime, &payload.workshop_id) + } + "query_collection" => { + let payload: WorkshopIdPayload = + serde_json::from_value(parsed.payload).map_err(|e| e.to_string())?; + query_collection(runtime, &payload.workshop_id) + } + "request_download" => { + let payload: WorkshopIdPayload = + serde_json::from_value(parsed.payload).map_err(|e| e.to_string())?; + request_download(runtime, &payload.workshop_id) + } + "get_download_status" => { + let payload: WorkshopIdPayload = + serde_json::from_value(parsed.payload).map_err(|e| e.to_string())?; + get_download_status(runtime, &payload.workshop_id) + } + "get_subscribed_items" => get_subscribed_items(runtime), + "get_favorited_collections" => get_favorited_collections(runtime), + method => Err(format!("Unsupported Steam bridge method: {}", method)), + }) +} + +fn ensure_runtime() -> Result>, String> { + let lock = RUNTIME.get_or_init(|| Mutex::new(None)); + let mut runtime = lock + .lock() + .map_err(|_| "Steam bridge mutex poisoned".to_string())?; + + if runtime.is_none() { + let client = + Client::init_app(AppId(APP_ID)).map_err(|e| format!("Steam init failed: {}", e))?; + *runtime = Some(BridgeRuntime { client }); + } + + Ok(runtime) +} + +fn with_runtime(f: impl FnOnce(&mut BridgeRuntime) -> Result) -> Result { + let mut runtime = ensure_runtime()?; + let runtime = runtime + .as_mut() + .ok_or_else(|| "Steam bridge runtime unavailable".to_string())?; + f(runtime) +} + +fn query_home(runtime: &mut BridgeRuntime) -> Result { + let sections = vec![ + HomeSectionDef { + id: "past-week", + title_key: "workshop.home.pastWeek", + subtitle_key: "workshop.home.pastWeekDesc", + icon: "Clock", + sort: "trend", + section: "readytouseitems", + days: Some(7), + }, + HomeSectionDef { + id: "trending", + title_key: "workshop.home.trending", + subtitle_key: "workshop.home.trendingDesc", + icon: "Flame", + sort: "trend", + section: "readytouseitems", + days: Some(90), + }, + HomeSectionDef { + id: "most-subscribed", + title_key: "workshop.home.mostSubscribed", + subtitle_key: "workshop.home.mostSubscribedDesc", + icon: "Users", + sort: "totalprofiles", + section: "readytouseitems", + days: None, + }, + HomeSectionDef { + id: "recently-updated", + title_key: "workshop.home.recentlyUpdated", + subtitle_key: "workshop.home.recentlyUpdatedDesc", + icon: "RefreshCw", + sort: "lastupdated", + section: "readytouseitems", + days: None, + }, + HomeSectionDef { + id: "newest", + title_key: "workshop.home.newest", + subtitle_key: "workshop.home.newestDesc", + icon: "Star", + sort: "mostrecent", + section: "readytouseitems", + days: None, + }, + ]; + + let mut mapped_sections = Vec::new(); + let mut warnings = Vec::new(); + for section in sections { + let query = build_query_handle( + runtime, + &QueryItemsPayload { + query: None, + sort: Some(section.sort.to_string()), + section: Some(section.section.to_string()), + page: Some(1), + creator_id: None, + active_tag: None, + active_tag_name: None, + }, + section.days, + )?; + let (items, section_warnings) = run_query(runtime, query)?; + warnings.extend(section_warnings); + mapped_sections.push(HomeSection { + id: section.id.to_string(), + title_key: section.title_key.to_string(), + subtitle_key: section.subtitle_key.to_string(), + icon: section.icon.to_string(), + items, + browse_params: BrowseParams { + sort: section.sort.to_string(), + section: section.section.to_string(), + days: section.days, + }, + }); + } + + Ok(json!({ + "source": "steam-sdk", + "sections": mapped_sections, + "warnings": dedupe_warnings(warnings), + })) +} + +fn query_items(runtime: &mut BridgeRuntime, payload: QueryItemsPayload) -> Result { + let query = build_query_handle(runtime, &payload, None)?; + let (mut items, warnings) = run_query(runtime, query)?; + normalize_query_items(&mut items, payload.sort.as_deref()); + Ok(json!({ + "source": "steam-sdk", + "items": items, + "warnings": dedupe_warnings(warnings), + })) +} + +fn query_item(runtime: &mut BridgeRuntime, workshop_id: &str) -> Result { + let (items, warnings) = query_details_internal(runtime, &[workshop_id.to_string()])?; + let item = items + .first() + .cloned() + .ok_or_else(|| "Workshop item not found".to_string())?; + + Ok(json!({ + "source": "steam-sdk", + "item": item, + "warnings": dedupe_warnings(warnings), + })) +} + +fn query_collection(runtime: &mut BridgeRuntime, workshop_id: &str) -> Result { + let ugc = runtime.client.ugc(); + let mut query = ugc + .query_item(parse_published_file_id(workshop_id)?) + .map_err(|e| e.to_string())?; + query = query.include_children(true).include_long_desc(true); + let (collection, children, mut warnings) = run_query_with_children(runtime, query)?; + let collection = collection + .into_iter() + .next() + .ok_or_else(|| "Workshop collection not found".to_string())?; + let items = if children.is_empty() { + Vec::new() + } else { + let (items, child_warnings) = query_details_internal(runtime, &children)?; + warnings.extend(child_warnings); + items + }; + + Ok(json!({ + "source": "steam-sdk", + "collection": collection, + "items": items, + "warnings": dedupe_warnings(warnings), + })) +} + +fn query_details(runtime: &mut BridgeRuntime, workshop_ids: &[String]) -> Result { + Ok(Value::Array( + query_details_internal(runtime, workshop_ids)?.0, + )) +} + +fn query_details_internal( + runtime: &mut BridgeRuntime, + workshop_ids: &[String], +) -> Result<(Vec, Vec), String> { + let ids = workshop_ids + .iter() + .map(|id| parse_published_file_id(id)) + .collect::, _>>()?; + let ugc = runtime.client.ugc(); + let mut query = ugc.query_items(ids).map_err(|e| e.to_string())?; + query = query + .include_children(true) + .include_long_desc(true) + .include_metadata(true); + let (items, _, warnings) = run_query_with_children(runtime, query)?; + Ok((items, warnings)) +} + +fn request_download(runtime: &mut BridgeRuntime, workshop_id: &str) -> Result { + let ugc = runtime.client.ugc(); + let published_file_id = parse_published_file_id(workshop_id)?; + let state = ugc.item_state(published_file_id); + if !state.contains(ItemState::SUBSCRIBED) { + let (tx, rx) = mpsc::channel(); + ugc.subscribe_item(published_file_id, move |result| { + let _ = tx.send(result.map(|_| ())); + }); + let started_at = Instant::now(); + loop { + runtime.client.run_callbacks(); + match rx.try_recv() { + Ok(Ok(())) => break, + Ok(Err(err)) => return Err(format!("SubscribeItem failed: {}", err)), + Err(mpsc::TryRecvError::Empty) => { + if started_at.elapsed() > QUERY_TIMEOUT { + return Err("SubscribeItem timed out".to_string()); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(mpsc::TryRecvError::Disconnected) => { + return Err("SubscribeItem callback channel disconnected".to_string()) + } + } + } + } + + let accepted = ugc.download_item(published_file_id, true); + Ok(json!({ + "accepted": accepted, + "message": if accepted { "Steam download requested" } else { "Steam download request was rejected" }, + })) +} + +fn get_download_status(runtime: &mut BridgeRuntime, workshop_id: &str) -> Result { + let ugc = runtime.client.ugc(); + let published_file_id = parse_published_file_id(workshop_id)?; + let state = ugc.item_state(published_file_id); + let install_info = ugc.item_install_info(published_file_id); + let download_info = ugc.item_download_info(published_file_id); + + Ok(json!({ + "subscribed": state.contains(ItemState::SUBSCRIBED), + "installed": state.contains(ItemState::INSTALLED), + "downloaded": download_info.map(|(downloaded, _)| downloaded), + "total": download_info.map(|(_, total)| total), + "installFolder": install_info.as_ref().map(|info| info.folder.clone()), + "itemState": item_state_strings(state), + })) +} + +fn get_subscribed_items(runtime: &mut BridgeRuntime) -> Result { + let ugc = runtime.client.ugc(); + let mut items = Vec::new(); + + for published_file_id in ugc.subscribed_items(true) { + let state = ugc.item_state(published_file_id); + let install_info = ugc.item_install_info(published_file_id); + items.push(SubscribedItemPayload { + workshop_id: published_file_id.0.to_string(), + item_state: item_state_strings(state), + install_folder: install_info.as_ref().map(|info| info.folder.clone()), + size_on_disk: install_info.as_ref().map(|info| info.size_on_disk), + }); + } + + Ok(json!({ + "source": "steam-sdk", + "items": items, + })) +} + +fn get_favorited_collections(runtime: &mut BridgeRuntime) -> Result { + let ugc = runtime.client.ugc(); + let current_user = runtime.client.user().steam_id().account_id(); + let app_ids = AppIDs::Both { + creator: AppId(APP_ID), + consumer: AppId(APP_ID), + }; + + let mut page = 1; + let mut items = Vec::new(); + let mut total_results = None; + + loop { + let mut query = ugc + .query_user( + current_user, + UserList::Favorited, + UGCType::Collections, + UserListOrder::LastUpdatedDesc, + app_ids, + page, + ) + .map_err(|e| e.to_string())?; + query = query + .include_long_desc(true) + .include_children(true) + .include_metadata(true); + + let execution = run_query_with_children_page(runtime, query)?; + if execution.items.is_empty() { + break; + } + + items.extend(execution.items); + let expected_total = *total_results.get_or_insert(execution.total_results); + if expected_total == 0 || items.len() as u32 >= expected_total { + break; + } + page += 1; + } + + Ok(json!({ + "source": "steam-sdk", + "items": items, + })) +} + +fn build_query_handle( + runtime: &mut BridgeRuntime, + payload: &QueryItemsPayload, + days_override: Option, +) -> Result { + let ugc = runtime.client.ugc(); + let page = payload.page.unwrap_or(1); + let item_type = map_item_type(payload.section.as_deref()); + let app_ids = AppIDs::Both { + creator: AppId(APP_ID), + consumer: AppId(APP_ID), + }; + + let mut query = if let Some(creator_id) = payload.creator_id.as_deref() { + let account_id = parse_account_id(creator_id)?; + ugc.query_user( + account_id, + UserList::Published, + item_type, + map_user_list_order(payload.sort.as_deref()), + app_ids, + page, + ) + .map_err(|e| e.to_string())? + } else { + ugc.query_all( + map_query_type(payload.sort.as_deref()), + item_type, + app_ids, + page, + ) + .map_err(|e| e.to_string())? + }; + + query = query + .include_long_desc(true) + .include_children(true) + .include_metadata(true); + + if let Some(text) = payload + .query + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + query = query.set_search_text(text.trim()); + } + if let Some(tag) = payload + .active_tag_name + .as_deref() + .or(payload.active_tag.as_deref()) + .filter(|value| !value.trim().is_empty()) + { + query = query.add_required_tag(tag.trim()); + } + if let Some(days) = days_override { + query = query.set_ranked_by_trend_days(days); + } + + Ok(query) +} + +fn run_query( + runtime: &mut BridgeRuntime, + query: steamworks::QueryHandle, +) -> Result<(Vec, Vec), String> { + let execution = run_query_with_children_page(runtime, query)?; + Ok((execution.items, execution.warnings)) +} + +fn run_query_with_children( + runtime: &mut BridgeRuntime, + query: steamworks::QueryHandle, +) -> Result { + let execution = run_query_with_children_page(runtime, query)?; + Ok((execution.items, execution.child_ids, execution.warnings)) +} + +fn run_query_with_children_page( + runtime: &mut BridgeRuntime, + query: steamworks::QueryHandle, +) -> Result { + let (tx, rx) = mpsc::channel(); + query.fetch(move |result| { + let payload = result.map_err(|err| err.to_string()).map(|results| { + let mut items = Vec::new(); + let mut child_ids = Vec::new(); + for index in 0..results.returned_results() { + if let Some(item) = results.get(index) { + let children = results + .get_children(index) + .unwrap_or_default() + .into_iter() + .map(|child| child.0.to_string()) + .collect::>(); + child_ids.extend(children.iter().cloned()); + items.push(query_result_to_json( + &results, + index, + &item, + results.preview_url(index), + &children, + )); + } + } + (items, child_ids, results.total_results()) + }); + let _ = tx.send(payload); + }); + + let started_at = Instant::now(); + loop { + runtime.client.run_callbacks(); + match rx.try_recv() { + Ok(result) => { + let (mut items, child_ids, total_results) = result?; + let warnings = fill_creator_persona_names(runtime, &mut items); + return Ok(QueryExecution { + items, + child_ids, + total_results, + warnings, + }); + } + Err(mpsc::TryRecvError::Empty) => { + if started_at.elapsed() > QUERY_TIMEOUT { + return Err("Steam workshop query timed out".to_string()); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(mpsc::TryRecvError::Disconnected) => { + return Err("Steam workshop query callback disconnected".to_string()) + } + } + } +} + +fn fill_creator_persona_names(runtime: &mut BridgeRuntime, items: &mut [Value]) -> Vec { + let mut unique_ids = Vec::new(); + let mut seen_ids = HashSet::new(); + + for item in items.iter() { + let Some(steam_id) = item.get("creator_steam_id").and_then(Value::as_str) else { + continue; + }; + let trimmed = steam_id.trim(); + if trimmed.is_empty() || !seen_ids.insert(trimmed.to_string()) { + continue; + } + unique_ids.push(trimmed.to_string()); + } + + if unique_ids.is_empty() { + return Vec::new(); + } + + let friends = runtime.client.friends(); + let mut pending = Vec::new(); + let mut names = HashMap::new(); + + for steam_id in &unique_ids { + let id = match steam_id.parse::() { + Ok(raw) => SteamId::from_raw(raw), + Err(_) => continue, + }; + let friend = friends.get_friend(id); + let name = friend.name(); + if is_usable_persona_name(&name) { + names.insert(steam_id.clone(), name); + continue; + } + + let _ = friends.request_user_information(id, true); + pending.push((steam_id.clone(), id)); + } + + if !pending.is_empty() { + let started_at = Instant::now(); + while started_at.elapsed() <= PERSONA_LOOKUP_TIMEOUT { + runtime.client.run_callbacks(); + let mut unresolved = Vec::new(); + + for (steam_id, id) in pending.into_iter() { + let name = friends.get_friend(id).name(); + if is_usable_persona_name(&name) { + names.insert(steam_id, name); + } else { + unresolved.push((steam_id, id)); + } + } + + if unresolved.is_empty() { + pending = unresolved; + break; + } + + pending = unresolved; + std::thread::sleep(Duration::from_millis(10)); + } + } + + for item in items.iter_mut() { + let Some(obj) = item.as_object_mut() else { + continue; + }; + let Some(steam_id) = obj.get("creator_steam_id").and_then(Value::as_str) else { + continue; + }; + if let Some(name) = names.get(steam_id) { + obj.insert("creator_name".to_string(), Value::String(name.clone())); + } + } + + if pending.is_empty() { + return Vec::new(); + } + + let sample_ids = pending + .iter() + .take(5) + .map(|(steam_id, _)| steam_id.clone()) + .collect::>() + .join(", "); + vec![format!( + "Steamworks SDK creator persona lookup failed for {} author(s): {}", + pending.len(), + sample_ids + )] +} + +fn is_usable_persona_name(name: &str) -> bool { + let trimmed = name.trim(); + !trimmed.is_empty() + && !trimmed.eq_ignore_ascii_case("[unknown]") + && !trimmed.eq_ignore_ascii_case("AUTHOR_NAME") +} + +fn dedupe_warnings(warnings: Vec) -> Vec { + let mut seen = HashSet::new(); + warnings + .into_iter() + .filter(|warning| { + let trimmed = warning.trim(); + !trimmed.is_empty() && seen.insert(trimmed.to_string()) + }) + .collect() +} + +fn query_result_to_json( + results: &steamworks::QueryResults<'_>, + index: u32, + item: &steamworks::QueryResult, + preview_url: Option, + child_ids: &[String], +) -> Value { + let owner_id = item.owner.raw().to_string(); + let account_id = item.owner.account_id().raw().to_string(); + let subscriptions = results.statistic(index, UGCStatisticType::Subscriptions); + let favorites = results.statistic(index, UGCStatisticType::Favorites); + let lifetime_subscriptions = results.statistic(index, UGCStatisticType::UniqueSubscriptions); + let lifetime_favorites = results.statistic(index, UGCStatisticType::UniqueFavorites); + let views = results.statistic(index, UGCStatisticType::UniqueWebsiteViews); + let comments = results.statistic(index, UGCStatisticType::Comments); + let total_votes = item.num_upvotes as u64 + item.num_downvotes as u64; + + json!({ + "publishedfileid": item.published_file_id.0.to_string(), + "title": item.title, + "description": item.description, + "short_description": item.description, + "preview_url": preview_url.unwrap_or_default(), + "creator": owner_id, + "creator_name": "", + "creator_steam_id": owner_id, + "creator_account_id": account_id, + "tags": item.tags.iter().map(|tag| json!({ "tag": tag })).collect::>(), + "file_size": item.file_size.to_string(), + "time_created": item.time_created, + "time_updated": item.time_updated, + "num_children": item.num_children, + "child_item_ids": child_ids, + "subscriptions": subscriptions, + "favorited": favorites, + "favorites": favorites, + "lifetime_subscriptions": lifetime_subscriptions, + "lifetime_favorited": lifetime_favorites, + "lifetime_favorites": lifetime_favorites, + "views": views, + "num_comments_public": comments, + "comments": comments, + "total_votes": total_votes, + "score": item.score, + "file_type": match item.file_type { + FileType::Collection => "collection", + _ => "item", + }, + }) +} + +fn normalize_query_items(items: &mut [Value], sort: Option<&str>) { + match sort.unwrap_or("trend") { + "mostrecent" => items.sort_by(|left, right| { + json_u64(right, "time_created") + .cmp(&json_u64(left, "time_created")) + .then_with(|| json_u64(right, "time_updated").cmp(&json_u64(left, "time_updated"))) + .then_with(|| { + json_string(left, "publishedfileid").cmp(json_string(right, "publishedfileid")) + }) + }), + "lastupdated" => items.sort_by(|left, right| { + json_u64(right, "time_updated") + .cmp(&json_u64(left, "time_updated")) + .then_with(|| json_u64(right, "time_created").cmp(&json_u64(left, "time_created"))) + .then_with(|| { + json_string(left, "publishedfileid").cmp(json_string(right, "publishedfileid")) + }) + }), + "totalprofiles" => items.sort_by(|left, right| { + json_u64(right, "lifetime_subscriptions") + .cmp(&json_u64(left, "lifetime_subscriptions")) + .then_with(|| { + json_u64(right, "subscriptions").cmp(&json_u64(left, "subscriptions")) + }) + .then_with(|| json_u64(right, "time_updated").cmp(&json_u64(left, "time_updated"))) + .then_with(|| { + json_string(left, "publishedfileid").cmp(json_string(right, "publishedfileid")) + }) + }), + "toprated" => items.sort_by(|left, right| { + json_f64(right, "score") + .total_cmp(&json_f64(left, "score")) + .then_with(|| json_u64(right, "total_votes").cmp(&json_u64(left, "total_votes"))) + .then_with(|| json_u64(right, "time_updated").cmp(&json_u64(left, "time_updated"))) + .then_with(|| { + json_string(left, "publishedfileid").cmp(json_string(right, "publishedfileid")) + }) + }), + _ => {} + } +} + +fn json_u64(value: &Value, key: &str) -> u64 { + value + .get(key) + .and_then(Value::as_u64) + .or_else(|| value.get(key).and_then(Value::as_str)?.parse::().ok()) + .unwrap_or(0) +} + +fn json_f64(value: &Value, key: &str) -> f64 { + value + .get(key) + .and_then(Value::as_f64) + .or_else(|| value.get(key).and_then(Value::as_str)?.parse::().ok()) + .unwrap_or(0.0) +} + +fn json_string<'a>(value: &'a Value, key: &str) -> &'a str { + value.get(key).and_then(Value::as_str).unwrap_or("") +} + +fn parse_published_file_id(workshop_id: &str) -> Result { + workshop_id + .trim() + .parse::() + .map(PublishedFileId) + .map_err(|_| format!("Invalid workshop id: {}", workshop_id)) +} + +fn parse_account_id(creator_id: &str) -> Result { + let raw = creator_id + .trim() + .parse::() + .map_err(|_| format!("Creator id is not numeric: {}", creator_id))?; + if raw > u32::MAX as u64 { + Ok(SteamId::from_raw(raw).account_id()) + } else { + Ok(AccountId::from_raw(raw as u32)) + } +} + +fn map_item_type(section: Option<&str>) -> UGCType { + match section.unwrap_or("readytouseitems") { + "collections" => UGCType::Collections, + _ => UGCType::ItemsReadyToUse, + } +} + +fn map_query_type(sort: Option<&str>) -> UGCQueryType { + match sort.unwrap_or("trend") { + "textsearch" => UGCQueryType::RankedByTextSearch, + "totalprofiles" => UGCQueryType::RankedByTotalUniqueSubscriptions, + "mostrecent" => UGCQueryType::RankedByPublicationDate, + "toprated" => UGCQueryType::RankedByVote, + "lastupdated" => UGCQueryType::RankedByLastUpdatedDate, + _ => UGCQueryType::RankedByTrend, + } +} + +fn map_user_list_order(sort: Option<&str>) -> UserListOrder { + match sort.unwrap_or("lastupdated") { + "mostrecent" => UserListOrder::CreationOrderDesc, + "lastupdated" => UserListOrder::LastUpdatedDesc, + _ => UserListOrder::LastUpdatedDesc, + } +} + +fn item_state_strings(state: ItemState) -> Vec<&'static str> { + let mut values = Vec::new(); + if state.contains(ItemState::SUBSCRIBED) { + values.push("subscribed"); + } + if state.contains(ItemState::LEGACY_ITEM) { + values.push("legacy"); + } + if state.contains(ItemState::INSTALLED) { + values.push("installed"); + } + if state.contains(ItemState::NEEDS_UPDATE) { + values.push("needs-update"); + } + if state.contains(ItemState::DOWNLOADING) { + values.push("downloading"); + } + if state.contains(ItemState::DOWNLOAD_PENDING) { + values.push("download-pending"); + } + values +} + +fn into_c_string(value: &T) -> *mut c_char { + let encoded = serde_json::to_string(value) + .unwrap_or_else(|err| json!({ "ok": false, "error": err.to_string() }).to_string()); + CString::new(encoded).unwrap().into_raw() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_mostrecent_sorts_newest_first() { + let mut items = vec![ + json!({ "publishedfileid": "1", "time_created": 10_u64, "time_updated": 50_u64 }), + json!({ "publishedfileid": "2", "time_created": 30_u64, "time_updated": 20_u64 }), + json!({ "publishedfileid": "3", "time_created": 20_u64, "time_updated": 60_u64 }), + ]; + + normalize_query_items(&mut items, Some("mostrecent")); + + assert_eq!(json_string(&items[0], "publishedfileid"), "2"); + assert_eq!(json_string(&items[1], "publishedfileid"), "3"); + assert_eq!(json_string(&items[2], "publishedfileid"), "1"); + } + + #[test] + fn normalize_lastupdated_sorts_latest_update_first() { + let mut items = vec![ + json!({ "publishedfileid": "1", "time_created": 10_u64, "time_updated": 50_u64 }), + json!({ "publishedfileid": "2", "time_created": 30_u64, "time_updated": 20_u64 }), + json!({ "publishedfileid": "3", "time_created": 20_u64, "time_updated": 60_u64 }), + ]; + + normalize_query_items(&mut items, Some("lastupdated")); + + assert_eq!(json_string(&items[0], "publishedfileid"), "3"); + assert_eq!(json_string(&items[1], "publishedfileid"), "1"); + assert_eq!(json_string(&items[2], "publishedfileid"), "2"); + } + + #[test] + fn normalize_totalprofiles_prefers_subscription_counts() { + let mut items = vec![ + json!({ "publishedfileid": "1", "subscriptions": 200_u64, "lifetime_subscriptions": 500_u64, "time_updated": 10_u64 }), + json!({ "publishedfileid": "2", "subscriptions": 100_u64, "lifetime_subscriptions": 900_u64, "time_updated": 20_u64 }), + json!({ "publishedfileid": "3", "subscriptions": 300_u64, "lifetime_subscriptions": 700_u64, "time_updated": 30_u64 }), + ]; + + normalize_query_items(&mut items, Some("totalprofiles")); + + assert_eq!(json_string(&items[0], "publishedfileid"), "2"); + assert_eq!(json_string(&items[1], "publishedfileid"), "3"); + assert_eq!(json_string(&items[2], "publishedfileid"), "1"); + } + + #[test] + fn normalize_toprated_prefers_score_then_votes() { + let mut items = vec![ + json!({ "publishedfileid": "1", "score": 0.90_f64, "total_votes": 100_u64, "time_updated": 10_u64 }), + json!({ "publishedfileid": "2", "score": 0.95_f64, "total_votes": 80_u64, "time_updated": 20_u64 }), + json!({ "publishedfileid": "3", "score": 0.95_f64, "total_votes": 120_u64, "time_updated": 30_u64 }), + ]; + + normalize_query_items(&mut items, Some("toprated")); + + assert_eq!(json_string(&items[0], "publishedfileid"), "3"); + assert_eq!(json_string(&items[1], "publishedfileid"), "2"); + assert_eq!(json_string(&items[2], "publishedfileid"), "1"); + } + + #[test] + fn parse_ids_trims_valid_input_and_rejects_invalid_values() { + assert_eq!(parse_published_file_id(" 123 ").unwrap().0, 123); + assert!(parse_published_file_id("not-an-id").is_err()); + assert!(parse_account_id("not-an-id").is_err()); + } + + #[test] + fn warning_deduplication_trims_for_comparison_and_keeps_first_value() { + assert_eq!( + dedupe_warnings(vec![ + "warning".to_string(), + " ".to_string(), + " warning ".to_string(), + "second warning".to_string(), + ]), + vec!["warning", "second warning"] + ); + } + + #[test] + fn persona_names_reject_placeholders_and_accept_real_names() { + assert!(!is_usable_persona_name("")); + assert!(!is_usable_persona_name(" [unknown] ")); + assert!(!is_usable_persona_name("AUTHOR_NAME")); + assert!(is_usable_persona_name("Bill")); + } +} diff --git a/vite.config.ts b/vite.config.ts index 9975f8e..f1dbb0b 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,23 @@ import { defineConfig } from 'vitest/config' import react from '@vitejs/plugin-react' -// https://vite.dev/config/ export default defineConfig({ plugins: [react()], + clearScreen: false, + server: { + port: 5173, + strictPort: true, + host: '127.0.0.1', + hmr: { + protocol: 'ws', + host: '127.0.0.1', + }, + watch: { + ignored: ['**/src-tauri/**'], + }, + }, test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], }, }) -