Skip to content

JS Plugin Development Guide

This document details the architecture, API, and development workflow of the Songloft JS plugin system.


1. Overview

The Songloft JS plugin system lets developers extend the music server with JavaScript, without compiling Go code.

Design Philosophy

The system is designed around the Skynet Actor model:

  • Each plugin is an independent Actor (JSService) with its own JS virtual machine
  • Plugins communicate with each other through messages, without interfering with one another
  • All messages are dispatched uniformly by the ServiceScheduler, guaranteeing serial processing
  • Two-layer SHA256 verification ensures the integrity of plugin code

Core Features

FeatureDescription
Sandbox isolationEach plugin runs in an independent QuickJS virtual machine
Permission controlFine-grained permission declarations, authorized on demand
Hot reloadUpdate plugins at runtime, no service restart required
Inter-plugin communicationsend/call message mechanism
Static assetsBuilt-in Web UI hosting
Health checkAutomatically detects and handles misbehaving plugins

Architecture Diagram

Manager
  ├── PackageManager (package management: install/update/uninstall)
  ├── ServiceScheduler (message scheduler)
  │   ├── JSService[plugin-a] (Actor + QuickJS VM)
  │   ├── JSService[plugin-b] (Actor + QuickJS VM)
  │   └── ...
  ├── HotReloader (hot reload monitor)
  └── HealthChecker (health check)

2. Quick Start

We recommend using the official toolchain songloft-plugin-toolchain to create, build, and upload your first JS plugin in 5 minutes.

Step 1: Create a Project with the Scaffolder

bash
npx create-songloft-plugin@latest
# or pnpm create songloft-plugin
cd <your-plugin-directory>
npm install   # or pnpm install / yarn install

The scaffolder interactively guides you through the following configuration:

  1. Basic info — directory name, plugin display name, entryPath, description, author
  2. Permission selection (multi-select) — storage, persistent-storage, songs.read, songs.write, playlists.read, playlists.write, inter-plugin, command, jsenv, fs, fs:music, fs:external, websocket, net
  3. Add-on feature templates (multi-select, skippable) — static pages (static/), executable management (bin/), Lynx native rendering (renderEngine: "lynx", ReactLynx + cross-platform native UI)
  4. Package manager — npm / pnpm / yarn

Generated project structure (when all add-on features are selected):

my-plugin/
├── plugin.json        # Plugin manifest (entryHash / zipHash generated by builder)
├── package.json       # npm dependencies (@songloft/plugin-sdk / @songloft/plugin-builder)
├── tsconfig.json
├── src/
│   └── main.ts        # TypeScript source entry
├── static/            # [add-on] Static assets (HTML + plugin custom JS)
│   ├── index.html
│   └── js/
│       └── app.js
└── bin/               # [add-on] Executable management (bundle/download/run external programs)

The templates use an overlay design: the base template is always included, and each selected add-on feature merges its corresponding files on top.

Step 2: Write Business Logic

src/main.ts uses the global types and helpers provided by @songloft/plugin-sdk:

typescript
/// <reference types="@songloft/plugin-sdk" />
import { jsonResponse, createRouter } from '@songloft/plugin-sdk';

const router = createRouter();

router.get('/hello', (req) => jsonResponse({ message: 'Hello!', query: req.query }));

router.get('/songs', async (req) => {
  const songs = await songloft.songs.list({ limit: 10 });
  return jsonResponse({ count: songs.length, songs });
});

function onInit(): void { songloft.log.info('my-plugin initialized'); }
function onDeinit(): void { songloft.log.info('my-plugin deinitialized'); }
function onHTTPRequest(req: HTTPRequest): HTTPResponse { return router.handle(req); }

// @ts-expect-error — QuickJS global injection
globalThis.onInit = onInit;
// @ts-expect-error
globalThis.onDeinit = onDeinit;
// @ts-expect-error
globalThis.onHTTPRequest = onHTTPRequest;
bash
pnpm run dev          # equivalent to songloft-plugin dev

The first run interactively asks for the Songloft instance address, username, and password, after which it:

  1. Writes the credentials to .songloft-dev.json in the project root (the builder automatically appends it to .gitignore), so subsequent runs log in silently;
  2. Immediately performs a build and upload, automatically enabling the plugin on first install;
  3. Watches src/, static/, and plugin.json; on source changes it automatically rebuilds and uploads, and any active plugin is automatically hot-reloaded by the backend.

Tokens are not cached: every session logs in on the fly with the username and password, so you never have to worry about token expiration / refresh. To switch accounts or change the password, edit (or simply delete) .songloft-dev.json.

The console prints the plugin's access entry point (e.g. http://localhost:58091/api/v1/jsplugin/<entryPath>/); press Ctrl+C to exit.

For the full CLI options, environment variables, and config file fields of development mode, see Development Mode In Depth below.

Step 4: Build a Production Package

Generate a distributable .jsplugin.zip before release:

bash
pnpm run build        # equivalent to songloft-plugin build

The builder will:

  1. Bundle src/main.ts into build/main.js with esbuild (format: iife, target: es2020, references to Node built-in modules are forbidden);
  2. Copy static/ into build/ and inject content hashes into JS/CSS/fonts/images (can be disabled by setting "staticHash": false in plugin.json);
  3. If an available jsc tool is detected, further compile main.js into main.jsc bytecode;
  4. Compute entryHash = sha256(main file) and zipHash (normalized algorithm, excluding plugin.json itself), and write them back to build/plugin.json;
  5. Package everything into dist/<entryPath>.jsplugin.zip and generate dist/<entryPath>.json remote update metadata.

Step 5: Install to a Target Instance

Choose any of the following:

  • Automatic upload in development modepnpm run dev (see Step 3), ideal for local iteration;
  • Upload from the settings page — select dist/<entryPath>.jsplugin.zip on the plugin management page of the Songloft client;
  • Directory placement — drop the zip into the server's data/jsplugins/ directory, and it is scanned automatically on the next startup;
  • API uploadPOST /api/v1/jsplugins/upload, multipart field name file (this is the interface development mode uses under the hood).

After installation, the plugin's HTTP API is accessed via /api/v1/jsplugin/<entryPath>/, and static assets via /api/v1/jsplugin/<entryPath>/static/....

Development Mode In Depth (songloft-plugin dev)

songloft-plugin dev compresses "build → upload → hot reload" into a single resident command, ideal for local development and debugging against a remote instance.

Default Behavior

StageBehavior
StartupReads .songloft-dev.json; if username / password is missing it asks interactively, then persists after a successful login
Login strategyDoes not cache tokens; logs in on the fly with the credentials on each startup, and automatically re-logs in with the same password if a 401 occurs during the session
First uploadCalls POST /api/v1/jsplugins/upload, and automatically calls enable after a fresh install
Subsequent uploadsReuses the upload interface for the same entryPath, which the backend recognizes as an overwrite update; an active plugin is hot-reloaded automatically
File watchingWatches src/, static/, and plugin.json, triggering an incremental build with a 250ms debounce
Password invalidationIf the server rejects the cached password (e.g. it was changed), the password field in .songloft-dev.json is automatically cleared and you are prompted to rerun

CLI Options

text
songloft-plugin dev [options]

--host <url>        Songloft instance URL (default http://localhost:58091,
                    can also read $MIMUSIC_HOST or .songloft-dev.json)
--username <name>   Login username (or $MIMUSIC_USER)
--password <pwd>    Login password (or $MIMUSIC_PASSWORD; prompts silently if omitted)
--token <jwt>       Use a pre-issued access token directly (or $MIMUSIC_TOKEN)
--once              Build + upload once, then exit, skipping watch
--no-enable         Do not automatically enable the plugin after first install

Environment Variables

VariableEquivalent Option
MIMUSIC_HOST--host
MIMUSIC_USER--username
MIMUSIC_PASSWORD--password
MIMUSIC_TOKEN--token

.songloft-dev.json Fields

The dev command automatically maintains the following config file in the project root (and appends it to .gitignore):

json
{
  "host": "http://localhost:58091",
  "username": "admin",
  "password": "your-password",
  "pluginId": 12,
  "entryPath": "my-plugin"
}
FieldWhen WrittenDescription
hostOn first startupSongloft instance URL
username / passwordWritten after interactive input on first startup, or can be filled in manuallyUsed to log in each session; stored in plaintext, never commit
pluginId / entryPathWritten after first uploadFor reference only; the dev command actually reconciles with the backend via entryPath

There are no accessToken / refreshToken fields: the dev command does not cache tokens.

Don't want the password stored in plaintext? Use --token <jwt> or $MIMUSIC_TOKEN to provide a pre-issued access token instead; in token mode the credential fields in .songloft-dev.json are neither read nor written.

Deleting the entire file is equivalent to resetting the login state.


3. Plugin Structure

ZIP Packaging Format

Plugins are distributed in .jsplugin.zip format, with the file naming rule: {entryPath}.jsplugin.zip

Internal ZIP structure (all files at the root level, no parent directory):

plugin.json          # Plugin manifest (required)
main.js              # Entry file (required, or main.jsc bytecode)
static/              # Static assets directory (optional)
  ├── index.html
  └── js/
      └── app.js

Common assets (CSS variables/reset/MD3 component styles, fonts, the API utility library) are injected automatically by the main program; plugins do not need to bundle them. See §8. Static Assets for details.

plugin.json Field Reference

FieldTypeRequiredDescription
namestringYesPlugin name (2-50 characters)
versionstringYesSemantic version number (e.g. 1.0.0)
descriptionstringNoPlugin description
authorstringNoAuthor
homepagestringNoHomepage URL
licensestringNoLicense
entryPathstringYesRoute prefix (lowercase letters + digits + hyphens, e.g. my-plugin)
mainstringYesEntry file path (must end in .js)
minHostVersionstringNoMinimum host version requirement
permissionsstring[]YesPermission list (may be an empty array [])
renderEnginestringNoEngine the client uses to render the plugin UI: webview / webf / lynx; missing or empty string means webview. See renderEngine — Declaring the Rendering Engine
updateUrlstringNoRemote update check URL
download_urlstringNoPlugin download URL
entryHashstringYessha256(main.js) as 64-character lowercase hex, generated automatically by @songloft/plugin-builder; do not edit manually
zipHashstringYesNormalized sha256 of all files in the zip except plugin.json, as 64-character lowercase hex, generated automatically by @songloft/plugin-builder; do not edit manually

entryHash / zipHash are mandatory verification fields; when missing or mismatched against the actual content, both installation and loading are rejected by the backend. The zipHash computation excludes plugin.json itself, avoiding the circular dependency caused by writing the hash back into plugin.json.

entryPath Naming Rules

  • Only lowercase letters, digits, and hyphens are allowed
  • Must start with a lowercase letter
  • Regex: ^[a-z][a-z0-9-]*$
  • Examples: example-basic, music-sync, metadata-helper

renderEngine — Declaring the Rendering Engine

Native clients have three paths for rendering a plugin UI: the system WebView, WebF (a W3C runtime rendered entirely by Flutter), and Lynx (cross-platform native UI driven by ReactLynx). Which one is used is declared by the plugin itself in plugin.json — the host has no global engine switch, and plugins do not affect each other.

json
{
  "entryPath": "my-plugin",
  "renderEngine": "webf"
}
ValueMeaning
field missing / ""Same as webview, i.e. the host default
"webview"Rendered by the system WebView (default)
"webf"Rendered by WebF (Flutter native rendering of HTML/CSS)
"lynx"Lynx native rendering (ReactLynx compiled to .lynx.bundle, loaded by the host via <frame>; an index.html + .web.bundle is auto-generated for clients that don't support Lynx to fall back to WebView)
  • Any other value is invalid: the backend fails it during ValidateManifest, so the plugin cannot be installed (it does not silently fall back to webview)
  • The plugin list API returns the value in the snake_case field render_engine
  • The field can change between versions: to stop using WebF/Lynx, publish a new version that sets it back to webview (or drops it)

When to declare webf

Only declare it once the plugin page has been verified to actually work under WebF. WebF is not a browser and lacks a number of HTML/CSS capabilities (built-in elements, env(), window.open, URL.createObjectURL, …). The capability boundary, the gaps already shimmed by the host, and the native elements available to you are documented in §9 · WebF Native Rendering — that section is what you judge "can my page run on WebF" against; do not conclude anything from how it looks in a browser alone.

What you take on by declaring it:

  • Verifying every page yourself under WebF, rendering and interaction alike — especially tables, sliders, file pickers and external links, which tend to degrade silently
  • Using the html.webf-engine class (added automatically by the host) when you need to branch on the engine; see §9 · WebF Native Rendering
  • WebF is currently 0.x beta. The host keeps no global fallback switch: if your page breaks under WebF, all a user can do is disable your plugin, or wait for you to ship a version that switches back to webview. Treat that as the cost of declaring webf

Platform limits (read before declaring)

  • The web build (Songloft Web in a browser) is completely unaffected by this field: WebF does not support Flutter Web, so the web build always uses the iframe path. Declaring webf changes nothing there
  • Linux coverage is narrow: WebF on Linux requires x86-64 with glibc ≥ 2.38 and has no arm64 build — NAS boxes, Debian 12 and Raspberry Pi are all outside that range and never get the WebF rendering surface
  • Therefore the plugin page must remain usable in the system WebView / a regular browser: webf means "use a better rendering surface on the platforms that support it", not "a license to write the page for WebF only"

When to declare lynx

Only declare it for plugins built with ReactLynx that have been tested end-to-end. Lynx is a completely different rendering path from WebView/WebF — the plugin UI is authored in ReactLynx, compiled to a .lynx.bundle, and loaded natively by the host via the Lynx <frame> element.

Prerequisites for declaring lynx:

  • Use pnpm create @songloft/songloft-plugin and select the "Lynx Native Rendering" template (or manually set up an rspeedy + ReactLynx environment)
  • Install @songloft/lynx-plugin-sdk as the host communication SDK (replaces @songloft/client-sdk used in WebView)
  • Set "staticHash": false in plugin.json (prevents bundle files from being renamed)

Build output structure:

static/
├── main.lynx.bundle   # Lynx native bundle (loaded by Lynx-capable clients)
├── main.web.bundle    # Web fallback bundle (loaded by non-Lynx clients)
└── index.html         # Auto-generated WebView fallback page (bootstraps web-core to load .web.bundle)

Host communication: Lynx plugins do NOT use window.SongloftPlugin / @songloft/client-sdk (those are WebView-only). Instead, use @songloft/lynx-plugin-sdk's invokeHost / onPlayerState / onThemeChange APIs, which communicate via the NativeModules.SongloftPluginBridge native bridge.

Fallback mechanism: Clients that don't support Lynx (e.g. Flutter-based builds) open index.html, which bootstraps web-core to load .web.bundle in a WebView — functionally equivalent, just without native performance benefits.


4. Lifecycle

Plugins have three core lifecycle callback functions:

onInit()

Called after the plugin finishes loading. Used to initialize resources, set up timers, etc.

javascript
async function onInit() {
    songloft.log.info("Plugin initialized");
    await songloft.storage.set("start_time", new Date().toISOString());
}

Note: An onInit() failure does not prevent the plugin from running; the plugin can still respond to HTTP requests.

onDeinit()

Called before the plugin is unloaded. Used to clean up resources and save state.

javascript
function onDeinit() {
    songloft.log.info("Plugin shutting down, saving state...");
}

onHTTPRequest(req)

Called when an HTTP request is received. This is the main entry point through which a plugin provides its service.

Structure of the req parameter:

javascript
{
    method: "GET",           // HTTP method
    path: "/songs",          // Request path (relative to the plugin's entryPath)
    headers: {},             // Request headers map
    body: "",                // Request body (for POST/PUT)
    query: "limit=10&offset=0"  // URL query string
}

Return value structure:

javascript
{
    statusCode: 200,          // HTTP status code
    headers: {                // Response headers
        "Content-Type": "application/json"
    },
    body: "..."               // Response body (string)
}

Example: Route Dispatch

javascript
function onHTTPRequest(req) {
    switch (req.path) {
        case "/":
        case "":
            return { statusCode: 200, body: "Hello!", headers: {} };
        case "/api/data":
            if (req.method === "POST") {
                return handlePost(req);
            }
            return handleGet(req);
        default:
            return { statusCode: 404, body: "Not Found", headers: {} };
    }
}

onWebSocket(req, socket)

Called when a client connects to /api/v1/jsplugin/{entryPath}/... and initiates a WebSocket upgrade. The plugin must declare the websocket permission. onWebSocket should register message/close/error callbacks and then return; the connection lifecycle is managed by the host.

Structure of the req parameter:

javascript
{
    method: "GET",
    path: "/api/inbound",
    headers: {},
    query: "access_token=...",
    remoteAddr: "127.0.0.1:12345"
}

Common socket methods:

  • socket.send(string | Uint8Array | ArrayBuffer): send a text or binary message
  • socket.close(code?, reason?): close the connection
  • socket.onMessage(fn) / socket.onClose(fn) / socket.onError(fn): register event callbacks
  • socket.onmessage = fn / socket.addEventListener(...): browser WebSocket-style compatibility

Example: Echo Service

javascript
globalThis.onWebSocket = async function(req, socket) {
    socket.onMessage(async function(event) {
        await socket.send(event.data);
    });
};

5. API Reference

All APIs are accessed through the global songloft object.

Important: all songloft.* methods are asynchronous and return a Promise; you must await them inside an async function. This matches the behavior of standard Web APIs such as fetch. All examples below are placed in an async function context. Exceptions: songloft.log.* (synchronous local logging) and songloft.comm.onMessage(...) (synchronous callback registration) do not need await.

HTTP Requests (Global fetch)

Use the standard global fetch function to make HTTP requests (provided by a runtime polyfill, returns a Promise). No permission declaration required.

javascript
// GET
const resp = await fetch("https://example.com/api");
const data = await resp.json();

// POST
const postResp = await fetch("https://example.com/api", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ hello: "world" })
});
const text = await postResp.text();

The request headers may use three runtime-internal control headers. They only affect runtime behavior and none of them are forwarded to the target server:

Control headerEffect
X-Fetch-No-RedirectDisables automatic redirect following so the JS side handles the redirect chain manually (required to collect Set-Cookie from intermediate hops — Go's net/http swallows them)
X-Fetch-Timeout-MsSets the per-request timeout (100-30000ms)
X-Fetch-InsecureSkips TLS certificate verification. Requires the net:insecure-tls permission; without it the header is silently ignored and full verification is kept (the host logs a warning)

X-Fetch-Insecure exists for self-hosted NAS-class devices: fnOS (port 5667), Synology and friends ship self-signed certificates by default, and plugins typically reach them by bare IP — even if the device installs a properly CA-signed certificate, the subject is a hostname, so connecting by IP always yields a hostname mismatch. There is no "just install a real certificate" escape hatch for these targets. Do not use it anywhere else: skipping verification means giving up MITM protection.

Response object fields:

  • okstatus >= 200 && status < 300
  • status — HTTP status code
  • statusText — status text
  • headers — response headers object, see below
  • json() — returns Promise<unknown>, parses JSON
  • text() — returns Promise<string>, raw text

Reading headers

headers supports both direct property access and the standard Headers accessor methods:

javascript
resp.headers['Content-Type']          // property style: keys are Go canonical form (Set-Cookie / Content-Type)
resp.headers.get('content-type')      // method style: case-insensitive, multi-values joined with ", ", null when absent
resp.headers.has('set-cookie')        // whether the header is present
resp.headers.getSetCookie()           // raw array of every Set-Cookie (empty array when there is none)
resp.headers.forEach(function(value, name) { /* ... */ });

For multiple cookies you must use getSetCookie() — do not call get('set-cookie') and split on ", " yourself. Joining multiple values is irreversible: a cookie's own Expires=Wed, 21 Oct 2026 07:28:00 GMT attribute contains ", ", so the split result cannot be told apart from the entry separator. getSetCookie() returns each entry intact.

These methods are non-enumerable, so Object.keys(resp.headers), for...in, and JSON.stringify(resp.headers) yield only the response headers themselves, never the method names.

In TypeScript, prefer .get() over property access. The SDK declares fetch as returning the standard DOM Response, whose headers is typed as Headers — which has no index signature. So resp.headers['Content-Type'] works at runtime but fails type checking, and can only be worked around with as unknown as Record<string, string>. .get() is valid on both sides.

onHTTPRequest, onWebSocket, and event callbacks can all be async function; the framework waits for the Promise to settle.

Crypto (Global crypto)

The runtime provides a lightweight crypto utility object. No permission declaration required.

javascript
const md5 = crypto.md5("data");
const sha256 = crypto.sha256Bytes(Buffer.from("data", "utf8")).toString("hex");

const key = Buffer.from("1234567890abcdef", "utf8");
const iv = Buffer.from("abcdef1234567890", "utf8");
const encrypted = crypto.aesEncrypt("hello", "cbc", key, iv).toString("base64");
const decrypted = crypto.aesDecrypt(encrypted, "cbc", key, iv).toString("utf8");

Common methods: md5(str), sha1(str), sha256Bytes(buffer), rc4(key, data), aesEncrypt(buffer, "cbc" | "ecb", key, iv?), aesDecrypt(buffer, "cbc" | "ecb", key, iv?), rsaEncrypt(buffer, publicKeyPEM), randomBytes(size). AES uses PKCS7 padding; a string ciphertext passed to aesDecrypt is parsed as base64 by default, while a Buffer is parsed as raw bytes.

Timers (Global setTimeout / setInterval)

Use the standard global timer APIs (provided by a runtime polyfill). No permission declaration required; the runtime automatically cleans up any uncleared timers when the plugin is unloaded.

javascript
// One-shot delay
const t = setTimeout(() => songloft.log.info("tick"), 1000);
clearTimeout(t);

// Periodic execution
const i = setInterval(() => songloft.log.info("heartbeat"), 60000);
clearInterval(i);

Note: Timer callbacks execute in a separate background goroutine (which checks for due timers every 500ms) and use a TryLock mechanism to ensure they do not block HTTP request handling. While an HTTP request is being processed, the timer automatically yields and waits for the next round. The minimum interval for setInterval is capped at 10ms.

songloft.storage — Persistent Storage

Requires permission: storage

javascript
async function storageExample() {
    // Read a value (asynchronously returns the original-typed value or null)
    var value = await songloft.storage.get("key");

    // Write a value (values are JSON-serialized automatically; objects/arrays can be stored directly)
    await songloft.storage.set("config", { volume: 80, list: [1, 2, 3] });

    // Delete a key
    await songloft.storage.delete("key");

    // Get all key names
    var keys = await songloft.storage.keys();  // ["key1", "key2", ...]
}

Storage limitations:

  • Keys are strings
  • Values are JSON-serialized automatically; you can store objects/arrays/numbers directly, and get asynchronously returns the original-typed value or null
  • Each plugin has its own independent storage space

songloft.songs — Song Operations

Requires permission: songs.read

javascript
async function songsExample() {
    // Get the song list
    var songs = await songloft.songs.list({ limit: 20, offset: 0 });

    // Get a song by ID
    var song = await songloft.songs.getById(123);

    // Search songs
    var results = await songloft.songs.search("keyword");
}

Song object structure:

javascript
{
    id: 1,
    type: "local",        // "local" | "remote" | "radio"
    title: "Song title",
    artist: "Artist",
    album: "Album name",
    duration: 240.5,      // seconds
    file_path: "/path/to/file.mp3",
    url: "",
    cover_url: "",        // Cover URL (the internal CoverPath field is never serialized)
    is_video: false       // Whether this is a video container
}

songloft.playlists — Playlist Operations

Requires permission: playlists.read (read) or playlists.write (modify); or the wildcard sugar playlists.*.

javascript
async function playlistsExample() {
    // Requires playlists.read
    var playlists = await songloft.playlists.list();
    var playlist = await songloft.playlists.getById(1);
    var songs = await songloft.playlists.getSongs(1, { limit: 50, offset: 0 });
}

songloft.comm — Inter-Plugin Communication

Requires permission: inter-plugin

javascript
async function commExample() {
    // Send a message (fire-and-forget)
    await songloft.comm.send("target-plugin", "action-name", { data: "hello" });

    // Request-response call (waits for a response, default timeout 10s)
    var resp = await songloft.comm.call("target-plugin", "action-name", { data: "hello" }, 5000);
    // resp = { success: true, data: { ... } }
}

// Register a message handler (registered synchronously, no await needed)
songloft.comm.onMessage("action-name", function(payload, from) {
    // payload: data passed by the sender
    // from: the sender's entryPath
    return { result: "processed" };  // return value is the response to call
});

songloft.log — Logging

No permission required.

javascript
songloft.log.info("informational message");
songloft.log.warn("warning message");
songloft.log.error("error message");

Logs are written to the server's standard log with a [plugin] prefix.

songloft.plugin — Plugin Information

No permission required.

javascript
async function pluginInfoExample() {
    // Get the plugin's JWT Token (for accessing host APIs, such as authenticated resources like music files and covers)
    var token = await songloft.plugin.getToken();

    // Get the base URL of the host service (e.g. http://192.168.1.100:58091)
    var hostUrl = await songloft.plugin.getHostUrl();
}

Typical use: building an authenticated resource URL

javascript
async function getMusicUrl(songId) {
    var host = await songloft.plugin.getHostUrl();
    var token = await songloft.plugin.getToken();
    return host + "/music/" + encodedPath + "?access_token=" + token;
}

Method reference:

  • getToken() — returns the currently valid JWT access_token string, usable for accessing the host's protected APIs
  • getHostUrl() — returns the base URL of the host service, used to build complete API or resource addresses

songloft.lyrics — Lyrics Provider

No permission required.

A plugin can register as a lyrics provider. The host will automatically call it when a song has no lyrics.

Register / Unregister

javascript
// Register as a lyrics provider
songloft.lyrics.registerProvider();

// Unregister
songloft.lyrics.unregisterProvider();

Implement the Search Endpoint

Once registered, the host calls the plugin's /lyric-search endpoint via InvokeHTTP. The plugin must implement this route.

Request parameters (Query String):

ParameterTypeDescription
titlestringSong title
artiststringArtist
albumstringAlbum name
durationnumberDuration in seconds
fingerprintstringAudio fingerprint (Chromaprint, optional, sent only when available)
isrcstringISRC code (optional, sent only when available)

Response format (HTTP 200 + JSON):

json
{
  "lyric": "[00:01.00]First line\n[00:05.00]Second line",
  "tlyric": "[00:01.00]Translation first line",
  "rlyric": "[00:01.00]Romanized first line",
  "lxlyric": "[00:01.00]Word-by-word lyrics"
}
  • lyric (required): main lyrics in LRC format
  • tlyric (optional): translated lyrics
  • rlyric (optional): romanized lyrics
  • lxlyric (optional): word-by-word synced lyrics

Return HTTP 404 or empty body when no result is found.

Complete Example

typescript
/// <reference types="@songloft/plugin-sdk" />
import { createRouter, jsonResponse, parseQuery } from '@songloft/plugin-sdk';

const router = createRouter();
let registered = false;

router.get('/lyric-search', async (req: HTTPRequest) => {
  const q = parseQuery(req.query);
  const result = await searchFromMySource(
    q.title, q.artist, q.album,
    parseFloat(q.duration) || 0,
    q.fingerprint,  // optional, for exact matching
    q.isrc           // optional, for exact matching
  );
  if (!result) return jsonResponse(null, 404);
  return jsonResponse(result);  // { lyric, tlyric?, rlyric?, lxlyric? }
});

globalThis.onInit = async () => {
  songloft.lyrics.registerProvider();
  registered = true;
};

globalThis.onDeinit = async () => {
  if (registered) songloft.lyrics.unregisterProvider();
};

globalThis.onHTTPRequest = (req: HTTPRequest) => router.handle(req);

Workflow

  1. User plays a song with no lyrics; client requests GET /api/v1/songs/{id}/lyric
  2. Host finds lyrics empty, iterates over all registered lyrics providers
  3. Calls GET /lyric-search?title=...&artist=... on each plugin (15s timeout)
  4. First plugin returning HTTP 200 + non-empty lyrics wins; iteration stops
  5. Found lyrics are cached to the database asynchronously (lyric_source=scraped); subsequent requests return the cache directly
  6. For local songs, lyrics are also embedded into the audio file tags

songloft.covers — Cover Provider

No permission required.

A plugin can register as a cover provider. The host will automatically call it when a song has no cover artwork.

Register / Unregister

javascript
// Register as a cover provider
songloft.covers.registerProvider();

// Unregister
songloft.covers.unregisterProvider();

Implement the Search Endpoint

Once registered, the host calls the plugin's /cover-search endpoint via InvokeHTTP. The plugin must implement this route.

Request parameters (Query String):

ParameterTypeDescription
titlestringSong title
artiststringArtist
albumstringAlbum name
fingerprintstringAudio fingerprint (Chromaprint, optional, sent only when available)
isrcstringISRC code (optional, sent only when available)

Response format (HTTP 200 + JSON):

json
{
  "cover_url": "https://example.com/covers/album.jpg"
}
  • cover_url (required): full URL of the cover image

Return HTTP 404 or empty body when no result is found.

Complete Example

typescript
/// <reference types="@songloft/plugin-sdk" />
import { createRouter, jsonResponse, parseQuery } from '@songloft/plugin-sdk';

const router = createRouter();
let registered = false;

router.get('/cover-search', async (req: HTTPRequest) => {
  const q = parseQuery(req.query);
  const coverUrl = await searchCoverFromMySource(
    q.title, q.artist, q.album,
    q.fingerprint,  // optional, for exact matching
    q.isrc           // optional, for exact matching
  );
  if (!coverUrl) return jsonResponse(null, 404);
  return jsonResponse({ cover_url: coverUrl });
});

globalThis.onInit = async () => {
  songloft.covers.registerProvider();
  registered = true;
};

globalThis.onDeinit = async () => {
  if (registered) songloft.covers.unregisterProvider();
};

globalThis.onHTTPRequest = (req: HTTPRequest) => router.handle(req);

Workflow

  1. User plays a song with no cover; client requests GET /api/v1/songs/{id}/cover
  2. Host finds cover empty, iterates over all registered cover providers
  3. Calls GET /cover-search?title=...&artist=... on each plugin (15s timeout)
  4. First plugin returning HTTP 200 + non-empty cover_url wins; iteration stops
  5. Found cover is persisted asynchronously:
    • Local songs: downloads the cover image → saves to local cover_path → embeds into audio file tags
    • Remote songs: stores cover_url in the database
  6. Subsequent requests return the cached result without calling plugins again

Provider Mechanism — Common Notes

Lyrics and cover providers share the same architecture:

  • Multi-plugin support: multiple plugins can register as the same provider type; the host uses a first-match-wins strategy
  • Idle-eviction safe: when a plugin is evicted from memory (idle cleanup), its provider registration is preserved; the host will auto-reload the plugin on next search
  • Lazy cleanup: disabled or deleted plugins are automatically removed from the provider set during search iteration
  • Fingerprint & ISRC: plugins are encouraged to prioritize fingerprint and isrc for exact matching (when available), then fall back to title/artist fuzzy search

6. Permission System

A plugin must declare the permissions it needs in the permissions field of plugin.json. Permissions are checked when APIs are called at runtime, and undeclared permissions are rejected.

Available Permissions

Consistent with AllPermissions in the backend's internal/jsplugin/permissions.go:

PermissionDescription
storageRead/write the plugin's private persistent storage
songs.readRead song metadata
songs.writeModify/write song metadata
songs.*Song read/write wildcard (all-in-one sugar)
playlists.readRead playlists and the songs within them
playlists.writeCreate/modify/delete playlists and their songs
playlists.*Playlist read/write wildcard (all-in-one sugar)
inter-pluginInter-plugin communication
commandExecute external commands / manage executables
jsenvCreate/run child JS sandbox environments
fsRead/write files within the plugin's data directory
fs:musicAccess the music_path music directory
fs:externalAccess administrator-configured external directories
websocketUse new WebSocket(...) to actively connect to external services, or handle inbound onWebSocket upgrades
persistent-storageRead/write persistent storage that remains after the plugin is uninstalled
netUse raw network sockets (UDP / outbound TCP)
net:insecure-tlsAllow fetch to skip TLS certificate verification via X-Fetch-Insecure (self-signed / bare-IP access to self-hosted devices). Not covered by net; must be declared separately

Note: capabilities such as network requests (fetch), timers (setTimeout/setInterval), and logging require no permission declaration; they are default host capabilities.

Wildcard Sugar

A permission ending in .* acts as all-in-one sugar at the declaration layer, and the runner uses prefix matching when checking. For example, declaring playlists.* covers both playlists.read and playlists.write; whereas declaring only playlists.read cannot call write interfaces.

Principle of Least Privilege

Declare only the permissions you actually need to reduce security risk:

json
{
  "permissions": ["storage", "songs.read"]
}

7. Inter-Plugin Communication

Plugins can collaborate with each other through a messaging mechanism.

Asynchronous Send

The sender does not wait for a response, suitable for notification scenarios:

javascript
// Plugin A: notify Plugin B
async function notifyB() {
    await songloft.comm.send("plugin-b", "data-updated", { source: "plugin-a" });
}

Synchronous Call

The sender waits for the receiver to process and return a result:

javascript
// Plugin A: call Plugin B's service
async function fetchFromB() {
    var response = await songloft.comm.call("plugin-b", "get-data", { id: 123 }, 5000);
    if (response.success) {
        var data = response.data;
    }
}

Register a Handler (onMessage)

The receiver registers a function to handle a specific action:

javascript
// Plugin B: register an action handler
songloft.comm.onMessage("get-data", function(payload, from) {
    songloft.log.info("Request from: " + from);
    // payload = { id: 123 }
    return { name: "example", value: 42 };
});

songloft.comm.onMessage("data-updated", function(payload, from) {
    songloft.log.info("Got notification from: " + from);
    // No return value needed (send scenario)
});

Communication Permission

Both communicating parties need the inter-plugin permission.


8. Static Assets

A plugin can provide a Web UI through the static/ directory.

Directory Structure

my-plugin/
├── plugin.json
├── main.js
└── static/
    ├── index.html
    └── js/
        └── app.js       # Plugin custom logic

Common assets (design tokens/reset/MD3 component styles, font files, the common.js API utility library) are injected automatically by the main program and do not need to be bundled in the plugin.

Automatic Injection by the Main Program

When returning a plugin's HTML page, the backend automatically injects the following at the top of <head> (in order):

  1. <base> — sets the relative path baseline, so relative paths in the HTML can directly reference static/... and the plugin API
  2. Auth bridge script — writes the URL ?access_token= into localStorage, and automatically retries on a fetch 503
  3. theme.css — MD3 color tokens (including light/dark dual themes), font declarations, CSS reset, body base styles, safe-area defaults
  4. components.css — the shared component library aligned with the client's Flutter widgets (.card/.btn*/.switch/.tab-bar, etc.)
  5. webf-shims.css — WebF engine shim styles (only matched on the WebF rendering surface)
  6. common.js — embed detection, theme bridging, the window.SongloftPlugin global API
  7. webf-shims.js — WebF engine capability shims (empty img src, <details>, slider, file picker, safe area, etc.; WebF only)

common.css is retired and split into theme.css + components.css; the WebF compatibility layer is extracted from common.js into webf-shims.js. Plugins need no changes — the public API (window.SongloftPlugin) surface is unchanged.

Therefore a plugin's HTML does not need:

  • <link> references to fonts.css or style.css (provided by the main program)
  • an embed detection script (provided by the main program)
  • bundled font files (the main program serves them via /api/v1/jsplugin-assets/fonts/)

window.SongloftPlugin — Browser-Side Global API

The common.js injected by the main program exposes the window.SongloftPlugin global object, which provides the following methods:

javascript
// API requests
SongloftPlugin.getAuthToken()        // Read the JWT token from localStorage
SongloftPlugin.apiGet(path)          // GET request, returns Promise<JSON>
SongloftPlugin.apiPost(path, body)   // POST request
SongloftPlugin.apiPut(path, body)    // PUT request
SongloftPlugin.apiDelete(path)       // DELETE request

// Theme
SongloftPlugin.getTheme()            // Returns 'light' | 'dark'
SongloftPlugin.onThemeChange(cb)     // Listen for theme changes, cb(theme: 'light' | 'dark')
SongloftPlugin.getColorScheme()      // Host's real color scheme {primary:'#415F91', ...}; null before push-down
SongloftPlugin.forceStyleRecalc()    // Call after changing root CSS variables yourself (WebF-only; a no-op elsewhere)

// In-page navigation
SongloftPlugin.onHostBack(fn)        // Register a back-key hook; fn returns true = consumed (WebF only)

Plugin JS can use them directly:

javascript
const { apiGet, getTheme, onThemeChange } = SongloftPlugin;

const data = await apiGet('/api/hello');
console.log('current theme:', getTheme());
onThemeChange(theme => console.log('theme switched to:', theme));

If a plugin has multiple JS files, just destructure from the global at the top of each file:

javascript
const { apiGet, apiPost } = SongloftPlugin;

Client SDK — Controlling the Host Player (webview pages only)

Plugin pages opened inside the Songloft client can call host client capabilities via window.SongloftPlugin.host / .player — most commonly to rewrite the host's "now playing queue".

  • Works in: the native client (Android/iOS/macOS/Windows/Linux) webview plugin pages, and Web plugin pages (both embedded tab pages and home/full-screen pages open inside a host iframe, via a postMessage bridge).
  • Does NOT work in: only when the user opens a plugin page standalone in a new browser tab via "Open in browser" (no host parent window) — there host.isAvailable() returns false and calls throw, so always feature-detect first.
  • Capabilities are injected by the host client and track its version. Set an appropriate minHostVersion in plugin.json and use host.getInfo().capabilities for capability negotiation.
javascript
const { host, player } = SongloftPlugin;

if (host && host.isAvailable()) {
  // Capability negotiation
  const info = await host.getInfo();   // { version, platform, capabilities: ['player'] }

  // Replace the now-playing queue with song ids and start from index 0
  // (ids typically come from your own search results, persisted first via the
  // server-side songs.create to obtain their ids)
  await player.setQueue([101, 102, 103], { startIndex: 0 });

  // Append to the end of the queue (without interrupting current playback)
  await player.addToQueue([104]);

  // Read state / subscribe to state changes
  const state = await player.getState();     // { queue, current_index, is_playing, ... }
  const off = player.onStateChange(s => console.log('now at index', s.current_index));
}

player namespace methods: getState / setQueue / addToQueue / insertToQueue / removeFromQueue / reorderQueue / clearQueue / play(id?) / pause / togglePlay / next / prev / seek(seconds) / setVolume(0-100) / setPlayMode('order'|'loop'|'single'|'random'|'singlePlay') / playPlaylistById(id) / onStateChange(cb).

When developing with TypeScript / a bundler (e.g. the Vue template), install @songloft/client-sdk for full types and ergonomic wrappers:

ts
import { player, host, isClient } from '@songloft/client-sdk';

if (isClient()) {
  await player.setQueue([101, 102], { startIndex: 0 });
}

Build-free vanilla static pages need no install — just use the injected window.SongloftPlugin.player directly (only losing type hints).

Favorite State Sync — Notify the Host After Changing Favorites

When a plugin changes favorites itself (e.g. POSTing /playlists/1/songs directly), the server data is correct, but the heart icons in the Flutter library read FavoriteNotifier's in-memory cache and will not follow along. Call favorite.refresh once afterwards:

js
const res = await SongloftPlugin.apiPost('/playlists/1/songs', { song_id: id });
await SongloftPlugin.favorite.refresh(id, res.is_favorited);

Pass the arguments whenever you can: with (songId, isFavorited) the host performs an incremental update touching only that one song; without arguments it does a full reload, which is a complete round trip when the library holds thousands of songs.

Generic Host Calls via invokeHost

The player / host / favorite / getCookies namespaces above are all typed wrappers over invokeHost(ns, method, params?). The host's dispatch table lives on the client side and may be newer than the server's copy of common.js, so invokeHost is exposed as well, letting plugins reach namespaces no wrapper covers yet:

js
await SongloftPlugin.invokeHost('favorite', 'refresh', { songId: 42, isFavorited: true });

⚠️ It lacks the type safety of a wrapper — a misspelled ns / method only rejects at runtime. Prefer the wrapper whenever one exists.

⚠️ Do not use window.__SongloftInternal.invokeHost — that is an internal handle shared with webf-shims.js, not a public API.

If a plugin needs session cookies from a third-party site (e.g., FN Connect gateway's os-access-code, self-hosted NAS login tokens, etc.), it can use the getCookies bridge — the host's native layer reads from the WebView Cookie Store, bypassing browser same-origin policy and HttpOnly restrictions.

javascript
// Prerequisite: user has opened the target site in the app's WebView and logged in
const cookies = await SongloftPlugin.getCookies('https://pcyear.5ddd.com');
// cookies: { 'os-access-code': 'xxx', 'music-token': 'yyy', ... }

Parameters:

ParameterTypeDescription
originstringTarget site origin, must include scheme+host(+port), e.g. https://example.com. Path is ignored

Returns: Promise<Record<string, string>> — Cookie name→value map. Returns empty object {} if no cookies exist for that origin.

Platform Support:

PlatformSupportedNotes
Android / iOS / macOS / Windows / LinuxNative CookieManager reads WebView Cookie Store
WebBrowser same-origin policy hard limit; calls will reject

⚠️ Check platform before calling:

javascript
const info = await SongloftPlugin.host.getInfo();
if (info.platform !== 'web') {
  const cookies = await SongloftPlugin.getCookies(origin);
}

Typical workflow (FN Connect example):

  1. User adds a FN Music source, plugin constructs origin (e.g. https://pcyear.5ddd.com)
  2. Plugin guides user to open the target site in the app's WebView and log in
  3. After login, plugin calls getCookies(origin) to retrieve session cookies
  4. Stores cookies in plugin backend config (via apiPost etc.), subsequent requests carry the session

TypeScript usage:

ts
import { getCookies, host } from '@songloft/client-sdk';

const info = await host.getInfo();
if (info.platform !== 'web') {
  const cookies = await getCookies('https://pcyear.5ddd.com');
  await SongloftPlugin.apiPost('/config/cookies', cookies);
}

Theme Adaptation

The main program's theme.css defines --md-* CSS variables under :root (light values), and overrides them with dark values under html[data-theme="dark"]. Plugin pages that use these variables adapt to the theme automatically:

css
/* Plugin custom styles — referencing --md-* variables automatically follows the theme */
.my-card {
    background: var(--md-surface-container);
    color: var(--md-on-surface);
    border: 1px solid var(--md-outline-variant);
}

When the theme changes (the user switches it in the main program's settings), common.js will:

  1. Update the data-theme attribute and the theme-light/theme-dark CSS classes on <html>
  2. Rewrite --md-* with the real color scheme pushed down by the host (see below)
  3. Dispatch a songloft-theme-change CustomEvent
  4. Write to localStorage['songloft-theme']

Plugin JS can listen for theme changes via SongloftPlugin.onThemeChange(callback) to perform additional handling.

Variable Reference

The --md-* variables map one-to-one onto Flutter's ColorScheme fields (camelCase → kebab-case), so the two sides can be audited against each other:

GroupVariables
Primary--md-primary --md-on-primary --md-primary-container --md-on-primary-container
Secondary--md-secondary --md-on-secondary --md-secondary-container --md-on-secondary-container
Tertiary--md-tertiary --md-on-tertiary --md-tertiary-container --md-on-tertiary-container
Error--md-error --md-on-error --md-error-container --md-on-error-container
Surface--md-surface --md-on-surface --md-on-surface-variant --md-surface-dim --md-surface-bright
Surface ladder--md-surface-container-lowest --md-surface-container-low --md-surface-container --md-surface-container-high --md-surface-container-highest
Outline / inverse--md-outline --md-outline-variant --md-inverse-surface --md-on-inverse-surface --md-inverse-primary
Songloft-specific (no M3 role, not pushed down)--md-success --md-success-container --md-warning --md-warning-container
Derived alias (used by components: switch track / progress background)--md-surface-variantsurfaceContainerHighest
Corner radii (aligned with Flutter's AppRadius)--md-radius-sm 8 · -md 12 · -lg 16 · -xl 24 · -xxl 28 · -full 50px
Shadows--md-shadow-1 --md-shadow-2 --md-shadow-3

The old --md-surface-1 / --md-surface-2 aliases have been removed — use --md-surface-container / --md-surface-container-high directly.

Don't invert surface and surface-container: --md-surface is the page background; cards, inputs and hover states step up through the container ladder. The shared .card is already the SectionCard form (outlined, no shadow, 16 radius), with the group label handled by .section-title (small uppercase text) outside the card — use them directly to match the client; you usually need no custom styles:

css
/* only needed when hand-drawing a card; the shared .card is already this form */
.my-section-card {
    background: var(--md-surface-container);
    border: 1px solid var(--md-outline-variant);
    border-radius: var(--md-radius-lg);
}

The Host Pushes Down the Real Color Scheme at Runtime

The static values in theme.css are only a first-frame fallback (derived from the default seed #415F91). Once the page is ready, the host pushes down the real ColorScheme — including the user's custom ThemePack — alongside the songloft-theme message, writing it as inline custom properties on documentElement. Inline styles win over everything, even a same-named variable the plugin redefines in its own :root.

So CSS-only plugins need no changes at all to match the main program's colors. Reading colors from JS, however, requires getColorScheme():

javascript
// Returns null before the host has pushed anything (the page is on static fallback colors)
const cs = SongloftPlugin.getColorScheme();
const primary = (cs && cs.primary) || '#415F91';

// Get notified on arrival / change. The event fires on document and does **not** bubble,
// so a listener on window will never see it.
document.addEventListener('songloft-color-scheme-change', e => {
    console.log('new primary:', e.detail.colors.primary);
});

⚠️ Under WebF this is the only way to read a color value. getComputedStyle(document.documentElement).getPropertyValue('--md-primary') always returns an empty string in WebF, and <flutter-cupertino-*> attribute values are not var()-expanded — they only accept literal hex (e.g. activeColor). Both of the usual tricks are dead ends; this API is how you get a literal value to feed into the attribute.

The color scheme is guaranteed to land before songloft-theme-change is dispatched, so calling getColorScheme() inside an onThemeChange callback always yields the new values. Switching theme packs may leave light/dark unchanged while the colors change — only songloft-color-scheme-change fires in that case, so listen to both events for full coverage.

Access Paths

After installation, static files are accessed via the following paths (note: the runtime route is the singular jsplugin, different from the management API /api/v1/jsplugins, which is plural):

GET /api/v1/jsplugin/{entryPath}/                 → static/index.html (auto-injected)
GET /api/v1/jsplugin/{entryPath}/static           → static/index.html
GET /api/v1/jsplugin/{entryPath}/static/<file>    → any static asset
GET /api/v1/jsplugin-assets/*                     → main program common assets (CSS/JS/fonts)

Notes

  • Static files are extracted from the ZIP to data/jsplugins_data/{entryPath}/static/ at install time
  • Static files are re-extracted when the plugin is updated
  • Use relative paths to reference the plugin API
  • Common assets are provided by the main program; plugins need not, and should not, bundle their own CSS variables/fonts/API utility library

9. WebF Native Rendering

On some platforms, newer clients can render plugin pages with WebF (an in-house W3C runtime rendered entirely by Flutter) instead of the system WebView, giving plugin pages native rendering whose look and performance match the main app. This is a per-plugin choice: only plugins that declare "renderEngine": "webf" in plugin.json get the WebF rendering surface; the default is still the system WebView, and the web build always uses the iframe path — see §3 · renderEngine — Declaring the Rendering Engine for the field semantics and platform limits. WebF is not a browser and lacks a number of HTML/CSS capabilities. The main program's webf-shims.js (with companion styles webf-shims.css) shims the common gaps (empty img src, <details> collapsing, etc.) and adds a webf-engine class to <html> so plugins can branch on the engine:

css
html.webf-engine .only-in-webf { display: block; }

WebF is still 0.x beta with its share of pitfalls, but as long as you follow the set of practices the official plugins have distilled (reuse the host's component styles for theming, obey the 8 layout constraints below, and gate controls through a feature-detecting wrapper layer), you get native rendering reliably. This chapter organizes those practices into a recommended form: the recommended template and theme/layout approach first, then the full per-element and per-API reference behind them.

The easiest way to build a new WebF plugin is to pick the "WebF Native Rendering" option in the official scaffolder, which gives you a skeleton with every recommended practice in this chapter (Vue 3 + Vite, engine feature detection, host theme reuse, a full set of Sl* form-control wrappers, and the layout constraints that work around WebF's defects):

bash
npm create songloft-plugin@latest my-plugin
# For the frontend mode, choose "WebF Native Rendering (Vue 3 + host native components, renderEngine=webf)"

For a complete reference implementation, see the frontend/ of the official songloft-plugin-downloader plugin — this chapter's practices are all distilled from it. Its frontend/src/ structure (also the structure of the scaffolder's WebF template):

FileResponsibility
engine.jsRendering-engine feature detection (useNativeUI / useNativeListView) — decides whether wrapper components use native elements or an HTML fallback
layout.jsOpen-mode detection (tab / fullscreen / browser) and measuring the usable height of <webf-list-view>
ui/Sl*.vue7 form-control wrappers (button / icon / input / switch / checkbox / select / list) — business code is written once
ui/native-props.jsImperative binding of boolean properties (works around Vue's prop/attr heuristic for custom elements)
ui/select-open-state.jsShared "which dropdown is open" mutual-exclusion state (must live in a standalone module, see below)
main.js / App.vue / style.cssEntry assertion, page skeleton, a WebF-safe subset of CSS

⚠️ Any page-wide singleton state in engine.js / layout.js / select-open-state.js / store.js must live in a standalone .js module, not at the top level of a component's <script setup> — that block is compiled into setup() and becomes one copy per component instance, silently breaking mutual-exclusion and singleton semantics.

A WebF plugin page's colors follow the main app's theme automatically (including user-defined ThemePacks); a plugin writes almost no theming code. The key is to reuse the host's injected layer rather than rolling your own:

  • Do not self-include common.css / common.js: the host injects them into <head> automatically (order: base → auth bridge → common.css → common.js, all render-blocking). The palette, reset, fonts, radius/shadow tokens, and safe-area variables all come from here; including them again double-loads.

  • Use the host's --md-* variables for color: the main program pushes the real ColorScheme down at runtime as inline variables on documentElement, overriding the static fallback values. Write all colors as var(--md-primary) / var(--md-on-surface) etc. and they follow the theme automatically, with no JS. Full variable list: §8 · Theme Adaptation · Variable Reference.

  • Reuse the host's components.css component classes directly for component appearance — matching the main app's UI and following the theme automatically. This is the easiest and most recommended approach for WebF plugins:

    Host classComponentNotes
    .cardCardSectionCard shape: surface-container background + outline-variant border + 16 radius
    .btn / .btn-filled / .btn-outlined / .btn-textButtonsMatch FilledButton / OutlinedButton / TextButton
    .switch / .switch-track / .switch-thumbSwitchMatches M3 Switch; track/thumb use var(--md-*)
    .progress-linear / .progress-linear-barLinear progress bar
    .material-symbols-outlinedIcon fontPre-registered by the host via Flutter FontLoader, so glyphs render under WebF too

    The scaffolder's WebF template SlButton / SlSwitch / SlIcon do exactly this — a single HTML implementation that attaches these classes, with no native-element dual branch.

  • When you need to read a color from JS (to feed a native control's property, e.g. cupertino components that only accept literal hex), you must call SongloftPlugin.getColorScheme(): WebF's getComputedStyle returns an empty string for custom properties, so var(--md-*) cannot be read out. See How the color follows the theme and The Host Pushes Down the Real Color Scheme at Runtime.

  • For safe areas, use the host-injected --sl-safe-* variables, not env(safe-area-inset-*) (WebF has no env()); see Safe areas: use --sl-safe-*, never env(safe-area-inset-*).

WebF lays out with Flutter, so a batch of things that are second nature in a browser silently fail or crash on it. The 8 hard layout constraints below are what the official plugins distilled through repeated rework (each has empirical backing — don't change them as if they were aesthetic preferences); the scaffolder template's style.css header restates this same list:

#ConstraintReason
List/cell text must be white-space: nowrap + ellipsisWrappable CJK text is measured as "one character per line" on several measurement paths
Don't use position: stickyGlobally ineffective under WebF (page level too)
Don't use transform to move a position: fixed elementPositioning goes wrong
Hide elements with v-if, not display: noneThe latter still mounts a 0-size box with unpredictable hit-testing
<webf-list-view> must have a definite height (not max-height)An unbounded constraint hits Infinity or NaN toInt
No max() / min() (unimplemented); clamp() works and accepts var() in its args
Children of a flex-wrap: wrap container must have an explicit widthOtherwise the base size is measured as the container width and each item takes its own row
Don't nest a flex container inside a flex containerOtherwise the whole subtree silently isn't painted; stack vertically with block flow (display:block + margin); horizontal flex rows themselves are fine

On top of these 8, the official template has a few page-level recommended patterns:

  • Prefer <webf-list-view> for scrollable lists (a webf built-in element with built-in recycling), which sidesteps the two nastiest defects of CSS Grid: auto row height and the sticky header. It has two hard constraints (list items must be direct children; shrink-wrap must be explicitly turned off and given a definite height), see Two hard constraints on <webf-list-view>.
  • Measure list height in JS: constraint ⑤ requires a definite height, while vertical flex is banned by ⑧, so measure the distance from the list's top edge to the viewport bottom and write it as an inline height (the CSS keeps a calc(100vh - constant) fallback for the first frame). WebF renders asynchronously, so the first frame may measure 0 — retry on the next frame. The template's layout.js#measureListHeight is a ready-made implementation.
  • Adapt to the three open modes: a plugin page may open as a tab (main-program tab, URL carries embed), fullscreen (a full-screen page entered from the home screen, the host already has an AppBar), or browser (the bare "open in browser" page). The host chrome differs, so whether you draw your own header depends on it (drawing one under fullscreen double-titles). The criteria are the embed class + SongloftPlugin.host.isAvailable(), wrapped in the template's layout.js#detectMode.
  • Use an overlay for multi-level pages, not a v-if full-page swap: keep the main page mounted and make the settings page a position:fixed full-screen overlay (v-if mounts only the few settings controls). WebF's large-scale render-tree teardown leaves dangling disposed-but-referenced render objects that trip a per-frame assertion → whole-page white screen; an overlay is a pure mount/unmount and is safe. Back-key integration is covered in the next section.

In-Page Navigation and the Back Key

Plugin pages are single-page. If yours has multiple internal views (e.g. "main → settings"), you need two things:

  1. Draw your own back button — required on every platform; this is the primary path.
  2. Make the host's back key follow along — the Android hardware back key and the back arrow in the full-screen page's AppBar.
javascript
// Returning true = this back press was consumed by the page; the host won't pop the route or exit the app
SongloftPlugin.onHostBack(() => {
    if (currentPage !== 'main') { currentPage = 'main'; return true; }
    return false;
});

⚠️ onHostBack only takes effect on WebF-rendered plugin pages (the host's registration point is gated on isWebFHost()). On the system WebView / iframe / browser paths the host uses each surface's own controller.canGoBack(), which reads real browser history and cannot be intercepted from JS. This asymmetry is deliberate: a self-drawn back button is sufficient in those environments.

⚠️ Do not use history.pushState to express in-page levels. WebF does not implement SPA history routing (the official answer is the native screen stack from @openwebf/*-router). After a pushState, history.length > 1 makes the host's fallback check report "consumed", but WebF never fires popstate — nothing happens on screen and the back key becomes a dead key.

Native Elements and API Reference

This section lists, item by item, how WebF differs from a browser, the gaps already shimmed by the host, and the native elements and host APIs available to you — as a lookup reference. The "Theme Style" and "Page Layout" sections above give the recommended approach; this section is the full rationale behind them.

Real limitations of inline SVG under WebF

WebF implements <svg> by re-serializing the whole svg subtree into a string and handing it to flutter_svg. Consequently:

  • SVG child nodes exist as data only and have no real box — you cannot get layout from them (getBoundingClientRect() is meaningless)
  • An individual <path> / <circle> cannot be animated with CSS on its own, nor hit-tested (it is not clickable)
  • Any change to a child's attributes / styles / subtree rebuilds the entire SVG (re-serialize the string, re-parse, re-rasterize)

Conclusion: frequently updated inline SVG performs worst under WebF. The textbook counter-example is "an SVG progress ring whose stroke-dashoffset changes every second" — every progress step rebuilds the whole SVG.

<songloft-progress-ring> — a native progress ring

For this reason the main program provides a native element: a progress change costs exactly one Flutter CustomPaint repaint, with no string serialization and no SVG re-parsing.

html
<songloft-progress-ring value="30" max="100" stroke-width="5"
                        style="width:48px;height:48px"></songloft-progress-ring>
js
// Updating progress means setting an attribute; there is no other API
document.querySelector('songloft-progress-ring').setAttribute('value', '65');

Attributes:

AttributeDefaultDescription
value0Current progress value
min0Lower bound
max100Upper bound. max <= min is a degenerate range and is treated as 0 (track only)
stroke-width4Stroke width in px, clamped to (0, shorter side / 2]
colorvalue of CSS colorProgress arc color; accepts concrete color values only (see below)
track-colorprogress color at 24% opacityTrack color
line-capbuttSet to round for rounded caps
  • Size comes from CSS width / height (36×36 when unspecified); display defaults to inline-block
  • The arc starts at the 12 o'clock position and grows clockwise
  • Invalid values are always clamped or ignored, never thrown: value="oops" counts as 0, out-of-range values are clamped to [min, max], a negative stroke-width is clamped to the minimum
  • Indeterminate animation is not supported yet; wrap the element in a CSS animation if you need a spinner

How the color follows the theme

The Flutter side cannot read the plugin page's --md-* CSS variables, so the color is decided by the page, in one of two ways:

css
/* ① Recommended: the CSS color property (currentColor semantics).
   This is the only path that tracks --md-* variables — when the user switches
   between light and dark in the main program, the ring repaints accordingly. */
songloft-progress-ring {
    color: var(--md-primary);
}
html
<!-- ② Override with attributes when the progress color must differ from the text
     color, or when the color is computed in JS -->
<songloft-progress-ring value="30" color="#4caf50" track-color="#e0e0e0"
                        style="width:48px;height:48px"></songloft-progress-ring>

color is an inherited property, so it follows the theme even with zero configuration: it picks up the inherited text color, and theme.css already binds the text color to --md-on-surface.

Two verified pitfalls (do not step in them):

  • Writing var(--md-primary) in the attribute does not work: WebF does not expand CSS variables in attribute values, so the element treats it as an invalid color, ignores it, and falls back to ①. Use CSS color if you want to track a variable.
  • getComputedStyle(el).getPropertyValue('--md-primary') always returns an empty string under WebF: WebF's getComputedStyle does not expose custom properties, so the common trick of "read the variable in JS, then write it into the attribute" does not work. Use SongloftPlugin.getColorScheme() instead (see Theme Adaptation · The Host Pushes Down the Real Color Scheme at Runtime) — it hands you literal hex, which is exactly what the attribute needs.
  • Changing a CSS variable on <html> at runtime does not re-resolve descendants: WebF notifies only the same element's own dependents on a variable change; it does not walk into descendants. Switching --md-* and --sl-safe-* is handled inside common.js, which forces a nested style recalculation — plugins need no changes. But if your plugin changes root-level variables itself at runtime (custom palettes, density switches, …), call SongloftPlugin.forceStyleRecalc() afterwards (a no-op outside WebF, so it is always safe to call).

Compatibility and graceful degradation

  • The element exists only on the WebF rendering surface. In a regular browser or the system WebView (older clients) it is an unknown tag and renders as an empty box. The main program does not auto-replace SVG in plugins with it (SVG is arbitrary graphics; mechanically deciding "which svg is a progress ring" would inevitably break legitimate SVG) — replacement and fallback are entirely up to the plugin
  • When you need both to look right, ship both implementations and pick one via html.webf-engine:
css
.ring-native { display: none; }                        /* hide the native element by default */
html.webf-engine .ring-native { display: inline-block; }
html.webf-engine .ring-svg { display: none; }          /* hide the SVG version under WebF */

<songloft-slider> — a native slider (stand-in for input[type=range])

Most plugins have to do nothing at all. WebF does not implement input[type=range] — measured behavior is that the whole line paints zero pixels under WebF: no slider, no text box, and the sibling text on that line plus the line's own background disappear along with it. So the main program's webf-shims.js shim automatically does the following under WebF:

  1. Scan every input[type="range"] on the page and insert a <songloft-slider> after each input;
  2. Hide the original <input> (add the .sl-range-hidden class plus inline display:none) instead of removing it;
  3. Keep the two in sync, both ways.

Your existing plugin JS therefore needs no changes at all:

  • Reading and writing el.value works as before (the shim installs accessors on the instance; JS writes are pushed to the slider, except while the user is dragging)
  • el.disabled = true / false works as before (the slider dims and stops responding to gestures)
  • el.addEventListener('input' / 'change', ...) works as before (slider interaction dispatches bubbling input / change events on the original input)
  • el.matches(':active') works as before (returns true while dragging). This is the standard way plugins detect "the user is dragging, don't overwrite with polled state"; a hidden input can never truly enter :active under WebF, so the shim also shadows matches

The shim is idempotent (marked with data-sl-range-shim); after inserting HTML dynamically, call SongloftPlugin.applyShims() to give the new range inputs a slider. If the .value accessor cannot be installed (the sentinel round-trip self-check fails), the shim gives up entirely: it removes the slider, restores the original input, and logs a console.warn — falling back to WebF's native behavior is better than "the input is hidden and the value no longer syncs".

Attributes (the shim transcribes these from the original input; supply them yourself when you write the element by hand). The shim also carries over aria-label and the original input's inline style, and adds the .sl-range-slider class plus data-sl-for="<id of the original input>" to the slider:

AttributeDefaultDescription
valueminCurrent value
min0Lower bound
max100Upper bound. max <= min is a degenerate range: the thumb stays at the start and drags are ignored
step1Step size. any or <= 0 means continuous
orientationhorizontalSet to vertical for a vertical slider (min at the bottom, max at the top)
disabledabsentPresent means disabled (false / 0 are the exception and count as enabled). When disabled the whole element is drawn at 38% opacity and ignores gestures
colorvalue of CSS colorColor of the filled track and the thumb; accepts concrete color values only
track-colorfill color at 24% opacityColor of the unfilled track
  • Size comes from CSS width / height; when unspecified it falls back per orientation to 160×28 horizontal / 28×160 vertical, and display defaults to inline-block
  • Events: dragging and tapping the track (a tap moves the thumb to the tap position, same as a browser) both dispatch input; releasing dispatches change. The new value is carried in event.data (a string; integers have no .0)
  • The element does not write back its own value attribute during interaction — the page owns the truth. So when you use the element directly, read the value from event.data and do not read getAttribute('value') (that is only whatever you last pushed into it)
  • min / max / step must be written as attributes: WebF does not implement property reflection for these three, so el.min reads back as an empty string
  • Invalid values are always ignored with a note in the client log, never thrown
Vertical sliders: data-sl-orientation must be declared explicitly

The shim does not guess the orientation — spell it out on the original <input>:

html
<input type="range" id="volumeSlider" min="0" max="100" value="50"
       aria-label="Volume" data-sl-orientation="vertical">

Why it cannot be inferred: in a browser a vertical range is usually produced with transform: rotate(-90deg), but WebF's getComputedStyle coverage is unreliable (it does not even expose custom properties), and inferring from transform means a wrong guess is a silently wrong orientation — far worse than requiring one declaration.

If you do not want the slider (you want WebF's native behavior, or your plugin already handles that range itself), add data-sl-no-slider and the shim skips it:

html
<input type="range" data-sl-no-slider>
Plugins usually need a few lines of CSS

<songloft-slider> is a new tag, so it does not match your existing input[type="range"] selectors and inherits none of their geometry. The shim copies only the original input's inline style (so things like style="width:100%" keep working automatically) and deliberately does not copy classes — those classes usually carry rules that make a native range look like a slider (-webkit-appearance, ::-webkit-slider-thumb, accent-color), and copying them would only drag in meaningless or harmful declarations.

It still works without any CSS; you just get the element's default size (160×28 horizontal), which rarely fits your layout. Three selectors are available: songloft-slider, the .sl-range-slider class added by the shim, and [data-sl-for="<id of the original input>"] (the original id stays on the input and is never moved to the slider).

How the first-party miot plugin actually does it (vertical volume bar, originally width: 110px + rotate(-90deg)):

css
songloft-slider {
    color: var(--md-primary);
}

/* A vertical element paints vertically on its own — no transform needed;
   what used to be the pre-rotation width is now the height. */
.volume-panel .volume-slider-wrap songloft-slider {
    width: 28px;
    height: 110px;
}

These rules never match in a browser or the system WebView (there is no songloft-slider element there), so they are purely additive and do not need to be wrapped in html.webf-engine.

Theme-following colors behave exactly as with <songloft-progress-ring> (CSS color / currentColor works, writing var() in the attribute does not) — see How the color follows the theme above; it is not repeated here.

Where it applies, and one known residual risk
  • The shim only runs on the WebF rendering surface: in a regular browser or the system WebView, the native input[type=range] keeps working and nothing about the page changes. A hand-written <songloft-slider> is an unknown tag (an empty box) outside WebF, so if you need both to look right, ship two implementations and pick one via html.webf-engine, just like the progress ring
  • A vertical slider inside a vertical scrolling container may lose the gesture: the slider uses a drag gesture on the same axis as its orientation and therefore competes with scrolling (which is the correct behavior — otherwise the page would scroll and the slider would move at the same time). Who wins depends on the gesture arena's "the deeper hit accepts first" ordering. miot's volume panel is a popup layer, so it is unaffected; test it for real before putting one inside a long list

Safe areas: use --sl-safe-*, never env(safe-area-inset-*)

WebF does not implement CSS env() at all — it is not a matter of imprecise evaluation, the parsing entry point simply does not exist. So on notched / rounded-corner / gesture-bar devices, a plugin page that writes env(safe-area-inset-bottom) will run under the status bar or get clipped by the home indicator.

The host therefore injects the real safe area (Flutter's MediaQuery.viewPadding) as four CSS variables, and plugins uniformly use var():

VariableMeaning
--sl-safe-topTop inset (status bar / notch)
--sl-safe-rightRight inset (landscape notch / rounded corner)
--sl-safe-bottomBottom inset (home gesture bar)
--sl-safe-leftLeft inset
css
/* Recommended: one stylesheet for all three runtimes, no forking needed */
.player-bar {
    padding: 6px 16px calc(4px + var(--sl-safe-bottom));
}

theme.css already declares defaults for all four on :root (under WebF they are then squashed to 0px by webf-shims.css and overwritten by the host-injected real values), so every runtime yields a defined value and plugins only ever write one form:

RuntimeValue of var(--sl-safe-bottom)
Regular browser / system WebView (default engine)env(safe-area-inset-bottom, 0px), i.e. the native value (0px on desktop browsers)
WebF + new clientThe real MediaQuery.viewPadding pushed by the host (re-pushed on rotation, entering/leaving fullscreen, and page remount)
WebF + older client (does not push safe areas)0px, equivalent to "no safe area" — the same as not doing any of this

So: just write var(--sl-safe-bottom); do not bolt an env() fallback onto it.

Three hard constraints, all verified on WebF:

  • var(--x, env(...)) evaluates to 0 under WebF — the fallback chain dies at env(), and even env()'s own inner fallback (the 19px in env(safe-area-inset-bottom, 19px)) is unreachable. So it is not a "safer" spelling; it merely throws away the variable's default value

  • WebF does not implement CSS max() / min(), and the whole declaration is dropped (not just the safe-area term). To express "at least 24px, more if the safe area is larger", replace max(24px, ...) with clamp():

    css
    /* clamp(MIN, VAL, MAX) is by definition max(MIN, min(VAL, MAX)), so for any
       safe area <= 96px it is exactly equivalent to max(24px, …) (real devices
       top out around 34px). Zero behavior change in browsers, verified on WebF. */
    .fp-controls {
        padding-bottom: clamp(24px, var(--sl-safe-bottom), 96px);
    }

    When you only want "a fixed gap on top of the safe area", calc() is more direct: calc(24px + var(--sl-safe-bottom))

  • clamp() works and accepts var() in its arguments (verified), but avoid every other CSS math function outside calc() (max / min / round / mod, …)

Note that the injected value is whatever is left for the page to handle: the client already consumes part of the safe area with an outer SafeArea (the plugin tab page consumes top / left / right and leaves the bottom to the page), so you will not get double padding where the host has already inset the surface.

File picking: input[type=file] is taken over automatically, but the result is not in input.files

Your HTML needs no changes at all; the JS that reads the result must change.

WebF does not implement input[type=file]: its <input> build switch only knows radio / checkbox / button / submit / date / time, so type=file falls through to the default branch and renders a Flutter text field — clicking it does nothing at all (and raises no error). So under WebF the webf-shims.js shim automatically:

  1. Scans every input[type="file"] on the page and hides it (adds the .sl-file-hidden class plus inline display:none);
  2. Intercepts its click event and overrides its instance click() method — so the common "hidden input + external button calling fileInput.click()" pattern still opens the picker;
  3. Opens the host's native file picker and sends the chosen files back to the page over the bridge;
  4. Writes the result into SongloftPlugin.lastPickedFiles, then dispatches a bubbling change on the original input.

Why the shim must hide the original input itself: measured behavior is that WebF ignores the HTML hidden attribute (a file input's box is 170×24 with or without hidden), so the input a plugin deliberately hid would really occupy a line under WebF — as an unclickable empty text field.

Reading the result: the primary channel is SongloftPlugin.lastPickedFiles
js
fileInput.addEventListener('change', function () {
    // ✅ Primary channel: a plain JS array, always readable
    var files = (window.SongloftPlugin && SongloftPlugin.lastPickedFiles) || [];
    if (!files.length) return;
    importPlaylist(files[0].text);   // a decoded string when as=text (the default)
});
  • input.files / FileReader / FileList are all unusable under WebF: the latter two do not exist at all (measured: typeof is undefined for both), which is why the host deliberately does not fake input.files — a fake File is useless without a real FileReader, and there is no real FileReader. Code that reads files with new FileReader() throws outright under WebF.
  • The change event also carries a best-effort event.data = {files: [...]}, but that is a bonus only: WebF's Event is a binding object and there is no contract that custom properties can be attached to it. Do not treat it as the primary channel.
  • No change is dispatched when the user cancels (same as in a browser), so you need not worry about an empty change falling into your "read failed" branch and showing an error for something the user did not do wrong.
  • lastPickedFiles is a single global value (null before the first pick); with several file inputs on a page it holds the most recent result — read it immediately inside the change handler. If the host call fails, the shim only logs a console.warn and dispatches no change.
Payload shape: data-sl-file-as
html
<!-- metadata only, do not read the contents -->
<input type="file" id="pick" accept=".m3u,.m3u8,.json" data-sl-file-as="none">
ValuePayloadWhen to use it
text (default)text string + encoding (+ possibly textLossy)Importing text such as m3u / json / lrc
bytesbytesBase64 (a base64 string)Binary files, or when you need to decode a legacy encoding (e.g. GBK) yourself
nonename / size onlyYou only need the file name and size

The default is text, not bytes: the real use cases (importing m3u / json) only need text, while base64 turns a 20 MB file into roughly a 27 MB string that must cross two serialization bridges (Dart → C++ → QuickJS). That is not a price to pay by default.

Fields of each file object:

FieldPresent whenMeaning
nameAlwaysFile name (no path)
sizeAlwaysSize in bytes
textas=text and the read succeededThe decoded string (BOM stripped)
encodingSame as aboveutf-8 / utf-8-lossy / utf-16le / utf-16be
textLossytrue when decoding was lossyThe host decides by BOM plus strict UTF-8 only and never guesses GBK; GBK files go through a lenient decode and get this flag — switch to as="bytes" and decode them yourself if you need exact handling
bytesBase64as=bytes and the read succeededbase64
errorOn read failuretoo_large (over the 32 MB per-file limit, accompanied by limit) / read_failed. Deliberately never truncated silently: half an m3u parses as "imported fine, but half the entries are missing", which is far harder to diagnose than an error
  • You never get a file path, by design: desktop platforms hand back a real path while Android SAF gives a content URI (inconsistent across platforms), a path is useless to page JS anyway, and it would be an unnecessary information leak.
  • accept is passed through verbatim, but only the .ext extension form becomes a real filter; with MIME forms (text/plain, image/*) or a mix of the two, the host drops filtering entirely (a few extra selectable files — which your plugin validates anyway — is better than blocking files the user was supposed to be able to pick).
  • multiple enables multi-select; without it only the first file is returned.
  • Only one picker may be in flight at a time, so rapid clicking cannot start two host calls (otherwise, of the two resulting change events, the later one is not necessarily the user's final choice).
Opt-out, idempotency and cross-runtime code

To keep WebF's native behavior (or when your plugin already handles that input itself), add data-sl-no-file-picker and the shim skips it:

html
<input type="file" data-sl-no-file-picker>

The shim is idempotent (marked with data-sl-file-shim); after inserting HTML dynamically, call SongloftPlugin.applyShims() to take over the newly added file inputs. The shim only runs on the WebF rendering surface: in a regular browser or the system WebView the native file input and FileReader keep working. So keep both paths and one body of code serves all three runtimes:

js
function readPickedFile(input, cb) {
    var picked = window.SongloftPlugin && SongloftPlugin.lastPickedFiles;
    if (picked && picked.length) return cb(picked[0].text);   // WebF
    var f = input.files && input.files[0];                    // browser / system WebView
    if (!f) return;
    var r = new FileReader();
    r.onload = function () { cb(r.result); };
    r.readAsText(f);
}

URL.createObjectURL does not exist: use SongloftPlugin.blobToDataURL() (async)

URL.createObjectURL simply does not exist under WebF (measured: typeof URL.createObjectURL === 'undefined'). Blob itself is there, but nothing can produce a blob: URL — and no shim is possible either: blob: requires cooperation from the resource loader, and WebF's loader only accepts http / https / assets / file / data: and throws on anything else. Even if JS fabricated a blob:xxx string, loading it would necessarily fail.

The textbook victim is "fetch an image with an auth header, then display it": fetch gives you a Blob, and <img src> cannot consume a Blob directly. The host's replacement is a data: URL (natively supported by WebF):

js
var url = await SongloftPlugin.blobToDataURL(blob);   // 'data:image/jpeg;base64,...'

Signature: blobToDataURL(blob, mimeType?) → Promise<string>. mimeType overrides blob.type (useful when blob.type is empty); with neither, application/octet-stream is used.

All three rendering paths share one implementation: it uses Blob.prototype.arrayBuffer + btoa, which exist in regular browsers and the system WebView too, so no engine forking is needed — one async spelling serves everything.

⚠️ It is async, so you must change the call sites

createObjectURL is synchronous while blobToDataURL returns a Promise. That is not implementation laziness but an unbridgeable difference in shape: Blob → base64 can only go through arrayBuffer() (FileReader does not exist under WebF), and that is inherently asynchronous. So do not expect a synchronous stand-in from the host — changing the call sites is the only path:

js
// ❌ Before: URL.createObjectURL is undefined under WebF, so this line throws a TypeError
function showCover(blob) {
    var url = URL.createObjectURL(blob);
    img.src = url;
    bg.style.backgroundImage = 'url(' + url + ')';
    // and you still have to remember URL.revokeObjectURL(url)
}

// ✅ After: the function becomes async; everything after you have the string is unchanged
async function showCover(blob) {
    var url = await SongloftPlugin.blobToDataURL(blob);
    img.src = url;
    bg.style.backgroundImage = 'url(' + url + ')';
    // no revoke needed
}

Note that "change the call sites" propagates upwards: once showCover() is async, its own callers must either await / .then it or accept that the image appears a beat later. This is the easiest step to miss when porting — missing it raises no error, the image just never shows up.

Two consumption points verified to work
  • <img src="data:…">
  • CSS background-image: url(data:…) — worth spelling out, because it takes a completely different code path from <img>, and a data URL contains commas and semicolons that CSS url() tokenization could plausibly split wrongly. It was measured to render, so "the same image as both a cover <img> and a blurred background" can reuse the same data URL instead of needing another route.
The lifetime semantics change

A data URL needs no revokeObjectURL (there is none): it is not a handle, just a string. The price is that it stays in memory (base64 is about 4/3 of the raw bytes) — as long as an element's src / style references it, or you stored it in a variable / array / DOM attribute, that string is not reclaimed. Therefore:

  • For large images or long-list thumbnails, do not blindly accumulate data URLs in an array as a cache — clear the references when you are done with them.
  • The cheaper approach is not to route through a Blob at all when a URL will do: if the image is reachable via a directly accessible URL (no custom request headers required), point <img src> at it and skip the whole dance.

Nothing on the plugin side has to change, but you need to know what it now means.

WebF's window.open used to be completely silent: no error, and nothing happened (the root cause is that with no navigation delegate installed, WebF's default navigation policy cancels external links unconditionally). So under WebF, "clicking 'log in on the web' does nothing" produced neither an error nor a log line.

Newer clients install a navigation delegate on the WebF rendering surface, and the behavior is now three-tiered:

TargetBehavior
In-page anchors starting with #Navigate as usual (pushState + hashchange keep working)
External http(s) / mailto: / tel:Opened with the system browser / system default app
Same-origin http(s) navigation (the plugin page itself)Blocked, with a warning in the client log
js
// Both call shapes are verified to work (one argument, and two arguments with a target,
// are both really forwarded to the host)
window.open('https://account.xiaomi.com/oauth2/authorize?...');
window.open('https://example.com/help', '_blank');

Three things to keep in mind:

  • It opens an external browser, not an in-page popup window. So flows where "the popup writes data back into the opener after the user finishes" (window.opener, assigning to the returned window object, cross-window postMessage) do not work here — restructure them so that the plugin polls after the user returns to the page, or offer an explicit "I'm done" button that triggers the callback.
  • Same-origin full-page navigation is deliberately blocked: under WebF that path means "load() the whole plugin page at a new address", which invalidates the context injected by the host, the loading state and the back-button behavior. Do not do multi-page navigation under WebF — stay single-page, switch views in place, and wire the back key through onHostBack (not history.pushState; see that section for why).
  • Any other scheme (relative paths, javascript:, custom schemes) is not allowed through. If your plugin relies on a custom scheme to launch a third-party app, treat that as unavailable under WebF and provide a fallback.

<table> does not exist under WebF: use CSS Grid instead

Read the next section first. If your "table" is really a scrollable list (identically structured rows, potentially many of them), prefer <webf-list-view> plus flex rows over the CSS Grid approach described here — that path sidesteps both of the nastiest pitfalls below (grid auto row heights, and sticky headers). This section applies to genuine two-dimensional tables: column widths must align strictly across rows, the row count is bounded, and you do not need virtual scrolling.

WebF's element registry registers none of table / thead / tbody / tr / th / td — they all fall through to unknown elements (display:block). The consequence is not "slightly off styling" but loss of information structure: a 6-column table stacks into 6 rows, and a few dozen records become several hundred lines of unlabeled text. And it is completely silent — no error, no log.

The host can only help you discover it, not fix it: on the WebF surface, webf-shims.js tags every <table> on the page with data-sl-table-unsupported and prints one console.warn (that warning is what pointed you at this section). The host deliberately does not rewrite the tags — see the rationale below.

The fix: CSS Grid. One row = N consecutive cell divs, wrapped by grid auto-placement:

css
.tbl-head, .tbl-body {
    display: grid;
    /* Both containers share one track definition — that is the whole secret
       behind columns lining up across rows. */
    grid-template-columns: 36px minmax(0, 3fr) minmax(0, 2fr) minmax(0, 2fr) 90px 60px;
}
html
<div class="table-wrap">            <!-- horizontal scroll: overflow-x here, BOTH header and body inside -->
  <div class="tbl">                 <!-- plain block: width baseline + min-width floor -->
    <div class="tbl-head">…6 header cells…</div>   <!-- outside the vertical scroller, no sticky -->
    <div class="tbl-scroll">       <!-- vertical scroll: max-height + overflow-y, body only -->
      <div class="tbl-body">…6×N data cells…</div>
    </div>
  </div>
</div>

Six hard constraints — each one was learned the hard way, do not rediscover them:

  • Never write display: table / table-row / table-cell. WebF's CSSDisplay enum has no table value at all, and resolveDisplay falls through to default, returning inline — which is worse than the default block, i.e. a net loss. display: contents (the standard trick for making row wrappers transparent in browsers) is unsupported too, so you cannot keep <tr> wrapper elements.
  • Cells must be white-space: nowrap + overflow: hidden + text-overflow: ellipsis; never let content wrap. WebF sizes auto grid rows by measuring children at min-content width (a confirmed upstream defect): when wrapping is allowed, every CJK character is a break opportunity, so a 3-character header is measured as 3 lines and a 12-character name as 13 lines — measured in the container, one row took 281px (the same content is naturally 41px tall) and the header row 72px, leaving room for exactly one row: the user sees an almost empty table. Under nowrap, min-content == max-content, so even that wrong measurement pass comes out right (measured: 41px rows / 39px header). These properties must live in CSS loaded with the page, not injected later by JS — they have to be in effect before rows are inserted so the very first layout is correct. Expose the full text via a title attribute for desktop hover (always build attributes with a quote-escaping helper; a textContent → innerHTML style esc() does not escape quotes and will truncate the attribute).
  • Do not make the header sticky — restructure so it does not need to be. Measured: position: sticky does not work at all under WebF, and not only in grid — put a plain div at the top of body and scroll the page itself (documentElement.scrollTop = 300) and it still scrolls away by the full amount (y = -300), while computed position is still "sticky", top is still "0px", and scroll events do fire (the style survives and the notification chain runs; only the offset is never applied). So avoid it structurally: only the data area scrolls, and the header is its sibling, sitting outside the vertical scroll container. That structure is equally correct in browsers and system WebViews, so one code path still covers all three.
    • A related source-level fact that still holds (it is why the header must be its own grid container): WebF's grid layout lumps position: sticky children together with absolute/fixed as out-of-flow, so sticky header cells neither occupy a grid cell nor contribute to track sizing. Either way you need two grid containers (header + body) sharing one grid-template-columns.
  • A vertical scrollbar offsets the header from the body by its width — compensate for it. The data area lives in its own scroll container, and a classic (space-consuming) scrollbar eats only its content width, which the header outside cannot see (measured: a dozen-odd pixels in desktop browsers; 0 under WebF and on mobile, where scrollbars are overlays). CSS cannot tell you that width, so measure it: scrollEl.getBoundingClientRect().width - bodyEl.getBoundingClientRect().width, write it into a custom property, and cancel it out with padding-right on the header. Two notes: ① measure across a frame — WebF's layout is asynchronous, so reading right after assigning innerHTML gives you the previous layout; wrap it in a setTimeout; ② if the measurement fails, treat it as 0, which is exactly the right answer for overlay scrollbars. Adding scrollbar-gutter: stable to the scroll container keeps the content width constant regardless of whether a scrollbar is present, removing one jump at the threshold.
  • Do not use auto / min-content / max-content in the track definition. Use only fixed px and minmax(0, Nfr), so each column width is a pure function of the available width, independent of what either container happens to hold — that is the precondition for two independent containers to stay aligned.
  • Do not hide columns on narrow screens with @media + display:none. Under WebF a display:none element still attaches a zero-sized box and still consumes a grid cell, shifting every following cell by one position. Give .tbl a min-width and let the whole table scroll horizontally below that width instead (the horizontal scroll container must wrap both the header and the body, otherwise they drift apart once you scroll right).

Why the host does not rewrite tags to WebF's built-in <webf-table> family: it is a thin wrapper over the Flutter Table widget, so its ceiling is set upstream — zero colspan/rowspan support, CSS width entirely ineffective (only the header cells' column-width attribute is honored), CSS position:sticky ineffective (you must use a sticky attribute instead), and rows must be direct children (leaving <thead>/<tbody> in place renders an empty table with no error). More importantly those tags do not exist at all in plain browsers and system WebViews, so using them would mean maintaining two templates forever. CSS Grid is standard CSS: all three rendering paths share one set of HTML/CSS/JS and one appearance.

Two unavoidable regressions: tr:hover whole-row highlighting degrades to single-cell highlighting (after flattening there is no row element in the DOM, and pure CSS cannot express it); table accessibility semantics are lost (mitigation: add aria-label to each row's interactive controls and role="group" to the container). These two regressions are precisely why lists are better served by <webf-list-view> — on that path rows are real row elements, so neither is lost.

webf-ui native components (<flutter-cupertino-*> / <webf-list-view>)

Every section above follows the pattern "some web capability is missing under WebF, here is the workaround". webf-ui goes the other direction: use native elements that map straight onto Flutter widgets, bypassing the CSS layout layer entirely. For lists and form controls this is usually less work than assembling your own HTML/CSS, and far less likely to run into WebF's layout defects.

The main client already bundles webf_cupertino_ui, so the tags below work out of the box on the WebF surface with no runtime to install on the plugin side (the npm packages @openwebf/vue-cupertino-ui / react-cupertino-ui are type declarations only; install them purely for editor completion):

CategoryTags
Buttonsflutter-cupertino-button
Inputflutter-cupertino-input, flutter-cupertino-search-text-field
Toggles & selectionflutter-cupertino-switch, flutter-cupertino-checkbox, flutter-cupertino-radio, flutter-cupertino-slider, flutter-cupertino-sliding-segmented-control
Lists & formsflutter-cupertino-list-section, flutter-cupertino-list-tile (with -leading / -subtitle / -trailing / -additional-info sub-tags), flutter-cupertino-form-section, flutter-cupertino-form-row, flutter-cupertino-text-form-field-row
Overlaysflutter-cupertino-alert, flutter-cupertino-action-sheet, flutter-cupertino-modal-popup, flutter-cupertino-context-menu
Navigationflutter-cupertino-tab-scaffold, flutter-cupertino-tab-bar, flutter-cupertino-tab-view
Otherflutter-cupertino-icon (1300+ icon names), flutter-cupertino-date-picker

Separately, <webf-list-view> comes from the webf package itself (not from the Cupertino set). It maps onto Flutter's ListView with built-in view recycling — the first choice for long lists.

Cupertino has no dropdown for arbitrary option lists (flutter-cupertino-picker is commented out inside installWebFCupertinoUI(); only date-picker is registered).

Do not reach for the native <select> to fill that gap — under WebF the selected value never makes it back to JS. It draws fine and it does pop up a menu, which makes it very easy to mistake for "supported"; but WebF's HTMLSelectElement only exposes value / selectedIndex / disabled / multiple / requiredthere is no options. Vue's v-model on a <select> resolves to the vModelSelect directive, whose entire implementation is built on el.options (its change listener is Array.prototype.filter.call(el.options, o => o.selected)), so filter.call(undefined, …) throws a TypeError — any framework's two-way binding on <select> hits this. Bypassing v-model with an explicit @change that reads el.valuealso does not work in practice (the remaining break is on the Dart side and is not observable from JS). No error, no log line.

Diagnostic trap: "the dropdown label updated" is not evidence that the data flowed. WebF's select is a WidgetElement; it mutates its own selectedIndex first and dispatches change afterwards, so the visible label is maintained on the Flutter side regardless of whether JS ever received the value.

Recommended: a trigger button plus an inline panel in normal flow, with plain <div> rows.

vue
<script setup>
const open = ref(false);
// Panel rows = placeholder + every option; clicking a row emits that row's value
const rows = computed(() => [{ value: '', label: 'All' }, ...options.value]);
</script>
<template>
  <SlButton :label="currentLabel" trailing-icon="chevron" @click="open = !open" />
  <!-- v-if, not display:none — under WebF a display:none element still gets a 0-size render box -->
  <div v-if="open" class="panel">
    <div v-for="r in rows" :key="r.value" class="opt"
         @click="open = false; pick(r.value)">{{ r.label }}</div>
  </div>
</template>

This uses only three core primitives: the cupertino button's click, normal-flow block boxes, and click on an ordinary element (WebF dispatches DOM click from its single global tap recognizer). The value only ever flows through your own JS; no WebF element property is read or written. The cost is that expanding pushes the content below it down.

Deliberately no overlay (position: absolute/fixed gambles on WebF's stacking and hit testing, and such a panel usually has to sit on top of some Flutter widget), no nested <webf-list-view> (gambles on the tap reaching through a Flutter ListView's gesture arena), and no reliance on overflow scrolling (let the page itself scroll when there are many options).

ℹ️ The official <flutter-cupertino-action-sheet> can also serve as a dropdown — but know what it costs. The contract (read action_sheet.dart, not the React-flavoured .md): el.show(config), where config may be an object or a JSON string (a string is safer); selecting dispatches CustomEvent('select', detail: {text, event, isDefault, isDestructive, index}), where index is the position within actions, cancelButton carries no index, and dismissing via the scrim dispatches nothing. The risk: the host element builds a SizedBox.shrink(), and show() is implemented as state?._showActionSheetImpl(args) — a silent no-op when state is not yet established, with no exception and no log line. So "nothing happens when tapped" and "working correctly" are indistinguishable in code. Keep the host mounted and never hide it with display:none; and accept that if it does not work, you have nothing to log and can only probe by hand. That is precisely why downloader switched to the hand-rolled panel above.

Conversely, v-model on a component is safe (it compiles to :modelValue + @update:modelValue, pure Vue, no native directive involved), and so is <input type=checkbox>'s vModelCheckbox (it only needs el.checked / el.value, both of which WebF has). The native <select> is the only one to watch out for.

🔍 When an icon renders as a ? box, do not go fixing the icon name — the client is missing the font, and it is not your bug. The rule in one line: a visible question mark means the name is right and the font is missing; nothing at all means the name is wrong. That is because icon.dart returns SizedBox.shrink() for a type it cannot resolve (a wrong name is invisible), whereas the question-mark box is what a missing font looks like — icon codepoints live in the Private Use Area, and with no font the system fallback draws its "unknown character" placeholder (on macOS, exactly a ? in a rounded box). The root cause is that webf_cupertino_ui does not depend on cupertino_icons, so the host app has to declare that dependency itself (the Songloft client now does; see item 15 in the parent repo's docs/webf/handoff.md). On an older client there is no plugin-side workaround — wait for the client update.

You must feature-detect, not engine-detect

webf-ui tags are unknown elements in plain browsers, system WebViews, and the web iframe, so using them obliges you to keep an HTML fallback path (see the next subsection). But when choosing between the two paths, the test must not be "am I running inside WebF?":

The client and the plugin ship independently. minHostVersion constrains only the server version; nothing constrains the client. So the combination "new plugin + old client" is inevitable, and on such a client <flutter-cupertino-*> falls through to _UnknownHTMLElement (an empty display:block box). What the user sees is every control silently vanishing, with no error at all.

So test whether the elements are actually registered — when they are, the bindings define the corresponding JS property on the instance:

js
function hasCupertinoUI() {
    if (!window.webf) return false;
    try {
        return document.createElement('flutter-cupertino-switch').checked !== undefined;
    } catch (e) {
        return false;
    }
}

// <webf-list-view> ships with the webf package, which is a separate concern from Cupertino
function hasListView() {
    if (!window.webf) return false;
    try {
        return typeof document.createElement('webf-list-view').finishLoad === 'function';
    } catch (e) {
        return false;
    }
}

The result cannot change during the page's lifetime, so compute it once and keep it as a constant.

Confine the fork to leaf components; write business code once

Do not sprinkle if (isWebF) through your business code, and do not maintain two page templates — that road has repeatedly been shown to rot, because one of the two copies can never be exercised on a dev machine, so breaking it goes unnoticed. Push the fork down into a thin wrapper layer:

vue
<!-- ui/SlSwitch.vue — the only fork -->
<template>
  <flutter-cupertino-switch v-if="useNativeUI" ref="el" @change="onNativeChange" />
  <label v-else class="my-switch">
    <input type="checkbox" :checked="modelValue" @change="onHtmlChange" />
    <span class="my-switch-track" />
  </label>
</template>
vue
<!-- business code only ever sees the wrapper -->
<SlSwitch :model-value="settings.embedMetadata" @update:model-value="save" />
Three attribute pitfalls (all of them fail silently)

① HTML attributes are kebab-case; only JS properties are camelCase.

webf_cupertino_ui's *_bindings_generated.dart registers both spellings:

dart
attributes['active-color'] = ElementAttributeProperty(...);   // HTML attribute
'activeColor': StaticDefinedBindingProperty(...)              // JS property

Writing activeColor in a template produces an activecolor attribute (HTML attribute names are case-insensitive), which matches no registration and is silently ignored. Always use kebab-case.

② The input's value attribute is val, not value — and it is controlled.

On every build <flutter-cupertino-input> performs if (_controller.text != val) { replace the whole text and collapse the caret to the end }. So if your write-back path performs a type conversion (e.g. storing a numeric field as a Number), the half-typed intermediate value gets rewritten and the caret jumps. Keep numeric fields as strings in state and parseInt only on submit.

⚠️⚠️ "Controlled" also implies a whole-page white-screen crash chain (debug builds): writing val back to an already-mounted input goes through _Editable.updateRenderObjectRenderEditable.text=TextPainter.markNeedsLayout; if the mouse is resting on the plugin page that same frame, MouseTracker.updateAllDevices hit-tests that not-yet-relaid RenderEditable, getClosestGlyphForOffset trips the Text layout not available assertion, the exception leaves _debugDuringDeviceUpdate stuck true, and from then on every frame spams the !_debugDuringDeviceUpdate assertion from mouse_tracker.dart until the frame loop is unusable (reproduced on downloader 2026-08-05 with a confirmed stack trace; triggering it requires the mouse to be over the page, so screenshot automation never hits it).

Conclusion: treat WebF native inputs as uncontrolled — give val its initial value exactly once at mount, then only read input events and never write back. When an external change really must update the visible value (normalization, clearing), change the element's :key so it remounts — the new value then arrives via mount instead of update. Downloader's ui/SlInput.vue is a complete implementation of this pattern. Switching to WebF's own HTML <input> does not help: it is also backed by a Flutter TextField (webf/lib/src/html/form/base_input.dart) — the same RenderEditable.

③ Boolean attributes have two entry points with different semantics, and frameworks choose heuristically.

The same checked:

dart
// HTML attribute setter — expects a string
setter: (value) => checked = value == 'true' || value == ''
// JS property setter — expects a real boolean
set checked(value) { final bool next = value == true; ... }

The string 'true' routed through the JS property entry point becomes false (in Dart 'true' == true is false). Vue and React decide heuristically whether to set a prop or an attribute on a custom element, and the plugin cannot tell which they picked — picking wrong gives you "the switch does nothing when tapped", one of the hardest failures to diagnose.

Conclusion: bypass template binding for boolean attributes. Grab an element reference and assign the JS property imperatively, passing a real boolean.

js
// rewrite whenever dependencies change; flush:'post' guarantees the element is mounted
watchEffect(() => {
    if (el.value) el.value.checked = !!props.modelValue;
}, { flush: 'post' });

String attributes (val / placeholder / type / variant / active-color) are toString()-ed on both entry points, so template binding is safe for them.

The child-node contract: only the first child counts

<flutter-cupertino-button> is literally childNodes.isEmpty ? SizedBox() : childNodes.first.toWidget(), and the upstream button.md states it outright: "The first child is used as the primary content". So with two sibling children — an icon and a label — the label is dropped entirely, with no error and nothing in the log; you just get a button missing part of its content.

Conclusion: wrap the content into exactly one child element, and wrap the text in an element of its own.

html
<!-- ✗ label dropped (only the first child is used) -->
<flutter-cupertino-button><flutter-cupertino-icon type="arrow_clockwise" />Refresh</flutter-cupertino-button>
<!-- ✓ -->
<flutter-cupertino-button><span class="btn-inner"
  ><flutter-cupertino-icon type="arrow_clockwise" /><span>Refresh</span
></span></flutter-cupertino-button>

In your wrapper component, do not expose a slot — make the text and icon props (<SlButton icon="refresh" label="Refresh" />) so callers have no chance to violate the rule. Also give the text layer white-space: nowrap: button width comes from the content's intrinsic width, and a wrappable CJK label gets measured as "one character per line", yielding a narrow, tall button.

A bare text child (<flutter-cupertino-button>Refresh</...>) is fine. That is exactly the quick-start form in upstream's button.md. This page once claimed bare text does not render; that observation turned out to come from a stale in-process bundle (the plugin was reinstalled without restarting the client), and has been retracted. Wrapping in an element is still the recommendation — you need the wrapper anyway as soon as there is an icon, so one form covers both.

Let the widget paint the decoration — do not write a second copy in CSS

The CupertinoTextField inside <flutter-cupertino-input> takes renderStyle.decoration — that is, the CSS border / border-radius / background you wrote — and uses it as its own decoration. WebF's own render box paints that same CSS decoration as well, so you get two frames on screen: the outer one on the border box, the widget's copy on the content box, inset by padding.

So for elements like this, write only sizing in CSS (width / height) and leave the decoration to the widget: with no background, border, radius or shadow, renderStyle.decoration returns null and the widget falls back to its own systemGrey6 + 8px radius, which also follows the CupertinoTheme's light/dark brightness. Likewise font-size / color have no effect here (the widget does not read renderStyle's text styles) — writing them only misleads the next reader.

Events carry their payload on event.detail
ElementEventdetail
switch / checkboxchangeboolean
input / search-text-fieldinput, submitstring
inputfocus / blur / clearnone
buttonclicknone (a plain Event)

Note that <flutter-cupertino-input> has no change event — only input / submit / blur. To implement "save on commit" rather than one request per keystroke, use blur (and change on the HTML fallback branch).

⚠️ But blur is not deduplicated, so "blur == the user finished editing a value" does not hold. Upstream does _focusNode.addListener(() { hasFocus ? dispatch('focus') : dispatch('blur') }) (in input.dart's initState) — it never remembers the previous focus state, so any notification the FocusNode emits while unfocused dispatches another blur. Observed in practice: a dozen byte-identical save requests, with the UI stuttering at the same time.

So "save on commit" needs a "only submit if the value actually changed" guard in your own code, rather than you trying to predict when a FocusNode notifies:

js
let savedFingerprint = null;          // register once after loading from the server
function save() {
  const fp = fingerprint();           // ← must use the **same** normalizer as the request body
  if (fp === savedFingerprint) return Promise.resolve(null);
  savedFingerprint = fp;
  return api.save(state);
}

Do not skip sharing the normalizer: if the fingerprint is computed from the raw -4 the user typed while the request body normalizes it to 0, the two never match and the guard degrades into "submit every time".

Two hard constraints on <webf-list-view>
html
<webf-list-view shrink-wrap="false" scroll-direction="vertical">
  <div class="row">…</div>   <!-- every item must be a **direct child** -->
  <div class="row">…</div>
</webf-list-view>
  • Items must be direct children — that is how Flutter's ListView recycles views. Wrapping them in a <div> makes Flutter see a single child and recycling stops working.
  • shrink-wrap defaults to true, and you almost always need false. When true the list is as tall as its total content and does not scroll internally, so hundreds of rows just keep extending downwards. Once it is false you must give the element a definite height (e.g. height: clamp(240px, calc(100vh - 420px), 720px)) — never leave the constraint unbounded, as WebF resolving flex under unbounded constraints triggers Infinity or NaN toInt.

Those are the only two attributes. The events are refresh / loadmore, and the methods are finishRefresh() / finishLoad() / resetHeader() / resetFooter(). If you hook loadmore you must call finishLoad('success' | 'noMore' | 'fail'), otherwise the loading indicator spins forever. If you are not paginating, do not hook those events at all — one less way to fail.

Lay rows out with flex, not grid

Inside <webf-list-view>, arrange row cells with flex and share column widths between the header and the rows through CSS custom properties. Do not use grid — you would run into the "grid auto row heights measured at min-content width" defect described in the previous section. Cells should still use white-space: nowrap plus ellipsis, with the full text in a title attribute.


10. Security Mechanisms

Two-Layer Hash Verification

The plugin system uses two layers of SHA256 verification to protect code integrity:

  1. Layer 1 — ZIP Hash: the SHA256 of the entire ZIP file
  2. Layer 2 — Entry Hash: the SHA256 of the entry file (main.js) content

Verification Flow

When loading a plugin:
1. Compute the ZIP file SHA256 → compare with zip_hash in the database
2. If it doesn't match:
   - Check whether the file mtime has changed
   - mtime unchanged = file tampered → refuse to load
   - mtime changed = legitimate update → allow and update the hash
3. Read main.js from the ZIP in memory (not written to disk)
4. Compute the main.js SHA256 → compare with entry_hash
5. If it doesn't match and the ZIP hash is unchanged → reject (internal tampering)

main.js Is Not Written to Disk

The entry file is read directly from the ZIP into memory and is not written to the disk file system, reducing the risk of tampering.

Permission Isolation

  • Each plugin declares its permissions, which are strictly checked at runtime
  • API calls for undeclared permissions are rejected
  • The QuickJS virtual machine provides runtime isolation

11. Packaging and Publishing

Packaging Steps

bash
# 1. Make sure the directory structure is correct
my-plugin/
├── plugin.json
├── main.js
└── static/
    └── index.html

# 2. Enter the plugin directory
cd my-plugin/

# 3. Package into a ZIP (files at the root level, no parent directory)
zip -r ../my-plugin.jsplugin.zip plugin.json main.js static/

# 4. Verify the ZIP structure
unzip -l ../my-plugin.jsplugin.zip
# You should see:
#   plugin.json
#   main.js
#   static/index.html

File Naming

ZIP file name format: {entryPath}.jsplugin.zip

The system extracts the entryPath from the file name: my-plugin.jsplugin.zipmy-plugin

Installation Methods

  1. Development mode (recommended): iterate locally with songloft-plugin dev, see §2.6
  2. UI upload: upload the ZIP via the Songloft client's settings page → plugin management
  3. Directory placement: drop the ZIP into the server's data/jsplugins/ directory, and it is discovered automatically when the service starts
  4. API upload: POST /api/v1/jsplugins/upload, multipart field name file (this is the interface development mode uses under the hood)

Updating an Existing Plugin

  • Simply re-upload a new-version ZIP with the same entryPath (the /upload endpoint handles both fresh installs and overwrite updates, which the backend distinguishes via response status code 201 / 200)
  • You can also explicitly call PUT /api/v1/jsplugins/{id} to upload the new ZIP
  • Or directly replace the ZIP file in the data/jsplugins/ directory

Regardless of the method, if the original plugin is in the active state, the backend automatically triggers a hot reload after the update succeeds.


12. Hot Reload

Plugins support runtime updates without restarting the Songloft service.

Hot Reload Flow

1. Detect a ZIP file change (mtime changed)
2. Freeze the current service (stop accepting new messages)
3. Call the onDeinit() callback
4. Destroy the old QuickJS virtual machine
5. Reload the code from the new ZIP
6. Create a new QuickJS virtual machine
7. Call the onInit() callback
8. Unfreeze the service, resume message processing

Automatic Detection

The system polls the data/jsplugins/ directory every 30 seconds to detect changes in ZIP file mtime. If a change is detected, a hot reload is triggered automatically.

Manual Triggering

There is currently no standalone reload endpoint. Common ways to re-trigger a hot reload:

  • During development: keep songloft-plugin dev running and just save the source;
  • Operations: re-upload a ZIP with the same entryPath (POST /api/v1/jsplugins/upload) or call PUT /api/v1/jsplugins/{id}; the backend automatically triggers a hot reload for plugins in the active state after a successful update;
  • Remote update: call POST /api/v1/jsplugins/{id}/update to pull the new version from updateUrl, which also hot-reloads automatically.

Error Rollback

If the new version fails to load, the system attempts to roll back to the old version. If the rollback also fails, the plugin is marked as being in the error state.

Notes

  • During a hot reload, requests currently being processed are completed before switching
  • Timers and storage state need to be re-initialized after a hot reload
  • We recommend restoring necessary state in onInit()

13. Best Practices

Performance Tips

  1. Avoid long-running blockingonHTTPRequest should return quickly
  2. Use timers wisely — timer callbacks execute in a separate thread and do not block HTTP requests. However, network operations such as fetch inside a callback still hold the VM lock, so avoid running multiple serial network requests in a single callback
  3. Cache computed results — use songloft.storage to cache frequently accessed data
  4. Control response body size — avoid returning overly large JSON responses
  5. Timer intervals — we recommend a setInterval interval of no less than 1 second; the system checks for due timers every 500ms

Error Handling

javascript
function onHTTPRequest(req) {
    try {
        // Business logic
        var data = processRequest(req);
        return {
            statusCode: 200,
            body: JSON.stringify(data),
            headers: { "Content-Type": "application/json" }
        };
    } catch (e) {
        songloft.log.error("Request failed: " + e.message);
        return {
            statusCode: 500,
            body: JSON.stringify({ error: e.message }),
            headers: { "Content-Type": "application/json" }
        };
    }
}

Version Management

  • Follow semantic versioning (SemVer)
  • Set updateUrl in plugin.json to support remote update checks
  • Bump the major version number on breaking changes

Development and Debugging

  1. Check the output prefixed with [plugin] in the server logs
  2. Use songloft.log.info/warn/error to output debug information
  3. Health check failures are recorded in the logs

Storage Usage Pattern

javascript
// Store a complex object (storage serializes to JSON automatically; store the object directly)
async function saveConfig(config) {
    await songloft.storage.set("config", config);
}

async function loadConfig() {
    var config = await songloft.storage.get("config");
    return config || { defaultKey: "defaultValue" };
}

Inter-Plugin Collaboration Pattern

javascript
// Service provider pattern
songloft.comm.onMessage("get-service", function(payload, from) {
    switch (payload.method) {
        case "translate":
            return { text: translate(payload.text) };
        case "summarize":
            return { summary: summarize(payload.text) };
        default:
            return { error: "unknown method" };
    }
});

// Service consumer pattern
async function useTranslation(text) {
    var resp = await songloft.comm.call("translator-plugin", "get-service", {
        method: "translate",
        text: text
    }, 5000);
    if (resp.success && resp.data) {
        return resp.data.text;
    }
    return text; // fallback
}

Appendix: Complete Example

See the plugin-toolchain/examples/basic directory for complete example plugin code based on the official toolchain.