Documentation
Browse documentation

DocumentationWrite a widget

Services and permissions

A PlayerVox OverCrow widget has no access of its own: no network, no file, no clipboard, no data about the game. Everything outside its own state comes from a service of OverCrow, which the logic calls through @overcrow/sdk. A service answers only when the widget declared the matching permission in its manifest and the user granted it.

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);
  });
}

That is the whole use of a service in the Session widget: it declares the session.read capability, checks that the user granted it, and subscribes.

Declare, then ask the user

Two kinds of authority exist, both declared in permissions of the manifest:

  • four permissions for general services: network, storage, clipboardWrite and gameEvents;
  • capabilities, listed under permissions.capabilities, for the data and actions of PlayerVox OverCrow itself and of the user's accounts: the frame rate, the media player, notes, PlayerVox ratings, Twitch chat…
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
  },

A declaration grants nothing by itself. The Control Center shows the user what the widget asks for; the user may uncheck an item, or take it back later, which restarts the widget without it. The widgets tagged built-in in the signed catalog (PlayerVox widgets only) start with their declared permissions granted. Declare the least you need: reviewers read every permission, and users see them before they consent.

hasGrant(capability) tells whether a capability was granted. Check it before you subscribe, and show a useful state when it is missing: a refused widget must still look right.

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

Calls and subscriptions

A service is one of two kinds.

A call asks once and returns a promise:

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;
  }
}

A subscription takes a listener. PlayerVox OverCrow calls it with the current value at once, then with each new value, until you cancel() it or it ends with a failure. The listener comes last; a service with parameters takes them first.

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);
    }
  });

Each update is { ok: true, value, final } or { ok: false, error, final: true }. A failed update is always the last one: the subscription is over.

Services are grouped in namespaces of the SDK: storage, http, clipboard, gameEvents, and one per data source (fps, telemetry, session, media, stopwatch, notes, playervox, journal, twitch). Every service, with its parameters and its result, is in the service reference.

PlayerVox OverCrow checks every call when it arrives, in this order: the permission or capability, the parameters, the user action when the service needs one, then the account when it needs one. The SDK's types help you call a service right; they are not what enforces the rules.

Errors

A failed call rejects with a ServiceError, whose code says why. A subscription that fails delivers the same error in its last update.

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";
  }
CodeMeaning
invalid_requestThe parameters are not what the service takes: a member is missing or unknown, has the wrong type or is out of bounds.
permission_deniedThe permission or capability is not declared in the manifest, or the user has not granted it. For http.fetch, no network rule allows this URL and method. A write through a form can also get it from the account's provider, for a write it does not allow.
gesture_requiredThe service can be called only during a user action, and this call was not made while one was handled, or that action already allowed another call.
not_connectedThe service needs the user's PlayerVox or Twitch account, which is not connected at the moment.
stale_contextWhat the call was about changed before the answer: another game, another account, another media player, an object deleted meanwhile. Read the current state again.
unavailablePlayerVox OverCrow's source for this data failed, or cannot act right now. A subscription that ends with it can be started again later.
busyToo much is in progress: a confirmation is already open, a queue or the budget of network responses is full, or the provider asks to wait. Try again later.
cancelledThe user declined the confirmation, or left Interactive mode while it was open.
quota_exceededA quota is full: the storage, the timers, the notes, the favourite channels.
unsupportedThe target cannot do this: a media player that does not offer the command, for instance.
url_invalidhttp.fetch: the URL is not an absolute HTTPS URL that PlayerVox OverCrow accepts.
resolve_failedhttp.fetch: the host name could not be resolved.
address_deniedhttp.fetch: the host name resolves to a local, private or otherwise non-public address.
too_many_addresseshttp.fetch: the host name resolves to more addresses than PlayerVox OverCrow accepts.
peer_mismatchhttp.fetch: the server that answered is not at one of the addresses that were resolved and checked.
redirect_deniedhttp.fetch: the server answered with a redirection. PlayerVox OverCrow follows none: declare and request the final URL.
encoding_deniedhttp.fetch: the response is compressed or encoded in a way PlayerVox OverCrow does not accept, or its body does not decode as as asks (invalid JSON or text).
content_type_deniedhttp.fetch: the media type of the response is not one that PlayerVox OverCrow accepts for this request.
response_metadata_limithttp.fetch: the status line or the headers of the response are malformed or exceed PlayerVox OverCrow's bounds.
request_body_limithttp.fetch: the request body is larger than 256 KiB.
response_body_limithttp.fetch: the response body is larger than the bound of the network rule (maxResponseBytes, or 1 MiB without it and for as: "image").
image_invalidhttp.fetch with as: "image": the response is not a PNG, JPEG or WebP image within the image limits.
timeouthttp.fetch: the exchange took longer than 30 seconds.
transport_failedhttp.fetch: the connection failed: no network, a TLS error, a connection closed by the server.

Three habits cover most cases:

  • Show a state, not an error message. The codes are for your logic; the user sees "Forecast unavailable", not transport_failed.
  • Retry a subscription that ended. A source of PlayerVox OverCrow can fail while the widget runs (the media player's session, for instance): the subscription ends with unavailable. Subscribe again a few seconds later with timers.after; OverCrow restarts the source for the new subscription. The Media widget waits five seconds:
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);
    }
  • Do not retry in a loop. busy and quota_exceeded mean that a bound is reached: wait, or do less.

Calls that need a user action

Services that change something for the user, or put something where other programs read it, can be called only during a user action: while the logic handles one of these events of the view.

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

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

toggle is the on:activate handler of the stopwatch's button: the click is the user action, and stopwatch.toggle() is called while it is handled. The same call from a timer, from the answer of another service, from onHost or from a row of the options menu is refused with gesture_required.

  • One user action allows one protected call. Call the service directly in the handler, not after an await on something else.
  • Widgets receive input only in PlayerVox OverCrow's Interactive mode, so a protected call can only happen there.
  • notes.delete and journal.delete also ask the user to confirm, in a dialog drawn by PlayerVox OverCrow; a declined confirmation is cancelled.

The tables below say, for each service, when it can be called.

Permissions

PermissionMeaningWith a sensitive capability
networkExact HTTPS routes reachable through the host broker, at most 32 rules.manifest rejected
storageHost-managed key-value storage partitioned by widget ID, within 256 KiB.process-lifetime data only
clipboardWriteText clipboard writes, during a user action in Interactive mode.manifest rejected
gameEventsNamed semantic game events overcrow.game.<name>.v1, at most 32.allowed
capabilitiesHost services listed with the capabilities.allowed
PermissionServiceWhen it can be called
networkhttp.fetchAt any time
storagestorage.getAt any time
storagestorage.setAt any time
storagestorage.removeAt any time
storagestorage.keysAt any time
clipboardWriteclipboard.writeTextOnly during a user action
gameEventsgameEvents.subscribeAt any time

“Only during a user action” is explained in Calls that need a user action.

Network rules

network is a list of rules. A rule allows requests to exactly one HTTPS origin, one method and one path; a {name} segment of the path is a typed parameter, and only the declared query parameters are accepted.

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
  },
FieldTypeRequiredMeaning
originoriginyesCanonical https://host[:port] origin, host ≤ 253 bytes with labels ≤ 63 bytes; no credentials, IP literal or local name.
methodGET | POST | PUT | PATCH | DELETEyesOne method.
pathtext ≤ 1 KiByesComplete path; {name} segments are typed path parameters.
pathParamsname → ParameterConstraintnoConstraint of every {name} segment, at most 8.
queryParamsname → ParameterConstraintnoAllowed query parameters, at most 16, each optionally required; others are refused.
maxResponseBytesinteger 1 to 3145728noLargest response body of this route, from 1 to 3 MiB; 1 MiB when absent. Not for as: "image", which keeps 1 MiB. When several rules allow a request, the largest bound applies.

Each path and query parameter has a constraint:

TypeShapeAccepts
integer{ min, max }Decimal without leading zero, from 0 to 2^53 - 1.
slug{ maxLength }ASCII letters of either case, digits, _ and -, at least one character; maxLength from 1 to 128 bytes.
enumlist of literal segment ≤ 32One of the listed values, each ≤ 128 bytes.
string{ maxLength }Query parameters only; any text without control characters; maxLength from 1 to 256 bytes.

The request leaves through PlayerVox OverCrow's broker, never from the widget's process. The broker:

  • refuses any URL that no rule allows (permission_denied), before any DNS query;
  • follows no redirection, and sends no cookie, credential or proxy header;
  • refuses local and private addresses, so a widget cannot reach the user's router or another program of the machine;
  • bounds each exchange: 256 KiB for a request body, 30 seconds in all, and 1 MiB for a response body unless the rule declares a larger maxResponseBytes, up to 3 MiB. Reviewers and users see that bound.

http.fetch(url, { as }) decodes the body as you ask: "json", "text", "bytes" (an ArrayBuffer) or "image", which gives an image handle to bind to an image element instead of the bytes. A string body is sent as text/plain and any other value as JSON, unless contentType says otherwise. An HTTP error status is not a failure of the call: check 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;
    }

At most four requests of a widget are in flight at once, and they share a budget of response bytes: a request that does not fit fails with busy at once.

Storage

storage is a key-value store that PlayerVox OverCrow keeps for your widget only: storage.get({ key }), storage.set({ key, value }), storage.remove({ key }) and storage.keys(). A value is any JSON value. There is no file access.

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(() => {});
}

The store holds at most 512 keys and 256 KiB in all, 64 KiB per value; beyond, storage.set fails with quota_exceeded and changes nothing. A write is answered once it is on disk. Uninstalling the widget erases its store, and a development session (overcrow-widget dev) never reads or writes the store of an installed widget.

Clipboard

clipboard.writeText({ text }) puts text on the clipboard, only during a user action. A widget cannot read the clipboard.

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" };
    },
  );

Text that the user selects in a text element with the selectable attribute is copied by PlayerVox OverCrow itself, and needs no permission.

Game events

gameEvents lists the named game events the widget wants, each of the form overcrow.game.<name>.v1. gameEvents.subscribe(listener) then delivers { event, at } for each one that happens.

Capabilities

A capability gives access to data or actions that PlayerVox OverCrow holds: what the game's use of the machine, the media player, the user's notes, their PlayerVox and Twitch accounts.

CapabilitySensitiveAccountMeaning
telemetry.readno—CPU, RAM and temperature samples of the active game.
fps.readno—Frame rate of the active game when the host has a source.
media.readyes—Now-playing metadata and the image of its cover art.
media.controlyes—Previous, play/pause and next.
session.readno—Duration of the active game session, without the game identity.
stopwatch.readno—State of the host's single manual stopwatch and its shortcuts.
stopwatch.controlno—Start, pause and reset the host's single manual stopwatch.
notes.readyes—The user's notes and checklists: one document shared by every game.
notes.writeyes—Create, select, save, check and delete notes; text only through a form bound to a write intent.
playervox.score.readyes—Public PlayerVox score of the active game; sensitive because it reveals the game.
playervox.rating.readyesPlayerVoxThe user's own rating of the active game.
playervox.rating.writeyesPlayerVoxPublish a rating and review through a form bound to a write intent.
playervox.reviews.readyesPlayerVoxPaged player reviews of the active game, optionally from followed players only.
journal.readyes—Play sessions of the active game: local ones, plus cloud ones while PlayerVox is connected.
journal.deleteyes—Delete a journal session after a confirmation asked by the host; cloud sessions need PlayerVox.
twitch.chat.readyesTwitchJoin, read and leave a Twitch channel chat, and keep favourite channels.
twitch.chat.composeyesTwitchSend a chat message through a form bound to a write intent; the host allows 20 messages per 30 s and answers busy beyond.
CapabilitySensitiveServiceWhen it can be called
telemetry.readnotelemetry.subscribeAt any time
fps.readnofps.subscribeAt any time
media.readyesmedia.subscribeAt any time
media.controlyesmedia.previousOnly during a user action
media.controlyesmedia.playPauseOnly during a user action
media.controlyesmedia.nextOnly during a user action
session.readnosession.subscribeAt any time
stopwatch.readnostopwatch.subscribeAt any time
stopwatch.controlnostopwatch.toggleOnly during a user action
stopwatch.controlnostopwatch.resetOnly during a user action
notes.readyesnotes.subscribeAt any time
notes.writeyesnotes.createOnly during a user action
notes.writeyesnotes.selectOnly during a user action
notes.writeyesnotes.setItemOnly during a user action
notes.writeyesnotes.deleteOnly during a user action
notes.writeyesform notes.saveWhen the user submits the form
playervox.score.readyesplayervox.score.subscribeAt any time
playervox.rating.readyesplayervox.rating.subscribeAt any time
playervox.rating.writeyesform playervox.rating.publishWhen the user submits the form
playervox.reviews.readyesplayervox.reviews.subscribeAt any time
playervox.reviews.readyesplayervox.reviews.pageAt any time
journal.readyesjournal.subscribeAt any time
journal.readyesjournal.pageAt any time
journal.deleteyesjournal.deleteOnly during a user action
twitch.chat.readyestwitch.chat.subscribeAt any time
twitch.chat.readyestwitch.chat.joinOnly during a user action
twitch.chat.readyestwitch.chat.leaveOnly during a user action
twitch.chat.readyestwitch.chat.favoriteOnly during a user action
twitch.chat.composeyesform twitch.chat.sendWhen the user submits the form

“Only during a user action” is explained in Calls that need a user action.

A row whose service is a form is a write that the user types: see forms that write user data.

Sensitive capabilities

A sensitive capability reveals personal data or the game being played. A widget that declares one:

  • cannot also declare network or clipboardWrite: the manifest is refused;
  • has a storage that lasts only as long as its process, and is emptied when the widget restarts.

So what such a widget reads cannot leave the machine through it. If your widget needs both an HTTP API and a sensitive capability, make two widgets.

Accounts

Some capabilities need the user's PlayerVox or Twitch account. The connection belongs to PlayerVox OverCrow: it draws the sign-in, keeps the tokens and shows its own panel over the widget while the account is signed out. A widget never sees a token; it receives only the data its capability describes, and its calls fail with not_connected while the account is not connected.

Data that changes

  • A subscription may deliver null (no active game, no media player): show an empty state.
  • Some subscriptions say what to read rather than the data itself. playervox.reviews.subscribe and journal.subscribe deliver a revision: when it changes, read your page again with playervox.reviews.page or journal.page.
  • twitch.chat.subscribe delivers changes rather than the whole chat, at most every 100 ms: the new messages, the changed ones (same id), the IDs of the removed ones, and the whole list when reset is set.
  • A timestamp that comes with its own offsetMinutes must be formatted with it; see time, dates and numbers.

The shape of every result is in result shapes.