From 0d6898a548df7f19d9a368b3ee34915f38c25826 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 f719494..f25a13c 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 51974f0..ef14d73 100644 --- a/src/LuaToolsGui/Resources/Strings.Designer.cs +++ b/src/LuaToolsGui/Resources/Strings.Designer.cs @@ -599,4 +599,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 093b4f9..9ce13e1 100644 --- a/src/LuaToolsGui/Resources/Strings.ar.resx +++ b/src/LuaToolsGui/Resources/Strings.ar.resx @@ -645,4 +645,10 @@ تغيّر {0} منذ تطبيق هذا الإصلاح، لذا فإن استعادته ستستبدل تلك التغييرات. إذا طبّقت إصلاحًا آخر فوقه، فتراجع عنه أولاً. الملفات المتأثرة: {1}. تم التراجع عن الإصلاح جزئيًا تعذّرت استعادة {0} من الملفات. أغلق اللعبة وحاول مرة أخرى. + هذا المصدر لا يحتوي على هذه اللعبة، أو تعذّر الوصول إليه. + مصادر المانيفست + مصادر إضافية معرَّفة في ملفات ‎.json. بيانات فقط: الملف يحدّد من أين تُجلب المانيفستات، ولا يُنفَّذ منه أي شيء. + فتح المجلد + لا توجد ملفات مصادر بعد. + المصادر: {0} diff --git a/src/LuaToolsGui/Resources/Strings.bg.resx b/src/LuaToolsGui/Resources/Strings.bg.resx index 8f59b22..f6ced90 100644 --- a/src/LuaToolsGui/Resources/Strings.bg.resx +++ b/src/LuaToolsGui/Resources/Strings.bg.resx @@ -619,4 +619,10 @@ {0} е променен, откакто беше приложена тази поправка, така че възстановяването ще презапише тези промени. Ако сте приложили друга поправка отгоре, първо върнете нея. Засегнати са {1} файла. Корекцията е върната частично {0} файл(а) не можаха да бъдат възстановени. Затворете играта и опитайте отново. + Този източник няма тази игра или е недостъпен. + Източници на манифести + Допълнителни източници, описани във файлове .json. Само данни: файлът казва откъде да се вземат манифести и нищо в него не се изпълнява. + Отваряне на папката + Все още няма файлове с източници. + източници: {0} diff --git a/src/LuaToolsGui/Resources/Strings.cs.resx b/src/LuaToolsGui/Resources/Strings.cs.resx index 0647ca8..bee5ea7 100644 --- a/src/LuaToolsGui/Resources/Strings.cs.resx +++ b/src/LuaToolsGui/Resources/Strings.cs.resx @@ -619,4 +619,10 @@ Zrušit - pokračovat ve stahování Soubor {0} se od použití této opravy změnil, takže obnovení by tyto změny přepsalo. Pokud jste navrch použili jinou opravu, vraťte nejprve ji. Dotčeno {1} souborů. Oprava vrácena zpět částečně {0} soubor(ů) se nepodařilo obnovit. Zavřete hru a zkuste to znovu. + 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 35c7454..a037d6b 100644 --- a/src/LuaToolsGui/Resources/Strings.da.resx +++ b/src/LuaToolsGui/Resources/Strings.da.resx @@ -619,4 +619,10 @@ Annuller - fortsæt download {0} er ændret, siden denne rettelse blev anvendt, så en gendannelse ville overskrive de ændringer. Hvis du har anvendt en anden rettelse ovenpå, så fortryd den først. {1} fil(er) berørt. Rettelse delvist fortrudt {0} fil(er) kunne ikke gendannes. Luk spillet og prøv igen. + 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 738ebdb..1069875 100644 --- a/src/LuaToolsGui/Resources/Strings.de.resx +++ b/src/LuaToolsGui/Resources/Strings.de.resx @@ -619,4 +619,10 @@ Abbrechen - weiter herunterladen {0} wurde seit dem Anwenden dieses Fixes geändert; eine Wiederherstellung würde diese Änderungen überschreiben. Falls du einen weiteren Fix darüber angewendet hast, setze zuerst diesen zurück. {1} Datei(en) betroffen. Fix teilweise rückgängig gemacht {0} Datei(en) konnten nicht wiederhergestellt werden. Schließe das Spiel und versuche es erneut. + 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 af801f6..11d1d09 100644 --- a/src/LuaToolsGui/Resources/Strings.el.resx +++ b/src/LuaToolsGui/Resources/Strings.el.resx @@ -619,4 +619,10 @@ Το {0} έχει αλλάξει από τότε που εφαρμόστηκε αυτή η διόρθωση, οπότε η επαναφορά θα αντικαταστήσει αυτές τις αλλαγές. Αν εφαρμόσατε άλλη διόρθωση από πάνω, επαναφέρετε πρώτα εκείνη. Επηρεάζονται {1} αρχεία. Η διόρθωση αναιρέθηκε μερικώς Δεν ήταν δυνατή η επαναφορά {0} αρχείων. Κλείστε το παιχνίδι και δοκιμάστε ξανά. + Αυτή η πηγή δεν έχει αυτό το παιχνίδι, ή δεν ήταν προσβάσιμη. + Πηγές manifest + Επιπλέον πηγές δηλωμένες σε αρχεία .json. Μόνο δεδομένα: το αρχείο λέει από πού λαμβάνονται τα manifest, και τίποτα μέσα του δεν εκτελείται. + Άνοιγμα φακέλου + Δεν υπάρχουν ακόμη αρχεία πηγών. + πηγές: {0} diff --git a/src/LuaToolsGui/Resources/Strings.es-419.resx b/src/LuaToolsGui/Resources/Strings.es-419.resx index 658a93e..f5a2093 100644 --- a/src/LuaToolsGui/Resources/Strings.es-419.resx +++ b/src/LuaToolsGui/Resources/Strings.es-419.resx @@ -619,4 +619,10 @@ Cancelar: seguir descargando {0} cambió desde que se aplicó este fix, así que restaurarlo sobrescribiría esos cambios. Si aplicaste otro fix encima, revierte ese primero. {1} archivo(s) afectado(s). Parche revertido parcialmente No se pudieron restaurar {0} archivo(s). Cerrá el juego y probá de nuevo. + 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 74d5eeb..0046e23 100644 --- a/src/LuaToolsGui/Resources/Strings.es.resx +++ b/src/LuaToolsGui/Resources/Strings.es.resx @@ -619,4 +619,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 783691b..3d42563 100644 --- a/src/LuaToolsGui/Resources/Strings.fi.resx +++ b/src/LuaToolsGui/Resources/Strings.fi.resx @@ -619,4 +619,10 @@ Peruuta - jatka lataamista {0} on muuttunut tämän korjauksen käyttöönoton jälkeen, joten palauttaminen korvaisi nuo muutokset. Jos otit päälle toisen korjauksen, peru ensin se. {1} tiedosto(a) koskee. Korjaus peruttu osittain {0} tiedostoa ei voitu palauttaa. Sulje peli ja yritä uudelleen. + 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 0874247..26acd6a 100644 --- a/src/LuaToolsGui/Resources/Strings.fr.resx +++ b/src/LuaToolsGui/Resources/Strings.fr.resx @@ -619,4 +619,10 @@ Annuler - continuer le téléchargement {0} a changé depuis l'application de ce correctif ; le restaurer écraserait ces modifications. Si vous avez appliqué un autre correctif par-dessus, annulez-le d'abord. {1} fichier(s) concerné(s). Correctif partiellement annulé Impossible de restaurer {0} fichier(s). Fermez le jeu et réessayez. + 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 d7d6d6e..d667910 100644 --- a/src/LuaToolsGui/Resources/Strings.hu.resx +++ b/src/LuaToolsGui/Resources/Strings.hu.resx @@ -619,4 +619,10 @@ Mégse - letöltés folytatása A(z) {0} megváltozott a javítás alkalmazása óta, ezért a visszaállítás felülírná ezeket a módosításokat. Ha másik javítást is alkalmaztál rá, előbb azt vond vissza. {1} fájl érintett. A javítás részben lett visszavonva {0} fájlt nem sikerült visszaállítani. Zárd be a játékot, és próbáld újra. + 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 ce783f7..44a6851 100644 --- a/src/LuaToolsGui/Resources/Strings.id.resx +++ b/src/LuaToolsGui/Resources/Strings.id.resx @@ -619,4 +619,10 @@ Batal - lanjutkan mengunduh {0} telah berubah sejak fix ini diterapkan, jadi memulihkannya akan menimpa perubahan tersebut. Jika Anda menerapkan fix lain di atasnya, kembalikan yang itu dulu. {1} berkas terpengaruh. Perbaikan dikembalikan sebagian {0} berkas tidak dapat dipulihkan. Tutup game lalu coba lagi. + 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 2797ab2..301a0bb 100644 --- a/src/LuaToolsGui/Resources/Strings.it.resx +++ b/src/LuaToolsGui/Resources/Strings.it.resx @@ -619,4 +619,10 @@ Annulla - continua a scaricare {0} è cambiato da quando questo fix è stato applicato, quindi ripristinarlo sovrascriverebbe quelle modifiche. Se hai applicato un altro fix sopra, ripristina prima quello. {1} file interessati. Fix annullata parzialmente Impossibile ripristinare {0} file. Chiudi il gioco e riprova. + 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 d502532..86e02b1 100644 --- a/src/LuaToolsGui/Resources/Strings.ja.resx +++ b/src/LuaToolsGui/Resources/Strings.ja.resx @@ -619,4 +619,10 @@ Steam\config\stplug-in から .lua ファイルを削除します。この修正の適用後に {0} が変更されているため、復元するとその変更が上書きされます。別の修正を上に適用した場合は、先にそちらを元に戻してください。対象は {1} 個のファイルです。 修正は一部だけ元に戻りました {0} 個のファイルを復元できませんでした。ゲームを閉じてやり直してください。 + このソースにはこのゲームがないか、接続できませんでした。 + マニフェストのソース + ​.json ファイルで宣言する追加ソース。データのみで、ファイルはマニフェストの取得先を示すだけです。中身が実行されることはありません。 + フォルダーを開く + ソースファイルはまだありません。 + ソース: {0} diff --git a/src/LuaToolsGui/Resources/Strings.ko.resx b/src/LuaToolsGui/Resources/Strings.ko.resx index d3fce87..dbf7de6 100644 --- a/src/LuaToolsGui/Resources/Strings.ko.resx +++ b/src/LuaToolsGui/Resources/Strings.ko.resx @@ -619,4 +619,10 @@ Steam\config\stplug-in에서 .lua 파일을 삭제합니다. 이 픽스를 적용한 뒤 {0} 이(가) 변경되어, 복원하면 해당 변경 사항을 덮어씁니다. 다른 픽스를 위에 적용했다면 그것부터 되돌리세요. {1}개 파일이 영향을 받습니다. 수정이 일부만 되돌려졌습니다 {0}개 파일을 복원하지 못했습니다. 게임을 닫고 다시 시도하세요. + 이 소스에는 이 게임이 없거나 연결할 수 없습니다. + 매니페스트 소스 + ​.json 파일로 선언하는 추가 소스. 데이터일 뿐이며, 파일은 매니페스트를 어디서 가져올지 알려줄 뿐 그 안의 어떤 것도 실행되지 않습니다. + 폴더 열기 + 아직 소스 파일이 없습니다. + 소스: {0} diff --git a/src/LuaToolsGui/Resources/Strings.nb.resx b/src/LuaToolsGui/Resources/Strings.nb.resx index 2daa452..0c67875 100644 --- a/src/LuaToolsGui/Resources/Strings.nb.resx +++ b/src/LuaToolsGui/Resources/Strings.nb.resx @@ -619,4 +619,10 @@ Avbryt - fortsett nedlastingen {0} er endret siden denne fiksen ble brukt, så gjenoppretting ville overskrive de endringene. Hvis du har brukt en annen fiks oppå, tilbakestill den først. {1} fil(er) berørt. Rettelse delvis angret {0} fil(er) kunne ikke gjenopprettes. Lukk spillet og prøv igjen. + 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 eed0fbd..edeac06 100644 --- a/src/LuaToolsGui/Resources/Strings.nl.resx +++ b/src/LuaToolsGui/Resources/Strings.nl.resx @@ -619,4 +619,10 @@ Annuleren - doorgaan met downloaden {0} is gewijzigd sinds deze fix is toegepast, dus herstellen zou die wijzigingen overschrijven. Als je er een andere fix overheen hebt toegepast, draai die dan eerst terug. {1} bestand(en) getroffen. Fix gedeeltelijk teruggedraaid {0} bestand(en) konden niet worden hersteld. Sluit de game en probeer opnieuw. + 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 00b2879..d22f774 100644 --- a/src/LuaToolsGui/Resources/Strings.pl.resx +++ b/src/LuaToolsGui/Resources/Strings.pl.resx @@ -619,4 +619,10 @@ Anuluj - kontynuuj pobieranie Plik {0} zmienił się od zastosowania tej poprawki, więc przywrócenie nadpisałoby te zmiany. Jeśli zastosowano na wierzchu inną poprawkę, cofnij najpierw ją. Dotyczy {1} plik(ów). Poprawka cofnięta częściowo Nie udało się przywrócić {0} plików. Zamknij grę i spróbuj ponownie. + 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 08cde61..fc3b17d 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-BR.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-BR.resx @@ -619,4 +619,10 @@ Cancelar - continuar baixando {0} mudou desde que este fix foi aplicado, então restaurá-lo sobrescreveria essas mudanças. Se você aplicou outro fix por cima, reverta aquele primeiro. {1} arquivo(s) afetado(s). Correção revertida parcialmente Não foi possível restaurar {0} arquivo(s). Feche o jogo e tente novamente. + 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 768f61c..ba5b13e 100644 --- a/src/LuaToolsGui/Resources/Strings.pt-PT.resx +++ b/src/LuaToolsGui/Resources/Strings.pt-PT.resx @@ -619,4 +619,10 @@ Cancelar - continuar a transferir {0} mudou desde que este fix foi aplicado, pelo que restaurá-lo iria sobrescrever essas alterações. Se aplicou outro fix por cima, reverta esse primeiro. {1} ficheiro(s) afetado(s). Correção revertida parcialmente Não foi possível restaurar {0} ficheiro(s). Feche o jogo e tente novamente. + 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 fe4c107..54e4ffe 100644 --- a/src/LuaToolsGui/Resources/Strings.resx +++ b/src/LuaToolsGui/Resources/Strings.resx @@ -654,4 +654,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 6a8df52..686766f 100644 --- a/src/LuaToolsGui/Resources/Strings.ro.resx +++ b/src/LuaToolsGui/Resources/Strings.ro.resx @@ -619,4 +619,10 @@ Anulare - continuă descărcarea {0} s-a modificat de la aplicarea acestui fix, așa că restaurarea ar suprascrie acele modificări. Dacă ai aplicat alt fix peste, anulează-l mai întâi pe acela. {1} fișier(e) afectate. Reparație anulată parțial {0} fișier(e) nu au putut fi restaurate. Închide jocul și încearcă din nou. + 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 be68125..5876f15 100644 --- a/src/LuaToolsGui/Resources/Strings.ru.resx +++ b/src/LuaToolsGui/Resources/Strings.ru.resx @@ -619,4 +619,10 @@ Файл {0} изменился после применения этого фикса, и восстановление перезапишет эти изменения. Если поверх был применён другой фикс, сначала откатите его. Затронуто файлов: {1}. Исправление откачено частично Не удалось восстановить файлов: {0}. Закройте игру и повторите попытку. + В этом источнике нет этой игры, либо он недоступен. + Источники манифестов + Дополнительные источники, описанные в файлах .json. Только данные: файл указывает, откуда брать манифесты, и ничего из него не выполняется. + Открыть папку + Файлов источников пока нет. + источники: {0} diff --git a/src/LuaToolsGui/Resources/Strings.sv.resx b/src/LuaToolsGui/Resources/Strings.sv.resx index ef19712..d978e32 100644 --- a/src/LuaToolsGui/Resources/Strings.sv.resx +++ b/src/LuaToolsGui/Resources/Strings.sv.resx @@ -619,4 +619,10 @@ Avbryt - fortsätt hämta {0} har ändrats sedan den här fixen tillämpades, så en återställning skulle skriva över de ändringarna. Om du tillämpat en annan fix ovanpå, återställ den först. {1} fil(er) påverkas. Korrigering delvis ångrad {0} fil(er) kunde inte återställas. Stäng spelet och försök igen. + 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 72faf46..31a0b44 100644 --- a/src/LuaToolsGui/Resources/Strings.th.resx +++ b/src/LuaToolsGui/Resources/Strings.th.resx @@ -619,4 +619,10 @@ {0} มีการเปลี่ยนแปลงหลังจากใช้แพตช์นี้ การกู้คืนจะเขียนทับการเปลี่ยนแปลงนั้น หากคุณใช้แพตช์อื่นทับไว้ ให้ย้อนกลับแพตช์นั้นก่อน มีไฟล์ที่ได้รับผลกระทบ {1} ไฟล์ ย้อนกลับตัวแก้ไขได้บางส่วน กู้คืนไฟล์ {0} รายการไม่สำเร็จ กรุณาปิดเกมแล้วลองใหม่ + แหล่งนี้ไม่มีเกมนี้ หรือเข้าถึงไม่ได้ + แหล่งแมนิเฟสต์ + แหล่งเพิ่มเติมที่ประกาศไว้ในไฟล์ .json เป็นข้อมูลล้วน ๆ ไฟล์บอกเพียงว่าจะดึงแมนิเฟสต์จากที่ใด และไม่มีอะไรในไฟล์ถูกรัน + เปิดโฟลเดอร์ + ยังไม่มีไฟล์แหล่งข้อมูล + แหล่ง: {0} diff --git a/src/LuaToolsGui/Resources/Strings.tr.resx b/src/LuaToolsGui/Resources/Strings.tr.resx index 823f0c0..14ae03a 100644 --- a/src/LuaToolsGui/Resources/Strings.tr.resx +++ b/src/LuaToolsGui/Resources/Strings.tr.resx @@ -619,4 +619,10 @@ Hayır - durdur ama dosyaları koru Bu düzeltme uygulandıktan sonra {0} değişti; geri yüklemek bu değişikliklerin üzerine yazar. Üstüne başka bir düzeltme uyguladıysanız önce onu geri alın. {1} dosya etkilendi. Düzeltme kısmen geri alındı {0} dosya geri yüklenemedi. Oyunu kapatıp tekrar deneyin. + 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 9f3ef67..9d0bb96 100644 --- a/src/LuaToolsGui/Resources/Strings.uk.resx +++ b/src/LuaToolsGui/Resources/Strings.uk.resx @@ -619,4 +619,10 @@ Файл {0} змінився після застосування цього фікса, тож відновлення перезапише ці зміни. Якщо згори застосовано інший фікс, спершу скасуйте його. Порушено файлів: {1}. Виправлення відкочено частково Не вдалося відновити файлів: {0}. Закрийте гру та спробуйте ще раз. + У цьому джерелі немає цієї гри, або воно недоступне. + Джерела маніфестів + Додаткові джерела, описані у файлах .json. Лише дані: файл вказує, звідки брати маніфести, і нічого з нього не виконується. + Відкрити теку + Файлів джерел поки немає. + джерела: {0} diff --git a/src/LuaToolsGui/Resources/Strings.vi.resx b/src/LuaToolsGui/Resources/Strings.vi.resx index c1439fa..76bad24 100644 --- a/src/LuaToolsGui/Resources/Strings.vi.resx +++ b/src/LuaToolsGui/Resources/Strings.vi.resx @@ -619,4 +619,10 @@ Hủy - tiếp tục tải {0} đã thay đổi kể từ khi bản vá này được áp dụng, nên khôi phục sẽ ghi đè các thay đổi đó. Nếu bạn đã áp dụng bản vá khác lên trên, hãy hoàn tác bản đó trước. {1} tệp bị ảnh hưởng. Bản sửa lỗi được hoàn tác một phần Không thể khôi phục {0} tệp. Hãy đóng trò chơi và thử lại. + 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 abb3467..d7da784 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hans.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hans.resx @@ -639,4 +639,10 @@ 自应用此修复以来,{0} 已被更改,还原会覆盖这些更改。如果你在其上应用了另一个修复,请先还原那一个。共影响 {1} 个文件。 修复部分还原 有 {0} 个文件无法恢复。请关闭游戏后重试。 + 此来源没有这个游戏,或无法连接。 + 清单来源 + 在 .json 文件中声明的额外来源。纯数据:文件只说明从哪里获取清单,其中的任何内容都不会被执行。 + 打开文件夹 + 还没有来源文件。 + 来源:{0} diff --git a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx index 3116e50..db6bf91 100644 --- a/src/LuaToolsGui/Resources/Strings.zh-Hant.resx +++ b/src/LuaToolsGui/Resources/Strings.zh-Hant.resx @@ -619,4 +619,10 @@ 自套用此修復以來,{0} 已被變更,還原會覆寫這些變更。如果你在其上套用了另一個修復,請先還原那一個。共影響 {1} 個檔案。 修復部分還原 有 {0} 個檔案無法復原。請關閉遊戲後重試。 + 此來源沒有這個遊戲,或無法連線。 + 資訊清單來源 + 在 .json 檔案中宣告的額外來源。純資料:檔案只說明從何處取得資訊清單,其中的任何內容都不會被執行。 + 開啟資料夾 + 尚未有來源檔案。 + 來源:{0} diff --git a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs index 43d055d..6296ce2 100644 --- a/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs +++ b/src/LuaToolsGui/Services/Downloads/ManifestJobFactory.cs @@ -25,7 +25,8 @@ public class ManifestJobFactory( DepotDownloaderService depotTool, SteamDepotInfo depotInfo, SteamAutoCrackService sac, - AppliedFixIndexService fixIndex) + AppliedFixIndexService fixIndex, + Sources.PackSourceService packSources) { // ── Job builders ───────────────────────────────────────────────── @@ -53,6 +54,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}" /> + + + + + + + + + + + + + + + + + + + + +