Documentation
Parcourir la documentation

DocumentationÉcrire un widget

Services et permissions

Un widget PlayerVox OverCrow n’a aucun accès par lui-même : ni réseau, ni fichier, ni presse-papiers, ni donnée sur le jeu. Tout ce qui sort de son propre état vient d’un service d’OverCrow, que la logique appelle par @overcrow/sdk. Un service ne répond que si le widget a déclaré la permission correspondante dans son manifeste et que l’utilisateur l’a accordée.

TSwidgets/session/logic.ts
// Without the grant the host would refuse the subscription: show the
// unknown duration instead of asking.
if (hasGrant("session.read")) {
  session.subscribe((update) => {
    state.session = anchor(update);
  });
}

C’est tout l’usage d’un service dans le widget Session : il déclare la capability session.read, vérifie que l’utilisateur l’a accordée, puis s’abonne.

Déclarer, puis demander à l’utilisateur

Il existe deux sortes d’autorité, toutes deux déclarées dans permissions du manifeste :

  • quatre permissions pour les services généraux : network, storage, clipboardWrite et gameEvents ;
  • des capabilities, listées sous permissions.capabilities, pour les données et les actions de PlayerVox OverCrow lui-même et des comptes de l’utilisateur : la fréquence d’images, le lecteur multimédia, les notes de l’utilisateur, la note qu’il donne à un jeu sur PlayerVox, le chat Twitch…
JSONdocs/content/examples/weather/manifest.json
  "permissions": {
    "network": [
      {
        "origin": "https://api.example.com",
        "method": "GET",
        "path": "/v1/forecast/{city}",
        "pathParams": { "city": { "type": "slug", "maxLength": 32 } },
        "queryParams": {
          "units": { "type": "enum", "values": ["metric", "imperial"], "required": true }
        }
      }
    ],
    "storage": true
  },

Une déclaration n’accorde rien à elle seule. Le Centre de contrôle montre à l’utilisateur ce que le widget demande ; celui-ci peut en décocher un élément, ou le retirer plus tard, ce qui redémarre le widget sans lui. Les widgets marqués built-in dans le catalogue signé (widgets PlayerVox uniquement) démarrent avec leurs permissions déclarées accordées. Déclarez le strict nécessaire : les relecteurs lisent chaque permission, et les utilisateurs les voient avant de donner leur accord.

hasGrant(capability) indique si une capability a été accordée. Vérifiez-le avant de vous abonner, et affichez un état utile quand elle manque : même après un refus, le widget doit rester présentable.

TSwidgets/fps/logic.ts
if (hasGrant("fps.read")) {
  fps.subscribe((update) => {
    Object.assign(state, reading(update));
  });
} else {
  state.status = "unavailable";
}

Appels et abonnements

Il existe deux sortes de services.

Un appel pose sa question une fois et renvoie une promesse :

TSwidgets/warframe-market/logic.ts
async function readStored(key: string): Promise<JsonValue | undefined> {
  try {
    return await storage.get({ key });
  } catch {
    state.error = "storage_unavailable";
    return undefined;
  }
}

Un abonnement prend un écouteur. PlayerVox OverCrow l’appelle aussitôt avec la valeur courante, puis à chaque nouvelle valeur, jusqu’à ce que vous l’annuliez avec cancel() ou qu’il se termine par un échec. L’écouteur vient en dernier ; un service qui a des paramètres les prend en premier.

TSwidgets/media/logic.ts
  const current = media.subscribe({ cover: state.showCover }, (update) => {
    if (update.ok) {
      state.media = update.value;
      state.unavailable = false;
      return;
    }
    // The subscription ended: the host's source failed. Show it and ask
    // again later; hidden, the timer waits until the widget shows.
    if (subscription === current) {
      subscription = null;
      state.media = null;
      state.unavailable = true;
      retry = timers.after(RETRY_MS, subscribe);
    }
  });

Chaque mise à jour vaut { ok: true, value, final } ou { ok: false, error, final: true }. Une mise à jour en échec est toujours la dernière : l’abonnement est terminé.

Les services sont regroupés dans des espaces de noms du SDK : storage, http, clipboard, gameEvents, et un par source de données (fps, telemetry, session, media, stopwatch, notes, playervox, journal, twitch). Chaque service figure, avec ses paramètres et son résultat, dans la référence des services.

PlayerVox OverCrow vérifie chaque appel à son arrivée, dans cet ordre : la permission ou la capability, les paramètres, l’action de l’utilisateur quand le service en demande une, puis le compte quand il en faut un. Les types du SDK vous aident à appeler correctement un service ; ce ne sont pas eux qui font respecter les règles.

Erreurs

Un appel qui échoue rejette sa promesse avec une ServiceError, dont le code dit pourquoi. Un abonnement qui échoue livre la même erreur dans sa dernière mise à jour.

TSdocs/content/examples/weather/logic.ts
  try {
    const url = `https://api.example.com/v1/forecast/${state.city}?units=${state.units}`;
    const { status, body } = await http.fetch(url, { as: "json" });
    const value = (body as { temperature?: unknown } | null)?.temperature;
    if (status !== 200 || typeof value !== "number") {
      state.status = "unavailable";
      return;
    }
    state.temperature = value;
    state.status = "updated";
    await storage.set({ key, value });
  } catch (error) {
    // permission_denied until the user consents, transport_failed offline…
    state.status = error instanceof ServiceError ? "unavailable" : "failed";
  }
CodeSignification
invalid_requestLes paramètres ne sont pas ceux que le service attend : un membre manque ou est inconnu, n’a pas le bon type ou sort de ses limites.
permission_deniedLa permission ou la capability n’est pas déclarée dans le manifeste, ou l’utilisateur ne l’a pas accordée. Pour http.fetch, aucune règle réseau n’autorise cette URL et cette méthode. Une écriture par formulaire peut aussi le recevoir du fournisseur du compte, pour une écriture qu’il n’autorise pas.
gesture_requiredLe service ne peut être appelé que pendant une action de l’utilisateur, et cet appel n’a pas eu lieu pendant le traitement d’une telle action, ou cette action a déjà autorisé un autre appel.
not_connectedLe service a besoin du compte PlayerVox ou Twitch de l’utilisateur, qui n’est pas connecté pour le moment.
stale_contextCe sur quoi portait l’appel a changé avant la réponse : un autre jeu, un autre compte, un autre lecteur multimédia, un objet supprimé entre-temps. Relisez l’état courant.
unavailableLa source de PlayerVox OverCrow pour ces données a échoué, ou ne peut pas agir pour l’instant. Un abonnement qui se termine ainsi peut être relancé plus tard.
busyTrop d’opérations sont en cours : une confirmation est déjà ouverte, une file ou le budget des réponses réseau est plein, ou le fournisseur demande d’attendre. Réessayez plus tard.
cancelledL’utilisateur a refusé la confirmation, ou a quitté le mode interactif pendant qu’elle était ouverte.
quota_exceededUn quota est atteint : le stockage, les minuteurs, les notes, les chaînes favorites.
unsupportedLa cible ne sait pas le faire : un lecteur multimédia qui ne propose pas la commande, par exemple.
url_invalidhttp.fetch : l’URL n’est pas une URL HTTPS absolue que PlayerVox OverCrow accepte.
resolve_failedhttp.fetch : le nom d’hôte n’a pas pu être résolu.
address_deniedhttp.fetch : le nom d’hôte se résout en une adresse locale, privée ou non publique.
too_many_addresseshttp.fetch : le nom d’hôte se résout en plus d’adresses que PlayerVox OverCrow n’en accepte.
peer_mismatchhttp.fetch : le serveur qui a répondu n’est pas à l’une des adresses résolues et vérifiées.
redirect_deniedhttp.fetch : le serveur a répondu par une redirection. PlayerVox OverCrow n’en suit aucune : déclarez et demandez l’URL finale.
encoding_deniedhttp.fetch : la réponse est compressée ou encodée d’une façon que PlayerVox OverCrow n’accepte pas, ou son corps ne se décode pas comme as le demande (JSON ou texte invalide).
content_type_deniedhttp.fetch : le type de média de la réponse n’est pas de ceux que PlayerVox OverCrow accepte pour cette requête.
response_metadata_limithttp.fetch : la ligne d’état ou les en-têtes de la réponse sont mal formés ou dépassent les limites de PlayerVox OverCrow.
request_body_limithttp.fetch : le corps de la requête dépasse 256 Kio.
response_body_limithttp.fetch : le corps de la réponse dépasse la limite de la règle réseau (maxResponseBytes, ou 1 Mio sans lui et pour as: "image").
image_invalidhttp.fetch avec as: "image" : la réponse n’est pas une image PNG, JPEG ou WebP dans les limites des images.
timeouthttp.fetch : l’échange a duré plus de 30 secondes.
transport_failedhttp.fetch : la connexion a échoué : pas de réseau, erreur TLS, connexion fermée par le serveur.

Trois habitudes couvrent la plupart des cas :

  • Affichez un état, pas un message d’erreur. Les codes servent à votre logique ; l’utilisateur voit « Prévisions indisponibles », pas transport_failed.
  • Réabonnez-vous quand un abonnement s’est terminé. Une source de PlayerVox OverCrow peut échouer pendant que le widget tourne (la session du lecteur multimédia, par exemple) : l’abonnement se termine avec unavailable. Réabonnez-vous quelques secondes plus tard avec timers.after ; OverCrow relance la source pour le nouvel abonnement. Le widget Média attend cinq secondes :
TSwidgets/media/logic.ts
    // The subscription ended: the host's source failed. Show it and ask
    // again later; hidden, the timer waits until the widget shows.
    if (subscription === current) {
      subscription = null;
      state.media = null;
      state.unavailable = true;
      retry = timers.after(RETRY_MS, subscribe);
    }
  • Ne réessayez pas en boucle. busy et quota_exceeded signalent qu’une limite est atteinte : attendez, ou faites-en moins.

Les appels qui demandent une action de l’utilisateur

Les services qui changent quelque chose pour l’utilisateur, ou qui déposent quelque chose là où d’autres programmes le lisent, s’appellent seulement pendant une action de l’utilisateur : pendant que la logique traite l’un de ces événements de la vue.

activate, contextmenu, keydown, input, change, submit.

TSwidgets/stopwatch/logic.ts
export function toggle(): void {
  command(() => stopwatch.toggle());
}

toggle est le gestionnaire on:activate du bouton du chronomètre : le clic est l’action de l’utilisateur, et stopwatch.toggle() est appelé pendant son traitement. Le même appel depuis un minuteur, depuis la réponse d’un autre service, depuis onHost ou depuis une ligne du menu d’options est refusé avec gesture_required.

  • Une action de l’utilisateur autorise un seul appel protégé. Appelez le service directement dans le gestionnaire, pas après un await sur autre chose.
  • Les widgets ne reçoivent d’entrées qu’en mode interactif de PlayerVox OverCrow : un appel protégé ne peut donc avoir lieu que dans ce mode.
  • notes.delete et journal.delete demandent en plus une confirmation à l’utilisateur, dans une boîte de dialogue dessinée par PlayerVox OverCrow ; une confirmation refusée donne cancelled.

Les tableaux ci-dessous indiquent, pour chaque service, quand l’appeler.

Permissions

PermissionSignificationAvec une capability sensible
networkRoutes HTTPS exactes, joignables par le broker de l’hôte ; au plus 32 règles.manifeste refusé
storageStockage clé-valeur tenu par l’hôte, cloisonné par ID de widget, dans la limite de 256 Kio.données conservées le temps du processus seulement
clipboardWriteÉcriture de texte dans le presse-papiers, pendant une action de l’utilisateur en mode interactif.manifeste refusé
gameEventsÉvénements de jeu sémantiques nommés overcrow.game.<name>.v1, au plus 32.admis
capabilitiesServices de l’hôte listés avec les capabilities.admis
PermissionServiceQuand l’appeler
networkhttp.fetchÀ tout moment
storagestorage.getÀ tout moment
storagestorage.setÀ tout moment
storagestorage.removeÀ tout moment
storagestorage.keysÀ tout moment
clipboardWriteclipboard.writeTextSeulement pendant une action de l’utilisateur
gameEventsgameEvents.subscribeÀ tout moment

« Seulement pendant une action de l’utilisateur » est expliqué dans Les appels qui demandent une action de l’utilisateur.

Règles réseau

network est une liste de règles. Une règle autorise les requêtes vers exactement une origine HTTPS, une méthode et un chemin ; un segment {name} du chemin est un paramètre typé, et seuls les paramètres de requête déclarés sont acceptés.

JSONwidgets/warframe-market/manifest.json
  "permissions": {
    "network": [
      {
        "origin": "https://api.warframe.market",
        "method": "GET",
        "path": "/v2/versions",
        "maxResponseBytes": 4096
      },
      {
        "origin": "https://api.warframe.market",
        "method": "GET",
        "path": "/v2/items",
        "maxResponseBytes": 3145728
      },
      {
        "origin": "https://api.warframe.market",
        "method": "GET",
        "path": "/v2/orders/item/{slug}",
        "pathParams": {
          "slug": {
            "type": "slug",
            "maxLength": 96
          }
        },
        "maxResponseBytes": 2097152
      }
    ],
    "storage": true,
    "clipboardWrite": true
  },
ChampTypeObligatoireSignification
originoriginouiOrigine canonique https://host[:port], hôte ≤ 253 octets avec des labels ≤ 63 octets ; ni identifiants, ni adresse IP littérale, ni nom local.
methodGET | POST | PUT | PATCH | DELETEouiUne seule méthode.
pathtexte ≤ 1 KioouiChemin complet ; les segments {name} sont des paramètres de chemin typés.
pathParamsname → ParameterConstraintnonContrainte de chaque segment {name}, au plus 8.
queryParamsname → ParameterConstraintnonParamètres de requête admis, au plus 16, chacun éventuellement required ; les autres sont refusés.
maxResponseBytesentier de 1 à 3145728nonPlus grand corps de réponse de cette route, de 1 à 3 Mio ; 1 Mio quand il est absent. Sans effet pour as: "image", qui garde 1 Mio. Quand plusieurs règles autorisent une requête, la plus grande limite s’applique.

Chaque paramètre de chemin ou de requête porte une contrainte :

TypeFormeAccepte
integer{ min, max }Entier décimal sans zéro initial, de 0 à 2^53 - 1.
slug{ maxLength }Lettres ASCII majuscules ou minuscules, chiffres, _ et -, au moins un caractère ; maxLength de 1 à 128 octets.
enumliste de literal segment ≤ 32L’une des valeurs listées, chacune ≤ 128 octets.
string{ maxLength }Paramètres de requête seulement ; tout texte sans caractère de contrôle ; maxLength de 1 à 256 octets.

La requête sort par le broker de PlayerVox OverCrow, jamais du processus du widget. Le broker :

  • refuse toute URL qu’aucune règle n’autorise (permission_denied), avant toute requête DNS ;
  • ne suit aucune redirection, et n’envoie ni cookie, ni identifiant, ni en-tête de proxy ;
  • refuse les adresses locales et privées : un widget ne peut donc pas joindre le routeur de l’utilisateur ni un autre programme de la machine ;
  • limite chaque échange : 256 Kio pour le corps d’une requête, 30 secondes en tout, et 1 Mio pour le corps d’une réponse, sauf si la règle déclare un maxResponseBytes plus grand, jusqu’à 3 Mio. Les relecteurs et les utilisateurs voient cette limite.

http.fetch(url, { as }) décode le corps comme vous le demandez : "json", "text", "bytes" (un ArrayBuffer) ou "image", qui donne, à la place des octets, un identifiant d’image à lier à un élément image. Un body de type chaîne est envoyé en text/plain, et toute autre valeur en JSON, sauf si contentType en décide autrement. Un statut HTTP d’erreur n’est pas un échec de l’appel : vérifiez status.

TSdocs/content/examples/weather/logic.ts
    const url = `https://api.example.com/v1/forecast/${state.city}?units=${state.units}`;
    const { status, body } = await http.fetch(url, { as: "json" });
    const value = (body as { temperature?: unknown } | null)?.temperature;
    if (status !== 200 || typeof value !== "number") {
      state.status = "unavailable";
      return;
    }

Un widget a au plus quatre requêtes en cours à la fois, et elles partagent un budget d’octets de réponse : une requête qui n’y tient pas échoue aussitôt avec busy.

Stockage

storage est un stockage clé-valeur que PlayerVox OverCrow tient pour votre seul widget : storage.get({ key }), storage.set({ key, value }), storage.remove({ key }) et storage.keys(). Une valeur est n’importe quelle valeur JSON. Il n’y a aucun accès aux fichiers.

TSdocs/content/examples/countdown/logic.ts
function setRounds(rounds: number): void {
  state.rounds = rounds;
  paint();
  // Storage needs the user's consent: without it the count lasts as long
  // as the widget runs.
  storage.set({ key: "rounds", value: rounds }).catch(() => {});
}

Le stockage contient au plus 512 clés et 256 Kio en tout, 64 Kio par valeur ; au-delà, storage.set échoue avec quota_exceeded et ne change rien. Une écriture reçoit sa réponse une fois qu’elle est sur le disque. Désinstaller le widget efface son stockage, et une session de développement (overcrow-widget dev) ne lit ni n’écrit jamais le stockage d’un widget installé.

Presse-papiers

clipboard.writeText({ text }) place du texte dans le presse-papiers, seulement pendant une action de l’utilisateur. Un widget ne peut pas lire le presse-papiers.

TSwidgets/warframe-market/logic.ts
  clipboard.writeText({ text: whisperLine(order, detail.name) }).then(
    () => {
      if (mine === selection) state.copy = { side, id, state: "copied" };
    },
    () => {
      if (mine === selection) state.copy = { side, id, state: "failed" };
    },
  );

Le texte que l’utilisateur sélectionne dans un élément text doté de l’attribut selectable est copié par PlayerVox OverCrow lui-même, sans aucune permission.

Événements de jeu

gameEvents liste les événements de jeu nommés que le widget veut recevoir, chacun de la forme overcrow.game.<name>.v1. gameEvents.subscribe(listener) livre ensuite { event, at } chaque fois que l’un d’eux se produit.

Capabilities

Une capability donne accès à des données ou à des actions que détient PlayerVox OverCrow : les ressources qu’utilise le jeu, le lecteur multimédia, les notes de l’utilisateur, ses comptes PlayerVox et Twitch.

CapabilitySensibleCompteSignification
telemetry.readnon—Mesures de CPU, de mémoire et de température du jeu actif.
fps.readnon—Fréquence d’images du jeu actif, quand l’hôte dispose d’une source.
media.readoui—Métadonnées du morceau en cours de lecture, et l’image de sa pochette.
media.controloui—Précédent, lecture/pause et suivant.
session.readnon—Durée de la session du jeu actif, sans l’identité du jeu.
stopwatch.readnon—État du chronomètre manuel de l’hôte, unique, et de ses raccourcis.
stopwatch.controlnon—Démarrer, mettre en pause et réinitialiser le chronomètre manuel de l’hôte, unique.
notes.readoui—Les notes et listes de tâches de l’utilisateur : un seul document, commun à tous les jeux.
notes.writeoui—Créer, sélectionner, enregistrer, cocher et supprimer des notes ; le texte ne passe que par un formulaire lié à un intent d’écriture.
playervox.score.readoui—Note publique PlayerVox du jeu actif ; sensible parce qu’elle révèle le jeu.
playervox.rating.readouiPlayerVoxLa note que l’utilisateur a lui-même donnée au jeu actif.
playervox.rating.writeouiPlayerVoxPublier une note et un avis, par un formulaire lié à un intent d’écriture.
playervox.reviews.readouiPlayerVoxAvis des joueurs sur le jeu actif, par pages, limités sur demande aux joueurs suivis.
journal.readoui—Sessions de jeu du jeu actif : les sessions locales, plus celles du cloud tant que PlayerVox est connecté.
journal.deleteoui—Supprimer une session du journal, après une confirmation demandée par l’hôte ; les sessions du cloud exigent PlayerVox.
twitch.chat.readouiTwitchRejoindre, lire et quitter le chat d’une chaîne Twitch, et conserver des chaînes favorites.
twitch.chat.composeouiTwitchEnvoyer un message dans le chat, par un formulaire lié à un intent d’écriture ; l’hôte admet 20 messages par 30 s et répond busy au-delà.
CapabilitySensibleServiceQuand l’appeler
telemetry.readnontelemetry.subscribeÀ tout moment
fps.readnonfps.subscribeÀ tout moment
media.readouimedia.subscribeÀ tout moment
media.controlouimedia.previousSeulement pendant une action de l’utilisateur
media.controlouimedia.playPauseSeulement pendant une action de l’utilisateur
media.controlouimedia.nextSeulement pendant une action de l’utilisateur
session.readnonsession.subscribeÀ tout moment
stopwatch.readnonstopwatch.subscribeÀ tout moment
stopwatch.controlnonstopwatch.toggleSeulement pendant une action de l’utilisateur
stopwatch.controlnonstopwatch.resetSeulement pendant une action de l’utilisateur
notes.readouinotes.subscribeÀ tout moment
notes.writeouinotes.createSeulement pendant une action de l’utilisateur
notes.writeouinotes.selectSeulement pendant une action de l’utilisateur
notes.writeouinotes.setItemSeulement pendant une action de l’utilisateur
notes.writeouinotes.deleteSeulement pendant une action de l’utilisateur
notes.writeouiformulaire notes.saveQuand l’utilisateur valide le formulaire
playervox.score.readouiplayervox.score.subscribeÀ tout moment
playervox.rating.readouiplayervox.rating.subscribeÀ tout moment
playervox.rating.writeouiformulaire playervox.rating.publishQuand l’utilisateur valide le formulaire
playervox.reviews.readouiplayervox.reviews.subscribeÀ tout moment
playervox.reviews.readouiplayervox.reviews.pageÀ tout moment
journal.readouijournal.subscribeÀ tout moment
journal.readouijournal.pageÀ tout moment
journal.deleteouijournal.deleteSeulement pendant une action de l’utilisateur
twitch.chat.readouitwitch.chat.subscribeÀ tout moment
twitch.chat.readouitwitch.chat.joinSeulement pendant une action de l’utilisateur
twitch.chat.readouitwitch.chat.leaveSeulement pendant une action de l’utilisateur
twitch.chat.readouitwitch.chat.favoriteSeulement pendant une action de l’utilisateur
twitch.chat.composeouiformulaire twitch.chat.sendQuand l’utilisateur valide le formulaire

« Seulement pendant une action de l’utilisateur » est expliqué dans Les appels qui demandent une action de l’utilisateur.

Une ligne dont le service est un formulaire correspond à une écriture que l’utilisateur saisit lui-même : voir les formulaires qui écrivent des données de l’utilisateur.

Capabilities sensibles

Une capability sensible révèle des données personnelles ou le jeu en cours. Un widget qui en déclare une :

  • ne peut pas déclarer aussi network ni clipboardWrite : le manifeste est refusé ;
  • a un stockage qui ne dure que le temps de son processus, et qui est vidé quand le widget redémarre.

Ce qu’un tel widget lit ne peut donc pas quitter la machine par son intermédiaire. Si votre widget a besoin à la fois d’une API HTTP et d’une capability sensible, faites deux widgets.

Comptes

Certaines capabilities ont besoin du compte PlayerVox ou Twitch de l’utilisateur. La connexion appartient à PlayerVox OverCrow : c’est lui qui dessine l’écran de connexion, conserve les jetons et affiche son propre panneau par-dessus le widget tant que le compte est déconnecté. Un widget ne voit jamais un jeton ; il ne reçoit que les données que décrit sa capability, et ses appels échouent avec not_connected tant que le compte n’est pas connecté.

Des données qui changent

  • Un abonnement peut livrer null (aucun jeu actif, aucun lecteur multimédia) : affichez un état vide.
  • Certains abonnements indiquent quoi relire plutôt que les données elles-mêmes. playervox.reviews.subscribe et journal.subscribe livrent une revision : quand elle change, relisez votre page avec playervox.reviews.page ou journal.page.
  • twitch.chat.subscribe livre des changements plutôt que le chat entier, au plus toutes les 100 ms : les nouveaux messages, les messages modifiés (même id), les IDs des messages supprimés, et la liste entière quand reset vaut true.
  • Un horodatage fourni avec son propre offsetMinutes doit être formaté avec celui-ci ; voir heure, dates et nombres.

La forme de chaque résultat figure dans les formes de résultat.