Files
punktfunk/sdk
enricobuehler f62a48d4a9
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s
feat(library): launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.

Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).

A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.

assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.

scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.

All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.

Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.

api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).

Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
2026-08-10 23:26:47 +02:00
..

@punktfunk/host

TypeScript SDK for the punktfunk streaming host: a typed management-API client plus the host's lifecycle event stream (client connect/disconnect, stream start/stop, pairing, displays, library) — built on Effect.

Two surfaces, one core:

  • @punktfunk/host — the Promise facade, the front door. connect(), then pf.api.* (the typed management API — every endpoint autocompletes, every response is typed) and pf.events.on(). You never need to know Effect exists.
  • @punktfunk/host/effect — the Effect-native surface for plugins and composed programs: the PunktfunkHost service + layer, Stream-based events, typed errors (AuthError | ApiError | TransportError | VersionSkew), and every wire shape as an effect/Schema (REST shapes generated from the host's OpenAPI spec; event shapes mirroring the host's snapshot-tested wire format).

Install

Published to the unom Gitea npm registry. Point the @punktfunk scope at it once — in your project's .npmrc (or ~/.npmrc):

@punktfunk:registry=https://git.unom.io/api/packages/unom/npm/

Then install:

bun add @punktfunk/host      # or: npm i @punktfunk/host

effect is a peer dependency (auto-installed by bun / npm ≥ 7) — so the SDK and your own @punktfunk/host/effect code share one Effect instance.

If the registry requires authentication (private org, or from CI), add a token line with a Gitea PAT that has read:package:

//git.unom.io/api/packages/unom/npm/:_authToken=${NODE_AUTH_TOKEN}

Quickstart

import { connect } from "@punktfunk/host";

const pf = await connect(); // zero config on the host box

// Typed API — autocomplete every endpoint, typed responses, no hand-written paths or casts.
const clients = await pf.api.listPairedClients();
console.log(`${clients.length} paired clients`);

// Live events:
pf.events.on("stream.started", (e) => {
  console.log(`${e.stream.client} started ${e.stream.mode}${e.stream.hdr ? " HDR" : ""}`);
});
pf.events.on("pairing.pending", async (e) => {
  // notify your phone, then decide through the typed API:
  const pending = await pf.api.listPendingDevices();
  const match = pending.find((d) => d.fingerprint === e.device.fingerprint);
  if (match) await pf.api.approvePendingDevice(String(match.id), { payload: {} });
});

Need something the generated client doesn't cover? pf.request(method, path, body) is the untyped escape hatch (returns unknown).

The same, Effect-native:

import { Effect, Stream } from "effect";
import { events, PunktfunkHostLive } from "@punktfunk/host/effect";

const program = events().pipe(
  Stream.filter((e) => e.kind === "stream.started"),
  Stream.runForEach((e) => Effect.log(`stream: ${e.stream.mode}`)),
);
Effect.runPromise(program.pipe(Effect.provide(PunktfunkHostLive())));

Examples

A complexity ladder in examples/ — start at the top:

  1. tail-events.tshello world: connect, one typed call, tail events.
  2. notify-pairing.tsevent → decision: approve/deny pairing through the typed API.
  3. provider-sync.tstyped bulk REST: declaratively reconcile a game-library provider.
  4. couch-preset.effect.tsadvanced, Effect-native: only if you're composing Effect programs.

Examples 13 are the plain Promise facade and cover most automation; you only need example 4's Effect surface for composed, interruptible programs. Run any in the repo with bun examples/<file>.ts. To deploy one on a host, install the package into its own directory (bun add @punktfunk/host) and change its ../src/… import to @punktfunk/host — see Running a single script as a service.

Plus a real-world recipe:

  • virtualhere-dualsense.tsUSB passthrough: bind a real DualSense (shared from the couch over VirtualHere) to the host for the length of each connection and release it after — full gyro, touchpad, adaptive triggers and USB rumble instead of an emulated pad. Shows the client.connected/disconnected bracket and clean release on systemctl stop.

Connection resolution

connect() / PunktfunkHostLive() resolve, in order:

What Source
URL { url }PUNKTFUNK_MGMT_URLhttps://127.0.0.1:47990
Token { token }PUNKTFUNK_MGMT_TOKENPUNKTFUNK_PLUGIN_TOKEN<config_dir>/plugin-token<config_dir>/mgmt-token
TLS pin { ca }PUNKTFUNK_MGMT_CA (path) → <config_dir>/cert.pem

<config_dir> is ~/.config/punktfunk (Linux/macOS) or %ProgramData%\punktfunk (Windows) — so a script running on the host box needs zero configuration. The TLS pin trusts exactly the host's self-signed identity cert (chain-verified; the hostname check is waived — the cert is deliberately CN-only, native clients pin its fingerprint). Bun and Node are first-class; other runtimes fall back to system trust (point your runtime's CA option at cert.pem).

The zero-config default is the host's scoped plugin token (plugin-token): the everyday surface — status, library, sessions, events, the plugin UI lease — but deliberately not hook registration or pairing administration, so a plugin defect can't install commands or admit devices. A script that needs the full admin surface opts in explicitly with PUNKTFUNK_MGMT_TOKEN or { token } (mgmt-token remains the fallback on hosts that predate the plugin token). Both tokens are honored from loopback only — run scripts on the host box (or through an SSH tunnel).

Events

  • Reconnects automatically (exponential backoff + jitter, capped) and resumes with Last-Event-ID — the host replays what you missed from its ring.
  • Default is live tail only (a fresh notify script must not re-fire on history); pass { since: 0 } on the Effect surface to replay the host's full ring, or since: N to resume after a seq you persisted.
  • on() patterns: exact kinds ("stream.started", typed callback), "domain.*" prefixes, "*", plus "dropped" (your cursor fell off the ring — resync via REST) and "unknown" (an event kind newer than this SDK — the additive-only wire at work).
  • Effect surface: events() is a Stream<HostEvent, EventStreamError>; eventsRaw() carries every SSE frame verbatim.

Plugins (punktfunk-plugin-*)

import { definePlugin } from "@punktfunk/host";
import { Effect } from "effect";
import { PunktfunkHost } from "@punktfunk/host/effect";

export default definePlugin({
  name: "romm-library",
  main: Effect.gen(function* () {
    const pf = yield* PunktfunkHost;
    // subscribe, sync, reconcile — scoped finalizers run on shutdown/interruption
  }),
  // …or the simple shape: main: async (pf) => { … }
});

In v1 a plugin is a script you run (see below); the managed runner package is a later step.

Persisting state — pluginStateDir

A plugin that keeps config or a cache must write it under pluginStateDir("<your-name>"), not directly under the config dir:

import { pluginStateDir } from "@punktfunk/host";
import * as fs from "node:fs";
import * as path from "node:path";

const dir = pluginStateDir("rom-manager"); // <config_dir>/plugin-state/rom-manager
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "cache.json"), data);

This matters on Windows: the managed runner is de-privileged (NT AUTHORITY\LocalService) and the config dir is locked read-only, so a write straight under it fails with EPERM. punktfunk-host plugins enable grants the runner write on exactly plugin-state — the config dir and your plugin's code stay read-only. On Linux the runner owns the whole config dir, so the same path is writable with no special step.

Receiving data from an interactive-user app — pluginIngestDir

If a plugin needs data produced by a different account — e.g. a desktop app running as the logged-in user, like the Playnite exporter — it can't read it from that user's profile: the de-privileged Windows runner can't traverse C:\Users\<you>\…. pluginIngestDir("<your-name>") resolves an inbox (<config_dir>/ingest/<name>) that plugins enable makes user-writable, so your app drops a file there and the runner reads it:

import { pluginIngestDir } from "@punktfunk/host";
const inbox = pluginIngestDir("playnite"); // <config_dir>/ingest/playnite  (your app writes here)

Treat what you read from it as lower trust than your own state — the inbox is writable by any local user.

A plugin UI in the console — servePluginUi

A plugin can surface a web UI inside the punktfunk console — no second password or port for the operator. It serves the UI on a loopback ephemeral port behind a per-boot secret; servePluginUi registers it with the host, and the console reverse-proxies to it and adds a nav entry gated by the console's own session. Your code implements zero human auth.

import { definePlugin, servePluginUi } from "@punktfunk/host";

export default definePlugin({
  name: "rom-manager",
  main: async (pf) => {
    const ui = await servePluginUi(pf, {
      id: "rom-manager",
      title: "ROM Manager",
      icon: "gamepad-2",                            // a lucide icon name
      staticDir: new URL("../dist/ui", import.meta.url), // your built SPA
      fetch: (req) => appRouter(req),               // plugin-local REST/SSE (after a static miss)
    });
    try {
      await runForever();
    } finally {
      await ui.close();                             // deregister + stop
    }
  },
});

Requests reach fetch prefix-stripped (the console proxy removed /plugin-ui/<id>), so your app sees /, /api/scan, … — the original prefix is on X-Forwarded-Prefix. servePluginUi serves staticDir first (with an index.html SPA fallback for navigations); return undefined from fetch to fall through to it. Build your SPA with a relative base (base: "./" + hash routing) or an absolute base: "/plugin-ui/<id>/", and expect a dark canvas. Requires the Bun runtime (the runner is bun).

The runner: punktfunk-scripting

Instead of one unit file per script, run everything under the managed runner — it discovers your units and supervises them:

bun src/runner-cli.ts            # runs <config_dir>/scripts/* + installed punktfunk-plugin-*
bun src/runner-cli.ts --list     # show what it found

The same CLI manages plugin packages — it creates the plugins dir, points it at the @punktfunk registry, and installs on the bun it is already running on:

bun src/runner-cli.ts add playnite      # → @punktfunk/plugin-playnite (bare names resolve first-party)
bun src/runner-cli.ts remove playnite
bun src/runner-cli.ts list              # installed plugin packages + versions

On an installed host these are reached through the host CLI, which also drives the runner service and checks for elevation on Windows — that is the documented path for operators:

punktfunk-host plugins add playnite
punktfunk-host plugins enable          # enable + start the runner (opt-in)
punktfunk-host plugins status
  • Plugins (a definePlugin default export, from the scripts dir or a punktfunk-plugin-* package installed under <config_dir>/plugins/): supervised — a crash restarts them with capped exponential backoff; a clean return completes them.
  • Bare scripts: importing them is the run — one-shot, no restart (export a plugin to be supervised).
  • Shutdown is structural: SIGINT/SIGTERM interrupt every unit's fiber — Effect plugins' scoped finalizers run (release the preset, deregister cleanly) and facade clients close before the process exits. This is what makes systemctl stop clean.
  • The sshd rule applies: a group/world-writable unit file is refused loudly.

systemd user unit for the runner (~/.config/systemd/user/punktfunk-scripting.service):

[Unit]
Description=punktfunk script/plugin runner
After=punktfunk-host.service

[Service]
ExecStart=/usr/bin/bun /path/to/sdk/src/runner-cli.ts
Restart=on-failure
RestartSec=5
# SIGTERM (the default KillSignal) triggers the runner's structured shutdown.

[Install]
WantedBy=default.target

Running a single script as a service

systemd user unit (~/.config/systemd/user/punktfunk-myscript.service):

[Unit]
Description=punktfunk automation: myscript
After=punktfunk-host.service

[Service]
ExecStart=/usr/bin/bun /home/me/punktfunk-scripts/myscript.ts
Restart=on-failure
RestartSec=5

[Install]
WantedBy=default.target

Windows Task Scheduler: a task triggered At log on running bun C:\Users\me\punktfunk-scripts\myscript.ts (the SDK reads %ProgramData%\punktfunk\plugin-token — run the task as an account that can; the managed runner's plugins enable grants its LocalService principal exactly that read).

Compatibility

  • SDK majors track the management-API major; an event schema bump or an effect major is an SDK major too.
  • The wire is additive-only within a major: an older SDK keeps working against a newer host (unknown response keys are ignored; unknown event kinds ride the "unknown" channel).
  • A 2xx response that doesn't match its schema surfaces as VersionSkew on the Effect surface — a typed nudge to update, not an undefined three frames later.

Development

bun install
bun run gen        # regenerate src/gen/punktfunk.ts from ../api/openapi.json (@effect/openapi-generator)
bun run typecheck
bun test