diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..a6db35e --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,32 @@ +# Install scripts are executed on users' machines, and a script that is merely +# valid JSON can still abort an install — an unknown $LOCATION throws, a +# $QUESTION with no question behind it resolves to nothing, an icon path that +# is not in the repo 404s in the catalog UI. This runs on GitHub-hosted runners +# with no secrets, so it is safe on pull_request from forks. +name: Validate install scripts + +on: + pull_request: + push: + branches: [staging, prod] + +jobs: + validate: + runs-on: ubuntu-latest + # Seconds of work; anything approaching this is a hang, not a slow run. + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + - name: Install tooling dependencies + run: bun install + working-directory: _lib + # Typecheck first, deliberately: bun executes TypeScript by stripping + # types, so a type error in _lib never stops the validator from running — + # it just makes its verdict untrustworthy. Checking the checker before + # believing it keeps a broken validator from reporting a green catalog. + - name: Typecheck the tooling + run: bun run typecheck + working-directory: _lib + - name: Validate install scripts + run: bun _lib/validate.ts diff --git a/.gitignore b/.gitignore index a3d83c0..b4057de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ __pycache__/ *.pyc -*.DS_Store \ No newline at end of file +*.DS_Store +node_modules/ diff --git a/README.md b/README.md index 686503b..c93e7c5 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,25 @@ Install scripts are JSON configurations that automate app deployment on HexOS For schema documentation, macros reference, and contribution guidelines, see [docs.hexos.com](https://docs.hexos.com). +## Requirement specifications + +`requirements.specifications` values are parametric — any sensible number works without a platform change. The accepted patterns: + +| Pattern | Meaning | Examples | +|---------|---------|----------| +| `{n}CORE` | Minimum CPU cores | `2CORE`, `4CORE`, `6CORE` | +| `{n}MB` / `{n}GB` | Approximate storage needed for installation | `256MB`, `1GB`, `30GB` | +| `{n}MBRAM` / `{n}GBRAM` | Minimum memory | `512MBRAM`, `8GBRAM` | +| `GPU` | GPU recommended | `GPU` | + +Every value must match `^(\d+)(MBRAM|GBRAM|MB|GB|CORE)$` or be the literal `GPU` — anything else fails the catalog sync for that app. Use the largest natural unit (`1GB`, not `1024MB`; `8GBRAM`, not `8192MBRAM`) so the rendered text reads cleanly. + +```json +"requirements": { + "specifications": ["4CORE", "30GB", "16GBRAM"] +} +``` + ## Contributing 1. Fork this repository diff --git a/_lib/bun.lock b/_lib/bun.lock new file mode 100644 index 0000000..b745539 --- /dev/null +++ b/_lib/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "hexos-app-catalog-lib", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.6.0", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/_lib/contract.ts b/_lib/contract.ts new file mode 100644 index 0000000..7b0dbb8 --- /dev/null +++ b/_lib/contract.ts @@ -0,0 +1,115 @@ +// Hand-synced mirror of the install-script contract in hexos-platform +// (packages/shared/eshtek/apps.ts, packages/shared/eshtek/preferences.ts, +// packages/shared/eshtek/hooks.ts, packages/backend/src/lib/macros.ts). +// +// The catalog is public and the platform is private, so CI cannot import the +// real types. Everything the validator enforces is duplicated here instead, in +// one place, so a drift fix is a single edit rather than a hunt through checks. + +/** LocationPreferenceId — the only names $LOCATION() can resolve. */ +export const LOCATIONS = [ + "ApplicationsPerformance", + "ApplicationsCapacity", + "Downloads", + "Documents", + "Media", + "Photos", + "Music", + "Movies", + "Shows", + "Videos", + "VirtualizationPerformance", + "VirtualizationCapacity", + "InstallMedia", + "VirtualDisks", +] as const; + +/** APP_SPEC_REGEX. */ +export const SPEC_REGEX = /^(\d+)(MBRAM|GBRAM|MB|GB|CORE)$|^GPU$/; + +/** AppPermission. */ +export const PERMISSIONS = ["READ_WRITE_LOCATIONS"] as const; + +/** InstallationQuestionType. */ +export const QUESTION_TYPES = ["text", "number", "select", "boolean", "password"] as const; + +/** HookEvent. */ +export const HOOK_EVENTS = [ + "onBeforeInstall", + "onAfterInstall", + "onBeforeUpgrade", + "onAfterUpgrade", + "action", +] as const; + +/** Install-script versions the backend still parses (appsInstallScriptSchema). */ +export const SUPPORTED_VERSIONS = [4, 5, 6] as const; + +/** Macros taking parenthesised arguments. */ +export const CALL_MACROS = [ + "IF", + "QUESTION", + "MEMORY", + "RANDOM_STRING", + "HOST_PATH", + "MOUNTED_HOST_PATH", + "LOCATION", + "APP_INSTALLED", + "GPU_CONFIG", +] as const; + +/** Macros substituted as bare tokens, with no argument list. */ +export const BARE_MACROS = ["SERVER_LAN_IP", "SERVER_HOST_ID"] as const; + +// --- v6 hook contract --- + +/** HookKind — descriptive taxonomy (surfaces, icons, grouping). */ +export const HOOK_KINDS = ["connect", "maintain", "custom"] as const; + +/** HookEventSpec — what goes in v6 hooks' `events` array. */ +export const HOOK_EVENT_SPECS = [ + "userAction", + "onBeforeInstall", + "onAfterInstall", + "onBeforeUpgrade", + "onAfterUpgrade", +] as const; + +/** HookRerun — re-run semantics for user-triggerable hooks. */ +export const HOOK_RERUNS = ["idempotent", "converge", "refuse"] as const; + +/** HookSurface — where a hook can appear. */ +export const HOOK_SURFACES = ["installPicker", "card", "fileBrowser", "widget"] as const; + +/** Known hook condition types. Unknown types are accepted (fail closed at runtime). */ +export const HOOK_CONDITION_TYPES = [ + "appInstalled", + "appRunning", + "appVersion", + "capabilityPresent", + "script", +] as const; + +/** HookConditionRole. */ +export const HOOK_CONDITION_ROLES = ["visibility", "availability"] as const; + +/** Hook target types. */ +export const HOOK_TARGET_TYPES = ["files"] as const; + +// --- v6 widget contract --- + +/** + * 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. 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; diff --git a/_lib/hook_context.ts b/_lib/hook_context.ts index 059abd9..7708fdd 100644 --- a/_lib/hook_context.ts +++ b/_lib/hook_context.ts @@ -1,6 +1,10 @@ /** * HookContext — the runtime context passed to every hook entrypoint. * + * MIRROR: this interface is a hand-maintained copy of the public surface of + * hexos-platform/packages/backend/src/interface/hooks.ts (class HookContext). + * Update BOTH files when the contract grows — nothing checks this for you. + * * The actual class is provided by hexos-platform at execution time. * Hook scripts import this interface for autocompletion and type safety. * @@ -15,6 +19,27 @@ * } * ``` */ +/** + * One mount entry from the app's live config — the same shape every script + * surface receives (hooks, actions, widgets). + */ +export interface ContextMount { + /** Host path (/mnt/...). */ + hostPath: string; + /** + * The same directory as the app sees it inside its container — null for + * chart-style primary storage (config/data/logs) whose container path the + * chart fixes internally. Match those by hostPath instead. + */ + containerPath: string | null; + /** + * The same directory as THIS script can read it (the local node's host + * bind-mount). Use for harvesting app config files, e.g. Plex's + * Preferences.xml — no more hand-walking /host/mnt. + */ + localPath: string; +} + export interface HookContext { /** "app" or "vm" */ readonly resourceType: string; @@ -41,6 +66,31 @@ export interface HookContext { */ readonly inputs: Record; + /** + * File-targeted actions only: the files the user selected. `containerPath` + * is the same file as THIS app sees it (pre-resolved from its mounts), or + * null when the file isn't under any of the app's mounts — scripts for + * requiresTargetMount:false actions must handle null (e.g. deliver the + * file themselves via the platform mounts). + */ + readonly target?: { + type: "files"; + files: Array<{ + path: string; + containerPath: string | null; + name: string; + extension: string; + size?: number; + }>; + }; + + /** + * The app's live mounts, pre-resolved (empty when the app doesn't exist + * yet, e.g. onBeforeInstall, or mount discovery failed — scripts must + * tolerate an empty list). + */ + readonly mounts: ContextMount[]; + /** * Type-safe input accessor. Throws if the input is missing. * @@ -67,6 +117,13 @@ export interface HookContext { * ]); * ``` */ + /** + * Base URL of another installed app (e.g. "http://192.168.1.50:5055"), + * or null when it isn't installed or has no known port. Available to app + * actions; lifecycle hooks may receive null depending on platform version. + */ + getInstalledAppUrl(appId: string): Promise; + registerCheckpoints(checkpoints: Array<{ id: string; message: string }>): Promise; /** @@ -94,6 +151,21 @@ export interface HookContext { */ updateCheckpointMessage(id: string, message: string): Promise; + /** + * Drive the task's progress bar directly (clamped 0-99) without touching + * checkpoints. For long-running work whose real progress the script can + * measure — e.g. frames encoded out of a known total. A later + * emitCheckpoint WITHOUT an explicit progress reverts the bar to + * checkpoint-fraction math, so pass explicit progress on the final + * checkpoint when using this. + * + * @example + * ```ts + * await ctx.setProgress(42); + * ``` + */ + setProgress(percent: number): Promise; + /** * Fail the hook with a structured error. The message is shown as the primary error, * and context items are rendered as labeled key-value pairs in the UI. diff --git a/_lib/package.json b/_lib/package.json new file mode 100644 index 0000000..8e4766e --- /dev/null +++ b/_lib/package.json @@ -0,0 +1,13 @@ +{ + "name": "hexos-app-catalog-lib", + "private": true, + "type": "module", + "scripts": { + "validate": "cd .. && bun _lib/validate.ts", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.6.0" + } +} diff --git a/_lib/tsconfig.json b/_lib/tsconfig.json new file mode 100644 index 0000000..3163f67 --- /dev/null +++ b/_lib/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "bundler", + "types": ["bun"], + "strict": true, + "noUncheckedIndexedAccess": false, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["*.ts"] +} diff --git a/_lib/validate.ts b/_lib/validate.ts new file mode 100644 index 0000000..0980f48 --- /dev/null +++ b/_lib/validate.ts @@ -0,0 +1,726 @@ +// Catalog validator. Every check here corresponds to a way an install script +// can be accepted by GitHub and still fail on a user's box: unparseable JSON, +// a version the backend no longer understands, a $LOCATION the resolver will +// throw on, a $QUESTION with no question behind it, an icon path that 404s. +// +// Run: bun run validate + +import { readdirSync, existsSync, statSync } from "node:fs"; +import { + BARE_MACROS, + CALL_MACROS, + HOOK_CONDITION_ROLES, + HOOK_CONDITION_TYPES, + HOOK_EVENT_SPECS, + HOOK_EVENTS, + HOOK_KINDS, + HOOK_RERUNS, + HOOK_SURFACES, + HOOK_TARGET_TYPES, + LOCATIONS, + PERMISSIONS, + QUESTION_TYPES, + SPEC_REGEX, + SUPPORTED_VERSIONS, + SUPPORTED_WIDGETS_SCHEMA, + WIDGET_MAX_SLOTS, + WIDGET_MIN_REFRESH_S, + WIDGET_SLOT_TYPES, +} from "./contract"; + +type Problem = { file: string; message: string }; + +const errors: Problem[] = []; +const warnings: Problem[] = []; + +const err = (file: string, message: string) => errors.push({ file, message }); +const warn = (file: string, message: string) => warnings.push({ file, message }); + +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** Every string leaf in the document, with a dotted path for error messages. */ +function* strings(value: unknown, path = ""): Generator<{ path: string; value: string }> { + if (typeof value === "string") { + yield { path, value }; + } else if (Array.isArray(value)) { + for (const [i, v] of value.entries()) yield* strings(v, `${path}[${i}]`); + } else if (isObject(value)) { + for (const [k, v] of Object.entries(value)) yield* strings(v, path ? `${path}.${k}` : k); + } +} + +/** + * A referenced file must exist and must sit inside the repo. Catalog assets are + * fetched by relative path from the raw GitHub URL, so a leading slash or a + * `../` escape resolves somewhere that is not the catalog. + */ +function checkAssetPath(file: string, field: string, value: string) { + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return; // absolute URL, not our asset + if (value.startsWith("/")) { + err(file, `${field}: "${value}" is an absolute path; asset paths are relative to the repo root`); + return; + } + if (value.split("/").includes("..")) { + err(file, `${field}: "${value}" escapes the repository`); + return; + } + if (!existsSync(value) || !statSync(value).isFile()) { + err(file, `${field}: "${value}" does not exist in the repository`); + } +} + +function checkCondition(file: string, where: string, cond: unknown) { + if (!isObject(cond)) { + err(file, `${where}: must be an object`); + return; + } + if (typeof cond.role !== "string" || !(HOOK_CONDITION_ROLES as readonly string[]).includes(cond.role)) { + err(file, `${where}: role must be "visibility" or "availability"`); + } + if (typeof cond.type !== "string" || !cond.type.trim()) { + err(file, `${where}: type is missing`); + return; + } + if ((HOOK_CONDITION_TYPES as readonly string[]).includes(cond.type)) { + if (cond.type === "appInstalled" || cond.type === "appRunning") { + if (typeof cond.app !== "string" || !cond.app.trim()) { + err(file, `${where}: ${cond.type} condition requires an app name`); + } + } + if (cond.type === "appVersion") { + if (typeof cond.app !== "string" || !cond.app.trim()) { + err(file, `${where}: appVersion condition requires an app name`); + } + if (typeof cond.range !== "string" || !cond.range.trim()) { + err(file, `${where}: appVersion condition requires a range`); + } + } + if (cond.type === "capabilityPresent") { + if (typeof cond.capability !== "string" || !cond.capability.trim()) { + err(file, `${where}: capabilityPresent condition requires a capability name`); + } + } + if (cond.type === "script") { + if (typeof cond.script !== "string" || !cond.script.trim()) { + err(file, `${where}: script condition requires a script path`); + } else { + checkAssetPath(file, `${where}.script`, cond.script); + } + } + } +} + +function checkMacros(file: string, script: unknown, questionKeys: Set) { + const raw = JSON.stringify(script); + + // A macro name we do not know is almost always a typo. It survives install + // untouched and lands in the container as a literal "$LOCATIN(...)". + for (const match of raw.matchAll(/\$([A-Z_]{3,})/g)) { + const name = match[1]; + const followedByParen = raw[match.index! + match[0].length] === "("; + if (followedByParen) { + if (!(CALL_MACROS as readonly string[]).includes(name)) { + err(file, `unknown macro $${name}()`); + } + } else if (!(BARE_MACROS as readonly string[]).includes(name)) { + // $LOCATION with no argument list is the same typo class. + if ((CALL_MACROS as readonly string[]).includes(name)) { + err(file, `$${name} is used without an argument list`); + } else { + err(file, `unknown macro $${name}`); + } + } + } + + // replaceLocation() throws PROCESS_INSTALL_SCRIPT_UNKNOWN_LOCATION on any + // name outside LocationPreferenceId, which aborts the whole install. + for (const match of raw.matchAll(/\$LOCATION\(([^)]+)\)/g)) { + const name = match[1].trim(); + if (!(LOCATIONS as readonly string[]).includes(name)) { + err(file, `$LOCATION(${name}) is not a known location`); + } + } + + // A $QUESTION with no declaration behind it resolves to nothing. + for (const match of raw.matchAll(/\$QUESTION\(([^)]+)\)/g)) { + const key = match[1].trim().replace(/^["']|["']$/g, "").split(",")[0].trim(); + if (!questionKeys.has(key)) { + err(file, `$QUESTION(${key}) has no matching question key`); + } + } + + // Unbalanced parentheses make the tokenizer consume to end-of-input. + for (const name of CALL_MACROS) { + for (const match of raw.matchAll(new RegExp(`\\$${name}\\(`, "g"))) { + let depth = 0; + let closed = false; + for (let i = match.index! + match[0].length - 1; i < raw.length; i++) { + if (raw[i] === "(") depth++; + else if (raw[i] === ")" && --depth === 0) { closed = true; break; } + } + if (!closed) err(file, `$${name}( is never closed`); + } + } +} + +function validate(file: string, script: Record) { + const version = script.version; + if (typeof version !== "number") { + err(file, "version is missing or not a number"); + return; + } + if (!(SUPPORTED_VERSIONS as readonly number[]).includes(version)) { + err( + file, + `version ${version} is not parsed by the backend (supported: ${SUPPORTED_VERSIONS.join(", ")})`, + ); + return; + } + + // --- script block (required from v4 on) --- + const scriptMeta = script.script; + if (!isObject(scriptMeta)) { + err(file, "script block is missing"); + } else if (typeof scriptMeta.version !== "string") { + err(file, "script.version is missing"); + } + + // --- requirements (required from v4 on) --- + const req = script.requirements; + const declaredLocations = new Set(); + if (!isObject(req)) { + err(file, "requirements block is missing"); + } else { + for (const key of ["permissions", "specifications", "locations", "ports"]) { + if (!Array.isArray(req[key])) err(file, `requirements.${key} must be an array`); + } + for (const spec of (req.specifications as unknown[]) ?? []) { + if (typeof spec !== "string" || !SPEC_REGEX.test(spec)) { + err(file, `requirements.specifications: "${String(spec)}" is not a valid spec`); + } + } + for (const perm of (req.permissions as unknown[]) ?? []) { + if (typeof perm !== "string" || !(PERMISSIONS as readonly string[]).includes(perm)) { + err(file, `requirements.permissions: "${String(perm)}" is not a known permission`); + } + } + for (const loc of (req.locations as unknown[]) ?? []) { + if (typeof loc !== "string" || !(LOCATIONS as readonly string[]).includes(loc)) { + err(file, `requirements.locations: "${String(loc)}" is not a known location`); + } else { + declaredLocations.add(loc); + } + } + for (const port of (req.ports as unknown[]) ?? []) { + if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) { + err(file, `requirements.ports: "${String(port)}" is not a valid port`); + } + } + } + + // --- metadata and its assets --- + const metadata = script.metadata; + if (isObject(metadata)) { + for (const key of ["name", "description", "icon", "version"]) { + if (typeof metadata[key] !== "string" || !(metadata[key] as string).trim()) { + err(file, `metadata.${key} is missing`); + } + } + if (typeof metadata.icon === "string") checkAssetPath(file, "metadata.icon", metadata.icon); + for (const [i, shot] of ((metadata.screenshots as unknown[]) ?? []).entries()) { + if (typeof shot === "string") checkAssetPath(file, `metadata.screenshots[${i}]`, shot); + } + } + + // --- questions --- + const questionKeys = new Set(); + const collectQuestion = (q: unknown, where: string, scopeKeys?: Set) => { + if (!isObject(q)) return; + const key = q.key; + if (typeof key !== "string" || !key.trim()) { + err(file, `${where}: question is missing a key`); + return; + } + const dupeSet = scopeKeys ?? questionKeys; + if (dupeSet.has(key)) err(file, `${where}: duplicate question key "${key}"`); + dupeSet.add(key); + questionKeys.add(key); + if (typeof q.type !== "string" || !(QUESTION_TYPES as readonly string[]).includes(q.type)) { + err(file, `${where}: question "${key}" has invalid type "${String(q.type)}"`); + } + if (q.type === "select") { + const options = q.options; + if (!Array.isArray(options) || options.length === 0) { + err(file, `${where}: select question "${key}" has no options`); + } + } + }; + + for (const [i, q] of ((script.installation_questions as unknown[]) ?? []).entries()) { + collectQuestion(q, `installation_questions[${i}]`); + } + + // --- ensure_directories_exists --- + for (const [i, entry] of ((script.ensure_directories_exists as unknown[]) ?? []).entries()) { + const where = `ensure_directories_exists[${i}]`; + if (!isObject(entry)) { + err(file, `${where}: v${version} entries must be objects with a path`); + continue; + } + if (typeof entry.path !== "string" || !entry.path.trim()) { + err(file, `${where}: path is missing`); + } + // owner accepts a bare username string as shorthand for { user }. + if (entry.owner !== undefined) { + if (typeof entry.owner === "string") { + if (!entry.owner.trim()) err(file, `${where}.owner: username is empty`); + } else if (!isObject(entry.owner)) { + err(file, `${where}.owner: must be a username string or { "user": "apps" }`); + } else { + if (typeof entry.owner.user !== "string" || !entry.owner.user.trim()) { + err(file, `${where}.owner.user is missing`); + } + if (entry.owner.group !== undefined && typeof entry.owner.group !== "string") { + err(file, `${where}.owner.group must be a string`); + } + } + } + if (entry.snapshot !== undefined) { + if (!isObject(entry.snapshot) || typeof entry.snapshot.id !== "string") { + err(file, `${where}.snapshot: must be an object with an id`); + } + } + if (entry.network_share !== undefined && typeof entry.network_share !== "boolean") { + err(file, `${where}.network_share must be a boolean`); + } + } + + // --- app_values --- + if (!isObject(script.app_values)) err(file, "app_values is missing"); + + // --- hooks (v5 and v6) --- + const hookIds = new Set(); + for (const [i, hook] of ((script.hooks as unknown[]) ?? []).entries()) { + const where = `hooks[${i}]`; + if (!isObject(hook)) { + err(file, `${where}: must be an object`); + continue; + } + if (version !== 5 && version !== 6) err(file, `${where}: hooks require version 5 or 6`); + const id = hook.id; + if (typeof id !== "string" || !id.trim()) { + err(file, `${where}: id is missing`); + } else { + if (hookIds.has(id)) err(file, `${where}: duplicate hook id "${id}"`); + hookIds.add(id); + } + + if (version === 6) { + if (typeof hook.title !== "string" || !hook.title.trim()) { + err(file, `${where}: title is missing`); + } + if (hook.kind !== undefined) { + if (typeof hook.kind !== "string" || !(HOOK_KINDS as readonly string[]).includes(hook.kind)) { + err(file, `${where}: kind "${String(hook.kind)}" is not a known hook kind`); + } + } + // V5's singular field in a V6 entry is rejected by the backend — it + // would otherwise silently turn a lifecycle hook into a user verb. + if (hook.event !== undefined) { + err(file, `${where}: v6 uses events (array) — found v5-style singular event`); + } + + // Trigger analysis mirrors the backend's superRefine: absent events + // defaults to ["userAction"], and several fields are legal only for + // one firing class. + const upgradeEvents = new Set(["onBeforeUpgrade", "onAfterUpgrade"]); + const installEvents = new Set(["onBeforeInstall", "onAfterInstall"]); + const eventKeys: string[] = []; + let hasVersionGate = false; + let eventsValid = true; + if (hook.events !== undefined) { + if (!Array.isArray(hook.events) || hook.events.length === 0) { + err(file, `${where}: events must be a non-empty array`); + eventsValid = false; + } else { + for (const [j, ev] of hook.events.entries()) { + if (typeof ev === "string") { + if (!(HOOK_EVENT_SPECS as readonly string[]).includes(ev)) { + err(file, `${where}.events[${j}]: "${ev}" is not a known event spec`); + eventsValid = false; + } else { + eventKeys.push(ev); + } + } else if (isObject(ev)) { + const evName = ev.event; + if (typeof evName !== "string" || !(HOOK_EVENT_SPECS as readonly string[]).includes(evName) || evName === "userAction") { + err(file, `${where}.events[${j}]: event object must name a lifecycle event`); + eventsValid = false; + continue; + } + const extraKeys = Object.keys(ev).filter((k) => !["event", "from", "to"].includes(k)); + if (extraKeys.length > 0) { + err(file, `${where}.events[${j}]: unknown keys ${extraKeys.join(", ")}`); + } + if ((ev.from !== undefined || ev.to !== undefined) && !upgradeEvents.has(evName)) { + err(file, `${where}.events[${j}]: from/to gate upgrade transitions — legal on upgrade events only`); + } + if (ev.from !== undefined || ev.to !== undefined) hasVersionGate = true; + eventKeys.push(evName); + } else { + err(file, `${where}.events[${j}]: must be a string or event object`); + eventsValid = false; + } + } + } + } else { + eventKeys.push("userAction"); + } + const userTriggerable = eventKeys.includes("userAction"); + const lifecycleCount = eventKeys.filter((k) => k !== "userAction").length; + const hasInstall = eventKeys.some((k) => installEvents.has(k)); + + if (eventsValid) { + const seen = new Map(); + for (const k of eventKeys) seen.set(k, (seen.get(k) ?? 0) + 1); + for (const [k, count] of seen) { + if (count > 1) err(file, `${where}: duplicate trigger entries for "${k}"`); + } + const keys = [...seen.keys()]; + if (keys.filter((k) => installEvents.has(k)).length > 1) { + err(file, `${where}: onBeforeInstall and onAfterInstall cannot share a declaration`); + } + if (keys.filter((k) => upgradeEvents.has(k)).length > 1) { + err(file, `${where}: onBeforeUpgrade and onAfterUpgrade cannot share a declaration`); + } + if (hasVersionGate && userTriggerable) { + err(file, `${where}: version-gated triggers cannot share a declaration with "userAction"`); + } + + // rerun: required iff user-triggerable. + if (hook.rerun === undefined) { + if (userTriggerable) err(file, `${where}: rerun is required on user-triggerable hooks`); + } else { + if (typeof hook.rerun !== "string" || !(HOOK_RERUNS as readonly string[]).includes(hook.rerun)) { + err(file, `${where}: rerun "${String(hook.rerun)}" is not a known rerun mode`); + } + if (!userTriggerable) err(file, `${where}: rerun applies to user firings only`); + } + if (hook.optional !== undefined && lifecycleCount === 0) { + err(file, `${where}: optional requires a lifecycle trigger`); + } + if (hook.userOptional !== undefined) { + if (!hasInstall) { + err(file, `${where}: userOptional requires an install trigger (onBeforeInstall/onAfterInstall)`); + } + if (!isObject(hook.userOptional)) { + err(file, `${where}: userOptional must be an object`); + } else if (hook.userOptional.link !== undefined) { + const link = hook.userOptional.link; + if (!isObject(link) || typeof link.url !== "string" || !/^https?:\/\//.test(link.url) || typeof link.label !== "string" || !link.label.trim()) { + err(file, `${where}: userOptional.link needs a valid url and a label`); + } + } + } + if (hook.target !== undefined && lifecycleCount > 0) { + err(file, `${where}: file-targeted hooks are user-fired only`); + } + } + + if (hook.surfaces !== undefined) { + if (!Array.isArray(hook.surfaces) || hook.surfaces.length === 0) { + err(file, `${where}: surfaces must be a non-empty array`); + } else { + for (const s of hook.surfaces) { + if (typeof s !== "string" || !(HOOK_SURFACES as readonly string[]).includes(s)) { + err(file, `${where}: surface "${String(s)}" is not known`); + } + } + } + } + if (hook.conditions !== undefined) { + if (!Array.isArray(hook.conditions)) { + err(file, `${where}: conditions must be an array`); + } else { + for (const [j, cond] of hook.conditions.entries()) { + checkCondition(file, `${where}.conditions[${j}]`, cond); + } + } + } + if (hook.target !== undefined) { + if (!isObject(hook.target)) { + err(file, `${where}: target must be an object`); + } else { + if (typeof hook.target.type !== "string" || !(HOOK_TARGET_TYPES as readonly string[]).includes(hook.target.type)) { + err(file, `${where}: target.type "${String(hook.target.type)}" is not known`); + } + if (hook.target.type === "files") { + if (!Array.isArray(hook.target.accepts) || hook.target.accepts.length === 0) { + err(file, `${where}: target.accepts must be a non-empty array of extensions`); + } else { + for (const ext of hook.target.accepts) { + if (typeof ext !== "string" || !/^\.[a-z0-9]+$/i.test(ext)) { + err(file, `${where}: target.accepts "${String(ext)}" — extensions are dot-prefixed (e.g. ".mkv")`); + } + } + } + if (hook.target.maxFiles !== undefined && (typeof hook.target.maxFiles !== "number" || !Number.isInteger(hook.target.maxFiles) || hook.target.maxFiles <= 0)) { + err(file, `${where}: target.maxFiles must be a positive integer`); + } + if (hook.target.requiresTargetMount !== undefined && typeof hook.target.requiresTargetMount !== "boolean") { + err(file, `${where}: target.requiresTargetMount must be a boolean`); + } + } + } + } + if (hook.requiresHooks !== undefined) { + if (!Array.isArray(hook.requiresHooks) || hook.requiresHooks.length > 8) { + err(file, `${where}: requiresHooks must be an array of at most 8 hook ids`); + } + } + if (hook.retries !== undefined && (typeof hook.retries !== "number" || !Number.isInteger(hook.retries) || hook.retries < 0)) { + err(file, `${where}: retries must be a non-negative integer`); + } + } else { + if (typeof hook.event !== "string" || !(HOOK_EVENTS as readonly string[]).includes(hook.event)) { + err(file, `${where}: event "${String(hook.event)}" is not a known hook event`); + } + } + + const hasFile = typeof hook.script === "string" && hook.script.trim().length > 0; + const hasInline = typeof hook.scriptContent === "string" && hook.scriptContent.trim().length > 0; + if (!hasFile && !hasInline) { + err(file, `${where}: needs either script or scriptContent`); + } else if (hasFile && hasInline) { + err(file, `${where}: declares both script and scriptContent`); + } else if (hasFile) { + checkAssetPath(file, `${where}.script`, hook.script as string); + } + if (typeof hook.entrypoint !== "string" || !hook.entrypoint.trim()) { + err(file, `${where}: entrypoint is missing`); + } + if (hook.timeout !== undefined && (typeof hook.timeout !== "number" || hook.timeout <= 0)) { + err(file, `${where}: timeout must be a positive number`); + } + const hookQuestionScope = version === 6 ? new Set() : undefined; + for (const [j, input] of ((hook.inputs as unknown[]) ?? []).entries()) { + if (!isObject(input)) continue; + if (input.type === "question") collectQuestion(input.question, `${where}.inputs[${j}]`, hookQuestionScope); + } + } + + // requiresHooks may forward-reference, so resolve after all ids are known. + for (const [i, hook] of ((script.hooks as unknown[]) ?? []).entries()) { + if (!isObject(hook) || !Array.isArray(hook.requiresHooks)) continue; + for (const ref of hook.requiresHooks) { + if (typeof ref === "string" && !hookIds.has(ref)) { + err(file, `hooks[${i}]: requiresHooks references unknown hook id "${ref}"`); + } + } + } + + // --- widgets (v6) --- + if (script.widgets !== undefined) { + if (version !== 6) err(file, "widgets require version 6"); + if (script.widgetsSchema !== undefined && script.widgetsSchema !== SUPPORTED_WIDGETS_SCHEMA) { + err(file, `widgetsSchema ${script.widgetsSchema} is not supported (expected ${SUPPORTED_WIDGETS_SCHEMA})`); + } + if (!Array.isArray(script.widgets)) { + err(file, "widgets must be an array"); + } else { + const widgetIds = new Set(); + for (const [i, widget] of script.widgets.entries()) { + const where = `widgets[${i}]`; + if (!isObject(widget)) { + err(file, `${where}: must be an object`); + continue; + } + const wid = widget.id; + if (typeof wid !== "string" || !wid.trim()) { + err(file, `${where}: id is missing`); + } else { + if (!/^[a-z0-9_-]+$/.test(wid)) { + err(file, `${where}: id "${wid}" must be lowercase alphanumeric with hyphens/underscores`); + } + if (widgetIds.has(wid)) err(file, `${where}: duplicate widget id "${wid}"`); + widgetIds.add(wid); + } + if (typeof widget.title !== "string" || !widget.title.trim()) { + err(file, `${where}: title is missing`); + } + 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 < 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; + const hasInline = typeof widget.scriptContent === "string" && widget.scriptContent.trim().length > 0; + if (!hasFile && !hasInline) { + err(file, `${where}: needs either script or scriptContent`); + } else if (hasFile && hasInline) { + err(file, `${where}: declares both script and scriptContent`); + } else if (hasFile) { + checkAssetPath(file, `${where}.script`, widget.script as string); + } + if (typeof widget.entrypoint !== "string" || !widget.entrypoint.trim()) { + err(file, `${where}: entrypoint is missing`); + } + if (widget.timeout !== undefined && (typeof widget.timeout !== "number" || widget.timeout <= 0)) { + err(file, `${where}: timeout must be a positive number`); + } + if (widget.conditions !== undefined) { + if (!Array.isArray(widget.conditions)) { + err(file, `${where}: conditions must be an array`); + } else { + for (const [j, cond] of widget.conditions.entries()) { + checkCondition(file, `${where}.conditions[${j}]`, cond); + } + } + } + // widgetsSchema 3 made the widget the app's card: no sizes, one layout. + if (widget.sizes !== undefined) { + 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`); + } + } + } + } + if (widget.buttons !== undefined) { + if (!Array.isArray(widget.buttons)) { + err(file, `${where}: buttons must be an array`); + } else { + if (widget.buttons.length > 4) err(file, `${where}: max 4 buttons`); + const seen = new Set(); + for (const btn of widget.buttons) { + if (typeof btn !== "string") continue; + if (seen.has(btn)) err(file, `${where}: duplicate button "${btn}"`); + seen.add(btn); + if (!hookIds.has(btn)) err(file, `${where}: button "${btn}" references unknown hook id`); + } + } + } + } + } + } + + checkMacros(file, script, questionKeys); + + // A location used by a macro but never declared still installs — the path + // resolves — but HexOS never prompts for it, so the requirements screen and + // the permission grant are both wrong. + const used = new Set( + [...JSON.stringify(script).matchAll(/\$LOCATION\(([^)]+)\)/g)].map((m) => m[1].trim()), + ); + for (const loc of used) { + if ((LOCATIONS as readonly string[]).includes(loc) && !declaredLocations.has(loc)) { + warn(file, `$LOCATION(${loc}) is used but not declared in requirements.locations`); + } + } +} + +// --- run --- + +const files = readdirSync(".") + .filter((f) => f.endsWith(".json")) + .sort(); + +if (files.length === 0) { + console.error("No install scripts found. Is the working directory the repo root?"); + process.exit(1); +} + +for (const file of files) { + let parsed: unknown; + try { + parsed = JSON.parse(await Bun.file(file).text()); + } catch (cause) { + err(file, `is not valid JSON: ${(cause as Error).message}`); + continue; + } + if (!isObject(parsed)) { + err(file, "top level must be an object"); + continue; + } + validate(file, parsed); +} + +// Assets are cheap to orphan and invisible when they rot, so name the ones +// nothing points at rather than letting them accumulate. +const referenced = new Set(); +for (const file of files) { + try { + for (const { value } of strings(JSON.parse(await Bun.file(file).text()))) { + if (value.includes("/") && !/^[a-z][a-z0-9+.-]*:/i.test(value) && existsSync(value)) { + referenced.add(value); + } + } + } catch { /* already reported as a parse error */ } +} + +const group = (problems: Problem[]) => { + const byFile = new Map(); + for (const p of problems) { + if (!byFile.has(p.file)) byFile.set(p.file, []); + byFile.get(p.file)!.push(p.message); + } + return byFile; +}; + +for (const [file, messages] of group(warnings)) { + console.log(`\n⚠ ${file}`); + for (const m of messages) console.log(` ${m}`); +} + +for (const [file, messages] of group(errors)) { + console.log(`\n✗ ${file}`); + for (const m of messages) console.log(` ${m}`); +} + +console.log( + `\n${files.length} install scripts checked — ` + + `${errors.length} error(s), ${warnings.length} warning(s)`, +); + +process.exit(errors.length > 0 ? 1 : 0); diff --git a/_lib/widget_context.ts b/_lib/widget_context.ts new file mode 100644 index 0000000..d99d15b --- /dev/null +++ b/_lib/widget_context.ts @@ -0,0 +1,89 @@ +/** + * MIRROR of the platform's WidgetContext (packages/backend/src/interface/ + * widgets.ts). Keep in sync — this file exists so catalog widget scripts + * typecheck; the platform constructs the real object at query time. + * + * Widgets are read-only glances: the script runs in-process on the local + * node, must return within its declared timeout, and its result is cached + * by the platform for the widget's refresh interval. No tasks, no + * checkpoints, no inputs — return data or { needsSetup }. + */ + +import type { ContextMount } from "./hook_context"; + +/** One mount shape across every script surface — defined once in hook_context. */ +export type WidgetMount = ContextMount; + +export interface WidgetContext { + readonly appId: string; + /** Box LAN IP — app APIs live at http://host:port. */ + readonly host: string; + /** The app's first declared catalog port, when it declares one. */ + readonly port?: number; + /** The app's live mounts. */ + readonly mounts: WidgetMount[]; + log(message: string): void; +} + +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 + * time between polls; the text floor (meta) must still carry a static + * representation. state "paused" = not accruing. + */ + elapsed?: { ms: number; ofMs?: number; state?: "running" | "paused" }; + /** + * 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 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 } + /** 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 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; + reason?: string; + fields?: Record; +} diff --git a/actual-budget.json b/actual-budget.json index 8001442..1a8b2a0 100644 --- a/actual-budget.json +++ b/actual-budget.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "2048MB" + "2GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/affine.json b/affine.json index 66bb672..e2d1770 100644 --- a/affine.json +++ b/affine.json @@ -23,8 +23,8 @@ ], "app_values": { "affine": { - "db_password": "$RANDOM_STRING(7)", - "redis_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "redis_password": "$RANDOM_STRING(16)", "additional_envs": [] }, "network": { diff --git a/bentopdf.json b/bentopdf.json index bb05134..479be80 100644 --- a/bentopdf.json +++ b/bentopdf.json @@ -10,7 +10,7 @@ "Downloads" ], "specifications": [ - "2CORE", "1024MB"], + "2CORE", "1GB"], "permissions": [ "READ_WRITE_LOCATIONS" ], diff --git a/blinko.json b/blinko.json index 9e78bbe..56fd313 100644 --- a/blinko.json +++ b/blinko.json @@ -22,8 +22,8 @@ ], "app_values": { "blinko": { - "db_password": "$RANDOM_STRING(7)", - "nextauth_secret": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "nextauth_secret": "$RANDOM_STRING(32)", "additional_envs": [] }, "network": { diff --git a/dozzle.json b/dozzle.json index b1dc57b..cc29d96 100644 --- a/dozzle.json +++ b/dozzle.json @@ -6,7 +6,7 @@ }, "requirements": { "locations": [], - "specifications": ["2CORE", "1024MB"], + "specifications": ["2CORE", "1GB"], "permissions": [], "ports": [30064] }, diff --git a/drawio.json b/drawio.json index 4cfd27e..042ff17 100644 --- a/drawio.json +++ b/drawio.json @@ -6,7 +6,7 @@ }, "requirements": { "locations": [], - "specifications": ["2CORE", "1024MB"], + "specifications": ["2CORE", "1GB"], "permissions": [], "ports": [30090, 30091] }, diff --git a/excalidraw.json b/excalidraw.json index 85abbb1..fb50851 100644 --- a/excalidraw.json +++ b/excalidraw.json @@ -6,7 +6,7 @@ }, "requirements": { "locations": [], - "specifications": ["2CORE", "1024MB"], + "specifications": ["2CORE", "1GB"], "permissions": [], "ports": [30255] }, diff --git a/firefly-iii.json b/firefly-iii.json index 0f8799b..9300998 100644 --- a/firefly-iii.json +++ b/firefly-iii.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "2048MB" + "2GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/handbrake.json b/handbrake.json index f0baaf4..7f9c058 100644 --- a/handbrake.json +++ b/handbrake.json @@ -1,19 +1,20 @@ { "version": 4, "script": { - "version": "1.1.0", - "changeLog": "Upgraded to V4 install script format" + "version": "1.1.1", + "changeLog": "Declared the Downloads location the app already mounts" }, "requirements": { "locations": [ "ApplicationsPerformance", + "Downloads", "Media", "Movies", "Shows", "Videos" ], "specifications": [ - "2CORE", "2048MB"], + "2CORE", "2GB"], "permissions": [ "READ_WRITE_LOCATIONS" ], diff --git a/hexos-test-fail.json b/hexos-test-fail.json index 9cdd306..0b67210 100644 --- a/hexos-test-fail.json +++ b/hexos-test-fail.json @@ -1,5 +1,5 @@ { - "version": 5, + "version": 6, "custom": true, "internal": true, "metadata": { @@ -79,12 +79,15 @@ "hooks": [ { "id": "required-fail", - "event": "onAfterInstall", + "title": "Required step that always fails", + "description": "Required step that always fails", + "events": [ + "onAfterInstall" + ], + "optional": false, "script": "hexos-test-fail/hooks.ts", "entrypoint": "requiredFail", "timeout": 30, - "description": "Required step that always fails", - "optional": false, "retries": 0 } ] diff --git a/hexos-test-inline.json b/hexos-test-inline.json index 0c0b86c..b58ed65 100644 --- a/hexos-test-inline.json +++ b/hexos-test-inline.json @@ -1,5 +1,5 @@ { - "version": 5, + "version": 6, "custom": true, "internal": true, "metadata": { @@ -39,29 +39,38 @@ "hooks": [ { "id": "inline-before-install", - "event": "onBeforeInstall", + "title": "Pre-install validation (inline)", + "description": "Pre-install validation (inline)", + "events": [ + "onBeforeInstall" + ], "scriptContent": "export async function beforeInstall(ctx) {\n await ctx.registerCheckpoints([\n { \"id\": \"validateContext\", \"message\": \"Validating install context\" },\n { \"id\": \"validateInline\", \"message\": \"Confirming inline execution\" }\n ]);\n\n if (ctx.resourceType !== \"app\") throw new Error(`Wrong resourceType: ${ctx.resourceType}`);\n if (ctx.resourceId !== \"hexos-test-inline\") throw new Error(`Wrong resourceId: ${ctx.resourceId}`);\n if (ctx.event !== \"onBeforeInstall\") throw new Error(`Wrong event: ${ctx.event}`);\n await ctx.emitCheckpoint(\"validateContext\");\n\n ctx.log(\"Inline hook running — no external .ts file needed\");\n await ctx.emitCheckpoint(\"validateInline\");\n}", "entrypoint": "beforeInstall", - "timeout": 30, - "description": "Pre-install validation (inline)" + "timeout": 30 }, { "id": "inline-after-install", - "event": "onAfterInstall", + "title": "Post-install health check (inline)", + "description": "Post-install health check (inline)", + "events": [ + "onAfterInstall" + ], + "optional": true, "scriptContent": "export async function afterInstall(ctx) {\n await ctx.registerCheckpoints([\n { \"id\": \"appReachable\", \"message\": \"Waiting for app to start\" },\n { \"id\": \"healthCheck\", \"message\": \"Running health check\" },\n { \"id\": \"complete\", \"message\": \"Inline hook complete\" }\n ]);\n\n await ctx.waitForApp(\"/\");\n await ctx.emitCheckpoint(\"appReachable\");\n\n const resp = await fetch(`${ctx.baseUrl}/health`, {\n signal: AbortSignal.timeout(5000)\n });\n ctx.log(`Health check: ${resp.status}`);\n await ctx.emitCheckpoint(\"healthCheck\", `Health responded ${resp.status}`);\n\n await ctx.emitCheckpoint(\"complete\", \"All inline checks passed\");\n}", "entrypoint": "afterInstall", - "timeout": 120, - "description": "Post-install health check (inline)", - "optional": true + "timeout": 120 }, { "id": "inline-optional-fail", - "event": "onAfterInstall", + "title": "Optional inline step that intentionally fails", + "description": "Optional inline step that intentionally fails", + "events": [ + "onAfterInstall" + ], + "optional": true, "scriptContent": "export async function optionalFail(ctx) {\n await ctx.registerCheckpoints([\n { \"id\": \"willFail\", \"message\": \"About to fail intentionally\" }\n ]);\n\n await ctx.updateCheckpointMessage(\"willFail\", \"Failing now...\");\n ctx.log(\"Inline hook intentionally fails\");\n throw new Error(\"Intentional inline failure for testing optional hook behavior\");\n}", "entrypoint": "optionalFail", - "timeout": 10, - "description": "Optional inline step that intentionally fails", - "optional": true + "timeout": 10 } ] } diff --git a/hexos-test.json b/hexos-test.json index 12170d2..c5916d7 100644 --- a/hexos-test.json +++ b/hexos-test.json @@ -1,5 +1,5 @@ { - "version": 5, + "version": 6, "custom": true, "internal": true, "metadata": { @@ -51,22 +51,24 @@ "hooks": [ { "id": "before-install", - "event": "onBeforeInstall", + "title": "Pre-install validation", + "description": "Pre-install validation", + "events": [ + "onBeforeInstall" + ], "script": "hexos-test/hooks.ts", "entrypoint": "beforeInstall", - "timeout": 30, - "description": "Pre-install validation" + "timeout": 30 }, { "id": "after-install", - "event": "onAfterInstall", - "script": "hexos-test/hooks.ts", - "entrypoint": "afterInstall", - "timeout": 120, + "title": "Run post-install validation", "description": "Post-install configuration", + "events": [ + "onAfterInstall" + ], "optional": true, "userOptional": { - "label": "Run post-install validation", "description": "Exercises all hook capabilities: OAuth, questions, checkpoints, health checks, and stress testing.", "default": true }, @@ -109,8 +111,14 @@ "type": "select", "key": "test_select", "options": [ - { "text": "Option A", "value": "a" }, - { "text": "Option B", "value": "b" } + { + "text": "Option A", + "value": "a" + }, + { + "text": "Option B", + "value": "b" + } ], "default": "a" } @@ -128,33 +136,192 @@ "placeholder": "HexOS Test App" } } - ] + ], + "script": "hexos-test/hooks.ts", + "entrypoint": "afterInstall", + "timeout": 120 }, { "id": "before-upgrade", - "event": "onBeforeUpgrade", + "title": "Pre-upgrade patch", + "description": "Pre-upgrade patch", + "events": [ + { + "event": "onBeforeUpgrade", + "from": "< 2.0.0" + } + ], "script": "hexos-test/hooks.ts", "entrypoint": "beforeUpgrade", - "timeout": 30, - "description": "Pre-upgrade patch", - "condition": { "fromVersionRange": "< 2.0.0" } + "timeout": 30 }, { "id": "after-upgrade", - "event": "onAfterUpgrade", + "title": "Post-upgrade cleanup", + "description": "Post-upgrade cleanup", + "events": [ + "onAfterUpgrade" + ], "script": "hexos-test/hooks.ts", "entrypoint": "afterUpgrade", - "timeout": 30, - "description": "Post-upgrade cleanup" + "timeout": 30 }, { "id": "optional-fail", - "event": "onAfterInstall", + "title": "Optional step that intentionally fails", + "description": "Optional step that intentionally fails", + "events": [ + "onAfterInstall" + ], + "optional": true, "script": "hexos-test/hooks.ts", "entrypoint": "optionalFail", + "timeout": 10 + }, + { + "id": "harness-ok", + "title": "Harness: succeed with checkpoints", + "description": "Runs three checkpoints over ~4s and completes.", + "kind": "custom", + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"work\", message: \"Doing the work\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"work\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-fail", + "title": "Harness: always fails", + "description": "Fails at its second checkpoint to exercise structured failure.", + "kind": "custom", + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"explode\", message: \"This step always fails\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 1000));\n throw new Error(\"Intentional harness failure\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-refuse", + "title": "Harness: runs once", + "description": "Refuses to run a second time once it has completed.", + "kind": "custom", + "rerun": "refuse", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 1500));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-needs-self-running", + "title": "Harness: requires this app running", + "description": "Visible when hexos-test is installed; runnable only while it is running.", + "kind": "custom", + "conditions": [ + { + "role": "visibility", + "type": "appInstalled", + "app": "hexos-test" + }, + { + "role": "availability", + "type": "appRunning", + "app": "hexos-test" + } + ], + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"work\", message: \"Doing the work\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"work\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-invisible", + "title": "Harness: should never be shown", + "description": "Gated on an app that does not exist.", + "kind": "custom", + "conditions": [ + { + "role": "visibility", + "type": "appInstalled", + "app": "app-that-does-not-exist" + } + ], + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"work\", message: \"Doing the work\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"work\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-future-condition", + "title": "Harness: unknown condition type", + "description": "Uses a predicate this platform does not know; must fail closed (hidden).", + "kind": "custom", + "conditions": [ + { + "role": "visibility", + "type": "quantumEntanglement", + "qubits": 7 + } + ], + "rerun": "idempotent", + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"work\", message: \"Doing the work\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"work\");\n await new Promise((r) => setTimeout(r, 2000));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-file-verb", + "title": "Harness: convert selected file", + "description": "Runs against selected .txt files in the file browser.", + "kind": "custom", + "rerun": "idempotent", + "surfaces": ["fileBrowser"], + "target": { "type": "files", "accepts": [".txt"], "maxFiles": 5, "requiresTargetMount": false }, + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 1500));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + }, + { + "id": "harness-widget-button", + "title": "Harness: widget button", + "description": "Fired from the dashboard widget.", + "kind": "custom", + "rerun": "idempotent", + "surfaces": ["card", "widget"], + "scriptContent": "export async function run(ctx) {\n await ctx.registerCheckpoints([\n { id: \"prepare\", message: \"Preparing\" },\n { id: \"verify\", message: \"Verifying\" },\n ]);\n await ctx.emitCheckpoint(\"prepare\");\n await new Promise((r) => setTimeout(r, 1500));\n await ctx.emitCheckpoint(\"verify\");\n}\n", + "entrypoint": "run", + "timeout": 60, + "retries": 0 + } + ], + "widgetsSchema": 3, + "widgets": [ + { + "id": "harness-stats", + "title": "Harness stats", + "description": "Renders one field of each widget kind.", + "refresh": 30, + "entrypoint": "run", + "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, - "description": "Optional step that intentionally fails", - "optional": true + "buttons": ["harness-widget-button"], + "slots": [ + { "type": "text", "field": "headline" }, + { "type": "stat", "field": "streams" }, + { "type": "list", "field": "items" } + ] + }, + { + "id": "harness-needs-setup", + "title": "Harness needs setup", + "description": "Always reports that it needs setup.", + "refresh": 30, + "entrypoint": "run", + "scriptContent": "export async function run(ctx) {\n return { needsSetup: true, reason: \"Harness widget needs setup\" };\n}\n", + "timeout": 10, + "slots": [{ "type": "text", "field": "headline" }] } ] } diff --git a/home-assistant.json b/home-assistant.json index e63b0e1..8c0cb28 100644 --- a/home-assistant.json +++ b/home-assistant.json @@ -24,7 +24,7 @@ ], "app_values": { "home_assistant": { - "db_password": "$RANDOM_STRING(7)" + "db_password": "$RANDOM_STRING(16)" }, "storage": { "config": "$HOST_PATH($LOCATION(ApplicationsPerformance)/home-assistant/config)", diff --git a/immich.json b/immich.json index 9906289..7acdcc6 100644 --- a/immich.json +++ b/immich.json @@ -26,8 +26,8 @@ "immich": { "enable_ml": true, "ml_image_selector": "ml_image", - "db_password": "$RANDOM_STRING(7)", - "redis_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "redis_password": "$RANDOM_STRING(16)", "log_level": "log", "hugging_face_endpoint": "", "db_storage_type": "SSD", diff --git a/jellyfin.json b/jellyfin.json index 117126c..3867ad3 100644 --- a/jellyfin.json +++ b/jellyfin.json @@ -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"], @@ -49,7 +49,10 @@ "hooks": [ { "id": "configure-jellyfin", - "event": "onAfterInstall", + "title": "Pre-configure Jellyfin", + "events": [ + "onAfterInstall" + ], "script": "jellyfin/jellyfin_hook.ts", "entrypoint": "afterInstall", "timeout": 300, @@ -57,7 +60,6 @@ "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": { @@ -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 + } ] } diff --git a/jellyfin/jellyfin_hook.ts b/jellyfin/jellyfin_hook.ts index f509510..3f566ef 100644 --- a/jellyfin/jellyfin_hook.ts +++ b/jellyfin/jellyfin_hook.ts @@ -3,6 +3,9 @@ import type { HookContext } from "../_lib/hook_context"; const DEVICE_ID = "hexos-jellyfin-setup"; const CLIENT_HEADER = `MediaBrowser Client="HexOS", Device="Setup", DeviceId="${DEVICE_ID}", Version="1.0.0"`; +/** The name of the API key the now-playing widget reads sessions with. */ +const WIDGET_KEY_NAME = "HexOS"; + const AUTO_RETRIES = 5; const AUTO_RETRY_DELAY_MS = 5000; const LIBRARY_PACE_MS = 5000; @@ -51,6 +54,36 @@ export async function afterInstall(ctx: HookContext) { } await setServerName(ctx, token, serverName); await createLibraries(ctx, token); + await ensureWidgetKey(ctx, token); +} + +/** + * The API key the now-playing widget reads sessions with (jellyfin/widget_now_playing.ts finds it + * by name in Jellyfin's database). Made once: a rerun finds it and leaves it. A failure here costs + * only the widget, which then asks for a key, so it never fails the setup. + */ +async function ensureWidgetKey(ctx: HookContext, token: string): Promise { + const headers = { "Authorization": `MediaBrowser Token="${token}"` }; + try { + const listResp = await fetch(`${ctx.baseUrl}/Auth/Keys`, { headers, signal: AbortSignal.timeout(10000) }); + if (!listResp.ok) { + ctx.log(`Auth/Keys returned ${listResp.status}; the widget will ask for an API key.`); + return; + } + const keys = await listResp.json() as { Items?: Array<{ AppName?: string }> }; + if (keys.Items?.some((key) => key.AppName === WIDGET_KEY_NAME)) { + ctx.log("Widget API key already exists."); + return; + } + const createResp = await fetch(`${ctx.baseUrl}/Auth/Keys?app=${WIDGET_KEY_NAME}`, { + method: "POST", + headers, + signal: AbortSignal.timeout(10000), + }); + ctx.log(createResp.ok ? "Created the widget API key." : `Creating the widget API key returned ${createResp.status}.`); + } catch (e) { + ctx.log(`Could not create the widget API key: ${e}`); + } } function getServerName(ctx: HookContext): string { @@ -144,9 +177,9 @@ async function authenticate(ctx: HookContext, username: string, password: string method: "POST", headers: { "Content-Type": "application/json", - // Jellyfin 12 dropped X-Emby-Authorization; 10.8+ reads Authorization - Authorization: CLIENT_HEADER, - "X-Emby-Authorization": CLIENT_HEADER, + // Jellyfin dropped the legacy X-Emby-Authorization header (EnableLegacyAuthorization is off + // since its 2026-05-31 migration); without client details in Authorization the sign-in is a 400. + "Authorization": CLIENT_HEADER, }, body: JSON.stringify({ Username: username, Pw: password }), signal: AbortSignal.timeout(10000), diff --git a/jellyfin/widget_now_playing.ts b/jellyfin/widget_now_playing.ts new file mode 100644 index 0000000..082c320 --- /dev/null +++ b/jellyfin/widget_now_playing.ts @@ -0,0 +1,265 @@ +// Sibling files are type imports only: each script is compiled standalone, so runtime imports of them wouldn't resolve. +import { Database } from "bun:sqlite"; +import { Buffer } from "node:buffer"; + +import type { WidgetContext, WidgetListEntry, WidgetQueryResult } from "../_lib/widget_context"; + +// What Jellyfin is playing, one stream at a time on the app card, each with its own artwork. +// The same card Plex draws, from Jellyfin's sessions. + +const DB_RELATIVE = "/data/jellyfin.db"; +/** The name the setup hook gives the API key it creates. */ +const KEY_NAME = "HexOS"; +/** The device the setup hook signs in as; its session is the first that stands in for the key. */ +const SETUP_DEVICE_ID = "hexos-jellyfin-setup"; +/** Jellyfin's PermissionKind.IsAdministrator. Only an administrator sees every session. */ +const IS_ADMINISTRATOR = 0; +const SETUP_REASON = "Sign in to Jellyfin to see what's playing here"; +/** Jellyfin counts time in ticks of 100 ns. */ +const TICKS_PER_MS = 10_000; + +/** + * A token the widget can read sessions with, from Jellyfin's own database, read-only, so the owner + * never has to hand HexOS one: the API key HexOS's setup made, else any API key, else the session + * of an administrator signed in to any Jellyfin app (HexOS's setup sign-in first, then the most + * recently used). Only an administrator sees every stream. Null when no administrator has ever + * signed in, which signing in fixes. + */ +function harvestToken(ctx: WidgetContext): string | null { + const config = + ctx.mounts.find((m) => m.containerPath === "/config") ?? + ctx.mounts.find((m) => m.hostPath.endsWith("/jellyfin/config")); + if (!config) return null; + let db: Database | undefined; + try { + db = new Database(config.localPath + DB_RELATIVE, { readonly: true }); + const key = db + .query<{ AccessToken: string }, [string]>( + "SELECT AccessToken FROM ApiKeys ORDER BY (Name = ?) DESC, DateCreated DESC LIMIT 1", + ) + .get(KEY_NAME); + if (key?.AccessToken) return key.AccessToken; + const session = db + .query<{ AccessToken: string }, [number, string]>( + `SELECT d.AccessToken FROM Devices d + JOIN Permissions p ON p.UserId = d.UserId AND p.Kind = ? AND p.Value = 1 + WHERE d.IsActive = 1 + ORDER BY (d.DeviceId = ?) DESC, d.DateLastActivity DESC + LIMIT 1`, + ) + .get(IS_ADMINISTRATOR, SETUP_DEVICE_ID); + return session?.AccessToken ?? null; + } catch { + return null; + } finally { + db?.close(); + } +} + +interface JellyfinItem { + Id: string; + Name?: string; + Type?: string; + SeriesName?: string; + ParentIndexNumber?: number; + IndexNumber?: number; + RunTimeTicks?: number; + BackdropImageTags?: string[]; + ParentBackdropItemId?: string; + ParentBackdropImageTags?: string[]; + ImageTags?: { Primary?: string; Thumb?: string }; + ParentThumbItemId?: string; +} + +interface JellyfinSession { + UserName?: string; + Client?: string; + DeviceName?: string; + NowPlayingItem?: JellyfinItem; + PlayState?: { PositionTicks?: number; IsPaused?: boolean; PlayMethod?: string }; + TranscodingInfo?: { Bitrate?: number; IsVideoDirect?: boolean; IsAudioDirect?: boolean }; +} + +/** SxxExx when both indices are known. */ +function episodeCode(season: number | undefined, episode: number | undefined): string | undefined { + if (season === undefined || episode === undefined) return undefined; + return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; +} + +/** A movie's title; an episode's show and code ("The Pitt · S01E12"), which is what fits a line. */ +function sessionTitle(item: JellyfinItem): string { + const name = item.Name || "Untitled"; + if (item.Type === "Episode" && item.SeriesName) { + return `${item.SeriesName} · ${episodeCode(item.ParentIndexNumber, item.IndexNumber) ?? name}`; + } + return name; +} + +/** bps → human bitrate; drops non-positive/unknown. */ +function bitrateLabel(bps: number | undefined): string | undefined { + const n = Number(bps); + if (!Number.isFinite(n) || n <= 0) return undefined; + return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)} Mbps` : `${Math.round(n / 1000)} kbps`; +} + +/** + * What the server does to the picture, the one thing about serving a stream an owner weighs: + * Transcode when the video is re-encoded, DirectPlay otherwise (a remux, or only the audio + * converted, costs little). Jellyfin's PlayMethod says Transcode whenever any stream is + * re-encoded, so the transcoding flags decide. + */ +function videoMethod(session: JellyfinSession): string { + const method = session.PlayState?.PlayMethod; + if (method !== "Transcode") return "DirectPlay"; + return session.TranscodingInfo?.IsVideoDirect ? "DirectPlay" : "Transcode"; +} + +/** + * The lines that take turns under the stream: who is watching where, then how the picture is + * served and, while the server is producing the stream, at what bitrate. + */ +function sessionLines(session: JellyfinSession): string[] { + const who = [session.UserName, session.DeviceName || session.Client].filter(Boolean).join(" · "); + const how = [videoMethod(session), bitrateLabel(session.TranscodingInfo?.Bitrate)].filter(Boolean).join(" · "); + return [who, how].filter(Boolean); +} + +function timecode(ms: number): string { + const total = Math.floor(ms / 1000); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` + : `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +/** + * Which picture of the item is its wide artwork: its own backdrop, its series' backdrop for an + * episode, else its thumb, else its primary image (an episode's primary is a 16:9 still). + */ +function artworkPath(item: JellyfinItem): string | undefined { + if (item.BackdropImageTags?.length) return `/Items/${item.Id}/Images/Backdrop/0`; + if (item.ParentBackdropItemId && item.ParentBackdropImageTags?.length) { + return `/Items/${item.ParentBackdropItemId}/Images/Backdrop/0`; + } + if (item.ImageTags?.Thumb) return `/Items/${item.Id}/Images/Thumb`; + if (item.ParentThumbItemId) return `/Items/${item.ParentThumbItemId}/Images/Thumb`; + if (item.ImageTags?.Primary) return `/Items/${item.Id}/Images/Primary`; + return undefined; +} + +/** A crop of the image filling `width` x `height`, as a data URI, or undefined when over the cap. */ +async function fetchImage( + base: string, + headers: Record, + path: string, + width: number, + height: number, +): Promise { + const response = await fetch(`${base}${path}?fillWidth=${width}&fillHeight=${height}&quality=80&format=Jpg`, { + headers, + }); + if (!response.ok) return undefined; + const type = response.headers.get("content-type") ?? ""; + if (!type.startsWith("image/")) return undefined; + const uri = `data:${type};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}`; + return uri.length <= 60_000 ? uri : undefined; +} + +/** + * The item's wide artwork as a size-capped data URI, for the app card's background while the + * card shows this stream. Near twice the card's size for a sharp picture, then near its own size + * when the sharp one is over the cap. + */ +// A stream's artwork does not change while it plays, and the card asks every few seconds, so each +// picture is fetched once and kept: the box reuses this module between runs until the script +// changes. Only pictures that arrived are kept, so a failed one is tried again on the next run. +const ARTWORK_CACHE_SIZE = 20; +const artworkCache = new Map(); + +function rememberArtwork(key: string, image: string): void { + artworkCache.delete(key); + artworkCache.set(key, image); + const oldest = artworkCache.keys().next().value; + if (artworkCache.size > ARTWORK_CACHE_SIZE && oldest !== undefined) artworkCache.delete(oldest); +} + +async function fetchArtwork( + base: string, + headers: Record, + item: JellyfinItem, +): Promise { + const path = artworkPath(item); + if (!path) return undefined; + const key = `${base}${path}`; + const kept = artworkCache.get(key); + if (kept) return kept; + try { + const image = + (await fetchImage(base, headers, path, 660, 260)) ?? (await fetchImage(base, headers, path, 480, 190)); + if (image) rememberArtwork(key, image); + return image; + } catch { + return undefined; + } +} + +export async function run(ctx: WidgetContext): Promise { + const token = harvestToken(ctx); + if (!token) return { needsSetup: true, reason: SETUP_REASON }; + + const base = `http://${ctx.host}:${ctx.port ?? 30013}`; + const headers = { Accept: "application/json", Authorization: `MediaBrowser Token="${token}"` }; + + const response = await fetch(`${base}/Sessions?activeWithinSeconds=960`, { headers }); + // A session token dies when its device signs out; the next run picks another. + if (response.status === 401) return { needsSetup: true, reason: SETUP_REASON }; + if (!response.ok) throw new Error(`Jellyfin sessions query failed (${response.status})`); + const sessions = ((await response.json()) as JellyfinSession[]).filter( + (session): session is JellyfinSession & { NowPlayingItem: JellyfinItem } => !!session.NowPlayingItem, + ); + + // The count reflects every active stream; the card steps through the first few, one dot each. + const total = sessions.length; + const shown = sessions.slice(0, 5); + const artwork = await Promise.all(shown.map((session) => fetchArtwork(base, headers, session.NowPlayingItem))); + + const entries: WidgetListEntry[] = shown.map((session, i) => { + const item = session.NowPlayingItem; + const paused = session.PlayState?.IsPaused === true; + const positionMs = session.PlayState?.PositionTicks !== undefined + ? Math.floor(session.PlayState.PositionTicks / TICKS_PER_MS) + : undefined; + const durationMs = item.RunTimeTicks ? Math.floor(item.RunTimeTicks / TICKS_PER_MS) : undefined; + const timed = positionMs !== undefined && durationMs !== undefined && durationMs > 0; + return { + title: sessionTitle(item), + // The floor shows the first line; the card takes turns with all of them. + subtitle: sessionLines(session)[0], + subtitles: sessionLines(session), + // Text floor: a static timecode any renderer can show as-is. + meta: [paused ? "paused" : undefined, timed ? `${timecode(positionMs)} / ${timecode(durationMs)}` : undefined] + .filter(Boolean) + .join(" · ") || undefined, + // Enrichment: capable renderers tick this between polls. + elapsed: timed + ? { ms: Math.min(positionMs, durationMs), ofMs: durationMs, state: paused ? "paused" : "running" } + : undefined, + image: artwork[i], + }; + }); + + return { + fields: { + streams: { type: "stat", label: total === 1 ? "Stream" : "Streams", value: String(total) }, + // Each entry carries its own artwork: the card's background names this list, so the + // picture behind the card is the one for the stream on screen. + sessions: { type: "list", entries }, + summary: { + type: "text", + text: total === 0 ? "Nothing playing" : `${total} stream${total === 1 ? "" : "s"} · ${entries[0].title}`, + }, + }, + }; +} diff --git a/jellystat.json b/jellystat.json index f760fc8..f86488d 100644 --- a/jellystat.json +++ b/jellystat.json @@ -39,8 +39,8 @@ "app_values": { "jellystat": { - "db_password": "$RANDOM_STRING(7)", - "jwt_secret": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "jwt_secret": "$RANDOM_STRING(32)", "is_emby_api": "$QUESTION(is_emby_api)", "additional_envs": [] }, diff --git a/linkwarden.json b/linkwarden.json index f4ec31e..a5dd734 100644 --- a/linkwarden.json +++ b/linkwarden.json @@ -23,9 +23,9 @@ ], "app_values": { "linkwarden": { - "db_password": "$RANDOM_STRING(7)", - "nextauth_secret": "$RANDOM_STRING(7)", - "meilisearch_master_key": "$RANDOM_STRING(7)" + "db_password": "$RANDOM_STRING(16)", + "nextauth_secret": "$RANDOM_STRING(32)", + "meilisearch_master_key": "$RANDOM_STRING(32)" }, "storage": { "data": "$HOST_PATH($LOCATION(ApplicationsCapacity)/linkwarden/data)", diff --git a/lubelogger.json b/lubelogger.json index 52c796c..5c7a6d2 100644 --- a/lubelogger.json +++ b/lubelogger.json @@ -23,7 +23,7 @@ ], "app_values": { "lubelogger": { - "db_password": "$RANDOM_STRING(7)" + "db_password": "$RANDOM_STRING(16)" }, "network": { "web_port": { diff --git a/mealie.json b/mealie.json index dec2a76..ae04462 100644 --- a/mealie.json +++ b/mealie.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" @@ -31,7 +31,7 @@ "mealie": { "allow_signup": true, "base_url": "http://$SERVER_LAN_IP:30111/", - "db_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", "default_group": "Home" }, "network": { diff --git a/memos.json b/memos.json index 9222f3c..fdc9283 100644 --- a/memos.json +++ b/memos.json @@ -22,7 +22,7 @@ ], "app_values": { "memos": { - "db_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", "additional_envs": [] }, "network": { diff --git a/mkvtoolnix.json b/mkvtoolnix.json index c95ed96..2f3409f 100644 --- a/mkvtoolnix.json +++ b/mkvtoolnix.json @@ -32,7 +32,7 @@ "Movies" ], "specifications": [ - "2CORE", "1024MB"], + "2CORE", "1GB"], "permissions": [ "READ_WRITE_LOCATIONS" ], diff --git a/navidrome.json b/navidrome.json index 4f40990..8be758b 100644 --- a/navidrome.json +++ b/navidrome.json @@ -175,7 +175,7 @@ "Music" ], "specifications": [ - "2CORE", "1024MB"], + "2CORE", "1GB"], "permissions": [ "READ_WRITE_LOCATIONS" ], diff --git a/nextcloud.json b/nextcloud.json index c643f79..95eb2f5 100644 --- a/nextcloud.json +++ b/nextcloud.json @@ -11,7 +11,7 @@ ], "specifications": [ "2CORE", - "2048MB" + "2GB" ], "permissions": [ "READ_WRITE_LOCATIONS" @@ -28,10 +28,11 @@ }, { "question": "Nextcloud Password", - "description": "Create a password for the admin account. You'll need this to log in via web browser and mobile apps. Choose something secure but memorable!", - "type": "text", + "description": "Create a password for the admin account, or press the button to generate one. You'll need this to log in via web browser and mobile apps, so copy it somewhere safe.", + "type": "password", "key": "admin_password", - "required": true + "required": true, + "default": "$RANDOM_STRING(16)" }, { "question": "Default Phone Region", diff --git a/pairdrop.json b/pairdrop.json index 2a09b28..06cb297 100644 --- a/pairdrop.json +++ b/pairdrop.json @@ -6,7 +6,7 @@ }, "requirements": { "locations": [], - "specifications": ["2CORE", "1024MB"], + "specifications": ["2CORE", "1GB"], "permissions": [], "ports": [30207] }, diff --git a/palworld.json b/palworld.json index ef36913..0e18b21 100644 --- a/palworld.json +++ b/palworld.json @@ -217,7 +217,7 @@ }, { "question": "Input a server password", - "type": "text", + "type": "password", "key": "server_password", "required": false, "default": "", @@ -226,10 +226,10 @@ { "question": "Input the admin password", "description": "This is used to login to the rest api server management tools at http://serverIP:30443. Login username is admin. Read more about the rest api here https://docs.palworldgame.com/category/rest-api", - "type": "text", + "type": "password", "key": "admin_password", "required": true, - "default": "" + "default": "$RANDOM_STRING(16)" }, { "question": "Set Pal spawn randomizer type", @@ -1349,7 +1349,7 @@ "ApplicationsPerformance" ], "permissions": ["READ_WRITE_LOCATIONS"], - "specifications": ["4CORE", "8GB"], + "specifications": ["4CORE", "30GB", "16GBRAM"], "ports": [30443, 38212, 38211] }, "ensure_directories_exists": [ diff --git a/paperless-ngx.json b/paperless-ngx.json index 8dc68b4..c3edf89 100644 --- a/paperless-ngx.json +++ b/paperless-ngx.json @@ -1,14 +1,19 @@ { "version": 5, "script": { - "version": "1.0.1", - "changeLog": "hotfix" + "version": "1.0.2", + "changeLog": "Declared CPU, install size, and RAM requirements" }, "requirements": { "locations": [ "ApplicationsPerformance", "Documents" ], + "specifications": [ + "2CORE", + "2GB", + "2GBRAM" + ], "permissions": [ "READ_WRITE_LOCATIONS" ], @@ -44,15 +49,17 @@ "type": "text", "key": "admin_user", "required": true, - "default": "" + "default": "", + "installonly": true }, { "question": "Choose the Admin Password", "description": "Only used during initial installation", - "type": "text", + "type": "password", "key": "admin_password", "required": true, - "default": "" +"default": "$RANDOM_STRING(16)", + "installonly": true } ], "app_values": { diff --git a/peanut.json b/peanut.json index 68f2c4d..0f12dc4 100644 --- a/peanut.json +++ b/peanut.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/plex.json b/plex.json index 448efa7..6ed1d2a 100644 --- a/plex.json +++ b/plex.json @@ -1,8 +1,8 @@ { - "version": 5, + "version": 6, "script": { - "version": "2.0.0", - "changeLog": "V5 with post-install hooks for automated Plex configuration" + "version": "2.1.0", + "changeLog": "Added the now-playing widget and moved to the v6 install script format" }, "installation_questions": [ { @@ -105,15 +105,13 @@ "hooks": [ { "id": "configure-plex", - "event": "onAfterInstall", - "script": "plex/plex_hook.ts", - "entrypoint": "afterInstall", - "timeout": 300, + "title": "Pre-configure Plex", "description": "Setting up Plex server", + "events": [ + "onAfterInstall" + ], "optional": true, - "retries": 0, "userOptional": { - "label": "Pre-configure Plex", "description": "Sign in to your Plex account to automatically claim your server, set preferences, and create media libraries. By enabling this, you agree that HexOS will accept the Plex Terms of Service on your behalf.", "default": true, "link": { @@ -154,7 +152,40 @@ "placeholder": "HexOS Plex" } } - ] + ], + "script": "plex/plex_hook.ts", + "entrypoint": "afterInstall", + "timeout": 300, + "retries": 0 + } + ], + "widgetsSchema": 3, + "widgets": [ + { + "id": "now-playing", + "title": "Now playing", + "description": "Active Plex streams right now.", + "refresh": 5, + "conditions": [ + { + "role": "availability", + "type": "appRunning", + "app": "plex" + } + ], + "background": { + "field": "sessions" + }, + "slots": [ + { + "type": "list", + "field": "sessions", + "rows": 1 + } + ], + "script": "plex/widget_now_playing.ts", + "entrypoint": "run", + "timeout": 10 } ] } diff --git a/plex/widget_now_playing.ts b/plex/widget_now_playing.ts new file mode 100644 index 0000000..6698d16 --- /dev/null +++ b/plex/widget_now_playing.ts @@ -0,0 +1,233 @@ +// Type imports only: each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. +import { Buffer } from "node:buffer"; +import { readFileSync } from "node:fs"; + +import type { WidgetContext, WidgetQueryResult } from "../_lib/widget_context"; + +const PREFS_RELATIVE = "/Library/Application Support/Plex Media Server/Preferences.xml"; + +function harvestPlexToken(ctx: WidgetContext): string | null { + const config = + ctx.mounts.find((m) => m.containerPath === "/config") ?? + ctx.mounts.find((m) => m.hostPath.endsWith("/plex/config")); + if (!config) return null; + try { + const xml = readFileSync(config.localPath + PREFS_RELATIVE, "utf-8"); + return xml.match(/PlexOnlineToken="([^"]+)"/)?.[1] ?? null; + } catch { + return null; + } +} + +/** A Plex photo-transcoder crop of `path` as a data URI, or undefined when it is over the size cap. */ +async function transcodeImage( + base: string, + token: string, + path: string, + width: number, + height: number, +): Promise { + const response = await fetch( + `${base}/photo/:/transcode?width=${width}&height=${height}&minSize=1&format=jpeg&url=${encodeURIComponent(path)}&X-Plex-Token=${token}`, + ); + if (!response.ok) return undefined; + const type = response.headers.get("content-type") ?? ""; + if (!type.startsWith("image/")) return undefined; + const uri = `data:${type};base64,${Buffer.from(await response.arrayBuffer()).toString("base64")}`; + return uri.length <= 60_000 ? uri : undefined; +} + +/** + * The session's wide artwork as a size-capped data URI, for the app card's background while the + * card shows this session. A card is about 330x130, so the transcoder is asked for a landscape + * crop near twice that for a sharp picture, then near the card's own size when the sharp one is + * over the cap. An item with no wide artwork falls back to its poster, cropped the same way. + */ +// A stream's artwork does not change while it plays, and the card asks every few seconds, so each +// picture is fetched once and kept: the box reuses this module between runs until the script +// changes. Only pictures that arrived are kept, so a failed one is tried again on the next run. +const ARTWORK_CACHE_SIZE = 20; + +/** Streams the last run saw, kept like the artwork: an empty answer is retried only after a busy one. */ +let lastActiveCount = 0; +const artworkCache = new Map(); + +function rememberArtwork(key: string, image: string): void { + artworkCache.delete(key); + artworkCache.set(key, image); + const oldest = artworkCache.keys().next().value; + if (artworkCache.size > ARTWORK_CACHE_SIZE && oldest !== undefined) artworkCache.delete(oldest); +} + +async function fetchArtwork(base: string, token: string, session: PlexSession): Promise { + const path = session.art || session.grandparentArt || session.grandparentThumb || session.thumb; + if (!path) return undefined; + const key = `${base}${path}`; + const kept = artworkCache.get(key); + if (kept) return kept; + try { + const image = + (await transcodeImage(base, token, path, 660, 260)) ?? (await transcodeImage(base, token, path, 480, 190)); + if (image) rememberArtwork(key, image); + return image; + } catch { + return undefined; + } +} + +interface PlexSession { + title: string; + type?: string; + grandparentTitle?: string; + parentIndex?: number; + index?: number; + viewOffset?: number; + duration?: number; + thumb?: string; + grandparentThumb?: string; + art?: string; + grandparentArt?: string; + User?: { title?: string }; + Player?: { title?: string; product?: string; state?: string }; + // The TranscodeSession is the authoritative decision signal; absent means + // direct play. Session.bandwidth is the reserved stream bandwidth in kbps. + Session?: { bandwidth?: number }; + TranscodeSession?: { videoDecision?: string; audioDecision?: string }; +} + +/** SxxExx when both indices are known. */ +function episodeCode(season: number | undefined, episode: number | undefined): string | undefined { + if (season === undefined || episode === undefined) return undefined; + return `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}`; +} + +/** A movie's title; an episode's show and code ("The Pitt · S01E12"), which is what fits a line. */ +function sessionTitle(session: PlexSession): string { + if (session.type === "episode" && session.grandparentTitle) { + const code = episodeCode(session.parentIndex, session.index); + return `${session.grandparentTitle} · ${code ?? session.title}`; + } + return session.title; +} + +/** + * What the server does to the picture, the one thing about serving a stream an + * owner weighs: Transcode when the video is re-encoded, DirectPlay otherwise + * (no TranscodeSession, or one that copies the video, remuxing or converting + * only the audio, which costs little). Decided by videoDecision, the per-stream + * decision; Part.decision cannot tell a remux from a transcode, and reading + * Media[0] misreports multi-version items. + */ +function videoMethod(session: PlexSession): string { + return session.TranscodeSession?.videoDecision === "transcode" ? "Transcode" : "DirectPlay"; +} + +/** kbps → human bitrate; drops non-positive/unknown. */ +function bandwidthLabel(kbps: number | undefined): string | undefined { + const n = Number(kbps); + if (!Number.isFinite(n) || n <= 0) return undefined; + return n >= 1000 ? `${(n / 1000).toFixed(1)} Mbps` : `${Math.round(n)} kbps`; +} + +/** + * The lines that take turns under the stream: who is watching where, then how + * the picture is served and at what bitrate. Either is left out when Plex says + * nothing for it. + */ +function sessionLines(session: PlexSession): string[] { + const who = [session.User?.title, session.Player?.product || session.Player?.title].filter(Boolean).join(" · "); + const how = [videoMethod(session), bandwidthLabel(session.Session?.bandwidth)].filter(Boolean).join(" · "); + return [who, how].filter(Boolean); +} + +function timecode(ms: number): string { + const total = Math.floor(ms / 1000); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}` + : `${minutes}:${String(seconds).padStart(2, "0")}`; +} + +function sessionMeta(session: PlexSession): string | undefined { + const parts: string[] = []; + if (session.Player?.state === "paused") parts.push("paused"); + if (session.viewOffset !== undefined && session.duration) { + parts.push(`${timecode(session.viewOffset)} / ${timecode(session.duration)}`); + } + return parts.join(" · ") || undefined; +} + +export async function run(ctx: WidgetContext): Promise { + const token = harvestPlexToken(ctx); + if (!token) return { needsSetup: true, reason: "Sign in to Plex to see your library here" }; + + const base = `http://${ctx.host}:${ctx.port ?? 32400}`; + const headers = { Accept: "application/json", "X-Plex-Token": token }; + + const fetchActive = async (signal?: AbortSignal): Promise => { + const response = await fetch(`${base}/status/sessions`, { headers, signal }); + if (!response.ok) throw new Error(`Plex sessions query failed (${response.status})`); + const body = (await response.json()) as { MediaContainer?: { Metadata?: PlexSession[] } }; + return body.MediaContainer?.Metadata ?? []; + }; + // /status/sessions has brief empty windows mid-playback (a missed client + // timeline ping): retry before believing a 0, or a blip gets cached for a + // whole refresh cycle. Only when the last run saw streams, though: with + // nothing playing before, an empty answer is simply true, and retrying it + // would hold every poll of an idle server for 1.4 s. Retries are bounded (a + // slow app can't eat the timeout) and a retry failure keeps the + // confirmed-good empty answer. + let active = await fetchActive(); + for (let i = 0; active.length === 0 && lastActiveCount > 0 && i < 2; i++) { + await new Promise((resolve) => setTimeout(resolve, 700)); + try { + active = await fetchActive(AbortSignal.timeout(2000)); + } catch { + break; + } + } + lastActiveCount = active.length; + // The count reflects every active stream; the card steps through the first few, one dot each. + const total = active.length; + const sessions = active.slice(0, 5); + + const artwork = await Promise.all(sessions.map((session) => fetchArtwork(base, token, session))); + + return { + fields: { + streams: { type: "stat", label: total === 1 ? "Stream" : "Streams", value: String(total) }, + // Each entry carries its own artwork: the card's background names this list, so the + // picture behind the card is the one for the stream on screen. + sessions: { + type: "list", + entries: sessions.map((session, i) => ({ + title: sessionTitle(session), + // The floor shows the first line; the card takes turns with all of them. + subtitle: sessionLines(session)[0], + subtitles: sessionLines(session), + // Text floor: a static timecode any renderer can show as-is. + meta: sessionMeta(session), + // Enrichment: capable renderers tick this between polls. + elapsed: + session.viewOffset !== undefined && session.duration + ? { + ms: session.viewOffset, + ofMs: session.duration, + state: session.Player?.state === "paused" ? ("paused" as const) : ("running" as const), + } + : undefined, + image: artwork[i], + })), + }, + summary: { + type: "text", + text: + total === 0 + ? "Nothing playing" + : `${total} stream${total === 1 ? "" : "s"} · ${sessionTitle(sessions[0])}`, + }, + }, + }; +} diff --git a/portracker.json b/portracker.json index e016690..29e3911 100644 --- a/portracker.json +++ b/portracker.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/rackula.json b/rackula.json index 0850186..7bfaa6d 100644 --- a/rackula.json +++ b/rackula.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/scrutiny.json b/scrutiny.json index e222c8b..30fe21a 100644 --- a/scrutiny.json +++ b/scrutiny.json @@ -1,8 +1,8 @@ { - "version": 4, + "version": 6, "script": { - "version": "1.1.0", - "changeLog": "Upgraded to V4 install script format" + "version": "1.2.0", + "changeLog": "Added the disk-health widget and moved to the v6 install script format" }, "requirements": { "locations": [ @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" @@ -58,5 +58,35 @@ "memory": 2048 } } - } + }, + "widgetsSchema": 3, + "widgets": [ + { + "id": "disk-health", + "title": "Disk health", + "description": "S.M.A.R.T. status and temperatures from Scrutiny.", + "refresh": 900, + "conditions": [ + { + "role": "availability", + "type": "appRunning", + "app": "scrutiny" + } + ], + "slots": [ + { + "type": "stat", + "field": "passing" + }, + { + "type": "list", + "field": "disks", + "rows": 2 + } + ], + "script": "scrutiny/widget_disk_health.ts", + "entrypoint": "run", + "timeout": 10 + } + ] } diff --git a/scrutiny/widget_disk_health.ts b/scrutiny/widget_disk_health.ts new file mode 100644 index 0000000..68cc7fe --- /dev/null +++ b/scrutiny/widget_disk_health.ts @@ -0,0 +1,77 @@ +// Type imports only — each script is compiled standalone, so runtime imports of sibling files wouldn't resolve. +import type { WidgetContext, WidgetQueryResult } from "../_lib/widget_context"; + +// Scrutiny's summary API is unauthenticated on the LAN port: one GET carries +// every device's identity, S.M.A.R.T. verdict, and live temperature. +interface ScrutinySummaryEntry { + device?: { + device_name?: string; + model_name?: string; + capacity?: number; + archived?: boolean; + /** 0 = passed; bit 1 = failed S.M.A.R.T., bit 2 = failed Scrutiny thresholds. */ + device_status?: number; + }; + smart?: { + temp?: number; + power_on_hours?: number; + }; +} + +function capacityText(bytes: number | undefined): string | undefined { + if (!bytes) return undefined; + const tb = bytes / 1_000_000_000_000; + if (tb >= 1) return `${tb >= 10 ? Math.round(tb) : Math.round(tb * 10) / 10} TB`; + return `${Math.round(bytes / 1_000_000_000)} GB`; +} + +export async function run(ctx: WidgetContext): Promise { + const base = `http://${ctx.host}:${ctx.port ?? 31054}`; + + const response = await fetch(`${base}/api/summary`, { headers: { Accept: "application/json" } }); + if (!response.ok) throw new Error(`Scrutiny summary query failed (${response.status})`); + const body = (await response.json()) as { data?: { summary?: Record } }; + + const devices = Object.values(body.data?.summary ?? {}).filter((entry) => !entry.device?.archived); + const failing = devices.filter((entry) => (entry.device?.device_status ?? 0) !== 0); + + // Failing disks lead the list — the whole point of the glance. + const ordered = [...failing, ...devices.filter((entry) => !failing.includes(entry))]; + + return { + fields: { + passing: { + type: "stat", + label: "Disks passing", + value: `${devices.length - failing.length}/${devices.length}`, + }, + disks: { + type: "list", + // Cap at the platform's per-list ceiling (20) so a large array can't + // fail result validation; failing disks sort first, so the cap never + // hides a problem, and only the top few actually render. + entries: ordered.slice(0, 20).map((entry) => ({ + title: entry.device?.model_name || entry.device?.device_name || "Unknown disk", + subtitle: [entry.device?.device_name, capacityText(entry.device?.capacity)] + .filter(Boolean) + .join(" · ") || undefined, + meta: [ + (entry.device?.device_status ?? 0) !== 0 ? "FAILING" : undefined, + entry.smart?.temp !== undefined ? `${entry.smart.temp}°C` : undefined, + ] + .filter(Boolean) + .join(" · ") || undefined, + })), + }, + status: { + type: "text", + text: + devices.length === 0 + ? "Waiting for the first disk report" + : failing.length === 0 + ? `All ${devices.length} disks passing` + : `${failing.length} of ${devices.length} disks FAILING`, + }, + }, + }; +} diff --git a/seerr.json b/seerr.json index ad23841..ba5e16a 100644 --- a/seerr.json +++ b/seerr.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "1024MB" + "1GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/snapotter.json b/snapotter.json index 460cd34..c6126dc 100644 --- a/snapotter.json +++ b/snapotter.json @@ -12,16 +12,18 @@ "type": "text", "key": "DEFAULT_USERNAME", "required": true, - "default": "" + "default": "", + "installonly": true }, { "question": "Choose the Admin Password", "description": "Only used on installation", "placeholder": "", - "type": "text", + "type": "password", "key": "DEFAULT_PASSWORD", "required": true, - "default": "" +"default": "$RANDOM_STRING(16)", + "installonly": true } ], "requirements": { @@ -30,7 +32,7 @@ ], "specifications": [ "4CORE", - "4096MB" + "4GB" ], "permissions": [ "READ_WRITE_LOCATIONS" @@ -60,8 +62,8 @@ } }, "snapotter": { - "db_password": "$RANDOM_STRING(7)", - "redis_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", + "redis_password": "$RANDOM_STRING(16)", "additional_envs": [ { "name": "DEFAULT_USERNAME", diff --git a/sure.json b/sure.json index d3e07c6..b3bbb9c 100644 --- a/sure.json +++ b/sure.json @@ -10,7 +10,7 @@ ], "specifications": [ "2CORE", - "2048MB" + "2GB" ], "permissions": [ "READ_WRITE_LOCATIONS" diff --git a/warracker.json b/warracker.json index 3a4ed03..c6138b0 100644 --- a/warracker.json +++ b/warracker.json @@ -1,11 +1,11 @@ { "version": 5, "script": { - "version": "1.0.0", - "changeLog": "initial install script" + "version": "1.0.1", + "changeLog": "Corrected the declared locations to match the ones used" }, "requirements": { - "locations": ["ApplicationsPerformance", "documents"], + "locations": ["ApplicationsPerformance", "ApplicationsCapacity"], "specifications": ["2CORE", "256MB"], "permissions": ["READ_WRITE_LOCATIONS"], "ports": [30148] @@ -22,7 +22,7 @@ ], "app_values": { "warracker": { - "db_password": "$RANDOM_STRING(7)" + "db_password": "$RANDOM_STRING(16)" }, "network": { "web_port": { diff --git a/wiki-js.json b/wiki-js.json index 5df5e18..6b05086 100644 --- a/wiki-js.json +++ b/wiki-js.json @@ -22,7 +22,7 @@ ], "app_values": { "wiki_js": { - "db_password": "$RANDOM_STRING(7)", + "db_password": "$RANDOM_STRING(16)", "additional_envs": [] }, "network": {