diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aface85 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [main, master] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + env: + # Tests and the renderer build do not need the Electron binary. + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run lint + - name: Typecheck + run: npx tsc -p tsconfig.node.json --noEmit && npx tsc -p tsconfig.json --noEmit + - name: Catalog + run: node scripts/verify-catalog.cjs --no-links && node scripts/generate-winget-map.cjs --verify + - name: Tests + run: | + node scripts/smoke.cjs + node scripts/test-download.cjs + node scripts/test-resilience.cjs + node scripts/test-app-full.cjs + - name: Build main + renderer + run: mkdir -p dist-electron && npx tsc -p tsconfig.node.json && npx vite build diff --git a/.github/workflows/refresh-installers.yml b/.github/workflows/refresh-installers.yml new file mode 100644 index 0000000..59dfd7b --- /dev/null +++ b/.github/workflows/refresh-installers.yml @@ -0,0 +1,36 @@ +name: Refresh installers + +# Pull the latest installer URLs and hashes from the winget manifests and +# propose them as a PR, so pinned downloads do not go stale between releases. +on: + schedule: + - cron: '17 5 * * 1' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + refresh: + runs-on: ubuntu-latest + env: + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run catalog:installers + - run: node scripts/smoke.cjs + - uses: peter-evans/create-pull-request@v7 + with: + branch: chore/refresh-installers + commit-message: Refresh installers.json from winget manifests + title: Refresh installers.json from winget manifests + body: | + Weekly `npm run catalog:installers`: latest installer URLs, SHA256 and switches + from cdn.winget.microsoft.com. Review version jumps and changed install types. + delete-branch: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..36bd67f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,33 @@ +# Changelog + +## 1.2.5 + +### Установка +- Ссылки на установщики больше не хранятся в каталоге вручную: `data/installers.json` генерируется из официальных манифестов winget (`npm run catalog:installers`) — актуальная версия, URL, SHA256 и ключи тихой установки. +- Перед запуском установщика сверяется SHA256 из манифеста; при несовпадении или сбое загрузки программа ставится через winget. +- Тип файла определяется по содержимому (EXE / MSI): HTML-страница вместо установщика больше не запускается. Исправлена установка Edge (MSI сохранялся как `.exe`). +- Загрузки только по HTTPS, в том числе после перенаправлений. +- Исправлены 10 неверных winget Id (HWMonitor ставил HWiNFO); генератор сверяет название пакета с каталогом. +- FileZilla и RustDesk, которых нет в winget, ставятся через Chocolatey; Teams, ImageGlass и Rufus — через winget. +- Коды MSI «нужна перезагрузка» (3010 / 1641) и коды успеха из манифестов считаются успешной установкой. + +### Рекомендации +- Лента «Для вас» на главной и ранжированная вкладка «Рекомендуем» с причиной на каждой карточке («Вместе с Visual Studio Code»). +- В карточке программы — «Часто ставят вместе» и «Альтернативы» (`data/related.json`). +- Пока ничего не установлено, показывается выбор редакции. + +### Безопасность +- Окно приложения работает в песочнице (`sandbox: true`), запрещены новые окна и переходы со страницы приложения. +- `openExternal` открывает только ссылки `http(s)`. +- Внешний каталог проверяется перед использованием. +- Удалён отладочный логгер, который в сборке писал файл и отправлял события на localhost. + +### Разработка +- Рабочая конфигурация ESLint (typescript-eslint, react-hooks). +- CI на GitHub Actions и еженедельный PR с обновлением `installers.json`. +- `npm run build` работает в свежем клоне. +- Исходники промо-роликов в `promo/`. + +## 1.2.0 + +Первая публичная версия: курируемый каталог, Scoop / winget / Chocolatey, очередь установок, профили, RU / EN. diff --git a/README.md b/README.md index 4fb40e1..6a59b9b 100644 --- a/README.md +++ b/README.md @@ -12,28 +12,30 @@ |---|---| | Платформа | Windows x64 | | Стек | Electron · React · TypeScript · Vite | -| Версия | 1.2.0 | +| Версия | 1.2.5 | | Дистрибутив | Portable `.exe` (~70 МБ) | --- ## Возможности -- **Курируемый каталог** — витрина с иконками, описаниями и прямыми ссылками на установщики +- **Курируемый каталог** — витрина с иконками и описаниями; ссылки на установщики не хранятся вручную, а берутся из манифестов winget - **Все пакеты** — индекс Scoop / winget / Chocolatey на отдельной вкладке - **Очередь установок** — один install за раз, с прогрессом -- **Избранное, профили, рекомендации** — быстрый старт наборами программ +- **Персональные рекомендации** — лента «Для вас» по установленному и избранному, «Часто ставят вместе» и «Альтернативы» в карточке программы +- **Избранное и профили** — быстрый старт наборами программ - **RU / EN** — переключение языка в интерфейсе - **Кастомный title bar** — без системного меню File / Edit / View - **Очередь с отменой** — можно отменить загрузку/установку, повторить или убрать ошибку - **Понятные ошибки** — 404, блокировка CDN/региона, сеть, таймаут (RU/EN) - **Offline-first** — каталог работает сразу, runtime и метаданные догружаются в фоне +- **Проверка установщиков** — только HTTPS, SHA256 из манифеста winget сверяется до запуска, тип файла определяется по содержимому ## Провайдеры | Провайдер | ID | Права | Описание | |-----------|-----|-------|----------| -| **Каталог GetBox** | `catalog` | Не нужны | Курируемый offline-каталог + прямая загрузка installer | +| **Каталог GetBox** | `catalog` | Не нужны | Курируемый offline-каталог; установщик качается напрямую по манифесту winget, при сбое — через winget | | **Scoop** | `scoop` | Не нужны | Изолированный Scoop в `runtime\scoop\` | | **winget** | `winget` | Не нужны* | Системный winget / App Installer | | **Chocolatey** | `chocolatey` | UAC 1× | Опционально: кнопка «Разрешить установку» при первом запуске | @@ -49,7 +51,7 @@ Соберите portable-сборку или возьмите готовый артефакт из [Releases](../../releases) (если опубликованы): ```text -release/GetBox-1.2.0-portable.exe +release/GetBox-1.2.5-portable.exe ``` Runtime не упакован внутрь exe — он ставится при первом запуске в `%LOCALAPPDATA%`, поэтому файл остаётся относительно лёгким. @@ -82,7 +84,7 @@ npm run electron:dev npm run dist ``` -Результат: `release/GetBox-1.2.0-portable.exe`. +Результат: `release/GetBox-1.2.5-portable.exe`. ### Скрипты @@ -95,14 +97,39 @@ npm run dist | `npm run prewarm` | Предзагрузка метаданных (CLI) | | `npm test` | Проверка каталога + smoke | | `npm run verify:catalog` | Только проверка `data/catalog.json` | +| `npm run catalog:installers` | Обновить `data/installers.json` из манифестов winget (Node 22.5+); `-- --check` только проверяет | | `npm run lint` | ESLint | +### Откуда берутся установщики + +В `data/catalog.json` только витрина: название, описание, категория, иконка. Как ставить программу, решает `data/installers.json`, сгенерированный `npm run catalog:installers` из официального индекса winget (`cdn.winget.microsoft.com`, тот же источник, что у клиента winget): + +- **прямая загрузка** (113 программ) — URL, SHA256, тип и ключи тихой установки из манифеста. GetBox качает только по HTTPS, сверяет SHA256 и лишь потом запускает; при любом сбое ставит через winget; +- **только winget** — у программы есть лишь MSIX / portable-сборка (Teams, ImageGlass, Rufus); +- **другой менеджер** — записи вида `{ "provider": "chocolatey", "packageId": "filezilla" }` для программ, которых нет в winget. Их скрипт не трогает. + +Связь «программа → winget Id» — в `data/winget-map.json`. Скрипт сверяет название пакета с каталогом и не даст подставить чужую программу из-за опечатки в Id. + +Добавили программу: допишите её в `catalog.json` и `winget-overrides.json`, затем `npm run winget:map` и `npm run catalog:installers`. + +Раз в неделю workflow `refresh-installers.yml` запускает этот скрипт и открывает PR с новыми версиями. Чтобы он мог создавать PR, в настройках репозитория включите *Settings → Actions → General → Allow GitHub Actions to create and approve pull requests*. + +### Рекомендации + +`src/recommend.ts` ранжирует программы каталога по тому, что у пользователя установлено и в избранном: + +- **связки** из `data/related.json` и профили — «ставят вместе с …»; +- **альтернативы** — если браузер или менеджер паролей уже есть, остальные из группы опускаются в ленте; +- доля установленного по категориям и выбор редакции (`picks.json`, `recommended`). + +Пока у пользователя ничего нет, лента показывает выбор редакции. В карточке программы — блоки «Часто ставят вместе» и «Альтернативы». + ## Структура репозитория ```text electron/ Main process, провайдеры, bootstrap, IPC, очередь установок src/ React UI (витрина, очередь, настройки, i18n) -data/ Каталог, категории, профили, иконки, logo.svg +data/ Каталог, installers.json, related.json, категории, профили, иконки scripts/ Иконки приложения, verify-catalog, smoke release/ Артефакты electron-builder (в git обычно не коммитятся) ``` diff --git a/data/catalog.json b/data/catalog.json index 4b437db..5296ce8 100644 --- a/data/catalog.json +++ b/data/catalog.json @@ -9,12 +9,6 @@ "provider": "catalog", "wikiTitle": "Mozilla_Firefox", "logoSlug": "firefox", - "installUrl": "https://download.mozilla.org/?product=firefox-latest&os=win64&lang=ru", - "installUrlFallbacks": [ - "https://download.mozilla.org/?product=firefox-latest&os=win64&lang=en-US" - ], - "altProvider": { "provider": "winget", "packageId": "Mozilla.Firefox" }, - "silentArgs": "/S", "recommended": true }, { @@ -26,10 +20,7 @@ "packageId": "googlechrome", "provider": "catalog", "wikiTitle": "Google_Chrome", - "logoSlug": "google-chrome", - "installUrl": "https://dl.google.com/chrome/install/latest/chrome_installer.exe", - "altProvider": { "provider": "winget", "packageId": "Google.Chrome" }, - "silentArgs": "/silent /install" + "logoSlug": "google-chrome" }, { "id": "edge", @@ -40,9 +31,7 @@ "packageId": "microsoft-edge", "provider": "catalog", "wikiTitle": "Microsoft_Edge", - "logoSlug": "microsoft-edge", - "installUrl": "https://go.microsoft.com/fwlink/?linkid=2093437", - "silentArgs": "/silent /install" + "logoSlug": "microsoft-edge" }, { "id": "brave", @@ -53,9 +42,7 @@ "packageId": "brave", "provider": "catalog", "wikiTitle": "Brave_(web_browser)", - "logoSlug": "brave", - "installUrl": "https://laptop-updates.brave.com/latest/winx64", - "silentArgs": "/silent /install" + "logoSlug": "brave" }, { "id": "opera", @@ -66,9 +53,7 @@ "packageId": "opera", "provider": "catalog", "wikiTitle": "Opera_(web_browser)", - "logoSlug": "opera", - "installUrl": "https://download.opera.com/download/get/?partner=www&opsys=Windows", - "silentArgs": "/silent /install" + "logoSlug": "opera" }, { "id": "vivaldi", @@ -79,9 +64,7 @@ "packageId": "vivaldi", "provider": "catalog", "wikiTitle": "Vivaldi_(web_browser)", - "logoSlug": "vivaldi", - "installUrl": "https://downloads.vivaldi.com/stable/Vivaldi.7.0.3495.29.x64.exe", - "silentArgs": "--vivaldi-silent --do-not-launch-chrome" + "logoSlug": "vivaldi" }, { "id": "steam", @@ -93,12 +76,6 @@ "provider": "catalog", "wikiTitle": "Steam_(service)", "logoSlug": "steam", - "installUrl": "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe", - "installUrlFallbacks": [ - "https://cdn.cloudflare.steamstatic.com/client/installer/SteamSetup.exe" - ], - "altProvider": { "provider": "winget", "packageId": "Valve.Steam" }, - "silentArgs": "/S", "recommended": true }, { @@ -111,9 +88,6 @@ "provider": "catalog", "wikiTitle": "Epic_Games_Store", "logoSlug": "epic-games", - "installUrl": "https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/installer/download/EpicGamesLauncherInstaller.msi", - "altProvider": { "provider": "winget", "packageId": "EpicGames.EpicGamesLauncher" }, - "silentArgs": "/quiet /norestart", "recommended": true }, { @@ -127,9 +101,6 @@ "wikiTitle": "GOG.com", "logoSlug": "gog-galaxy", "simpleSlug": "gogdotcom", - "installUrl": "https://webinstallers.gog.com/download/GOG_Galaxy_2.0.exe", - "altProvider": { "provider": "winget", "packageId": "GOG.Galaxy" }, - "silentArgs": "/S", "recommended": true }, { @@ -142,9 +113,7 @@ "provider": "catalog", "wikiTitle": "Battle.net", "logoSlug": "battlenet", - "simpleSlug": "battledotnet", - "installUrl": "https://downloader.battle.net/download/getInstaller?os=win&installer=Battle.net-Setup.exe", - "silentArgs": "--installpath-silent" + "simpleSlug": "battledotnet" }, { "id": "ubisoft-connect", @@ -156,9 +125,7 @@ "provider": "catalog", "wikiTitle": "Ubisoft_Connect", "logoSlug": "ubisoft-connect", - "simpleSlug": "ubisoft", - "installUrl": "https://ubistatic3-a.akamaihd.net/orbit/launcher_installer/UbisoftConnectInstaller.exe", - "silentArgs": "/S" + "simpleSlug": "ubisoft" }, { "id": "ea-app", @@ -170,9 +137,7 @@ "provider": "catalog", "wikiTitle": "EA_app", "logoSlug": "electronic-arts", - "simpleSlug": "ea", - "installUrl": "https://origin-a.akamaihd.net/EA-Desktop-Client-Download/installer-releases/EAappInstaller.exe", - "silentArgs": "/quiet" + "simpleSlug": "ea" }, { "id": "rockstar", @@ -184,9 +149,7 @@ "provider": "catalog", "wikiTitle": "Rockstar_Games_Launcher", "logoSlug": "rockstar-games", - "simpleSlug": "rockstargames", - "installUrl": "https://gamedownloads.rockstargames.com/public/installer/Rockstar-Games-Launcher.exe", - "silentArgs": "/S" + "simpleSlug": "rockstargames" }, { "id": "playnite", @@ -198,9 +161,7 @@ "provider": "catalog", "wikiTitle": "Playnite", "logoSlug": "playnite", - "iconUrl": "https://playnite.link/applogo.png", - "installUrl": "https://playnite.link/download/PlayniteInstaller.exe", - "silentArgs": "/SILENT /NORESTART" + "iconUrl": "https://playnite.link/applogo.png" }, { "id": "heroic", @@ -211,9 +172,7 @@ "packageId": "heroic", "provider": "catalog", "logoSlug": "heroic", - "iconUrl": "https://raw.githubusercontent.com/Heroic-Games-Launcher/HeroicGamesLauncher/main/public/icon.png", - "installUrl": "https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/releases/download/v2.22.0/Heroic-2.22.0-Setup-x64.exe", - "silentArgs": "/S" + "iconUrl": "https://raw.githubusercontent.com/Heroic-Games-Launcher/HeroicGamesLauncher/main/public/icon.png" }, { @@ -226,9 +185,6 @@ "provider": "catalog", "wikiTitle": "Visual_Studio_Code", "logoSlug": "vscode", - "installUrl": "https://code.visualstudio.com/sha/download?build=stable&os=win32-x64-user", - "altProvider": { "provider": "winget", "packageId": "Microsoft.VisualStudioCode" }, - "silentArgs": "/VERYSILENT /NORESTART", "recommended": true }, { @@ -241,9 +197,6 @@ "provider": "catalog", "wikiTitle": "Git", "logoSlug": "git", - "installUrl": "https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.3/Git-2.55.0.3-64-bit.exe", - "altProvider": { "provider": "winget", "packageId": "Git.Git" }, - "silentArgs": "/VERYSILENT /NORESTART", "recommended": true }, { @@ -256,9 +209,6 @@ "provider": "catalog", "wikiTitle": "Node.js", "logoSlug": "nodejs", - "installUrl": "https://nodejs.org/dist/v22.11.0/node-v22.11.0-x64.msi", - "altProvider": { "provider": "winget", "packageId": "OpenJS.NodeJS.LTS" }, - "silentArgs": "/quiet", "recommended": true }, { @@ -270,9 +220,7 @@ "packageId": "python", "provider": "catalog", "wikiTitle": "Python_(programming_language)", - "logoSlug": "python", - "installUrl": "https://www.python.org/ftp/python/3.12.7/python-3.12.7-amd64.exe", - "silentArgs": "/quiet InstallAllUsers=0 PrependPath=1" + "logoSlug": "python" }, { "id": "notepadpp", @@ -284,9 +232,7 @@ "provider": "catalog", "wikiTitle": "Notepad++", "logoSlug": "notepad-plus-plus", - "simpleSlug": "notepadplusplus", - "installUrl": "https://github.com/notepad-plus-plus/notepad-plus-plus/releases/download/v8.9.7/npp.8.9.7.Installer.x64.exe", - "silentArgs": "/S" + "simpleSlug": "notepadplusplus" }, { "id": "docker", @@ -297,9 +243,7 @@ "packageId": "docker", "provider": "catalog", "wikiTitle": "Docker_(software)", - "logoSlug": "docker", - "installUrl": "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe", - "silentArgs": "install --quiet" + "logoSlug": "docker" }, { "id": "postman", @@ -310,9 +254,7 @@ "packageId": "postman", "provider": "catalog", "wikiTitle": "Postman_(software)", - "logoSlug": "postman", - "installUrl": "https://dl.pstmn.io/download/latest/win64", - "silentArgs": "/S" + "logoSlug": "postman" }, { "id": "golang", @@ -323,9 +265,7 @@ "packageId": "go", "provider": "catalog", "wikiTitle": "Go_(programming_language)", - "logoSlug": "go", - "installUrl": "https://go.dev/dl/go1.23.4.windows-amd64.msi", - "silentArgs": "/quiet" + "logoSlug": "go" }, { "id": "rust", @@ -336,9 +276,7 @@ "packageId": "rustup", "provider": "catalog", "wikiTitle": "Rust_(programming_language)", - "logoSlug": "rust", - "installUrl": "https://win.rustup.rs/x86_64", - "silentArgs": "-y" + "logoSlug": "rust" }, { "id": "temurin-jdk", @@ -349,9 +287,7 @@ "packageId": "temurin-21", "provider": "catalog", "wikiTitle": "OpenJDK", - "logoSlug": "java", - "installUrl": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12%2B8/OpenJDK21U-jdk_x64_windows_hotspot_21.0.12_8.msi", - "silentArgs": "/quiet" + "logoSlug": "java" }, { "id": "intellij-idea", @@ -363,9 +299,7 @@ "provider": "catalog", "wikiTitle": "IntelliJ_IDEA", "logoSlug": "intellij", - "simpleSlug": "intellijidea", - "installUrl": "https://download.jetbrains.com/idea/ideaIC-2024.3.1.1.exe", - "silentArgs": "/S" + "simpleSlug": "intellijidea" }, { "id": "pycharm", @@ -377,9 +311,7 @@ "provider": "catalog", "wikiTitle": "PyCharm", "logoSlug": "pycharm", - "simpleSlug": "pycharm", - "installUrl": "https://download.jetbrains.com/python/pycharm-community-2024.3.1.1.exe", - "silentArgs": "/S" + "simpleSlug": "pycharm" }, { "id": "sublime-text", @@ -391,9 +323,7 @@ "provider": "catalog", "wikiTitle": "Sublime_Text", "logoSlug": "sublime-text", - "simpleSlug": "sublimetext", - "installUrl": "https://download.sublimetext.com/sublime_text_build_4192_x64_setup.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "simpleSlug": "sublimetext" }, { "id": "neovim", @@ -404,9 +334,7 @@ "packageId": "neovim", "provider": "catalog", "wikiTitle": "Vim_(text_editor)", - "logoSlug": "neovim", - "installUrl": "https://github.com/neovim/neovim/releases/download/v0.12.4/nvim-win64.msi", - "silentArgs": "/quiet" + "logoSlug": "neovim" }, { "id": "dbeaver", @@ -418,9 +346,7 @@ "provider": "catalog", "wikiTitle": "DBeaver", "logoSlug": "dbeaver", - "simpleSlug": "dbeaver", - "installUrl": "https://dbeaver.io/files/dbeaver-ce-latest-x86_64-setup.exe", - "silentArgs": "/S" + "simpleSlug": "dbeaver" }, { "id": "wireshark", @@ -432,9 +358,7 @@ "provider": "catalog", "wikiTitle": "Wireshark", "logoSlug": "wireshark", - "simpleSlug": "wireshark", - "installUrl": "https://www.wireshark.org/download/win64/Wireshark-latest-x64.exe", - "silentArgs": "/S" + "simpleSlug": "wireshark" }, { "id": "virtualbox", @@ -445,9 +369,7 @@ "packageId": "virtualbox", "provider": "catalog", "wikiTitle": "VirtualBox", - "logoSlug": "virtualbox", - "installUrl": "https://download.virtualbox.org/virtualbox/7.2.14/VirtualBox-7.2.14-174565-Win.exe", - "silentArgs": "/silent" + "logoSlug": "virtualbox" }, { @@ -460,13 +382,6 @@ "provider": "catalog", "wikiTitle": "Discord", "logoSlug": "discord", - "installUrl": "https://discord.com/api/download?platform=win", - "installUrlFallbacks": [ - "https://discord.com/api/downloads/distributions/app/installers/latest?channel=stable&platform=win&arch=x64", - "https://discordapp.com/api/download?platform=win" - ], - "altProvider": { "provider": "winget", "packageId": "Discord.Discord" }, - "silentArgs": "-s", "recommended": true }, { @@ -479,12 +394,6 @@ "provider": "catalog", "wikiTitle": "Telegram_(software)", "logoSlug": "telegram", - "installUrl": "https://telegram.org/dl/desktop/win64", - "installUrlFallbacks": [ - "https://updates.tdesktop.com/tsetup/x64/tsetup-x64.latest.exe" - ], - "altProvider": { "provider": "winget", "packageId": "Telegram.TelegramDesktop" }, - "silentArgs": "/VERYSILENT /NORESTART", "recommended": true }, { @@ -496,10 +405,7 @@ "packageId": "slack", "provider": "catalog", "wikiTitle": "Slack_(software)", - "logoSlug": "slack", - "installUrl": "https://downloads.slack-edge.com/desktop-releases/windows/x64/4.42.115/SlackSetup.exe", - "altProvider": { "provider": "winget", "packageId": "SlackTechnologies.Slack" }, - "silentArgs": "/silent" + "logoSlug": "slack" }, { "id": "teams", @@ -510,9 +416,7 @@ "packageId": "teams", "provider": "catalog", "wikiTitle": "Microsoft_Teams", - "logoSlug": "microsoft-teams", - "installUrl": "https://go.microsoft.com/fwlink/?linkid=2196106", - "silentArgs": "/silent" + "logoSlug": "microsoft-teams" }, { "id": "zoom", @@ -523,13 +427,7 @@ "packageId": "zoom", "provider": "catalog", "wikiTitle": "Zoom_(software)", - "logoSlug": "zoom", - "installUrl": "https://zoom.us/client/latest/ZoomInstallerFull.exe", - "installUrlFallbacks": [ - "https://zoom.us/client/latest/ZoomInstaller.exe" - ], - "altProvider": { "provider": "winget", "packageId": "Zoom.Zoom" }, - "silentArgs": "/silent" + "logoSlug": "zoom" }, { @@ -543,12 +441,6 @@ "wikiTitle": "VLC_media_player", "logoSlug": "vlc", "simpleSlug": "vlcmediaplayer", - "installUrl": "https://get.videolan.org/vlc/3.0.21/win64/vlc-3.0.21-win64.exe", - "installUrlFallbacks": [ - "https://mirror.accum.se/mirror/videolan.org/vlc/3.0.21/win64/vlc-3.0.21-win64.exe" - ], - "altProvider": { "provider": "winget", "packageId": "VideoLAN.VLC" }, - "silentArgs": "/S", "recommended": true }, { @@ -560,13 +452,7 @@ "packageId": "spotify", "provider": "catalog", "wikiTitle": "Spotify", - "logoSlug": "spotify", - "installUrl": "https://download.scdn.co/SpotifySetup.exe", - "installUrlFallbacks": [ - "https://www.spotify.com/download/win/" - ], - "altProvider": { "provider": "winget", "packageId": "Spotify.Spotify" }, - "silentArgs": "/silent" + "logoSlug": "spotify" }, { "id": "obs", @@ -579,9 +465,6 @@ "wikiTitle": "Open_Broadcaster_Software", "logoSlug": "obs-studio", "simpleSlug": "obsstudio", - "installUrl": "https://github.com/obsproject/obs-studio/releases/download/32.2.1/OBS-Studio-32.2.1-Windows-x64-Installer.exe", - "altProvider": { "provider": "winget", "packageId": "OBSProject.OBSStudio" }, - "silentArgs": "/S", "recommended": true }, { @@ -593,9 +476,7 @@ "packageId": "audacity", "provider": "catalog", "wikiTitle": "Audacity_(audio_editor)", - "logoSlug": "audacity", - "installUrl": "https://github.com/audacity/audacity/releases/download/Audacity-3.7.8/audacity-win-3.7.8-64bit.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "logoSlug": "audacity" }, { "id": "handbrake", @@ -606,9 +487,7 @@ "packageId": "handbrake", "provider": "catalog", "wikiTitle": "HandBrake", - "logoSlug": "handbrake", - "installUrl": "https://github.com/HandBrake/HandBrake/releases/download/1.11.2/HandBrake-1.11.2-x86_64-Win_GUI.exe", - "silentArgs": "/S" + "logoSlug": "handbrake" }, { "id": "kodi", @@ -619,9 +498,7 @@ "packageId": "kodi", "provider": "catalog", "wikiTitle": "Kodi_(software)", - "logoSlug": "kodi", - "installUrl": "https://mirrors.kodi.tv/releases/windows/win64/kodi-21.2-Omega-x64.exe", - "silentArgs": "/S" + "logoSlug": "kodi" }, { "id": "foobar2000", @@ -633,9 +510,7 @@ "provider": "catalog", "wikiTitle": "Foobar2000", "logoSlug": "foobar2000", - "iconUrl": "https://www.google.com/s2/favicons?domain=foobar2000.org&sz=256", - "installUrl": "https://www.foobar2000.org/files/foobar2000-x64_v2.24.1.exe", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=foobar2000.org&sz=256" }, { @@ -647,9 +522,7 @@ "packageId": "gimp", "provider": "catalog", "wikiTitle": "GIMP", - "logoSlug": "gimp", - "installUrl": "https://download.gimp.org/mirror/pub/gimp/v2.10/windows/gimp-2.10.38-setup.exe", - "silentArgs": "/VERYSILENT /NORESTART /ALLUSERS=0" + "logoSlug": "gimp" }, { "id": "krita", @@ -661,9 +534,7 @@ "provider": "catalog", "wikiTitle": "Krita", "logoSlug": "krita", - "simpleSlug": "krita", - "installUrl": "https://download.kde.org/stable/krita/5.2.9/krita-x64-5.2.9-setup.exe", - "silentArgs": "/S" + "simpleSlug": "krita" }, { "id": "blender", @@ -674,9 +545,7 @@ "packageId": "blender", "provider": "catalog", "wikiTitle": "Blender_(software)", - "logoSlug": "blender", - "installUrl": "https://mirrors.dotsrc.org/blender/release/Blender4.3/blender-4.3.2-windows-x64.msi", - "silentArgs": "/quiet" + "logoSlug": "blender" }, { "id": "inkscape", @@ -688,10 +557,7 @@ "provider": "catalog", "wikiTitle": "Inkscape", "logoSlug": "inkscape", - "simpleSlug": "inkscape", - "installUrl": "https://inkscape.org/gallery/item/53678/inkscape-1.4.msi", - "altProvider": { "provider": "winget", "packageId": "Inkscape.Inkscape" }, - "silentArgs": "/quiet" + "simpleSlug": "inkscape" }, { "id": "figma", @@ -702,9 +568,7 @@ "packageId": "figma", "provider": "catalog", "wikiTitle": "Figma", - "logoSlug": "figma", - "installUrl": "https://desktop.figma.com/win/FigmaSetup.exe", - "silentArgs": "/S" + "logoSlug": "figma" }, { "id": "sharex", @@ -716,9 +580,7 @@ "provider": "catalog", "wikiTitle": "ShareX", "logoSlug": "sharex", - "simpleSlug": "sharex", - "installUrl": "https://github.com/ShareX/ShareX/releases/download/v21.0.0/ShareX-21.0.0-setup-x64.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "simpleSlug": "sharex" }, { @@ -731,9 +593,6 @@ "provider": "catalog", "wikiTitle": "7-Zip", "logoSlug": "7zip", - "installUrl": "https://www.7-zip.org/a/7z2501-x64.exe", - "altProvider": { "provider": "winget", "packageId": "7zip.7zip" }, - "silentArgs": "/S", "recommended": true }, { @@ -746,9 +605,7 @@ "provider": "catalog", "wikiTitle": "WinRAR", "logoSlug": "winrar", - "iconUrl": "https://www.google.com/s2/favicons?domain=win-rar.com&sz=256", - "installUrl": "https://www.win-rar.com/fileadmin/winrar-versions/winrar/winrar-x64-701.exe", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=win-rar.com&sz=256" }, { "id": "everything", @@ -761,9 +618,6 @@ "wikiTitle": "Everything_(software)", "logoSlug": "everything", "iconUrl": "https://www.google.com/s2/favicons?domain=voidtools.com&sz=256", - "installUrl": "https://www.voidtools.com/Everything-1.4.1.1024.x64-Setup.exe", - "altProvider": { "provider": "winget", "packageId": "voidtools.Everything" }, - "silentArgs": "/S", "recommended": true }, { @@ -775,10 +629,7 @@ "packageId": "qbittorrent", "provider": "catalog", "wikiTitle": "QBitTorrent", - "logoSlug": "qbittorrent", - "installUrl": "https://sourceforge.net/projects/qbittorrent/files/qbittorrent-win32/qbittorrent-5.0.3/qbittorrent_5.0.3_x64_setup.exe/download", - "altProvider": { "provider": "winget", "packageId": "qBittorrent.qBittorrent" }, - "silentArgs": "/S" + "logoSlug": "qbittorrent" }, { "id": "putty", @@ -789,9 +640,7 @@ "packageId": "putty", "provider": "catalog", "wikiTitle": "PuTTY", - "logoSlug": "putty", - "installUrl": "https://the.earth.li/~sgtatham/putty/latest/w64/putty-64bit-0.84-installer.msi", - "silentArgs": "/quiet /norestart" + "logoSlug": "putty" }, { "id": "winscp", @@ -803,9 +652,7 @@ "provider": "catalog", "wikiTitle": "WinSCP", "logoSlug": "winscp", - "iconUrl": "https://www.google.com/s2/favicons?domain=winscp.net&sz=256", - "installUrl": "https://winscp.net/download/WinSCP-6.3.6-Setup.exe", - "silentArgs": "/SILENT /NORESTART" + "iconUrl": "https://www.google.com/s2/favicons?domain=winscp.net&sz=256" }, { "id": "filezilla", @@ -816,13 +663,7 @@ "packageId": "filezilla", "provider": "catalog", "wikiTitle": "FileZilla", - "logoSlug": "filezilla", - "installUrl": "https://dl2.cdn.filezilla-project.org/client/FileZilla_3.68.1_win64-setup.exe", - "installUrlFallbacks": [ - "https://dl1.cdn.filezilla-project.org/client/FileZilla_3.68.1_win64-setup.exe" - ], - "altProvider": { "provider": "winget", "packageId": "TimKosse.FileZilla.Client" }, - "silentArgs": "/S" + "logoSlug": "filezilla" }, { "id": "anydesk", @@ -833,9 +674,7 @@ "packageId": "anydesk", "provider": "catalog", "wikiTitle": "AnyDesk", - "logoSlug": "anydesk", - "installUrl": "https://download.anydesk.com/AnyDesk.exe", - "silentArgs": "--install --silent" + "logoSlug": "anydesk" }, { "id": "teamviewer", @@ -847,13 +686,7 @@ "provider": "catalog", "wikiTitle": "TeamViewer", "logoSlug": "teamviewer", - "simpleSlug": "teamviewer", - "installUrl": "https://download.teamviewer.com/download/TeamViewer_Setup_x64.exe", - "installUrlFallbacks": [ - "https://download.teamviewer.com/full" - ], - "altProvider": { "provider": "winget", "packageId": "TeamViewer.TeamViewer" }, - "silentArgs": "/S" + "simpleSlug": "teamviewer" }, { "id": "parsec", @@ -865,9 +698,7 @@ "provider": "catalog", "wikiTitle": "Parsec_(software)", "logoSlug": "parsec", - "iconUrl": "https://www.google.com/s2/favicons?domain=parsec.app&sz=256", - "installUrl": "https://builds.parsec.app/package/parsec-windows.exe", - "silentArgs": "/silent" + "iconUrl": "https://www.google.com/s2/favicons?domain=parsec.app&sz=256" }, { "id": "moonlight", @@ -878,9 +709,7 @@ "packageId": "moonlight", "provider": "catalog", "logoSlug": "moonlight", - "iconUrl": "https://www.google.com/s2/favicons?domain=moonlight-stream.org&sz=256", - "installUrl": "https://github.com/moonlight-stream/moonlight-qt/releases/download/v6.1.0/MoonlightSetup-6.1.0.exe", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=moonlight-stream.org&sz=256" }, { @@ -893,9 +722,7 @@ "provider": "catalog", "wikiTitle": "CPU-Z", "logoSlug": "cpu-z", - "iconUrl": "https://www.google.com/s2/favicons?domain=cpuid.com&sz=256", - "installUrl": "https://download.cpuid.com/cpu-z/cpu-z_2.13-en.exe", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=cpuid.com&sz=256" }, { "id": "hwmonitor", @@ -906,9 +733,7 @@ "packageId": "hwmonitor", "provider": "catalog", "logoSlug": "hwmonitor", - "iconUrl": "https://www.google.com/s2/favicons?domain=cpuid.com&sz=256", - "installUrl": "https://download.cpuid.com/hwmonitor/hwmonitor_1.56.exe", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=cpuid.com&sz=256" }, { "id": "crystaldiskinfo", @@ -920,9 +745,7 @@ "provider": "catalog", "wikiTitle": "CrystalDiskInfo", "logoSlug": "crystaldiskinfo", - "iconUrl": "https://www.google.com/s2/favicons?domain=crystalmark.info&sz=256", - "installUrl": "https://sourceforge.net/projects/crystaldiskinfo/files/latest/download", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=crystalmark.info&sz=256" }, { "id": "powertoys", @@ -935,9 +758,6 @@ "wikiTitle": "Microsoft_PowerToys", "logoSlug": "powertoys", "iconUrl": "https://raw.githubusercontent.com/microsoft/PowerToys/main/src/settings-ui/Settings.UI/Assets/Settings/icon.ico", - "installUrl": "https://github.com/microsoft/PowerToys/releases/download/v0.100.2/PowerToysUserSetup-0.100.2-x64.exe", - "altProvider": { "provider": "winget", "packageId": "Microsoft.PowerToys" }, - "silentArgs": "/silent", "recommended": true }, { @@ -950,9 +770,7 @@ "provider": "catalog", "wikiTitle": "Rufus_(software)", "logoSlug": "rufus", - "iconUrl": "https://rufus.ie/pics/rufus-128.png", - "installUrl": "https://github.com/pbatard/rufus/releases/download/v4.15/rufus-4.15.exe", - "silentArgs": "/S" + "iconUrl": "https://rufus.ie/pics/rufus-128.png" }, { "id": "treesize-free", @@ -963,9 +781,7 @@ "packageId": "treesize-free", "provider": "catalog", "logoSlug": "treesize", - "iconUrl": "https://www.google.com/s2/favicons?domain=jam-software.com&sz=256", - "installUrl": "https://downloads.jam-software.de/treesize_free/TreeSizeFreeSetup.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "iconUrl": "https://www.google.com/s2/favicons?domain=jam-software.com&sz=256" }, { @@ -977,10 +793,7 @@ "packageId": "keepass", "provider": "catalog", "wikiTitle": "KeePass", - "logoSlug": "keepass", - "installUrl": "https://sourceforge.net/projects/keepass/files/KeePass%202.x/2.57/KeePass-2.57-Setup.exe/download", - "altProvider": { "provider": "winget", "packageId": "DominikReichl.KeePass" }, - "silentArgs": "/VERYSILENT" + "logoSlug": "keepass" }, { "id": "bitwarden", @@ -992,9 +805,6 @@ "provider": "catalog", "wikiTitle": "Bitwarden", "logoSlug": "bitwarden", - "installUrl": "https://vault.bitwarden.com/download/?app=desktop&platform=windows&variant=exe", - "altProvider": { "provider": "winget", "packageId": "Bitwarden.Bitwarden" }, - "silentArgs": "/S", "recommended": true }, { @@ -1006,9 +816,7 @@ "packageId": "veracrypt", "provider": "catalog", "wikiTitle": "VeraCrypt", - "logoSlug": "vera-crypt", - "installUrl": "https://launchpad.net/veracrypt/trunk/1.26.20/+download/VeraCrypt%20Setup%201.26.20.exe", - "silentArgs": "/S" + "logoSlug": "vera-crypt" }, { "id": "malwarebytes", @@ -1020,10 +828,7 @@ "provider": "catalog", "wikiTitle": "Malwarebytes_(software)", "logoSlug": "malwarebytes", - "simpleSlug": "malwarebytes", - "installUrl": "https://downloads.malwarebytes.com/file/mb-windows", - "altProvider": { "provider": "winget", "packageId": "Malwarebytes.Malwarebytes" }, - "silentArgs": "/silent" + "simpleSlug": "malwarebytes" }, { "id": "keepassxc", @@ -1034,9 +839,7 @@ "packageId": "keepassxc", "provider": "catalog", "wikiTitle": "KeePassXC", - "logoSlug": "keepassxc", - "installUrl": "https://github.com/keepassxreboot/keepassxc/releases/download/2.7.9/KeePassXC-2.7.9-Win64.msi", - "silentArgs": "/quiet /norestart" + "logoSlug": "keepassxc" }, { "id": "wireguard", @@ -1047,10 +850,7 @@ "packageId": "wireguard", "provider": "catalog", "wikiTitle": "WireGuard", - "logoSlug": "wireguard", - "installUrl": "https://download.wireguard.com/windows-client/wireguard-installer.exe", - "altProvider": { "provider": "winget", "packageId": "WireGuard.WireGuard" }, - "silentArgs": "/S" + "logoSlug": "wireguard" }, { "id": "tailscale", @@ -1061,9 +861,7 @@ "packageId": "tailscale", "provider": "catalog", "wikiTitle": "Tailscale", - "logoSlug": "tailscale", - "installUrl": "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi", - "silentArgs": "/quiet" + "logoSlug": "tailscale" }, { @@ -1076,9 +874,6 @@ "provider": "catalog", "wikiTitle": "LibreOffice", "logoSlug": "libreoffice", - "installUrl": "https://download.documentfoundation.org/libreoffice/stable/26.2.5/win/x86_64/LibreOffice_26.2.5_Win_x86-64.msi", - "altProvider": { "provider": "winget", "packageId": "TheDocumentFoundation.LibreOffice" }, - "silentArgs": "/quiet /norestart", "recommended": true }, { @@ -1090,13 +885,7 @@ "packageId": "thunderbird", "provider": "catalog", "wikiTitle": "Mozilla_Thunderbird", - "logoSlug": "thunderbird", - "installUrl": "https://download.mozilla.org/?product=thunderbird-latest&os=win64&lang=ru", - "installUrlFallbacks": [ - "https://download.mozilla.org/?product=thunderbird-latest&os=win64&lang=en-US" - ], - "altProvider": { "provider": "winget", "packageId": "Mozilla.Thunderbird" }, - "silentArgs": "/S" + "logoSlug": "thunderbird" }, { "id": "obsidian", @@ -1107,10 +896,7 @@ "packageId": "obsidian", "provider": "catalog", "wikiTitle": "Obsidian_(software)", - "logoSlug": "obsidian", - "installUrl": "https://github.com/obsidianmd/obsidian-releases/releases/download/v1.13.4/Obsidian-1.13.4.exe", - "altProvider": { "provider": "winget", "packageId": "Obsidian.Obsidian" }, - "silentArgs": "/S" + "logoSlug": "obsidian" }, { "id": "sumatra", @@ -1122,9 +908,7 @@ "provider": "catalog", "wikiTitle": "Sumatra_PDF", "logoSlug": "sumatrapdf", - "iconUrl": "https://www.google.com/s2/favicons?domain=sumatrapdfreader.org&sz=256", - "installUrl": "https://www.sumatrapdfreader.org/dl/rel/3.5.2/SumatraPDF-3.5.2-64-install.exe", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=sumatrapdfreader.org&sz=256" }, { "id": "calibre", @@ -1135,10 +919,7 @@ "packageId": "calibre", "provider": "catalog", "wikiTitle": "Calibre_(software)", - "logoSlug": "calibre", - "installUrl": "https://download.calibre-ebook.com/7.23.0/calibre-64bit-7.23.0.msi", - "altProvider": { "provider": "winget", "packageId": "calibre.calibre" }, - "silentArgs": "/quiet" + "logoSlug": "calibre" }, { @@ -1151,9 +932,7 @@ "provider": "catalog", "wikiTitle": "Amazon_Games", "logoSlug": "amazon-games", - "simpleSlug": "amazongames", - "installUrl": "https://download.amazongames.com/AmazonGamesSetup.exe", - "silentArgs": "/S" + "simpleSlug": "amazongames" }, { "id": "minecraft-launcher", @@ -1164,9 +943,7 @@ "packageId": "minecraft-launcher", "provider": "catalog", "wikiTitle": "Minecraft", - "logoSlug": "minecraft", - "installUrl": "https://launcher.mojang.com/download/MinecraftInstaller.msi", - "silentArgs": "/quiet" + "logoSlug": "minecraft" }, { "id": "prism-launcher", @@ -1177,9 +954,7 @@ "packageId": "prism-launcher", "provider": "catalog", "logoSlug": "prism-launcher", - "homepage": "https://prismlauncher.org/", - "installUrl": "https://github.com/PrismLauncher/PrismLauncher/releases/download/11.0.3/PrismLauncher-Windows-MSVC-Setup-11.0.3.exe", - "silentArgs": "/S" + "homepage": "https://prismlauncher.org/" }, { "id": "roblox", @@ -1190,9 +965,7 @@ "packageId": "roblox", "provider": "catalog", "wikiTitle": "Roblox", - "logoSlug": "roblox", - "installUrl": "https://www.roblox.com/download/client", - "silentArgs": "/S" + "logoSlug": "roblox" }, { @@ -1205,9 +978,7 @@ "provider": "catalog", "wikiTitle": "Yandex_Browser", "logoSlug": "yandex-browser", - "homepage": "https://browser.yandex.ru/", - "installUrl": "https://browser.yandex.ru/download?banerid=8300&partner_id=exp_ya_common", - "silentArgs": "/silent" + "homepage": "https://browser.yandex.ru/" }, { @@ -1219,9 +990,7 @@ "packageId": "element", "provider": "catalog", "wikiTitle": "Element_(software)", - "logoSlug": "element", - "installUrl": "https://packages.element.io/desktop/install/win32/x64/Element%20Setup.exe", - "silentArgs": "/S" + "logoSlug": "element" }, { "id": "teamspeak", @@ -1232,9 +1001,7 @@ "packageId": "teamspeak", "provider": "catalog", "wikiTitle": "TeamSpeak", - "logoSlug": "teamspeak", - "installUrl": "https://files.teamspeak-services.com/releases/client/3.6.2/TeamSpeak3-Client-win64-3.6.2.exe", - "silentArgs": "/S" + "logoSlug": "teamspeak" }, { @@ -1247,9 +1014,7 @@ "provider": "catalog", "wikiTitle": "Media_Player_Classic", "logoSlug": "mpc-hc", - "homepage": "https://mpc-hc.org/", - "installUrl": "https://github.com/clsid2/mpc-hc/releases/download/2.7.4/MPC-HC.2.7.4.x64.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://mpc-hc.org/" }, { "id": "kdenlive", @@ -1261,9 +1026,7 @@ "provider": "catalog", "wikiTitle": "Kdenlive", "logoSlug": "kdenlive", - "simpleSlug": "kdenlive", - "installUrl": "https://download.kde.org/stable/kdenlive/25.08/windows/kdenlive-25.08.0.exe", - "silentArgs": "/S" + "simpleSlug": "kdenlive" }, { "id": "shotcut", @@ -1275,9 +1038,7 @@ "provider": "catalog", "wikiTitle": "Shotcut", "logoSlug": "shotcut", - "simpleSlug": "shotcut", - "installUrl": "https://github.com/mltframework/shotcut/releases/download/v26.6.25/shotcut-win64-26.6.25.exe", - "silentArgs": "/S" + "simpleSlug": "shotcut" }, { @@ -1290,9 +1051,7 @@ "provider": "catalog", "wikiTitle": "Darktable", "logoSlug": "darktable", - "homepage": "https://www.darktable.org/", - "installUrl": "https://github.com/darktable-org/darktable/releases/download/release-5.6.0/darktable-5.6.0-win64.exe", - "silentArgs": "/S" + "homepage": "https://www.darktable.org/" }, { "id": "pinta", @@ -1304,9 +1063,7 @@ "provider": "catalog", "wikiTitle": "Pinta_(software)", "logoSlug": "pinta", - "homepage": "https://www.pinta-project.com/", - "installUrl": "https://github.com/PintaProject/Pinta/releases/download/3.1.2/Pinta-x86_64.exe", - "silentArgs": "/S" + "homepage": "https://www.pinta-project.com/" }, { "id": "xnviewmp", @@ -1318,9 +1075,7 @@ "provider": "catalog", "wikiTitle": "XnView", "logoSlug": "xnview", - "homepage": "https://www.xnview.com/", - "installUrl": "https://download.xnview.com/XnViewMP-win-x64.exe", - "silentArgs": "/S" + "homepage": "https://www.xnview.com/" }, { "id": "imageglass", @@ -1332,9 +1087,7 @@ "provider": "catalog", "wikiTitle": "ImageGlass", "logoSlug": "imageglass", - "homepage": "https://imageglass.org/", - "installUrl": "https://github.com/d2phap/ImageGlass/releases/download/9.6.0.801/ImageGlass_9.6.0.801_x64.msi", - "silentArgs": "/quiet /norestart" + "homepage": "https://imageglass.org/" }, { "id": "freecad", @@ -1346,9 +1099,7 @@ "provider": "catalog", "wikiTitle": "FreeCAD", "logoSlug": "freecad", - "simpleSlug": "freecad", - "installUrl": "https://github.com/FreeCAD/FreeCAD/releases/download/1.1.3/FreeCAD_1.1.3-Windows-x86_64-py311-installer.exe", - "silentArgs": "/S" + "simpleSlug": "freecad" }, { @@ -1361,9 +1112,7 @@ "provider": "catalog", "wikiTitle": "Microsoft_Visual_Studio", "logoSlug": "visual-studio", - "simpleSlug": "visualstudio", - "installUrl": "https://aka.ms/vs/17/release/vs_community.exe", - "silentArgs": "--quiet --norestart --wait" + "simpleSlug": "visualstudio" }, { "id": "dotnet-sdk", @@ -1375,9 +1124,7 @@ "provider": "catalog", "wikiTitle": ".NET", "logoSlug": "dotnet", - "simpleSlug": "dotnet", - "installUrl": "https://aka.ms/dotnet/8.0/dotnet-sdk-win-x64.exe", - "silentArgs": "/install /quiet /norestart" + "simpleSlug": "dotnet" }, { "id": "powershell", @@ -1389,9 +1136,7 @@ "provider": "catalog", "wikiTitle": "PowerShell", "logoSlug": "powershell", - "simpleSlug": "powershell", - "installUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.6.4/PowerShell-7.6.4-win-x64.msi", - "silentArgs": "/quiet" + "simpleSlug": "powershell" }, { "id": "github-desktop", @@ -1403,9 +1148,7 @@ "provider": "catalog", "wikiTitle": "GitHub_Desktop", "logoSlug": "github-desktop", - "simpleSlug": "github", - "installUrl": "https://central.github.com/deployments/desktop/desktop/latest/win32", - "silentArgs": "--silent" + "simpleSlug": "github" }, { "id": "insomnia", @@ -1416,9 +1159,7 @@ "packageId": "insomnia", "provider": "catalog", "logoSlug": "insomnia", - "simpleSlug": "insomnia", - "installUrl": "https://github.com/Kong/insomnia/releases/download/core%4013.1.0/Insomnia.Core-13.1.0.exe", - "silentArgs": "/S" + "simpleSlug": "insomnia" }, { "id": "winmerge", @@ -1430,9 +1171,7 @@ "provider": "catalog", "wikiTitle": "WinMerge", "logoSlug": "winmerge", - "homepage": "https://winmerge.org/", - "installUrl": "https://github.com/WinMerge/winmerge/releases/download/v2.16.58/WinMerge-2.16.58-x64-Setup.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://winmerge.org/" }, { "id": "cmake", @@ -1444,9 +1183,7 @@ "provider": "catalog", "wikiTitle": "CMake", "logoSlug": "cmake", - "simpleSlug": "cmake", - "installUrl": "https://github.com/Kitware/CMake/releases/download/v4.4.2/cmake-4.4.2-windows-x86_64.msi", - "silentArgs": "/quiet" + "simpleSlug": "cmake" }, { "id": "miniconda", @@ -1458,9 +1195,7 @@ "provider": "catalog", "wikiTitle": "Anaconda_(Python_distribution)", "logoSlug": "miniconda", - "simpleSlug": "anaconda", - "installUrl": "https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-x86_64.exe", - "silentArgs": "/S /InstallationType=JustMe /AddToPath=1" + "simpleSlug": "anaconda" }, { "id": "postgresql", @@ -1471,9 +1206,7 @@ "packageId": "postgresql", "provider": "catalog", "wikiTitle": "PostgreSQL", - "logoSlug": "postgresql", - "installUrl": "https://get.enterprisedb.com/postgresql/postgresql-16.6-1-windows-x64.exe", - "silentArgs": "--mode unattended" + "logoSlug": "postgresql" }, { "id": "pgadmin", @@ -1484,9 +1217,7 @@ "packageId": "pgadmin4", "provider": "catalog", "wikiTitle": "PgAdmin", - "logoSlug": "pgadmin", - "installUrl": "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/v8.14/windows/pgadmin4-8.14-x64.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "logoSlug": "pgadmin" }, { "id": "mongodb-compass", @@ -1498,9 +1229,7 @@ "provider": "catalog", "wikiTitle": "MongoDB", "logoSlug": "mongodb-compass", - "simpleSlug": "mongodb", - "installUrl": "https://downloads.mongodb.com/compass/mongodb-compass-1.44.6-win32-x64.exe", - "silentArgs": "/S" + "simpleSlug": "mongodb" }, { "id": "ollama", @@ -1511,9 +1240,7 @@ "packageId": "ollama", "provider": "catalog", "logoSlug": "ollama", - "homepage": "https://ollama.com/", - "installUrl": "https://ollama.com/download/OllamaSetup.exe", - "silentArgs": "/SILENT" + "homepage": "https://ollama.com/" }, { @@ -1526,9 +1253,7 @@ "provider": "catalog", "wikiTitle": "Zotero", "logoSlug": "zotero", - "simpleSlug": "zotero", - "installUrl": "https://www.zotero.org/download/client/dl?channel=release&platform=win-x64", - "silentArgs": "/S" + "simpleSlug": "zotero" }, { "id": "joplin", @@ -1539,9 +1264,7 @@ "packageId": "joplin", "provider": "catalog", "wikiTitle": "Joplin_(software)", - "logoSlug": "joplin", - "installUrl": "https://github.com/laurent22/joplin/releases/download/v3.6.15/Joplin-Setup-3.6.15.exe", - "silentArgs": "/S" + "logoSlug": "joplin" }, { "id": "logseq", @@ -1551,9 +1274,7 @@ "category": "office", "packageId": "logseq", "provider": "catalog", - "logoSlug": "logseq", - "installUrl": "https://github.com/logseq/logseq/releases/download/2.0.1/Logseq-win-x64-2.0.1-nsis.exe", - "silentArgs": "/S" + "logoSlug": "logseq" }, { "id": "anki", @@ -1565,9 +1286,7 @@ "provider": "catalog", "wikiTitle": "Anki_(software)", "logoSlug": "anki", - "homepage": "https://apps.ankiweb.net/", - "installUrl": "https://github.com/ankitects/anki/releases/download/26.05/anki-26.05-win-x64.msi", - "silentArgs": "/quiet" + "homepage": "https://apps.ankiweb.net/" }, { @@ -1579,9 +1298,7 @@ "packageId": "system-informer", "provider": "catalog", "logoSlug": "system-informer", - "homepage": "https://systeminformer.sourceforge.io/", - "installUrl": "https://github.com/winsiderss/systeminformer/releases/download/v3.2.25011.2103/systeminformer-3.2.25011-release-setup.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://systeminformer.sourceforge.io/" }, { "id": "wiztree", @@ -1592,9 +1309,7 @@ "packageId": "wiztree", "provider": "catalog", "logoSlug": "wiztree", - "homepage": "https://diskanalyzer.com/", - "installUrl": "https://diskanalyzer.com/files/wiztree_4_30_setup.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://diskanalyzer.com/" }, { "id": "bleachbit", @@ -1606,9 +1321,7 @@ "provider": "catalog", "wikiTitle": "BleachBit", "logoSlug": "bleachbit", - "homepage": "https://www.bleachbit.org/", - "installUrl": "https://download.bleachbit.org/BleachBit-4.6.2-setup.exe", - "silentArgs": "/S" + "homepage": "https://www.bleachbit.org/" }, { @@ -1621,9 +1334,7 @@ "provider": "catalog", "wikiTitle": "Google_Drive", "logoSlug": "google-drive", - "simpleSlug": "googledrive", - "installUrl": "https://dl.google.com/drive-file-stream/GoogleDriveSetup.exe", - "silentArgs": "--silent" + "simpleSlug": "googledrive" }, { "id": "nextcloud", @@ -1634,9 +1345,7 @@ "packageId": "nextcloud", "provider": "catalog", "wikiTitle": "Nextcloud", - "logoSlug": "nextcloud", - "installUrl": "https://github.com/nextcloud-releases/desktop/releases/download/v34.0.0/Nextcloud-34.0.0-x64.msi", - "silentArgs": "/quiet" + "logoSlug": "nextcloud" }, { "id": "free-download-manager", @@ -1648,9 +1357,7 @@ "provider": "catalog", "wikiTitle": "Free_Download_Manager", "logoSlug": "free-download-manager", - "homepage": "https://www.freedownloadmanager.org/", - "installUrl": "https://dn3.freedownloadmanager.org/6/latest/fdm_x64_setup.exe", - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://www.freedownloadmanager.org/" }, { "id": "rustdesk", @@ -1661,9 +1368,7 @@ "packageId": "rustdesk", "provider": "catalog", "wikiTitle": "RustDesk", - "logoSlug": "rustdesk", - "installUrl": "https://github.com/rustdesk/rustdesk/releases/download/1.4.9/rustdesk-1.4.9-x86_64.exe", - "silentArgs": "--silent-install" + "logoSlug": "rustdesk" }, { @@ -1675,8 +1380,6 @@ "packageId": "openvpn", "provider": "catalog", "wikiTitle": "OpenVPN", - "logoSlug": "openvpn", - "installUrl": "https://swupdate.openvpn.net/community/releases/OpenVPN-2.6.12-I001-amd64.msi", - "silentArgs": "/quiet" + "logoSlug": "openvpn" } ] diff --git a/data/installers.json b/data/installers.json new file mode 100644 index 0000000..ddcde57 --- /dev/null +++ b/data/installers.json @@ -0,0 +1,1088 @@ +{ + "7zip": { + "wingetId": "7zip.7zip", + "version": "26.04", + "type": "exe", + "url": "https://www.7-zip.org/a/7z2604-x64.exe", + "sha256": "d54bf805f9f3704d1e8db2fa3498ae7ef2df0312b40b558e7c71c734430a665d", + "silentArgs": "/S", + "scope": "machine" + }, + "amazon-games": { + "wingetId": "Amazon.Games", + "version": "3.0.9700.3", + "type": "exe", + "url": "https://download.amazongames.com/AmazonGamesSetup.exe", + "sha256": "b5d74ab5a63cfbef2756e9c445b1e939e51a328f17af301b5d7b9a8e9b1d4141", + "silentArgs": "/S", + "scope": "user" + }, + "anki": { + "wingetId": "Anki.Anki", + "version": "26.09.3", + "type": "wix", + "url": "https://github.com/ankitects/anki/releases/download/26.09.3/anki-26.09.3-win-x64.msi", + "sha256": "8a45823d1c31017cebb7d3af4493d69b6ae3c185345fcb2390ede4af01e0a950", + "silentArgs": "/quiet /norestart", + "scope": "user" + }, + "anydesk": { + "wingetId": "AnyDesk.AnyDesk", + "version": "9.8.1", + "type": "exe", + "url": "https://download.anydesk.com/AnyDesk.exe", + "sha256": "dbcb74f0523a2e281c8e0f89cb47234ce46249ad67126dc4ea5af22d4b99af34", + "silentArgs": "--install \"C:\\Program Files (x86)\\AnyDesk\" --silent --update-auto --create-desktop-icon --create-shortcuts", + "args": "--update-auto --create-desktop-icon --create-shortcuts", + "scope": "machine" + }, + "audacity": { + "wingetId": "Audacity.Audacity", + "version": "4.0.1.262721353", + "type": "wix", + "url": "https://github.com/audacity/audacity/releases/download/Audacity-4.0.1/audacity-win-4.0.1-x86_64.msi", + "sha256": "efb652bf04168f5d4893f28b7cefaaf5ef385d9251544540ac774e3bf54793a3", + "silentArgs": "/quiet /norestart" + }, + "battlenet": { + "wingetId": "Blizzard.BattleNet", + "version": "1.19.3.3219", + "type": "exe", + "url": "https://downloader.battle.net/download/getInstaller?os=win&installer=Battle.net-Setup.exe", + "sha256": "de5d32d4ea5eed5a9e120027fb68b370976dbfecc8f2a8f91305977f0b87fcaf", + "silentArgs": "--lang=enUS", + "args": "--lang=enUS", + "scope": "machine" + }, + "bitwarden": { + "wingetId": "Bitwarden.Bitwarden", + "version": "2026.9.1", + "type": "nullsoft", + "url": "https://github.com/bitwarden/clients/releases/download/desktop-v2026.9.1/Bitwarden-Installer-2026.9.1.exe", + "sha256": "49b10f0840e70bdd1701cc9a0b729813015a70d3d3b487682a4e4a93819a3263", + "silentArgs": "/S /currentuser", + "args": "/currentuser", + "scope": "user" + }, + "bleachbit": { + "wingetId": "BleachBit.BleachBit", + "version": "6.0.4", + "type": "nullsoft", + "url": "https://download.bleachbit.org/BleachBit-6.0.4-setup.exe", + "sha256": "c4cd6bb1f4b163dc28b13cef2995423de69e6776f16387f54943dcc6e7267a6d", + "silentArgs": "/S /currentuser", + "args": "/currentuser", + "scope": "user" + }, + "blender": { + "wingetId": "BlenderFoundation.Blender", + "version": "5.2.2", + "type": "wix", + "url": "https://download.blender.org/release/Blender5.2/blender-5.2.2-windows-x64.msi", + "sha256": "8dac2751ebe8e3867327eec52f1ef9b344c0a27499bb6221a301b4b35f2828d1", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "brave": { + "wingetId": "Brave.Brave", + "version": "155.1.97.56", + "type": "exe", + "url": "https://github.com/brave/brave-browser/releases/download/v1.97.56/BraveBrowserStandaloneSilentSetup.exe", + "sha256": "860fcd9b0daaa7d2647d5230b16aa7b7f1be0ca03db49944a925e24a2600a9a2", + "scope": "user" + }, + "calibre": { + "wingetId": "calibre.calibre", + "version": "9.16.0", + "type": "wix", + "url": "https://download.calibre-ebook.com/9.16.0/calibre-64bit-9.16.0.msi", + "sha256": "99ba538ca701c7c3b234d19b2236349c3aff3b55244ab9016072f74e6294e40a", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "chrome": { + "wingetId": "Google.Chrome", + "version": "155.0.8059.40", + "type": "wix", + "url": "https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi", + "sha256": "32ba438dea18f0fed1ec621bfb274a2669adec67be2aa91d96b3cc6c64779276", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "cmake": { + "wingetId": "Kitware.CMake", + "version": "4.4.4", + "type": "wix", + "url": "https://github.com/Kitware/CMake/releases/download/v4.4.4/cmake-4.4.4-windows-x86_64.msi", + "sha256": "f7aac962e8eea82c0a1c603bb11ee3514806a43a35147d5b1e9d69926ffd7d7e", + "silentArgs": "/quiet /norestart ADD_CMAKE_TO_PATH=System", + "args": "ADD_CMAKE_TO_PATH=System", + "scope": "machine" + }, + "cpu-z": { + "wingetId": "CPUID.CPU-Z", + "version": "3.01", + "type": "inno", + "url": "https://download.cpuid.com/cpu-z/cpu-z_3.01-en.exe", + "sha256": "74ab9b1c24fb223d0a5c23e2e444b6cbba7d0c6882cc4bf389e3f88bfca37490", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "machine" + }, + "crystaldiskinfo": { + "wingetId": "CrystalDewWorld.CrystalDiskInfo", + "version": "9.9.3", + "type": "inno", + "url": "https://sourceforge.net/projects/crystaldiskinfo/files/9.9.3/CrystalDiskInfo9_9_3.exe/download", + "sha256": "aeb4f4ab81c71f9b41ce65de841162260ff64c1ec07a758a459cac925bd0fcf6", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "machine" + }, + "darktable": { + "wingetId": "darktable.darktable", + "version": "5.6.2", + "type": "inno", + "url": "https://github.com/darktable-org/darktable/releases/download/release-5.6.2/darktable-5.6.2-win64.exe", + "sha256": "7f7a1eabcd0a1b548dcfacac842c8203fc85a4175697fb09f90bb891c8931edd", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER", + "args": "/CURRENTUSER", + "scope": "user" + }, + "dbeaver": { + "wingetId": "DBeaver.DBeaver.Community", + "version": "26.2.0", + "type": "nullsoft", + "url": "https://github.com/dbeaver/dbeaver/releases/download/26.2.0/dbeaver-ce-26.2.0-windows-x86_64.exe", + "sha256": "009e8a99be478261d09fdeb40ab5ef64bfe1f5faf79288cac670164719d921d2", + "silentArgs": "/S /currentuser", + "args": "/currentuser", + "scope": "user" + }, + "discord": { + "wingetId": "Discord.Discord", + "version": "1.0.9261", + "type": "exe", + "url": "https://dl.discordapp.net/distro/app/stable/win/x64/1.0.9261/DiscordSetup.exe", + "sha256": "e6d2f3f781904b6f336a2aa22699d1c97cecab3784509804e870ac4cb69f2ac2", + "scope": "user" + }, + "docker": { + "wingetId": "Docker.DockerDesktop", + "version": "4.94.0", + "type": "exe", + "url": "https://desktop.docker.com/win/main/amd64/241994/Docker%20Desktop%20Installer.exe", + "sha256": "a9814e31049d66156477a86614e83365669677733014ec72f74229623ff3890a", + "silentArgs": "install --quiet --accept-license --backend=wsl-2 --no-windows-containers", + "args": "--accept-license --backend=wsl-2 --no-windows-containers", + "scope": "machine" + }, + "dotnet-sdk": { + "wingetId": "Microsoft.DotNet.SDK.8", + "version": "8.0.425", + "type": "burn", + "url": "https://download.microsoft.com/download/4df5e4e8-4c5f-4fa6-9730-c51e177fb61b/dc7c534c-bf24-48ca-943d-d849785c5d12/dotnet-sdk-8.0.425-win-x64.exe", + "sha256": "48c31649ed0c37a161704e68fc9f5f67be9eb01edbd8f92b54b4c2e374ef6dfb", + "silentArgs": "/quiet /norestart", + "args": "/norestart", + "scope": "machine" + }, + "ea-app": { + "wingetId": "ElectronicArts.EADesktop", + "version": "13.805.11.6320", + "type": "burn", + "url": "https://origin-a.akamaihd.net/EA-Desktop-Client-Download/installer-releases/EAappInstaller-13.805.11.6320-15991211.exe", + "sha256": "4b581cb5cf94ee0031661ef3cbc98d8f9a60d80030f111acc641a68c20cf958b", + "silentArgs": "/passive /norestart /norestart", + "args": "/norestart", + "scope": "machine" + }, + "edge": { + "wingetId": "Microsoft.Edge", + "version": "155.0.4283.45", + "type": "msi", + "url": "https://download.microsoft.com/download/6c6e5c59-4d92-40ad-bb99-daa7e663985d/MicrosoftEdgeEnterpriseX64.msi", + "sha256": "46db09d14b85a0df8bca87fbe9c3224ccf54f97d776bedc2eebf4b0c2ccbf5b4", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "element": { + "wingetId": "Element.Element", + "version": "1.12.30", + "type": "exe", + "url": "https://packages.element.io/desktop/install/win32/x64/Element%20Setup%201.12.30.exe", + "sha256": "b9ac9617acfa667eb5470e67e5db744d3b595b35c285100a53ad17719f7cc6d3", + "silentArgs": "--silent", + "scope": "user" + }, + "epic": { + "wingetId": "EpicGames.EpicGamesLauncher", + "version": "1.3.210.0", + "type": "wix", + "url": "https://epicgames-download1.akamaized.net/Builds/UnrealEngineLauncher/Installers/Windows/EpicInstaller-20.3.3.msi?launcherfilename=EpicInstaller-20.3.3.msi", + "sha256": "d55d79710edfeaa107a62f2ea007bec1eeeac8b715a43c2ba3858eec7bc60337", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "everything": { + "wingetId": "voidtools.Everything", + "version": "1.4.1.1032", + "type": "wix", + "url": "https://www.voidtools.com/Everything-1.4.1.1032.x64.msi", + "sha256": "4e7a80885aab8566b750c56a2a2b3e7c3c1f4920bcc53777e07f5eeb9e1f7485", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "figma": { + "wingetId": "Figma.Figma", + "version": "126.10.7", + "type": "exe", + "url": "https://desktop.figma.com/win/build/Figma-126.10.7.exe", + "sha256": "97c47d41055813eb1dbebb7416e59c362088d1b7292565f620b7db2f96913271", + "silentArgs": "--silent", + "scope": "user" + }, + "filezilla": { + "provider": "chocolatey", + "packageId": "filezilla" + }, + "firefox": { + "wingetId": "Mozilla.Firefox", + "version": "157.0.1", + "type": "nullsoft", + "url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/157.0.1/win64/en-US/Firefox%20Setup%20157.0.1.exe", + "sha256": "71dc620e7749bb65be6df67b99261a303805a804f9c787b76b81c095cfa7d9ca", + "silentArgs": "/S /PreventRebootRequired=true", + "scope": "machine" + }, + "foobar2000": { + "wingetId": "PeterPawlowski.foobar2000", + "version": "2.26.1", + "type": "nullsoft", + "url": "https://www.foobar2000.org/files/foobar2000-x64_v2.26.1.exe", + "sha256": "72fe15a88b085ee4e49d68114e2bb9655000c2e0fdf40dcc128bbef8bcaf3600", + "silentArgs": "/S", + "scope": "machine" + }, + "free-download-manager": { + "wingetId": "SoftDeluxe.FreeDownloadManager", + "version": "6.35.1.7021", + "type": "inno", + "url": "https://dn3.freedownloadmanager.org/6/latest/fdm_x64_setup.exe", + "sha256": "053c7917c490984fb93c8cc74f40ada7dc83f1653438520e96b42a4aff69a5d8", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER", + "args": "/CURRENTUSER", + "scope": "user" + }, + "freecad": { + "wingetId": "FreeCAD.FreeCAD", + "version": "1.1.4", + "type": "nullsoft", + "url": "https://github.com/FreeCAD/FreeCAD/releases/download/1.1.4/FreeCAD_1.1.4-Windows-x86_64-py311-installer.exe", + "sha256": "845f7101d33faf257a82a0cadc5f4f3804441f46ee493eb32b92fcf9c7147a24", + "silentArgs": "/S /CurrentUser", + "args": "/CurrentUser", + "scope": "user" + }, + "gimp": { + "wingetId": "GIMP.GIMP.3", + "version": "3.2.6.0", + "type": "inno", + "url": "https://download.gimp.org/gimp/v3.2/windows/gimp-3.2.6-setup.exe", + "sha256": "9337cccbc01d4098ee7a3dab215b3afbe6ece99c5287c92441d6f12cf541ebca", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER", + "args": "/CURRENTUSER", + "scope": "user" + }, + "git": { + "wingetId": "Git.Git", + "version": "2.55.0.5", + "type": "inno", + "url": "https://github.com/git-for-windows/git/releases/download/v2.55.0.windows.5/Git-2.55.0.5-64-bit.exe", + "sha256": "d065a4e23c3d9a6b5073d609b5be0830227ec3ca053c083ba385061ddfaf94c6", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "user" + }, + "github-desktop": { + "wingetId": "GitHub.GitHubDesktop", + "version": "3.6.7", + "type": "exe", + "url": "https://desktop.githubusercontent.com/releases/3.6.7-dbc92f97/GitHubDesktopSetup-x64.exe", + "sha256": "2e38db8d986884474a1e45afddf554a7187f42124bd38a3d7ea165be7f77f90d", + "silentArgs": "--silent", + "scope": "user" + }, + "gog-galaxy": { + "wingetId": "GOG.Galaxy", + "version": "2.1.10.56", + "type": "inno", + "url": "https://content-system.gog.com/open_link/download?path=/open/galaxy/client/setup_galaxy_2.1.10.56.exe", + "sha256": "ec1ef3744a6ba365c022d5e114a482b74ff0496e00a91924ebdae401e2c9291b", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "machine" + }, + "golang": { + "wingetId": "GoLang.Go", + "version": "1.27.0", + "type": "wix", + "url": "https://go.dev/dl/go1.27.0.windows-amd64.msi", + "sha256": "728e9318c061b95082bcf167bab8b6c6e0d91a045f89f403b3285fa39f0c455b", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "google-drive": { + "wingetId": "Google.GoogleDrive", + "version": "132.0.0.0", + "type": "exe", + "url": "https://dl.google.com/release2/drive-file-stream/glebcd62hy755nfh23p5kqwm4i_132.0.0.0/setup.exe", + "sha256": "6ab7af8224736d4b6f0b06345f5bdc78a664c0a995973c0b1b38c3a0ad5cd853", + "silentArgs": "--silent --skip_launch_new --gsuite_shortcuts=false", + "scope": "machine" + }, + "handbrake": { + "wingetId": "HandBrake.HandBrake", + "version": "1.11.2", + "type": "nullsoft", + "url": "https://github.com/HandBrake/HandBrake/releases/download/1.11.2/HandBrake-1.11.2-x86_64-Win_GUI.exe", + "sha256": "6becb8e5a041941d5f2fd6fe83c66fc15b2b16190e2e79f5ad47eb4cd18df8cd", + "silentArgs": "/S", + "scope": "machine" + }, + "heroic": { + "wingetId": "HeroicGamesLauncher.HeroicGamesLauncher", + "version": "2.22.3", + "type": "nullsoft", + "url": "https://github.com/Heroic-Games-Launcher/HeroicGamesLauncher/releases/download/v2.22.3/Heroic-2.22.3-Setup-x64.exe", + "sha256": "3c74b3f1727a005d9d578880a97e0b94c8ce04c94c6e08f99e644b072878360b", + "silentArgs": "/S", + "scope": "user" + }, + "hwmonitor": { + "wingetId": "CPUID.HWMonitor", + "version": "1.68", + "type": "inno", + "url": "https://download.cpuid.com/hwmonitor/hwmonitor_1.68.exe", + "sha256": "fae15460133a648caca1a63fd809ef0b042cf82daf73c239fa5ac7b062cb2b16", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "machine" + }, + "imageglass": { + "wingetId": "DuongDieuPhap.ImageGlass", + "version": "10.0.906.0", + "type": "msix", + "direct": false + }, + "inkscape": { + "wingetId": "Inkscape.Inkscape", + "version": "1.4.4", + "type": "wix", + "url": "https://media.inkscape.org/dl/resources/file/inkscape-1.4.4_2026-05-05_dcaf3e7-x64.signed_xMx7DJV.msi", + "sha256": "2fa033e95113ae83d6430b2479e7e63e4266d0632a5b03f99ee3db9360e3b8b8", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "insomnia": { + "wingetId": "Insomnia.Insomnia", + "version": "13.3.1", + "type": "exe", + "url": "https://github.com/Kong/insomnia/releases/download/core@13.3.1/Insomnia.Core-13.3.1.exe", + "sha256": "ff4d012e135df13cce4fc9afeeb50e11cf6d6e616ad22587b246bf9bbeb51522", + "silentArgs": "--silent", + "scope": "user" + }, + "intellij-idea": { + "wingetId": "JetBrains.IntelliJIDEA.Community", + "version": "2025.2.6.2", + "type": "nullsoft", + "url": "https://download.jetbrains.com/idea/ideaIC-2025.2.6.2.exe", + "sha256": "8393c2c9ccbd8581d646f01f0b6f0e7f78e58ebbe5cd8cbd95f9e236518e9fe8", + "silentArgs": "/S", + "scope": "user" + }, + "joplin": { + "wingetId": "Joplin.Joplin", + "version": "3.7.21", + "type": "nullsoft", + "url": "https://github.com/laurent22/joplin/releases/download/v3.7.21/Joplin-Setup-3.7.21.exe", + "sha256": "4222536c69360a30a566627b35c2e1cd8d38d0c79735ebde4e39edbcddd750cd", + "silentArgs": "/S /currentuser", + "args": "/currentuser", + "scope": "user" + }, + "kdenlive": { + "wingetId": "KDE.Kdenlive", + "version": "26.08.1", + "type": "nullsoft", + "url": "https://download.kde.org/stable/kdenlive/26.08/windows/kdenlive-26.08.1.exe", + "sha256": "b436430eb7dd4b3d1da318a53a77798cdaf3543a9f790824df214588b07f5d1e", + "silentArgs": "/S /CurrentUser", + "args": "/CurrentUser", + "scope": "user" + }, + "keepass": { + "wingetId": "DominikReichl.KeePass", + "version": "2.61.1", + "type": "inno", + "url": "https://sourceforge.net/projects/keepass/files/KeePass%202.x/2.61.1/KeePass-2.61.1-Setup.exe/download", + "sha256": "9923a8b228fe3d8fbe46979991f45d7e0d63f649766596da191eb879b293d38d", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /ALLUSERS", + "args": "/ALLUSERS", + "scope": "machine" + }, + "keepassxc": { + "wingetId": "KeePassXCTeam.KeePassXC", + "version": "2.7.12", + "type": "wix", + "url": "https://github.com/keepassxreboot/keepassxc/releases/download/2.7.12/KeePassXC-2.7.12-Win64.msi", + "sha256": "feee096c1d5d0d7bb7b36b18174818f7bac889be7668c550ff0e5ef9206ea9a5", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "kodi": { + "wingetId": "XBMCFoundation.Kodi", + "version": "21.3.0.0", + "type": "nullsoft", + "url": "https://mirrors.kodi.tv/releases/windows/win64/kodi-21.3-Omega-x64.exe", + "sha256": "2c46c2bd60b38bb60eb0a5e3468bfd43e9235521eae2910b8c81455e46335c5c", + "silentArgs": "/S", + "scope": "user" + }, + "krita": { + "wingetId": "KDE.Krita", + "version": "5.3.4.1", + "type": "nullsoft", + "url": "https://download.kde.org/stable/krita/5.3.4.1/krita-x64-5.3.4.1-setup.exe", + "sha256": "025523cf1751ff209faf7ecfe1cff851f2cdab64acb8c555b307b349ad56ce8f", + "silentArgs": "/S", + "scope": "machine" + }, + "libreoffice": { + "wingetId": "TheDocumentFoundation.LibreOffice", + "version": "26.8.1.1", + "type": "msi", + "url": "https://download.documentfoundation.org/libreoffice/stable/26.8.1/win/x86_64/LibreOffice_26.8.1_Win_x86-64.msi", + "sha256": "c6298b10cfa1748bcbedad94c62ce771e240e5021bfd6d7a967cd571b04b5312", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "logseq": { + "wingetId": "Logseq.Logseq", + "version": "2.0.2", + "type": "nullsoft", + "url": "https://github.com/logseq/logseq/releases/download/2.0.2/Logseq-win-x64-2.0.2-nsis.exe", + "sha256": "5d7f52fa676a65cb91b88a688ff2705de629dd60a0530dc6c9119e97b463d853", + "silentArgs": "/S /currentuser", + "args": "/currentuser", + "scope": "user" + }, + "malwarebytes": { + "wingetId": "Malwarebytes.Malwarebytes", + "version": "5.7.2.358", + "type": "exe", + "url": "https://data-cdn.mbamupdates.com/web/mb5-setup-consumer/offline/mb5-setup-consumer-5.7.2.358-166.0.5763-1.0.115215.exe", + "sha256": "aee718183b29d045cf26e3bf3b9cff9f3c31ad4d379ecacba9cfaff4d26756e7", + "silentArgs": "/VERYSILENT /NORESTART", + "scope": "machine" + }, + "minecraft-launcher": { + "wingetId": "Mojang.MinecraftLauncher", + "version": "2.0.0.0", + "type": "wix", + "url": "https://launcher.mojang.com/download/MinecraftInstaller.msi", + "sha256": "e7040addf5fbb1dac0d7e61d0793b1550526accaf80a315e36b636285bd9d90e", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "miniconda": { + "wingetId": "Anaconda.Miniconda3", + "version": "py314_26.7.1-1", + "type": "nullsoft", + "url": "https://repo.anaconda.com/miniconda/Miniconda3-py314_26.7.1-1-Windows-x86_64.exe", + "sha256": "27f1f8ae8c27bc22bbc383b84bf08c63a20a6e190f3975f3502625b1045330b2", + "silentArgs": "/S /InstallationType=JustMe", + "args": "/InstallationType=JustMe", + "scope": "user" + }, + "mongodb-compass": { + "wingetId": "MongoDB.Compass.Full", + "version": "1.52.0.0", + "type": "wix", + "url": "https://github.com/mongodb-js/compass/releases/download/v1.52.0/mongodb-compass-1.52.0-win32-x64.msi", + "sha256": "74936797eb2bbff0756aa1f20276d95256355344a4af2c824c45c34ccf884344", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "moonlight": { + "wingetId": "MoonlightGameStreamingProject.Moonlight", + "version": "6.2.0.0", + "type": "burn", + "url": "https://github.com/moonlight-stream/moonlight-qt/releases/download/v6.2.0/MoonlightSetup-6.2.0.exe", + "sha256": "ee2a1b56ec83010f4f8a275bb71892a7dc7a3bc874dfaebe706b74d5d10831bd", + "silentArgs": "/quiet /norestart /norestart", + "args": "/norestart", + "scope": "user" + }, + "mpc-hc": { + "wingetId": "clsid2.mpc-hc", + "version": "2.8.3", + "type": "inno", + "url": "https://github.com/clsid2/mpc-hc/releases/download/2.8.3/MPC-HC.2.8.3.x64.exe", + "sha256": "a2f0e384a4222435ab37635e74d975f6518b81c31060ca95bc9bb7d2776e5dbc", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "machine" + }, + "neovim": { + "wingetId": "Neovim.Neovim", + "version": "0.12.6", + "type": "wix", + "url": "https://github.com/neovim/neovim/releases/download/v0.12.6/nvim-win64.msi", + "sha256": "72ec7df85655528952e086858ca6b3add8498f60977108ba8e7f3ede34a7cca5", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "nextcloud": { + "wingetId": "Nextcloud.NextcloudDesktop", + "version": "35.0.0", + "type": "wix", + "url": "https://github.com/nextcloud-releases/desktop/releases/download/v35.0.0/Nextcloud-35.0.0-x64.msi", + "sha256": "7df0f2fe42519e454e14d45a34c4d8d601e98434710087cb1d6d96f315206cd7", + "silentArgs": "/quiet /norestart Reboot=ReallySuppress", + "args": "Reboot=ReallySuppress", + "scope": "machine", + "successCodes": [ + 3010 + ] + }, + "nodejs": { + "wingetId": "OpenJS.NodeJS.LTS", + "version": "24.20.0", + "type": "wix", + "url": "https://nodejs.org/dist/v24.20.0/node-v24.20.0-x64.msi", + "sha256": "28b69132c35ccc033bf8f2a67cd10c9d75ef5822593363309da448f2afff2d8a", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "notepadpp": { + "wingetId": "Notepad++.Notepad++", + "version": "8.9.8.1", + "type": "nullsoft", + "url": "https://github.com/notepad-plus-plus/notepad-plus-plus/releases/download/v8.9.8.1/npp.8.9.8.1.Installer.x64.exe", + "sha256": "26f3bcced788fadbac4e7e577b430ea2ce6060cf3bcad993ef4612ecd7d743da", + "silentArgs": "/S", + "scope": "machine" + }, + "obs": { + "wingetId": "OBSProject.OBSStudio", + "version": "32.2.2", + "type": "nullsoft", + "url": "https://github.com/obsproject/obs-studio/releases/download/32.2.2/OBS-Studio-32.2.2-Windows-x64-Installer.exe", + "sha256": "c3a0b880adbe64dc4bcb68f93016916ab5b55ae43fd227115287bf80257d92dc", + "silentArgs": "/S", + "scope": "machine" + }, + "obsidian": { + "wingetId": "Obsidian.Obsidian", + "version": "1.14.4", + "type": "nullsoft", + "url": "https://github.com/obsidianmd/obsidian-releases/releases/download/v1.14.4/Obsidian-1.14.4.exe", + "sha256": "28662520368d5956df7798076b8370ac730164a8863dcd4f678ced53f63faa00", + "silentArgs": "/S /currentuser", + "args": "/currentuser", + "scope": "user" + }, + "ollama": { + "wingetId": "Ollama.Ollama", + "version": "0.40.3", + "type": "inno", + "url": "https://github.com/ollama/ollama/releases/download/v0.40.3/OllamaSetup.exe", + "sha256": "ab81db6c6f4f65547242d1b2912e854da660ab3bc1dbd5ae3483831cf7668c80", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "user" + }, + "openvpn": { + "wingetId": "OpenVPNTechnologies.OpenVPNConnect", + "version": "3.9.0", + "type": "wix", + "url": "https://packages.openvpn.net/connect/v3/openvpn-connect-3.9.0.5008_signed.msi", + "sha256": "31347812dd37dbbb69bc47de842eb179827266e9dfb45e7f6ce10c5d71a5bad6", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "opera": { + "wingetId": "Opera.Opera", + "version": "137.0.6036.39", + "type": "exe", + "url": "https://get.geo.opera.com/pub/opera/desktop/137.0.6036.39/win/Opera_137.0.6036.39_Setup_x64.exe", + "sha256": "95bdd5acc7c5ba4a59d6baa32ef649d2cf4c30de35f86a29ab99c4ba1ab9daab", + "silentArgs": "/silent /allusers=0", + "args": "/allusers=0", + "scope": "user" + }, + "parsec": { + "wingetId": "Parsec.Parsec", + "version": "150.104.1.0", + "type": "nullsoft", + "url": "https://builds.parsec.app/package/parsec-windows.exe", + "sha256": "b8a9cd519010666def0ef6e119eb42b33b5b811216f34e4f6c8e2e25ac290fbc", + "silentArgs": "/silent /norun /nocleanuser /currentuser", + "args": "/currentuser", + "scope": "user" + }, + "pgadmin": { + "wingetId": "PostgreSQL.pgAdmin", + "version": "9.18", + "type": "inno", + "url": "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/v9.18/windows/pgadmin4-9.18-x64.exe", + "sha256": "ca82b9a07007308a70a9af55192f5ca826c9ce839fe716cea465e2cfc4529245", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER", + "args": "/CURRENTUSER", + "scope": "user" + }, + "pinta": { + "wingetId": "Pinta.Pinta", + "version": "3.0.5", + "type": "inno", + "url": "https://github.com/PintaProject/Pinta/releases/download/3.0.5/Pinta.exe", + "sha256": "74017975bbf12480994217d13e8a7bde05fc26ec3bb3bd39ee7f0de3c3c4cd46", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + }, + "playnite": { + "wingetId": "Playnite.Playnite", + "version": "10.58", + "type": "inno", + "url": "https://github.com/JosefNemec/Playnite/releases/download/10.58/Playnite1058.exe", + "sha256": "acf8bf9c50b8dc8552cee43f348ae5c171ae5219fbad6a916ed3b01e3e9335d7", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NORESTART", + "args": "/NORESTART", + "scope": "user" + }, + "postgresql": { + "wingetId": "PostgreSQL.PostgreSQL.17", + "version": "17.11-5", + "type": "exe", + "url": "https://get.enterprisedb.com/postgresql/postgresql-17.11-5-windows-x64.exe", + "sha256": "612c7f5400a003aaffa0372cb273eed52278f93c680ab911eb9cb2229e329072", + "silentArgs": "--mode unattended --unattendedmodeui none", + "scope": "machine" + }, + "postman": { + "wingetId": "Postman.Postman", + "version": "12.32.0", + "type": "exe", + "url": "https://dl.pstmn.io/download/version/12.32.0/windows_64", + "sha256": "eb56eebfdd0104a1c579cdeac22f99cd11049b5a7cf36c79f6d26a310d56def7", + "silentArgs": "--silent", + "scope": "user" + }, + "powershell": { + "wingetId": "Microsoft.PowerShell", + "version": "7.6.6.0", + "type": "wix", + "url": "https://github.com/PowerShell/PowerShell/releases/download/v7.6.6/PowerShell-7.6.6-win-x64.msi", + "sha256": "958838ff55091e1c8705d89efed0cc7e8245a3a6ef6c0ccfae20015227108ad8", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "powertoys": { + "wingetId": "Microsoft.PowerToys", + "version": "0.101.2362.0", + "type": "burn", + "url": "https://github.com/microsoft/PowerToys/releases/download/v0.101.2362.0/PowerToysUserSetup-0.101.2362.0-x64.exe", + "sha256": "d56fa7130fa68afe553068c15a59a6b24c8dbcc9a0989a43ef0fc5a373230de3", + "silentArgs": "/quiet /norestart", + "scope": "user" + }, + "prism-launcher": { + "wingetId": "PrismLauncher.PrismLauncher", + "version": "11.1.1", + "type": "nullsoft", + "url": "https://github.com/PrismLauncher/PrismLauncher/releases/download/11.1.1/PrismLauncher-Windows-MSVC-Setup-11.1.1.exe", + "sha256": "aaf1f371a41d530785b679e3a2e1141dceec98651fc35d56dc0842b2740280ae", + "silentArgs": "/S", + "scope": "user" + }, + "putty": { + "wingetId": "PuTTY.PuTTY", + "version": "0.85.0.0", + "type": "wix", + "url": "https://the.earth.li/~sgtatham/putty/0.85/w64/putty-64bit-0.85-installer.msi", + "sha256": "2213ebacf962b709411d654a40087508a694756bc4e41c5a1c24ad86596b2511", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "pycharm": { + "wingetId": "JetBrains.PyCharm.Community", + "version": "2025.2.6.1", + "type": "nullsoft", + "url": "https://download.jetbrains.com/python/pycharm-community-2025.2.6.1.exe", + "sha256": "cd9baffec7dd2ab33494a6c3b11fc4f14318c3866343064fc625729e35c868da", + "silentArgs": "/S", + "scope": "user" + }, + "python": { + "wingetId": "Python.Python.3.12", + "version": "3.12.10", + "type": "burn", + "url": "https://www.python.org/ftp/python/3.12.10/python-3.12.10-amd64.exe", + "sha256": "67b5635e80ea51072b87941312d00ec8927c4db9ba18938f7ad2d27b328b95fb", + "silentArgs": "/quiet /norestart InstallAllUsers=0 PrependPath=1", + "args": "InstallAllUsers=0 PrependPath=1", + "scope": "user" + }, + "qbittorrent": { + "wingetId": "qBittorrent.qBittorrent", + "version": "5.2.4", + "type": "nullsoft", + "url": "https://sourceforge.net/projects/qbittorrent/files/qbittorrent-win32/qbittorrent-5.2.4/qbittorrent_5.2.4_x64_setup.exe/download", + "sha256": "ef6efe581d80efeb0a77df281d93f93021fbdcc48e4d8f7b341dc864f53c30dd", + "silentArgs": "/S", + "scope": "machine" + }, + "roblox": { + "wingetId": "Roblox.Roblox", + "version": "0.726", + "type": "exe", + "url": "https://setup.rbxcdn.com/version-8884371d30284041-RobloxPlayerInstaller.exe", + "sha256": "22858fa38591e6f06b10adff0a994b624c8c45b1ff8cc9f349adcdaaf2cbef7b", + "silentArgs": "-install -silent" + }, + "rockstar": { + "wingetId": "RockstarGames.Launcher", + "version": "1.0.109.3031", + "type": "exe", + "url": "https://gamedownloads.rockstargames.com/public/installer/Rockstar-Games-Launcher.exe", + "sha256": "321886457b7e4ab727a27671c17a815684a3943579eedf7e2248a3b9f0973746", + "silentArgs": "/s /f" + }, + "rufus": { + "wingetId": "Rufus.Rufus", + "version": "4.15", + "type": "portable", + "direct": false + }, + "rust": { + "wingetId": "Rustlang.Rust.MSVC", + "version": "1.99.0", + "type": "wix", + "url": "https://static.rust-lang.org/dist/rust-1.99.0-x86_64-pc-windows-msvc.msi", + "sha256": "0ccecc0d77722cf4ab3288ecdcf541530bec129b530f91618cbdd9e3a0dbe7fd", + "silentArgs": "/quiet /norestart" + }, + "rustdesk": { + "provider": "chocolatey", + "packageId": "rustdesk" + }, + "sharex": { + "wingetId": "ShareX.ShareX", + "version": "21.0.0", + "type": "inno", + "url": "https://github.com/ShareX/ShareX/releases/download/v21.0.0/ShareX-21.0.0-setup-x64.exe", + "sha256": "f213aca04d30e0e2dc7c43bc79acae8622be291cee6014a90962ed87ea5f10cc", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /NORUN", + "args": "/NORUN", + "scope": "user" + }, + "shotcut": { + "wingetId": "Meltytech.Shotcut", + "version": "26.9.27", + "type": "inno", + "url": "https://github.com/mltframework/shotcut/releases/download/v26.9.27/shotcut-win64-26.9.27.exe", + "sha256": "77e1ee1f713d92bdbaff23266f416e33cde3d367f7ef518b1841ddc961d05af9", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER", + "args": "/CURRENTUSER", + "scope": "user" + }, + "slack": { + "wingetId": "SlackTechnologies.Slack", + "version": "4.52.178", + "type": "exe", + "url": "https://downloads.slack-edge.com/desktop-releases/windows/x64/4.52.178/SlackSetup.exe", + "sha256": "edec34a6cd42a33da7556bac0ceb4046ae9e37ac0b1a4ae83a1b321c381ee062", + "silentArgs": "--silent", + "scope": "user" + }, + "spotify": { + "wingetId": "Spotify.Spotify", + "version": "1.3.4.258.gc60b10b0946a", + "type": "exe", + "url": "https://download.scdn.co/SpotifyFullSetupX64.exe", + "sha256": "5d7cd1780218fde3de9888b2e7b95398de6d155228f8382961d6dbf14c2a20b0", + "silentArgs": "/silent /skip-app-launch", + "scope": "user" + }, + "steam": { + "wingetId": "Valve.Steam", + "version": "2.10.91.91", + "type": "nullsoft", + "url": "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe", + "sha256": "7d3654531c32d941b8cae81c4137fc542172bfa9635f169cb392f245a0a12bcb", + "silentArgs": "/S", + "scope": "machine" + }, + "sublime-text": { + "wingetId": "SublimeHQ.SublimeText.4", + "version": "4.0.0.421500", + "type": "inno", + "url": "https://download.sublimetext.com/sublime_text_build_4215_x64_setup.exe", + "sha256": "bafea3ffc2d151fbbf7269868d7eaaa20d7b884254ad5512c1548b6bebfa7cde", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "machine" + }, + "sumatra": { + "wingetId": "SumatraPDF.SumatraPDF", + "version": "3.6.1", + "type": "exe", + "url": "https://www.sumatrapdfreader.org/dl/rel/3.6.1/SumatraPDF-3.6.1-64-install.exe", + "sha256": "1eee71cccd2ea6e94d5bcea54ee2f759844da3e1a0ee2f6045035b1d17b94381", + "silentArgs": "-s -install -with-preview", + "args": "-install -with-preview", + "scope": "user" + }, + "system-informer": { + "wingetId": "WinsiderSS.SystemInformer", + "version": "4.0.26241.138", + "type": "exe", + "url": "https://github.com/winsiderss/si-builds/releases/download/4.0.26241.138/systeminformer-build-release-setup.exe", + "sha256": "ceb46bc42cd9993a3a8e9e7002d1f1715e3d2a077d5226d72612e2b8578e51eb", + "silentArgs": "-install -silent", + "scope": "machine" + }, + "tailscale": { + "wingetId": "Tailscale.Tailscale", + "version": "1.104.1", + "type": "burn", + "url": "https://pkgs.tailscale.com/stable/tailscale-setup-full-1.104.1.exe", + "sha256": "ada83c21ab64bd3658042c8bc6f0bb9846f29937eb37fa1291c6c841999f9cb1", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "teams": { + "wingetId": "Microsoft.Teams", + "version": "26198.304.4946.9672", + "type": "msix", + "direct": false + }, + "teamspeak": { + "wingetId": "TeamSpeakSystems.TeamSpeakClient", + "version": "3.6.2", + "type": "nullsoft", + "url": "https://files.teamspeak-services.com/releases/client/3.6.2/TeamSpeak3-Client-win64-3.6.2.exe", + "sha256": "eab9e0c1a7134643e5f7116b7e0e58faffb20d6db528f8b333d2c2b5d1ab68ae", + "silentArgs": "/S /CURRENTUSER", + "args": "/CURRENTUSER", + "scope": "user" + }, + "teamviewer": { + "wingetId": "TeamViewer.TeamViewer", + "version": "15.82.6", + "type": "nullsoft", + "url": "https://download.teamviewer.com/download/version_15x/TeamViewer_Setup_x64_15.82.6.exe", + "sha256": "24625b7445d456a13880bf52bfb8ae3ffe8a1cd5ec6d12a8415a15552d79de7a", + "silentArgs": "/S", + "scope": "machine" + }, + "telegram": { + "wingetId": "Telegram.TelegramDesktop", + "version": "7.3.1", + "type": "inno", + "url": "https://github.com/telegramdesktop/tdesktop/releases/download/v7.3.1/td-setup-win-x64-7.3.1.exe", + "sha256": "d5534936dcb8913c250b9e0bc14ee438feaf9ce3b5e076ba771b4211645acc81", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "user" + }, + "temurin-jdk": { + "wingetId": "EclipseAdoptium.Temurin.17.JDK", + "version": "17.0.20.101", + "type": "wix", + "url": "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.20.1+1/OpenJDK17U-jdk_x64_windows_hotspot_17.0.20.1_1.msi", + "sha256": "a6013774c5e4a34951e11c1997d8b4641d2de7736d3eac839231f4266bd1116f", + "silentArgs": "/quiet /norestart ADDLOCAL=ALL", + "args": "ADDLOCAL=ALL", + "scope": "machine" + }, + "thunderbird": { + "wingetId": "Mozilla.Thunderbird", + "version": "157.0.1", + "type": "nullsoft", + "url": "https://download-installer.cdn.mozilla.net/pub/thunderbird/releases/157.0.1/win64/en-US/Thunderbird%20Setup%20157.0.1.exe", + "sha256": "dec7acc0e246c9bce3ab23e02fcc55543ea96526bb0a13f5ca8d33a09e904f35", + "silentArgs": "/S /PreventRebootRequired=true", + "scope": "machine" + }, + "treesize-free": { + "wingetId": "JAMSoftware.TreeSize.Free", + "version": "4.8.1.610", + "type": "inno", + "url": "https://downloads.jam-software.de/treesize_free/archive/v4.8.1.610/TreeSizeFreeSetup.exe", + "sha256": "d800f1765539194a088cd06b2b8a73c40c4a1001425f2a892f495a53db9da2de", + "silentArgs": "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CurrentUser /CURRENTUSER", + "args": "/CURRENTUSER", + "scope": "user" + }, + "ubisoft-connect": { + "wingetId": "Ubisoft.Connect", + "version": "174.1.0.13380", + "type": "nullsoft", + "url": "https://static3.cdn.ubi.com/orbit/launcher_installer/UbisoftConnectInstaller.exe", + "sha256": "3cba64c512523f58579f83c10c30d55004ef59a7f926a67903fe1fa5ddf2539a", + "silentArgs": "/S", + "scope": "machine" + }, + "veracrypt": { + "wingetId": "IDRIX.VeraCrypt", + "version": "1.26.29", + "type": "wix", + "url": "https://launchpad.net/veracrypt/trunk/1.26.29/+download/VeraCrypt_Setup_x64_1.26.29.msi", + "sha256": "5ba6426983123cfb92bc1f09bd888fdbc0f53f300d0d9c5da52ce3aee8d474f0", + "silentArgs": "/qn ACCEPTLICENSE=YES /norestart", + "args": "/norestart", + "scope": "machine" + }, + "virtualbox": { + "wingetId": "Oracle.VirtualBox", + "version": "7.2.20", + "type": "exe", + "url": "https://download.virtualbox.org/virtualbox/7.2.20/VirtualBox-7.2.20-175154-Win.exe", + "sha256": "a81777d2b36380ce042a29e9c554cf032eb46a793f62e3cc82e7411e535c2c26", + "silentArgs": "--silent -msiparams REBOOT=ReallySuppress", + "args": "-msiparams REBOOT=ReallySuppress", + "scope": "machine", + "successCodes": [ + 3010 + ] + }, + "vivaldi": { + "wingetId": "Vivaldi.Vivaldi", + "version": "8.2.4133.84", + "type": "exe", + "url": "https://downloads.vivaldi.com/stable/Vivaldi.8.2.4133.84.x64.exe", + "sha256": "5da83090d30ab69243e68588ba79acd20c2276fa3e26976e32801c22ff42c89d", + "silentArgs": "--vivaldi-silent --do-not-launch-chrome", + "args": "--do-not-launch-chrome", + "scope": "user" + }, + "vlc": { + "wingetId": "VideoLAN.VLC", + "version": "3.0.24", + "type": "nullsoft", + "url": "https://download.videolan.org/pub/videolan/vlc/3.0.24/win64/vlc-3.0.24-win64.exe", + "sha256": "d711e1e1fe52052748c39080c7dce63f6b7e4c315efedf1774a3f1957b782ff3", + "silentArgs": "/S", + "scope": "machine" + }, + "vs2022": { + "wingetId": "Microsoft.VisualStudio.2022.Community", + "version": "17.14.41", + "type": "exe", + "url": "https://download.visualstudio.microsoft.com/download/pr/bc92e2cb-33de-4a0c-995d-efa817f16b16/7041ccc4d8d52901e386b0d96d9f11394244fb2b09fc6aae2c573f16c20ccdcc/vs_Community.exe", + "sha256": "7041ccc4d8d52901e386b0d96d9f11394244fb2b09fc6aae2c573f16c20ccdcc", + "silentArgs": "--quiet --wait --campaign \"winget\"", + "args": "--wait --campaign \"winget\"", + "scope": "machine" + }, + "vscode": { + "wingetId": "Microsoft.VisualStudioCode", + "version": "1.141.0", + "type": "inno", + "url": "https://vscode.download.prss.microsoft.com/dbazure/download/stable/2a59476c9bfcb90b3ddc372c36762471b7dfad1c/VSCodeUserSetup-x64-1.141.0.exe", + "sha256": "e377a8e2e081a4b59dfd19514abdf4f35aa95aa697e217ad819a0fccff11c602", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /mergetasks=!runcode", + "args": "/mergetasks=!runcode", + "scope": "user" + }, + "winmerge": { + "wingetId": "WinMerge.WinMerge", + "version": "2.16.58.2", + "type": "inno", + "url": "https://github.com/WinMerge/winmerge/releases/download/v2.16.58.2/WinMerge-2.16.58.2-x64-PerUser-Setup.exe", + "sha256": "6ac1e3c0028c39e49962dacd662ab553989d7e817a5cdf96ebbbc1fb345bcff4", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "user" + }, + "winrar": { + "wingetId": "RARLab.WinRAR", + "version": "7.23.0", + "type": "exe", + "url": "https://www.rarlab.com/rar/winrar-x64-723.exe", + "sha256": "8ff0daf3ed564cc743c0e23ff2e253997ffc74460f9673f0b6dd037b2db4ce7b", + "silentArgs": "-s1", + "scope": "machine" + }, + "winscp": { + "wingetId": "WinSCP.WinSCP", + "version": "6.5.7", + "type": "inno", + "url": "https://sourceforge.net/projects/winscp/files/WinSCP/6.5.7/WinSCP-6.5.7-Setup.exe/download", + "sha256": "65103dad94a907bf8b7b9b8db8174dfb60010237d9125db066f34d89817bcd99", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CURRENTUSER", + "args": "/CURRENTUSER", + "scope": "user" + }, + "wireguard": { + "wingetId": "WireGuard.WireGuard", + "version": "1.1.1", + "type": "wix", + "url": "https://download.wireguard.com/windows-client/wireguard-amd64-1.1.1.msi", + "sha256": "7bfed60ad61b785c914b38b61555a975488e1d3ec472dbfb2fcdf498fca75242", + "silentArgs": "/quiet /norestart DO_NOT_LAUNCH=1", + "args": "DO_NOT_LAUNCH=1", + "scope": "machine" + }, + "wireshark": { + "wingetId": "WiresharkFoundation.Wireshark", + "version": "4.6.9", + "type": "nullsoft", + "url": "https://2.na.dl.wireshark.org/win64/all-versions/Wireshark-4.6.9-x64.exe", + "sha256": "bf9b5ce8a89f244c376a9b1a946276eaa06463dde3e33069a34d7f102f5878cf", + "silentArgs": "/S", + "scope": "machine" + }, + "wiztree": { + "wingetId": "AntibodySoftware.WizTree", + "version": "4.33", + "type": "inno", + "url": "https://diskanalyzer.com/files/wiztree_4_33_setup.exe", + "sha256": "3c56dfc08f06c58b9a9133a137c8559e016749e1605cf81e1b1d6b1deead2ff1", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "machine" + }, + "xnviewmp": { + "wingetId": "XnSoft.XnViewMP", + "version": "1.12.1.0", + "type": "inno", + "url": "https://download.xnview.com/old_versions/XnView_MP/XnView_MP-1.12.1-win-x64.exe", + "sha256": "309bda96d2728c0de254257a50cdf3aa9956e3352c60b6f8a55d0c829ece1ee9", + "silentArgs": "/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART", + "scope": "machine" + }, + "yandex-browser": { + "wingetId": "Yandex.Browser", + "version": "26.10.0.1796", + "type": "exe", + "url": "https://download.cdn.yandex.net/browser/int/26_10_0_1796_118428/en/Yandex.exe", + "sha256": "ba3c76e0b4283057b5bc7734e142666ed50ca711b5e135143513bfe495f1d251", + "silentArgs": "--silent --do-not-launch-browser", + "args": "--do-not-launch-browser", + "scope": "user", + "successCodes": [ + 1, + 3 + ] + }, + "zoom": { + "wingetId": "Zoom.Zoom", + "version": "7.2.48556", + "type": "msi", + "url": "https://zoom.us/client/7.2.1.48556/ZoomInstallerFull.msi?archType=x64", + "sha256": "b0dac9fa5237d2e18144ca723644460e7cc26675e5d3db0f89fdfe47ad8c429b", + "silentArgs": "/quiet /norestart", + "scope": "machine" + }, + "zotero": { + "wingetId": "DigitalScholar.Zotero", + "version": "10.0.6", + "type": "nullsoft", + "url": "https://download.zotero.org/client/release/10.0.6/Zotero-10.0.6_x64_setup.exe", + "sha256": "1cf48672c17c4be057d28b309875204f35b52b7d045d38bb7a1992828828b124", + "silentArgs": "/S", + "scope": "machine" + } +} diff --git a/data/related.json b/data/related.json new file mode 100644 index 0000000..74ccb30 --- /dev/null +++ b/data/related.json @@ -0,0 +1,70 @@ +{ + "stacks": [ + { "id": "pc-gaming", "apps": ["steam", "epic", "gog-galaxy", "discord", "playnite", "hwmonitor"] }, + { "id": "multi-launcher", "apps": ["playnite", "heroic", "gog-galaxy", "epic", "amazon-games", "ubisoft-connect", "ea-app"] }, + { "id": "blizzard-ea-ubisoft", "apps": ["battlenet", "ea-app", "ubisoft-connect", "rockstar", "discord"] }, + { "id": "minecraft", "apps": ["minecraft-launcher", "prism-launcher", "temurin-jdk", "discord"] }, + { "id": "game-streaming", "apps": ["parsec", "moonlight", "steam", "obs", "discord"] }, + { "id": "streamer", "apps": ["obs", "discord", "sharex", "audacity", "kdenlive", "handbrake"] }, + { "id": "video-editing", "apps": ["kdenlive", "shotcut", "handbrake", "audacity", "vlc", "blender"] }, + { "id": "photo", "apps": ["darktable", "gimp", "xnviewmp", "imageglass", "krita"] }, + { "id": "illustration", "apps": ["krita", "inkscape", "gimp", "figma", "pinta"] }, + { "id": "3d-cad", "apps": ["blender", "freecad", "gimp", "inkscape"] }, + { "id": "listening", "apps": ["spotify", "foobar2000", "vlc", "audacity"] }, + { "id": "home-theater", "apps": ["kodi", "vlc", "mpc-hc", "qbittorrent"] }, + { "id": "web-dev", "apps": ["vscode", "git", "nodejs", "github-desktop", "postman", "insomnia", "docker", "brave", "firefox"] }, + { "id": "python-data", "apps": ["python", "miniconda", "pycharm", "vscode", "git", "ollama"] }, + { "id": "jvm", "apps": ["temurin-jdk", "intellij-idea", "git", "dbeaver", "docker"] }, + { "id": "dotnet", "apps": ["vs2022", "dotnet-sdk", "git", "powershell", "winmerge"] }, + { "id": "systems", "apps": ["rust", "golang", "cmake", "neovim", "git", "vscode"] }, + { "id": "databases", "apps": ["postgresql", "pgadmin", "dbeaver", "mongodb-compass", "docker"] }, + { "id": "devops", "apps": ["docker", "powershell", "putty", "winscp", "wireshark", "virtualbox", "git"] }, + { "id": "local-ai", "apps": ["ollama", "python", "vscode", "docker"] }, + { "id": "editors", "apps": ["notepadpp", "sublime-text", "neovim", "winmerge", "everything"] }, + { "id": "remote-admin", "apps": ["putty", "winscp", "filezilla", "rustdesk", "anydesk", "tailscale", "wireguard"] }, + { "id": "remote-help", "apps": ["anydesk", "teamviewer", "rustdesk"] }, + { "id": "pc-health", "apps": ["cpu-z", "hwmonitor", "crystaldiskinfo", "system-informer", "malwarebytes"] }, + { "id": "disk-cleanup", "apps": ["wiztree", "treesize-free", "bleachbit", "everything", "crystaldiskinfo"] }, + { "id": "power-user", "apps": ["powertoys", "everything", "7zip", "sharex", "notepadpp", "powershell"] }, + { "id": "fresh-install", "apps": ["7zip", "vlc", "firefox", "sumatra", "everything", "bitwarden", "libreoffice"] }, + { "id": "usb-tools", "apps": ["rufus", "crystaldiskinfo", "veracrypt", "7zip"] }, + { "id": "privacy", "apps": ["bitwarden", "keepassxc", "veracrypt", "wireguard", "brave", "firefox", "element", "thunderbird"] }, + { "id": "vpn", "apps": ["wireguard", "openvpn", "tailscale"] }, + { "id": "office-suite", "apps": ["libreoffice", "thunderbird", "sumatra", "zoom", "teams"] }, + { "id": "study", "apps": ["anki", "obsidian", "zotero", "calibre", "sumatra", "libreoffice"] }, + { "id": "notes", "apps": ["obsidian", "logseq", "joplin", "anki"] }, + { "id": "reading", "apps": ["calibre", "sumatra", "zotero"] }, + { "id": "work-chat", "apps": ["slack", "teams", "zoom", "telegram"] }, + { "id": "community-chat", "apps": ["discord", "telegram", "element", "teamspeak"] }, + { "id": "downloads", "apps": ["qbittorrent", "free-download-manager", "7zip", "winrar"] }, + { "id": "cloud-sync", "apps": ["google-drive", "nextcloud", "7zip"] } + ], + "alternatives": [ + { "id": "browser", "apps": ["firefox", "chrome", "edge", "brave", "opera", "vivaldi", "yandex-browser"] }, + { "id": "archiver", "apps": ["7zip", "winrar"] }, + { "id": "video-player", "apps": ["vlc", "mpc-hc", "kodi"] }, + { "id": "music-player", "apps": ["spotify", "foobar2000"] }, + { "id": "video-editor", "apps": ["kdenlive", "shotcut"] }, + { "id": "raster-editor", "apps": ["gimp", "krita", "pinta"] }, + { "id": "image-viewer", "apps": ["xnviewmp", "imageglass"] }, + { "id": "password-manager", "apps": ["bitwarden", "keepass", "keepassxc"] }, + { "id": "vpn-client", "apps": ["wireguard", "openvpn", "tailscale"] }, + { "id": "remote-desktop", "apps": ["anydesk", "teamviewer", "rustdesk", "parsec"] }, + { "id": "file-transfer", "apps": ["winscp", "filezilla"] }, + { "id": "code-editor", "apps": ["vscode", "sublime-text", "neovim", "notepadpp"] }, + { "id": "java-ide", "apps": ["intellij-idea", "vs2022"] }, + { "id": "python-dist", "apps": ["python", "miniconda"] }, + { "id": "api-client", "apps": ["postman", "insomnia"] }, + { "id": "db-client", "apps": ["dbeaver", "pgadmin"] }, + { "id": "disk-usage", "apps": ["wiztree", "treesize-free"] }, + { "id": "notes-app", "apps": ["obsidian", "logseq", "joplin"] }, + { "id": "pdf-reader", "apps": ["sumatra", "calibre"] }, + { "id": "voice-chat", "apps": ["discord", "teamspeak"] }, + { "id": "messenger", "apps": ["telegram", "element"] }, + { "id": "meetings", "apps": ["zoom", "teams", "slack"] }, + { "id": "minecraft-launcher", "apps": ["minecraft-launcher", "prism-launcher"] }, + { "id": "game-library", "apps": ["playnite", "heroic"] }, + { "id": "cloud-storage", "apps": ["google-drive", "nextcloud"] }, + { "id": "hw-monitor", "apps": ["hwmonitor", "cpu-z"] } + ] +} diff --git a/data/winget-map.json b/data/winget-map.json index fb58def..51c164d 100644 --- a/data/winget-map.json +++ b/data/winget-map.json @@ -1,6 +1,6 @@ { "7zip": "7zip.7zip", - "amazon-games": "AmazonGames.AmazonGames", + "amazon-games": "Amazon.Games", "anki": "Anki.Anki", "anydesk": "AnyDesk.AnyDesk", "audacity": "Audacity.Audacity", @@ -15,7 +15,7 @@ "cpu-z": "CPUID.CPU-Z", "crystaldiskinfo": "CrystalDewWorld.CrystalDiskInfo", "darktable": "darktable.darktable", - "dbeaver": "DBeaver.DBeaver", + "dbeaver": "DBeaver.DBeaver.Community", "discord": "Discord.Discord", "docker": "Docker.DockerDesktop", "dotnet-sdk": "Microsoft.DotNet.SDK.8", @@ -25,10 +25,9 @@ "epic": "EpicGames.EpicGamesLauncher", "everything": "voidtools.Everything", "figma": "Figma.Figma", - "filezilla": "TimKosse.FileZilla.Client", "firefox": "Mozilla.Firefox", "foobar2000": "PeterPawlowski.foobar2000", - "free-download-manager": "FreeDownloadManager.FDM", + "free-download-manager": "SoftDeluxe.FreeDownloadManager", "freecad": "FreeCAD.FreeCAD", "gimp": "GIMP.GIMP.3", "git": "Git.Git", @@ -38,7 +37,7 @@ "google-drive": "Google.GoogleDrive", "handbrake": "HandBrake.HandBrake", "heroic": "HeroicGamesLauncher.HeroicGamesLauncher", - "hwmonitor": "REALiX.HWiNFO", + "hwmonitor": "CPUID.HWMonitor", "imageglass": "DuongDieuPhap.ImageGlass", "inkscape": "Inkscape.Inkscape", "insomnia": "Insomnia.Insomnia", @@ -64,11 +63,11 @@ "obs": "OBSProject.OBSStudio", "obsidian": "Obsidian.Obsidian", "ollama": "Ollama.Ollama", - "openvpn": "OpenVPNTech.OpenVPNConnect", + "openvpn": "OpenVPNTechnologies.OpenVPNConnect", "opera": "Opera.Opera", "parsec": "Parsec.Parsec", "pgadmin": "PostgreSQL.pgAdmin", - "pinta": "PintaProject.Pinta", + "pinta": "Pinta.Pinta", "playnite": "Playnite.Playnite", "postgresql": "PostgreSQL.PostgreSQL.17", "postman": "Postman.Postman", @@ -80,16 +79,15 @@ "python": "Python.Python.3.12", "qbittorrent": "qBittorrent.qBittorrent", "roblox": "Roblox.Roblox", - "rockstar": "RockstarGames.RockstarGamesLauncher", + "rockstar": "RockstarGames.Launcher", "rufus": "Rufus.Rufus", "rust": "Rustlang.Rust.MSVC", - "rustdesk": "RustDesk.RustDesk", "sharex": "ShareX.ShareX", "shotcut": "Meltytech.Shotcut", "slack": "SlackTechnologies.Slack", "spotify": "Spotify.Spotify", "steam": "Valve.Steam", - "sublime-text": "SublimeText.SublimeText.4", + "sublime-text": "SublimeHQ.SublimeText.4", "sumatra": "SumatraPDF.SumatraPDF", "system-informer": "WinsiderSS.SystemInformer", "tailscale": "Tailscale.Tailscale", @@ -114,7 +112,7 @@ "wireshark": "WiresharkFoundation.Wireshark", "wiztree": "AntibodySoftware.WizTree", "xnviewmp": "XnSoft.XnViewMP", - "yandex-browser": "Yandex.YandexBrowser", + "yandex-browser": "Yandex.Browser", "zoom": "Zoom.Zoom", - "zotero": "Zotero.Zotero" + "zotero": "DigitalScholar.Zotero" } diff --git a/data/winget-overrides.json b/data/winget-overrides.json index cbc4373..dfb2f3c 100644 --- a/data/winget-overrides.json +++ b/data/winget-overrides.json @@ -6,7 +6,7 @@ "battlenet": "Blizzard.BattleNet", "ubisoft-connect": "Ubisoft.Connect", "ea-app": "ElectronicArts.EADesktop", - "rockstar": "RockstarGames.RockstarGamesLauncher", + "rockstar": "RockstarGames.Launcher", "playnite": "Playnite.Playnite", "heroic": "HeroicGamesLauncher.HeroicGamesLauncher", "python": "Python.Python.3.12", @@ -18,9 +18,9 @@ "temurin-jdk": "EclipseAdoptium.Temurin.17.JDK", "intellij-idea": "JetBrains.IntelliJIDEA.Community", "pycharm": "JetBrains.PyCharm.Community", - "sublime-text": "SublimeText.SublimeText.4", + "sublime-text": "SublimeHQ.SublimeText.4", "neovim": "Neovim.Neovim", - "dbeaver": "DBeaver.DBeaver", + "dbeaver": "DBeaver.DBeaver.Community", "wireshark": "WiresharkFoundation.Wireshark", "virtualbox": "Oracle.VirtualBox", "teams": "Microsoft.Teams", @@ -40,7 +40,7 @@ "parsec": "Parsec.Parsec", "moonlight": "MoonlightGameStreamingProject.Moonlight", "cpu-z": "CPUID.CPU-Z", - "hwmonitor": "REALiX.HWiNFO", + "hwmonitor": "CPUID.HWMonitor", "crystaldiskinfo": "CrystalDewWorld.CrystalDiskInfo", "rufus": "Rufus.Rufus", "treesize-free": "JAMSoftware.TreeSize.Free", @@ -48,18 +48,18 @@ "keepassxc": "KeePassXCTeam.KeePassXC", "tailscale": "Tailscale.Tailscale", "sumatra": "SumatraPDF.SumatraPDF", - "amazon-games": "AmazonGames.AmazonGames", + "amazon-games": "Amazon.Games", "minecraft-launcher": "Mojang.MinecraftLauncher", "prism-launcher": "PrismLauncher.PrismLauncher", "roblox": "Roblox.Roblox", - "yandex-browser": "Yandex.YandexBrowser", + "yandex-browser": "Yandex.Browser", "element": "Element.Element", "teamspeak": "TeamSpeakSystems.TeamSpeakClient", "mpc-hc": "clsid2.mpc-hc", "kdenlive": "KDE.Kdenlive", "shotcut": "Meltytech.Shotcut", "darktable": "darktable.darktable", - "pinta": "PintaProject.Pinta", + "pinta": "Pinta.Pinta", "xnviewmp": "XnSoft.XnViewMP", "imageglass": "DuongDieuPhap.ImageGlass", "freecad": "FreeCAD.FreeCAD", @@ -75,7 +75,7 @@ "pgadmin": "PostgreSQL.pgAdmin", "mongodb-compass": "MongoDB.Compass.Full", "ollama": "Ollama.Ollama", - "zotero": "Zotero.Zotero", + "zotero": "DigitalScholar.Zotero", "joplin": "Joplin.Joplin", "logseq": "Logseq.Logseq", "anki": "Anki.Anki", @@ -84,7 +84,6 @@ "bleachbit": "BleachBit.BleachBit", "google-drive": "Google.GoogleDrive", "nextcloud": "Nextcloud.NextcloudDesktop", - "free-download-manager": "FreeDownloadManager.FDM", - "rustdesk": "RustDesk.RustDesk", - "openvpn": "OpenVPNTech.OpenVPNConnect" + "free-download-manager": "SoftDeluxe.FreeDownloadManager", + "openvpn": "OpenVPNTechnologies.OpenVPNConnect" } diff --git a/electron/bootstrap/runtime.ts b/electron/bootstrap/runtime.ts index fcf1eec..6b6de29 100644 --- a/electron/bootstrap/runtime.ts +++ b/electron/bootstrap/runtime.ts @@ -319,7 +319,6 @@ export async function bootstrapChocolateyWithUAC(onProgress?: (msg: string) => v const existing = await testChocolatey(); if (existing.ok) return 'ok'; - const chocoDir = path.join(getRuntimeDir(), 'chocolatey'); const installScript = ` Set-ExecutionPolicy Bypass -Scope Process -Force [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 diff --git a/electron/catalog/merge.ts b/electron/catalog/merge.ts index 1e01398..4b0a113 100644 --- a/electron/catalog/merge.ts +++ b/electron/catalog/merge.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { MergedApp, Package, PickEntry, ProfileEntry } from '../providers/types.js'; +import type { MergedApp, Package, PickEntry, ProfileEntry, RelatedData } from '../providers/types.js'; import { packageKey, softIsInstalled } from '../providers/types.js'; import { loadCuratedCatalog } from '../providers/catalog.js'; import { peekScoopInstalledCache } from '../providers/scoop.js'; @@ -108,6 +108,16 @@ function loadProfiles(): ProfileEntry[] { } } +const EMPTY_RELATED: RelatedData = { stacks: [], alternatives: [] }; + +function loadRelated(): RelatedData { + try { + return JSON.parse(fs.readFileSync(path.join(getDataDir(), 'related.json'), 'utf-8')) as RelatedData; + } catch { + return EMPTY_RELATED; + } +} + function loadCategories(): Record { try { return JSON.parse(fs.readFileSync(path.join(getDataDir(), 'categories.json'), 'utf-8')) as Record; @@ -171,7 +181,12 @@ function mergeApps(): MergedApp[] { return [...appsMap.values()]; } -export function getCatalog(): { apps: MergedApp[]; categories: Record; profiles: ProfileEntry[] } { +export function getCatalog(): { + apps: MergedApp[]; + categories: Record; + profiles: ProfileEntry[]; + related: RelatedData; +} { if (!cache || cache.invalidated.size > 0) { const apps = mergeApps(); cache = { @@ -185,6 +200,7 @@ export function getCatalog(): { apps: MergedApp[]; categories: Record, - hypothesisId: string, -): void { - const payload = { - sessionId: '7931cb', - location, - message, - data, - hypothesisId, - timestamp: Date.now(), - }; - const line = `${JSON.stringify(payload)}\n`; - - fetch(INGEST, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': '7931cb' }, - body: JSON.stringify(payload), - }).catch(() => {}); - - const file = logFilePath(); - if (!file) return; - try { - fs.mkdirSync(path.dirname(file), { recursive: true }); - fs.appendFileSync(file, line); - } catch { - // ignore file write errors - } -} diff --git a/electron/main.ts b/electron/main.ts index 86e5827..80cab8d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2,7 +2,7 @@ import { app, BrowserWindow, ipcMain, shell, protocol, Menu } from 'electron'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { initProviders, getAllProviders, setCatalogRemoteUrl } from './providers/registry.js'; -import { warmMetaCacheFromDisk, enrichMeta, prewarmMeta, maybeAutoPrewarm, getMetaCompleteness, getMetaProgress, type MetaProgress } from './enrich/meta-cache.js'; +import { warmMetaCacheFromDisk, enrichMeta, prewarmMeta, maybeAutoPrewarm, getMetaCompleteness, getMetaProgress } from './enrich/meta-cache.js'; import { enqueuePackageMeta, onPackageMetaUpdate, type PackageMetaRequest } from './enrich/package-meta.js'; import { getCatalog, invalidateCatalog, setInstalledSet, findApp } from './catalog/merge.js'; import { searchPackages } from './search-cache.js'; @@ -12,7 +12,6 @@ import { getSettings, updateSettings } from './store/settings.js'; import { toggleFavorite, isFavorite } from './store/favorites.js'; import { registerMediaProtocol } from './protocol.js'; import { loadCuratedCatalog } from './providers/catalog.js'; -import { packageKey } from './providers/types.js'; import fs from 'node:fs'; import { getDataDir } from './paths.js'; import type { ProfileEntry } from './providers/types.js'; @@ -90,6 +89,16 @@ function resolvePreloadPath(): string { return path.join(__dirname, 'preload.cjs'); } +/** Only web links leave the app: file:, ms-*, search-ms: and friends can launch local programs. */ +function isWebUrl(url: string): boolean { + try { + const scheme = new URL(url).protocol; + return scheme === 'https:' || scheme === 'http:'; + } catch { + return false; + } +} + function createWindow(): void { const preloadPath = resolvePreloadPath(); if (process.env.VITE_DEV_SERVER_URL) { @@ -107,7 +116,8 @@ function createWindow(): void { preload: preloadPath, contextIsolation: true, nodeIntegration: false, - sandbox: false, + // The preload only needs contextBridge + ipcRenderer, both available in the sandbox. + sandbox: true, }, title: 'GetBox', show: false, @@ -124,6 +134,18 @@ function createWindow(): void { console.error('[GetBox] preload-error:', preload, err); }); + // The renderer is the app UI only: links leave for the browser, nothing navigates in place. + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + if (isWebUrl(url)) void shell.openExternal(url); + return { action: 'deny' }; + }); + mainWindow.webContents.on('will-navigate', (event, url) => { + const devUrl = process.env.VITE_DEV_SERVER_URL; + if (devUrl && url.startsWith(devUrl)) return; + event.preventDefault(); + if (isWebUrl(url)) void shell.openExternal(url); + }); + const devUrl = process.env.VITE_DEV_SERVER_URL; if (devUrl) { mainWindow.loadURL(devUrl); @@ -265,7 +287,10 @@ function setupIpc(): void { return { ok: true, message: `Добавлено в очередь: ${items.length}` }; }); - ipcMain.handle('shell:openExternal', (_e, url: string) => shell.openExternal(url)); + ipcMain.handle('shell:openExternal', (_e, url: string) => { + if (!isWebUrl(url)) throw new Error(`Blocked external URL: ${String(url).slice(0, 200)}`); + return shell.openExternal(url); + }); ipcMain.handle('bootstrap:status', async () => { const state = loadRuntimeState(); diff --git a/electron/preload.ts b/electron/preload.ts index b4992f7..a9a86bc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -36,7 +36,12 @@ export interface MetaProgress { } export interface GetBoxAPI { - getCatalog: () => Promise<{ apps: MergedApp[]; categories: Record; profiles: ProfileEntry[] }>; + getCatalog: () => Promise<{ + apps: MergedApp[]; + categories: Record; + profiles: ProfileEntry[]; + related: RelatedData; + }>; onCatalogDirty: (cb: () => void) => () => void; search: (query: string) => Promise; enrichMeta: (items: Array<{ slug: string; wikiTitle?: string; logoSlug?: string }>) => Promise; @@ -88,6 +93,11 @@ export interface PackageMetaUpdate { iconUrl?: string; } +interface RelatedData { + stacks: Array<{ id: string; apps: string[] }>; + alternatives: Array<{ id: string; apps: string[] }>; +} + interface ProfileEntry { id: string; name: string; diff --git a/electron/providers/catalog.ts b/electron/providers/catalog.ts index 69273ff..4f7619f 100644 --- a/electron/providers/catalog.ts +++ b/electron/providers/catalog.ts @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import { createHash } from 'node:crypto'; import path from 'node:path'; import { spawn } from 'node:child_process'; import { createWriteStream } from 'node:fs'; @@ -24,21 +25,44 @@ import { shouldTryAltProvider, } from './install-errors.js'; import { killProcessTree } from './cli-progress.js'; -import { debugLog } from '../debug-log.js'; -interface CatalogItem extends CuratedEntry { - installUrl?: string; - installUrlFallbacks?: string[]; - altProvider?: { provider: string; packageId: string }; - noWingetFallback?: boolean; +type CatalogItem = CuratedEntry; + +/** A runnable installer resolved from the app's winget manifest. */ +export interface DirectInstaller { + wingetId: string; + version: string; + /** winget InstallerType: nullsoft, inno, burn, exe, msi, wix. */ + type: string; + url: string; + sha256: string; silentArgs?: string; - version?: string; + /** Switches applied in every mode (manifest "Custom"). */ + args?: string; + scope?: 'user' | 'machine'; + successCodes?: number[]; } -export type InstallFallback = { provider: 'winget'; packageId: string }; +/** + * One app in data/installers.json (generated by scripts/refresh-installers.cjs): + * a direct installer, a winget-only package (MSIX, portable…), or a + * hand-written pointer to another package manager. + */ +export type InstallerEntry = + | DirectInstaller + | { wingetId: string; version: string; type: string; direct: false } + | { provider: 'winget' | 'scoop' | 'chocolatey'; packageId: string }; + +export type InstallFallback = { provider: 'winget' | 'scoop' | 'chocolatey'; packageId: string }; + +export interface InstallPlan { + direct?: DirectInstaller; + fallback?: InstallFallback; +} let catalogCache: CatalogItem[] | null = null; let wingetMapCache: Record | null = null; +let installersCache: Record | null = null; let remoteCatalogCache: CatalogItem[] | null = null; let remoteCatalogFetchedAt = 0; @@ -75,14 +99,88 @@ function loadWingetMap(): Record { return wingetMapCache; } -export function resolveInstallFallback(item: CatalogItem): InstallFallback | null { - if (item.noWingetFallback) return null; - if (item.altProvider?.provider === 'winget' && item.altProvider.packageId) { - return { provider: 'winget', packageId: item.altProvider.packageId }; +function loadInstallers(): Record { + if (installersCache) return installersCache; + try { + installersCache = JSON.parse( + fs.readFileSync(path.join(getDataDir(), 'installers.json'), 'utf-8'), + ) as Record; + } catch { + installersCache = {}; + } + return installersCache; +} + +/** + * Direct download when installers.json has a runnable installer for the app, + * with the package manager as fallback; otherwise the package manager alone. + * Apps missing from installers.json (not refreshed yet) still go via winget-map. + */ +export function resolveInstallPlan( + appId: string, + installers: Record = loadInstallers(), + wingetMap: Record = loadWingetMap(), +): InstallPlan { + const entry = installers[appId]; + if (entry && 'provider' in entry) { + return { fallback: { provider: entry.provider, packageId: entry.packageId } }; + } + const wingetId = entry?.wingetId ?? wingetMap[appId]; + const fallback = wingetId ? { provider: 'winget' as const, packageId: wingetId } : undefined; + if (entry && 'url' in entry) return { direct: entry, fallback }; + return { fallback }; +} + +function markInstalled(packageId: string): void { + const tracking = loadInstalledTracking(); + tracking.add(packageId); + saveInstalledTracking(tracking); +} + +function sha256File(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash('sha256'); + fs.createReadStream(filePath) + .on('data', (chunk) => hash.update(chunk)) + .on('error', reject) + .on('end', () => resolve(hash.digest('hex'))); + }); +} + +/** Download, verify against the manifest hash, run. Throws InstallError on any failure. */ +async function installDirect( + appId: string, + entry: DirectInstaller, + opts: InstallOptions, + hasFallback: boolean, +): Promise { + const tempDir = path.join(getAppDataDir(), 'downloads'); + fs.mkdirSync(tempDir, { recursive: true }); + const dest = path.join(tempDir, `${appId}.download`); + let installer: string | null = null; + try { + // With a fallback, one quick attempt per URL: the package manager is the better retry. + await downloadFile(entry.url, dest, opts.onProgress, opts.signal, hasFallback ? 1 : undefined); + if (opts.signal?.aborted) throw new InstallError('cancelled', 'Установка отменена'); + + opts.onProgress?.({ phase: 'install' }); + const actual = await sha256File(dest); + if (actual !== entry.sha256.toLowerCase()) { + throw new InstallError( + 'untrusted', + 'Контрольная сумма установщика не совпадает', + `SHA256 ${actual}, expected ${entry.sha256} (${entry.wingetId} ${entry.version})`, + ); + } + installer = prepareInstaller(dest); + const args = [opts.quiet ? entry.silentArgs : entry.args].filter(Boolean).join(' '); + await runInstaller(installer, args, opts.signal, entry.successCodes); + if (opts.signal?.aborted) throw new InstallError('cancelled', 'Установка отменена'); + } finally { + unlinkQuiet(dest); + unlinkQuiet(`${dest}.part`); + if (installer) unlinkQuiet(installer); } - const mapped = loadWingetMap()[item.id]; - if (mapped) return { provider: 'winget', packageId: mapped }; - return null; } function installedTrackingPath(): string { @@ -105,6 +203,11 @@ function saveInstalledTracking(ids: Set): void { fs.writeFileSync(installedTrackingPath(), JSON.stringify([...ids])); } +function versionOf(appId: string): string | undefined { + const entry = loadInstallers()[appId]; + return entry && 'version' in entry ? entry.version : undefined; +} + function toPackage(item: CatalogItem): Package { return { id: item.packageId || item.id, @@ -112,9 +215,7 @@ function toPackage(item: CatalogItem): Package { name: item.name, description: item.description, category: item.category, - version: item.version, - installUrl: item.installUrl, - silentArgs: item.silentArgs, + version: versionOf(item.id), wikiTitle: item.wikiTitle, logoSlug: item.logoSlug, simpleSlug: item.simpleSlug, @@ -143,11 +244,47 @@ async function fetchRemoteCatalog(url: string): Promise { } const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); - remoteCatalogCache = (await res.json()) as CatalogItem[]; + remoteCatalogCache = sanitizeRemoteCatalog(await res.json()); remoteCatalogFetchedAt = Date.now(); return remoteCatalogCache; } +/** + * Remote catalogs only extend search and listing; how an app is installed + * always comes from the bundled installers.json. Still drop anything + * malformed so a bad feed cannot break the UI. + */ +function sanitizeRemoteCatalog(raw: unknown): CatalogItem[] { + if (!Array.isArray(raw)) throw new Error('Remote catalog is not an array'); + return raw + .filter( + (i): i is CatalogItem => + Boolean(i) && + typeof i === 'object' && + typeof i.id === 'string' && + typeof i.name === 'string' && + typeof i.packageId === 'string', + ) + .map((rest) => ({ + ...rest, + description: typeof rest.description === 'string' ? rest.description : '', + features: Array.isArray(rest.features) ? rest.features.filter((f) => typeof f === 'string') : [], + category: typeof rest.category === 'string' ? rest.category : 'other', + provider: 'catalog', + })); +} + +/** Installers run with the user's rights, so they only travel over HTTPS (loopback is for tests). */ +function isAllowedDownloadUrl(url: string): boolean { + try { + const u = new URL(url); + if (u.protocol === 'https:') return true; + return u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname); + } catch { + return false; + } +} + function unlinkQuiet(filePath: string): void { try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath); @@ -187,12 +324,19 @@ function downloadFileOnce( reject(new InstallError('http', 'Слишком много перенаправлений')); return; } + // Checked per hop: an https link that redirects to plain http is refused too. + if (!isAllowedDownloadUrl(url)) { + reject(new InstallError('untrusted', 'Небезопасная ссылка на загрузку (не HTTPS)', url)); + return; + } unlinkQuiet(destPart); const getter = url.startsWith('https') ? get : httpGet; + // eslint-disable-next-line prefer-const -- assigned below, but read by fail() which is defined first let req: ClientRequest; let idleTimer: ReturnType | undefined; + // eslint-disable-next-line prefer-const -- same: cleanupTimers() closes over it before assignment let overallTimer: ReturnType | undefined; let settled = false; @@ -365,14 +509,71 @@ async function downloadUrlWithRetries( throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)); } -function runInstaller(exe: string, args: string[], signal?: AbortSignal): Promise { +type InstallerKind = 'exe' | 'msi'; + +/** Identify a download by its magic bytes: PE ("MZ") or OLE compound file (MSI). */ +export function detectInstallerKind(filePath: string): InstallerKind | null { + const head = Buffer.alloc(8); + const fd = fs.openSync(filePath, 'r'); + try { + if (fs.readSync(fd, head, 0, 8, 0) < 8) return null; + } finally { + fs.closeSync(fd); + } + if (head.readUInt16LE(0) === 0x5a4d) return 'exe'; + if (head.readUInt32LE(0) === 0xe011cfd0 && head.readUInt32LE(4) === 0xe11ab1a1) return 'msi'; + return null; +} + +/** + * Give the download the extension its content calls for. Links like + * go.microsoft.com/fwlink serve an MSI without saying so, and a broken link + * often serves an HTML page with status 200 — that must never be executed. + */ +function prepareInstaller(downloaded: string): string { + const kind = detectInstallerKind(downloaded); + if (!kind) { + throw new InstallError('untrusted', 'Скачанный файл не является установщиком', path.basename(downloaded)); + } + const target = downloaded.replace(/\.download$/, `.${kind}`); + unlinkQuiet(target); + fs.renameSync(downloaded, target); + return target; +} + +/** + * Manifest switches reach cmd.exe as written (quotes included, e.g. + * /mergetasks=!runcode or --install "C:\\Program Files"), so refuse the + * characters cmd would interpret and unbalanced quotes. + */ +export function isSafeInstallerArgs(args: string): boolean { + if (/[&|<>^%`\r\n]/.test(args)) return false; + return (args.match(/"/g)?.length ?? 0) % 2 === 0; +} + +/** Windows Installer "success, reboot required/initiated" — the app is installed. */ +const REBOOT_EXIT_CODES = [3010, 1641]; + +function runInstaller( + exe: string, + args: string, + signal?: AbortSignal, + successCodes: number[] = [], +): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new InstallError('cancelled', 'Установка отменена')); return; } - const child = spawn(exe, args, { windowsHide: true, shell: true }); + // cmd.exe stays in the loop: it raises the UAC prompt for installers that + // require elevation and opens .msi via its file association. So the path is + // quoted (profiles with spaces) and args may not carry shell metacharacters. + if (!isSafeInstallerArgs(args)) { + reject(new InstallError('untrusted', 'Недопустимые аргументы установщика', args)); + return; + } + const child = spawn(`"${exe}"`, args ? [args] : [], { windowsHide: true, shell: true }); let settled = false; const timer = setTimeout(() => { @@ -401,7 +602,7 @@ function runInstaller(exe: string, args: string[], signal?: AbortSignal): Promis finish(new InstallError('cancelled', 'Установка отменена')); return; } - if (code !== 0 && code !== null) { + if (code !== 0 && code !== null && !REBOOT_EXIT_CODES.includes(code) && !successCodes.includes(code)) { finish(new InstallError('installer', 'Ошибка установщика', `Exit code ${code}`)); return; } @@ -430,17 +631,10 @@ export async function downloadFile( await downloadUrlWithRetries(urls[i], destPart, onProgress, signal, maxRetriesPerUrl); unlinkQuiet(dest); fs.renameSync(destPart, dest); - // #region agent log - debugLog('catalog.ts:downloadFile', 'download ok', { urlIndex: i, urlCount: urls.length, dest: path.basename(dest) }, 'H3'); - // #endregion return; } catch (err) { lastErr = err; unlinkQuiet(destPart); - const code = err instanceof InstallError ? err.code : classifyError(err).code; - // #region agent log - debugLog('catalog.ts:downloadFile', 'url failed', { urlIndex: i, urlCount: urls.length, code, hasMore: i < urls.length - 1 }, 'H3'); - // #endregion if (isCancelledError(err)) throw err; // Try the next mirror for connection/CDN failures. if (i < urls.length - 1 && isRetryableDownloadError(err)) continue; @@ -497,161 +691,62 @@ export class CatalogProvider implements PackageProvider { async install(packageId: string, opts: InstallOptions): Promise { const item = this.findItem(packageId); - if (!item?.installUrl) { - return { - ok: false, - errorCode: 'not_found', - message: `Пакет ${packageId} не найден или без installUrl`, - }; + if (!item) { + return { ok: false, errorCode: 'not_found', message: `Пакет ${packageId} не найден в каталоге` }; } - - const urls = [item.installUrl, ...(item.installUrlFallbacks ?? [])]; - const tempDir = path.join(getAppDataDir(), 'downloads'); - fs.mkdirSync(tempDir, { recursive: true }); - let ext = '.exe'; - try { - ext = path.extname(new URL(item.installUrl).pathname) || '.exe'; - } catch { - ext = '.exe'; + const plan = resolveInstallPlan(item.id); + if (!plan.direct && !plan.fallback) { + return { ok: false, errorCode: 'not_found', message: `Для ${item.name} нет источника установки` }; } - const dest = path.join(tempDir, `${packageId}${ext}`); - - const fallback = resolveInstallFallback(item); - // #region agent log - debugLog('catalog.ts:install', 'install start', { - packageId, - urlCount: urls.length, - hasFallback: Boolean(fallback), - fallbackProvider: fallback?.provider, - maxRetriesPerUrl: fallback ? 1 : MAX_RETRIES, - }, 'H1'); - // #endregion - - try { - if (opts.signal?.aborted) { - return { ok: false, errorCode: 'cancelled', message: 'Установка отменена' }; - } - - await downloadFile( - urls, - dest, - opts.onProgress, - opts.signal, - fallback ? 1 : undefined, - ); - - if (opts.signal?.aborted) { - unlinkQuiet(dest); - return { ok: false, errorCode: 'cancelled', message: 'Установка отменена' }; - } - - opts.onProgress?.({ phase: 'install' }); - const args = opts.quiet && item.silentArgs ? item.silentArgs.split(' ') : []; - await runInstaller(dest, args, opts.signal); - - if (opts.signal?.aborted) { - return { ok: false, errorCode: 'cancelled', message: 'Установка отменена' }; - } - - const tracking = loadInstalledTracking(); - tracking.add(packageId); - saveInstalledTracking(tracking); - - // #region agent log - debugLog('catalog.ts:install', 'install ok direct', { packageId }, 'H4'); - // #endregion - return { ok: true, message: `Установлено: ${item.name}` }; - } catch (err) { - unlinkQuiet(dest); - unlinkQuiet(`${dest}.part`); - - if (isCancelledError(err)) { - return { ok: false, errorCode: 'cancelled', message: 'Установка отменена' }; - } - - // CDN / geo resets: fall back to winget when catalog URLs fail. - // Dynamic import avoids a catalog ↔ registry cycle at module load. - if (fallback && shouldTryAltProvider(err)) { - // #region agent log - debugLog('catalog.ts:install', 'winget fallback', { - packageId, - wingetId: fallback.packageId, - primaryError: err instanceof InstallError ? err.code : classifyError(err).code, - }, 'H1'); - // #endregion - const registry = await import('./registry.js'); - registry.initProviders(); - const winget = registry.getProvider(fallback.provider); - if (!winget) { - // #region agent log - debugLog('catalog.ts:install', 'winget registry miss', { - packageId, - provider: fallback.provider, - }, 'H1'); - // #endregion + let primaryError: unknown; + if (plan.direct) { + try { + await installDirect(item.id, plan.direct, opts, Boolean(plan.fallback)); + markInstalled(packageId); + return { ok: true, message: `Установлено: ${item.name}` }; + } catch (err) { + if (isCancelledError(err)) { + return { ok: false, errorCode: 'cancelled', message: 'Установка отменена' }; } - if (winget) { - opts.onProgress?.({ - phase: 'download', - note: `winget:${fallback.packageId}`, - }); - const wingetResult = await winget.install(fallback.packageId, opts); - if (wingetResult.ok) { - // #region agent log - debugLog('catalog.ts:install', 'winget ok', { packageId, via: fallback.provider }, 'H1'); - // #endregion - const tracking = loadInstalledTracking(); - tracking.add(packageId); - saveInstalledTracking(tracking); - return { - ok: true, - message: `Установлено: ${item.name} (через ${fallback.provider})`, - detail: wingetResult.detail, - }; - } + // Download failures and hash mismatches go to the package manager; + // a failing installer would most likely fail there too. + if (!plan.fallback || !shouldTryAltProvider(err)) { const classified = classifyError(err); - // #region agent log - debugLog('catalog.ts:install', 'winget failed', { - packageId, - via: fallback.provider, - wingetErrorCode: wingetResult.errorCode, - wingetMessage: wingetResult.message?.slice(0, 120), - primaryCode: classified.code, - }, 'H1'); - // #endregion return { ok: false, - errorCode: wingetResult.errorCode ?? classified.code, + errorCode: classified.code, message: `Ошибка установки ${item.name}`, - detail: [ - classified.detail || classified.message, - `Запасной ${fallback.provider}: ${wingetResult.detail || wingetResult.message}`, - ] - .filter(Boolean) - .join('\n'), + detail: classified.detail || classified.message, }; } + primaryError = err; } + } - const classified = classifyError(err); - // #region agent log - debugLog('catalog.ts:install', 'install failed', { - packageId, - errorCode: classified.code, - hasFallback: Boolean(fallback), - }, 'H2'); - // #endregion - return { - ok: false, - errorCode: classified.code, - message: - classified.code === 'cancelled' - ? 'Установка отменена' - : `Ошибка установки ${item.name}`, - detail: classified.detail || classified.message, - }; + const { provider, packageId: targetId } = plan.fallback!; + // Dynamic import avoids a catalog ↔ registry cycle at module load. + const registry = await import('./registry.js'); + registry.initProviders(); + const target = registry.getProvider(provider); + if (!target) { + return { ok: false, errorCode: 'not_found', message: `Провайдер ${provider} недоступен` }; } + opts.onProgress?.({ phase: 'download', note: `${provider}:${targetId}` }); + const result = await target.install(targetId, opts); + if (result.ok) { + markInstalled(packageId); + return { ok: true, message: `Установлено: ${item.name} (через ${provider})`, detail: result.detail }; + } + const primary = primaryError ? classifyError(primaryError) : undefined; + return { + ok: false, + errorCode: result.errorCode ?? primary?.code, + message: `Ошибка установки ${item.name}`, + detail: [primary && (primary.detail || primary.message), `${provider}: ${result.detail || result.message}`] + .filter(Boolean) + .join('\n'), + }; } async listInstalled(): Promise> { @@ -670,4 +765,5 @@ export function loadCuratedCatalog(): CuratedEntry[] { export function invalidateCatalogCache(): void { catalogCache = null; wingetMapCache = null; + installersCache = null; } diff --git a/electron/providers/install-errors.ts b/electron/providers/install-errors.ts index bc37165..131a1b0 100644 --- a/electron/providers/install-errors.ts +++ b/electron/providers/install-errors.ts @@ -7,6 +7,7 @@ export type InstallErrorCode = | 'timeout' | 'http' | 'installer' + | 'untrusted' | 'unknown'; export class InstallError extends Error { @@ -87,6 +88,7 @@ export function classifyError(err: unknown): { timeout: 'Превышено время ожидания', http: `Ошибка загрузки (HTTP ${status})`, installer: 'Ошибка установщика', + untrusted: 'Установщик не прошёл проверку подлинности', unknown: 'Неизвестная ошибка', }; return { code, message: labels[code], detail: msg }; @@ -109,7 +111,7 @@ export function isRetryableDownloadError(err: unknown): boolean { return code === 'timeout' || code === 'network' || code === 'http'; } -/** Whether catalog.install should attempt altProvider (winget/scoop) after direct download fails. */ +/** Whether catalog.install should hand over to the package manager after the direct download fails. */ export function shouldTryAltProvider(err: unknown): boolean { if (isCancelledError(err)) return false; if (err instanceof InstallError) { @@ -118,7 +120,8 @@ export function shouldTryAltProvider(err: unknown): boolean { err.code === 'network' || err.code === 'http' || err.code === 'forbidden' || - err.code === 'not_found' + err.code === 'not_found' || + err.code === 'untrusted' ); } const { code } = classifyError(err); diff --git a/electron/providers/scoop.ts b/electron/providers/scoop.ts index 3550997..0a41304 100644 --- a/electron/providers/scoop.ts +++ b/electron/providers/scoop.ts @@ -9,7 +9,7 @@ import type { PackageProvider, ProviderStatus, } from './types.js'; -import { getRuntimePaths, testScoop, runCmd, loadRuntimeState } from '../bootstrap/runtime.js'; +import { getRuntimePaths, testScoop, loadRuntimeState } from '../bootstrap/runtime.js'; import { runCliWithProgress } from './cli-progress.js'; import { classifyError } from './install-errors.js'; import { getAppDataDir } from '../paths.js'; diff --git a/electron/providers/types.ts b/electron/providers/types.ts index 0ddc1b3..02a518a 100644 --- a/electron/providers/types.ts +++ b/electron/providers/types.ts @@ -9,8 +9,6 @@ export interface Package { version?: string; category?: string; sourceUrl?: string; - installUrl?: string; - silentArgs?: string; wikiTitle?: string; logoSlug?: string; simpleSlug?: string; @@ -89,18 +87,12 @@ export interface CuratedEntry { /** Vendor site; its favicon is the icon of last resort. */ homepage?: string; recommended?: boolean; - installUrl?: string; - /** Extra direct download mirrors tried after installUrl fails. */ - installUrlFallbacks?: string[]; - /** - * When every catalog download URL fails (CDN reset / geo-block), install via - * another provider instead of leaving the user stuck. - * Legacy per-app override; winget-map.json covers the rest of the catalog. - */ - altProvider?: { provider: string; packageId: string }; - /** Skip winget fallback (no package in winget store). */ - noWingetFallback?: boolean; - silentArgs?: string; +} + +/** data/related.json — catalog ids that go together or replace each other. */ +export interface RelatedData { + stacks: Array<{ id: string; apps: string[] }>; + alternatives: Array<{ id: string; apps: string[] }>; } export interface PickEntry { diff --git a/electron/queue/install-queue.ts b/electron/queue/install-queue.ts index 551f994..46c40e0 100644 --- a/electron/queue/install-queue.ts +++ b/electron/queue/install-queue.ts @@ -3,8 +3,7 @@ import crypto from 'node:crypto'; import type { InstallProgress, QueueJob } from '../providers/types.js'; import { getProvider } from '../providers/registry.js'; import { getSettings } from '../store/settings.js'; -import { classifyError, isCancelledError } from '../providers/install-errors.js'; -import { debugLog } from '../debug-log.js'; +import { classifyError } from '../providers/install-errors.js'; const PROGRESS_EMIT_MS = 250; @@ -207,15 +206,6 @@ export class InstallQueue extends EventEmitter { } } - // #region agent log - debugLog('install-queue.ts:processNext', 'job finished', { - jobId: next.id, - packageId: next.packageId, - provider: next.provider, - status: next.status, - errorCode: next.errorCode, - }, 'H5'); - // #endregion this.finishJob(next.id); } diff --git a/eslint.config.js b/eslint.config.js index 895ea65..066e614 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,41 @@ -export default [ +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import reactHooks from 'eslint-plugin-react-hooks'; +import globals from 'globals'; + +export default tseslint.config( { - ignores: ['dist/**', 'dist-electron/**', 'release/**', 'node_modules/**'], + ignores: ['dist/**', 'dist-electron/**', 'release/**', 'node_modules/**', 'build/**', 'data/**'], }, -]; + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + // `_name` marks a parameter that is required by a signature but unused. + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + // Empty catch blocks are deliberate best-effort cleanup throughout the main process. + 'no-empty': ['error', { allowEmptyCatch: true }], + }, + }, + { + files: ['electron/**/*.ts', 'vite.config.ts'], + languageOptions: { globals: globals.node }, + }, + { + files: ['src/**/*.{ts,tsx}'], + languageOptions: { globals: globals.browser }, + plugins: { 'react-hooks': reactHooks }, + rules: reactHooks.configs.recommended.rules, + }, + { + files: ['scripts/**/*.cjs'], + languageOptions: { sourceType: 'commonjs', globals: globals.node }, + rules: { '@typescript-eslint/no-require-imports': 'off' }, + }, + { + // Playwright scripts: Node, plus browser globals inside page.evaluate callbacks. + files: ['promo/**/*.cjs'], + languageOptions: { sourceType: 'commonjs', globals: { ...globals.node, ...globals.browser } }, + rules: { '@typescript-eslint/no-require-imports': 'off' }, + }, +); diff --git a/package-lock.json b/package-lock.json index 0a6e244..5921837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "getbox", - "version": "1.1.0", + "version": "1.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "getbox", - "version": "1.1.0", + "version": "1.2.5", "dependencies": { "electron-store": "^10.0.0" }, "devDependencies": { + "@eslint/js": "^9.39.5", "@resvg/resvg-js": "^2.6.2", "@types/node": "^22.10.0", "@types/react": "^19.0.0", @@ -19,9 +20,13 @@ "electron": "^33.2.0", "electron-builder": "^25.1.8", "eslint": "^9.16.0", + "eslint-plugin-react-hooks": "^5.2.0", + "globals": "^15.15.0", + "js-yaml": "^4.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.7.2", + "typescript-eslint": "^8.71.1", "vite": "^6.0.3", "vite-plugin-electron": "^0.28.8", "vite-plugin-electron-renderer": "^0.14.6" @@ -1293,6 +1298,19 @@ "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2559,6 +2577,262 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -4743,6 +5017,19 @@ } } }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, "node_modules/eslint-scope": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", @@ -5373,9 +5660,9 @@ } }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", "dev": true, "license": "MIT", "engines": { @@ -7929,6 +8216,19 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7968,6 +8268,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", diff --git a/package.json b/package.json index e867ec2..e1affd5 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,22 @@ { "name": "getbox", - "version": "1.2.0", + "version": "1.2.5", "description": "GetBox — локальный магазин программ для Windows", "main": "dist-electron/main.js", "type": "module", "scripts": { "dev": "vite", - "build": "node scripts/render-app-icon.cjs && node scripts/fetch-icons.cjs && node -e \"const fs=require('fs');const d='data/bootstrap';fs.mkdirSync(d,{recursive:true});fs.copyFileSync('electron/bootstrap/scoop-zip-install.ps1',d+'/scoop-zip-install.ps1');fs.copyFileSync('electron/bootstrap/scoop-zip-install.ps1','dist-electron/scoop-zip-install.ps1');\" && tsc -p tsconfig.node.json && vite build", + "build": "node scripts/render-app-icon.cjs && node scripts/fetch-icons.cjs && node -e \"const fs=require('fs');const d='data/bootstrap';fs.mkdirSync(d,{recursive:true});fs.mkdirSync('dist-electron',{recursive:true});fs.copyFileSync('electron/bootstrap/scoop-zip-install.ps1',d+'/scoop-zip-install.ps1');fs.copyFileSync('electron/bootstrap/scoop-zip-install.ps1','dist-electron/scoop-zip-install.ps1');\" && tsc -p tsconfig.node.json && vite build", "electron:dev": "vite", "dist": "npm run build && electron-builder --win portable", "index": "electron . --index-rebuild", "prewarm": "electron . --prewarm-meta", "test": "node scripts/verify-catalog.cjs && node scripts/smoke.cjs && node scripts/test-download.cjs && node scripts/test-resilience.cjs && node scripts/generate-winget-map.cjs --verify && node scripts/test-app-full.cjs", "verify:catalog": "node scripts/verify-catalog.cjs", + "catalog:installers": "node --no-warnings scripts/refresh-installers.cjs", "winget:map": "node scripts/generate-winget-map.cjs", "winget:map:verify": "node scripts/generate-winget-map.cjs --verify", - "lint": "eslint . --ext .ts,.tsx" + "lint": "eslint ." }, "build": { "appId": "com.getbox.app", @@ -52,6 +53,7 @@ "electron-store": "^10.0.0" }, "devDependencies": { + "@eslint/js": "^9.39.5", "@resvg/resvg-js": "^2.6.2", "@types/node": "^22.10.0", "@types/react": "^19.0.0", @@ -60,9 +62,13 @@ "electron": "^33.2.0", "electron-builder": "^25.1.8", "eslint": "^9.16.0", + "eslint-plugin-react-hooks": "^5.2.0", + "globals": "^15.15.0", + "js-yaml": "^4.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.7.2", + "typescript-eslint": "^8.71.1", "vite": "^6.0.3", "vite-plugin-electron": "^0.28.8", "vite-plugin-electron-renderer": "^0.14.6" diff --git a/promo/.gitignore b/promo/.gitignore new file mode 100644 index 0000000..e050f39 --- /dev/null +++ b/promo/.gitignore @@ -0,0 +1,7 @@ +# Rendered output and captured frames — regenerate with the steps in README.md. +shots/ +*.mp4 +*.wav +still-*.png +contact*.png +spec.png diff --git a/promo/README.md b/promo/README.md new file mode 100644 index 0000000..3ba51ad --- /dev/null +++ b/promo/README.md @@ -0,0 +1,25 @@ +# Промо-ролики GetBox + +- `promo.html` — основной ролик: 40 с, 1920×1080, 30 fps. +- `shorts.html` — вертикальная версия для Shorts / Reels: 30 с, 1080×1920, 60 fps, монтаж по долям 128 BPM. + +Кадры интерфейса — настоящий renderer GetBox на тестовых данных из `data/`. + +```bash +npm run build # собрать dist/ (renderer) +node promo/capture.cjs # скриншоты интерфейса → promo/shots/ +node promo/render.cjs stills promo.html 12 23.5 # (по желанию) отдельные кадры для проверки +node promo/render.cjs video promo.html # покадровый рендер → promo/promo.mp4 +python3 promo/music.py # фоновая музыка → promo/music.wav (нужен numpy) +ffmpeg -i promo/promo.mp4 -i promo/music.wav -c:v copy -c:a aac -b:a 192k -shortest promo/GetBox-promo.mp4 + +# Shorts +node promo/render.cjs video shorts.html +python3 promo/music-shorts.py +ffmpeg -i promo/shorts.mp4 -i promo/music-shorts.wav -c:v copy -af loudnorm=I=-13:TP=-1 -c:a aac -b:a 192k -shortest promo/GetBox-shorts.mp4 +``` + +Нужны Playwright с Chromium (`PLAYWRIGHT_MODULE`, `CHROMIUM_PATH` — если стоят не в стандартном месте) и ffmpeg с libx264. + +- `promo.html` — сцены; `render(t)` задаёт состояние кадра по времени, поэтому рендер детерминированный. Тексты и тайминги — там же. +- `music.py`, `music-shorts.py` — синтезированная музыка (96 и 128 BPM, Am–F–C–G), без сторонних сэмплов. diff --git a/promo/capture.cjs b/promo/capture.cjs new file mode 100644 index 0000000..d27310f --- /dev/null +++ b/promo/capture.cjs @@ -0,0 +1,66 @@ +// Capture real GetBox UI states for the promo: the built renderer (npm run build) +// with window.getbox stubbed from data/*.json. Writes promo/shots/*.png. +// Playwright is not a project dependency: npx playwright, or point PLAYWRIGHT_MODULE at an install. +const { chromium } = require(process.env.PLAYWRIGHT_MODULE ?? 'playwright'); +const launchOpts = process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}; +const fs = require('fs'); const path = require('path'); const http = require('http'); +const ROOT = path.join(__dirname, '..'); const OUT = path.join(__dirname, 'shots'); +const read = (f) => JSON.parse(fs.readFileSync(path.join(ROOT, 'data', f), 'utf-8')); +const catalog = read('catalog.json'), picks = read('picks.json'), installers = read('installers.json'); +const pickMap = new Map(picks.map((p) => [p.packageRef, p])); +const iconFor = (c) => { for (const s of [c.logoSlug, c.id]) for (const e of ['.svg', '.png', '.jpg']) if (s && fs.existsSync(path.join(ROOT, 'data/icons', s + e))) return `/data/icons/${s}${e}`; }; +function buildData(owned, lang) { + const apps = catalog.map((c) => { const p = pickMap.get(`catalog:${c.packageId}`); const en = lang === 'en'; + return { id: c.packageId, provider: 'catalog', name: c.name, description: (en ? p?.descriptionOverlayEn : p?.descriptionOverlay) ?? c.description, + category: c.category, features: c.features, recommended: c.recommended, curatedId: c.id, isInstalled: owned.has(c.id), isFavorite: c.id === 'obsidian', + iconUrl: iconFor(c), pickTag: en ? p?.tagEn : p?.tag, pickWhy: en ? p?.whyEn : p?.why, version: installers[c.id]?.version }; }); + return { apps, categories: read('categories.json'), profiles: read('profiles.json'), related: read('related.json') }; +} +const QUEUE = [ + { id: 'j1', packageId: 'firefox', provider: 'catalog', name: 'Mozilla Firefox', status: 'running', progress: { phase: 'download', percent: 64, bytesDone: 61e6, bytesTotal: 95e6, bytesPerSecond: 11.8e6 } }, + { id: 'j2', packageId: 'discord', provider: 'catalog', name: 'Discord', status: 'pending' }, + { id: 'j3', packageId: 'obs-studio', provider: 'catalog', name: 'OBS Studio', status: 'pending' }, + { id: 'j4', packageId: 'vscode', provider: 'catalog', name: 'Visual Studio Code', status: 'done', message: 'Установлено' }, + { id: 'j5', packageId: '7zip', provider: 'catalog', name: '7-Zip', status: 'done', message: 'Установлено' }, +]; +const server = http.createServer((req, res) => { + const file = path.join(ROOT, decodeURIComponent(req.url.split('?')[0])); + if (!file.startsWith(ROOT) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) { res.writeHead(404); return res.end(); } + const type = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg' }[path.extname(file)] ?? 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': type }); fs.createReadStream(file).pipe(res); +}); +async function shot(browser, base, name, { owned = [], lang = 'ru', queue = [], act }) { + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 }); + await page.addInitScript(({ d, lang, queue }) => { + const ok = async () => ({ ok: true }); + const values = { + getCatalog: async () => d, getSettings: async () => ({ enabledProviders: ['catalog', 'scoop', 'winget', 'chocolatey'], quietInstall: true, language: lang }), + getProvidersStatus: async () => [['catalog', 'Каталог GetBox'], ['scoop', 'Scoop'], ['winget', 'winget'], ['chocolatey', 'Chocolatey']].map(([id, displayName]) => ({ id, displayName, ok: true })), + getQueue: async () => queue, + getBootstrapStatus: async () => ({ complete: true, running: false, state: {}, tools: { scoop: { status: 'ok', ok: true }, winget: { status: 'ok', ok: true }, chocolatey: { status: 'ok', ok: true } } }), + getMetaProgress: async () => null, enrichMeta: async () => [], search: async () => ({ results: [] }), + }; + window.getbox = new Proxy({}, { get: (_t, k) => values[k] ?? (String(k).startsWith('on') ? () => () => {} : ok) }); + }, { d: buildData(new Set(owned), lang), lang, queue }); + await page.goto(`${base}/dist/index.html`); + await page.waitForTimeout(1200); + if (act) await act(page); + await page.waitForTimeout(500); + await page.screenshot({ path: path.join(OUT, `${name}.png`) }); + await page.close(); + console.log('shot', name); +} +server.listen(0, async () => { + const base = `http://127.0.0.1:${server.address().port}`; + const browser = await chromium.launch(launchOpts); + const me = ['vscode', 'steam', 'firefox', 'git']; + await shot(browser, base, 'home-cold', {}); + await shot(browser, base, 'home', { owned: me }); + await shot(browser, base, 'home-scrolled', { owned: me, act: (p) => p.locator('.content-area').evaluate((el) => el.scrollTo(0, 330)) }); + await shot(browser, base, 'recommended', { owned: me, act: (p) => p.getByRole('button', { name: /Рекомендуем/ }).first().click() }); + await shot(browser, base, 'games', { owned: me, act: (p) => p.locator('.sidebar').getByText('Игры').first().click() }); + await shot(browser, base, 'modal', { owned: me, act: async (p) => { await p.getByRole('button', { name: /Рекомендуем/ }).first().click(); await p.waitForTimeout(300); await p.locator('.capsule', { hasText: 'Discord' }).first().click(); } }); + await shot(browser, base, 'queue', { owned: me, queue: QUEUE, act: (p) => p.getByRole('button', { name: /Очередь/ }).first().click() }); + await shot(browser, base, 'home-en', { owned: me, lang: 'en' }); + await browser.close(); server.close(); +}); diff --git a/promo/music-shorts.py b/promo/music-shorts.py new file mode 100644 index 0000000..5d3008e --- /dev/null +++ b/promo/music-shorts.py @@ -0,0 +1,177 @@ +"""Procedural 30 s track for the vertical Shorts cut: 128 bpm, drop on bar 3. + +Sections follow promo/shorts.html: bars 1-2 filtered intro with a click per +popping dialog, riser, drop at bar 3, full beat to bar 13, crash on the scene +hits (bars 6, 10, 14) and a final hit with a ringing tail. +""" +import os +import wave + +import numpy as np + +SR, BPM = 44100, 128 +B = 60 / BPM +BAR = 4 * B +DUR = 16 * BAR +N = int(SR * DUR) +t = np.arange(N) / SR +L = np.zeros(N) +R = np.zeros(N) +rng = np.random.default_rng(11) + + +def hz(n): + return 440 * 2 ** ((n - 69) / 12) + + +def lowpass(x, cutoff): + """One-pole low-pass; cutoff may be a scalar or a per-sample array.""" + cutoff = np.broadcast_to(np.asarray(cutoff, dtype=float), x.shape) + a = np.exp(-2 * np.pi * cutoff / SR) + y = np.empty_like(x) + acc = 0.0 + for i in range(len(x)): + acc = (1 - a[i]) * x[i] + a[i] * acc + y[i] = acc + return y + + +def place(buf, start, sig, gain=1.0): + s = int(start * SR) + e = min(s + len(sig), len(buf)) + if 0 <= s < len(buf): + buf[s:e] += sig[: e - s] * gain + + +def both(start, sig, gain=1.0, pan=0.0): + place(L, start, sig, gain * (1 - pan)) + place(R, start, sig, gain * (1 + pan)) + + +# Am – F – C – G, one chord per bar. +CHORDS = [[57, 60, 64], [53, 57, 60], [48, 52, 55], [55, 59, 62]] +DROP, END_BEAT = 2 * BAR, 13 * BAR + +# Sidechain pump: every beat during the drop. +pump = np.ones(N) +for i in range(int(DUR / B)): + at = i * B + if DROP <= at < END_BEAT: + s = int(at * SR) + e = min(s + int(B * SR), N) + x = np.arange(e - s) / SR + pump[s:e] = 1 - 0.65 * np.exp(-x * 14) + +# Pad: detuned supersaw, opening filter through the intro. +pad = np.zeros(N) +for bar in range(16): + chord = CHORDS[bar % 4] if bar < 15 else [57, 64, 69] + s, e = int(bar * BAR * SR), min(int((bar + 1) * BAR * SR + 0.3 * SR), N) + tt = t[s:e] - t[s] + for note in chord: + for det in (-0.15, 0.0, 0.15): + f = hz(note + det) + pad[s:e] += sum(np.sin(2 * np.pi * f * k * tt + det * k) / k for k in range(1, 7)) * 0.03 +cut = np.where(t < DROP, 300 + 2500 * (t / DROP) ** 2, 3200) +pad = lowpass(pad, cut) * pump * np.where(t >= END_BEAT, 1.6, 1.0) +L += pad +R += pad + +# Bass: root on 8ths, saw through a low-pass, pumped. +bass = np.zeros(N) +for i in range(int(DUR / (B / 2))): + at = i * B / 2 + if not (DROP <= at < END_BEAT): + continue + root = CHORDS[int(at // BAR) % 4][0] - 24 + tt = np.arange(int(B / 2 * SR)) / SR + f = hz(root) + saw = 2 * (tt * f - np.floor(tt * f + 0.5)) + bass_note = saw * np.exp(-tt * 3) * (1 if i % 2 else 0.8) + place(bass, at, bass_note) +bass = lowpass(bass, 420) * pump +L += bass * 0.55 +R += bass * 0.55 + +# Pluck lead: 16th arpeggio in the drop. +for i in range(int(DUR / (B / 4))): + at = i * B / 4 + if not (DROP <= at < END_BEAT): + continue + chord = CHORDS[int(at // BAR) % 4] + seq = [chord[0] + 12, chord[2] + 12, chord[1] + 24, chord[2] + 12, chord[0] + 24, chord[1] + 12] + n = seq[i % len(seq)] + tt = np.arange(int(0.25 * SR)) / SR + tone = (np.sin(2 * np.pi * hz(n) * tt) + 0.4 * np.sin(4 * np.pi * hz(n) * tt)) * np.exp(-tt * 14) + both(at, tone, 0.06, pan=0.4 if i % 2 else -0.4) + +# Drums. +def kick(): + tt = np.arange(int(0.32 * SR)) / SR + f = 48 + 110 * np.exp(-tt * 38) + return np.tanh(np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 8) * 1.6) + + +def clap(): + tt = np.arange(int(0.22 * SR)) / SR + env = sum(np.exp(-np.clip(tt - d, 0, None) * 60) * (tt >= d) for d in (0, 0.011, 0.023)) + return lowpass(rng.standard_normal(len(tt)), 2500) * env + + +def hat(length=0.05, decay=90): + tt = np.arange(int(length * SR)) / SR + return np.diff(rng.standard_normal(len(tt) + 1)) * np.exp(-tt * decay) + + +def crash(): + tt = np.arange(int(1.6 * SR)) / SR + return np.diff(rng.standard_normal(len(tt) + 1)) * np.exp(-tt * 2.6) + + +K, C = kick(), clap() +for i in range(int(DUR / B)): + at = i * B + if DROP <= at < END_BEAT: + both(at, K, 0.55) + if i % 2 == 1: + both(at, C, 0.22) + both(at + B / 2, hat(0.09, 40), 0.05, pan=0.2) + both(at + B / 4, hat(), 0.025, pan=-0.3) + both(at + 3 * B / 4, hat(), 0.025, pan=-0.3) + +# Intro: a "pop" per dialog (beats 1–7) and a riser into the drop. +for i in range(1, 8): + tt = np.arange(int(0.12 * SR)) / SR + f = 900 + 500 * np.exp(-tt * 40) + pop_ = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 30) + both(i * B, pop_, 0.18, pan=0.3 if i % 2 else -0.3) +rs = BAR + B +tt = np.arange(int((DROP - rs) * SR)) / SR +riser = lowpass(rng.standard_normal(len(tt)), 200 + 6000 * (tt / tt[-1]) ** 2) * (tt / tt[-1]) ** 2 +both(rs, riser, 0.35) + +# Crashes on scene hits + the final hit. +for at in (DROP, 5 * BAR, 9 * BAR): + both(at, crash(), 0.12) +fin = END_BEAT +both(fin, crash(), 0.16) +both(fin, K, 0.6) +tt = np.arange(int((DUR - fin) * SR)) / SR +for note in (45, 57, 64, 69): + both(fin, np.sin(2 * np.pi * hz(note) * tt) * np.exp(-tt * 0.55), 0.06) + +# Master: gentle saturation, short fade-out, normalise. +fade = np.ones(N) +fade[-int(1.2 * SR):] = np.linspace(1, 0, int(1.2 * SR)) +L = np.tanh(L * 1.2) * fade +R = np.tanh(R * 1.2) * fade +peak = max(np.abs(L).max(), np.abs(R).max()) +L, R = L / peak * 0.89, R / peak * 0.89 +pcm = (np.stack([L, R], 1) * 32767).astype(' + + + +GetBox promo + + + +
+
+
+
+ + +
+ +
GetBox
+
Магазин программ для Windows
+
+ + +
+
Знакомо?
+
+
Искать установщики по сайтам
+
Десять раз нажимать «Далее»
+
Бояться скачать не то
+
+
Есть способ проще.
+
+ + +
+
Каталог
118 проверенных программ — в один клик
+
+
+ + +
+
По полочкам
Игры, браузеры, разработка, медиа
+
+
+ + +
+
Очередь установок
Нажали — и занимайтесь своими делами
+
+
+
Тихая установка, без мастеров
+
SHA256 сверяется до запуска
+
+ + +
+
Рекомендации
Подбирает программы под вас
+
+
+
+ + +
+
Ставят вместе
Подскажет, что пригодится — и чем заменить
+
+
+
+ + +
+
Под капотом
+
Надёжно, как и должно быть
+
+
WОфициальные источники
Установщики из манифестов winget — без зеркал и «репаков»
+
#Проверка SHA256
Файл с чужим хэшем никогда не запустится
+
↻Запасной путь
Сайт недоступен — поставит через winget, Scoop или Chocolatey
+
⚑Portable и RU / EN
Один .exe, ничего не нужно ставить заранее
+
+
+ + +
+ +
GetBox
+
Скачали GetBox.exe — готово.
+
Скачать бесплатно
+
github.com/IncludeSpecial/GetBox
+
+ +
+
+ + + + diff --git a/promo/render.cjs b/promo/render.cjs new file mode 100644 index 0000000..c6c87ef --- /dev/null +++ b/promo/render.cjs @@ -0,0 +1,52 @@ +// Render a promo page frame by frame (deterministic) and pipe the frames into ffmpeg. +// node promo/render.cjs video [page.html] → promo/.mp4 (no audio) +// node promo/render.cjs stills [page.html] t1 t2 … → promo/still--.png +// The page sets window.FORMAT = { width, height, fps, duration } and window.render(t). +// Playwright is not a project dependency: npx playwright, or point PLAYWRIGHT_MODULE at an install. +const { chromium } = require(process.env.PLAYWRIGHT_MODULE ?? 'playwright'); +const launchOpts = process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}; +const http = require('http'); const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); +const DIR = __dirname; +const ROOT = path.join(__dirname, '..'); +const [mode = 'video', pageArg, ...rest] = process.argv.slice(2); +const pageFile = pageArg && pageArg.endsWith('.html') ? pageArg : 'promo.html'; +const times = (pageArg && !pageArg.endsWith('.html') ? [pageArg, ...rest] : rest).map(Number); +const pageName = path.basename(pageFile, '.html'); + +const server = http.createServer((req, res) => { + const f = path.join(ROOT, decodeURIComponent(req.url.split('?')[0])); + if (!f.startsWith(ROOT) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); return res.end(); } + const type = { '.html': 'text/html', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.ico': 'image/x-icon' }; + res.writeHead(200, { 'Content-Type': type[path.extname(f)] ?? 'application/octet-stream' }); + fs.createReadStream(f).pipe(res); +}).listen(0, async () => { + const browser = await chromium.launch(launchOpts); + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${server.address().port}/promo/${pageFile}`); + const format = await page.evaluate(() => window.FORMAT ?? { width: 1920, height: 1080, fps: 30, duration: window.DURATION }); + await page.setViewportSize({ width: format.width, height: format.height }); + await page.evaluate(() => Promise.all([...document.images].map((i) => i.decode().catch(() => {})))); + await page.evaluate(() => document.fonts.ready); + + if (mode === 'stills') { + for (const t of times) { + await page.evaluate((t) => window.render(t), t); + await page.screenshot({ path: path.join(DIR, `still-${pageName}-${t}.png`) }); + } + } else { + const out = path.join(DIR, `${pageName}.mp4`); + const ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(format.fps), '-c:v', 'mjpeg', '-i', '-', + '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', out], { stdio: ['pipe', 'inherit', 'inherit'] }); + const frames = Math.round(format.duration * format.fps); + for (let i = 0; i < frames; i++) { + await page.evaluate((t) => window.render(t), i / format.fps); + const buf = await page.screenshot({ type: 'jpeg', quality: 95 }); + if (!ff.stdin.write(buf)) await new Promise((r) => ff.stdin.once('drain', r)); + if (i % 300 === 0) console.log(`frame ${i}/${frames}`); + } + ff.stdin.end(); + await new Promise((r) => ff.on('close', r)); + console.log(out); + } + await browser.close(); server.close(); +}); diff --git a/promo/shorts.html b/promo/shorts.html new file mode 100644 index 0000000..bdab1ac --- /dev/null +++ b/promo/shorts.html @@ -0,0 +1,408 @@ + + + + +GetBox Shorts + + + +
+
+
+
+
+ + +
+
Знакомо?
+
Ставишь программы
вот так?
+
+
+ + +
+ +
GetBox
+
Все программы — в одном месте
+
+ + +
+
0
+
программ
в один клик
+
+
+ + +
+
1 клик.
+
Без «Далее».
Без мастеров.
+
+
+
+
+ +
+
✓ Установлено
+
+ + +
+
+
+
+ + +
+
Рекомендации
+
Знает, что тебе
пригодится
+
+
Вместе с Visual Studio Code
+
+ + +
+
Без репаков
и сомнительных зеркал
+
W
Официальные источники
Манифесты winget
+
#
Проверка SHA256
Чужой файл не запустится
+
↻
Запасной путь
winget · Scoop · Chocolatey
+
+ + +
+ +
GetBox
+
Бесплатно · Windows 10/11
Один .exe — и готово
+
Ссылка в описании
+
+
+
+
+
+ + + + diff --git a/scripts/generate-winget-map.cjs b/scripts/generate-winget-map.cjs index 6405958..03b61f5 100644 --- a/scripts/generate-winget-map.cjs +++ b/scripts/generate-winget-map.cjs @@ -14,6 +14,7 @@ const DATA = path.join(ROOT, 'data'); const CATALOG_PATH = path.join(DATA, 'catalog.json'); const OVERRIDES_PATH = path.join(DATA, 'winget-overrides.json'); const MAP_PATH = path.join(DATA, 'winget-map.json'); +const INSTALLERS_PATH = path.join(DATA, 'installers.json'); const VERIFY = process.argv.includes('--verify'); const DRY_RUN = process.argv.includes('--dry-run'); @@ -107,17 +108,14 @@ function pickBestMatch(entry, results) { return results[0]?.id ?? null; } -function seedFromCatalog(catalog) { - const map = {}; - for (const entry of catalog) { - if (entry.altProvider?.provider === 'winget' && entry.altProvider.packageId) { - map[entry.id] = entry.altProvider.packageId; - } - } - return map; +/** Apps hand-pinned to another package manager in installers.json need no winget Id. */ +function pinnedElsewhere() { + const installers = readJson(INSTALLERS_PATH, {}); + return new Set(Object.keys(installers).filter((id) => installers[id].provider)); } function validateMapStructure(catalog, map) { + const pinned = pinnedElsewhere(); let errors = 0; const orphans = Object.keys(map).filter((id) => !catalog.some((e) => e.id === id)); for (const id of orphans) { @@ -125,9 +123,9 @@ function validateMapStructure(catalog, map) { } for (const entry of catalog) { - if (entry.noWingetFallback) continue; + if (pinned.has(entry.id)) continue; if (map[entry.id]) continue; - console.error(` x ${entry.id}: missing winget-map entry and no noWingetFallback`); + console.error(` x ${entry.id}: missing winget-map entry and not pinned to another provider in installers.json`); errors++; } @@ -139,18 +137,18 @@ function validateMapStructure(catalog, map) { } const withFallback = catalog.filter( - (e) => !e.noWingetFallback && (map[e.id] || e.altProvider?.provider === 'winget'), + (e) => !pinned.has(e.id) && map[e.id], ); console.log(`\n coverage: ${withFallback.length}/${catalog.length} apps with winget fallback`); - console.log(` noWingetFallback: ${catalog.filter((e) => e.noWingetFallback).length}`); + console.log(` pinned to another provider: ${pinned.size}`); return errors; } function buildMap(catalog, overrides, useWinget) { - const map = seedFromCatalog(catalog); - Object.assign(map, overrides); + const pinned = pinnedElsewhere(); + const map = { ...overrides }; - const pending = catalog.filter((e) => !e.noWingetFallback && !map[e.id]); + const pending = catalog.filter((e) => !pinned.has(e.id) && !map[e.id]); if (pending.length && useWinget) { console.log(`\n searching winget for ${pending.length} unmapped apps…`); for (const entry of pending) { @@ -193,7 +191,7 @@ function main() { console.log(`winget-map: ${catalog.length} catalog apps`); if (VERIFY) { - const map = { ...existing, ...seedFromCatalog(catalog), ...overrides }; + const map = { ...existing, ...overrides }; const errors = validateMapStructure(catalog, map); if (errors) { console.error(`\n${errors} error(s)\n`); @@ -209,7 +207,7 @@ function main() { const map = buildMap(catalog, overrides, useWinget); const errors = validateMapStructure(catalog, map); if (errors) { - console.error(`\n${errors} structural error(s) — fix overrides or mark noWingetFallback in catalog\n`); + console.error(`\n${errors} structural error(s) — fix winget-overrides.json or pin a provider in installers.json\n`); process.exit(1); } diff --git a/scripts/refresh-installers.cjs b/scripts/refresh-installers.cjs new file mode 100644 index 0000000..318b6c8 --- /dev/null +++ b/scripts/refresh-installers.cjs @@ -0,0 +1,293 @@ +/** + * Generate data/installers.json — how GetBox installs each catalog app — + * from the official winget-pkgs manifests on cdn.winget.microsoft.com. + * + * The catalog itself holds no download links: every app maps to a winget Id + * (data/winget-map.json), and this script resolves that Id to the latest + * installer URL, its SHA256, type and silent switches. GetBox downloads the + * file directly and refuses it unless the hash matches; apps whose only + * installer is MSIX/zip/portable are left to the winget client. Apps missing + * from winget get a hand-written entry, e.g. { "provider": "chocolatey", + * "packageId": "filezilla" }, which this script leaves untouched. + * + * Chain (the same one the winget client uses for its "winget" source): + * source2.msix → Public/index.db (packages: id, latest_version, hash) + * → packages///versionData.mszyml (versions → manifest path) + * → (merged YAML manifest) + * + * Needs Node 22.5+ (node:sqlite). No winget, no GitHub API, no rate limits. + * + * Run: node scripts/refresh-installers.cjs [--check] [id ...] + * --check do not write; exit 1 if installers.json is out of date + */ +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); +const os = require('node:os'); +const yaml = require('js-yaml'); + +const ROOT = path.join(__dirname, '..'); +const DATA = path.join(ROOT, 'data'); +const OUT = path.join(DATA, 'installers.json'); +const CDN = 'https://cdn.winget.microsoft.com/cache'; +const CHECK = process.argv.includes('--check'); +const ONLY = process.argv.slice(2).filter((a) => !a.startsWith('--')); +const CONCURRENCY = 8; + +/** Installer types GetBox can run itself; the rest go through the winget client. */ +const RUNNABLE = { nullsoft: 'exe', inno: 'exe', burn: 'exe', exe: 'exe', msi: 'msi', wix: 'msi' }; +/** Silent switches winget uses when a manifest does not specify its own. */ +const DEFAULT_SILENT = { + nullsoft: '/S', + inno: '/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART', + burn: '/quiet /norestart', + msi: '/quiet /norestart', + wix: '/quiet /norestart', +}; +const ARCH_RANK = { x64: 0, neutral: 1, x86: 2 }; +/** Per-user installs need no UAC prompt, which keeps the queue unattended. */ +const SCOPE_RANK = { user: 0, undefined: 1, machine: 2 }; +const LOCALE_RANK = { 'ru-RU': 0, 'en-US': 1, undefined: 2 }; + +async function get(url) { + for (let attempt = 1; ; attempt++) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`); + return Buffer.from(await res.arrayBuffer()); + } catch (err) { + if (attempt >= 3 || /HTTP 404/.test(err.message)) throw err; + await new Promise((r) => setTimeout(r, 1000 * attempt)); + } + } +} + +/** Pull one file out of a zip (the msix, which is written as zip64) via its central directory. */ +function unzipEntry(zip, name) { + const MAX32 = 0xffffffff; + const eocd = zip.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06])); + if (eocd < 0) throw new Error('zip: no end of central directory'); + let p = zip.readUInt32LE(eocd + 16); + let count = zip.readUInt16LE(eocd + 10); + if (p === MAX32 || count === 0xffff) { + const locator = eocd - 20; + if (zip.readUInt32LE(locator) !== 0x07064b50) throw new Error('zip: no zip64 locator'); + const eocd64 = Number(zip.readBigUInt64LE(locator + 8)); + count = Number(zip.readBigUInt64LE(eocd64 + 32)); + p = Number(zip.readBigUInt64LE(eocd64 + 48)); + } + for (let i = 0; i < count; i++) { + const method = zip.readUInt16LE(p + 10); + let size = zip.readUInt32LE(p + 20); + const rawSize = zip.readUInt32LE(p + 24); + const nameLen = zip.readUInt16LE(p + 28); + const extraLen = zip.readUInt16LE(p + 30); + const commentLen = zip.readUInt16LE(p + 32); + let local = zip.readUInt32LE(p + 42); + // zip64 extra field: 64-bit values for whichever 32-bit fields are saturated, in this order. + for (let e = p + 46 + nameLen; e < p + 46 + nameLen + extraLen; e += 4 + zip.readUInt16LE(e + 2)) { + if (zip.readUInt16LE(e) !== 0x0001) continue; + let q = e + 4; + if (rawSize === MAX32) q += 8; + if (size === MAX32) { + size = Number(zip.readBigUInt64LE(q)); + q += 8; + } + if (local === MAX32) local = Number(zip.readBigUInt64LE(q)); + } + if (zip.toString('utf-8', p + 46, p + 46 + nameLen) === name) { + const start = local + 30 + zip.readUInt16LE(local + 26) + zip.readUInt16LE(local + 28); + const data = zip.subarray(start, start + size); + return method === 0 ? data : zlib.inflateRawSync(data); + } + p += 46 + nameLen + extraLen + commentLen; + } + throw new Error(`zip: ${name} not found`); +} + +/** + * Windows Compression API buffer in MSZIP mode: a 24-byte header, then blocks + * of "CK" + raw deflate, each using the previous 32 KB of output as dictionary. + */ +function decodeMszip(buf) { + if (buf.length < 4 || buf.readUInt32LE(0) !== 0xc0e5510a) return buf; + const total = Number(buf.readBigUInt64LE(8)); + const out = []; + let size = 0; + let off = 24; + let dict = Buffer.alloc(0); + while (size < total) { + const len = buf.readUInt32LE(off); + off += 4; + if (buf.toString('latin1', off, off + 2) !== 'CK') throw new Error('mszip: bad block'); + const chunk = zlib.inflateRawSync(buf.subarray(off + 2, off + len), dict.length ? { dictionary: dict } : {}); + out.push(chunk); + size += chunk.length; + dict = Buffer.concat([dict, chunk]).subarray(-32768); + off += len; + } + return Buffer.concat(out); +} + +async function openIndex() { + const { DatabaseSync } = require('node:sqlite'); + const msix = await get(`${CDN}/source2.msix`); + const file = path.join(os.tmpdir(), `getbox-winget-index-${process.pid}.db`); + fs.writeFileSync(file, unzipEntry(msix, 'Public/index.db')); + const db = new DatabaseSync(file, { readOnly: true }); + const stmt = db.prepare('SELECT id, latest_version, hex(hash) AS hash FROM packages WHERE id = ? COLLATE NOCASE'); + return { + lookup: (id) => stmt.get(id), + close: () => { + db.close(); + fs.rmSync(file, { force: true }); + }, + }; +} + +function rank(table, key) { + return table[String(key)] ?? 9; +} + +/** Pick the installer GetBox should use, applying root-level manifest defaults. */ +function pickInstaller(manifest) { + const candidates = (manifest.Installers ?? []).map((inst) => ({ + ...inst, + InstallerType: inst.InstallerType ?? manifest.InstallerType, + Scope: inst.Scope ?? manifest.Scope, + InstallerLocale: inst.InstallerLocale ?? manifest.InstallerLocale, + InstallerSwitches: { ...manifest.InstallerSwitches, ...inst.InstallerSwitches }, + InstallerSuccessCodes: inst.InstallerSuccessCodes ?? manifest.InstallerSuccessCodes, + })); + const usable = candidates.filter((i) => i.Architecture in ARCH_RANK); + const runnable = usable.filter((i) => RUNNABLE[String(i.InstallerType).toLowerCase()]); + const sort = (list) => + list.sort( + (a, b) => + rank(ARCH_RANK, a.Architecture) - rank(ARCH_RANK, b.Architecture) || + rank(SCOPE_RANK, a.Scope) - rank(SCOPE_RANK, b.Scope) || + rank(LOCALE_RANK, a.InstallerLocale) - rank(LOCALE_RANK, b.InstallerLocale), + ); + return { direct: sort(runnable)[0], fallbackType: sort(usable)[0]?.InstallerType }; +} + +function toEntry(wingetId, version, manifest) { + const { direct, fallbackType } = pickInstaller(manifest); + if (!direct) { + return { wingetId, version, type: String(fallbackType ?? 'unknown').toLowerCase(), direct: false }; + } + const type = String(direct.InstallerType).toLowerCase(); + const switches = direct.InstallerSwitches ?? {}; + const silent = [switches.Silent ?? DEFAULT_SILENT[type], switches.Custom].filter(Boolean).join(' ').trim(); + const entry = { + wingetId, + version, + type, + url: direct.InstallerUrl, + sha256: String(direct.InstallerSha256).toLowerCase(), + }; + if (silent) entry.silentArgs = silent; + if (switches.Custom) entry.args = switches.Custom; + if (direct.Scope) entry.scope = direct.Scope; + if (direct.InstallerSuccessCodes?.length) entry.successCodes = direct.InstallerSuccessCodes; + return entry; +} + +/** A mapping typo installs the wrong program, so the catalog name or id must share a word with the package. */ +function namesAgree(app, packageName, wingetId) { + const words = `${app.name} ${app.id}`.toLowerCase().match(/[a-z0-9]{2,}/g) ?? []; + const pkg = `${packageName} ${wingetId}`.toLowerCase().replace(/[^a-z0-9]/g, ''); + return words.some((w) => pkg.includes(w)); +} + +async function resolve(index, app, wingetId) { + const row = index.lookup(wingetId); + if (!row) throw new Error(`${wingetId} is not in the winget index`); + const versionData = yaml.load( + decodeMszip(await get(`${CDN}/packages/${row.id}/${row.hash.slice(0, 8).toLowerCase()}/versionData.mszyml`)).toString( + 'utf-8', + ), + ); + const versions = versionData.vD ?? []; + const latest = versions.find((v) => String(v.v) === row.latest_version) ?? versions[0]; + if (!latest) throw new Error(`${wingetId}: no versions`); + const manifest = yaml.load(decodeMszip(await get(`${CDN}/${latest.rP}`)).toString('utf-8')); + if (!namesAgree(app, manifest.PackageName, row.id)) { + throw new Error(`${wingetId} is "${manifest.PackageName}", not "${app.name}" — check winget-map`); + } + return toEntry(row.id, String(latest.v), manifest); +} + +async function main() { + const catalog = JSON.parse(fs.readFileSync(path.join(DATA, 'catalog.json'), 'utf-8')); + const wingetMap = JSON.parse(fs.readFileSync(path.join(DATA, 'winget-map.json'), 'utf-8')); + const previous = fs.existsSync(OUT) ? JSON.parse(fs.readFileSync(OUT, 'utf-8')) : {}; + const apps = catalog.filter((a) => ONLY.length === 0 || ONLY.includes(a.id)); + + const index = await openIndex(); + const result = { ...previous }; + const failures = []; + let next = 0; + try { + await Promise.all( + Array.from({ length: CONCURRENCY }, async () => { + while (next < apps.length) { + const app = apps[next++]; + // Hand-written entries pin another provider for apps winget does not carry. + if (previous[app.id]?.provider) continue; + const wingetId = wingetMap[app.id]; + if (!wingetId) { + failures.push(`${app.id}: no winget-map entry`); + continue; + } + try { + result[app.id] = await resolve(index, app, wingetId); + } catch (err) { + failures.push(`${app.id}: ${err.message}`); + } + } + }), + ); + } finally { + index.close(); + } + + // Drop apps that left the catalog; keep stable key order for readable diffs. + const known = new Set(catalog.map((a) => a.id)); + const sorted = Object.fromEntries( + Object.keys(result) + .filter((id) => known.has(id)) + .sort() + .map((id) => [id, result[id]]), + ); + + let changed = 0; + for (const [id, entry] of Object.entries(sorted)) { + const before = previous[id]; + if (JSON.stringify(before) === JSON.stringify(entry)) continue; + changed++; + const how = entry.provider + ? `via ${entry.provider}` + : entry.direct === false ? `winget only (${entry.type})` : `${entry.type} ${entry.scope ?? ''}`.trim(); + console.log(`${id.padEnd(24)} ${before?.version ?? '—'} → ${entry.version} ${how}`); + } + for (const f of failures) console.error(` x ${f}`); + + const entries = Object.values(sorted); + const direct = entries.filter((e) => e.url).length; + const winget = entries.filter((e) => e.direct === false).length; + console.log( + `\n${entries.length} apps · ${direct} direct · ${winget} via winget · ${entries.length - direct - winget} other provider · ${changed} changed · ${failures.length} failed`, + ); + + if (CHECK) { + process.exit(changed > 0 || failures.length > 0 ? 1 : 0); + } + fs.writeFileSync(OUT, `${JSON.stringify(sorted, null, 2)}\n`); + if (failures.length > 0) process.exitCode = 1; +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index dcb6f65..53519e1 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -4,7 +4,7 @@ const { strict: assert } = require('node:assert'); const fs = require('node:fs'); const path = require('node:path'); -const crypto = require('node:crypto'); +const { createHash } = require('node:crypto'); let passed = 0; let failed = 0; @@ -104,14 +104,14 @@ const SEARCH_TTL_MS = 14 * 24 * 60 * 60 * 1000; function writeSearchCache(dir, query, results) { fs.mkdirSync(dir, { recursive: true }); - const hash = crypto.createHash('sha256').update(query.toLowerCase().trim()).digest('hex'); + const hash = createHash('sha256').update(query.toLowerCase().trim()).digest('hex'); const entry = { query, results, cachedAt: Date.now() }; fs.writeFileSync(path.join(dir, `${hash}.json`), JSON.stringify(entry)); return hash; } function readSearchCache(dir, query) { - const hash = crypto.createHash('sha256').update(query.toLowerCase().trim()).digest('hex'); + const hash = createHash('sha256').update(query.toLowerCase().trim()).digest('hex'); const p = path.join(dir, `${hash}.json`); if (!fs.existsSync(p)) return null; return JSON.parse(fs.readFileSync(p, 'utf-8')); @@ -210,15 +210,15 @@ test('addMany emits once', () => { }); console.log('\ncatalog:'); -test('catalog.json has curated hits with direct installUrl', () => { +test('catalog.json is curation only, every app has an install source', () => { const catalog = loadCatalog(); assert.ok(catalog.length >= 70, `Expected >= 70 entries, got ${catalog.length}`); - const firefox = catalog.find((c) => c.id === 'firefox'); - assert.ok(firefox, 'firefox entry missing'); - assert.equal(firefox.provider, 'catalog'); - assert.ok(firefox.installUrl, 'firefox must have installUrl'); - const allCatalog = catalog.every((c) => c.provider === 'catalog' && c.installUrl); - assert.ok(allCatalog, 'all catalog entries must be provider=catalog with installUrl'); + const installers = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'installers.json'), 'utf-8')); + for (const c of catalog) { + assert.equal(c.provider, 'catalog', c.id); + assert.ok(!('installUrl' in c) && !('silentArgs' in c), `${c.id}: links belong in installers.json`); + assert.ok(installers[c.id], `${c.id}: missing from installers.json`); + } }); test('game launchers cover the major stores', () => { const games = loadCatalog().filter((c) => c.category === 'games'); @@ -436,26 +436,44 @@ test('shouldTryAltProvider includes forbidden/not_found for winget fallback', () test('catalog uses shouldTryAltProvider for winget fallback', () => { const src = fs.readFileSync(path.join(__dirname, '..', 'electron', 'providers', 'catalog.ts'), 'utf-8'); assert.ok(src.includes('shouldTryAltProvider(err)'), 'install should use shouldTryAltProvider'); - assert.ok(src.includes('resolveInstallFallback'), 'resolveInstallFallback missing'); + assert.ok(src.includes('resolveInstallPlan'), 'resolveInstallPlan missing'); }); -const { resolveInstallFallback } = loadTsModule(path.join(__dirname, '..', 'electron', 'providers', 'catalog.ts'), { - electron: electronStub, +const { resolveInstallPlan, isSafeInstallerArgs } = loadTsModule( + path.join(__dirname, '..', 'electron', 'providers', 'catalog.ts'), + { electron: electronStub }, +); + +test('resolveInstallPlan: direct installer with winget as fallback', () => { + const direct = { wingetId: 'Mozilla.Firefox', version: '1', type: 'nullsoft', url: 'https://x/y.exe', sha256: 'ab' }; + const plan = resolveInstallPlan('firefox', { firefox: direct }, {}); + assert.equal(plan.direct, direct); + assert.deepEqual(plan.fallback, { provider: 'winget', packageId: 'Mozilla.Firefox' }); }); -test('resolveInstallFallback prefers altProvider then winget-map', () => { - const alt = resolveInstallFallback({ - id: 'discord', - altProvider: { provider: 'winget', packageId: 'Discord.Discord' }, - }); - assert.deepEqual(alt, { provider: 'winget', packageId: 'Discord.Discord' }); +test('resolveInstallPlan: winget-only and hand-pinned providers', () => { + const plan = resolveInstallPlan('teams', { teams: { wingetId: 'Microsoft.Teams', version: '1', type: 'msix', direct: false } }, {}); + assert.equal(plan.direct, undefined); + assert.deepEqual(plan.fallback, { provider: 'winget', packageId: 'Microsoft.Teams' }); + + const choco = resolveInstallPlan('filezilla', { filezilla: { provider: 'chocolatey', packageId: 'filezilla' } }, {}); + assert.deepEqual(choco, { fallback: { provider: 'chocolatey', packageId: 'filezilla' } }); +}); - const mapped = resolveInstallFallback({ id: 'putty' }); - assert.equal(mapped?.provider, 'winget'); - assert.ok(mapped?.packageId, 'putty should resolve via winget-map'); +test('resolveInstallPlan: app not refreshed yet falls back to winget-map', () => { + assert.deepEqual(resolveInstallPlan('new-app', {}, { 'new-app': 'Vendor.App' }), { + fallback: { provider: 'winget', packageId: 'Vendor.App' }, + }); + assert.deepEqual(resolveInstallPlan('nothing', {}, {}), { fallback: undefined }); +}); - const skipped = resolveInstallFallback({ id: 'x', noWingetFallback: true }); - assert.equal(skipped, null); +test('isSafeInstallerArgs allows manifest quoting, refuses cmd metacharacters', () => { + assert.ok(isSafeInstallerArgs('')); + assert.ok(isSafeInstallerArgs('/SP- /VERYSILENT /mergetasks=!runcode')); + assert.ok(isSafeInstallerArgs('--install "C:\\Program Files (x86)\\AnyDesk" --silent')); + for (const bad of ['/S & calc', '/S | x', '/S > out', '%PATH%', '/S ^', '"unbalanced', '/S\ncalc']) { + assert.ok(!isSafeInstallerArgs(bad), bad); + } }); test('queue exposes cancel / dismiss / retryJob APIs', () => { @@ -521,5 +539,105 @@ test('clearDone removes done, cancelled, and failed', () => { assert.equal(q.getJobs().length, 0); }); +// --- data/installers.json (generated by scripts/refresh-installers.cjs) --- +{ + const { isSafeInstallerArgs: safeArgs } = loadTsModule( + path.join(__dirname, '..', 'electron', 'providers', 'catalog.ts'), + { electron: electronStub }, + ); + const installers = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'installers.json'), 'utf-8')); + + test('installers: direct entries are https + sha256 with safe switches', () => { + for (const [id, e] of Object.entries(installers)) { + if (!e.url) continue; + assert.ok(e.url.startsWith('https://'), `${id}: url must be https`); + assert.match(e.sha256, /^[0-9a-f]{64}$/, `${id}: sha256`); + assert.ok(['nullsoft', 'inno', 'burn', 'exe', 'msi', 'wix'].includes(e.type), `${id}: type ${e.type}`); + assert.ok(e.wingetId && e.version, `${id}: wingetId/version`); + for (const args of [e.silentArgs, e.args]) { + if (args) assert.ok(safeArgs(args), `${id}: unsafe switches ${args}`); + } + } + }); + + test('installers: every entry is direct, winget-only or another provider', () => { + for (const [id, e] of Object.entries(installers)) { + const kind = e.url ? 'direct' : e.direct === false ? 'winget' : e.provider ? 'provider' : null; + assert.ok(kind, `${id}: unrecognised entry`); + if (kind === 'provider') { + assert.ok(['winget', 'scoop', 'chocolatey'].includes(e.provider) && e.packageId, id); + } + } + }); +} + +// --- recommendations (src/recommend.ts + data/related.json) --- +{ + const { buildRecommender } = loadTsModule(path.join(__dirname, '..', 'src', 'recommend.ts')); + const related = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'related.json'), 'utf-8')); + const catalogIds = new Set(loadCatalog().map((c) => c.id)); + + test('related.json: groups reference catalog ids and have 2+ apps', () => { + for (const kind of ['stacks', 'alternatives']) { + const seen = new Set(); + for (const g of related[kind]) { + assert.ok(!seen.has(g.id), `${kind}: duplicate ${g.id}`); + seen.add(g.id); + assert.ok(g.apps.length >= 2, `${kind}/${g.id}: needs 2+ apps`); + for (const id of g.apps) assert.ok(catalogIds.has(id), `${kind}/${g.id}: unknown app "${id}"`); + } + } + }); + + const app = (id, extra = {}) => ({ + id, curatedId: id, provider: 'catalog', name: id, category: 'x', isInstalled: false, isFavorite: false, ...extra, + }); + const rel = { + stacks: [{ id: 'dev', apps: ['vscode', 'git', 'nodejs'] }, { id: 'web', apps: ['vscode', 'postman'] }], + alternatives: [{ id: 'browser', apps: ['firefox', 'chrome'] }], + }; + + test('recommend: cold start falls back to editorial picks', () => { + const r = buildRecommender([app('a', { pickTag: 'Must-have' }), app('b', { recommended: true }), app('c')], rel); + assert.equal(r.personalized, false); + assert.deepEqual(r.forYou().map((x) => x.app.id), ['a', 'b']); + assert.equal(r.forYou()[0].reason.kind, 'editor'); + }); + + test('recommend: installed app pulls in its stack-mates with a named reason', () => { + const apps = [app('vscode', { isInstalled: true, name: 'VS Code' }), app('git'), app('nodejs'), app('postman'), app('c', { recommended: true })]; + const top = buildRecommender(apps, rel).forYou(); + assert.ok(top.every((x) => x.app.id !== 'vscode'), 'installed apps are not recommended'); + assert.deepEqual(top.slice(0, 3).map((x) => x.app.id).sort(), ['git', 'nodejs', 'postman']); + assert.deepEqual(top[0].reason, { kind: 'pairs', withName: 'VS Code' }); + }); + + test('recommend: owning an alternative demotes the rest', () => { + const apps = [app('firefox', { isInstalled: true }), app('chrome', { pickTag: 'Popular', category: 'browsers' }), app('vlc', { recommended: true })]; + const ids = buildRecommender(apps, rel).forYou().map((x) => x.app.id); + assert.ok(ids.indexOf('vlc') < ids.indexOf('chrome'), ids.join(',')); + }); + + test('recommend: starred but not installed comes first', () => { + const apps = [app('x', { isFavorite: true }), app('y', { pickTag: 'Top' })]; + const top = buildRecommender(apps, rel).forYou()[0]; + assert.equal(top.app.id, 'x'); + assert.equal(top.reason.kind, 'favorite'); + }); + + test('recommend: pairsWith skips alternatives, alternativesTo lists them', () => { + const r = buildRecommender([app('vscode'), app('git'), app('nodejs'), app('postman'), app('firefox'), app('chrome')], rel); + assert.deepEqual(r.pairsWith(app('vscode')).map((a) => a.id), ['git', 'nodejs', 'postman']); + assert.deepEqual(r.alternativesTo(app('firefox')).map((a) => a.id), ['chrome']); + }); + + test('recommend: profiles count as stacks (by package id)', () => { + const apps = [app('steam', { isInstalled: true, id: 'steam-pkg' }), app('discord', { id: 'discord-pkg' })]; + const profiles = [{ id: 'p', name: 'p', description: '', packages: [{ provider: 'catalog', packageId: 'steam-pkg' }, { provider: 'catalog', packageId: 'discord-pkg' }] }]; + const top = buildRecommender(apps, { stacks: [], alternatives: [] }, profiles).forYou(); + assert.equal(top[0]?.app.curatedId, 'discord'); + }); +} + console.log(`\n${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0); diff --git a/scripts/test-download.cjs b/scripts/test-download.cjs index ecb37de..880f0a6 100644 --- a/scripts/test-download.cjs +++ b/scripts/test-download.cjs @@ -79,7 +79,7 @@ async function withServer(handler, fn) { } } -const { downloadFile } = loadTsModule( +const { downloadFile, detectInstallerKind } = loadTsModule( path.join(ROOT, 'electron', 'providers', 'catalog.ts'), { electron: electronStub }, ); @@ -199,6 +199,46 @@ console.log('\ndownload integration:'); }); }); + await test('redirect to plain http on another host is refused', async () => { + await withServer((_req, res) => { + res.writeHead(302, { Location: 'http://example.com/setup.exe' }); + res.end(); + }, async (base) => { + const dest = tempFile('downgrade.exe'); + unlinkQuiet(dest); + await assert.rejects( + () => downloadFile(`${base}/start`, dest), + (err) => isInstallError(err, 'untrusted'), + ); + assert.ok(!fs.existsSync(dest)); + }); + }); + + await test('non-https catalog url is refused before connecting', async () => { + const dest = tempFile('plain.exe'); + unlinkQuiet(dest); + await assert.rejects( + () => downloadFile('http://example.com/setup.exe', dest), + (err) => isInstallError(err, 'untrusted'), + ); + }); + + await test('detectInstallerKind reads magic bytes, not the name', async () => { + const cases = [ + ['kind-pe.download', Buffer.from('4d5a900003000000', 'hex'), 'exe'], + ['kind-msi.download', Buffer.from('d0cf11e0a1b11ae1', 'hex'), 'msi'], + ['kind-html.download', Buffer.from(''), null], + ['kind-msix.download', Buffer.from('504b030414000800', 'hex'), null], + ['kind-empty.download', Buffer.alloc(0), null], + ]; + for (const [name, bytes, expected] of cases) { + const file = tempFile(name); + fs.writeFileSync(file, bytes); + assert.equal(detectInstallerKind(file), expected, name); + unlinkQuiet(file); + } + }); + console.log(`\n${passed} passed, ${failed} failed\n`); process.exit(failed > 0 ? 1 : 0); })(); diff --git a/scripts/test-live-install.cjs b/scripts/test-live-install.cjs index 67a35a1..16c5a1c 100644 --- a/scripts/test-live-install.cjs +++ b/scripts/test-live-install.cjs @@ -34,7 +34,7 @@ const packageId = process.argv[2] || 'discord'; (async () => { console.log(`\nLive install probe: ${packageId}\n`); - const { CatalogProvider, initProviders } = loadTsModule( + const { CatalogProvider } = loadTsModule( path.join(ROOT, 'electron', 'providers', 'catalog.ts'), { electron: electronStub }, ); @@ -47,8 +47,10 @@ const packageId = process.argv[2] || 'discord'; process.exit(1); } console.log(' name:', item.name); - console.log(' urls:', 1 + (item.installUrlFallbacks?.length || 0)); - console.log(' altProvider:', item.altProvider ? `${item.altProvider.provider}:${item.altProvider.packageId}` : 'none'); + const { resolveInstallPlan } = loadTsModule(path.join(ROOT, 'electron', 'providers', 'catalog.ts'), { electron: electronStub }); + const plan = resolveInstallPlan(item.id); + console.log(' direct:', plan.direct ? `${plan.direct.type} ${plan.direct.version} ${plan.direct.url}` : 'none'); + console.log(' fallback:', plan.fallback ? `${plan.fallback.provider}:${plan.fallback.packageId}` : 'none'); console.log(' starting install (may take minutes; uses real network + winget)…\n'); const t0 = Date.now(); @@ -65,7 +67,6 @@ const packageId = process.argv[2] || 'discord'; console.log(' errorCode:', result.errorCode); console.log(' message:', result.message); if (result.detail) console.log(' detail:', result.detail.slice(0, 300)); - console.log('\nCheck .cursor/debug-7931cb.log for install trace.\n'); process.exit(result.ok ? 0 : 1); })().catch((err) => { console.error(err); diff --git a/scripts/test-resilience.cjs b/scripts/test-resilience.cjs index 557a803..e6dd1f4 100644 --- a/scripts/test-resilience.cjs +++ b/scripts/test-resilience.cjs @@ -1,6 +1,7 @@ /** - * Resilience audit: every catalog app must have winget install fallback - * (altProvider, winget-map entry, or explicit noWingetFallback). + * Resilience audit: every catalog app must have a package-manager route — + * a winget Id (winget-map / installers.json) or a hand-pinned provider — + * so a dead direct download never leaves the user stuck. * * Run: node scripts/test-resilience.cjs [--winget] */ @@ -11,6 +12,7 @@ const { spawnSync } = require('node:child_process'); const ROOT = path.join(__dirname, '..'); const catalog = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'catalog.json'), 'utf-8')); const wingetMap = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'winget-map.json'), 'utf-8')); +const installers = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'installers.json'), 'utf-8')); const CHECK_WINGET = process.argv.includes('--winget'); let passed = 0; @@ -47,15 +49,12 @@ function wingetIdExists(id) { return r.status === 0; } -function hasInstallFallback(entry) { - if (entry.noWingetFallback) return false; - if (entry.altProvider?.provider === 'winget' && entry.altProvider.packageId) return true; - return Boolean(wingetMap[entry.id]); +function resolveWingetId(entry) { + return installers[entry.id]?.wingetId ?? wingetMap[entry.id]; } -function resolveWingetId(entry) { - if (entry.altProvider?.provider === 'winget') return entry.altProvider.packageId; - return wingetMap[entry.id]; +function hasInstallFallback(entry) { + return Boolean(resolveWingetId(entry) || installers[entry.id]?.provider); } console.log('\nresilience audit:'); @@ -65,34 +64,16 @@ test('every recommended app has install fallback', () => { if (missing.length) throw new Error(`missing install fallback: ${missing.join(', ')}`); }); -test('catalog ids are covered by winget-map or noWingetFallback', () => { - const missing = catalog - .filter((e) => !e.noWingetFallback && !wingetMap[e.id] && e.altProvider?.provider !== 'winget') - .map((e) => e.id); - if (missing.length) throw new Error(`uncovered catalog ids: ${missing.join(', ')}`); -}); - -test('every altProvider has winget or scoop + packageId', () => { - for (const entry of catalog) { - if (!entry.altProvider) continue; - const { provider, packageId } = entry.altProvider; - if (!['winget', 'scoop'].includes(provider)) { - throw new Error(`${entry.id}: invalid altProvider.provider "${provider}"`); - } - if (!packageId || !String(packageId).trim()) { - throw new Error(`${entry.id}: empty altProvider.packageId`); - } - } +test('installers.json winget Ids agree with winget-map', () => { + const drift = catalog + .filter((e) => installers[e.id]?.wingetId && wingetMap[e.id] && installers[e.id].wingetId.toLowerCase() !== wingetMap[e.id].toLowerCase()) + .map((e) => `${e.id} (${wingetMap[e.id]} vs ${installers[e.id].wingetId})`); + if (drift.length) throw new Error(`drift: ${drift.join(', ')}`); }); -const withFallback = catalog.filter((e) => hasInstallFallback(e)); -const withMap = catalog.filter((e) => wingetMap[e.id]); -const withAlt = catalog.filter((e) => e.altProvider); -const withFallbacks = catalog.filter((e) => e.installUrlFallbacks?.length); -console.log(`\n coverage: ${withFallback.length}/${catalog.length} apps with winget fallback`); -console.log(` coverage: ${withMap.length}/${catalog.length} apps in winget-map.json`); -console.log(` coverage: ${withAlt.length}/${catalog.length} apps with legacy altProvider`); -console.log(` coverage: ${withFallbacks.length}/${catalog.length} apps with URL fallbacks`); +const values = catalog.map((e) => installers[e.id] ?? {}); +console.log(`\n coverage: ${catalog.filter(hasInstallFallback).length}/${catalog.length} apps with a package-manager route`); +console.log(` coverage: ${values.filter((v) => v.url).length}/${catalog.length} apps with a verified direct installer`); if (CHECK_WINGET && wingetAvailable()) { console.log('\n validating winget IDs…'); diff --git a/scripts/verify-catalog.cjs b/scripts/verify-catalog.cjs index 1d11ab7..0f2581a 100644 --- a/scripts/verify-catalog.cjs +++ b/scripts/verify-catalog.cjs @@ -18,7 +18,9 @@ const ICONS = path.join(DATA, 'icons'); const CHECK_LINKS = !process.argv.includes('--no-links'); const CONCURRENCY = 8; -const REQUIRED = ['id', 'name', 'description', 'category', 'packageId', 'provider', 'installUrl']; +const REQUIRED = ['id', 'name', 'description', 'category', 'packageId', 'provider']; +/** Install fields moved to installers.json, generated from winget manifests. */ +const LINK_FIELDS = ['installUrl', 'installUrlFallbacks', 'silentArgs', 'altProvider', 'noWingetFallback']; const ICON_EXTENSIONS = ['.svg', '.png', '.ico', '.jpg']; const NETWORK_ERRORS = ['ENOTFOUND', 'ETIMEDOUT', 'ECONNREFUSED', 'ECONNRESET', 'EAI_AGAIN', 'TIMEOUT', 'EPROTO']; /** Statuses that say "not now" rather than "gone". */ @@ -105,8 +107,8 @@ function verifyStructure(catalog, categories, picks, profiles) { if (!Array.isArray(entry.features) || entry.features.length === 0) { errors.push(`${label}: "features" must be a non-empty array`); } - if (!entry.silentArgs) { - warnings.push(`${label}: no silentArgs, install will show the vendor UI`); + for (const field of LINK_FIELDS) { + if (field in entry) errors.push(`${label}: "${field}" belongs in installers.json (npm run catalog:installers)`); } if (entry.provider !== 'catalog') { errors.push(`${label}: provider must be "catalog", got "${entry.provider}"`); @@ -124,40 +126,6 @@ function verifyStructure(catalog, categories, picks, profiles) { seenPackageIds.add(entry.packageId); } - if (entry.installUrlFallbacks !== undefined) { - if (!Array.isArray(entry.installUrlFallbacks)) { - errors.push(`${label}: installUrlFallbacks must be an array`); - } else { - for (const fb of entry.installUrlFallbacks) { - if (typeof fb !== 'string' || !fb.trim()) { - errors.push(`${label}: installUrlFallbacks contains invalid URL`); - } - } - } - } - - if (entry.noWingetFallback !== undefined && typeof entry.noWingetFallback !== 'boolean') { - errors.push(`${label}: noWingetFallback must be a boolean`); - } - - if (entry.altProvider !== undefined) { - const ap = entry.altProvider; - if (!ap || typeof ap !== 'object') { - errors.push(`${label}: altProvider must be an object`); - } else { - if (!['winget', 'scoop'].includes(ap.provider)) { - errors.push(`${label}: altProvider.provider must be winget or scoop, got "${ap.provider}"`); - } - if (!ap.packageId || String(ap.packageId).trim() === '') { - errors.push(`${label}: altProvider.packageId is required`); - } - } - } - - if (entry.recommended && !entry.installUrlFallbacks?.length && !entry.altProvider) { - warnings.push(`${label}: recommended app has no installUrlFallbacks or altProvider`); - } - const slug = entry.logoSlug || entry.id; const hasIcon = ICON_EXTENSIONS.some((ext) => fs.existsSync(path.join(ICONS, slug + ext))); if (!hasIcon) { @@ -199,7 +167,23 @@ function verifyStructure(catalog, categories, picks, profiles) { } } -function verifyWingetMap(catalog) { +function verifyInstallers(catalog, installers) { + for (const entry of catalog) { + const inst = installers[entry.id]; + if (!inst) { + errors.push(`${entry.id}: missing from installers.json — run npm run catalog:installers`); + continue; + } + if (inst.url && !inst.silentArgs) { + warnings.push(`${entry.id}: ${inst.type} installer has no silent switches, the vendor UI will show`); + } + } + for (const id of Object.keys(installers)) { + if (!catalog.some((e) => e.id === id)) warnings.push(`installers.json: orphan key "${id}"`); + } +} + +function verifyWingetMap(catalog, installers) { let wingetMap; try { wingetMap = readJson('winget-map.json'); @@ -226,9 +210,8 @@ function verifyWingetMap(catalog) { } for (const entry of catalog) { - if (entry.noWingetFallback) continue; - if (wingetMap[entry.id] || entry.altProvider?.provider === 'winget') continue; - errors.push(`${entry.id}: no winget-map entry and no noWingetFallback`); + if (wingetMap[entry.id] || installers[entry.id]?.provider) continue; + errors.push(`${entry.id}: no winget-map entry and no hand-pinned provider in installers.json`); } } @@ -237,11 +220,13 @@ function verifyWingetMap(catalog) { let categories; let picks; let profiles; + let installers; try { catalog = readJson('catalog.json'); categories = readJson('categories.json'); picks = readJson('picks.json'); profiles = readJson('profiles.json'); + installers = readJson('installers.json'); } catch (err) { console.error('Failed to read catalog data:', err.message); process.exit(1); @@ -254,7 +239,8 @@ function verifyWingetMap(catalog) { console.log(`Catalog: ${catalog.length} apps, ${Object.keys(categories).length} categories\n`); verifyStructure(catalog, categories, picks, profiles); - verifyWingetMap(catalog); + verifyInstallers(catalog, installers); + verifyWingetMap(catalog, installers); const byCategory = {}; for (const entry of catalog) byCategory[entry.category] = (byCategory[entry.category] || 0) + 1; @@ -265,12 +251,10 @@ function verifyWingetMap(catalog) { if (CHECK_LINKS) { const linkTargets = []; for (const entry of catalog) { - if (entry.installUrl) linkTargets.push({ entry, url: entry.installUrl, kind: 'primary' }); - for (const fb of entry.installUrlFallbacks || []) { - linkTargets.push({ entry, url: fb, kind: 'fallback' }); - } + const url = installers[entry.id]?.url; + if (url) linkTargets.push({ entry, url, kind: 'primary' }); } - console.log(`\nChecking ${linkTargets.length} install links (${catalog.filter((e) => e.installUrl).length} apps)…`); + console.log(`\nChecking ${linkTargets.length} installer links from installers.json…`); const results = await mapLimit(linkTargets, CONCURRENCY, async ({ entry, url, kind }) => { const res = await checkUrl(url); if (!res.ok) console.log(` x ${entry.id.padEnd(18)} [${kind}] ${res.status}`); @@ -283,7 +267,7 @@ function verifyWingetMap(catalog) { if (res.ok) { ok++; if (kind === 'primary' && res.length && res.length < 20000) { - warnings.push(`${entry.id}: installUrl is only ${res.length} bytes, may be an error page`); + warnings.push(`${entry.id}: installer is only ${res.length} bytes, may be an error page`); } continue; } @@ -292,11 +276,8 @@ function verifyWingetMap(catalog) { warnings.push(`${entry.id}: could not verify ${kind} (${res.status}) — ${url}`); continue; } - if (kind === 'fallback') { - warnings.push(`${entry.id}: fallback URL returned ${res.status} — ${url}`); - continue; - } - errors.push(`${entry.id}: installUrl returned ${res.status} — ${url}`); + // The app falls back to winget, so a dead pinned link is stale data, not a broken install. + warnings.push(`${entry.id}: installer link returned ${res.status}, refresh installers.json — ${url}`); } console.log(` ${ok} OK, ${unverified} unverifiable, ${linkTargets.length - ok - unverified} broken`); } diff --git a/src/App.tsx b/src/App.tsx index 3bbb5b5..f1fcd17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { MergedApp, ProfileEntry, QueueJob, SearchResult, ViewMode, AppSettings } from './types'; import { packageKey } from './types'; import { I18nProvider } from './i18n/I18nContext'; import { translations, type Locale } from './i18n/translations'; -import { categoryLabel, profileLabel } from './i18n/helpers'; +import { categoryLabel, profileLabel, reasonLabel } from './i18n/helpers'; +import { buildRecommender, type RelatedData } from './recommend'; import { Sidebar } from './components/Sidebar'; import { TopBar } from './components/TopBar'; import { HeroBanner } from './components/HeroBanner'; @@ -32,6 +33,7 @@ export default function App() { const [apps, setApps] = useState([]); const [categories, setCategories] = useState>({}); const [profiles, setProfiles] = useState([]); + const [related, setRelated] = useState({ stacks: [], alternatives: [] }); const [providers, setProviders] = useState>([]); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); @@ -81,11 +83,13 @@ export default function App() { apps: MergedApp[]; categories: Record; profiles: ProfileEntry[]; + related?: RelatedData; error?: string; }; setApps(data.apps ?? []); setCategories(data.categories ?? {}); setProfiles(data.profiles ?? []); + setRelated(data.related ?? { stacks: [], alternatives: [] }); if (data.error) addToast(`${tRef.current.nav.catalog}: ${data.error}`, 'error'); } catch (err) { addToast(err instanceof Error ? err.message : tRef.current.toast.catalogError, 'error'); @@ -273,6 +277,8 @@ export default function App() { useEffect(() => { if (loading || storeApps.length === 0) return; enrichVisible(storeApps.slice(0, 40)); + // storeApps is a fresh array every render; its length is the change worth reacting to. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [loading, storeApps.length, enrichVisible]); const handleInstall = useCallback(async (app: MergedApp) => { @@ -420,6 +426,13 @@ export default function App() { await loadCatalog(); }, [settings, loadCatalog]); + const recommender = useMemo(() => buildRecommender(apps, related, profiles), [apps, related, profiles]); + const recommendations = useMemo(() => recommender.forYou(60), [recommender]); + const reasonByKey = useMemo( + () => new Map(recommendations.map((r) => [packageKey(r.app.provider, r.app.id), reasonLabel(t, r.reason, categories)])), + [recommendations, t, categories], + ); + const getFilteredApps = (): MergedApp[] => { switch (view) { case 'catalog': @@ -434,7 +447,7 @@ export default function App() { return [...list].sort((a, b) => a.name.localeCompare(b.name)); } case 'recommended': - return storeApps.filter((a) => a.recommended || a.pickTag); + return recommendations.map((r) => r.app); case 'favorites': return apps.filter((a) => a.isFavorite); case 'installed': @@ -451,6 +464,8 @@ export default function App() { }; const filteredApps = getFilteredApps(); + const forYouTitle = recommender.personalized ? t.recommend.forYou : t.recommend.startHere; + const forYouHint = recommender.personalized ? t.recommend.forYouHint : t.recommend.startHereHint; const heroPool = storeApps.filter((a) => a.pickTag || a.recommended).slice(0, 5); const featured = heroPool.find((a) => a.id === featuredId) ?? heroPool[0] ?? storeApps[0]; const activeQueueJobs = queueJobs.filter((j) => j.status === 'pending' || j.status === 'running'); @@ -542,6 +557,20 @@ export default function App() { ) : ( )} + r.app)} + reasons={reasonByKey} + categories={categories} + activeJobs={activeJobs} + onInstall={handleInstall} + onRetry={handleRetryJob} + onCancel={handleCancelJob} + onOpen={setSelectedApp} + onToggleFavorite={handleToggleFavorite} + onSeeAll={() => handleNavigate('recommended')} + /> {profiles.length > 0 && (

{t.hero.profiles}

@@ -704,7 +733,12 @@ export default function App() { )} )} - {view === 'recommended' &&

{t.nav.recommended}

} + {view === 'recommended' && ( + <> +

{forYouTitle}

+

{forYouHint}

+ + )} {view === 'favorites' &&

{t.nav.favorites}

} {view === 'installed' &&

{t.nav.installed}

} {view === 'search' &&

{t.search.results}

} @@ -728,6 +762,7 @@ export default function App() { onOpen={setSelectedApp} onToggleFavorite={handleToggleFavorite} onVisibleChange={enrichVisible} + reasons={view === 'recommended' ? reasonByKey : undefined} /> )} @@ -758,6 +793,9 @@ export default function App() { onRetry={handleRetryJob} onCancel={handleCancelJob} onToggleFavorite={handleToggleFavorite} + pairs={recommender.pairsWith(selectedApp)} + alternatives={recommender.alternativesTo(selectedApp)} + onOpen={setSelectedApp} /> )} diff --git a/src/components/AppModal.tsx b/src/components/AppModal.tsx index e32d5fe..0ffff4a 100644 --- a/src/components/AppModal.tsx +++ b/src/components/AppModal.tsx @@ -13,9 +13,43 @@ interface AppModalProps { onRetry?: (app: MergedApp) => void; onCancel?: (jobId: string) => void; onToggleFavorite: (app: MergedApp) => void; + /** Apps from the same stacks; shown as chips that open their own card. */ + pairs?: MergedApp[]; + alternatives?: MergedApp[]; + onOpen?: (app: MergedApp) => void; } -export function AppModal({ app, job, onClose, onInstall, onRetry, onCancel, onToggleFavorite }: AppModalProps) { +function RelatedChips({ title, apps, onOpen }: { title: string; apps: MergedApp[]; onOpen?: (app: MergedApp) => void }) { + const { t } = useI18n(); + if (apps.length === 0) return null; + return ( +
+

{title}

+
+ {apps.map((a) => ( + + ))} +
+
+ ); +} + +export function AppModal({ + app, + job, + onClose, + onInstall, + onRetry, + onCancel, + onToggleFavorite, + pairs = [], + alternatives = [], + onOpen, +}: AppModalProps) { const { t } = useI18n(); const desc = appDescription(app, t); @@ -45,6 +79,8 @@ export function AppModal({ app, job, onClose, onInstall, onRetry, onCancel, onTo ))} )} + +
)}
+ {reason &&
{reason}
} ); } diff --git a/src/components/PagedGrid.tsx b/src/components/PagedGrid.tsx index eeaaf78..26a7604 100644 --- a/src/components/PagedGrid.tsx +++ b/src/components/PagedGrid.tsx @@ -27,6 +27,8 @@ interface PagedGridProps { onOpen: (app: MergedApp) => void; onToggleFavorite: (app: MergedApp) => void; onVisibleChange?: (visibleApps: MergedApp[]) => void; + /** Why each app is shown, keyed by packageKey. */ + reasons?: Map; } export function PagedGrid({ @@ -39,6 +41,7 @@ export function PagedGrid({ onOpen, onToggleFavorite, onVisibleChange, + reasons, }: PagedGridProps) { const { t } = useI18n(); const [page, setPage] = useState(1); @@ -86,6 +89,7 @@ export function PagedGrid({ key={`${app.provider}:${app.id}`} app={app} categoryLabel={metaLabel(app, t, categories)} + reason={reasons?.get(packageKey(app.provider, app.id))} job={activeJobs?.get(packageKey(app.provider, app.id))} onInstall={onInstall} onRetry={onRetry} diff --git a/src/components/ShelfRow.tsx b/src/components/ShelfRow.tsx index 27989e1..f8e61b6 100644 --- a/src/components/ShelfRow.tsx +++ b/src/components/ShelfRow.tsx @@ -17,9 +17,12 @@ interface ShelfRowProps { onOpen: (app: MergedApp) => void; onToggleFavorite: (app: MergedApp) => void; onSeeAll?: () => void; + /** Why each app is shown, keyed by packageKey. */ + reasons?: Map; + hint?: string; } -export function ShelfRow({ title, apps, categories, activeJobs, onInstall, onRetry, onCancel, onOpen, onToggleFavorite, onSeeAll }: ShelfRowProps) { +export function ShelfRow({ title, apps, categories, activeJobs, onInstall, onRetry, onCancel, onOpen, onToggleFavorite, onSeeAll, reasons, hint }: ShelfRowProps) { const { t } = useI18n(); const viewportRef = useRef(null); const [atStart, setAtStart] = useState(true); @@ -77,12 +80,14 @@ export function ShelfRow({ title, apps, categories, activeJobs, onInstall, onRet )} + {hint &&

{hint}

}
{apps.slice(0, 16).map((app) => ( , +): string { + switch (reason.kind) { + case 'favorite': + return t.recommend.favorite; + case 'pairs': + return t.recommend.pairs.replace('{name}', reason.withName); + case 'category': + return t.recommend.category.replace('{category}', categoryLabel(t, reason.category, categories[reason.category])); + case 'editor': + return reason.tag ?? t.recommend.editor; + default: + return t.recommend.popular; + } +} + export function localeLabel(t: TranslationKey, locale: 'ru' | 'en'): string { return t.lang[locale]; } diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index eba338f..e4e0f42 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -70,6 +70,7 @@ export const translations = { timeout: 'Превышено время ожидания.', http: 'Ошибка загрузки с сервера.', installer: 'Установщик завершился с ошибкой.', + untrusted: 'Файл не прошёл проверку подлинности (подпись издателя или небезопасная ссылка). Поставьте через winget («Все пакеты»).', unknown: 'Неизвестная ошибка.', }, }, @@ -95,6 +96,19 @@ export const translations = { profiles: 'Профили установки', installSet: 'Установить набор', }, + recommend: { + forYou: 'Для вас', + startHere: 'С чего начать', + forYouHint: 'Подобрано по тому, что у вас установлено и в избранном.', + startHereHint: 'Выбор редакции. Поставьте или добавьте в избранное пару программ — подборка подстроится под вас.', + favorite: 'В избранном, ещё не установлено', + pairs: 'Вместе с {name}', + category: 'Вы часто ставите: {category}', + editor: 'Выбор редакции', + popular: 'Популярное', + pairsWith: 'Часто ставят вместе', + alternatives: 'Альтернативы', + }, profiles: { 'gaming-rig': { name: 'Gaming Rig', description: 'Steam, Epic, GOG Galaxy и Discord' }, 'dev-starter': { name: 'Dev Starter', description: 'Git, Node.js и VS Code для разработки' }, @@ -235,6 +249,7 @@ export const translations = { timeout: 'Timed out waiting for the download.', http: 'Download failed on the server side.', installer: 'The installer exited with an error.', + untrusted: 'The file failed the authenticity check (publisher signature or insecure link). Install via winget (All packages).', unknown: 'Unknown error.', }, }, @@ -260,6 +275,19 @@ export const translations = { profiles: 'Install profiles', installSet: 'Install bundle', }, + recommend: { + forYou: 'For you', + startHere: 'Where to start', + forYouHint: 'Picked from what you have installed and starred.', + startHereHint: "Editor's picks. Install or star a few apps and this list adapts to you.", + favorite: 'Starred, not installed yet', + pairs: 'Goes with {name}', + category: 'You often install: {category}', + editor: "Editor's choice", + popular: 'Popular', + pairsWith: 'Often installed together', + alternatives: 'Alternatives', + }, profiles: { 'gaming-rig': { name: 'Gaming Rig', description: 'Steam, Epic, GOG Galaxy and Discord' }, 'dev-starter': { name: 'Dev Starter', description: 'Git, Node.js and VS Code for developers' }, diff --git a/src/index.css b/src/index.css index 7fd62b7..797e599 100644 --- a/src/index.css +++ b/src/index.css @@ -593,6 +593,8 @@ button, input, select { .packages-header { margin-bottom: 16px; } .packages-header .page-title { margin-bottom: 6px; } +.page-hint { margin: -8px 0 14px; max-width: 70ch; color: var(--text-muted); font-size: 12px; line-height: 1.5; } + .packages-hint { margin-bottom: 10px; max-width: 70ch; @@ -873,6 +875,15 @@ button, input, select { text-transform: uppercase; } +.capsule-reason { + padding: 0 9px 8px; + overflow: hidden; + color: var(--accent-hover); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + .capsule-cta { flex-shrink: 0; padding: 4px 9px; @@ -976,6 +987,8 @@ button, input, select { } .shelf-count { color: var(--text-muted); font-size: 11px; } + +.shelf-hint { margin: -4px 0 9px; color: var(--text-muted); font-size: 12px; } .shelf-spacer { flex: 1; } .shelf-arrow { @@ -1120,6 +1133,43 @@ button, input, select { font-size: 10.5px; } +.modal-related { padding: 16px 24px 0; } + +.modal-related-title { + margin-bottom: 7px; + color: var(--text-muted); + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.modal-related-list { display: flex; flex-wrap: wrap; gap: 6px; } + +.related-chip { + display: inline-flex; + align-items: center; + gap: 7px; + max-width: 200px; + padding: 4px 9px 4px 5px; + border: 1px solid var(--border-strong); + border-radius: var(--r-sm); + background: rgba(255, 255, 255, 0.04); + color: var(--text-secondary); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.related-chip:hover { border-color: var(--accent-line); background: var(--accent-dim); color: var(--text-primary); } +.related-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } + +.related-chip .app-cover-img, +.related-chip .app-monogram { flex: none; } + +.related-chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.related-chip-badge { color: var(--ok); font-size: 10px; } + .modal-actions { display: flex; gap: 8px; @@ -1374,6 +1424,7 @@ button, input, select { /* ---------- Covers ---------- */ .app-cover-img { object-fit: contain; display: block; } +.app-cover-img--xs { width: 20px; height: 20px; } .app-cover-img--sm { width: 28px; height: 28px; } .app-cover-img--md { width: 52px; height: 52px; } .app-cover-img--lg { width: 76px; height: 76px; } diff --git a/src/recommend.ts b/src/recommend.ts new file mode 100644 index 0000000..46d2811 --- /dev/null +++ b/src/recommend.ts @@ -0,0 +1,160 @@ +import type { MergedApp, ProfileEntry } from './types'; + +/** Curated relations between catalog apps (data/related.json), keyed by catalog id. */ +export interface RelatedData { + /** Apps people tend to use together. */ + stacks: Array<{ id: string; apps: string[] }>; + /** Interchangeable apps: owning one makes the others less relevant. */ + alternatives: Array<{ id: string; apps: string[] }>; +} + +export type RecommendationReason = + | { kind: 'favorite' } + | { kind: 'pairs'; withName: string } + | { kind: 'category'; category: string } + | { kind: 'editor'; tag?: string } + | { kind: 'popular' }; + +export interface Recommendation { + app: MergedApp; + score: number; + reason: RecommendationReason; +} + +export interface Recommender { + /** Apps to suggest next, best first; editorial picks when the user owns nothing yet. */ + forYou(limit?: number): Recommendation[]; + /** Apps from the same stacks and profiles, most shared groups first. */ + pairsWith(app: MergedApp, limit?: number): MergedApp[]; + /** Apps that do the same job. */ + alternativesTo(app: MergedApp, limit?: number): MergedApp[]; + /** Whether the user has installed or starred anything the catalog knows. */ + personalized: boolean; +} + +const WEIGHT = { + pairOwned: 2, + pairLiked: 1, + categoryShare: 1.5, + pick: 1, + recommended: 0.6, + favorite: 3, + /** Multiplier when the user already owns an alternative. */ + alternativeOwned: 0.3, +}; + +/** + * Score catalog apps for one user from what they installed and starred. + * Pure: everything comes in as arguments, so it runs in the renderer and in tests. + */ +export function buildRecommender(apps: MergedApp[], related: RelatedData, profiles: ProfileEntry[] = []): Recommender { + const curated = apps.filter((a) => a.curatedId); + const byCatalogId = new Map(curated.map((a) => [a.curatedId!, a])); + const byPackageId = new Map(curated.map((a) => [a.id, a])); + + // Profiles are hand-picked install bundles, i.e. stacks too (they reference package ids). + const stacks: string[][] = [ + ...related.stacks.map((s) => s.apps), + ...profiles.map((p) => p.packages.map((pkg) => byPackageId.get(pkg.packageId)?.curatedId).filter(Boolean) as string[]), + ].map((group) => group.filter((id) => byCatalogId.has(id))); + const alternatives = related.alternatives.map((g) => g.apps.filter((id) => byCatalogId.has(id))); + + const groupsOf = (groups: string[][], id: string) => groups.filter((g) => g.includes(id)); + const owned = new Set(curated.filter((a) => a.isInstalled).map((a) => a.curatedId!)); + const liked = new Set(curated.filter((a) => a.isFavorite && !a.isInstalled).map((a) => a.curatedId!)); + const personalized = owned.size + liked.size > 0; + + const ownedPerCategory = new Map(); + for (const id of owned) { + const category = byCatalogId.get(id)?.category; + if (category) ownedPerCategory.set(category, (ownedPerCategory.get(category) ?? 0) + 1); + } + + function score(app: MergedApp): Recommendation { + const id = app.curatedId!; + // Which owned/liked app shares the most stacks with this one — it names the reason. + const anchors = new Map(); + let pairScore = 0; + for (const group of groupsOf(stacks, id)) { + for (const other of group) { + if (other === id) continue; + if (owned.has(other)) { + pairScore += WEIGHT.pairOwned; + anchors.set(other, (anchors.get(other) ?? 0) + WEIGHT.pairOwned); + } else if (liked.has(other)) { + pairScore += WEIGHT.pairLiked; + anchors.set(other, (anchors.get(other) ?? 0) + WEIGHT.pairLiked); + } + } + } + // Diminishing returns: five shared stacks are not five times as convincing. + pairScore = Math.sqrt(pairScore) * 2; + + const categoryShare = owned.size && app.category ? (ownedPerCategory.get(app.category) ?? 0) / owned.size : 0; + const editorial = (app.pickTag ? WEIGHT.pick : 0) + (app.recommended ? WEIGHT.recommended : 0); + const isLiked = liked.has(id); + + let total = pairScore + categoryShare * WEIGHT.categoryShare + editorial + (isLiked ? WEIGHT.favorite : 0); + const hasOwnedAlternative = groupsOf(alternatives, id).some((g) => g.some((other) => other !== id && owned.has(other))); + if (hasOwnedAlternative && !isLiked) total *= WEIGHT.alternativeOwned; + + let reason: RecommendationReason; + const anchor = [...anchors.entries()].sort((a, b) => b[1] - a[1])[0]; + if (isLiked) reason = { kind: 'favorite' }; + else if (anchor) reason = { kind: 'pairs', withName: byCatalogId.get(anchor[0])!.name }; + else if (categoryShare > 0 && app.category) reason = { kind: 'category', category: app.category }; + else if (editorial > 0) reason = { kind: 'editor', tag: app.pickTag }; + else reason = { kind: 'popular' }; + + return { app, score: total, reason }; + } + + const byScore = (a: Recommendation, b: Recommendation) => b.score - a.score || a.app.name.localeCompare(b.app.name); + + return { + personalized, + forYou(limit = 24) { + return curated + .filter((a) => !a.isInstalled) + .map(score) + .filter((r) => r.score > 0) + .sort(byScore) + .slice(0, limit); + }, + pairsWith(app, limit = 6) { + const id = app.curatedId; + if (!id) return []; + const altIds = new Set(groupsOf(alternatives, id).flat()); + const counts = new Map(); + for (const group of groupsOf(stacks, id)) { + for (const other of group) { + if (other !== id && !altIds.has(other)) counts.set(other, (counts.get(other) ?? 0) + 1); + } + } + return [...counts.entries()] + .map(([other, count]) => ({ app: byCatalogId.get(other)!, count })) + .sort( + (a, b) => + Number(a.app.isInstalled) - Number(b.app.isInstalled) || + b.count - a.count || + a.app.name.localeCompare(b.app.name), + ) + .slice(0, limit) + .map((x) => x.app); + }, + alternativesTo(app, limit = 6) { + const id = app.curatedId; + if (!id) return []; + const ids = new Set(groupsOf(alternatives, id).flat()); + ids.delete(id); + return [...ids] + .map((other) => byCatalogId.get(other)!) + .sort( + (a, b) => + Number(Boolean(b.pickTag || b.recommended)) - Number(Boolean(a.pickTag || a.recommended)) || + a.name.localeCompare(b.name), + ) + .slice(0, limit); + }, + }; +} diff --git a/src/types.ts b/src/types.ts index c5319ff..0a64988 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export type InstallErrorCode = | 'timeout' | 'http' | 'installer' + | 'untrusted' | 'unknown'; export interface QueueJob {