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
| Feature | Description |
|---|---|
| Sandbox isolation | Each plugin runs in an independent QuickJS virtual machine |
| Permission control | Fine-grained permission declarations, authorized on demand |
| Hot reload | Update plugins at runtime, no service restart required |
| Inter-plugin communication | send/call message mechanism |
| Static assets | Built-in Web UI hosting |
| Health check | Automatically 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
npx create-songloft-plugin@latest
# or pnpm create songloft-plugin
cd <your-plugin-directory>
npm install # or pnpm install / yarn installThe scaffolder interactively guides you through the following configuration:
- Basic info — directory name, plugin display name, entryPath, description, author
- 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 - Add-on feature templates (multi-select, skippable) — static pages (
static/), executable management (bin/), Lynx native rendering (renderEngine: "lynx", ReactLynx + cross-platform native UI) - 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:
/// <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;Step 3: Start Development Mode (Recommended)
pnpm run dev # equivalent to songloft-plugin devThe first run interactively asks for the Songloft instance address, username, and password, after which it:
- Writes the credentials to
.songloft-dev.jsonin the project root (the builder automatically appends it to.gitignore), so subsequent runs log in silently; - Immediately performs a build and upload, automatically enabling the plugin on first install;
- Watches
src/,static/, andplugin.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:
pnpm run build # equivalent to songloft-plugin buildThe builder will:
- Bundle
src/main.tsintobuild/main.jswith esbuild (format: iife,target: es2020, references to Node built-in modules are forbidden); - Copy
static/intobuild/and inject content hashes into JS/CSS/fonts/images (can be disabled by setting"staticHash": falseinplugin.json); - If an available
jsctool is detected, further compilemain.jsintomain.jscbytecode; - Compute
entryHash = sha256(main file)andzipHash(normalized algorithm, excludingplugin.jsonitself), and write them back tobuild/plugin.json; - Package everything into
dist/<entryPath>.jsplugin.zipand generatedist/<entryPath>.jsonremote update metadata.
Step 5: Install to a Target Instance
Choose any of the following:
- Automatic upload in development mode —
pnpm run dev(see Step 3), ideal for local iteration; - Upload from the settings page — select
dist/<entryPath>.jsplugin.zipon 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 upload —
POST /api/v1/jsplugins/upload, multipart field namefile(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
| Stage | Behavior |
|---|---|
| Startup | Reads .songloft-dev.json; if username / password is missing it asks interactively, then persists after a successful login |
| Login strategy | Does 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 upload | Calls POST /api/v1/jsplugins/upload, and automatically calls enable after a fresh install |
| Subsequent uploads | Reuses the upload interface for the same entryPath, which the backend recognizes as an overwrite update; an active plugin is hot-reloaded automatically |
| File watching | Watches src/, static/, and plugin.json, triggering an incremental build with a 250ms debounce |
| Password invalidation | If 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
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 installEnvironment Variables
| Variable | Equivalent 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):
{
"host": "http://localhost:58091",
"username": "admin",
"password": "your-password",
"pluginId": 12,
"entryPath": "my-plugin"
}| Field | When Written | Description |
|---|---|---|
host | On first startup | Songloft instance URL |
username / password | Written after interactive input on first startup, or can be filled in manually | Used to log in each session; stored in plaintext, never commit |
pluginId / entryPath | Written after first upload | For reference only; the dev command actually reconciles with the backend via entryPath |
There are no
accessToken/refreshTokenfields: the dev command does not cache tokens.Don't want the password stored in plaintext? Use
--token <jwt>or$MIMUSIC_TOKENto provide a pre-issued access token instead; in token mode the credential fields in.songloft-dev.jsonare 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.jsCommon 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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Plugin name (2-50 characters) |
version | string | Yes | Semantic version number (e.g. 1.0.0) |
description | string | No | Plugin description |
author | string | No | Author |
homepage | string | No | Homepage URL |
license | string | No | License |
entryPath | string | Yes | Route prefix (lowercase letters + digits + hyphens, e.g. my-plugin) |
main | string | Yes | Entry file path (must end in .js) |
minHostVersion | string | No | Minimum host version requirement |
permissions | string[] | Yes | Permission list (may be an empty array []) |
renderEngine | string | No | Engine the client uses to render the plugin UI: webview / webf / lynx; missing or empty string means webview. See renderEngine — Declaring the Rendering Engine |
updateUrl | string | No | Remote update check URL |
download_url | string | No | Plugin download URL |
entryHash | string | Yes | sha256(main.js) as 64-character lowercase hex, generated automatically by @songloft/plugin-builder; do not edit manually |
zipHash | string | Yes | Normalized 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/zipHashare mandatory verification fields; when missing or mismatched against the actual content, both installation and loading are rejected by the backend. ThezipHashcomputation excludesplugin.jsonitself, avoiding the circular dependency caused by writing the hash back intoplugin.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.
{
"entryPath": "my-plugin",
"renderEngine": "webf"
}| Value | Meaning |
|---|---|
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 towebview) - 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-engineclass (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 declaringwebf
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
webfchanges 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:
webfmeans "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-pluginand select the "Lynx Native Rendering" template (or manually set up an rspeedy + ReactLynx environment) - Install
@songloft/lynx-plugin-sdkas the host communication SDK (replaces@songloft/client-sdkused in WebView) - Set
"staticHash": falseinplugin.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.
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.
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:
{
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:
{
statusCode: 200, // HTTP status code
headers: { // Response headers
"Content-Type": "application/json"
},
body: "..." // Response body (string)
}Example: Route Dispatch
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:
{
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 messagesocket.close(code?, reason?): close the connectionsocket.onMessage(fn)/socket.onClose(fn)/socket.onError(fn): register event callbackssocket.onmessage = fn/socket.addEventListener(...): browser WebSocket-style compatibility
Example: Echo Service
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 mustawaitthem inside anasyncfunction. This matches the behavior of standard Web APIs such asfetch. All examples below are placed in anasyncfunction context. Exceptions:songloft.log.*(synchronous local logging) andsongloft.comm.onMessage(...)(synchronous callback registration) do not needawait.
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.
// 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 header | Effect |
|---|---|
X-Fetch-No-Redirect | Disables 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-Ms | Sets the per-request timeout (100-30000ms) |
X-Fetch-Insecure | Skips 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-Insecureexists 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:
ok—status >= 200 && status < 300status— HTTP status codestatusText— status textheaders— response headers object, see belowjson()— returnsPromise<unknown>, parses JSONtext()— returnsPromise<string>, raw text
Reading headers
headers supports both direct property access and the standard Headers accessor methods:
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 callget('set-cookie')and split on", "yourself. Joining multiple values is irreversible: a cookie's ownExpires=Wed, 21 Oct 2026 07:28:00 GMTattribute 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 declaresfetchas returning the standard DOMResponse, whoseheadersis typed asHeaders— which has no index signature. Soresp.headers['Content-Type']works at runtime but fails type checking, and can only be worked around withas 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.
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.
// 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
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
getasynchronously returns the original-typed value or null - Each plugin has its own independent storage space
songloft.songs — Song Operations
Requires permission: songs.read
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:
{
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.*.
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
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.
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.
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
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 APIsgetHostUrl()— 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
// 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):
| Parameter | Type | Description |
|---|---|---|
title | string | Song title |
artist | string | Artist |
album | string | Album name |
duration | number | Duration in seconds |
fingerprint | string | Audio fingerprint (Chromaprint, optional, sent only when available) |
isrc | string | ISRC code (optional, sent only when available) |
Response format (HTTP 200 + 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 formattlyric(optional): translated lyricsrlyric(optional): romanized lyricslxlyric(optional): word-by-word synced lyrics
Return HTTP 404 or empty body when no result is found.
Complete Example
/// <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
- User plays a song with no lyrics; client requests
GET /api/v1/songs/{id}/lyric - Host finds lyrics empty, iterates over all registered lyrics providers
- Calls
GET /lyric-search?title=...&artist=...on each plugin (15s timeout) - First plugin returning HTTP 200 + non-empty lyrics wins; iteration stops
- Found lyrics are cached to the database asynchronously (
lyric_source=scraped); subsequent requests return the cache directly - 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
// 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):
| Parameter | Type | Description |
|---|---|---|
title | string | Song title |
artist | string | Artist |
album | string | Album name |
fingerprint | string | Audio fingerprint (Chromaprint, optional, sent only when available) |
isrc | string | ISRC code (optional, sent only when available) |
Response format (HTTP 200 + 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
/// <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
- User plays a song with no cover; client requests
GET /api/v1/songs/{id}/cover - Host finds cover empty, iterates over all registered cover providers
- Calls
GET /cover-search?title=...&artist=...on each plugin (15s timeout) - First plugin returning HTTP 200 + non-empty
cover_urlwins; iteration stops - 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_urlin the database
- Local songs: downloads the cover image → saves to local
- 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
fingerprintandisrcfor 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:
| Permission | Description |
|---|---|
storage | Read/write the plugin's private persistent storage |
songs.read | Read song metadata |
songs.write | Modify/write song metadata |
songs.* | Song read/write wildcard (all-in-one sugar) |
playlists.read | Read playlists and the songs within them |
playlists.write | Create/modify/delete playlists and their songs |
playlists.* | Playlist read/write wildcard (all-in-one sugar) |
inter-plugin | Inter-plugin communication |
command | Execute external commands / manage executables |
jsenv | Create/run child JS sandbox environments |
fs | Read/write files within the plugin's data directory |
fs:music | Access the music_path music directory |
fs:external | Access administrator-configured external directories |
websocket | Use new WebSocket(...) to actively connect to external services, or handle inbound onWebSocket upgrades |
persistent-storage | Read/write persistent storage that remains after the plugin is uninstalled |
net | Use raw network sockets (UDP / outbound TCP) |
net:insecure-tls | Allow 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:
{
"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:
// 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:
// 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:
// 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 logicCommon assets (design tokens/reset/MD3 component styles, font files, the
common.jsAPI 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):
<base>— sets the relative path baseline, so relative paths in the HTML can directly referencestatic/...and the plugin API- Auth bridge script — writes the URL
?access_token=into localStorage, and automatically retries on a fetch 503 theme.css— MD3 color tokens (including light/dark dual themes), font declarations, CSS reset,bodybase styles, safe-area defaultscomponents.css— the shared component library aligned with the client's Flutter widgets (.card/.btn*/.switch/.tab-bar, etc.)webf-shims.css— WebF engine shim styles (only matched on the WebF rendering surface)common.js— embed detection, theme bridging, thewindow.SongloftPluginglobal APIwebf-shims.js— WebF engine capability shims (emptyimg src,<details>, slider, file picker, safe area, etc.; WebF only)
common.cssis retired and split intotheme.css+components.css; the WebF compatibility layer is extracted fromcommon.jsintowebf-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:
// 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:
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:
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()returnsfalseand calls throw, so always feature-detect first.- Capabilities are injected by the host client and track its version. Set an appropriate
minHostVersioninplugin.jsonand usehost.getInfo().capabilitiesfor capability negotiation.
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:
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:
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:
await SongloftPlugin.invokeHost('favorite', 'refresh', { songId: 42, isFavorited: true });⚠️ It lacks the type safety of a wrapper — a misspelled
ns/methodonly rejects at runtime. Prefer the wrapper whenever one exists.⚠️ Do not use
window.__SongloftInternal.invokeHost— that is an internal handle shared withwebf-shims.js, not a public API.
Cookie Reading Bridge — Retrieve Third-Party Site Sessions (Native Only)
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.
// 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:
| Parameter | Type | Description |
|---|---|---|
origin | string | Target 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:
| Platform | Supported | Notes |
|---|---|---|
| Android / iOS / macOS / Windows / Linux | ✅ | Native CookieManager reads WebView Cookie Store |
| Web | ❌ | Browser same-origin policy hard limit; calls will reject |
⚠️ Check platform before calling:
javascriptconst info = await SongloftPlugin.host.getInfo(); if (info.platform !== 'web') { const cookies = await SongloftPlugin.getCookies(origin); }
Typical workflow (FN Connect example):
- User adds a FN Music source, plugin constructs origin (e.g.
https://pcyear.5ddd.com) - Plugin guides user to open the target site in the app's WebView and log in
- After login, plugin calls
getCookies(origin)to retrieve session cookies - Stores cookies in plugin backend config (via
apiPostetc.), subsequent requests carry the session
TypeScript usage:
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:
/* 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:
- Update the
data-themeattribute and thetheme-light/theme-darkCSS classes on<html> - Rewrite
--md-*with the real color scheme pushed down by the host (see below) - Dispatch a
songloft-theme-changeCustomEvent - 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:
| Group | Variables |
|---|---|
| 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-variant→surfaceContainerHighest |
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-2aliases have been removed — use--md-surface-container/--md-surface-container-highdirectly.
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:
/* 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():
// 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 notvar()-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-changeis dispatched, so callinggetColorScheme()inside anonThemeChangecallback always yields the new values. Switching theme packs may leave light/dark unchanged while the colors change — onlysongloft-color-scheme-changefires 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:
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.
Starting from the Recommended Template
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):
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):
| File | Responsibility |
|---|---|
engine.js | Rendering-engine feature detection (useNativeUI / useNativeListView) — decides whether wrapper components use native elements or an HTML fallback |
layout.js | Open-mode detection (tab / fullscreen / browser) and measuring the usable height of <webf-list-view> |
ui/Sl*.vue | 7 form-control wrappers (button / icon / input / switch / checkbox / select / list) — business code is written once |
ui/native-props.js | Imperative binding of boolean properties (works around Vue's prop/attr heuristic for custom elements) |
ui/select-open-state.js | Shared "which dropdown is open" mutual-exclusion state (must live in a standalone module, see below) |
main.js / App.vue / style.css | Entry assertion, page skeleton, a WebF-safe subset of CSS |
⚠️ Any page-wide singleton state in
engine.js/layout.js/select-open-state.js/store.jsmust live in a standalone.jsmodule, not at the top level of a component's<script setup>— that block is compiled intosetup()and becomes one copy per component instance, silently breaking mutual-exclusion and singleton semantics.
Theme Style (Recommended Form)
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 realColorSchemedown at runtime as inline variables ondocumentElement, overriding the static fallback values. Write all colors asvar(--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.csscomponent 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 class Component Notes .cardCard SectionCard shape: surface-container background + outline-variant border + 16 radius .btn/.btn-filled/.btn-outlined/.btn-textButtons Match FilledButton / OutlinedButton / TextButton .switch/.switch-track/.switch-thumbSwitch Matches M3 Switch; track/thumb use var(--md-*).progress-linear/.progress-linear-barLinear progress bar .material-symbols-outlinedIcon font Pre-registered by the host via Flutter FontLoader, so glyphs render under WebF too The scaffolder's WebF template
SlButton/SlSwitch/SlIcondo 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'sgetComputedStylereturns an empty string for custom properties, sovar(--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, notenv(safe-area-inset-*)(WebF has noenv()); see Safe areas: use --sl-safe-*, never env(safe-area-inset-*).
Page Layout (Recommended Form)
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:
| # | Constraint | Reason |
|---|---|---|
| ① | List/cell text must be white-space: nowrap + ellipsis | Wrappable CJK text is measured as "one character per line" on several measurement paths |
| ② | Don't use position: sticky | Globally ineffective under WebF (page level too) |
| ③ | Don't use transform to move a position: fixed element | Positioning goes wrong |
| ④ | Hide elements with v-if, not display: none | The 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 width | Otherwise 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 container | Otherwise 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:autorow height and the sticky header. It has two hard constraints (list items must be direct children;shrink-wrapmust 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 acalc(100vh - constant)fallback for the first frame). WebF renders asynchronously, so the first frame may measure 0 — retry on the next frame. The template'slayout.js#measureListHeightis 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 theembedclass +SongloftPlugin.host.isAvailable(), wrapped in the template'slayout.js#detectMode. - Use an overlay for multi-level pages, not a
v-iffull-page swap: keep the main page mounted and make the settings page aposition:fixedfull-screen overlay (v-ifmounts 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:
- Draw your own back button — required on every platform; this is the primary path.
- Make the host's back key follow along — the Android hardware back key and the back arrow in the full-screen page's AppBar.
// 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;
});⚠️
onHostBackonly takes effect on WebF-rendered plugin pages (the host's registration point is gated onisWebFHost()). On the system WebView / iframe / browser paths the host uses each surface's owncontroller.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.pushStateto express in-page levels. WebF does not implement SPA history routing (the official answer is the native screen stack from@openwebf/*-router). After apushState,history.length > 1makes the host's fallback check report "consumed", but WebF never firespopstate— 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.
<songloft-progress-ring value="30" max="100" stroke-width="5"
style="width:48px;height:48px"></songloft-progress-ring>// Updating progress means setting an attribute; there is no other API
document.querySelector('songloft-progress-ring').setAttribute('value', '65');Attributes:
| Attribute | Default | Description |
|---|---|---|
value | 0 | Current progress value |
min | 0 | Lower bound |
max | 100 | Upper bound. max <= min is a degenerate range and is treated as 0 (track only) |
stroke-width | 4 | Stroke width in px, clamped to (0, shorter side / 2] |
color | value of CSS color | Progress arc color; accepts concrete color values only (see below) |
track-color | progress color at 24% opacity | Track color |
line-cap | butt | Set to round for rounded caps |
- Size comes from CSS
width/height(36×36 when unspecified);displaydefaults toinline-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 negativestroke-widthis 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:
/* ① 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);
}<!-- ② 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 CSScolorif 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. UseSongloftPlugin.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 insidecommon.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, …), callSongloftPlugin.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:
.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:
- Scan every
input[type="range"]on the page and insert a<songloft-slider>after each input; - Hide the original
<input>(add the.sl-range-hiddenclass plus inlinedisplay:none) instead of removing it; - Keep the two in sync, both ways.
Your existing plugin JS therefore needs no changes at all:
- Reading and writing
el.valueworks 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 / falseworks as before (the slider dims and stops responding to gestures)el.addEventListener('input' / 'change', ...)works as before (slider interaction dispatches bubblinginput/changeevents on the original input)el.matches(':active')works as before (returnstruewhile 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:activeunder WebF, so the shim also shadowsmatches
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:
| Attribute | Default | Description |
|---|---|---|
value | min | Current value |
min | 0 | Lower bound |
max | 100 | Upper bound. max <= min is a degenerate range: the thumb stays at the start and drags are ignored |
step | 1 | Step size. any or <= 0 means continuous |
orientation | horizontal | Set to vertical for a vertical slider (min at the bottom, max at the top) |
disabled | absent | Present means disabled (false / 0 are the exception and count as enabled). When disabled the whole element is drawn at 38% opacity and ignores gestures |
color | value of CSS color | Color of the filled track and the thumb; accepts concrete color values only |
track-color | fill color at 24% opacity | Color of the unfilled track |
- Size comes from CSS
width/height; when unspecified it falls back per orientation to 160×28 horizontal / 28×160 vertical, anddisplaydefaults toinline-block - Events: dragging and tapping the track (a tap moves the thumb to the tap position, same as a browser) both dispatch
input; releasing dispatcheschange. The new value is carried inevent.data(a string; integers have no.0) - The element does not write back its own
valueattribute during interaction — the page owns the truth. So when you use the element directly, read the value fromevent.dataand do not readgetAttribute('value')(that is only whatever you last pushed into it) min/max/stepmust be written as attributes: WebF does not implement property reflection for these three, soel.minreads 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>:
<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:
<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)):
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 viahtml.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():
| Variable | Meaning |
|---|---|
--sl-safe-top | Top inset (status bar / notch) |
--sl-safe-right | Right inset (landscape notch / rounded corner) |
--sl-safe-bottom | Bottom inset (home gesture bar) |
--sl-safe-left | Left inset |
/* 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:
| Runtime | Value 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 client | The 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 to0under WebF — the fallback chain dies atenv(), and evenenv()'s own inner fallback (the19pxinenv(safe-area-inset-bottom, 19px)) is unreachable. So it is not a "safer" spelling; it merely throws away the variable's default valueWebF 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", replacemax(24px, ...)withclamp():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 acceptsvar()in its arguments (verified), but avoid every other CSS math function outsidecalc()(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:
- Scans every
input[type="file"]on the page and hides it (adds the.sl-file-hiddenclass plus inlinedisplay:none); - Intercepts its
clickevent and overrides its instanceclick()method — so the common "hidden input + external button callingfileInput.click()" pattern still opens the picker; - Opens the host's native file picker and sends the chosen files back to the page over the bridge;
- Writes the result into
SongloftPlugin.lastPickedFiles, then dispatches a bubblingchangeon 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
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/FileListare all unusable under WebF: the latter two do not exist at all (measured:typeofisundefinedfor both), which is why the host deliberately does not fakeinput.files— a fakeFileis useless without a realFileReader, and there is no realFileReader. Code that reads files withnew FileReader()throws outright under WebF.- The
changeevent also carries a best-effortevent.data = {files: [...]}, but that is a bonus only: WebF'sEventis 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
changeis dispatched when the user cancels (same as in a browser), so you need not worry about an emptychangefalling into your "read failed" branch and showing an error for something the user did not do wrong. lastPickedFilesis a single global value (nullbefore the first pick); with several file inputs on a page it holds the most recent result — read it immediately inside thechangehandler. If the host call fails, the shim only logs aconsole.warnand dispatches nochange.
Payload shape: data-sl-file-as
<!-- metadata only, do not read the contents -->
<input type="file" id="pick" accept=".m3u,.m3u8,.json" data-sl-file-as="none">| Value | Payload | When to use it |
|---|---|---|
text (default) | text string + encoding (+ possibly textLossy) | Importing text such as m3u / json / lrc |
bytes | bytesBase64 (a base64 string) | Binary files, or when you need to decode a legacy encoding (e.g. GBK) yourself |
none | name / size only | You 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:
| Field | Present when | Meaning |
|---|---|---|
name | Always | File name (no path) |
size | Always | Size in bytes |
text | as=text and the read succeeded | The decoded string (BOM stripped) |
encoding | Same as above | utf-8 / utf-8-lossy / utf-16le / utf-16be |
textLossy | true when decoding was lossy | The 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 |
bytesBase64 | as=bytes and the read succeeded | base64 |
error | On read failure | too_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.
acceptis passed through verbatim, but only the.extextension 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).multipleenables 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
changeevents, 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:
<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:
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):
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:
// ❌ 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 CSSurl()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.
window.open: external links now open in the system browser (no plugin changes needed)
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:
| Target | Behavior |
|---|---|
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 |
// 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-windowpostMessage) 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 throughonHostBack(nothistory.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 (gridautorow 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:
.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;
}<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'sCSSDisplayenum has no table value at all, andresolveDisplayfalls through todefault, returninginline— which is worse than the defaultblock, 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 sizesautogrid 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. Undernowrap, 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 atitleattribute for desktop hover (always build attributes with a quote-escaping helper; atextContent → innerHTMLstyleesc()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: stickydoes not work at all under WebF, and not only in grid — put a plain div at the top ofbodyand scroll the page itself (documentElement.scrollTop = 300) and it still scrolls away by the full amount (y = -300), while computedpositionis still"sticky",topis still"0px", andscrollevents 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: stickychildren 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 onegrid-template-columns.
- A related source-level fact that still holds (it is why the header must be its own grid container): WebF's grid layout lumps
- 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 withpadding-righton the header. Two notes: ① measure across a frame — WebF's layout is asynchronous, so reading right after assigninginnerHTMLgives you the previous layout; wrap it in asetTimeout; ② if the measurement fails, treat it as 0, which is exactly the right answer for overlay scrollbars. Addingscrollbar-gutter: stableto 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-contentin the track definition. Use only fixedpxandminmax(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 adisplay:noneelement still attaches a zero-sized box and still consumes a grid cell, shifting every following cell by one position. Give.tblamin-widthand 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):
| Category | Tags |
|---|---|
| Buttons | flutter-cupertino-button |
| Input | flutter-cupertino-input, flutter-cupertino-search-text-field |
| Toggles & selection | flutter-cupertino-switch, flutter-cupertino-checkbox, flutter-cupertino-radio, flutter-cupertino-slider, flutter-cupertino-sliding-segmented-control |
| Lists & forms | flutter-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 |
| Overlays | flutter-cupertino-alert, flutter-cupertino-action-sheet, flutter-cupertino-modal-popup, flutter-cupertino-context-menu |
| Navigation | flutter-cupertino-tab-scaffold, flutter-cupertino-tab-bar, flutter-cupertino-tab-view |
| Other | flutter-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'sHTMLSelectElementonly exposesvalue/selectedIndex/disabled/multiple/required— there is nooptions. Vue'sv-modelon a<select>resolves to thevModelSelectdirective, whose entire implementation is built onel.options(its change listener isArray.prototype.filter.call(el.options, o => o.selected)), sofilter.call(undefined, …)throws a TypeError — any framework's two-way binding on<select>hits this. Bypassingv-modelwith an explicit@changethat readsel.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 ownselectedIndexfirst and dispatcheschangeafterwards, 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.
<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 (readaction_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 dispatchesCustomEvent('select', detail: {text, event, isDefault, isDestructive, index}), whereindexis the position withinactions,cancelButtoncarries noindex, and dismissing via the scrim dispatches nothing. The risk: the host element builds aSizedBox.shrink(), andshow()is implemented asstate?._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 withdisplay: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 becauseicon.dartreturnsSizedBox.shrink()for atypeit 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 thatwebf_cupertino_uidoes not depend oncupertino_icons, so the host app has to declare that dependency itself (the Songloft client now does; see item 15 in the parent repo'sdocs/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:
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:
<!-- 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><!-- 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:
attributes['active-color'] = ElementAttributeProperty(...); // HTML attribute
'activeColor': StaticDefinedBindingProperty(...) // JS propertyWriting 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
valback to an already-mounted input goes through_Editable.updateRenderObject→RenderEditable.text=→TextPainter.markNeedsLayout; if the mouse is resting on the plugin page that same frame,MouseTracker.updateAllDeviceshit-tests that not-yet-relaidRenderEditable,getClosestGlyphForOffsettrips theText layout not availableassertion, the exception leaves_debugDuringDeviceUpdatestuck true, and from then on every frame spams the!_debugDuringDeviceUpdateassertion frommouse_tracker.dartuntil 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
valits initial value exactly once at mount, then only readinputevents and never write back. When an external change really must update the visible value (normalization, clearing), change the element's:keyso it remounts — the new value then arrives via mount instead of update. Downloader'sui/SlInput.vueis a complete implementation of this pattern. Switching to WebF's own HTML<input>does not help: it is also backed by a FlutterTextField(webf/lib/src/html/form/base_input.dart) — the sameRenderEditable.
③ Boolean attributes have two entry points with different semantics, and frameworks choose heuristically.
The same checked:
// 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.
// 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.
<!-- ✗ 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'sbutton.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
| Element | Event | detail |
|---|---|---|
switch / checkbox | change | boolean |
input / search-text-field | input, submit | string |
input | focus / blur / clear | none |
button | click | none (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
bluris not deduplicated, so "blur == the user finished editing a value" does not hold. Upstream does_focusNode.addListener(() { hasFocus ? dispatch('focus') : dispatch('blur') })(ininput.dart'sinitState) — it never remembers the previous focus state, so any notification the FocusNode emits while unfocused dispatches anotherblur. 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:
jslet 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
-4the user typed while the request body normalizes it to0, the two never match and the guard degrades into "submit every time".
Two hard constraints on <webf-list-view>
<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-wrapdefaults totrue, and you almost always needfalse. 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 triggersInfinity 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:
- Layer 1 — ZIP Hash: the SHA256 of the entire ZIP file
- 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
# 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.htmlFile Naming
ZIP file name format: {entryPath}.jsplugin.zip
The system extracts the entryPath from the file name: my-plugin.jsplugin.zip → my-plugin
Installation Methods
- Development mode (recommended): iterate locally with
songloft-plugin dev, see §2.6 - UI upload: upload the ZIP via the Songloft client's settings page → plugin management
- Directory placement: drop the ZIP into the server's
data/jsplugins/directory, and it is discovered automatically when the service starts - API upload:
POST /api/v1/jsplugins/upload, multipart field namefile(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/uploadendpoint handles both fresh installs and overwrite updates, which the backend distinguishes via response status code201/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 processingAutomatic 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 devrunning and just save the source; - Operations: re-upload a ZIP with the same
entryPath(POST /api/v1/jsplugins/upload) or callPUT /api/v1/jsplugins/{id}; the backend automatically triggers a hot reload for plugins in theactivestate after a successful update; - Remote update: call
POST /api/v1/jsplugins/{id}/updateto pull the new version fromupdateUrl, 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
- Avoid long-running blocking —
onHTTPRequestshould return quickly - Use timers wisely — timer callbacks execute in a separate thread and do not block HTTP requests. However, network operations such as
fetchinside a callback still hold the VM lock, so avoid running multiple serial network requests in a single callback - Cache computed results — use
songloft.storageto cache frequently accessed data - Control response body size — avoid returning overly large JSON responses
- Timer intervals — we recommend a
setIntervalinterval of no less than 1 second; the system checks for due timers every 500ms
Error Handling
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
updateUrlinplugin.jsonto support remote update checks - Bump the major version number on breaking changes
Development and Debugging
- Check the output prefixed with
[plugin]in the server logs - Use
songloft.log.info/warn/errorto output debug information - Health check failures are recorded in the logs
Storage Usage Pattern
// 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
// 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.
