Skip to content
18 changes: 14 additions & 4 deletions _lib/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,18 @@ export const HOOK_TARGET_TYPES = ["files"] as const;

// --- v6 widget contract ---

/** Supported widgetsSchema version. */
export const SUPPORTED_WIDGETS_SCHEMA = 2;
/**
* Supported widgetsSchema version. In 3 a widget is the app's dashboard card,
* so its `slots` and `background` sit on the declaration itself; the `sizes`
* of 2 are gone.
*/
export const SUPPORTED_WIDGETS_SCHEMA = 3;

/** Widget slot types. */
export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "image"] as const;
/** Widget slot types. An image is never a slot: the card's artwork is its background. */
export const WIDGET_SLOT_TYPES = ["text", "stat", "list", "progress"] as const;

/** The shortest widget `refresh` the platform honours, in seconds: one deck poll (MIN_REFRESH_S). */
export const WIDGET_MIN_REFRESH_S = 5;

/** Most slots a widget declares. */
export const WIDGET_MAX_SLOTS = 3;
83 changes: 46 additions & 37 deletions _lib/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
SPEC_REGEX,
SUPPORTED_VERSIONS,
SUPPORTED_WIDGETS_SCHEMA,
WIDGET_MAX_SLOTS,
WIDGET_MIN_REFRESH_S,
WIDGET_SLOT_TYPES,
} from "./contract";

Expand Down Expand Up @@ -550,8 +552,11 @@ function validate(file: string, script: Record<string, unknown>) {
if (widget.refresh !== undefined) {
if (typeof widget.refresh !== "number" || !Number.isInteger(widget.refresh) || widget.refresh <= 0) {
err(file, `${where}: refresh must be a positive integer`);
} else if (widget.refresh < 10) {
warn(file, `${where}: refresh ${widget.refresh} is below the 10s floor — the platform clamps it to 10`);
} else if (widget.refresh < WIDGET_MIN_REFRESH_S) {
warn(
file,
`${where}: refresh ${widget.refresh} is below the ${WIDGET_MIN_REFRESH_S}s floor; the platform clamps it to ${WIDGET_MIN_REFRESH_S}`,
);
}
}
const hasFile = typeof widget.script === "string" && widget.script.trim().length > 0;
Expand All @@ -578,42 +583,46 @@ function validate(file: string, script: Record<string, unknown>) {
}
}
}
// widgetsSchema 3 made the widget the app's card: no sizes, one layout.
if (widget.sizes !== undefined) {
if (!isObject(widget.sizes)) {
err(file, `${where}: sizes must be an object`);
} else {
for (const sizeKey of ["small", "large"] as const) {
const size = widget.sizes[sizeKey];
if (size === undefined) continue;
if (!isObject(size)) {
err(file, `${where}.sizes.${sizeKey}: must be an object`);
continue;
}
if (size.media !== undefined) {
const placements = sizeKey === "small" ? ["top", "bottom"] : ["left", "right", "both"];
if (!isObject(size.media) || typeof size.media.placement !== "string" || !placements.includes(size.media.placement)) {
err(file, `${where}.sizes.${sizeKey}.media: placement must be one of ${placements.join("/")}`);
} else if (typeof size.media.field !== "string" || !size.media.field.trim()) {
err(file, `${where}.sizes.${sizeKey}.media: field is missing`);
}
}
const slots = size.slots;
if (!Array.isArray(slots) || slots.length === 0) {
err(file, `${where}.sizes.${sizeKey}: slots must be a non-empty array`);
} else {
const maxSlots = sizeKey === "small" ? 3 : 4;
if (slots.length > maxSlots) {
err(file, `${where}.sizes.${sizeKey}: max ${maxSlots} slots`);
}
for (const [k, slot] of slots.entries()) {
if (!isObject(slot)) continue;
if (typeof slot.type !== "string" || !(WIDGET_SLOT_TYPES as readonly string[]).includes(slot.type)) {
err(file, `${where}.sizes.${sizeKey}.slots[${k}]: type "${String(slot.type)}" is not known`);
}
if (typeof slot.field !== "string" || !slot.field.trim()) {
err(file, `${where}.sizes.${sizeKey}.slots[${k}]: field is missing`);
}
}
err(file, `${where}: sizes is gone in widgetsSchema 3; put slots and background on the widget itself`);
}
if (widget.media !== undefined) {
err(file, `${where}: has no media slot; name an image field in background instead`);
}
if (
widget.background !== undefined &&
(!isObject(widget.background) ||
typeof widget.background.field !== "string" ||
!widget.background.field.trim())
) {
err(file, `${where}.background: field is missing`);
}
const slots = widget.slots;
if (!Array.isArray(slots) || slots.length === 0) {
err(file, `${where}: slots must be a non-empty array`);
} else {
if (slots.length > WIDGET_MAX_SLOTS) {
err(file, `${where}: max ${WIDGET_MAX_SLOTS} slots`);
}
for (const [k, slot] of slots.entries()) {
if (!isObject(slot)) {
err(file, `${where}.slots[${k}]: must be an object`);
continue;
}
if (slot.type === "image") {
err(file, `${where}.slots[${k}]: an image is not a slot; name the image field in background instead`);
} else if (typeof slot.type !== "string" || !(WIDGET_SLOT_TYPES as readonly string[]).includes(slot.type)) {
err(file, `${where}.slots[${k}]: type "${String(slot.type)}" is not known`);
}
if (typeof slot.field !== "string" || !slot.field.trim()) {
err(file, `${where}.slots[${k}]: field is missing`);
}
if (slot.rows !== undefined) {
if (slot.type !== "list") {
err(file, `${where}.slots[${k}]: rows applies to a list slot only`);
} else if (typeof slot.rows !== "number" || ![1, 2, 3].includes(slot.rows)) {
err(file, `${where}.slots[${k}]: rows must be 1, 2 or 3`);
}
}
}
Expand Down
45 changes: 32 additions & 13 deletions _lib/widget_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ export interface WidgetContext {
export interface WidgetListEntry {
title: string;
subtitle?: string;
/**
* Lines that take turns under the entry (up to 4, each at most 80 chars).
* The featured form steps through them at twice the pace of its entries,
* starting over with each entry. `subtitle` stays the floor for renderers
* that ignore this, so repeat the first line there.
*/
subtitles?: string[];
meta?: string;
/**
* A duration in steady motion — capable renderers advance it in real
Expand All @@ -36,32 +43,44 @@ export interface WidgetListEntry {
*/
elapsed?: { ms: number; ofMs?: number; state?: "running" | "paused" };
/**
* Optional thumbnail as a size-capped (60KB) data URI. Data URIs ONLY —
* fetch and inline server-side; never emit app URLs (key leak,
* mixed-content, off-LAN breakage).
* How far along this entry is, 0 to 100, for a quantity that is not time
* in motion (a download, a conversion). Capable renderers draw a bar; the
* text floor (meta) must still carry the figure.
*/
progress?: number;
/**
* The entry's artwork as a size-capped (60KB) data URI. When the widget's
* background names this list, the card draws the image of the entry it has
* on screen, so fetch a wide crop near the card's shape (about 660x260).
* Data URIs ONLY: fetch and inline server-side; never emit app URLs (key
* leak, mixed-content, off-LAN breakage).
*/
image?: string;
}

/**
* One named field of the result document (widgetsSchema 2). The
* declaration's `sizes` slots reference these fields by name; a standalone
* image field is the media slot's source. Max 16 fields; images are
* One named field of the result document (widgetsSchema 3). The
* declaration's `slots` reference these fields by name; the card's background
* names a standalone image field, or a list whose entries carry images (the
* card then draws the image of the entry on screen). An image is never a slot.
* Max 16 fields; images are
* size-capped (60KB) data URIs, text is capped at 500 chars.
*/
export type WidgetFieldValue =
| { type: "text"; text: string }
| { type: "stat"; label: string; value: string }
| { type: "list"; entries: WidgetListEntry[] }
| { type: "image"; image: string; alt?: string };
| { type: "image"; image: string; alt?: string }
/** A bounded quantity: `value` is 0 to 100, `text` the caption beside the label ("3.2 GB of 5 GB", max 60 chars). */
| { type: "progress"; label: string; value: number; text?: string };

/**
* What a widget script returns: named fields the platform's size templates
* project from — one widget, ONE query; sizes never add polls. Return
* `needsSetup` (with a human reason) when the data source isn't usable
* yet. Harvest credentials first — needs-setup is the fallback, not the
* default. Field names must be STABLE (sizes reference them); dynamic
* collections (users, library sections) ride as `list` entries.
* What a widget script returns: named fields the card's slots project
* from. One widget is ONE query. Return `needsSetup` (with a human reason)
* when the data source isn't usable yet. Harvest credentials first:
* needs-setup is the fallback, not the default. Field names must be STABLE
* (slots reference them); dynamic collections (users, library sections)
* ride as `list` entries.
*/
export interface WidgetQueryResult {
needsSetup?: boolean;
Expand Down
31 changes: 10 additions & 21 deletions fileflows.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
"retries": 0
}
],
"widgetsSchema": 2,
"widgetsSchema": 3,
"widgets": [
{
"id": "conversions",
Expand All @@ -152,28 +152,17 @@
"app": "fileflows"
}
],
"sizes": {
"small": {
"slots": [
{
"type": "stat",
"field": "active"
}
]
"slots": [
{
"type": "stat",
"field": "active"
},
"large": {
"slots": [
{
"type": "stat",
"field": "active"
},
{
"type": "list",
"field": "files"
}
]
{
"type": "list",
"field": "files",
"rows": 1
}
},
],
"script": "fileflows/widget_conversions.ts",
"entrypoint": "run",
"timeout": 10
Expand Down
3 changes: 3 additions & 0 deletions fileflows/widget_conversions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,10 @@ export async function run(ctx: WidgetContext): Promise<WidgetQueryResult> {
return {
title: basename(file.name ?? "Unknown file"),
subtitle: conversion || undefined,
// Text floor: the figure any renderer can show as-is.
meta: meta || undefined,
// Enrichment: capable renderers draw this as a bar.
progress: percent !== undefined ? Math.min(Math.max(percent, 0), 100) : undefined,
};
}),
},
Expand Down
19 changes: 7 additions & 12 deletions hexos-test.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@
"retries": 0
}
],
"widgetsSchema": 2,
"widgetsSchema": 3,
"widgets": [
{
"id": "harness-stats",
Expand All @@ -307,16 +307,11 @@
"scriptContent": "export async function run(ctx) {\n return {\n fields: {\n headline: { type: \"text\", text: \"Harness widget\" },\n streams: { type: \"stat\", label: \"Streams\", value: \"3\" },\n items: { type: \"list\", entries: [{ title: \"First\" }, { title: \"Second\" }] },\n },\n };\n}\n",
"timeout": 10,
"buttons": ["harness-widget-button"],
"sizes": {
"small": { "slots": [{ "type": "stat", "field": "streams" }] },
"large": {
"slots": [
{ "type": "text", "field": "headline" },
{ "type": "stat", "field": "streams" },
{ "type": "list", "field": "items" }
]
}
}
"slots": [
{ "type": "text", "field": "headline" },
{ "type": "stat", "field": "streams" },
{ "type": "list", "field": "items" }
]
},
{
"id": "harness-needs-setup",
Expand All @@ -326,7 +321,7 @@
"entrypoint": "run",
"scriptContent": "export async function run(ctx) {\n return { needsSetup: true, reason: \"Harness widget needs setup\" };\n}\n",
"timeout": 10,
"sizes": { "small": { "slots": [{ "type": "text", "field": "headline" }] } }
"slots": [{ "type": "text", "field": "headline" }]
}
]
}
41 changes: 36 additions & 5 deletions jellyfin.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"version": 5,
"version": 6,
"script": {
"version": "2.0.0",
"changeLog": "V5 with post-install hook for automated Jellyfin configuration"
"version": "2.1.0",
"changeLog": "Added the now-playing widget and moved to the v6 install script format"
},
"requirements": {
"locations": ["ApplicationsPerformance", "ApplicationsCapacity", "Media", "Photos", "Music", "Movies", "Shows", "Videos"],
Expand Down Expand Up @@ -49,15 +49,17 @@
"hooks": [
{
"id": "configure-jellyfin",
"event": "onAfterInstall",
"title": "Pre-configure Jellyfin",
"events": [
"onAfterInstall"
],
"script": "jellyfin/jellyfin_hook.ts",
"entrypoint": "afterInstall",
"timeout": 300,
"description": "Setting up Jellyfin",
"optional": false,
"retries": 0,
"userOptional": {
"label": "Pre-configure Jellyfin",
"description": "Automatically set up your server, apply preferences, and create media libraries. By enabling this, you acknowledge Jellyfin's media server policy.",
"default": true,
"link": {
Expand Down Expand Up @@ -103,5 +105,34 @@
}
]
}
],
"widgetsSchema": 3,
"widgets": [
{
"id": "now-playing",
"title": "Now playing",
"description": "Active Jellyfin streams right now.",
"refresh": 5,
"conditions": [
{
"role": "availability",
"type": "appRunning",
"app": "jellyfin"
}
],
"background": {
"field": "sessions"
},
"slots": [
{
"type": "list",
"field": "sessions",
"rows": 1
}
],
"script": "jellyfin/widget_now_playing.ts",
"entrypoint": "run",
"timeout": 10
}
]
}
Loading
Loading