From 407680cf3e64a78cac4a17e2aa47ce0e35426d24 Mon Sep 17 00:00:00 2001 From: CheapManga Date: Sun, 6 Sep 2026 12:22:32 +0200 Subject: [PATCH] Let manifest sources be declared in JSON The sources the app can fetch from are fixed at build time, so following a repo that moved, or adding a community one, means cutting a release. A `.json` file under %AppData%\LuaToolsGui\sources\ now declares extra sources; they appear as rows on the Add page and install through the existing pipeline. Data only, by design. A file says WHERE manifests come from and nothing else: it names one of the shapes the app already consumes (a zip or a lua per appid) and the app does the fetching. There is no way for one of these files to supply code, a binary, or a routine of its own, so installing one from a stranger cannot execute anything. Pack rows are appended after the app's own sources rather than ranked among them - that order is the app's decision, not a dropped-in file's. They are also exempt from the lua.tools sign-in gate, since they are fetched from the url the file names and never touch lua.tools. The availability probe goes through GithubProxy like the download does; one that skipped it would report "doesn't have the game" whenever GitHub was blocked while the download would have succeeded through a mirror. A host that refuses HEAD is probed with a one-byte ranged GET, because these urls are whatever host the author picked. Refusals are shown in Settings with their reason - a name the app already uses, a non-https url, a missing {appid}, an unknown kind - and one bad entry never takes a file's good ones with it. Files are re-read when the Settings page opens, so nothing needs a restart. All 29 languages. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BPSigFCgHqiUUbL9RRPZWs --- SOURCES.md | 56 +++++++ src/LuaToolsGui/App.xaml.cs | 2 + src/LuaToolsGui/Resources/Strings.Designer.cs | 6 + src/LuaToolsGui/Resources/Strings.ar.resx | 6 + src/LuaToolsGui/Resources/Strings.bg.resx | 6 + src/LuaToolsGui/Resources/Strings.cs.resx | 6 + src/LuaToolsGui/Resources/Strings.da.resx | 6 + src/LuaToolsGui/Resources/Strings.de.resx | 6 + src/LuaToolsGui/Resources/Strings.el.resx | 6 + src/LuaToolsGui/Resources/Strings.es-419.resx | 6 + src/LuaToolsGui/Resources/Strings.es.resx | 6 + src/LuaToolsGui/Resources/Strings.fi.resx | 6 + src/LuaToolsGui/Resources/Strings.fr.resx | 6 + src/LuaToolsGui/Resources/Strings.hu.resx | 6 + src/LuaToolsGui/Resources/Strings.id.resx | 6 + src/LuaToolsGui/Resources/Strings.it.resx | 6 + src/LuaToolsGui/Resources/Strings.ja.resx | 6 + src/LuaToolsGui/Resources/Strings.ko.resx | 6 + src/LuaToolsGui/Resources/Strings.nb.resx | 6 + src/LuaToolsGui/Resources/Strings.nl.resx | 6 + src/LuaToolsGui/Resources/Strings.pl.resx | 6 + src/LuaToolsGui/Resources/Strings.pt-BR.resx | 6 + src/LuaToolsGui/Resources/Strings.pt-PT.resx | 6 + src/LuaToolsGui/Resources/Strings.resx | 6 + src/LuaToolsGui/Resources/Strings.ro.resx | 6 + src/LuaToolsGui/Resources/Strings.ru.resx | 6 + src/LuaToolsGui/Resources/Strings.sv.resx | 6 + src/LuaToolsGui/Resources/Strings.th.resx | 6 + src/LuaToolsGui/Resources/Strings.tr.resx | 6 + src/LuaToolsGui/Resources/Strings.uk.resx | 6 + src/LuaToolsGui/Resources/Strings.vi.resx | 6 + .../Resources/Strings.zh-Hans.resx | 6 + .../Resources/Strings.zh-Hant.resx | 6 + .../Services/Downloads/ManifestJobFactory.cs | 29 +++- .../Services/Sources/PackSourceService.cs | 111 ++++++++++++ .../Services/Sources/SourcePack.cs | 75 +++++++++ .../Services/Sources/SourcePackRegistry.cs | 158 ++++++++++++++++++ .../ViewModels/DownloadViewModel.cs | 82 +++++++-- .../ViewModels/SettingsViewModel.cs | 45 ++++- src/LuaToolsGui/Views/SettingsView.xaml | 39 +++++ 40 files changed, 771 insertions(+), 12 deletions(-) create mode 100644 SOURCES.md create mode 100644 src/LuaToolsGui/Services/Sources/PackSourceService.cs create mode 100644 src/LuaToolsGui/Services/Sources/SourcePack.cs create mode 100644 src/LuaToolsGui/Services/Sources/SourcePackRegistry.cs diff --git a/SOURCES.md b/SOURCES.md new file mode 100644 index 0000000..dcd0fe1 --- /dev/null +++ b/SOURCES.md @@ -0,0 +1,56 @@ +# Manifest sources as JSON + +Extra manifest sources can be declared in `.json` files under +`%AppData%\LuaToolsGui\sources\`. A source declared this way appears as a row on the **Add** page and +installs through the same pipeline as any other. + +Settings → *Manifest sources* lists the files found, what each contributed, and why anything was +refused. Files are re-read whenever that page is opened, so there is nothing to restart. + +## Why + +The sources the app can fetch from are otherwise fixed at build time. Following a repo that moved, or +adding a community one, means cutting a release. This makes it a line of JSON. + +## Data only + +A file can say **where** manifests are fetched from. It cannot supply code, a binary, or a fetch routine +of its own: it names one of the shapes the app already knows how to consume, and the app does the +fetching. Installing one of these files cannot execute anything. + +## Format + +`%AppData%\LuaToolsGui\sources\example.json`: + +```json +{ + "schema": 1, + "name": "Example sources", + "author": "you", + "sources": [ + { + "name": "example-zip", + "displayName": "Example", + "kind": "manifestZip", + "url": "https://raw.githubusercontent.com/someone/some-repo/main/{appid}.zip", + "mirrors": ["https://cdn.jsdelivr.net/gh/someone/some-repo@main/{appid}.zip"], + "badge": "Free" + } + ] +} +``` + +| Field | | +|---|---| +| `kind` | `manifestZip` — one `.zip` holding the lua and its `.manifest` files.
`luaFile` — one `.lua`, entitlements and depot keys only. | +| `url` | Must be `https` and contain `{appid}`. | +| `mirrors` | Tried in order when the primary is unreachable. Optional. | +| `displayName` | Row label. Defaults to `name`. | +| `badge` | Short label on the row. Cosmetic. Optional. | + +A source is refused, with the reason shown in Settings, if it uses a name the app already uses, is not +`https`, has no `{appid}`, or names a `kind` that does not exist. One bad entry never takes the file's +good ones down with it. + +GitHub urls go through the app's existing mirror fallback, the availability check included. A host that +refuses `HEAD` is probed with a one-byte ranged `GET` instead. diff --git a/src/LuaToolsGui/App.xaml.cs b/src/LuaToolsGui/App.xaml.cs index 41374c1..2ee9da7 100644 --- a/src/LuaToolsGui/App.xaml.cs +++ b/src/LuaToolsGui/App.xaml.cs @@ -30,6 +30,8 @@ public App() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/LuaToolsGui/Resources/Strings.Designer.cs b/src/LuaToolsGui/Resources/Strings.Designer.cs index 199186b..6486ae6 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -583,4 +583,10 @@ public static class Strings public static string Depot_Err_NoKeyFor => Get(nameof(Depot_Err_NoKeyFor)); public static string Builds_Select_SharedHint => Get(nameof(Builds_Select_SharedHint)); public static string Downloads_ClearHistory_Confirm => Get(nameof(Downloads_ClearHistory_Confirm)); + public static string Sources_Err_Fetch => Get(nameof(Sources_Err_Fetch)); + public static string Settings_Section_Sources => Get(nameof(Settings_Section_Sources)); + public static string Settings_SourcePacks_Hint => Get(nameof(Settings_SourcePacks_Hint)); + public static string Settings_SourcePacks_Open => Get(nameof(Settings_SourcePacks_Open)); + public static string Settings_SourcePacks_None => Get(nameof(Settings_SourcePacks_None)); + public static string Settings_SourcePacks_Count => Get(nameof(Settings_SourcePacks_Count)); } diff --git a/src/LuaToolsGui/Resources/Strings.ar.resx b/src/LuaToolsGui/Resources/Strings.ar.resx index 8bb97b8..a0257d0 100644 --- a/src/LuaToolsGui/Resources/Strings.ar.resx +++ b/src/LuaToolsGui/Resources/Strings.ar.resx @@ -629,4 +629,10 @@ لا يوجد مفتاح فك تشفير في ملف Lua للـ depot ‏{0}. بيئة تشغيل مشتركة — مثبّتة غالبًا بالفعل. حدّدها للتنزيل على أي حال. هل تريد إزالة كل الإدخالات ({0}) من سجل التنزيلات؟ لن تتأثر الملفات التي تم تنزيلها. + هذا المصدر لا يحتوي على هذه اللعبة، أو تعذّر الوصول إليه. + مصادر المانيفست + مصادر إضافية معرَّفة في ملفات ‎.json. بيانات فقط: الملف يحدّد من أين تُجلب المانيفستات، ولا يُنفَّذ منه أي شيء. + فتح المجلد + لا توجد ملفات مصادر بعد. + المصادر: {0} diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index bf12f47..1a16f7c 100644 --- a/src/LuaToolsGui/Resources/Strings.bg.resx +++ b/src/LuaToolsGui/Resources/Strings.bg.resx @@ -603,4 +603,10 @@ В Lua няма ключ за дешифриране за depot {0}. Споделена среда за изпълнение — обикновено вече е инсталирана. Отметнете, за да я изтеглите. Да се премахнат ли всички {0} записа от историята на изтеглянията? Изтеглените файлове не се засягат. + Този източник няма тази игра или е недостъпен. + Източници на манифести + Допълнителни източници, описани във файлове .json. Само данни: файлът казва откъде да се вземат манифести и нищо в него не се изпълнява. + Отваряне на папката + Все още няма файлове с източници. + източници: {0} diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index 1163975..551b40c 100644 --- a/src/LuaToolsGui/Resources/Strings.cs.resx +++ b/src/LuaToolsGui/Resources/Strings.cs.resx @@ -603,4 +603,10 @@ Zrušit - pokračovat ve stahování V Lua není dešifrovací klíč pro depot {0}. Sdílený runtime — obvykle již nainstalován. Zaškrtnutím jej přesto stáhnete. Odstranit všech {0} záznamů z historie stahování? Stažené soubory zůstanou zachovány. + Tento zdroj tuto hru nemá, nebo je nedostupný. + Zdroje manifestů + Další zdroje deklarované v souborech .json. Pouze data: soubor říká, odkud manifesty stáhnout, a nic z něj se nespouští. + Otevřít složku + Zatím žádné soubory zdrojů. + zdroje: {0} diff --git a/src/LuaToolsGui/Resources/Strings.da.resx b/src/LuaToolsGui/Resources/Strings.da.resx index 57129a4..064fcc1 100644 --- a/src/LuaToolsGui/Resources/Strings.da.resx +++ b/src/LuaToolsGui/Resources/Strings.da.resx @@ -603,4 +603,10 @@ Annuller - fortsæt download Ingen dekrypteringsnøgle i Lua til depot {0}. Delt runtime — normalt allerede installeret. Sæt flueben for at hente alligevel. Fjern alle {0} poster fra downloadhistorikken? Hentede filer påvirkes ikke. + Denne kilde har ikke dette spil, eller kunne ikke nås. + Manifestkilder + Ekstra kilder angivet i .json-filer. Kun data: en fil siger, hvor manifester hentes fra, og intet i den køres. + Åbn mappen + Ingen kildefiler endnu. + kilder: {0} diff --git a/src/LuaToolsGui/Resources/Strings.de.resx b/src/LuaToolsGui/Resources/Strings.de.resx index 3054c0d..4aab882 100644 --- a/src/LuaToolsGui/Resources/Strings.de.resx +++ b/src/LuaToolsGui/Resources/Strings.de.resx @@ -603,4 +603,10 @@ Abbrechen - weiter herunterladen Kein Entschlüsselungsschlüssel im Lua für Depot {0}. Gemeinsame Laufzeit — meist bereits installiert. Zum Herunterladen trotzdem ankreuzen. Alle {0} Einträge aus dem Download-Verlauf entfernen? Heruntergeladene Dateien bleiben erhalten. + Diese Quelle hat dieses Spiel nicht oder war nicht erreichbar. + Manifest-Quellen + Zusätzliche Quellen, in .json-Dateien deklariert. Nur Daten: eine Datei sagt, woher Manifeste geholt werden, ausgeführt wird daraus nie etwas. + Ordner öffnen + Noch keine Quellendateien. + Quellen: {0} diff --git a/src/LuaToolsGui/Resources/Strings.el.resx b/src/LuaToolsGui/Resources/Strings.el.resx index 4850027..0db7108 100644 --- a/src/LuaToolsGui/Resources/Strings.el.resx +++ b/src/LuaToolsGui/Resources/Strings.el.resx @@ -603,4 +603,10 @@ Δεν υπάρχει κλειδί αποκρυπτογράφησης στο Lua για το depot {0}. Κοινόχρηστο runtime — συνήθως ήδη εγκατεστημένο. Επιλέξτε για λήψη ούτως ή άλλως. Να αφαιρεθούν και οι {0} καταχωρίσεις από το ιστορικό λήψεων; Τα ληφθέντα αρχεία δεν επηρεάζονται. + Αυτή η πηγή δεν έχει αυτό το παιχνίδι, ή δεν ήταν προσβάσιμη. + Πηγές manifest + Επιπλέον πηγές δηλωμένες σε αρχεία .json. Μόνο δεδομένα: το αρχείο λέει από πού λαμβάνονται τα manifest, και τίποτα μέσα του δεν εκτελείται. + Άνοιγμα φακέλου + Δεν υπάρχουν ακόμη αρχεία πηγών. + πηγές: {0} diff --git a/src/LuaToolsGui/Resources/Strings.es-419.resx b/src/LuaToolsGui/Resources/Strings.es-419.resx index 9f24190..8be6ad4 100644 --- a/src/LuaToolsGui/Resources/Strings.es-419.resx +++ b/src/LuaToolsGui/Resources/Strings.es-419.resx @@ -603,4 +603,10 @@ Cancelar: seguir descargando No hay clave de descifrado en el Lua para el depot {0}. Runtime compartido: normalmente ya está instalado. Marca para descargarlo igualmente. ¿Quitar las {0} entradas del historial de descargas? Los archivos descargados no se ven afectados. + Esta fuente no tiene este juego, o no se pudo contactar. + Fuentes de manifiestos + Fuentes adicionales declaradas en archivos .json. Solo datos: un archivo indica de dónde obtener manifiestos, y nada en él se ejecuta. + Abrir la carpeta + Aún no hay archivos de fuentes. + fuentes: {0} diff --git a/src/LuaToolsGui/Resources/Strings.es.resx b/src/LuaToolsGui/Resources/Strings.es.resx index f49178d..3f0c25c 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -603,4 +603,10 @@ Cancelar: seguir descargando No hay clave de descifrado en el Lua para el depot {0}. Runtime compartido: normalmente ya está instalado. Marca para descargarlo igualmente. ¿Quitar las {0} entradas del historial de descargas? Los archivos descargados no se ven afectados. + Esta fuente no tiene este juego, o no se pudo contactar. + Fuentes de manifiestos + Fuentes adicionales declaradas en archivos .json. Solo datos: un archivo indica de dónde obtener manifiestos, y nada en él se ejecuta. + Abrir la carpeta + Aún no hay archivos de fuentes. + fuentes: {0} diff --git a/src/LuaToolsGui/Resources/Strings.fi.resx b/src/LuaToolsGui/Resources/Strings.fi.resx index 98f667a..1ff81d4 100644 --- a/src/LuaToolsGui/Resources/Strings.fi.resx +++ b/src/LuaToolsGui/Resources/Strings.fi.resx @@ -603,4 +603,10 @@ Peruuta - jatka lataamista Lua-tiedostossa ei ole salauksenpurkuavainta depotille {0}. Jaettu suoritusympäristö — yleensä jo asennettu. Valitse ladataksesi silti. Poistetaanko kaikki {0} merkintää lataushistoriasta? Ladattuihin tiedostoihin tämä ei vaikuta. + Tässä lähteessä ei ole tätä peliä, tai siihen ei saatu yhteyttä. + Manifestilähteet + Lisälähteitä .json-tiedostoissa. Pelkkää dataa: tiedosto kertoo mistä manifestit haetaan, eikä siitä suoriteta mitään. + Avaa kansio + Ei vielä lähdetiedostoja. + lähteet: {0} diff --git a/src/LuaToolsGui/Resources/Strings.fr.resx b/src/LuaToolsGui/Resources/Strings.fr.resx index 9bd3003..66427a5 100644 --- a/src/LuaToolsGui/Resources/Strings.fr.resx +++ b/src/LuaToolsGui/Resources/Strings.fr.resx @@ -603,4 +603,10 @@ Annuler - continuer le téléchargement Aucune clé de déchiffrement dans le Lua pour le depot {0}. Runtime partagé — généralement déjà installé. Cochez pour le télécharger quand même. Supprimer les {0} entrées de l'historique des téléchargements ? Les fichiers téléchargés ne sont pas affectés. + Cette source n'a pas ce jeu, ou n'a pas pu être jointe. + Sources de manifests + Sources supplémentaires déclarées dans des fichiers .json. Uniquement des données : un fichier indique où récupérer des manifests, rien n'y est jamais exécuté. + Ouvrir le dossier + Aucun fichier de source pour l'instant. + sources : {0} diff --git a/src/LuaToolsGui/Resources/Strings.hu.resx b/src/LuaToolsGui/Resources/Strings.hu.resx index 184a58e..40a625f 100644 --- a/src/LuaToolsGui/Resources/Strings.hu.resx +++ b/src/LuaToolsGui/Resources/Strings.hu.resx @@ -603,4 +603,10 @@ Mégse - letöltés folytatása A Lua nem tartalmaz visszafejtő kulcsot a(z) {0} depot-hoz. Megosztott futtatókörnyezet — általában már telepítve van. Jelölje be, ha mégis letöltené. Eltávolítja mind a(z) {0} bejegyzést a letöltési előzményekből? A letöltött fájlokat ez nem érinti. + Ez a forrás nem tartalmazza ezt a játékot, vagy nem érhető el. + Manifest-források + További források .json fájlokban megadva. Csak adat: a fájl megmondja, honnan töltsük le a manifesteket, és semmi nem fut le belőle. + Mappa megnyitása + Még nincs forrásfájl. + források: {0} diff --git a/src/LuaToolsGui/Resources/Strings.id.resx b/src/LuaToolsGui/Resources/Strings.id.resx index 2bff851..a73c49c 100644 --- a/src/LuaToolsGui/Resources/Strings.id.resx +++ b/src/LuaToolsGui/Resources/Strings.id.resx @@ -603,4 +603,10 @@ Batal - lanjutkan mengunduh Tidak ada kunci dekripsi di Lua untuk depot {0}. Runtime bersama — biasanya sudah terpasang. Centang untuk tetap mengunduh. Hapus semua {0} entri dari riwayat unduhan? Berkas yang sudah diunduh tidak terpengaruh. + Sumber ini tidak memiliki gim ini, atau tidak dapat dijangkau. + Sumber manifest + Sumber tambahan yang dideklarasikan dalam berkas .json. Hanya data: berkas menyebutkan dari mana manifest diambil, dan tidak ada isinya yang dijalankan. + Buka folder + Belum ada berkas sumber. + sumber: {0} diff --git a/src/LuaToolsGui/Resources/Strings.it.resx b/src/LuaToolsGui/Resources/Strings.it.resx index 3d89b23..f32e20d 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -603,4 +603,10 @@ Annulla - continua a scaricare Nessuna chiave di decrittazione nel Lua per il depot {0}. Runtime condiviso — di solito già installato. Spunta per scaricarlo comunque. Rimuovere tutte le {0} voci dalla cronologia dei download? I file scaricati non vengono toccati. + Questa fonte non ha questo gioco, o non è raggiungibile. + Fonti di manifest + Fonti aggiuntive dichiarate in file .json. Solo dati: un file indica dove prendere i manifest, e nulla al suo interno viene mai eseguito. + Apri la cartella + Nessun file di fonti per ora. + fonti: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ja.resx b/src/LuaToolsGui/Resources/Strings.ja.resx index f59f34d..ff8987b 100644 --- a/src/LuaToolsGui/Resources/Strings.ja.resx +++ b/src/LuaToolsGui/Resources/Strings.ja.resx @@ -603,4 +603,10 @@ Steam\config\stplug-in から .lua ファイルを削除します。Lua に depot {0} の復号キーがありません。 共有ランタイム — 通常はインストール済みです。必要ならチェックしてください。 ダウンロード履歴から {0} 件すべてを削除しますか?ダウンロード済みのファイルには影響しません。 + このソースにはこのゲームがないか、接続できませんでした。 + マニフェストのソース + ​.json ファイルで宣言する追加ソース。データのみで、ファイルはマニフェストの取得先を示すだけです。中身が実行されることはありません。 + フォルダーを開く + ソースファイルはまだありません。 + ソース: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index a2294a5..ad4d5a5 100644 --- a/src/LuaToolsGui/Resources/Strings.ko.resx +++ b/src/LuaToolsGui/Resources/Strings.ko.resx @@ -603,4 +603,10 @@ Steam\config\stplug-in에서 .lua 파일을 삭제합니다. Lua에 depot {0}의 복호화 키가 없습니다. 공유 런타임 — 대개 이미 설치되어 있습니다. 그래도 받으려면 선택하세요. 다운로드 기록에서 {0}개 항목을 모두 제거할까요? 내려받은 파일은 영향을 받지 않습니다. + 이 소스에는 이 게임이 없거나 연결할 수 없습니다. + 매니페스트 소스 + ​.json 파일로 선언하는 추가 소스. 데이터일 뿐이며, 파일은 매니페스트를 어디서 가져올지 알려줄 뿐 그 안의 어떤 것도 실행되지 않습니다. + 폴더 열기 + 아직 소스 파일이 없습니다. + 소스: {0} diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index 5c61f94..07ac936 100644 --- a/src/LuaToolsGui/Resources/Strings.nb.resx +++ b/src/LuaToolsGui/Resources/Strings.nb.resx @@ -603,4 +603,10 @@ Avbryt - fortsett nedlastingen Ingen dekrypteringsnøkkel i Lua for depot {0}. Delt kjøretid — vanligvis allerede installert. Huk av for å laste ned likevel. Fjerne alle {0} oppføringer fra nedlastingsloggen? Nedlastede filer påvirkes ikke. + Denne kilden har ikke dette spillet, eller kunne ikke nås. + Manifestkilder + Ekstra kilder angitt i .json-filer. Bare data: en fil sier hvor manifester hentes fra, og ingenting i den kjøres. + Åpne mappen + Ingen kildefiler ennå. + kilder: {0} diff --git a/src/LuaToolsGui/Resources/Strings.nl.resx b/src/LuaToolsGui/Resources/Strings.nl.resx index 1821279..48aca3f 100644 --- a/src/LuaToolsGui/Resources/Strings.nl.resx +++ b/src/LuaToolsGui/Resources/Strings.nl.resx @@ -603,4 +603,10 @@ Annuleren - doorgaan met downloaden Geen ontsleutelingssleutel in de Lua voor depot {0}. Gedeelde runtime — meestal al geïnstalleerd. Vink aan om toch te downloaden. Alle {0} items uit de downloadgeschiedenis verwijderen? Gedownloade bestanden blijven staan. + Deze bron heeft dit spel niet, of was niet bereikbaar. + Manifestbronnen + Extra bronnen, opgegeven in .json-bestanden. Alleen gegevens: een bestand zegt waar manifesten vandaan komen, er wordt nooit iets uit uitgevoerd. + Map openen + Nog geen bronbestanden. + bronnen: {0} diff --git a/src/LuaToolsGui/Resources/Strings.pl.resx b/src/LuaToolsGui/Resources/Strings.pl.resx index cfd84fa..b697d55 100644 --- a/src/LuaToolsGui/Resources/Strings.pl.resx +++ b/src/LuaToolsGui/Resources/Strings.pl.resx @@ -603,4 +603,10 @@ Anuluj - kontynuuj pobieranie Brak klucza deszyfrującego w Lua dla depotu {0}. Współdzielone środowisko — zwykle już zainstalowane. Zaznacz, aby i tak pobrać. Usunąć wszystkie {0} wpisy z historii pobierania? Pobrane pliki pozostaną nienaruszone. + To źródło nie ma tej gry lub jest nieosiągalne. + Źródła manifestów + Dodatkowe źródła zadeklarowane w plikach .json. Wyłącznie dane: plik mówi, skąd pobrać manifesty, i nic z niego nie jest uruchamiane. + Otwórz folder + Brak plików źródeł. + źródła: {0} diff --git a/src/LuaToolsGui/Resources/Strings.pt-BR.resx b/src/LuaToolsGui/Resources/Strings.pt-BR.resx index c5801cd..14a2605 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-BR.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-BR.resx @@ -603,4 +603,10 @@ Cancelar - continuar baixando Sem chave de descriptografia no Lua para o depot {0}. Runtime compartilhado — normalmente já instalado. Marque para baixar mesmo assim. Remover todas as {0} entradas do histórico de downloads? Os arquivos baixados não são afetados. + Esta fonte não tem este jogo, ou não pôde ser acessada. + Fontes de manifesto + Fontes extras declaradas em arquivos .json. Apenas dados: um arquivo diz de onde buscar manifestos, e nada nele é executado. + Abrir a pasta + Ainda não há arquivos de fontes. + fontes: {0} diff --git a/src/LuaToolsGui/Resources/Strings.pt-PT.resx b/src/LuaToolsGui/Resources/Strings.pt-PT.resx index 0835d98..e57536c 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-PT.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-PT.resx @@ -603,4 +603,10 @@ Cancelar - continuar a transferir Sem chave de desencriptação no Lua para o depot {0}. Runtime partilhado — normalmente já instalado. Marque para transferir na mesma. Remover todas as {0} entradas do histórico de transferências? Os ficheiros transferidos não são afetados. + Esta fonte não tem este jogo, ou não pôde ser contactada. + Fontes de manifesto + Fontes adicionais declaradas em ficheiros .json. Apenas dados: um ficheiro diz onde obter manifestos, e nada nele é executado. + Abrir a pasta + Ainda não há ficheiros de fontes. + fontes: {0} diff --git a/src/LuaToolsGui/Resources/Strings.resx b/src/LuaToolsGui/Resources/Strings.resx index 340b033..1e1addf 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -638,4 +638,10 @@ Cancel - keep downloading No decryption key in Lua for depot {0}. Shared runtime — usually already installed. Tick to download anyway. Remove all {0} entries from the download history? Downloaded files are not affected. + This source doesn't have this game, or couldn't be reached. + Manifest sources + Extra sources declared in .json files. Data only: a file says where manifests can be fetched from, and nothing in it is ever executed. + Open the folder + No source files yet. + sources: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ro.resx b/src/LuaToolsGui/Resources/Strings.ro.resx index 5c8d6c8..559bb45 100644 --- a/src/LuaToolsGui/Resources/Strings.ro.resx +++ b/src/LuaToolsGui/Resources/Strings.ro.resx @@ -603,4 +603,10 @@ Anulare - continuă descărcarea Nu există cheie de decriptare în Lua pentru depot {0}. Runtime partajat — de obicei deja instalat. Bifează pentru a-l descărca oricum. Elimini toate cele {0} intrări din istoricul descărcărilor? Fișierele descărcate nu sunt afectate. + Această sursă nu are acest joc sau nu a putut fi contactată. + Surse de manifeste + Surse suplimentare declarate în fișiere .json. Doar date: un fișier spune de unde se iau manifestele și nimic din el nu se execută. + Deschide folderul + Încă niciun fișier de surse. + surse: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ru.resx b/src/LuaToolsGui/Resources/Strings.ru.resx index e43eb9c..e9eca78 100644 --- a/src/LuaToolsGui/Resources/Strings.ru.resx +++ b/src/LuaToolsGui/Resources/Strings.ru.resx @@ -603,4 +603,10 @@ В Lua нет ключа расшифровки для depot {0}. Общая среда выполнения — обычно уже установлена. Отметьте, чтобы всё равно скачать. Удалить все записи ({0}) из истории загрузок? Загруженные файлы не затрагиваются. + В этом источнике нет этой игры, либо он недоступен. + Источники манифестов + Дополнительные источники, описанные в файлах .json. Только данные: файл указывает, откуда брать манифесты, и ничего из него не выполняется. + Открыть папку + Файлов источников пока нет. + источники: {0} diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index 4ea7e47..4fe1c77 100644 --- a/src/LuaToolsGui/Resources/Strings.sv.resx +++ b/src/LuaToolsGui/Resources/Strings.sv.resx @@ -603,4 +603,10 @@ Avbryt - fortsätt hämta Ingen dekrypteringsnyckel i Lua för depot {0}. Delad körtid — vanligtvis redan installerad. Kryssa i för att hämta ändå. Ta bort alla {0} poster från hämtningshistoriken? Hämtade filer påverkas inte. + Den här källan har inte det här spelet, eller kunde inte nås. + Manifestkällor + Extra källor som anges i .json-filer. Endast data: en fil säger var manifest hämtas, inget i den körs någonsin. + Öppna mappen + Inga källfiler ännu. + källor: {0} diff --git a/src/LuaToolsGui/Resources/Strings.th.resx b/src/LuaToolsGui/Resources/Strings.th.resx index f45496a..a297445 100644 --- a/src/LuaToolsGui/Resources/Strings.th.resx +++ b/src/LuaToolsGui/Resources/Strings.th.resx @@ -603,4 +603,10 @@ ไม่มีคีย์ถอดรหัสใน Lua สำหรับ depot {0} รันไทม์ที่ใช้ร่วมกัน — ปกติติดตั้งไว้แล้ว ติ๊กเพื่อดาวน์โหลดอยู่ดี ต้องการลบรายการทั้งหมด {0} รายการออกจากประวัติการดาวน์โหลดหรือไม่ ไฟล์ที่ดาวน์โหลดแล้วจะไม่ได้รับผลกระทบ + แหล่งนี้ไม่มีเกมนี้ หรือเข้าถึงไม่ได้ + แหล่งแมนิเฟสต์ + แหล่งเพิ่มเติมที่ประกาศไว้ในไฟล์ .json เป็นข้อมูลล้วน ๆ ไฟล์บอกเพียงว่าจะดึงแมนิเฟสต์จากที่ใด และไม่มีอะไรในไฟล์ถูกรัน + เปิดโฟลเดอร์ + ยังไม่มีไฟล์แหล่งข้อมูล + แหล่ง: {0} diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index bfeb407..56e7cf2 100644 --- a/src/LuaToolsGui/Resources/Strings.tr.resx +++ b/src/LuaToolsGui/Resources/Strings.tr.resx @@ -603,4 +603,10 @@ Hayır - durdur ama dosyaları koru Lua'da {0} depot'u için şifre çözme anahtarı yok. Paylaşılan çalışma zamanı — genelde zaten kurulu. Yine de indirmek için işaretleyin. İndirme geçmişindeki {0} kaydın tümü kaldırılsın mı? İndirilen dosyalar etkilenmez. + Bu kaynakta bu oyun yok ya da kaynağa ulaşılamadı. + Manifest kaynakları + ​.json dosyalarında tanımlanan ek kaynaklar. Yalnızca veri: dosya manifestlerin nereden alınacağını söyler, içinden hiçbir şey çalıştırılmaz. + Klasörü aç + Henüz kaynak dosyası yok. + kaynaklar: {0} diff --git a/src/LuaToolsGui/Resources/Strings.uk.resx b/src/LuaToolsGui/Resources/Strings.uk.resx index 8e9a528..d5b45e9 100644 --- a/src/LuaToolsGui/Resources/Strings.uk.resx +++ b/src/LuaToolsGui/Resources/Strings.uk.resx @@ -603,4 +603,10 @@ У Lua немає ключа розшифрування для depot {0}. Спільне середовище виконання — зазвичай уже встановлене. Позначте, щоб усе одно завантажити. Вилучити всі записи ({0}) з історії завантажень? Завантажені файли не зачіпаються. + У цьому джерелі немає цієї гри, або воно недоступне. + Джерела маніфестів + Додаткові джерела, описані у файлах .json. Лише дані: файл вказує, звідки брати маніфести, і нічого з нього не виконується. + Відкрити теку + Файлів джерел поки немає. + джерела: {0} diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index e8ce5f5..415bccb 100644 --- a/src/LuaToolsGui/Resources/Strings.vi.resx +++ b/src/LuaToolsGui/Resources/Strings.vi.resx @@ -603,4 +603,10 @@ Hủy - tiếp tục tải Không có khóa giải mã trong Lua cho depot {0}. Runtime dùng chung — thường đã được cài. Đánh dấu để vẫn tải xuống. Xóa tất cả {0} mục khỏi lịch sử tải xuống? Các tệp đã tải không bị ảnh hưởng. + Nguồn này không có trò chơi này, hoặc không kết nối được. + Nguồn manifest + Nguồn bổ sung khai báo trong tệp .json. Chỉ là dữ liệu: tệp cho biết lấy manifest ở đâu, và không có gì trong đó được thực thi. + Mở thư mục + Chưa có tệp nguồn nào. + nguồn: {0} diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx index 39890c0..920760d 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx @@ -623,4 +623,10 @@ Lua 中没有 depot {0} 的解密密钥。 共享运行库 — 通常已安装。如仍需下载请勾选。 要从下载历史中移除全部 {0} 条记录吗?已下载的文件不会受影响。 + 此来源没有这个游戏,或无法连接。 + 清单来源 + 在 .json 文件中声明的额外来源。纯数据:文件只说明从哪里获取清单,其中的任何内容都不会被执行。 + 打开文件夹 + 还没有来源文件。 + 来源:{0} diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index 0d70526..8603372 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx @@ -603,4 +603,10 @@ Lua 中沒有 depot {0} 的解密金鑰。 共用執行階段 — 通常已安裝。如仍需下載請勾選。 要從下載紀錄中移除全部 {0} 筆記錄嗎?已下載的檔案不會受影響。 + 此來源沒有這個遊戲,或無法連線。 + 資訊清單來源 + 在 .json 檔案中宣告的額外來源。純資料:檔案只說明從何處取得資訊清單,其中的任何內容都不會被執行。 + 開啟資料夾 + 尚未有來源檔案。 + 來源:{0} diff --git a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs index df1ca2d..e44bf52 100644 --- a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -24,7 +24,8 @@ public class ManifestJobFactory( ToastService toast, DepotDownloaderService depotTool, SteamDepotInfo depotInfo, - SteamAutoCrackService sac) + SteamAutoCrackService sac, + Sources.PackSourceService packSources) { // ── Job builders ───────────────────────────────────────────────── @@ -52,6 +53,32 @@ public DownloadJob CreateManifestJob( onReveal); } + /// + /// A manifest from a source a pack declared. Fetched from the pack's url, then installed by exactly + /// the same code as any other manifest — a pack changes where a file comes from, never what is done + /// with it. + /// + public DownloadJob CreatePackSourceJob( + Sources.PackSource source, long appId, string? gameName, + Func>? confirm = null, + Action? onFinished = null, + Action? onReveal = null) + { + string title = gameName ?? appId.ToString(); + return new DownloadJob( + DownloadKind.Manifest, + $"manifest:{appId}", + appId, + title, + source.DisplayName, + covers.GetLocalPath(appId), + (_, progress, ct) => packSources.FetchAsync(source, appId, progress, ct), + (file, _, _) => Task.FromResult(InstallManifest(file, appId, title)), + confirm, + onFinished, + onReveal); + } + /// DLC unlock lua. Installed silently: it's an unlock, so there's nothing to confirm. public DownloadJob CreateDlcJob( long appId, string baseAppId, string? gameName, diff --git a/src/LuaToolsGui/Services/Sources/PackSourceService.cs b/src/LuaToolsGui/Services/Sources/PackSourceService.cs new file mode 100644 index 0000000..6100660 --- /dev/null +++ b/src/LuaToolsGui/Services/Sources/PackSourceService.cs @@ -0,0 +1,111 @@ +using System.IO; +using System.Net; +using System.Net.Http; +using LuaToolsGui.Services.Downloads; +using Microsoft.Extensions.Logging; + +namespace LuaToolsGui.Services.Sources; + +/// +/// Fetches from the manifest sources a pack declared. One service for all of them, because a pack names +/// a SHAPE the app already knows how to consume rather than supplying a routine of its own. +/// +/// +/// Everything goes through , the existence probe included: the proxy tries the +/// url directly and only then its mirrors, so a probe that skipped it would answer "this source doesn't +/// have the game" whenever GitHub was blocked, while the download that followed would have succeeded +/// through a mirror. +/// +public class PackSourceService(GithubProxy gh, ILogger log) +{ + // Its own client so a probe never inherits a long download timeout. + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) }; + + /// Does this source have the game? Any failure answers "no", never an error. + public async Task HasGameAsync(PackSource source, long appId, CancellationToken ct = default) + { + try + { + foreach (string url in Urls(source, appId)) + foreach (string candidate in GithubProxy.Candidates(url)) + { + try { if (await ExistsAsync(candidate, ct)) return true; } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch { /* this candidate is out; the next one may answer */ } + } + + return false; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) + { + log.LogDebug(ex, "Pack source {Source} probe for {AppId} failed", source.Name, appId); + return false; + } + } + + /// Fetch the game to a temp file, for the existing install pipeline to take. + public async Task FetchAsync( + PackSource source, long appId, IProgress? progress, CancellationToken ct = default) + { + string ext = source.Kind is SourceKind.ManifestZip ? "zip" : "lua"; + string path = Path.Combine(Path.GetTempPath(), $"pack-{Sanitize(source.Name)}-{appId}.{ext}"); + + // GithubProxy reports 0..1 fractions; scaled to the byte-shaped report the queue's UI speaks, so + // the bar moves rather than sitting still. + var sink = progress is null ? null + : new ProgressRelay(f => progress.Report(new DownloadProgress((long)((f ?? 0) * 1000), 1000))); + + Exception? last = null; + foreach (string url in Urls(source, appId)) + { + try + { + await gh.DownloadAsync(url, path, sink, ct); + if (File.Exists(path) && new FileInfo(path).Length > 0) + return new DownloadedFile(path, $"{appId}.{ext}"); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } + catch (Exception ex) { last = ex; } + } + + log.LogDebug(last, "Pack source {Source} could not fetch {AppId}", source.Name, appId); + throw new DownloadAbortedException(Resources.Strings.Sources_Err_Fetch); + } + + /// + /// Does this exact url serve something? HEAD first, and on a host that refuses HEAD, a one-byte + /// ranged GET — a pack's url is any host its author chose, and plenty answer 405 or 501 to a HEAD + /// while serving the file perfectly well over GET. + /// + private async Task ExistsAsync(string url, CancellationToken ct) + { + using var head = new HttpRequestMessage(HttpMethod.Head, url); + head.Headers.TryAddWithoutValidation("User-Agent", "LuaTools"); + using var headRes = await _http.SendAsync(head, ct); + if (headRes.StatusCode == HttpStatusCode.OK) return true; + if (headRes.StatusCode is not (HttpStatusCode.MethodNotAllowed or HttpStatusCode.NotImplemented)) + return false; + + // Range is a request, not a promise: a server may ignore it and start sending the whole file, so + // the response is disposed without reading the body and only the status is used. + using var get = new HttpRequestMessage(HttpMethod.Get, url); + get.Headers.TryAddWithoutValidation("User-Agent", "LuaTools"); + get.Headers.TryAddWithoutValidation("Range", "bytes=0-0"); + using var getRes = await _http.SendAsync(get, HttpCompletionOption.ResponseHeadersRead, ct); + return getRes.StatusCode is HttpStatusCode.OK or HttpStatusCode.PartialContent; + } + + private static IEnumerable Urls(PackSource source, long appId) + { + yield return Fill(source.Url, appId); + foreach (string m in source.Mirrors) yield return Fill(m, appId); + } + + private static string Fill(string template, long appId) => + template.Replace("{appid}", appId.ToString(), StringComparison.OrdinalIgnoreCase); + + /// Keeps a pack-supplied name fit for a temp file name. + private static string Sanitize(string name) => + string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); +} diff --git a/src/LuaToolsGui/Services/Sources/SourcePack.cs b/src/LuaToolsGui/Services/Sources/SourcePack.cs new file mode 100644 index 0000000..b96bae5 --- /dev/null +++ b/src/LuaToolsGui/Services/Sources/SourcePack.cs @@ -0,0 +1,75 @@ +using System.Text.Json.Serialization; + +namespace LuaToolsGui.Services.Sources; + +/// +/// One .json file in the source-pack folder. Pure data: a pack declares where manifests can be +/// fetched from and nothing else. There is no code path here by design — a pack cannot execute anything, +/// which is what makes it safe to install one from someone you don't know. +/// +public sealed class SourcePack +{ + /// + /// Format version of the file. Bumped only when a change would make an older build misread a newer + /// pack; a build refuses a schema from the future rather than guess at what it means. + /// + [JsonPropertyName("schema")] + public int Schema { get; set; } = 1; + + /// Shown in the pack list. Falls back to the file name. + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("author")] + public string? Author { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("sources")] + public List Sources { get; set; } = []; +} + +/// The shapes of source LuaTools knows how to fetch. A pack picks one; it cannot supply its own. +public enum SourceKind +{ + /// One <appid>.zip per game, holding the lua and its .manifest files. + ManifestZip, + + /// One <appid>.lua per game: entitlements and depot keys, no manifests. + LuaFile, +} + +/// A single source declared by a pack. +public sealed class SourceEntry +{ + /// + /// Key used for the row, ordering and the download route. Must not be one the app already uses — + /// a pack that claims an existing name is refused rather than silently shadowing it. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + /// What the Add page's row shows. Falls back to . + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + /// "manifestZip" or "luaFile", case-insensitive. + [JsonPropertyName("kind")] + public string Kind { get; set; } = ""; + + /// Where to fetch, containing the literal token {appid}. + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + /// + /// Tried in order when the primary url is unreachable. The reason packs are worth having at all: a + /// repo that moves or goes stale becomes a line to edit rather than a release to cut. + /// + [JsonPropertyName("mirrors")] + public List Mirrors { get; set; } = []; + + /// Short label on the row. Cosmetic; the app never reads it back as a capability. + [JsonPropertyName("badge")] + public string? Badge { get; set; } +} diff --git a/src/LuaToolsGui/Services/Sources/SourcePackRegistry.cs b/src/LuaToolsGui/Services/Sources/SourcePackRegistry.cs new file mode 100644 index 0000000..4323381 --- /dev/null +++ b/src/LuaToolsGui/Services/Sources/SourcePackRegistry.cs @@ -0,0 +1,158 @@ +using System.IO; +using System.Text.Json; + +namespace LuaToolsGui.Services.Sources; + +/// A source a pack declared, after the registry has vetted it. +public sealed record PackSource( + string Name, + string DisplayName, + SourceKind Kind, + string Url, + IReadOnlyList Mirrors, + string? Badge); + +/// One pack file, and what became of it. +public sealed class LoadedPack +{ + public required string FileName { get; init; } + public required string DisplayName { get; init; } + public string? Author { get; init; } + public string? Description { get; init; } + + /// Null when the pack loaded; otherwise why it was refused, in words for the user. + public string? Error { get; set; } + + public List Sources { get; } = []; +} + +/// +/// Reads manifest sources from %AppData%\LuaToolsGui\sources\*.json. +/// +/// +/// The sources this app can fetch from are otherwise fixed at build time, so following one to a +/// fresher mirror — or adding a community repo at all — means cutting a release. A pack file moves that +/// to editing a line of JSON. +/// +/// A pack is data only. There is deliberately no way for one to supply code, a binary or a +/// fetch routine of its own: it names one of the shapes the app already knows how to consume, and the +/// app does the fetching. Installing a pack from a stranger cannot execute anything. +/// +/// Nothing here may stop the app from starting. Every stage is wrapped, a bad file is recorded +/// against its own name and the loop moves on. +/// +public sealed class SourcePackRegistry +{ + /// Highest pack schema this build understands. + private const int SupportedSchema = 1; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + public static string Root { get; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "LuaToolsGui", "sources"); + + private readonly List _packs = []; + + /// Every pack file found, loaded or refused. + public IReadOnlyList Packs => _packs; + + /// Sources contributed by the packs that loaded, in file then declaration order. + public IReadOnlyList Sources => _packs.SelectMany(p => p.Sources).ToList(); + + /// + /// Re-read the folder. Cheap (a handful of small files) and safe to call whenever the list might + /// have changed, which is what lets a pack be added without restarting the app. + /// + /// + /// Source names the app already uses. A pack claiming one is refused rather than shadowing it: a row + /// whose behaviour depended on which registration won would be impossible to reason about. + /// + public void Reload(IReadOnlyCollection reservedNames) + { + _packs.Clear(); + + List files; + try + { + if (!Directory.Exists(Root)) return; + files = Directory.EnumerateFiles(Root, "*.json").OrderBy(f => f, StringComparer.OrdinalIgnoreCase).ToList(); + } + catch { return; } + + var taken = new HashSet(reservedNames, StringComparer.OrdinalIgnoreCase); + + foreach (string file in files) + { + string shown = Path.GetFileName(file); + SourcePack? pack; + try { pack = JsonSerializer.Deserialize(File.ReadAllText(file), JsonOpts); } + catch (Exception ex) + { + _packs.Add(new LoadedPack { FileName = shown, DisplayName = shown, Error = ex.Message }); + continue; + } + + if (pack is null) + { + _packs.Add(new LoadedPack { FileName = shown, DisplayName = shown, Error = "the file is empty" }); + continue; + } + + var loaded = new LoadedPack + { + FileName = shown, + DisplayName = string.IsNullOrWhiteSpace(pack.Name) ? shown : pack.Name!, + Author = pack.Author, + Description = pack.Description, + }; + _packs.Add(loaded); + + if (pack.Schema > SupportedSchema) + { + loaded.Error = $"needs a newer LuaTools (schema {pack.Schema}, this build reads {SupportedSchema})"; + continue; + } + + foreach (var e in pack.Sources) Vet(loaded, e, taken); + } + } + + /// Adds one declared source, or records why it was skipped. Never throws. + private static void Vet(LoadedPack pack, SourceEntry e, HashSet taken) + { + void Skip(string why) => pack.Error = pack.Error is null ? why : $"{pack.Error}; {why}"; + + if (string.IsNullOrWhiteSpace(e.Name) || string.IsNullOrWhiteSpace(e.Url)) + { Skip("a source is missing its name or url"); return; } + + if (!Enum.TryParse(e.Kind, ignoreCase: true, out var kind)) + { Skip($"\"{e.Name}\" has an unknown kind \"{e.Kind}\""); return; } + + // Everything a pack fetches goes over TLS. A source anyone can add is not the place to accept + // plaintext: the file it returns is installed into Steam. + if (!IsHttps(e.Url) || e.Mirrors.Any(u => !IsHttps(u))) + { Skip($"\"{e.Name}\" must use https"); return; } + + if (!e.Url.Contains("{appid}", StringComparison.OrdinalIgnoreCase)) + { Skip($"\"{e.Name}\" has no {{appid}} in its url"); return; } + + if (!taken.Add(e.Name)) + { Skip($"\"{e.Name}\" is a name the app already uses"); return; } + + pack.Sources.Add(new PackSource( + e.Name, + string.IsNullOrWhiteSpace(e.DisplayName) ? e.Name : e.DisplayName!, + kind, + e.Url, + e.Mirrors.Where(IsHttps).ToArray(), + e.Badge)); + } + + private static bool IsHttps(string url) => + Uri.TryCreate(url, UriKind.Absolute, out var u) && u.Scheme == Uri.UriSchemeHttps; +} diff --git a/src/LuaToolsGui/ViewModels/DownloadViewModel.cs b/src/LuaToolsGui/ViewModels/DownloadViewModel.cs index 1e6e4ec..88199f2 100644 --- a/src/LuaToolsGui/ViewModels/DownloadViewModel.cs +++ b/src/LuaToolsGui/ViewModels/DownloadViewModel.cs @@ -46,13 +46,17 @@ public partial class SourceRowViewModel : ObservableObject // would collapse it anyway; this just keeps the button from looking clickable. public bool CanDownload => IsAvailable && !IsLocked && QueueItem?.IsActive != true; - public SourceRowViewModel(DownloadViewModel parent, string name, string status) + /// + /// Overrides the source-meta table. A source declared by a pack is not in it and never will be — + /// its label travels with the declaration. + /// + public SourceRowViewModel(DownloadViewModel parent, string name, string status, string? displayName = null) { _parent = parent; Name = name; Status = status; var meta = SourceMeta.Get(name); - DisplayName = meta.DisplayName ?? name; + DisplayName = displayName ?? meta.DisplayName ?? name; DiscordUrl = meta.DiscordUrl; NeedsKey = meta.RequiresUserKey; } @@ -89,6 +93,8 @@ public partial class DownloadViewModel : ObservableObject private readonly HardwareAppIdService _hardware; private readonly DownloadQueue _queue; private readonly ManifestJobFactory _jobs; + private readonly Services.Sources.SourcePackRegistry _packs; + private readonly Services.Sources.PackSourceService _packSources; private CancellationTokenSource? _searchCts; private CancellationTokenSource? _detailsCts; @@ -324,8 +330,11 @@ public DownloadViewModel(LuaToolsApiClient api, HubcapService hubcap, SettingsSe AuthService auth, ToastService toast, LuaInstaller installer, SteamAppListCache appList, SteamAppInfoCache appInfo, SteamDepotInfo depotInfo, HardwareAppIdService hardware, DropInstallViewModel drop, - DownloadQueue queue, ManifestJobFactory jobs) + DownloadQueue queue, ManifestJobFactory jobs, + Services.Sources.SourcePackRegistry packs, Services.Sources.PackSourceService packSources) { + _packs = packs; + _packSources = packSources; _api = api; _hubcap = hubcap; _settings = settings; @@ -543,6 +552,8 @@ private async Task FetchAsync() foreach (var (name, status) in statuses.OrderByDescending(kv => SourceMeta.Get(kv.Key).RequiresUserKey ? 1 : 0)) Sources.Add(new SourceRowViewModel(this, name, status)); + await AddPackSourcesAsync(Details.AppId); + await ApplyHubcapStateAsync(); if (FastFetch) @@ -667,8 +678,14 @@ public async Task RefreshStandardUsageAsync() // Hubcap downloads use the user's OWN key and never touch lua.tools, so a guest with a key // configured can download without signing in. Every other source still needs a lua.tools account. + // A pack source is fetched straight from the url its file names and never touches lua.tools, so + // a lua.tools account has no bearing on it. Resolved before the gate for that reason. + var packSource = _packs.Sources.FirstOrDefault(x => + string.Equals(x.Name, source.Name, StringComparison.OrdinalIgnoreCase)); + bool hubcapWithKey = source.NeedsKey && !string.IsNullOrEmpty(_settings.HubcapApiKey); - if (!hubcapWithKey && await PromptSignInIfGuestAsync(Resources.Strings.Add_SignIn_Download)) return null; + if (!hubcapWithKey && packSource is null + && await PromptSignInIfGuestAsync(Resources.Strings.Add_SignIn_Download)) return null; Error = null; LastDownload = null; @@ -680,12 +697,20 @@ public async Task RefreshStandardUsageAsync() string gameName = Details.Name; bool needsKey = source.NeedsKey; - var job = _jobs.CreateManifestJob( - appId, gameName, source.Name, needsKey, - // Silent/headless installs have no surfaced window to confirm on, so they skip the gate. - confirm: _silentInstall ? null : (file, _, ct) => ConfirmOverwriteAsync(file, appId, gameName, ct), - onFinished: (item, result) => OnManifestFinished(item, result, needsKey), - onReveal: () => NavigateToGame?.Invoke(appId)); + // Silent/headless installs have no surfaced window to confirm on, so they skip the gate. + Func>? confirm = + _silentInstall ? null : (file, _, ct) => ConfirmOverwriteAsync(file, appId, gameName, ct); + + var job = packSource is not null + ? _jobs.CreatePackSourceJob(packSource, appId, gameName, + confirm: confirm, + onFinished: (item, result) => OnManifestFinished(item, result, needsKey: false), + onReveal: () => NavigateToGame?.Invoke(appId)) + : _jobs.CreateManifestJob( + appId, gameName, source.Name, needsKey, + confirm: confirm, + onFinished: (item, result) => OnManifestFinished(item, result, needsKey), + onReveal: () => NavigateToGame?.Invoke(appId)); var queued = _queue.Enqueue(job); source.QueueItem = queued; @@ -693,6 +718,43 @@ public async Task RefreshStandardUsageAsync() return queued; } + /// + /// Append a row for every pack-declared source that has this game. + /// + /// + /// Appended after the app's own sources rather than ranked among them: their order is a decision + /// this app makes, and a file the user dropped in a folder should not be able to overturn it. + /// Probed in parallel, because the count is whatever the user installed and doing them in sequence + /// would put a pack's latency on the critical path of every fetch. A probe that fails means "this + /// source doesn't have it", never an error. + /// + private async Task AddPackSourcesAsync(long appId) + { + _packs.Reload(SourceMeta.All.Keys.ToList()); + + var sources = _packs.Sources; + if (sources.Count == 0) return; + + var probes = sources.Select(src => (Source: src, Has: SafeHasAsync(src, appId))).ToList(); + await Task.WhenAll(probes.Select(p => p.Has)); + + foreach (var (src, has) in probes) + { + if (!has.Result) continue; + Sources.Add(new SourceRowViewModel(this, src.Name, "available", src.DisplayName) + { + StatsText = src.Badge, + }); + } + } + + /// A HasGameAsync that never throws: a failed or offline lookup just means "not covered". + private async Task SafeHasAsync(Services.Sources.PackSource src, long appId) + { + try { return await _packSources.HasGameAsync(src, appId); } + catch { return false; } + } + /// DLC lua: download and install silently (it's just an unlock, no confirm). [RelayCommand] private async Task GenerateDlcAsync() diff --git a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs index cdc5d30..ed68db5 100644 --- a/src/LuaToolsGui/ViewModels/SettingsViewModel.cs +++ b/src/LuaToolsGui/ViewModels/SettingsViewModel.cs @@ -18,6 +18,7 @@ public partial class SettingsViewModel : ObservableObject private readonly AuthService _auth; private readonly SteamService _steam; private readonly HubcapService _hubcap; + private readonly Services.Sources.SourcePackRegistry _packs; [ObservableProperty] private string? _displayName; [ObservableProperty] private string? _email; @@ -222,8 +223,9 @@ partial void OnSelectedLanguageChanged(LanguageOption value) public Action? RequestRestartPrompt { get; set; } public SettingsViewModel(SettingsService settings, AuthService auth, SteamService steam, - HubcapService hubcap) + HubcapService hubcap, Services.Sources.SourcePackRegistry packs) { + _packs = packs; _settings = settings; _auth = auth; _steam = steam; @@ -366,6 +368,10 @@ public void OnViewLoaded() // that only read the setting at construction). No-op if unchanged; a real change writes back the // same value, so no feedback loop. FastFetch = _settings.FastFetch; + + // Re-read the source-pack folder every time the page is shown: dropping a file in and coming + // back here to check it was accepted is the natural gesture. Cheap - a handful of small files. + RefreshSourcePacks(); } /// Re-fetch usage stats for the saved key. Silent no-op if no key is saved. @@ -451,4 +457,41 @@ private static string FormatHubcapStats(HubcapStats stats) expiry.ToString("yyyy-MM-dd")); return usage; } + // ── Manifest source packs ──────────────────────────────────────── + + /// One line per .json file found: what it contributed, or why it was refused. + public System.Collections.ObjectModel.ObservableCollection SourcePacks { get; } = []; + + public bool HasSourcePacks => SourcePacks.Count > 0; + + /// + /// Re-read the source-pack folder. Called when the page is shown, so dropping a file in and coming + /// back here is enough to see whether it was accepted - no restart, since a pack is only ever data. + /// + public void RefreshSourcePacks() + { + _packs.Reload(Models.SourceMeta.All.Keys.ToList()); + + SourcePacks.Clear(); + foreach (var pack in _packs.Packs) + { + string detail = pack.Error is not null + ? pack.Error + : string.Format(Resources.Strings.Settings_SourcePacks_Count, pack.Sources.Count); + SourcePacks.Add($"{pack.DisplayName} — {detail}"); + } + OnPropertyChanged(nameof(HasSourcePacks)); + } + + [RelayCommand] + private void OpenSourcesFolder() + { + try + { + System.IO.Directory.CreateDirectory(Services.Sources.SourcePackRegistry.Root); + System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo( + Services.Sources.SourcePackRegistry.Root) { UseShellExecute = true }); + } + catch { /* opening a folder is never worth an error dialog */ } + } } diff --git a/src/LuaToolsGui/Views/SettingsView.xaml b/src/LuaToolsGui/Views/SettingsView.xaml index 2449d4d..58aba2e 100644 --- a/src/LuaToolsGui/Views/SettingsView.xaml +++ b/src/LuaToolsGui/Views/SettingsView.xaml @@ -551,6 +551,45 @@ IsChecked="{Binding DonateKeys, Mode=TwoWay}" /> + + + + + + + + + + + + + + + + + + + + +