Note de conception (écrite avant le code) : un mod peut ajouter un miroir à la source osu! ou une source de téléchargement entière de maps VSRG, sans jamais toucher au réseau lui-même. Ce document liste les choix à valider ; l'API finale est décrite dans Téléchargements : downloads.register et la page Télécharger dans telechargement.md.
Principe
Un script ne déclare que des données : des modèles d'URL, des chemins JSON, des
hôtes autorisés, une cadence. Tout le HTTP est fait par crates/downloader (le même
client ureq/rustls, les mêmes bornes que les sources intégrées) ; rien ne passe par
le thread mods en dehors de la déclaration, validée en Rust à l'enregistrement
(setup seulement, comme gameplay.register). Il n'y a pas de fonction de recherche
écrite en script : elle exigerait du réseau depuis les scripts, ce qui est refusé.
defineMod({ id: "exemple.miroir", /* … */ permissions: ["network"], setup() {
downloads.register({
id: "mirror", name: "Exemple", kind: "mirror", target: "osu",
hosts: ["mirror.example.org"],
download: { url: "https://mirror.example.org/d/{id}" },
});
}});
Deux genres, une seule fonction :
kind: "mirror",target: "osu": un miroir de plus pour les beatmapsets osu! (fichiers par{id}, liste facultative). Il rejoint les miroirs intégrés dans l'ordre des réglages ; un jeu de réponses « beatmapset osu! v2 » est lu tel quel (response.format: "osu").kind: "source": un onglet de plus dans Télécharger, avec recherche, liste de résultats (sets de difficultés) et téléchargement d'un set.
Ce qu'une déclaration contient
| Champ | Rôle |
|---|---|
id, name, description?, site? |
identité ; clé publiée <modId>/<id> |
hosts |
liste blanche d'hôtes (1 à 8 noms DNS exacts, minuscules, sans schéma, port, chemin, joker ni adresse IP) |
rateLimit? |
requêtes par minute (1 à 120, défaut 30), appliquées par l'hôte |
auth? |
{ kind: "none" } ou { kind: "token", header, scheme } : le jeton est saisi par le joueur |
search? |
url (https, sans variable), params, sorts, statuses, paging, response |
download |
url (modèle avec {id}) ou urlField (chemin JSON de l'URL dans le résultat) |
Modèles d'URL : variables typées ({query} texte, {status}/{sort} via les tables
déclarées, {offset}, {page}, {limit}, {cursor}, {keysMin}, {keysMax},
{starsMin}, {starsMax}, {bpmMin}, {bpmMax}, {lengthMin}, {lengthMax}, {genre},
{language}, et {id} pour le fichier). Le texte est toujours encodé en pourcentage,
les nombres sont formatés par l'hôte ; il n'existe aucune autre variable. Un paramètre
dont une variable n'a pas de valeur est omis ; un groupe optionnel [ …] n'est écrit que
si toutes ses variables en ont une ({query}[ cs>={keysMin}]).
Correspondance des réponses : chemins JSON simples (a.b[0].c, 8 niveaux au plus,
sans filtre ni script) vers id, title, artist, creator, status, bpm,
playCount, favourites, cover, et vers les difficultés (name, keys, stars,
length, condition only: { path, equals }). Chaque valeur est typée et bornée par
Rust (chaînes tronquées, nombres finis, 1 à 18 touches) ; un résultat illisible est
ignoré, jamais inventé.
Sécurité
- Permission
network, déclarée dansdefineMod: sans elle,downloads.registeréchoue. Elle est montrée sur la page Mods avec les hôtes que le mod contactera, et le joueur peut la retirer mod par mod (réglagenetworkDenied, commestageDenied) : les déclarations du mod disparaissent aussitôt. - HTTPS seulement, hôtes déclarés seulement : chaque URL (modèle, URL lue dans une réponse, couverture) est vérifiée contre la liste blanche avant d'être envoyée.
- Redirections : suivies à la main, 3 au plus, chaque saut revérifié (https + liste blanche) ; un saut vers un autre hôte est refusé ; l'en-tête du jeton est retiré dès que l'hôte change.
- Adresses privées refusées : les noms qui se résolvent vers le réseau local, la boucle locale ou une adresse locale de lien sont refusés à la résolution (un hôte déclaré ne sert pas à sonder le réseau du joueur).
- Jeton : gardé par l'hôte (DPAPI, un fichier par source dans
<data>/download-tokens/, entropie propre), jamais donné au script, jamais dans un événement, un message d'erreur ou un journal ; envoyé seulement dans l'en-tête déclaré, aux hôtes déclarés, pour cette source. Les couvertures n'en portent pas. - Bornes (identiques aux sources intégrées) : 8 Mio de JSON, 512 Mio par set,
extraction sûre (
extract.rs: chemins absolus,.., liens, taille recomptée), fichier reçu qui doit commencer parPK, délais de connexion et de réponse, 30 min par fichier. - Cadence : fenêtre glissante par source ; au-delà, la recherche répond « limité » avec le délai, un téléchargement attend (30 s au plus) puis échoue.
Interface
- Onglet par source de mod, après Osu!/Quaver/Etterna, rendu par le composant générique de la
page (les 8 thèmes partagent
Download.svelte) ; chaque résultat est la carte d'un set osu!-like. Sous la barre, un bandeau : « Source fournie par <mod> · contacte <hôtes> » et une icône pour désactiver la source. - Réglages → Bibliothèque → Téléchargements : liste ordonnée des miroirs (intégrés et de mods, avec le mod fournisseur et les hôtes), monter/descendre, interrupteur par miroir ou source de mod, saisie du jeton des sources qui en demandent un.
- Suit le cycle de vie des mods : désactiver, échouer ou désinstaller un mod retire ses miroirs et ses onglets (la liste de miroirs, les curseurs en cours et l'onglet actif sont repris proprement).
- Réglages ajoutés :
networkDenied(ids de mods),downloadMirrorOrder(clés),downloadDisabled(clés<modId>/<id>), tous trois propres à la machine, exclus de l'export de réglages.
Choix à valider
- Défaut autorisé, retirable : la permission
networkdéclarée est active tant que le joueur ne la retire pas (commestage) ; l'alternative serait « refusé tant qu'on ne l'accorde pas ». Aucune requête ne part avant une recherche ou un téléchargement. - Miroirs de mods en queue d'ordre : ils sont ajoutés après les miroirs intégrés et sont donc essayés en dernier en mode automatique, jusqu'à ce que le joueur les remonte.
- Identifiants : un résultat de source de mod porte un id texte ou nombre ; l'interface
et la file utilisent un identifiant de session numérique dérivé de (source, id), l'id réel
est gardé côté Rust et enregistré (
installed_pack.source = "mod:<modId>/<id>"). Un miroir, lui, exige unidnumérique (celui du beatmapset osu!). - Pas de packs : une source de mod liste des sets (une archive zip = un dossier), pas de packs à la Etterna avec contenu déplié, pas de dossier racine conservé.
- Jetons : un seul jeton par source (en-tête déclaré parmi
Authorization,X-API-Key,X-Auth-Token), pas de connexion OAuth ni de cookie. - Ce qui n'est pas descriptible et reste refusé : recherche qui exige plusieurs requêtes
chaînées ou une signature, pages HTML à lire, XML, POST, cookies, OAuth/connexion,
URL de fichier calculée. Pour ces cas il faut ajouter la source dans
crates/downloader. - Exemple :
examples/mods/download-sources(non livré, comme les autres exemples) ; son test lit un enregistrement figé, jamais un site réel. - Jeton de 8 à 4096 caractères visibles pour toutes les sources à jeton (le minimum de Quaver passe de 16 à 8 : certaines clés d'API sont courtes).
- Un miroir forcé qui a disparu (mod désactivé ou désinstallé, miroir coupé) retombe sur l'ordre automatique au lieu d'échouer ; un identifiant que personne n'a jamais eu reste une erreur.
- Réglages exclus de l'export :
networkDenied,downloadMirrorOrder,downloadDisabled(ils nomment ce qui est installé ici).
Addendum : ponts d'API écrits par le mod (downloads.registerBridge)
Demande : « le mod s'occupe de faire le bridge pour une API particulière et de ce qu'il faut afficher ». Les sources déclaratives restent telles quelles (JSON, GET, une requête par page). Pour une API qui ne rentre pas dans ce cadre (POST avec formulaire, réponse XML ou HTML, enchaînement recherche → détail → téléchargement), un mod enregistre un pont : des fonctions pures, jamais d'entrée-sortie. Le principe « aucun réseau pour les scripts » reste vrai : c'est l'hôte qui fait chaque requête, le script ne fait que décrire la suivante.
downloads.registerBridge({
id, name, hosts, auth?, rateLimit?,
search(query, page, state) -> Step, // première page : page = 1
onResponse(response, state) -> Step, // appelé avec la réponse de la requête précédente
action?(result, actionId, state) -> Step, // clic sur un bouton d'un résultat
})
// Step = { request, state? } | { results, nextPage?, state? } | { download, state? } | { error }
request:{ url, method: GET|POST, headers?, body?: string | {form} | {json}, expect }. L'hôte vérifie https, hôte dans la liste déclarée, en-têtes (liste blanche de noms ; niHostniCookie), corps ≤ 64 Kio, puis fait la requête nativement (redirections déjà bornées et revérifiées), et rappelleonResponse({ status, headers, text }, state). Texte ≤ 2 Mio ; HTML et XML arrivent comme texte (le script les lit avecJSON.parse, des chaînes ou des regex).- Au plus 6 requêtes par recherche ou par action, 60 s au total, un pas de plus est une erreur claire.
results: cartes typées (id,title,artist,creator?,coverUrl?,tags?,size?,keyCount?,details?,actions), bornées et validées en Rust, rendues par le composant générique (les 8 thèmes).coverUrldoit être sur un hôte déclaré et passe par la route de couvertures de l'hôte.download:{ url, method?, headers?, body?, filename?, format: zip|osz|qp }; l'hôte télécharge (plafond de taille), extrait en sécurité (zip-slip) et exige au moins un fichier de chart lisible par ROX ; installé comme les autres sources.- Jeton : stocké par l'hôte (même
TokenStore), injecté dans l'en-tête déclaré ou à la place de{token}dans l'URL, le corps ou un en-tête ; le script ne le voit jamais (l'hôte efface aussi le jeton d'une réponse qui le répéterait). Pas de cookies, pas d'OAuth. - Les fonctions tournent sur le thread
modssous le budget d'exécution. Une exception, une sortie invalide ou un dépassement de budget désactive le pont (message dans le journal du mod, source retirée), les autres sources continuent.
Choix à valider :
11. Un pont n'est qu'une source (onglet), pas un miroir ; pas de filtres/tris pour un pont (le mod gère sa recherche).
12. Réponse trop grosse : erreur (pas de troncature silencieuse, un JSON coupé serait faux).
13. Statuts 4xx/5xx (sauf 401/403 avec jeton, 429) sont remis au script (status) : une API peut y répondre quelque chose.
14. xml/html : pas de parseXml natif (pas de DOM, pas de parseur ajouté) ; JSON.parse suffit pour le JSON.
15. Un clic sur une action relance le script (action) au moment du téléchargement, sur un thread de téléchargement.
16. Formats acceptés : zip, osz, qp (conteneurs zip) ; rar/7z et fichiers isolés restent refusés.
17. Un pont est désactivé (sa source disparaît, le journal du mod le dit) quand une de ses fonctions lève une exception, renvoie
ce que JSON ne sait pas écrire ou dépasse le budget d'exécution ; pas quand une étape renvoyée est invalide (champ inconnu,
hôte non déclaré, en-tête interdit) : c'est une erreur montrée au joueur, car la déclaration est en cause, pas l'exécution.
18. Le jeton est remplacé par [token] dans toute réponse remise au script (un site qui le renvoie ne le divulgue pas) ; {token}
n'est accepté dans l'URL, les en-têtes et le corps que si auth est déclaré ; jamais dans le nom d'hôte.
19. Une coverUrl hors des hôtes déclarés est ignorée sans erreur (le résultat reste) ; filename d'une étape download est
informatif (le dossier est nommé par le jeu).
20. Le rendu des cartes est générique (étiquettes, taille, détails, boutons) : le mod choisit le contenu, jamais le balisage.