From 23ef603405124666fde4d771131e8e3d8ef2dd1f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:18:04 +0000 Subject: [PATCH 1/7] Verify catalog installers before running them Direct catalog installs downloaded a file and executed it with no checks. - Downloads must be HTTPS on every redirect hop (loopback http stays for tests). - The file type comes from magic bytes (PE / MSI); anything else, such as an HTML page served by a broken link, is refused instead of executed. This also fixes Edge, whose fwlink serves an MSI that was saved and run as .exe. - Authenticode is checked with Get-AuthenticodeSignature: the status must be Valid and the signer must match the new `publisher` field, or the entry must be marked `unsigned`. Failures use the new `untrusted` error code and fall back to winget when available. - scripts/fetch-publishers.cjs reads signers from the PE certificate table or the MSI DigitalSignature stream and fills catalog.json (99 publishers, 8 unsigned). - Installer path is quoted and silentArgs are limited to a safe charset before reaching cmd.exe (kept for its UAC elevation and .msi association); MSI reboot-required exit codes 3010/1641 count as success. - Remote catalog JSON is validated and stripped of install fields. - shell:openExternal only accepts http(s) URLs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- README.md | 12 ++ data/catalog.json | 109 +++++++++++- electron/main.ts | 14 +- electron/providers/catalog.ts | 115 +++++++++++-- electron/providers/install-errors.ts | 5 +- electron/providers/signature.ts | 97 +++++++++++ package.json | 1 + scripts/fetch-publishers.cjs | 245 +++++++++++++++++++++++++++ scripts/smoke.cjs | 50 ++++++ scripts/test-download.cjs | 42 ++++- src/i18n/translations.ts | 2 + src/types.ts | 1 + 12 files changed, 677 insertions(+), 16 deletions(-) create mode 100644 electron/providers/signature.ts create mode 100644 scripts/fetch-publishers.cjs diff --git a/README.md b/README.md index 4fb40e1..c1eddec 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ - **Очередь с отменой** — можно отменить загрузку/установку, повторить или убрать ошибку - **Понятные ошибки** — 404, блокировка CDN/региона, сеть, таймаут (RU/EN) - **Offline-first** — каталог работает сразу, runtime и метаданные догружаются в фоне +- **Проверка установщиков** — только HTTPS; перед запуском GetBox проверяет Authenticode-подпись и издателя, тип файла определяет по содержимому ## Провайдеры @@ -95,8 +96,19 @@ npm run dist | `npm run prewarm` | Предзагрузка метаданных (CLI) | | `npm test` | Проверка каталога + smoke | | `npm run verify:catalog` | Только проверка `data/catalog.json` | +| `npm run catalog:publishers` | Показать подписантов установщиков; `-- --write` записывает `publisher` / `unsigned` в каталог | | `npm run lint` | ESLint | +### Подпись установщиков + +Перед запуском скачанного установщика из каталога GetBox вызывает `Get-AuthenticodeSignature`: + +- подпись `Valid`, и подписант совпадает с полем `publisher` в `data/catalog.json` (если оно указано), — запуск; +- подписи нет — запуск только для записей с `"unsigned": true`; +- остальные случаи (подпись повреждена, недоверенная цепочка, чужой издатель, HTML вместо установщика) — отказ с ошибкой `untrusted` и переход на winget, если для программы он есть. + +Когда добавляете программу или меняете ссылку, выполните `npm run catalog:publishers -- --write `. + ## Структура репозитория ```text diff --git a/data/catalog.json b/data/catalog.json index 4b437db..996ad63 100644 --- a/data/catalog.json +++ b/data/catalog.json @@ -10,6 +10,7 @@ "wikiTitle": "Mozilla_Firefox", "logoSlug": "firefox", "installUrl": "https://download.mozilla.org/?product=firefox-latest&os=win64&lang=ru", + "publisher": "Mozilla Corporation", "installUrlFallbacks": [ "https://download.mozilla.org/?product=firefox-latest&os=win64&lang=en-US" ], @@ -28,6 +29,7 @@ "wikiTitle": "Google_Chrome", "logoSlug": "google-chrome", "installUrl": "https://dl.google.com/chrome/install/latest/chrome_installer.exe", + "publisher": "Google LLC", "altProvider": { "provider": "winget", "packageId": "Google.Chrome" }, "silentArgs": "/silent /install" }, @@ -42,7 +44,8 @@ "wikiTitle": "Microsoft_Edge", "logoSlug": "microsoft-edge", "installUrl": "https://go.microsoft.com/fwlink/?linkid=2093437", - "silentArgs": "/silent /install" + "publisher": "Microsoft Corporation", + "silentArgs": "/quiet /norestart" }, { "id": "brave", @@ -55,6 +58,7 @@ "wikiTitle": "Brave_(web_browser)", "logoSlug": "brave", "installUrl": "https://laptop-updates.brave.com/latest/winx64", + "publisher": "Brave Software, Inc.", "silentArgs": "/silent /install" }, { @@ -81,6 +85,7 @@ "wikiTitle": "Vivaldi_(web_browser)", "logoSlug": "vivaldi", "installUrl": "https://downloads.vivaldi.com/stable/Vivaldi.7.0.3495.29.x64.exe", + "publisher": "Vivaldi Technologies AS", "silentArgs": "--vivaldi-silent --do-not-launch-chrome" }, { @@ -94,6 +99,7 @@ "wikiTitle": "Steam_(service)", "logoSlug": "steam", "installUrl": "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe", + "publisher": "Valve Corp.", "installUrlFallbacks": [ "https://cdn.cloudflare.steamstatic.com/client/installer/SteamSetup.exe" ], @@ -112,6 +118,7 @@ "wikiTitle": "Epic_Games_Store", "logoSlug": "epic-games", "installUrl": "https://launcher-public-service-prod06.ol.epicgames.com/launcher/api/installer/download/EpicGamesLauncherInstaller.msi", + "publisher": "Epic Games Inc.", "altProvider": { "provider": "winget", "packageId": "EpicGames.EpicGamesLauncher" }, "silentArgs": "/quiet /norestart", "recommended": true @@ -128,6 +135,7 @@ "logoSlug": "gog-galaxy", "simpleSlug": "gogdotcom", "installUrl": "https://webinstallers.gog.com/download/GOG_Galaxy_2.0.exe", + "publisher": "GOG sp. z o.o", "altProvider": { "provider": "winget", "packageId": "GOG.Galaxy" }, "silentArgs": "/S", "recommended": true @@ -144,6 +152,7 @@ "logoSlug": "battlenet", "simpleSlug": "battledotnet", "installUrl": "https://downloader.battle.net/download/getInstaller?os=win&installer=Battle.net-Setup.exe", + "publisher": "Blizzard Entertainment, Inc.", "silentArgs": "--installpath-silent" }, { @@ -158,6 +167,7 @@ "logoSlug": "ubisoft-connect", "simpleSlug": "ubisoft", "installUrl": "https://ubistatic3-a.akamaihd.net/orbit/launcher_installer/UbisoftConnectInstaller.exe", + "publisher": "UBISOFT ENTERTAINMENT INC.", "silentArgs": "/S" }, { @@ -172,6 +182,7 @@ "logoSlug": "electronic-arts", "simpleSlug": "ea", "installUrl": "https://origin-a.akamaihd.net/EA-Desktop-Client-Download/installer-releases/EAappInstaller.exe", + "publisher": "Electronic Arts, Inc.", "silentArgs": "/quiet" }, { @@ -186,6 +197,7 @@ "logoSlug": "rockstar-games", "simpleSlug": "rockstargames", "installUrl": "https://gamedownloads.rockstargames.com/public/installer/Rockstar-Games-Launcher.exe", + "publisher": "Rockstar Games, Inc.", "silentArgs": "/S" }, { @@ -200,6 +212,7 @@ "logoSlug": "playnite", "iconUrl": "https://playnite.link/applogo.png", "installUrl": "https://playnite.link/download/PlayniteInstaller.exe", + "publisher": "SignPath Foundation", "silentArgs": "/SILENT /NORESTART" }, { @@ -213,6 +226,7 @@ "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", + "publisher": "SignPath Foundation", "silentArgs": "/S" }, @@ -227,6 +241,7 @@ "wikiTitle": "Visual_Studio_Code", "logoSlug": "vscode", "installUrl": "https://code.visualstudio.com/sha/download?build=stable&os=win32-x64-user", + "publisher": "Microsoft Corporation", "altProvider": { "provider": "winget", "packageId": "Microsoft.VisualStudioCode" }, "silentArgs": "/VERYSILENT /NORESTART", "recommended": true @@ -242,6 +257,7 @@ "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", + "publisher": "Johannes Schindelin", "altProvider": { "provider": "winget", "packageId": "Git.Git" }, "silentArgs": "/VERYSILENT /NORESTART", "recommended": true @@ -257,6 +273,7 @@ "wikiTitle": "Node.js", "logoSlug": "nodejs", "installUrl": "https://nodejs.org/dist/v22.11.0/node-v22.11.0-x64.msi", + "publisher": "OpenJS Foundation", "altProvider": { "provider": "winget", "packageId": "OpenJS.NodeJS.LTS" }, "silentArgs": "/quiet", "recommended": true @@ -272,6 +289,7 @@ "wikiTitle": "Python_(programming_language)", "logoSlug": "python", "installUrl": "https://www.python.org/ftp/python/3.12.7/python-3.12.7-amd64.exe", + "publisher": "Python Software Foundation", "silentArgs": "/quiet InstallAllUsers=0 PrependPath=1" }, { @@ -286,6 +304,7 @@ "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", + "publisher": "NOTEPAD++", "silentArgs": "/S" }, { @@ -299,6 +318,7 @@ "wikiTitle": "Docker_(software)", "logoSlug": "docker", "installUrl": "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe", + "publisher": "Docker Inc", "silentArgs": "install --quiet" }, { @@ -312,6 +332,7 @@ "wikiTitle": "Postman_(software)", "logoSlug": "postman", "installUrl": "https://dl.pstmn.io/download/latest/win64", + "publisher": "Postman, Inc.", "silentArgs": "/S" }, { @@ -325,6 +346,7 @@ "wikiTitle": "Go_(programming_language)", "logoSlug": "go", "installUrl": "https://go.dev/dl/go1.23.4.windows-amd64.msi", + "publisher": "Google LLC", "silentArgs": "/quiet" }, { @@ -338,6 +360,7 @@ "wikiTitle": "Rust_(programming_language)", "logoSlug": "rust", "installUrl": "https://win.rustup.rs/x86_64", + "unsigned": true, "silentArgs": "-y" }, { @@ -351,6 +374,7 @@ "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", + "publisher": "Eclipse Foundation", "silentArgs": "/quiet" }, { @@ -365,6 +389,7 @@ "logoSlug": "intellij", "simpleSlug": "intellijidea", "installUrl": "https://download.jetbrains.com/idea/ideaIC-2024.3.1.1.exe", + "publisher": "JetBrains s.r.o.", "silentArgs": "/S" }, { @@ -379,6 +404,7 @@ "logoSlug": "pycharm", "simpleSlug": "pycharm", "installUrl": "https://download.jetbrains.com/python/pycharm-community-2024.3.1.1.exe", + "publisher": "JetBrains s.r.o.", "silentArgs": "/S" }, { @@ -393,6 +419,7 @@ "logoSlug": "sublime-text", "simpleSlug": "sublimetext", "installUrl": "https://download.sublimetext.com/sublime_text_build_4192_x64_setup.exe", + "publisher": "Sublime HQ Pty Ltd", "silentArgs": "/VERYSILENT /NORESTART" }, { @@ -406,6 +433,7 @@ "wikiTitle": "Vim_(text_editor)", "logoSlug": "neovim", "installUrl": "https://github.com/neovim/neovim/releases/download/v0.12.4/nvim-win64.msi", + "unsigned": true, "silentArgs": "/quiet" }, { @@ -420,6 +448,7 @@ "logoSlug": "dbeaver", "simpleSlug": "dbeaver", "installUrl": "https://dbeaver.io/files/dbeaver-ce-latest-x86_64-setup.exe", + "publisher": "DBeaver Corp", "silentArgs": "/S" }, { @@ -434,6 +463,7 @@ "logoSlug": "wireshark", "simpleSlug": "wireshark", "installUrl": "https://www.wireshark.org/download/win64/Wireshark-latest-x64.exe", + "publisher": "Wireshark Foundation", "silentArgs": "/S" }, { @@ -447,6 +477,7 @@ "wikiTitle": "VirtualBox", "logoSlug": "virtualbox", "installUrl": "https://download.virtualbox.org/virtualbox/7.2.14/VirtualBox-7.2.14-174565-Win.exe", + "publisher": "Oracle America, Inc.", "silentArgs": "/silent" }, @@ -461,6 +492,7 @@ "wikiTitle": "Discord", "logoSlug": "discord", "installUrl": "https://discord.com/api/download?platform=win", + "publisher": "Discord Inc.", "installUrlFallbacks": [ "https://discord.com/api/downloads/distributions/app/installers/latest?channel=stable&platform=win&arch=x64", "https://discordapp.com/api/download?platform=win" @@ -480,6 +512,7 @@ "wikiTitle": "Telegram_(software)", "logoSlug": "telegram", "installUrl": "https://telegram.org/dl/desktop/win64", + "publisher": "Telegram FZ-LLC", "installUrlFallbacks": [ "https://updates.tdesktop.com/tsetup/x64/tsetup-x64.latest.exe" ], @@ -498,6 +531,7 @@ "wikiTitle": "Slack_(software)", "logoSlug": "slack", "installUrl": "https://downloads.slack-edge.com/desktop-releases/windows/x64/4.42.115/SlackSetup.exe", + "publisher": "Slack Technologies, LLC", "altProvider": { "provider": "winget", "packageId": "SlackTechnologies.Slack" }, "silentArgs": "/silent" }, @@ -525,6 +559,7 @@ "wikiTitle": "Zoom_(software)", "logoSlug": "zoom", "installUrl": "https://zoom.us/client/latest/ZoomInstallerFull.exe", + "publisher": "Zoom Video Communications, Inc.", "installUrlFallbacks": [ "https://zoom.us/client/latest/ZoomInstaller.exe" ], @@ -562,6 +597,7 @@ "wikiTitle": "Spotify", "logoSlug": "spotify", "installUrl": "https://download.scdn.co/SpotifySetup.exe", + "publisher": "Spotify AB", "installUrlFallbacks": [ "https://www.spotify.com/download/win/" ], @@ -580,6 +616,7 @@ "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", + "publisher": "OBS Project, LLC", "altProvider": { "provider": "winget", "packageId": "OBSProject.OBSStudio" }, "silentArgs": "/S", "recommended": true @@ -595,6 +632,7 @@ "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", + "publisher": "MuseCY SM Ltd", "silentArgs": "/VERYSILENT /NORESTART" }, { @@ -608,6 +646,7 @@ "wikiTitle": "HandBrake", "logoSlug": "handbrake", "installUrl": "https://github.com/HandBrake/HandBrake/releases/download/1.11.2/HandBrake-1.11.2-x86_64-Win_GUI.exe", + "publisher": "Open Source Developer, Scott Rae", "silentArgs": "/S" }, { @@ -621,6 +660,7 @@ "wikiTitle": "Kodi_(software)", "logoSlug": "kodi", "installUrl": "https://mirrors.kodi.tv/releases/windows/win64/kodi-21.2-Omega-x64.exe", + "unsigned": true, "silentArgs": "/S" }, { @@ -635,6 +675,7 @@ "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", + "unsigned": true, "silentArgs": "/S" }, @@ -649,6 +690,7 @@ "wikiTitle": "GIMP", "logoSlug": "gimp", "installUrl": "https://download.gimp.org/mirror/pub/gimp/v2.10/windows/gimp-2.10.38-setup.exe", + "publisher": "Jernej Simončič", "silentArgs": "/VERYSILENT /NORESTART /ALLUSERS=0" }, { @@ -663,6 +705,7 @@ "logoSlug": "krita", "simpleSlug": "krita", "installUrl": "https://download.kde.org/stable/krita/5.2.9/krita-x64-5.2.9-setup.exe", + "publisher": "K Desktop Environment e. V.", "silentArgs": "/S" }, { @@ -676,6 +719,7 @@ "wikiTitle": "Blender_(software)", "logoSlug": "blender", "installUrl": "https://mirrors.dotsrc.org/blender/release/Blender4.3/blender-4.3.2-windows-x64.msi", + "publisher": "Stichting Blender Foundation", "silentArgs": "/quiet" }, { @@ -704,6 +748,7 @@ "wikiTitle": "Figma", "logoSlug": "figma", "installUrl": "https://desktop.figma.com/win/FigmaSetup.exe", + "publisher": "Figma, Inc.", "silentArgs": "/S" }, { @@ -718,6 +763,7 @@ "logoSlug": "sharex", "simpleSlug": "sharex", "installUrl": "https://github.com/ShareX/ShareX/releases/download/v21.0.0/ShareX-21.0.0-setup-x64.exe", + "unsigned": true, "silentArgs": "/VERYSILENT /NORESTART" }, @@ -732,6 +778,7 @@ "wikiTitle": "7-Zip", "logoSlug": "7zip", "installUrl": "https://www.7-zip.org/a/7z2501-x64.exe", + "unsigned": true, "altProvider": { "provider": "winget", "packageId": "7zip.7zip" }, "silentArgs": "/S", "recommended": true @@ -748,6 +795,7 @@ "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", + "publisher": "win.rar GmbH", "silentArgs": "/S" }, { @@ -762,6 +810,7 @@ "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", + "publisher": "voidtools", "altProvider": { "provider": "winget", "packageId": "voidtools.Everything" }, "silentArgs": "/S", "recommended": true @@ -835,6 +884,7 @@ "wikiTitle": "AnyDesk", "logoSlug": "anydesk", "installUrl": "https://download.anydesk.com/AnyDesk.exe", + "publisher": "AnyDesk Software GmbH", "silentArgs": "--install --silent" }, { @@ -849,6 +899,7 @@ "logoSlug": "teamviewer", "simpleSlug": "teamviewer", "installUrl": "https://download.teamviewer.com/download/TeamViewer_Setup_x64.exe", + "publisher": "TeamViewer Germany GmbH", "installUrlFallbacks": [ "https://download.teamviewer.com/full" ], @@ -867,6 +918,7 @@ "logoSlug": "parsec", "iconUrl": "https://www.google.com/s2/favicons?domain=parsec.app&sz=256", "installUrl": "https://builds.parsec.app/package/parsec-windows.exe", + "publisher": "Unity Technologies SF", "silentArgs": "/silent" }, { @@ -880,6 +932,7 @@ "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", + "publisher": "Cameron Gutman", "silentArgs": "/S" }, @@ -895,6 +948,7 @@ "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", + "publisher": "CPUID", "silentArgs": "/S" }, { @@ -908,6 +962,7 @@ "logoSlug": "hwmonitor", "iconUrl": "https://www.google.com/s2/favicons?domain=cpuid.com&sz=256", "installUrl": "https://download.cpuid.com/hwmonitor/hwmonitor_1.56.exe", + "publisher": "CPUID", "silentArgs": "/S" }, { @@ -936,6 +991,7 @@ "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", + "publisher": "Microsoft Corporation", "altProvider": { "provider": "winget", "packageId": "Microsoft.PowerToys" }, "silentArgs": "/silent", "recommended": true @@ -952,6 +1008,7 @@ "logoSlug": "rufus", "iconUrl": "https://rufus.ie/pics/rufus-128.png", "installUrl": "https://github.com/pbatard/rufus/releases/download/v4.15/rufus-4.15.exe", + "publisher": "Akeo Consulting", "silentArgs": "/S" }, { @@ -965,6 +1022,7 @@ "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", + "publisher": "JAM Software GmbH", "silentArgs": "/VERYSILENT /NORESTART" }, @@ -993,6 +1051,7 @@ "wikiTitle": "Bitwarden", "logoSlug": "bitwarden", "installUrl": "https://vault.bitwarden.com/download/?app=desktop&platform=windows&variant=exe", + "publisher": "Bitwarden Inc.", "altProvider": { "provider": "winget", "packageId": "Bitwarden.Bitwarden" }, "silentArgs": "/S", "recommended": true @@ -1022,6 +1081,7 @@ "logoSlug": "malwarebytes", "simpleSlug": "malwarebytes", "installUrl": "https://downloads.malwarebytes.com/file/mb-windows", + "publisher": "Malwarebytes Inc", "altProvider": { "provider": "winget", "packageId": "Malwarebytes.Malwarebytes" }, "silentArgs": "/silent" }, @@ -1036,6 +1096,7 @@ "wikiTitle": "KeePassXC", "logoSlug": "keepassxc", "installUrl": "https://github.com/keepassxreboot/keepassxc/releases/download/2.7.9/KeePassXC-2.7.9-Win64.msi", + "publisher": "DroidMonkey Apps, LLC", "silentArgs": "/quiet /norestart" }, { @@ -1049,6 +1110,7 @@ "wikiTitle": "WireGuard", "logoSlug": "wireguard", "installUrl": "https://download.wireguard.com/windows-client/wireguard-installer.exe", + "publisher": "WireGuard LLC", "altProvider": { "provider": "winget", "packageId": "WireGuard.WireGuard" }, "silentArgs": "/S" }, @@ -1063,6 +1125,7 @@ "wikiTitle": "Tailscale", "logoSlug": "tailscale", "installUrl": "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi", + "publisher": "Tailscale Inc.", "silentArgs": "/quiet" }, @@ -1077,6 +1140,7 @@ "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", + "publisher": "The Document Foundation", "altProvider": { "provider": "winget", "packageId": "TheDocumentFoundation.LibreOffice" }, "silentArgs": "/quiet /norestart", "recommended": true @@ -1092,6 +1156,7 @@ "wikiTitle": "Mozilla_Thunderbird", "logoSlug": "thunderbird", "installUrl": "https://download.mozilla.org/?product=thunderbird-latest&os=win64&lang=ru", + "publisher": "Mozilla Corporation", "installUrlFallbacks": [ "https://download.mozilla.org/?product=thunderbird-latest&os=win64&lang=en-US" ], @@ -1109,6 +1174,7 @@ "wikiTitle": "Obsidian_(software)", "logoSlug": "obsidian", "installUrl": "https://github.com/obsidianmd/obsidian-releases/releases/download/v1.13.4/Obsidian-1.13.4.exe", + "publisher": "Dynalist Inc", "altProvider": { "provider": "winget", "packageId": "Obsidian.Obsidian" }, "silentArgs": "/S" }, @@ -1124,6 +1190,7 @@ "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", + "publisher": "Krzysztof Kowalczyk", "silentArgs": "/S" }, { @@ -1137,6 +1204,7 @@ "wikiTitle": "Calibre_(software)", "logoSlug": "calibre", "installUrl": "https://download.calibre-ebook.com/7.23.0/calibre-64bit-7.23.0.msi", + "publisher": "Kovid Goyal", "altProvider": { "provider": "winget", "packageId": "calibre.calibre" }, "silentArgs": "/quiet" }, @@ -1153,6 +1221,7 @@ "logoSlug": "amazon-games", "simpleSlug": "amazongames", "installUrl": "https://download.amazongames.com/AmazonGamesSetup.exe", + "publisher": "Amazon.com Services LLC", "silentArgs": "/S" }, { @@ -1166,6 +1235,7 @@ "wikiTitle": "Minecraft", "logoSlug": "minecraft", "installUrl": "https://launcher.mojang.com/download/MinecraftInstaller.msi", + "publisher": "Microsoft Corporation", "silentArgs": "/quiet" }, { @@ -1179,6 +1249,7 @@ "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", + "publisher": "Seth Flynn", "silentArgs": "/S" }, { @@ -1192,6 +1263,7 @@ "wikiTitle": "Roblox", "logoSlug": "roblox", "installUrl": "https://www.roblox.com/download/client", + "publisher": "Roblox Corporation", "silentArgs": "/S" }, @@ -1207,6 +1279,7 @@ "logoSlug": "yandex-browser", "homepage": "https://browser.yandex.ru/", "installUrl": "https://browser.yandex.ru/download?banerid=8300&partner_id=exp_ya_common", + "publisher": "YANDEX LLC", "silentArgs": "/silent" }, @@ -1221,6 +1294,7 @@ "wikiTitle": "Element_(software)", "logoSlug": "element", "installUrl": "https://packages.element.io/desktop/install/win32/x64/Element%20Setup.exe", + "publisher": "ELEMENT CREATIONS LIMITED", "silentArgs": "/S" }, { @@ -1234,6 +1308,7 @@ "wikiTitle": "TeamSpeak", "logoSlug": "teamspeak", "installUrl": "https://files.teamspeak-services.com/releases/client/3.6.2/TeamSpeak3-Client-win64-3.6.2.exe", + "publisher": "TeamSpeak Systems GmbH", "silentArgs": "/S" }, @@ -1249,6 +1324,7 @@ "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", + "unsigned": true, "silentArgs": "/VERYSILENT /NORESTART" }, { @@ -1263,6 +1339,7 @@ "logoSlug": "kdenlive", "simpleSlug": "kdenlive", "installUrl": "https://download.kde.org/stable/kdenlive/25.08/windows/kdenlive-25.08.0.exe", + "publisher": "K Desktop Environment e. V.", "silentArgs": "/S" }, { @@ -1277,6 +1354,7 @@ "logoSlug": "shotcut", "simpleSlug": "shotcut", "installUrl": "https://github.com/mltframework/shotcut/releases/download/v26.6.25/shotcut-win64-26.6.25.exe", + "publisher": "Meltytech, LLC", "silentArgs": "/S" }, @@ -1292,6 +1370,7 @@ "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", + "unsigned": true, "silentArgs": "/S" }, { @@ -1306,6 +1385,7 @@ "logoSlug": "pinta", "homepage": "https://www.pinta-project.com/", "installUrl": "https://github.com/PintaProject/Pinta/releases/download/3.1.2/Pinta-x86_64.exe", + "publisher": "SignPath Foundation", "silentArgs": "/S" }, { @@ -1320,6 +1400,7 @@ "logoSlug": "xnview", "homepage": "https://www.xnview.com/", "installUrl": "https://download.xnview.com/XnViewMP-win-x64.exe", + "publisher": "GOUGELET PIERRE-EMMANUEL", "silentArgs": "/S" }, { @@ -1334,6 +1415,7 @@ "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", + "publisher": "38236a9c-0242-4542-b67f-d8882dc78761", "silentArgs": "/quiet /norestart" }, { @@ -1348,6 +1430,7 @@ "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", + "publisher": "The FreeCAD project association AISBL", "silentArgs": "/S" }, @@ -1363,6 +1446,7 @@ "logoSlug": "visual-studio", "simpleSlug": "visualstudio", "installUrl": "https://aka.ms/vs/17/release/vs_community.exe", + "publisher": "Microsoft Corporation", "silentArgs": "--quiet --norestart --wait" }, { @@ -1377,6 +1461,7 @@ "logoSlug": "dotnet", "simpleSlug": "dotnet", "installUrl": "https://aka.ms/dotnet/8.0/dotnet-sdk-win-x64.exe", + "publisher": ".NET", "silentArgs": "/install /quiet /norestart" }, { @@ -1391,6 +1476,7 @@ "logoSlug": "powershell", "simpleSlug": "powershell", "installUrl": "https://github.com/PowerShell/PowerShell/releases/download/v7.6.4/PowerShell-7.6.4-win-x64.msi", + "publisher": "Microsoft Corporation", "silentArgs": "/quiet" }, { @@ -1405,6 +1491,7 @@ "logoSlug": "github-desktop", "simpleSlug": "github", "installUrl": "https://central.github.com/deployments/desktop/desktop/latest/win32", + "publisher": "GitHub, Inc.", "silentArgs": "--silent" }, { @@ -1418,6 +1505,7 @@ "logoSlug": "insomnia", "simpleSlug": "insomnia", "installUrl": "https://github.com/Kong/insomnia/releases/download/core%4013.1.0/Insomnia.Core-13.1.0.exe", + "publisher": "Kong Inc.", "silentArgs": "/S" }, { @@ -1432,6 +1520,7 @@ "logoSlug": "winmerge", "homepage": "https://winmerge.org/", "installUrl": "https://github.com/WinMerge/winmerge/releases/download/v2.16.58/WinMerge-2.16.58-x64-Setup.exe", + "publisher": "Takashi Sawanaka", "silentArgs": "/VERYSILENT /NORESTART" }, { @@ -1446,6 +1535,7 @@ "logoSlug": "cmake", "simpleSlug": "cmake", "installUrl": "https://github.com/Kitware/CMake/releases/download/v4.4.2/cmake-4.4.2-windows-x86_64.msi", + "publisher": "Kitware, Inc.", "silentArgs": "/quiet" }, { @@ -1460,6 +1550,7 @@ "logoSlug": "miniconda", "simpleSlug": "anaconda", "installUrl": "https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-x86_64.exe", + "publisher": "Anaconda, Inc.", "silentArgs": "/S /InstallationType=JustMe /AddToPath=1" }, { @@ -1473,6 +1564,7 @@ "wikiTitle": "PostgreSQL", "logoSlug": "postgresql", "installUrl": "https://get.enterprisedb.com/postgresql/postgresql-16.6-1-windows-x64.exe", + "publisher": "EnterpriseDB Corporation", "silentArgs": "--mode unattended" }, { @@ -1486,6 +1578,7 @@ "wikiTitle": "PgAdmin", "logoSlug": "pgadmin", "installUrl": "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/v8.14/windows/pgadmin4-8.14-x64.exe", + "publisher": "EnterpriseDB Corporation", "silentArgs": "/VERYSILENT /NORESTART" }, { @@ -1500,6 +1593,7 @@ "logoSlug": "mongodb-compass", "simpleSlug": "mongodb", "installUrl": "https://downloads.mongodb.com/compass/mongodb-compass-1.44.6-win32-x64.exe", + "publisher": "MONGODB, INC.", "silentArgs": "/S" }, { @@ -1513,6 +1607,7 @@ "logoSlug": "ollama", "homepage": "https://ollama.com/", "installUrl": "https://ollama.com/download/OllamaSetup.exe", + "publisher": "Ollama Inc.", "silentArgs": "/SILENT" }, @@ -1528,6 +1623,7 @@ "logoSlug": "zotero", "simpleSlug": "zotero", "installUrl": "https://www.zotero.org/download/client/dl?channel=release&platform=win-x64", + "publisher": "Corporation for Digital Scholarship", "silentArgs": "/S" }, { @@ -1541,6 +1637,7 @@ "wikiTitle": "Joplin_(software)", "logoSlug": "joplin", "installUrl": "https://github.com/laurent22/joplin/releases/download/v3.6.15/Joplin-Setup-3.6.15.exe", + "publisher": "Joplin", "silentArgs": "/S" }, { @@ -1553,6 +1650,7 @@ "provider": "catalog", "logoSlug": "logseq", "installUrl": "https://github.com/logseq/logseq/releases/download/2.0.1/Logseq-win-x64-2.0.1-nsis.exe", + "publisher": "Logseq, Inc.", "silentArgs": "/S" }, { @@ -1567,6 +1665,7 @@ "logoSlug": "anki", "homepage": "https://apps.ankiweb.net/", "installUrl": "https://github.com/ankitects/anki/releases/download/26.05/anki-26.05-win-x64.msi", + "publisher": "Anki Software LLC", "silentArgs": "/quiet" }, @@ -1581,6 +1680,7 @@ "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", + "publisher": "Winsider Seminars & Solutions Inc.", "silentArgs": "/VERYSILENT /NORESTART" }, { @@ -1594,6 +1694,7 @@ "logoSlug": "wiztree", "homepage": "https://diskanalyzer.com/", "installUrl": "https://diskanalyzer.com/files/wiztree_4_30_setup.exe", + "publisher": "Antibody Software Limited", "silentArgs": "/VERYSILENT /NORESTART" }, { @@ -1608,6 +1709,7 @@ "logoSlug": "bleachbit", "homepage": "https://www.bleachbit.org/", "installUrl": "https://download.bleachbit.org/BleachBit-4.6.2-setup.exe", + "publisher": "Open Source Developer, Andrew Ziem", "silentArgs": "/S" }, @@ -1623,6 +1725,7 @@ "logoSlug": "google-drive", "simpleSlug": "googledrive", "installUrl": "https://dl.google.com/drive-file-stream/GoogleDriveSetup.exe", + "publisher": "Google LLC", "silentArgs": "--silent" }, { @@ -1636,6 +1739,7 @@ "wikiTitle": "Nextcloud", "logoSlug": "nextcloud", "installUrl": "https://github.com/nextcloud-releases/desktop/releases/download/v34.0.0/Nextcloud-34.0.0-x64.msi", + "publisher": "Nextcloud GmbH", "silentArgs": "/quiet" }, { @@ -1650,6 +1754,7 @@ "logoSlug": "free-download-manager", "homepage": "https://www.freedownloadmanager.org/", "installUrl": "https://dn3.freedownloadmanager.org/6/latest/fdm_x64_setup.exe", + "publisher": "Softdeluxe LLC", "silentArgs": "/VERYSILENT /NORESTART" }, { @@ -1663,6 +1768,7 @@ "wikiTitle": "RustDesk", "logoSlug": "rustdesk", "installUrl": "https://github.com/rustdesk/rustdesk/releases/download/1.4.9/rustdesk-1.4.9-x86_64.exe", + "publisher": "PURSLANE", "silentArgs": "--silent-install" }, @@ -1677,6 +1783,7 @@ "wikiTitle": "OpenVPN", "logoSlug": "openvpn", "installUrl": "https://swupdate.openvpn.net/community/releases/OpenVPN-2.6.12-I001-amd64.msi", + "publisher": "OpenVPN Inc.", "silentArgs": "/quiet" } ] diff --git a/electron/main.ts b/electron/main.ts index 86e5827..2588aba 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -265,7 +265,19 @@ 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) => { + // Only web links: file:, ms-*, search-ms: and friends can launch local programs. + let protocol = ''; + try { + protocol = new URL(url).protocol; + } catch { + // malformed → rejected below + } + if (protocol !== 'https:' && protocol !== 'http:') { + 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/providers/catalog.ts b/electron/providers/catalog.ts index 69273ff..0d56453 100644 --- a/electron/providers/catalog.ts +++ b/electron/providers/catalog.ts @@ -24,6 +24,7 @@ import { shouldTryAltProvider, } from './install-errors.js'; import { killProcessTree } from './cli-progress.js'; +import { verifyInstallerSignature } from './signature.js'; import { debugLog } from '../debug-log.js'; interface CatalogItem extends CuratedEntry { @@ -33,6 +34,10 @@ interface CatalogItem extends CuratedEntry { noWingetFallback?: boolean; silentArgs?: string; version?: string; + /** Expected Authenticode signer CN — kept current by scripts/fetch-publishers.cjs. */ + publisher?: string | string[]; + /** Vendor ships an unsigned installer; skip the signer check. */ + unsigned?: boolean; } export type InstallFallback = { provider: 'winget'; packageId: string }; @@ -143,11 +148,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; installs always use the + * bundled catalog. Still drop anything malformed so a bad feed cannot break + * the UI, and strip install fields so they can never be trusted by accident. + */ +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(({ installUrl: _u, installUrlFallbacks: _f, silentArgs: _a, publisher: _p, unsigned: _n, ...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,6 +228,11 @@ 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); @@ -365,6 +411,44 @@ async function downloadUrlWithRetries( throw lastErr instanceof Error ? lastErr : new Error(String(lastErr)); } +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; +} + +/** Silent-install switches: /S, --silent, /InstallationType=JustMe, PrependPath=1 … */ +export const SAFE_INSTALLER_ARG = /^[A-Za-z0-9_\-/=.:]+$/; + +/** Windows Installer "success, reboot required/initiated" — the app is installed. */ +const REBOOT_EXIT_CODES = [3010, 1641]; + function runInstaller(exe: string, args: string[], signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { @@ -372,7 +456,15 @@ function runInstaller(exe: string, args: string[], signal?: AbortSignal): Promis 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. + const unsafe = args.find((a) => !SAFE_INSTALLER_ARG.test(a)); + if (unsafe !== undefined) { + reject(new InstallError('untrusted', 'Недопустимые аргументы установщика', unsafe)); + return; + } + const child = spawn(`"${exe}"`, args, { windowsHide: true, shell: true }); let settled = false; const timer = setTimeout(() => { @@ -401,7 +493,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)) { finish(new InstallError('installer', 'Ошибка установщика', `Exit code ${code}`)); return; } @@ -508,13 +600,9 @@ export class CatalogProvider implements PackageProvider { 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 dest = path.join(tempDir, `${packageId}${ext}`); + // The real type comes from the file's magic bytes once it is on disk. + const dest = path.join(tempDir, `${packageId}.download`); + let installer: string | null = null; const fallback = resolveInstallFallback(item); @@ -547,8 +635,10 @@ export class CatalogProvider implements PackageProvider { } opts.onProgress?.({ phase: 'install' }); - const args = opts.quiet && item.silentArgs ? item.silentArgs.split(' ') : []; - await runInstaller(dest, args, opts.signal); + installer = prepareInstaller(dest); + await verifyInstallerSignature(installer, item); + const args = opts.quiet && item.silentArgs ? item.silentArgs.split(' ').filter(Boolean) : []; + await runInstaller(installer, args, opts.signal); if (opts.signal?.aborted) { return { ok: false, errorCode: 'cancelled', message: 'Установка отменена' }; @@ -565,6 +655,7 @@ export class CatalogProvider implements PackageProvider { } catch (err) { unlinkQuiet(dest); unlinkQuiet(`${dest}.part`); + if (installer) unlinkQuiet(installer); if (isCancelledError(err)) { return { ok: false, errorCode: 'cancelled', message: 'Установка отменена' }; diff --git a/electron/providers/install-errors.ts b/electron/providers/install-errors.ts index bc37165..d2a9fcb 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 }; @@ -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/signature.ts b/electron/providers/signature.ts new file mode 100644 index 0000000..ef137f0 --- /dev/null +++ b/electron/providers/signature.ts @@ -0,0 +1,97 @@ +import { execFile } from 'node:child_process'; +import { InstallError } from './install-errors.js'; + +/** What Get-AuthenticodeSignature reports for a file. */ +export interface SignatureInfo { + /** Valid, NotSigned, HashMismatch, NotTrusted, UnknownError, … */ + status: string; + /** CN of the signer certificate, when there is one. */ + signer?: string; +} + +/** Catalog fields that describe who is expected to have signed the installer. */ +export interface SignatureExpectation { + /** Signer CN recorded by scripts/fetch-publishers.cjs; several when the vendor rotates certificates. */ + publisher?: string | string[]; + /** The vendor ships an unsigned installer; only HTTPS protects it. */ + unsigned?: boolean; +} + +const SIGNATURE_SCRIPT = [ + '[Console]::OutputEncoding = [Text.Encoding]::UTF8', + '$s = Get-AuthenticodeSignature -LiteralPath $env:GETBOX_SIGNATURE_FILE', + '$signer = $null', + 'if ($s.SignerCertificate) { $signer = $s.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) }', + '[pscustomobject]@{ status = [string]$s.Status; signer = $signer } | ConvertTo-Json -Compress', +].join('; '); + +/** Ask Windows to verify the Authenticode signature of `filePath`. */ +export function readSignature(filePath: string): Promise { + return new Promise((resolve, reject) => { + execFile( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', SIGNATURE_SCRIPT], + { + timeout: 60_000, + windowsHide: true, + // The path travels in the environment so it never has to be quoted into the script. + env: { ...process.env, GETBOX_SIGNATURE_FILE: filePath }, + }, + (err, stdout) => { + if (err) { + reject(err); + return; + } + try { + const parsed = JSON.parse(stdout.trim()) as { status?: string; signer?: string | null }; + resolve({ status: parsed.status ?? 'UnknownError', signer: parsed.signer ?? undefined }); + } catch { + reject(new Error(`Unexpected Get-AuthenticodeSignature output: ${stdout.slice(0, 200)}`)); + } + }, + ); + }); +} + +/** + * Decide whether an installer may run. Returns null when it may, otherwise the + * error to report. Pure, so the policy is covered by smoke tests. + * + * - Valid signature: accepted; when `publisher` is known the signer must match it. + * - No signature: accepted only for entries explicitly marked `unsigned`. + * - Anything else (tampered, untrusted chain, …): rejected. + */ +export function checkSignature(info: SignatureInfo, expected: SignatureExpectation): InstallError | null { + const reject = (detail: string) => + new InstallError('untrusted', 'Установщик не прошёл проверку подлинности', detail); + + if (info.status === 'NotSigned') { + return expected.unsigned ? null : reject('Installer is not signed'); + } + if (info.status !== 'Valid') { + return reject(`Signature status: ${info.status}${info.signer ? ` (${info.signer})` : ''}`); + } + + const publishers = [expected.publisher ?? []].flat(); + if (publishers.length === 0) return null; + const signer = (info.signer ?? '').trim().toLowerCase(); + if (publishers.some((p) => p.trim().toLowerCase() === signer)) return null; + return reject(`Signed by "${info.signer ?? '?'}", expected "${publishers.join('" or "')}"`); +} + +/** Throws InstallError('untrusted') unless the downloaded installer passes `checkSignature`. */ +export async function verifyInstallerSignature(filePath: string, expected: SignatureExpectation): Promise { + let info: SignatureInfo; + try { + info = await readSignature(filePath); + } catch (err) { + // Fail closed: an installer we could not verify is not run. + throw new InstallError( + 'untrusted', + 'Не удалось проверить подпись установщика', + err instanceof Error ? err.message : String(err), + ); + } + const problem = checkSignature(info, expected); + if (problem) throw problem; +} diff --git a/package.json b/package.json index e867ec2..dbeb4ca 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "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:publishers": "node scripts/fetch-publishers.cjs", "winget:map": "node scripts/generate-winget-map.cjs", "winget:map:verify": "node scripts/generate-winget-map.cjs --verify", "lint": "eslint . --ext .ts,.tsx" diff --git a/scripts/fetch-publishers.cjs b/scripts/fetch-publishers.cjs new file mode 100644 index 0000000..4a96a50 --- /dev/null +++ b/scripts/fetch-publishers.cjs @@ -0,0 +1,245 @@ +/** + * Read the Authenticode signer of every catalog installer and record it as + * `publisher` in data/catalog.json. GetBox refuses to run a downloaded + * installer whose signer does not match (see electron/providers/signature.ts). + * + * EXE: only the PE header and the certificate table are fetched (HTTP Range) + * when the server allows it. MSI: the signature lives in a stream of the OLE + * container, so the whole file is downloaded. + * + * Run: node scripts/fetch-publishers.cjs [--write] [id ...] + * --write insert/update "publisher" (or "unsigned": true) in catalog.json + */ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { X509Certificate } = require('node:crypto'); + +const ROOT = path.join(__dirname, '..'); +const CATALOG = path.join(ROOT, 'data', 'catalog.json'); +const WRITE = process.argv.includes('--write'); +const ONLY = process.argv.slice(2).filter((a) => !a.startsWith('--')); +const CONCURRENCY = 4; +const MAX_FULL_DOWNLOAD = 600 * 1024 * 1024; +const UA = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; +const CODE_SIGNING_EKU = '1.3.6.1.5.5.7.3.3'; + +async function fetchRange(url, start, end) { + const res = await fetch(url, { + headers: { 'User-Agent': UA, Range: `bytes=${start}-${end}`, 'Accept-Encoding': 'identity' }, + redirect: 'follow', + }); + if (res.status !== 206) { + res.body?.cancel().catch(() => {}); + return null; + } + return Buffer.from(await res.arrayBuffer()); +} + +async function fetchFull(url) { + const res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept-Encoding': 'identity' }, redirect: 'follow' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const len = Number(res.headers.get('content-length')); + if (len > MAX_FULL_DOWNLOAD) throw new Error(`too large (${Math.round(len / 1e6)} MB)`); + return Buffer.from(await res.arrayBuffer()); +} + +/** Locate the certificate table via the PE optional header's data directory #4. */ +function peSecurityDir(head) { + if (head.readUInt16LE(0) !== 0x5a4d) throw new Error('not a PE file'); + const pe = head.readUInt32LE(0x3c); + if (head.readUInt32LE(pe) !== 0x4550) throw new Error('bad PE signature'); + const opt = pe + 24; + const magic = head.readUInt16LE(opt); + const dirs = opt + (magic === 0x20b ? 112 : 96); + return { offset: head.readUInt32LE(dirs + 32), size: head.readUInt32LE(dirs + 36) }; +} + +/** + * Minimal OLE compound-file reader: returns the named root-level stream. + * MSI keeps its Authenticode blob in "\x05DigitalSignature"; scanning the raw + * file instead would find signatures of embedded custom-action DLLs. + */ +function oleStream(buf, wanted) { + if (buf.readUInt32LE(0) !== 0xe011cfd0) throw new Error('not an OLE file'); + const ss = 1 << buf.readUInt16LE(0x1e); + const mss = 1 << buf.readUInt16LE(0x20); + const cutoff = buf.readUInt32LE(0x38); + const sector = (n) => buf.subarray((n + 1) * ss, (n + 2) * ss); + const END = 0xfffffffe; + + const fatSectors = []; + for (let i = 0; i < 109; i++) { + const s = buf.readUInt32LE(0x4c + i * 4); + if (s < 0xfffffffa) fatSectors.push(s); + } + for (let d = buf.readUInt32LE(0x44), n = buf.readUInt32LE(0x48); n-- > 0 && d < 0xfffffffa; ) { + const sec = sector(d); + for (let i = 0; i < ss / 4 - 1; i++) { + const s = sec.readUInt32LE(i * 4); + if (s < 0xfffffffa) fatSectors.push(s); + } + d = sec.readUInt32LE(ss - 4); + } + const fat = Buffer.concat(fatSectors.map(sector)); + const chain = (start, table) => { + const out = []; + for (let s = start; s !== END && s < table.length / 4 && out.length < 1e7; s = table.readUInt32LE(s * 4)) out.push(s); + return out; + }; + const readChain = (start) => Buffer.concat(chain(start, fat).map(sector)); + + const dir = readChain(buf.readUInt32LE(0x30)); + const entries = []; + for (let off = 0; off + 128 <= dir.length; off += 128) { + const nameLen = dir.readUInt16LE(off + 64); + entries.push({ + name: dir.subarray(off, off + Math.max(0, nameLen - 2)).toString('utf16le'), + type: dir[off + 66], + start: dir.readUInt32LE(off + 116), + size: dir.readUInt32LE(off + 120), + }); + } + const entry = entries.find((e) => e.type === 2 && e.name === wanted); + if (!entry) return null; + if (entry.size >= cutoff) return readChain(entry.start).subarray(0, entry.size); + + const miniStream = readChain(entries[0].start); + const miniFat = readChain(buf.readUInt32LE(0x3c)); + const parts = chain(entry.start, miniFat).map((s) => miniStream.subarray(s * mss, (s + 1) * mss)); + return Buffer.concat(parts).subarray(0, entry.size); +} + +/** + * CN of the primary signer's certificate in a PKCS#7 blob. The blob may carry + * several leaf certificates, so the signer is matched by the serial in the + * first signer_info — the same one Get-AuthenticodeSignature reports. + */ +function signerFromPkcs7(der) { + const tmp = path.join(os.tmpdir(), `getbox-sig-${process.pid}-${Math.random().toString(36).slice(2)}.p7b`); + fs.writeFileSync(tmp, der); + try { + const openssl = (...args) => + execFileSync('openssl', ['pkcs7', '-inform', 'DER', '-in', tmp, ...args], { + encoding: 'utf-8', + maxBuffer: 64 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const pem = openssl('-print_certs'); + const certs = (pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map( + (p) => new X509Certificate(p), + ); + const serialMatch = /signer_info:[\s\S]*?serial:\s*(0x[0-9a-f]+|-?\d+)/i.exec(openssl('-print')); + const serial = serialMatch ? BigInt(serialMatch[1]) : null; + const leaf = + certs.find((c) => serial !== null && BigInt(`0x${c.serialNumber}`) === serial) ?? + certs.find((c) => !c.ca && (c.keyUsage ?? []).includes(CODE_SIGNING_EKU)); + if (!leaf) return null; + const cn = /^CN=(.+)$/m.exec(leaf.subject); + return cn ? cn[1].replace(/\\(.)/g, '$1').trim() : null; + } finally { + fs.rmSync(tmp, { force: true }); + } +} + +function peSigner(file) { + const dir = peSecurityDir(file); + if (!dir.offset || !dir.size) return null; + return certTableSigner(file.subarray(dir.offset, dir.offset + dir.size)); +} + +/** WIN_CERTIFICATE: dwLength, wRevision, wCertificateType, then PKCS#7. */ +function certTableSigner(table) { + return signerFromPkcs7(table.subarray(8, table.readUInt32LE(0))); +} + +function msiSigner(file) { + const blob = oleStream(file, '\u0005DigitalSignature'); + return blob ? signerFromPkcs7(blob) : null; +} + +/** File type by magic bytes, the same way GetBox decides how to run it. */ +function kindOf(head) { + if (head.readUInt16LE(0) === 0x5a4d) return 'exe'; + if (head.readUInt32LE(0) === 0xe011cfd0) return 'msi'; + return null; +} + +async function signerOf(url) { + const head = await fetchRange(url, 0, 4095); + if (head && kindOf(head) === 'exe') { + const dir = peSecurityDir(head); + if (!dir.offset || !dir.size) return null; + const table = await fetchRange(url, dir.offset, dir.offset + dir.size - 1); + if (table) return certTableSigner(table); + } + // No range support, an MSI, or a CDN that hands out a different file per request. + const file = await fetchFull(url); + const kind = kindOf(file); + if (kind === 'exe') return peSigner(file); + if (kind === 'msi') return msiSigner(file); + throw new Error(`not an installer (${file.subarray(0, 16).toString('latin1').replace(/[^\x20-\x7e]/g, '.')})`); +} + +async function inspect(item) { + try { + const signer = await signerOf(item.installUrl); + return { id: item.id, signer, unsigned: signer === null }; + } catch (err) { + return { id: item.id, error: err.message }; + } +} + +/** Insert or replace a top-level key inside the item's block, keeping the file's hand formatting. */ +function setField(text, id, key, valueJson) { + const start = text.indexOf(`"id": "${id}"`); + if (start < 0) return text; + const end = text.indexOf('\n }', start); + let block = text.slice(start, end); + block = block.replace(new RegExp(`\\n\\s*"(publisher|unsigned)": [^\\n]*,`, 'g'), ''); + block = block.replace(/(\n(\s*)"installUrl": [^\n]*,)/, `$1\n$2"${key}": ${valueJson},`); + return text.slice(0, start) + block + text.slice(end); +} + +async function main() { + const catalog = JSON.parse(fs.readFileSync(CATALOG, 'utf-8')); + const items = catalog.filter((i) => i.installUrl && (ONLY.length === 0 || ONLY.includes(i.id))); + const results = []; + let next = 0; + await Promise.all( + Array.from({ length: CONCURRENCY }, async () => { + while (next < items.length) { + const item = items[next++]; + const r = await inspect(item); + results.push(r); + const label = r.error ? `ERROR ${r.error}` : r.unsigned ? 'UNSIGNED' : r.signer; + const prev = item.publisher ?? (item.unsigned ? 'UNSIGNED' : '—'); + const mark = !r.error && prev !== label ? ' (changed)' : ''; + console.log(`${item.id.padEnd(24)} ${label}${mark}`); + } + }), + ); + + if (WRITE) { + let text = fs.readFileSync(CATALOG, 'utf-8'); + for (const r of results) { + if (r.error) continue; + text = r.unsigned + ? setField(text, r.id, 'unsigned', 'true') + : setField(text, r.id, 'publisher', JSON.stringify(r.signer)); + } + JSON.parse(text); + fs.writeFileSync(CATALOG, text); + } + + const errors = results.filter((r) => r.error).length; + const unsigned = results.filter((r) => r.unsigned).length; + console.log(`\n${results.length} checked · ${unsigned} unsigned · ${errors} errors${WRITE ? ' · catalog.json updated' : ''}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/smoke.cjs b/scripts/smoke.cjs index dcb6f65..2e8b3fb 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -521,5 +521,55 @@ test('clearDone removes done, cancelled, and failed', () => { assert.equal(q.getJobs().length, 0); }); +// --- installer signature policy (electron/providers/signature.ts) --- +{ + const { checkSignature } = loadTsModule(path.join(__dirname, '..', 'electron', 'providers', 'signature.ts')); + const code = (info, expected) => checkSignature(info, expected)?.code ?? null; + + test('signature: valid + matching publisher passes (case-insensitive)', () => { + assert.equal(code({ status: 'Valid', signer: 'Mozilla Corporation' }, { publisher: 'mozilla corporation' }), null); + assert.equal(code({ status: 'Valid', signer: 'B' }, { publisher: ['A', 'B'] }), null); + }); + + test('signature: valid but different publisher is rejected', () => { + assert.equal(code({ status: 'Valid', signer: 'Evil LLC' }, { publisher: 'Mozilla Corporation' }), 'untrusted'); + }); + + test('signature: valid with no recorded publisher passes', () => { + assert.equal(code({ status: 'Valid', signer: 'Anyone' }, {}), null); + }); + + test('signature: unsigned passes only when catalog marks it unsigned', () => { + assert.equal(code({ status: 'NotSigned' }, {}), 'untrusted'); + assert.equal(code({ status: 'NotSigned' }, { publisher: 'X' }), 'untrusted'); + assert.equal(code({ status: 'NotSigned' }, { unsigned: true }), null); + }); + + test('signature: tampered or untrusted chain is always rejected', () => { + for (const status of ['HashMismatch', 'NotTrusted', 'UnknownError', 'NotSupportedFileFormat']) { + assert.equal(code({ status, signer: 'Mozilla Corporation' }, { publisher: 'Mozilla Corporation' }), 'untrusted', status); + assert.equal(code({ status }, { unsigned: true }), 'untrusted', status); + } + }); + + test('catalog: publisher / unsigned fields are well-formed', () => { + const catalog = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'catalog.json'), 'utf-8')); + for (const item of catalog) { + if (item.publisher !== undefined) { + const list = [item.publisher].flat(); + assert.ok(list.length > 0 && list.every((p) => typeof p === 'string' && p.trim()), item.id); + assert.ok(!item.unsigned, `${item.id}: publisher and unsigned are exclusive`); + } + if (item.unsigned !== undefined) assert.equal(item.unsigned, true, item.id); + if (item.installUrl) assert.ok(item.installUrl.startsWith('https://'), `${item.id}: installUrl must be https`); + // Mirrors SAFE_INSTALLER_ARG in electron/providers/catalog.ts — args reach cmd.exe. + for (const arg of (item.silentArgs ?? '').split(' ').filter(Boolean)) { + assert.match(arg, /^[A-Za-z0-9_\-/=.:]+$/, `${item.id}: unsafe silentArgs`); + } + for (const u of item.installUrlFallbacks ?? []) assert.ok(u.startsWith('https://'), `${item.id}: fallback must be https`); + } + }); +} + 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/src/i18n/translations.ts b/src/i18n/translations.ts index eba338f..e6eeffa 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -70,6 +70,7 @@ export const translations = { timeout: 'Превышено время ожидания.', http: 'Ошибка загрузки с сервера.', installer: 'Установщик завершился с ошибкой.', + untrusted: 'Файл не прошёл проверку подлинности (подпись издателя или небезопасная ссылка). Поставьте через winget («Все пакеты»).', unknown: 'Неизвестная ошибка.', }, }, @@ -235,6 +236,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.', }, }, 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 { From 8ab93f9ad8a4fa5eedc57b30aa6787cde4cf5317 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:34:16 +0000 Subject: [PATCH 2/7] Install from winget manifests instead of hand-kept links; add recommendations Links - catalog.json is curation only: installUrl, fallbacks, silentArgs, altProvider and the Authenticode publisher fields are gone. - scripts/refresh-installers.cjs (npm run catalog:installers) builds data/installers.json from the official winget index on cdn.winget.microsoft.com (source2.msix -> versionData -> manifest): installer URL, SHA256, type, silent/custom switches, success codes. It prefers x64, per-user scope and ru-RU/en-US, and refuses a winget Id whose package name does not match the catalog app. - 113 apps download directly and run only after the SHA256 matches; Teams, ImageGlass and Rufus (MSIX/portable only) go through winget; FileZilla and RustDesk, absent from winget, are pinned to Chocolatey. - Fixed winget Ids: hwmonitor pointed at HWiNFO, plus nine stale Ids. - SHA256 replaces the Authenticode check (signature.ts and fetch-publishers.cjs removed). Installer switches from manifests may contain quotes, so the cmd.exe guard now rejects metacharacters and unbalanced quotes instead of whitelisting characters. - Removed the leftover debug logger that wrote a log file and posted events to a localhost ingest endpoint in production builds. Recommendations - src/recommend.ts scores catalog apps from installed and starred ones: stacks in data/related.json and install profiles ("goes with X"), alternatives demoted once one is owned, category affinity and editorial picks; cold start shows editor's picks. - Home gets a "For you" shelf, the Recommended tab is ranked with a reason per card, and the app modal lists "Often installed together" and "Alternatives". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- README.md | 37 +- data/catalog.json | 608 +++----------- data/installers.json | 1088 ++++++++++++++++++++++++++ data/related.json | 70 ++ data/winget-map.json | 22 +- data/winget-overrides.json | 21 +- electron/catalog/merge.ts | 20 +- electron/debug-log.ts | 53 -- electron/preload.ts | 12 +- electron/providers/catalog.ts | 373 ++++----- electron/providers/install-errors.ts | 2 +- electron/providers/signature.ts | 97 --- electron/providers/types.ts | 20 +- electron/queue/install-queue.ts | 10 - package-lock.json | 1 + package.json | 3 +- scripts/fetch-publishers.cjs | 245 ------ scripts/generate-winget-map.cjs | 32 +- scripts/refresh-installers.cjs | 293 +++++++ scripts/smoke.cjs | 180 +++-- scripts/test-live-install.cjs | 7 +- scripts/test-resilience.cjs | 51 +- scripts/verify-catalog.cjs | 87 +- src/App.tsx | 44 +- src/components/AppModal.tsx | 38 +- src/components/AppMonogram.tsx | 6 +- src/components/PagedGrid.tsx | 5 +- src/components/ShelfRow.tsx | 11 +- src/i18n/helpers.ts | 21 + src/i18n/translations.ts | 26 + src/index.css | 42 + src/recommend.ts | 160 ++++ 32 files changed, 2360 insertions(+), 1325 deletions(-) create mode 100644 data/installers.json create mode 100644 data/related.json delete mode 100644 electron/debug-log.ts delete mode 100644 electron/providers/signature.ts delete mode 100644 scripts/fetch-publishers.cjs create mode 100644 scripts/refresh-installers.cjs create mode 100644 src/recommend.ts diff --git a/README.md b/README.md index c1eddec..bd5bdb9 100644 --- a/README.md +++ b/README.md @@ -19,22 +19,23 @@ ## Возможности -- **Курируемый каталог** — витрина с иконками, описаниями и прямыми ссылками на установщики +- **Курируемый каталог** — витрина с иконками и описаниями; ссылки на установщики не хранятся вручную, а берутся из манифестов winget - **Все пакеты** — индекс Scoop / winget / Chocolatey на отдельной вкладке - **Очередь установок** — один install за раз, с прогрессом -- **Избранное, профили, рекомендации** — быстрый старт наборами программ +- **Персональные рекомендации** — лента «Для вас» по установленному и избранному, «Часто ставят вместе» и «Альтернативы» в карточке программы +- **Избранное и профили** — быстрый старт наборами программ - **RU / EN** — переключение языка в интерфейсе - **Кастомный title bar** — без системного меню File / Edit / View - **Очередь с отменой** — можно отменить загрузку/установку, повторить или убрать ошибку - **Понятные ошибки** — 404, блокировка CDN/региона, сеть, таймаут (RU/EN) - **Offline-first** — каталог работает сразу, runtime и метаданные догружаются в фоне -- **Проверка установщиков** — только HTTPS; перед запуском GetBox проверяет Authenticode-подпись и издателя, тип файла определяет по содержимому +- **Проверка установщиков** — только 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× | Опционально: кнопка «Разрешить установку» при первом запуске | @@ -96,25 +97,37 @@ npm run dist | `npm run prewarm` | Предзагрузка метаданных (CLI) | | `npm test` | Проверка каталога + smoke | | `npm run verify:catalog` | Только проверка `data/catalog.json` | -| `npm run catalog:publishers` | Показать подписантов установщиков; `-- --write` записывает `publisher` / `unsigned` в каталог | +| `npm run catalog:installers` | Обновить `data/installers.json` из манифестов winget (Node 22.5+); `-- --check` только проверяет | | `npm run lint` | ESLint | -### Подпись установщиков +### Откуда берутся установщики -Перед запуском скачанного установщика из каталога GetBox вызывает `Get-AuthenticodeSignature`: +В `data/catalog.json` только витрина: название, описание, категория, иконка. Как ставить программу, решает `data/installers.json`, сгенерированный `npm run catalog:installers` из официального индекса winget (`cdn.winget.microsoft.com`, тот же источник, что у клиента winget): -- подпись `Valid`, и подписант совпадает с полем `publisher` в `data/catalog.json` (если оно указано), — запуск; -- подписи нет — запуск только для записей с `"unsigned": true`; -- остальные случаи (подпись повреждена, недоверенная цепочка, чужой издатель, HTML вместо установщика) — отказ с ошибкой `untrusted` и переход на winget, если для программы он есть. +- **прямая загрузка** (113 программ) — URL, SHA256, тип и ключи тихой установки из манифеста. GetBox качает только по HTTPS, сверяет SHA256 и лишь потом запускает; при любом сбое ставит через winget; +- **только winget** — у программы есть лишь MSIX / portable-сборка (Teams, ImageGlass, Rufus); +- **другой менеджер** — записи вида `{ "provider": "chocolatey", "packageId": "filezilla" }` для программ, которых нет в winget. Их скрипт не трогает. -Когда добавляете программу или меняете ссылку, выполните `npm run catalog:publishers -- --write `. +Связь «программа → winget Id» — в `data/winget-map.json`. Скрипт сверяет название пакета с каталогом и не даст подставить чужую программу из-за опечатки в Id. + +Добавили программу: допишите её в `catalog.json` и `winget-overrides.json`, затем `npm run winget:map` и `npm run catalog:installers`. + +### Рекомендации + +`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 996ad63..5296ce8 100644 --- a/data/catalog.json +++ b/data/catalog.json @@ -9,13 +9,6 @@ "provider": "catalog", "wikiTitle": "Mozilla_Firefox", "logoSlug": "firefox", - "installUrl": "https://download.mozilla.org/?product=firefox-latest&os=win64&lang=ru", - "publisher": "Mozilla Corporation", - "installUrlFallbacks": [ - "https://download.mozilla.org/?product=firefox-latest&os=win64&lang=en-US" - ], - "altProvider": { "provider": "winget", "packageId": "Mozilla.Firefox" }, - "silentArgs": "/S", "recommended": true }, { @@ -27,11 +20,7 @@ "packageId": "googlechrome", "provider": "catalog", "wikiTitle": "Google_Chrome", - "logoSlug": "google-chrome", - "installUrl": "https://dl.google.com/chrome/install/latest/chrome_installer.exe", - "publisher": "Google LLC", - "altProvider": { "provider": "winget", "packageId": "Google.Chrome" }, - "silentArgs": "/silent /install" + "logoSlug": "google-chrome" }, { "id": "edge", @@ -42,10 +31,7 @@ "packageId": "microsoft-edge", "provider": "catalog", "wikiTitle": "Microsoft_Edge", - "logoSlug": "microsoft-edge", - "installUrl": "https://go.microsoft.com/fwlink/?linkid=2093437", - "publisher": "Microsoft Corporation", - "silentArgs": "/quiet /norestart" + "logoSlug": "microsoft-edge" }, { "id": "brave", @@ -56,10 +42,7 @@ "packageId": "brave", "provider": "catalog", "wikiTitle": "Brave_(web_browser)", - "logoSlug": "brave", - "installUrl": "https://laptop-updates.brave.com/latest/winx64", - "publisher": "Brave Software, Inc.", - "silentArgs": "/silent /install" + "logoSlug": "brave" }, { "id": "opera", @@ -70,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", @@ -83,10 +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", - "publisher": "Vivaldi Technologies AS", - "silentArgs": "--vivaldi-silent --do-not-launch-chrome" + "logoSlug": "vivaldi" }, { "id": "steam", @@ -98,13 +76,6 @@ "provider": "catalog", "wikiTitle": "Steam_(service)", "logoSlug": "steam", - "installUrl": "https://cdn.akamai.steamstatic.com/client/installer/SteamSetup.exe", - "publisher": "Valve Corp.", - "installUrlFallbacks": [ - "https://cdn.cloudflare.steamstatic.com/client/installer/SteamSetup.exe" - ], - "altProvider": { "provider": "winget", "packageId": "Valve.Steam" }, - "silentArgs": "/S", "recommended": true }, { @@ -117,10 +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", - "publisher": "Epic Games Inc.", - "altProvider": { "provider": "winget", "packageId": "EpicGames.EpicGamesLauncher" }, - "silentArgs": "/quiet /norestart", "recommended": true }, { @@ -134,10 +101,6 @@ "wikiTitle": "GOG.com", "logoSlug": "gog-galaxy", "simpleSlug": "gogdotcom", - "installUrl": "https://webinstallers.gog.com/download/GOG_Galaxy_2.0.exe", - "publisher": "GOG sp. z o.o", - "altProvider": { "provider": "winget", "packageId": "GOG.Galaxy" }, - "silentArgs": "/S", "recommended": true }, { @@ -150,10 +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", - "publisher": "Blizzard Entertainment, Inc.", - "silentArgs": "--installpath-silent" + "simpleSlug": "battledotnet" }, { "id": "ubisoft-connect", @@ -165,10 +125,7 @@ "provider": "catalog", "wikiTitle": "Ubisoft_Connect", "logoSlug": "ubisoft-connect", - "simpleSlug": "ubisoft", - "installUrl": "https://ubistatic3-a.akamaihd.net/orbit/launcher_installer/UbisoftConnectInstaller.exe", - "publisher": "UBISOFT ENTERTAINMENT INC.", - "silentArgs": "/S" + "simpleSlug": "ubisoft" }, { "id": "ea-app", @@ -180,10 +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", - "publisher": "Electronic Arts, Inc.", - "silentArgs": "/quiet" + "simpleSlug": "ea" }, { "id": "rockstar", @@ -195,10 +149,7 @@ "provider": "catalog", "wikiTitle": "Rockstar_Games_Launcher", "logoSlug": "rockstar-games", - "simpleSlug": "rockstargames", - "installUrl": "https://gamedownloads.rockstargames.com/public/installer/Rockstar-Games-Launcher.exe", - "publisher": "Rockstar Games, Inc.", - "silentArgs": "/S" + "simpleSlug": "rockstargames" }, { "id": "playnite", @@ -210,10 +161,7 @@ "provider": "catalog", "wikiTitle": "Playnite", "logoSlug": "playnite", - "iconUrl": "https://playnite.link/applogo.png", - "installUrl": "https://playnite.link/download/PlayniteInstaller.exe", - "publisher": "SignPath Foundation", - "silentArgs": "/SILENT /NORESTART" + "iconUrl": "https://playnite.link/applogo.png" }, { "id": "heroic", @@ -224,10 +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", - "publisher": "SignPath Foundation", - "silentArgs": "/S" + "iconUrl": "https://raw.githubusercontent.com/Heroic-Games-Launcher/HeroicGamesLauncher/main/public/icon.png" }, { @@ -240,10 +185,6 @@ "provider": "catalog", "wikiTitle": "Visual_Studio_Code", "logoSlug": "vscode", - "installUrl": "https://code.visualstudio.com/sha/download?build=stable&os=win32-x64-user", - "publisher": "Microsoft Corporation", - "altProvider": { "provider": "winget", "packageId": "Microsoft.VisualStudioCode" }, - "silentArgs": "/VERYSILENT /NORESTART", "recommended": true }, { @@ -256,10 +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", - "publisher": "Johannes Schindelin", - "altProvider": { "provider": "winget", "packageId": "Git.Git" }, - "silentArgs": "/VERYSILENT /NORESTART", "recommended": true }, { @@ -272,10 +209,6 @@ "provider": "catalog", "wikiTitle": "Node.js", "logoSlug": "nodejs", - "installUrl": "https://nodejs.org/dist/v22.11.0/node-v22.11.0-x64.msi", - "publisher": "OpenJS Foundation", - "altProvider": { "provider": "winget", "packageId": "OpenJS.NodeJS.LTS" }, - "silentArgs": "/quiet", "recommended": true }, { @@ -287,10 +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", - "publisher": "Python Software Foundation", - "silentArgs": "/quiet InstallAllUsers=0 PrependPath=1" + "logoSlug": "python" }, { "id": "notepadpp", @@ -302,10 +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", - "publisher": "NOTEPAD++", - "silentArgs": "/S" + "simpleSlug": "notepadplusplus" }, { "id": "docker", @@ -316,10 +243,7 @@ "packageId": "docker", "provider": "catalog", "wikiTitle": "Docker_(software)", - "logoSlug": "docker", - "installUrl": "https://desktop.docker.com/win/main/amd64/Docker%20Desktop%20Installer.exe", - "publisher": "Docker Inc", - "silentArgs": "install --quiet" + "logoSlug": "docker" }, { "id": "postman", @@ -330,10 +254,7 @@ "packageId": "postman", "provider": "catalog", "wikiTitle": "Postman_(software)", - "logoSlug": "postman", - "installUrl": "https://dl.pstmn.io/download/latest/win64", - "publisher": "Postman, Inc.", - "silentArgs": "/S" + "logoSlug": "postman" }, { "id": "golang", @@ -344,10 +265,7 @@ "packageId": "go", "provider": "catalog", "wikiTitle": "Go_(programming_language)", - "logoSlug": "go", - "installUrl": "https://go.dev/dl/go1.23.4.windows-amd64.msi", - "publisher": "Google LLC", - "silentArgs": "/quiet" + "logoSlug": "go" }, { "id": "rust", @@ -358,10 +276,7 @@ "packageId": "rustup", "provider": "catalog", "wikiTitle": "Rust_(programming_language)", - "logoSlug": "rust", - "installUrl": "https://win.rustup.rs/x86_64", - "unsigned": true, - "silentArgs": "-y" + "logoSlug": "rust" }, { "id": "temurin-jdk", @@ -372,10 +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", - "publisher": "Eclipse Foundation", - "silentArgs": "/quiet" + "logoSlug": "java" }, { "id": "intellij-idea", @@ -387,10 +299,7 @@ "provider": "catalog", "wikiTitle": "IntelliJ_IDEA", "logoSlug": "intellij", - "simpleSlug": "intellijidea", - "installUrl": "https://download.jetbrains.com/idea/ideaIC-2024.3.1.1.exe", - "publisher": "JetBrains s.r.o.", - "silentArgs": "/S" + "simpleSlug": "intellijidea" }, { "id": "pycharm", @@ -402,10 +311,7 @@ "provider": "catalog", "wikiTitle": "PyCharm", "logoSlug": "pycharm", - "simpleSlug": "pycharm", - "installUrl": "https://download.jetbrains.com/python/pycharm-community-2024.3.1.1.exe", - "publisher": "JetBrains s.r.o.", - "silentArgs": "/S" + "simpleSlug": "pycharm" }, { "id": "sublime-text", @@ -417,10 +323,7 @@ "provider": "catalog", "wikiTitle": "Sublime_Text", "logoSlug": "sublime-text", - "simpleSlug": "sublimetext", - "installUrl": "https://download.sublimetext.com/sublime_text_build_4192_x64_setup.exe", - "publisher": "Sublime HQ Pty Ltd", - "silentArgs": "/VERYSILENT /NORESTART" + "simpleSlug": "sublimetext" }, { "id": "neovim", @@ -431,10 +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", - "unsigned": true, - "silentArgs": "/quiet" + "logoSlug": "neovim" }, { "id": "dbeaver", @@ -446,10 +346,7 @@ "provider": "catalog", "wikiTitle": "DBeaver", "logoSlug": "dbeaver", - "simpleSlug": "dbeaver", - "installUrl": "https://dbeaver.io/files/dbeaver-ce-latest-x86_64-setup.exe", - "publisher": "DBeaver Corp", - "silentArgs": "/S" + "simpleSlug": "dbeaver" }, { "id": "wireshark", @@ -461,10 +358,7 @@ "provider": "catalog", "wikiTitle": "Wireshark", "logoSlug": "wireshark", - "simpleSlug": "wireshark", - "installUrl": "https://www.wireshark.org/download/win64/Wireshark-latest-x64.exe", - "publisher": "Wireshark Foundation", - "silentArgs": "/S" + "simpleSlug": "wireshark" }, { "id": "virtualbox", @@ -475,10 +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", - "publisher": "Oracle America, Inc.", - "silentArgs": "/silent" + "logoSlug": "virtualbox" }, { @@ -491,14 +382,6 @@ "provider": "catalog", "wikiTitle": "Discord", "logoSlug": "discord", - "installUrl": "https://discord.com/api/download?platform=win", - "publisher": "Discord Inc.", - "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 }, { @@ -511,13 +394,6 @@ "provider": "catalog", "wikiTitle": "Telegram_(software)", "logoSlug": "telegram", - "installUrl": "https://telegram.org/dl/desktop/win64", - "publisher": "Telegram FZ-LLC", - "installUrlFallbacks": [ - "https://updates.tdesktop.com/tsetup/x64/tsetup-x64.latest.exe" - ], - "altProvider": { "provider": "winget", "packageId": "Telegram.TelegramDesktop" }, - "silentArgs": "/VERYSILENT /NORESTART", "recommended": true }, { @@ -529,11 +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", - "publisher": "Slack Technologies, LLC", - "altProvider": { "provider": "winget", "packageId": "SlackTechnologies.Slack" }, - "silentArgs": "/silent" + "logoSlug": "slack" }, { "id": "teams", @@ -544,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", @@ -557,14 +427,7 @@ "packageId": "zoom", "provider": "catalog", "wikiTitle": "Zoom_(software)", - "logoSlug": "zoom", - "installUrl": "https://zoom.us/client/latest/ZoomInstallerFull.exe", - "publisher": "Zoom Video Communications, Inc.", - "installUrlFallbacks": [ - "https://zoom.us/client/latest/ZoomInstaller.exe" - ], - "altProvider": { "provider": "winget", "packageId": "Zoom.Zoom" }, - "silentArgs": "/silent" + "logoSlug": "zoom" }, { @@ -578,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 }, { @@ -595,14 +452,7 @@ "packageId": "spotify", "provider": "catalog", "wikiTitle": "Spotify", - "logoSlug": "spotify", - "installUrl": "https://download.scdn.co/SpotifySetup.exe", - "publisher": "Spotify AB", - "installUrlFallbacks": [ - "https://www.spotify.com/download/win/" - ], - "altProvider": { "provider": "winget", "packageId": "Spotify.Spotify" }, - "silentArgs": "/silent" + "logoSlug": "spotify" }, { "id": "obs", @@ -615,10 +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", - "publisher": "OBS Project, LLC", - "altProvider": { "provider": "winget", "packageId": "OBSProject.OBSStudio" }, - "silentArgs": "/S", "recommended": true }, { @@ -630,10 +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", - "publisher": "MuseCY SM Ltd", - "silentArgs": "/VERYSILENT /NORESTART" + "logoSlug": "audacity" }, { "id": "handbrake", @@ -644,10 +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", - "publisher": "Open Source Developer, Scott Rae", - "silentArgs": "/S" + "logoSlug": "handbrake" }, { "id": "kodi", @@ -658,10 +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", - "unsigned": true, - "silentArgs": "/S" + "logoSlug": "kodi" }, { "id": "foobar2000", @@ -673,10 +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", - "unsigned": true, - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=foobar2000.org&sz=256" }, { @@ -688,10 +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", - "publisher": "Jernej Simončič", - "silentArgs": "/VERYSILENT /NORESTART /ALLUSERS=0" + "logoSlug": "gimp" }, { "id": "krita", @@ -703,10 +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", - "publisher": "K Desktop Environment e. V.", - "silentArgs": "/S" + "simpleSlug": "krita" }, { "id": "blender", @@ -717,10 +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", - "publisher": "Stichting Blender Foundation", - "silentArgs": "/quiet" + "logoSlug": "blender" }, { "id": "inkscape", @@ -732,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", @@ -746,10 +568,7 @@ "packageId": "figma", "provider": "catalog", "wikiTitle": "Figma", - "logoSlug": "figma", - "installUrl": "https://desktop.figma.com/win/FigmaSetup.exe", - "publisher": "Figma, Inc.", - "silentArgs": "/S" + "logoSlug": "figma" }, { "id": "sharex", @@ -761,10 +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", - "unsigned": true, - "silentArgs": "/VERYSILENT /NORESTART" + "simpleSlug": "sharex" }, { @@ -777,10 +593,6 @@ "provider": "catalog", "wikiTitle": "7-Zip", "logoSlug": "7zip", - "installUrl": "https://www.7-zip.org/a/7z2501-x64.exe", - "unsigned": true, - "altProvider": { "provider": "winget", "packageId": "7zip.7zip" }, - "silentArgs": "/S", "recommended": true }, { @@ -793,10 +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", - "publisher": "win.rar GmbH", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=win-rar.com&sz=256" }, { "id": "everything", @@ -809,10 +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", - "publisher": "voidtools", - "altProvider": { "provider": "winget", "packageId": "voidtools.Everything" }, - "silentArgs": "/S", "recommended": true }, { @@ -824,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", @@ -838,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", @@ -852,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", @@ -865,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", @@ -882,10 +674,7 @@ "packageId": "anydesk", "provider": "catalog", "wikiTitle": "AnyDesk", - "logoSlug": "anydesk", - "installUrl": "https://download.anydesk.com/AnyDesk.exe", - "publisher": "AnyDesk Software GmbH", - "silentArgs": "--install --silent" + "logoSlug": "anydesk" }, { "id": "teamviewer", @@ -897,14 +686,7 @@ "provider": "catalog", "wikiTitle": "TeamViewer", "logoSlug": "teamviewer", - "simpleSlug": "teamviewer", - "installUrl": "https://download.teamviewer.com/download/TeamViewer_Setup_x64.exe", - "publisher": "TeamViewer Germany GmbH", - "installUrlFallbacks": [ - "https://download.teamviewer.com/full" - ], - "altProvider": { "provider": "winget", "packageId": "TeamViewer.TeamViewer" }, - "silentArgs": "/S" + "simpleSlug": "teamviewer" }, { "id": "parsec", @@ -916,10 +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", - "publisher": "Unity Technologies SF", - "silentArgs": "/silent" + "iconUrl": "https://www.google.com/s2/favicons?domain=parsec.app&sz=256" }, { "id": "moonlight", @@ -930,10 +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", - "publisher": "Cameron Gutman", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=moonlight-stream.org&sz=256" }, { @@ -946,10 +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", - "publisher": "CPUID", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=cpuid.com&sz=256" }, { "id": "hwmonitor", @@ -960,10 +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", - "publisher": "CPUID", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=cpuid.com&sz=256" }, { "id": "crystaldiskinfo", @@ -975,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", @@ -990,10 +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", - "publisher": "Microsoft Corporation", - "altProvider": { "provider": "winget", "packageId": "Microsoft.PowerToys" }, - "silentArgs": "/silent", "recommended": true }, { @@ -1006,10 +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", - "publisher": "Akeo Consulting", - "silentArgs": "/S" + "iconUrl": "https://rufus.ie/pics/rufus-128.png" }, { "id": "treesize-free", @@ -1020,10 +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", - "publisher": "JAM Software GmbH", - "silentArgs": "/VERYSILENT /NORESTART" + "iconUrl": "https://www.google.com/s2/favicons?domain=jam-software.com&sz=256" }, { @@ -1035,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", @@ -1050,10 +805,6 @@ "provider": "catalog", "wikiTitle": "Bitwarden", "logoSlug": "bitwarden", - "installUrl": "https://vault.bitwarden.com/download/?app=desktop&platform=windows&variant=exe", - "publisher": "Bitwarden Inc.", - "altProvider": { "provider": "winget", "packageId": "Bitwarden.Bitwarden" }, - "silentArgs": "/S", "recommended": true }, { @@ -1065,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", @@ -1079,11 +828,7 @@ "provider": "catalog", "wikiTitle": "Malwarebytes_(software)", "logoSlug": "malwarebytes", - "simpleSlug": "malwarebytes", - "installUrl": "https://downloads.malwarebytes.com/file/mb-windows", - "publisher": "Malwarebytes Inc", - "altProvider": { "provider": "winget", "packageId": "Malwarebytes.Malwarebytes" }, - "silentArgs": "/silent" + "simpleSlug": "malwarebytes" }, { "id": "keepassxc", @@ -1094,10 +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", - "publisher": "DroidMonkey Apps, LLC", - "silentArgs": "/quiet /norestart" + "logoSlug": "keepassxc" }, { "id": "wireguard", @@ -1108,11 +850,7 @@ "packageId": "wireguard", "provider": "catalog", "wikiTitle": "WireGuard", - "logoSlug": "wireguard", - "installUrl": "https://download.wireguard.com/windows-client/wireguard-installer.exe", - "publisher": "WireGuard LLC", - "altProvider": { "provider": "winget", "packageId": "WireGuard.WireGuard" }, - "silentArgs": "/S" + "logoSlug": "wireguard" }, { "id": "tailscale", @@ -1123,10 +861,7 @@ "packageId": "tailscale", "provider": "catalog", "wikiTitle": "Tailscale", - "logoSlug": "tailscale", - "installUrl": "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi", - "publisher": "Tailscale Inc.", - "silentArgs": "/quiet" + "logoSlug": "tailscale" }, { @@ -1139,10 +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", - "publisher": "The Document Foundation", - "altProvider": { "provider": "winget", "packageId": "TheDocumentFoundation.LibreOffice" }, - "silentArgs": "/quiet /norestart", "recommended": true }, { @@ -1154,14 +885,7 @@ "packageId": "thunderbird", "provider": "catalog", "wikiTitle": "Mozilla_Thunderbird", - "logoSlug": "thunderbird", - "installUrl": "https://download.mozilla.org/?product=thunderbird-latest&os=win64&lang=ru", - "publisher": "Mozilla Corporation", - "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", @@ -1172,11 +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", - "publisher": "Dynalist Inc", - "altProvider": { "provider": "winget", "packageId": "Obsidian.Obsidian" }, - "silentArgs": "/S" + "logoSlug": "obsidian" }, { "id": "sumatra", @@ -1188,10 +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", - "publisher": "Krzysztof Kowalczyk", - "silentArgs": "/S" + "iconUrl": "https://www.google.com/s2/favicons?domain=sumatrapdfreader.org&sz=256" }, { "id": "calibre", @@ -1202,11 +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", - "publisher": "Kovid Goyal", - "altProvider": { "provider": "winget", "packageId": "calibre.calibre" }, - "silentArgs": "/quiet" + "logoSlug": "calibre" }, { @@ -1219,10 +932,7 @@ "provider": "catalog", "wikiTitle": "Amazon_Games", "logoSlug": "amazon-games", - "simpleSlug": "amazongames", - "installUrl": "https://download.amazongames.com/AmazonGamesSetup.exe", - "publisher": "Amazon.com Services LLC", - "silentArgs": "/S" + "simpleSlug": "amazongames" }, { "id": "minecraft-launcher", @@ -1233,10 +943,7 @@ "packageId": "minecraft-launcher", "provider": "catalog", "wikiTitle": "Minecraft", - "logoSlug": "minecraft", - "installUrl": "https://launcher.mojang.com/download/MinecraftInstaller.msi", - "publisher": "Microsoft Corporation", - "silentArgs": "/quiet" + "logoSlug": "minecraft" }, { "id": "prism-launcher", @@ -1247,10 +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", - "publisher": "Seth Flynn", - "silentArgs": "/S" + "homepage": "https://prismlauncher.org/" }, { "id": "roblox", @@ -1261,10 +965,7 @@ "packageId": "roblox", "provider": "catalog", "wikiTitle": "Roblox", - "logoSlug": "roblox", - "installUrl": "https://www.roblox.com/download/client", - "publisher": "Roblox Corporation", - "silentArgs": "/S" + "logoSlug": "roblox" }, { @@ -1277,10 +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", - "publisher": "YANDEX LLC", - "silentArgs": "/silent" + "homepage": "https://browser.yandex.ru/" }, { @@ -1292,10 +990,7 @@ "packageId": "element", "provider": "catalog", "wikiTitle": "Element_(software)", - "logoSlug": "element", - "installUrl": "https://packages.element.io/desktop/install/win32/x64/Element%20Setup.exe", - "publisher": "ELEMENT CREATIONS LIMITED", - "silentArgs": "/S" + "logoSlug": "element" }, { "id": "teamspeak", @@ -1306,10 +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", - "publisher": "TeamSpeak Systems GmbH", - "silentArgs": "/S" + "logoSlug": "teamspeak" }, { @@ -1322,10 +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", - "unsigned": true, - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://mpc-hc.org/" }, { "id": "kdenlive", @@ -1337,10 +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", - "publisher": "K Desktop Environment e. V.", - "silentArgs": "/S" + "simpleSlug": "kdenlive" }, { "id": "shotcut", @@ -1352,10 +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", - "publisher": "Meltytech, LLC", - "silentArgs": "/S" + "simpleSlug": "shotcut" }, { @@ -1368,10 +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", - "unsigned": true, - "silentArgs": "/S" + "homepage": "https://www.darktable.org/" }, { "id": "pinta", @@ -1383,10 +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", - "publisher": "SignPath Foundation", - "silentArgs": "/S" + "homepage": "https://www.pinta-project.com/" }, { "id": "xnviewmp", @@ -1398,10 +1075,7 @@ "provider": "catalog", "wikiTitle": "XnView", "logoSlug": "xnview", - "homepage": "https://www.xnview.com/", - "installUrl": "https://download.xnview.com/XnViewMP-win-x64.exe", - "publisher": "GOUGELET PIERRE-EMMANUEL", - "silentArgs": "/S" + "homepage": "https://www.xnview.com/" }, { "id": "imageglass", @@ -1413,10 +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", - "publisher": "38236a9c-0242-4542-b67f-d8882dc78761", - "silentArgs": "/quiet /norestart" + "homepage": "https://imageglass.org/" }, { "id": "freecad", @@ -1428,10 +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", - "publisher": "The FreeCAD project association AISBL", - "silentArgs": "/S" + "simpleSlug": "freecad" }, { @@ -1444,10 +1112,7 @@ "provider": "catalog", "wikiTitle": "Microsoft_Visual_Studio", "logoSlug": "visual-studio", - "simpleSlug": "visualstudio", - "installUrl": "https://aka.ms/vs/17/release/vs_community.exe", - "publisher": "Microsoft Corporation", - "silentArgs": "--quiet --norestart --wait" + "simpleSlug": "visualstudio" }, { "id": "dotnet-sdk", @@ -1459,10 +1124,7 @@ "provider": "catalog", "wikiTitle": ".NET", "logoSlug": "dotnet", - "simpleSlug": "dotnet", - "installUrl": "https://aka.ms/dotnet/8.0/dotnet-sdk-win-x64.exe", - "publisher": ".NET", - "silentArgs": "/install /quiet /norestart" + "simpleSlug": "dotnet" }, { "id": "powershell", @@ -1474,10 +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", - "publisher": "Microsoft Corporation", - "silentArgs": "/quiet" + "simpleSlug": "powershell" }, { "id": "github-desktop", @@ -1489,10 +1148,7 @@ "provider": "catalog", "wikiTitle": "GitHub_Desktop", "logoSlug": "github-desktop", - "simpleSlug": "github", - "installUrl": "https://central.github.com/deployments/desktop/desktop/latest/win32", - "publisher": "GitHub, Inc.", - "silentArgs": "--silent" + "simpleSlug": "github" }, { "id": "insomnia", @@ -1503,10 +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", - "publisher": "Kong Inc.", - "silentArgs": "/S" + "simpleSlug": "insomnia" }, { "id": "winmerge", @@ -1518,10 +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", - "publisher": "Takashi Sawanaka", - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://winmerge.org/" }, { "id": "cmake", @@ -1533,10 +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", - "publisher": "Kitware, Inc.", - "silentArgs": "/quiet" + "simpleSlug": "cmake" }, { "id": "miniconda", @@ -1548,10 +1195,7 @@ "provider": "catalog", "wikiTitle": "Anaconda_(Python_distribution)", "logoSlug": "miniconda", - "simpleSlug": "anaconda", - "installUrl": "https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-x86_64.exe", - "publisher": "Anaconda, Inc.", - "silentArgs": "/S /InstallationType=JustMe /AddToPath=1" + "simpleSlug": "anaconda" }, { "id": "postgresql", @@ -1562,10 +1206,7 @@ "packageId": "postgresql", "provider": "catalog", "wikiTitle": "PostgreSQL", - "logoSlug": "postgresql", - "installUrl": "https://get.enterprisedb.com/postgresql/postgresql-16.6-1-windows-x64.exe", - "publisher": "EnterpriseDB Corporation", - "silentArgs": "--mode unattended" + "logoSlug": "postgresql" }, { "id": "pgadmin", @@ -1576,10 +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", - "publisher": "EnterpriseDB Corporation", - "silentArgs": "/VERYSILENT /NORESTART" + "logoSlug": "pgadmin" }, { "id": "mongodb-compass", @@ -1591,10 +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", - "publisher": "MONGODB, INC.", - "silentArgs": "/S" + "simpleSlug": "mongodb" }, { "id": "ollama", @@ -1605,10 +1240,7 @@ "packageId": "ollama", "provider": "catalog", "logoSlug": "ollama", - "homepage": "https://ollama.com/", - "installUrl": "https://ollama.com/download/OllamaSetup.exe", - "publisher": "Ollama Inc.", - "silentArgs": "/SILENT" + "homepage": "https://ollama.com/" }, { @@ -1621,10 +1253,7 @@ "provider": "catalog", "wikiTitle": "Zotero", "logoSlug": "zotero", - "simpleSlug": "zotero", - "installUrl": "https://www.zotero.org/download/client/dl?channel=release&platform=win-x64", - "publisher": "Corporation for Digital Scholarship", - "silentArgs": "/S" + "simpleSlug": "zotero" }, { "id": "joplin", @@ -1635,10 +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", - "publisher": "Joplin", - "silentArgs": "/S" + "logoSlug": "joplin" }, { "id": "logseq", @@ -1648,10 +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", - "publisher": "Logseq, Inc.", - "silentArgs": "/S" + "logoSlug": "logseq" }, { "id": "anki", @@ -1663,10 +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", - "publisher": "Anki Software LLC", - "silentArgs": "/quiet" + "homepage": "https://apps.ankiweb.net/" }, { @@ -1678,10 +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", - "publisher": "Winsider Seminars & Solutions Inc.", - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://systeminformer.sourceforge.io/" }, { "id": "wiztree", @@ -1692,10 +1309,7 @@ "packageId": "wiztree", "provider": "catalog", "logoSlug": "wiztree", - "homepage": "https://diskanalyzer.com/", - "installUrl": "https://diskanalyzer.com/files/wiztree_4_30_setup.exe", - "publisher": "Antibody Software Limited", - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://diskanalyzer.com/" }, { "id": "bleachbit", @@ -1707,10 +1321,7 @@ "provider": "catalog", "wikiTitle": "BleachBit", "logoSlug": "bleachbit", - "homepage": "https://www.bleachbit.org/", - "installUrl": "https://download.bleachbit.org/BleachBit-4.6.2-setup.exe", - "publisher": "Open Source Developer, Andrew Ziem", - "silentArgs": "/S" + "homepage": "https://www.bleachbit.org/" }, { @@ -1723,10 +1334,7 @@ "provider": "catalog", "wikiTitle": "Google_Drive", "logoSlug": "google-drive", - "simpleSlug": "googledrive", - "installUrl": "https://dl.google.com/drive-file-stream/GoogleDriveSetup.exe", - "publisher": "Google LLC", - "silentArgs": "--silent" + "simpleSlug": "googledrive" }, { "id": "nextcloud", @@ -1737,10 +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", - "publisher": "Nextcloud GmbH", - "silentArgs": "/quiet" + "logoSlug": "nextcloud" }, { "id": "free-download-manager", @@ -1752,10 +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", - "publisher": "Softdeluxe LLC", - "silentArgs": "/VERYSILENT /NORESTART" + "homepage": "https://www.freedownloadmanager.org/" }, { "id": "rustdesk", @@ -1766,10 +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", - "publisher": "PURSLANE", - "silentArgs": "--silent-install" + "logoSlug": "rustdesk" }, { @@ -1781,9 +1380,6 @@ "packageId": "openvpn", "provider": "catalog", "wikiTitle": "OpenVPN", - "logoSlug": "openvpn", - "installUrl": "https://swupdate.openvpn.net/community/releases/OpenVPN-2.6.12-I001-amd64.msi", - "publisher": "OpenVPN Inc.", - "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/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/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 0d56453..aba282e 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,26 +25,44 @@ import { shouldTryAltProvider, } from './install-errors.js'; import { killProcessTree } from './cli-progress.js'; -import { verifyInstallerSignature } from './signature.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; - /** Expected Authenticode signer CN — kept current by scripts/fetch-publishers.cjs. */ - publisher?: string | string[]; - /** Vendor ships an unsigned installer; skip the signer check. */ - unsigned?: boolean; + /** 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; @@ -80,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 { @@ -110,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, @@ -117,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, @@ -154,9 +250,9 @@ async function fetchRemoteCatalog(url: string): Promise { } /** - * Remote catalogs only extend search and listing; installs always use the - * bundled catalog. Still drop anything malformed so a bad feed cannot break - * the UI, and strip install fields so they can never be trusted by accident. + * 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'); @@ -169,7 +265,7 @@ function sanitizeRemoteCatalog(raw: unknown): CatalogItem[] { typeof i.name === 'string' && typeof i.packageId === 'string', ) - .map(({ installUrl: _u, installUrlFallbacks: _f, silentArgs: _a, publisher: _p, unsigned: _n, ...rest }) => ({ + .map((rest) => ({ ...rest, description: typeof rest.description === 'string' ? rest.description : '', features: Array.isArray(rest.features) ? rest.features.filter((f) => typeof f === 'string') : [], @@ -443,13 +539,25 @@ function prepareInstaller(downloaded: string): string { return target; } -/** Silent-install switches: /S, --silent, /InstallationType=JustMe, PrependPath=1 … */ -export const SAFE_INSTALLER_ARG = /^[A-Za-z0-9_\-/=.:]+$/; +/** + * 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): Promise { +function runInstaller( + exe: string, + args: string, + signal?: AbortSignal, + successCodes: number[] = [], +): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { reject(new InstallError('cancelled', 'Установка отменена')); @@ -459,12 +567,11 @@ function runInstaller(exe: string, args: string[], signal?: AbortSignal): Promis // 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. - const unsafe = args.find((a) => !SAFE_INSTALLER_ARG.test(a)); - if (unsafe !== undefined) { - reject(new InstallError('untrusted', 'Недопустимые аргументы установщика', unsafe)); + if (!isSafeInstallerArgs(args)) { + reject(new InstallError('untrusted', 'Недопустимые аргументы установщика', args)); return; } - const child = spawn(`"${exe}"`, args, { windowsHide: true, shell: true }); + const child = spawn(`"${exe}"`, args ? [args] : [], { windowsHide: true, shell: true }); let settled = false; const timer = setTimeout(() => { @@ -493,7 +600,7 @@ function runInstaller(exe: string, args: string[], signal?: AbortSignal): Promis finish(new InstallError('cancelled', 'Установка отменена')); return; } - if (code !== 0 && code !== null && !REBOOT_EXIT_CODES.includes(code)) { + if (code !== 0 && code !== null && !REBOOT_EXIT_CODES.includes(code) && !successCodes.includes(code)) { finish(new InstallError('installer', 'Ошибка установщика', `Exit code ${code}`)); return; } @@ -522,17 +629,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; @@ -589,160 +689,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 plan = resolveInstallPlan(item.id); + if (!plan.direct && !plan.fallback) { + return { ok: false, errorCode: 'not_found', message: `Для ${item.name} нет источника установки` }; } - const urls = [item.installUrl, ...(item.installUrlFallbacks ?? [])]; - const tempDir = path.join(getAppDataDir(), 'downloads'); - fs.mkdirSync(tempDir, { recursive: true }); - // The real type comes from the file's magic bytes once it is on disk. - const dest = path.join(tempDir, `${packageId}.download`); - let installer: string | null = null; - - 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' }); - installer = prepareInstaller(dest); - await verifyInstallerSignature(installer, item); - const args = opts.quiet && item.silentArgs ? item.silentArgs.split(' ').filter(Boolean) : []; - await runInstaller(installer, 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 (installer) unlinkQuiet(installer); - - 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> { @@ -761,4 +763,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 d2a9fcb..131a1b0 100644 --- a/electron/providers/install-errors.ts +++ b/electron/providers/install-errors.ts @@ -111,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) { diff --git a/electron/providers/signature.ts b/electron/providers/signature.ts deleted file mode 100644 index ef137f0..0000000 --- a/electron/providers/signature.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { execFile } from 'node:child_process'; -import { InstallError } from './install-errors.js'; - -/** What Get-AuthenticodeSignature reports for a file. */ -export interface SignatureInfo { - /** Valid, NotSigned, HashMismatch, NotTrusted, UnknownError, … */ - status: string; - /** CN of the signer certificate, when there is one. */ - signer?: string; -} - -/** Catalog fields that describe who is expected to have signed the installer. */ -export interface SignatureExpectation { - /** Signer CN recorded by scripts/fetch-publishers.cjs; several when the vendor rotates certificates. */ - publisher?: string | string[]; - /** The vendor ships an unsigned installer; only HTTPS protects it. */ - unsigned?: boolean; -} - -const SIGNATURE_SCRIPT = [ - '[Console]::OutputEncoding = [Text.Encoding]::UTF8', - '$s = Get-AuthenticodeSignature -LiteralPath $env:GETBOX_SIGNATURE_FILE', - '$signer = $null', - 'if ($s.SignerCertificate) { $signer = $s.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName, $false) }', - '[pscustomobject]@{ status = [string]$s.Status; signer = $signer } | ConvertTo-Json -Compress', -].join('; '); - -/** Ask Windows to verify the Authenticode signature of `filePath`. */ -export function readSignature(filePath: string): Promise { - return new Promise((resolve, reject) => { - execFile( - 'powershell.exe', - ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', SIGNATURE_SCRIPT], - { - timeout: 60_000, - windowsHide: true, - // The path travels in the environment so it never has to be quoted into the script. - env: { ...process.env, GETBOX_SIGNATURE_FILE: filePath }, - }, - (err, stdout) => { - if (err) { - reject(err); - return; - } - try { - const parsed = JSON.parse(stdout.trim()) as { status?: string; signer?: string | null }; - resolve({ status: parsed.status ?? 'UnknownError', signer: parsed.signer ?? undefined }); - } catch { - reject(new Error(`Unexpected Get-AuthenticodeSignature output: ${stdout.slice(0, 200)}`)); - } - }, - ); - }); -} - -/** - * Decide whether an installer may run. Returns null when it may, otherwise the - * error to report. Pure, so the policy is covered by smoke tests. - * - * - Valid signature: accepted; when `publisher` is known the signer must match it. - * - No signature: accepted only for entries explicitly marked `unsigned`. - * - Anything else (tampered, untrusted chain, …): rejected. - */ -export function checkSignature(info: SignatureInfo, expected: SignatureExpectation): InstallError | null { - const reject = (detail: string) => - new InstallError('untrusted', 'Установщик не прошёл проверку подлинности', detail); - - if (info.status === 'NotSigned') { - return expected.unsigned ? null : reject('Installer is not signed'); - } - if (info.status !== 'Valid') { - return reject(`Signature status: ${info.status}${info.signer ? ` (${info.signer})` : ''}`); - } - - const publishers = [expected.publisher ?? []].flat(); - if (publishers.length === 0) return null; - const signer = (info.signer ?? '').trim().toLowerCase(); - if (publishers.some((p) => p.trim().toLowerCase() === signer)) return null; - return reject(`Signed by "${info.signer ?? '?'}", expected "${publishers.join('" or "')}"`); -} - -/** Throws InstallError('untrusted') unless the downloaded installer passes `checkSignature`. */ -export async function verifyInstallerSignature(filePath: string, expected: SignatureExpectation): Promise { - let info: SignatureInfo; - try { - info = await readSignature(filePath); - } catch (err) { - // Fail closed: an installer we could not verify is not run. - throw new InstallError( - 'untrusted', - 'Не удалось проверить подпись установщика', - err instanceof Error ? err.message : String(err), - ); - } - const problem = checkSignature(info, expected); - if (problem) throw problem; -} 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..a3f9707 100644 --- a/electron/queue/install-queue.ts +++ b/electron/queue/install-queue.ts @@ -4,7 +4,6 @@ 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'; 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/package-lock.json b/package-lock.json index 0a6e244..0d5ae1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "electron": "^33.2.0", "electron-builder": "^25.1.8", "eslint": "^9.16.0", + "js-yaml": "^4.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.7.2", diff --git a/package.json b/package.json index dbeb4ca..74beb1b 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "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:publishers": "node scripts/fetch-publishers.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" @@ -61,6 +61,7 @@ "electron": "^33.2.0", "electron-builder": "^25.1.8", "eslint": "^9.16.0", + "js-yaml": "^4.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", "typescript": "^5.7.2", diff --git a/scripts/fetch-publishers.cjs b/scripts/fetch-publishers.cjs deleted file mode 100644 index 4a96a50..0000000 --- a/scripts/fetch-publishers.cjs +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Read the Authenticode signer of every catalog installer and record it as - * `publisher` in data/catalog.json. GetBox refuses to run a downloaded - * installer whose signer does not match (see electron/providers/signature.ts). - * - * EXE: only the PE header and the certificate table are fetched (HTTP Range) - * when the server allows it. MSI: the signature lives in a stream of the OLE - * container, so the whole file is downloaded. - * - * Run: node scripts/fetch-publishers.cjs [--write] [id ...] - * --write insert/update "publisher" (or "unsigned": true) in catalog.json - */ -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); -const { X509Certificate } = require('node:crypto'); - -const ROOT = path.join(__dirname, '..'); -const CATALOG = path.join(ROOT, 'data', 'catalog.json'); -const WRITE = process.argv.includes('--write'); -const ONLY = process.argv.slice(2).filter((a) => !a.startsWith('--')); -const CONCURRENCY = 4; -const MAX_FULL_DOWNLOAD = 600 * 1024 * 1024; -const UA = - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'; -const CODE_SIGNING_EKU = '1.3.6.1.5.5.7.3.3'; - -async function fetchRange(url, start, end) { - const res = await fetch(url, { - headers: { 'User-Agent': UA, Range: `bytes=${start}-${end}`, 'Accept-Encoding': 'identity' }, - redirect: 'follow', - }); - if (res.status !== 206) { - res.body?.cancel().catch(() => {}); - return null; - } - return Buffer.from(await res.arrayBuffer()); -} - -async function fetchFull(url) { - const res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept-Encoding': 'identity' }, redirect: 'follow' }); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const len = Number(res.headers.get('content-length')); - if (len > MAX_FULL_DOWNLOAD) throw new Error(`too large (${Math.round(len / 1e6)} MB)`); - return Buffer.from(await res.arrayBuffer()); -} - -/** Locate the certificate table via the PE optional header's data directory #4. */ -function peSecurityDir(head) { - if (head.readUInt16LE(0) !== 0x5a4d) throw new Error('not a PE file'); - const pe = head.readUInt32LE(0x3c); - if (head.readUInt32LE(pe) !== 0x4550) throw new Error('bad PE signature'); - const opt = pe + 24; - const magic = head.readUInt16LE(opt); - const dirs = opt + (magic === 0x20b ? 112 : 96); - return { offset: head.readUInt32LE(dirs + 32), size: head.readUInt32LE(dirs + 36) }; -} - -/** - * Minimal OLE compound-file reader: returns the named root-level stream. - * MSI keeps its Authenticode blob in "\x05DigitalSignature"; scanning the raw - * file instead would find signatures of embedded custom-action DLLs. - */ -function oleStream(buf, wanted) { - if (buf.readUInt32LE(0) !== 0xe011cfd0) throw new Error('not an OLE file'); - const ss = 1 << buf.readUInt16LE(0x1e); - const mss = 1 << buf.readUInt16LE(0x20); - const cutoff = buf.readUInt32LE(0x38); - const sector = (n) => buf.subarray((n + 1) * ss, (n + 2) * ss); - const END = 0xfffffffe; - - const fatSectors = []; - for (let i = 0; i < 109; i++) { - const s = buf.readUInt32LE(0x4c + i * 4); - if (s < 0xfffffffa) fatSectors.push(s); - } - for (let d = buf.readUInt32LE(0x44), n = buf.readUInt32LE(0x48); n-- > 0 && d < 0xfffffffa; ) { - const sec = sector(d); - for (let i = 0; i < ss / 4 - 1; i++) { - const s = sec.readUInt32LE(i * 4); - if (s < 0xfffffffa) fatSectors.push(s); - } - d = sec.readUInt32LE(ss - 4); - } - const fat = Buffer.concat(fatSectors.map(sector)); - const chain = (start, table) => { - const out = []; - for (let s = start; s !== END && s < table.length / 4 && out.length < 1e7; s = table.readUInt32LE(s * 4)) out.push(s); - return out; - }; - const readChain = (start) => Buffer.concat(chain(start, fat).map(sector)); - - const dir = readChain(buf.readUInt32LE(0x30)); - const entries = []; - for (let off = 0; off + 128 <= dir.length; off += 128) { - const nameLen = dir.readUInt16LE(off + 64); - entries.push({ - name: dir.subarray(off, off + Math.max(0, nameLen - 2)).toString('utf16le'), - type: dir[off + 66], - start: dir.readUInt32LE(off + 116), - size: dir.readUInt32LE(off + 120), - }); - } - const entry = entries.find((e) => e.type === 2 && e.name === wanted); - if (!entry) return null; - if (entry.size >= cutoff) return readChain(entry.start).subarray(0, entry.size); - - const miniStream = readChain(entries[0].start); - const miniFat = readChain(buf.readUInt32LE(0x3c)); - const parts = chain(entry.start, miniFat).map((s) => miniStream.subarray(s * mss, (s + 1) * mss)); - return Buffer.concat(parts).subarray(0, entry.size); -} - -/** - * CN of the primary signer's certificate in a PKCS#7 blob. The blob may carry - * several leaf certificates, so the signer is matched by the serial in the - * first signer_info — the same one Get-AuthenticodeSignature reports. - */ -function signerFromPkcs7(der) { - const tmp = path.join(os.tmpdir(), `getbox-sig-${process.pid}-${Math.random().toString(36).slice(2)}.p7b`); - fs.writeFileSync(tmp, der); - try { - const openssl = (...args) => - execFileSync('openssl', ['pkcs7', '-inform', 'DER', '-in', tmp, ...args], { - encoding: 'utf-8', - maxBuffer: 64 * 1024 * 1024, - stdio: ['ignore', 'pipe', 'ignore'], - }); - const pem = openssl('-print_certs'); - const certs = (pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []).map( - (p) => new X509Certificate(p), - ); - const serialMatch = /signer_info:[\s\S]*?serial:\s*(0x[0-9a-f]+|-?\d+)/i.exec(openssl('-print')); - const serial = serialMatch ? BigInt(serialMatch[1]) : null; - const leaf = - certs.find((c) => serial !== null && BigInt(`0x${c.serialNumber}`) === serial) ?? - certs.find((c) => !c.ca && (c.keyUsage ?? []).includes(CODE_SIGNING_EKU)); - if (!leaf) return null; - const cn = /^CN=(.+)$/m.exec(leaf.subject); - return cn ? cn[1].replace(/\\(.)/g, '$1').trim() : null; - } finally { - fs.rmSync(tmp, { force: true }); - } -} - -function peSigner(file) { - const dir = peSecurityDir(file); - if (!dir.offset || !dir.size) return null; - return certTableSigner(file.subarray(dir.offset, dir.offset + dir.size)); -} - -/** WIN_CERTIFICATE: dwLength, wRevision, wCertificateType, then PKCS#7. */ -function certTableSigner(table) { - return signerFromPkcs7(table.subarray(8, table.readUInt32LE(0))); -} - -function msiSigner(file) { - const blob = oleStream(file, '\u0005DigitalSignature'); - return blob ? signerFromPkcs7(blob) : null; -} - -/** File type by magic bytes, the same way GetBox decides how to run it. */ -function kindOf(head) { - if (head.readUInt16LE(0) === 0x5a4d) return 'exe'; - if (head.readUInt32LE(0) === 0xe011cfd0) return 'msi'; - return null; -} - -async function signerOf(url) { - const head = await fetchRange(url, 0, 4095); - if (head && kindOf(head) === 'exe') { - const dir = peSecurityDir(head); - if (!dir.offset || !dir.size) return null; - const table = await fetchRange(url, dir.offset, dir.offset + dir.size - 1); - if (table) return certTableSigner(table); - } - // No range support, an MSI, or a CDN that hands out a different file per request. - const file = await fetchFull(url); - const kind = kindOf(file); - if (kind === 'exe') return peSigner(file); - if (kind === 'msi') return msiSigner(file); - throw new Error(`not an installer (${file.subarray(0, 16).toString('latin1').replace(/[^\x20-\x7e]/g, '.')})`); -} - -async function inspect(item) { - try { - const signer = await signerOf(item.installUrl); - return { id: item.id, signer, unsigned: signer === null }; - } catch (err) { - return { id: item.id, error: err.message }; - } -} - -/** Insert or replace a top-level key inside the item's block, keeping the file's hand formatting. */ -function setField(text, id, key, valueJson) { - const start = text.indexOf(`"id": "${id}"`); - if (start < 0) return text; - const end = text.indexOf('\n }', start); - let block = text.slice(start, end); - block = block.replace(new RegExp(`\\n\\s*"(publisher|unsigned)": [^\\n]*,`, 'g'), ''); - block = block.replace(/(\n(\s*)"installUrl": [^\n]*,)/, `$1\n$2"${key}": ${valueJson},`); - return text.slice(0, start) + block + text.slice(end); -} - -async function main() { - const catalog = JSON.parse(fs.readFileSync(CATALOG, 'utf-8')); - const items = catalog.filter((i) => i.installUrl && (ONLY.length === 0 || ONLY.includes(i.id))); - const results = []; - let next = 0; - await Promise.all( - Array.from({ length: CONCURRENCY }, async () => { - while (next < items.length) { - const item = items[next++]; - const r = await inspect(item); - results.push(r); - const label = r.error ? `ERROR ${r.error}` : r.unsigned ? 'UNSIGNED' : r.signer; - const prev = item.publisher ?? (item.unsigned ? 'UNSIGNED' : '—'); - const mark = !r.error && prev !== label ? ' (changed)' : ''; - console.log(`${item.id.padEnd(24)} ${label}${mark}`); - } - }), - ); - - if (WRITE) { - let text = fs.readFileSync(CATALOG, 'utf-8'); - for (const r of results) { - if (r.error) continue; - text = r.unsigned - ? setField(text, r.id, 'unsigned', 'true') - : setField(text, r.id, 'publisher', JSON.stringify(r.signer)); - } - JSON.parse(text); - fs.writeFileSync(CATALOG, text); - } - - const errors = results.filter((r) => r.error).length; - const unsigned = results.filter((r) => r.unsigned).length; - console.log(`\n${results.length} checked · ${unsigned} unsigned · ${errors} errors${WRITE ? ' · catalog.json updated' : ''}`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); 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 2e8b3fb..1fc5f90 100644 --- a/scripts/smoke.cjs +++ b/scripts/smoke.cjs @@ -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 mapped = resolveInstallFallback({ id: 'putty' }); - assert.equal(mapped?.provider, 'winget'); - assert.ok(mapped?.packageId, 'putty should resolve via winget-map'); + const choco = resolveInstallPlan('filezilla', { filezilla: { provider: 'chocolatey', packageId: 'filezilla' } }, {}); + assert.deepEqual(choco, { fallback: { provider: 'chocolatey', packageId: 'filezilla' } }); +}); - const skipped = resolveInstallFallback({ id: 'x', noWingetFallback: true }); - assert.equal(skipped, null); +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 }); +}); + +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,53 +539,103 @@ test('clearDone removes done, cancelled, and failed', () => { assert.equal(q.getJobs().length, 0); }); -// --- installer signature policy (electron/providers/signature.ts) --- +// --- 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 { checkSignature } = loadTsModule(path.join(__dirname, '..', 'electron', 'providers', 'signature.ts')); - const code = (info, expected) => checkSignature(info, expected)?.code ?? null; + 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}"`); + } + } + }); - test('signature: valid + matching publisher passes (case-insensitive)', () => { - assert.equal(code({ status: 'Valid', signer: 'Mozilla Corporation' }, { publisher: 'mozilla corporation' }), null); - assert.equal(code({ status: 'Valid', signer: 'B' }, { publisher: ['A', 'B'] }), null); + 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('signature: valid but different publisher is rejected', () => { - assert.equal(code({ status: 'Valid', signer: 'Evil LLC' }, { publisher: 'Mozilla Corporation' }), 'untrusted'); + 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('signature: valid with no recorded publisher passes', () => { - assert.equal(code({ status: 'Valid', signer: 'Anyone' }, {}), null); + 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('signature: unsigned passes only when catalog marks it unsigned', () => { - assert.equal(code({ status: 'NotSigned' }, {}), 'untrusted'); - assert.equal(code({ status: 'NotSigned' }, { publisher: 'X' }), 'untrusted'); - assert.equal(code({ status: 'NotSigned' }, { unsigned: true }), null); + 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('signature: tampered or untrusted chain is always rejected', () => { - for (const status of ['HashMismatch', 'NotTrusted', 'UnknownError', 'NotSupportedFileFormat']) { - assert.equal(code({ status, signer: 'Mozilla Corporation' }, { publisher: 'Mozilla Corporation' }), 'untrusted', status); - assert.equal(code({ status }, { unsigned: true }), 'untrusted', status); - } + 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('catalog: publisher / unsigned fields are well-formed', () => { - const catalog = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'data', 'catalog.json'), 'utf-8')); - for (const item of catalog) { - if (item.publisher !== undefined) { - const list = [item.publisher].flat(); - assert.ok(list.length > 0 && list.every((p) => typeof p === 'string' && p.trim()), item.id); - assert.ok(!item.unsigned, `${item.id}: publisher and unsigned are exclusive`); - } - if (item.unsigned !== undefined) assert.equal(item.unsigned, true, item.id); - if (item.installUrl) assert.ok(item.installUrl.startsWith('https://'), `${item.id}: installUrl must be https`); - // Mirrors SAFE_INSTALLER_ARG in electron/providers/catalog.ts — args reach cmd.exe. - for (const arg of (item.silentArgs ?? '').split(' ').filter(Boolean)) { - assert.match(arg, /^[A-Za-z0-9_\-/=.:]+$/, `${item.id}: unsafe silentArgs`); - } - for (const u of item.installUrlFallbacks ?? []) assert.ok(u.startsWith('https://'), `${item.id}: fallback must be https`); - } + 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'); }); } diff --git a/scripts/test-live-install.cjs b/scripts/test-live-install.cjs index 67a35a1..ad1c796 100644 --- a/scripts/test-live-install.cjs +++ b/scripts/test-live-install.cjs @@ -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..0796793 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'); @@ -420,6 +424,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 +445,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 +462,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 +555,20 @@ export default function App() { ) : ( )} + r.app)} + subtitles={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 +731,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 +760,7 @@ export default function App() { onOpen={setSelectedApp} onToggleFavorite={handleToggleFavorite} onVisibleChange={enrichVisible} + subtitles={view === 'recommended' ? reasonByKey : undefined} /> )} @@ -758,6 +791,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 ))} )} + +
)}
+ {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 e6eeffa..bbc88f1 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -96,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 для разработки' }, @@ -262,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..fa4cfa6 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; @@ -976,6 +978,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 +1124,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 +1415,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); + }, + }; +} From a41ec43b591a77438cac5421eca2d6ab20ec38d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:38:42 +0000 Subject: [PATCH 3/7] Working ESLint, sandboxed renderer, fresh-clone build, card reasons - eslint.config.js had only ignores, and `--ext` is not supported by ESLint 9 flat config, so `npm run lint` checked nothing. Added typescript-eslint, react-hooks and per-folder globals; fixed the ten findings (dead imports and variables, a shadowed `crypto` in smoke.cjs). - Renderer runs with sandbox: true (the preload only needs contextBridge and ipcRenderer). window.open is denied and in-page navigation blocked; http(s) links go to the system browser. - `npm run build` creates dist-electron before copying the Scoop bootstrap script into it, so it works in a fresh clone. - Recommendation reasons get their own one-line row on cards instead of replacing the uppercase category label, which wrapped over 4-5 lines. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- electron/bootstrap/runtime.ts | 1 - electron/main.ts | 39 ++-- electron/providers/catalog.ts | 2 + electron/providers/scoop.ts | 2 +- electron/queue/install-queue.ts | 2 +- eslint.config.js | 36 +++- package-lock.json | 333 +++++++++++++++++++++++++++++++- package.json | 8 +- scripts/smoke.cjs | 6 +- scripts/test-live-install.cjs | 2 +- src/App.tsx | 6 +- src/components/CapsuleCard.tsx | 5 +- src/components/PagedGrid.tsx | 9 +- src/components/ShelfRow.tsx | 12 +- src/i18n/translations.ts | 2 +- src/index.css | 9 + 16 files changed, 429 insertions(+), 45 deletions(-) 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/main.ts b/electron/main.ts index 2588aba..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); @@ -266,16 +288,7 @@ function setupIpc(): void { }); ipcMain.handle('shell:openExternal', (_e, url: string) => { - // Only web links: file:, ms-*, search-ms: and friends can launch local programs. - let protocol = ''; - try { - protocol = new URL(url).protocol; - } catch { - // malformed → rejected below - } - if (protocol !== 'https:' && protocol !== 'http:') { - throw new Error(`Blocked external URL: ${String(url).slice(0, 200)}`); - } + if (!isWebUrl(url)) throw new Error(`Blocked external URL: ${String(url).slice(0, 200)}`); return shell.openExternal(url); }); diff --git a/electron/providers/catalog.ts b/electron/providers/catalog.ts index aba282e..4f7619f 100644 --- a/electron/providers/catalog.ts +++ b/electron/providers/catalog.ts @@ -333,8 +333,10 @@ function downloadFileOnce( 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; 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/queue/install-queue.ts b/electron/queue/install-queue.ts index a3f9707..46c40e0 100644 --- a/electron/queue/install-queue.ts +++ b/electron/queue/install-queue.ts @@ -3,7 +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 { classifyError } from '../providers/install-errors.js'; const PROGRESS_EMIT_MS = 250; diff --git a/eslint.config.js b/eslint.config.js index 895ea65..c686b74 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,5 +1,35 @@ -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' }, + }, +); diff --git a/package-lock.json b/package-lock.json index 0d5ae1c..a61addf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,17 @@ { "name": "getbox", - "version": "1.1.0", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "getbox", - "version": "1.1.0", + "version": "1.2.0", "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,10 +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" @@ -1294,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", @@ -2560,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", @@ -4744,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", @@ -5374,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": { @@ -7930,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", @@ -7969,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 74beb1b..f161cbe 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "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", @@ -16,7 +16,7 @@ "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", @@ -53,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", @@ -61,10 +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/scripts/smoke.cjs b/scripts/smoke.cjs index 1fc5f90..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')); diff --git a/scripts/test-live-install.cjs b/scripts/test-live-install.cjs index ad1c796..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 }, ); diff --git a/src/App.tsx b/src/App.tsx index 0796793..f1fcd17 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -277,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) => { @@ -559,7 +561,7 @@ export default function App() { title={forYouTitle} hint={forYouHint} apps={recommendations.slice(0, 12).map((r) => r.app)} - subtitles={reasonByKey} + reasons={reasonByKey} categories={categories} activeJobs={activeJobs} onInstall={handleInstall} @@ -760,7 +762,7 @@ export default function App() { onOpen={setSelectedApp} onToggleFavorite={handleToggleFavorite} onVisibleChange={enrichVisible} - subtitles={view === 'recommended' ? reasonByKey : undefined} + reasons={view === 'recommended' ? reasonByKey : undefined} /> )} diff --git a/src/components/CapsuleCard.tsx b/src/components/CapsuleCard.tsx index 156a07d..d1b2fbb 100644 --- a/src/components/CapsuleCard.tsx +++ b/src/components/CapsuleCard.tsx @@ -7,6 +7,8 @@ import { IconStar, IconStarFilled } from './Icons'; interface CapsuleCardProps { app: MergedApp; categoryLabel?: string; + /** Why the app is recommended; one line under the card body. */ + reason?: string; job?: QueueJob; onInstall: (app: MergedApp) => void; onRetry?: (app: MergedApp) => void; @@ -15,7 +17,7 @@ interface CapsuleCardProps { onToggleFavorite: (app: MergedApp) => void; } -function CapsuleCardInner({ app, categoryLabel, job, onInstall, onRetry, onCancel, onOpen, onToggleFavorite }: CapsuleCardProps) { +function CapsuleCardInner({ app, categoryLabel, reason, job, onInstall, onRetry, onCancel, onOpen, onToggleFavorite }: CapsuleCardProps) { const { t } = useI18n(); const busy = job?.status === 'running' || job?.status === 'pending'; const failed = job?.status === 'failed'; @@ -90,6 +92,7 @@ function CapsuleCardInner({ app, categoryLabel, job, onInstall, onRetry, onCance )}
+ {reason &&
{reason}
} ); } diff --git a/src/components/PagedGrid.tsx b/src/components/PagedGrid.tsx index 8cf1e14..26a7604 100644 --- a/src/components/PagedGrid.tsx +++ b/src/components/PagedGrid.tsx @@ -27,8 +27,8 @@ interface PagedGridProps { onOpen: (app: MergedApp) => void; onToggleFavorite: (app: MergedApp) => void; onVisibleChange?: (visibleApps: MergedApp[]) => void; - /** Per-app line shown instead of the category, keyed by packageKey. */ - subtitles?: Map; + /** Why each app is shown, keyed by packageKey. */ + reasons?: Map; } export function PagedGrid({ @@ -41,7 +41,7 @@ export function PagedGrid({ onOpen, onToggleFavorite, onVisibleChange, - subtitles, + reasons, }: PagedGridProps) { const { t } = useI18n(); const [page, setPage] = useState(1); @@ -88,7 +88,8 @@ export function PagedGrid({ void; onToggleFavorite: (app: MergedApp) => void; onSeeAll?: () => void; - /** Per-app line shown instead of the category, keyed by packageKey. */ - subtitles?: Map; + /** 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, subtitles, hint }: 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); @@ -86,10 +86,8 @@ export function ShelfRow({ title, apps, categories, activeJobs, onInstall, onRet Date: Mon, 12 Oct 2026 16:39:08 +0000 Subject: [PATCH 4/7] Add CI and a weekly installers.json refresh - ci.yml: lint, typecheck, catalog checks, unit/integration tests and the main + renderer build on every push to main and every PR. - refresh-installers.yml: weekly (and on demand) runs `npm run catalog:installers` and opens a PR with the new URLs, hashes and switches, so pinned downloads do not go stale between releases. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- .github/workflows/ci.yml | 36 ++++++++++++++++++++++++ .github/workflows/refresh-installers.yml | 36 ++++++++++++++++++++++++ README.md | 2 ++ 3 files changed, 74 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/refresh-installers.yml 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/README.md b/README.md index bd5bdb9..e2840ac 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,8 @@ npm run dist Добавили программу: допишите её в `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` ранжирует программы каталога по тому, что у пользователя установлено и в избранном: From da9421578b38c2395aab5d69ba799d3717522945 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:48:57 +0000 Subject: [PATCH 5/7] Add promo video sources promo/ holds the 40 s 1080p promo: promo.html (scenes as a pure render(t) function, so frames render deterministically), capture.cjs (real renderer screenshots with window.getbox stubbed from data/*.json), render.cjs (frame-by-frame into ffmpeg) and music.py (synthesised ambient bed). Rendered video, audio and captured frames are gitignored; README lists the steps to rebuild them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- eslint.config.js | 6 ++ promo/.gitignore | 5 + promo/README.md | 17 +++ promo/capture.cjs | 66 ++++++++++++ promo/music.py | 87 ++++++++++++++++ promo/promo.html | 261 ++++++++++++++++++++++++++++++++++++++++++++++ promo/render.cjs | 40 +++++++ 7 files changed, 482 insertions(+) create mode 100644 promo/.gitignore create mode 100644 promo/README.md create mode 100644 promo/capture.cjs create mode 100644 promo/music.py create mode 100644 promo/promo.html create mode 100644 promo/render.cjs diff --git a/eslint.config.js b/eslint.config.js index c686b74..066e614 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -32,4 +32,10 @@ export default tseslint.config( 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/promo/.gitignore b/promo/.gitignore new file mode 100644 index 0000000..dfe35fc --- /dev/null +++ b/promo/.gitignore @@ -0,0 +1,5 @@ +# Rendered output and captured frames — regenerate with the steps in README.md. +shots/ +*.mp4 +*.wav +still-*.png diff --git a/promo/README.md b/promo/README.md new file mode 100644 index 0000000..8636c48 --- /dev/null +++ b/promo/README.md @@ -0,0 +1,17 @@ +# Промо-ролик GetBox + +40 с, 1920×1080, 30 fps. Кадры интерфейса — настоящий renderer GetBox на тестовых данных из `data/`. + +```bash +npm run build # собрать dist/ (renderer) +node promo/capture.cjs # скриншоты интерфейса → promo/shots/ +node promo/render.cjs stills 12 23.5 # (по желанию) отдельные кадры для проверки +node promo/render.cjs video # покадровый рендер promo.html → promo/video.mp4 +python3 promo/music.py # фоновая музыка → promo/music.wav (нужен numpy) +ffmpeg -i promo/video.mp4 -i promo/music.wav -c:v copy -c:a aac -b:a 192k -shortest promo/GetBox-promo.mp4 +``` + +Нужны Playwright с Chromium (`PLAYWRIGHT_MODULE`, `CHROMIUM_PATH` — если стоят не в стандартном месте) и ffmpeg с libx264. + +- `promo.html` — сцены; `render(t)` задаёт состояние кадра по времени, поэтому рендер детерминированный. Тексты и тайминги — там же. +- `music.py` — синтезированный эмбиент (96 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.py b/promo/music.py new file mode 100644 index 0000000..6262062 --- /dev/null +++ b/promo/music.py @@ -0,0 +1,87 @@ +"""Procedural 40 s ambient bed for the GetBox promo (96 bpm, Am-F-C-G).""" +import numpy as np, wave + +SR, DUR, BPM = 44100, 40.0, 96 +BEAT = 60 / BPM +t = np.arange(int(SR * DUR)) / SR +L = np.zeros_like(t); R = np.zeros_like(t) + +def hz(n): return 440 * 2 ** ((n - 69) / 12) +CHORDS = [[57, 60, 64], [53, 57, 60], [48, 52, 55], [55, 59, 62]] # Am F C G +BAR = 4 * BEAT +CHORD_LEN = 2 * BAR + +def env(n, a, r): + e = np.ones(n) + a, r = int(a * SR), int(r * SR) + e[:a] = np.linspace(0, 1, a); e[-r:] *= np.linspace(1, 0, r) + return e + +def lowpass(x, cutoff): + a = np.exp(-2 * np.pi * cutoff / SR) + y = np.empty_like(x); acc = 0.0 + for i, v in enumerate(x): # one-pole, fine for a 40 s render + acc = (1 - a) * v + a * acc; y[i] = acc + return y + +# Pad: detuned saws (8 harmonics), slow swells per chord. +pad_l = np.zeros_like(t); pad_r = np.zeros_like(t) +n_chords = int(np.ceil(DUR / CHORD_LEN)) +for c in range(n_chords): + s, e = int(c * CHORD_LEN * SR), min(int((c + 1) * CHORD_LEN * SR + 0.6 * SR), len(t)) + tt = t[s:e] - t[s] + chord = CHORDS[c % 4] if c < n_chords - 1 else [57, 60, 64, 69] + for note in chord + [chord[0] - 12]: + for det, side in ((-0.12, 'l'), (0.12, 'r')): + f = hz(note + det) + saw = sum(np.sin(2 * np.pi * f * k * tt) / k for k in range(1, 9)) + sig = saw * env(e - s, 1.2, 1.4) * 0.05 + (pad_l if side == 'l' else pad_r)[s:e] += sig +L += lowpass(pad_l, 1400); R += lowpass(pad_r, 1400) + +def place(buf, start, sig, gain=1.0): + s = int(start * SR); e = min(s + len(sig), len(buf)) + if s < len(buf): buf[s:e] += sig[: e - s] * gain + +# Pluck arpeggio (8ths) from the first product shot until the trust cards. +ARP = (9.4, 34.6) +step = BEAT / 2 +for i in range(int((ARP[1] - ARP[0]) / step)): + at = ARP[0] + i * step + chord = CHORDS[int(at // CHORD_LEN) % 4] + seq = [chord[0] + 12, chord[1] + 12, chord[2] + 12, chord[1] + 24] + n = seq[i % 4] + tt = np.arange(int(0.6 * SR)) / SR + tone = (np.sin(2 * np.pi * hz(n) * tt) + 0.3 * np.sin(4 * np.pi * hz(n) * tt)) * np.exp(-tt * 7) + pan = 0.35 if i % 2 else -0.35 + fade_in = min(1, (at - ARP[0]) / 2) + place(L, at, tone, 0.075 * (1 - pan) * fade_in); place(R, at, tone, 0.075 * (1 + pan) * fade_in) + +# Soft kick on beats, hats on off-beats. +rng = np.random.default_rng(7) +for i in range(int(DUR / BEAT)): + at = i * BEAT + if 9.4 <= at < 34.6: + tt = np.arange(int(0.35 * SR)) / SR + f = 45 + 70 * np.exp(-tt * 30) + kick = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 9) + place(L, at, kick, 0.33); place(R, at, kick, 0.33) + if 15.0 <= at < 34.6: + tt = np.arange(int(0.06 * SR)) / SR + hat = np.diff(rng.standard_normal(len(tt) + 1)) * np.exp(-tt * 70) + place(L, at + BEAT / 2, hat, 0.03); place(R, at + BEAT / 2, hat, 0.04) + +# Riser into the outro. +rs, re_ = 37.0, 38.2 +tt = np.arange(int((re_ - rs) * SR)) / SR +noise = lowpass(rng.standard_normal(len(tt)), 3000) * (tt / tt[-1]) ** 2 * 0.25 +place(L, rs, noise); place(R, rs, noise) + +master = env(len(t), 0.8, 2.2) +L *= master; R *= master +peak = max(np.abs(L).max(), np.abs(R).max()) +L, R = L / peak * 0.85, R / peak * 0.85 +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..3cb6f71 --- /dev/null +++ b/promo/render.cjs @@ -0,0 +1,40 @@ +// Render promo.html frame by frame (deterministic) and pipe into ffmpeg. +// 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 FPS = 30; +const mode = process.argv[2] ?? 'video'; // 'stills' | 'video' +const server = http.createServer((req, res) => { + const f = path.join(ROOT, decodeURIComponent(req.url.split('?')[0])); + if (!f.startsWith(ROOT) || !fs.existsSync(f)) { res.writeHead(404); return res.end(); } + res.writeHead(200, { 'Content-Type': { '.html': 'text/html', '.svg': 'image/svg+xml', '.png': 'image/png' }[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({ viewport: { width: 1920, height: 1080 } }); + await page.goto(`http://127.0.0.1:${server.address().port}/promo/promo.html`); + await page.evaluate(() => Promise.all([...document.images].map((i) => i.decode().catch(() => {})))); + await page.evaluate(() => document.fonts.ready); + const duration = await page.evaluate(() => window.DURATION); + if (mode === 'stills') { + for (const t of process.argv.slice(3).map(Number)) { + await page.evaluate((t) => window.render(t), t); + await page.screenshot({ path: path.join(DIR, `still-${t}.png`) }); + } + } else { + const ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'mjpeg', '-i', '-', + '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', path.join(DIR, 'video.mp4')], { stdio: ['pipe', 'inherit', 'inherit'] }); + const frames = Math.round(duration * FPS); + for (let i = 0; i < frames; i++) { + await page.evaluate((t) => window.render(t), i / 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 % 150 === 0) console.log(`frame ${i}/${frames}`); + } + ff.stdin.end(); + await new Promise((r) => ff.on('close', r)); + } + await browser.close(); server.close(); +}); From 8369e1bf8adda512718e3d21e206b1e8fb465dd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:54:53 +0000 Subject: [PATCH 6/7] Add vertical Shorts promo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shorts.html: 30 s, 1080x1920, 60 fps, cut to a 128 bpm grid — dialog pile-up hook, logo drop, catalog counter, click-to-install demo, icon storm, recommendations, trust badges, CTA. music-shorts.py synthesises the matching track (filtered intro, riser, drop on bar 3, crashes on the scene hits). render.cjs now takes the page and reads its size, fps and duration from window.FORMAT, so it renders both formats. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- promo/.gitignore | 2 + promo/README.md | 20 ++- promo/music-shorts.py | 177 ++++++++++++++++++ promo/promo.html | 1 + promo/render.cjs | 42 +++-- promo/shorts.html | 408 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 629 insertions(+), 21 deletions(-) create mode 100644 promo/music-shorts.py create mode 100644 promo/shorts.html diff --git a/promo/.gitignore b/promo/.gitignore index dfe35fc..e050f39 100644 --- a/promo/.gitignore +++ b/promo/.gitignore @@ -3,3 +3,5 @@ shots/ *.mp4 *.wav still-*.png +contact*.png +spec.png diff --git a/promo/README.md b/promo/README.md index 8636c48..3ba51ad 100644 --- a/promo/README.md +++ b/promo/README.md @@ -1,17 +1,25 @@ -# Промо-ролик GetBox +# Промо-ролики GetBox -40 с, 1920×1080, 30 fps. Кадры интерфейса — настоящий renderer GetBox на тестовых данных из `data/`. +- `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 12 23.5 # (по желанию) отдельные кадры для проверки -node promo/render.cjs video # покадровый рендер promo.html → promo/video.mp4 +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/video.mp4 -i promo/music.wav -c:v copy -c:a aac -b:a 192k -shortest promo/GetBox-promo.mp4 +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` — синтезированный эмбиент (96 BPM, Am–F–C–G), без сторонних сэмплов. +- `music.py`, `music-shorts.py` — синтезированная музыка (96 и 128 BPM, Am–F–C–G), без сторонних сэмплов. 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(' diff --git a/promo/render.cjs b/promo/render.cjs index 3cb6f71..c6c87ef 100644 --- a/promo/render.cjs +++ b/promo/render.cjs @@ -1,40 +1,52 @@ -// Render promo.html frame by frame (deterministic) and pipe into ffmpeg. +// 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 FPS = 30; -const mode = process.argv[2] ?? 'video'; // 'stills' | 'video' +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)) { res.writeHead(404); return res.end(); } - res.writeHead(200, { 'Content-Type': { '.html': 'text/html', '.svg': 'image/svg+xml', '.png': 'image/png' }[path.extname(f)] ?? 'application/octet-stream' }); + 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({ viewport: { width: 1920, height: 1080 } }); - await page.goto(`http://127.0.0.1:${server.address().port}/promo/promo.html`); + 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); - const duration = await page.evaluate(() => window.DURATION); + if (mode === 'stills') { - for (const t of process.argv.slice(3).map(Number)) { + for (const t of times) { await page.evaluate((t) => window.render(t), t); - await page.screenshot({ path: path.join(DIR, `still-${t}.png`) }); + await page.screenshot({ path: path.join(DIR, `still-${pageName}-${t}.png`) }); } } else { - const ff = spawn('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(FPS), '-c:v', 'mjpeg', '-i', '-', - '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-pix_fmt', 'yuv420p', '-movflags', '+faststart', path.join(DIR, 'video.mp4')], { stdio: ['pipe', 'inherit', 'inherit'] }); - const frames = Math.round(duration * FPS); + 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 / FPS); + 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 % 150 === 0) console.log(`frame ${i}/${frames}`); + 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 — и готово
+
Ссылка в описании
+
+
+
+
+
+ + + + From 01047ef23cd31c6a1899a64a5705d06bbdb3a59e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 16:57:08 +0000 Subject: [PATCH 7/7] v1.2.5 Bump version to 1.2.5 and add CHANGELOG.md covering the installer, recommendation, security and tooling changes since 1.2.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019LtWxNVyWyutDLyX8cWQFC --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ README.md | 6 +++--- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 CHANGELOG.md 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 e2840ac..6a59b9b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ |---|---| | Платформа | Windows x64 | | Стек | Electron · React · TypeScript · Vite | -| Версия | 1.2.0 | +| Версия | 1.2.5 | | Дистрибутив | Portable `.exe` (~70 МБ) | --- @@ -51,7 +51,7 @@ Соберите portable-сборку или возьмите готовый артефакт из [Releases](../../releases) (если опубликованы): ```text -release/GetBox-1.2.0-portable.exe +release/GetBox-1.2.5-portable.exe ``` Runtime не упакован внутрь exe — он ставится при первом запуске в `%LOCALAPPDATA%`, поэтому файл остаётся относительно лёгким. @@ -84,7 +84,7 @@ npm run electron:dev npm run dist ``` -Результат: `release/GetBox-1.2.0-portable.exe`. +Результат: `release/GetBox-1.2.5-portable.exe`. ### Скрипты diff --git a/package-lock.json b/package-lock.json index a61addf..5921837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "getbox", - "version": "1.2.0", + "version": "1.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "getbox", - "version": "1.2.0", + "version": "1.2.5", "dependencies": { "electron-store": "^10.0.0" }, diff --git a/package.json b/package.json index f161cbe..e1affd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "getbox", - "version": "1.2.0", + "version": "1.2.5", "description": "GetBox — локальный магазин программ для Windows", "main": "dist-electron/main.js", "type": "module",