diff --git a/client/src/pages/DashboardPage.tsx b/client/src/pages/DashboardPage.tsx index 97fd0161a..33883343c 100644 --- a/client/src/pages/DashboardPage.tsx +++ b/client/src/pages/DashboardPage.tsx @@ -21,6 +21,7 @@ import { import { IcsSubscribeModal } from '../components/Planner/IcsSubscribeModal' import CollectionsWidget from '../components/Dashboard/CollectionsWidget' import PluginWidgets from '../components/Plugins/PluginWidgets' +import PluginFrame from '../components/Plugins/PluginFrame' import { usePluginStore } from '../store/pluginStore' import { formatTime, splitReservationDateTime } from '../utils/formatters' import { convertDistance, getDistanceUnitLabel } from '../utils/units' @@ -127,7 +128,7 @@ export default function DashboardPage(): React.ReactElement { const isAddonEnabled = useAddonStore(s => s.isEnabled) const showCollections = isAddonEnabled('collections') && sideWidgets.collections // Desktop has a master toggle for the whole right column; off → centered layout. - const widgetPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget') + const widgetPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot !== 'hero') const sidebarVisible = (isMobile || dashCfg.desktop.sidebar) && (showCurrency || showCollections || showTimezones || showUpcoming || widgetPlugins.length > 0) return ( @@ -293,6 +294,7 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch }): React.ReactElement { const { t } = useTranslation() const mobile = useIsMobile() + const heroPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'hero') const stop = (e: React.MouseEvent, fn: () => void) => { e.stopPropagation(); fn() } const status = getTripStatus(trip) const start = splitDate(trip.start_date, locale) @@ -408,7 +410,16 @@ function BoardingPassHero({ trip, bundle, locale, onOpen, onEdit, onCopy, onArch {!mobile && ( -
{ e.stopPropagation(); onOpen() }}>{passCells}
+
+ {heroPlugins.length > 0 && ( + + )} +
{ e.stopPropagation(); onOpen() }}>{passCells}
+
)} diff --git a/client/src/store/pluginStore.ts b/client/src/store/pluginStore.ts index ae098b999..7e327a807 100644 --- a/client/src/store/pluginStore.ts +++ b/client/src/store/pluginStore.ts @@ -12,6 +12,7 @@ export interface ActivePlugin { name: string type: 'integration' | 'page' | 'widget' icon: string | null + slot?: 'sidebar' | 'hero' } interface PluginState { @@ -21,6 +22,7 @@ interface PluginState { getById: (id: string) => ActivePlugin | undefined pages: () => ActivePlugin[] widgets: () => ActivePlugin[] + heroWidgets: () => ActivePlugin[] } export const usePluginStore = create((set, get) => ({ @@ -38,5 +40,6 @@ export const usePluginStore = create((set, get) => ({ getById: (id) => get().plugins.find((p) => p.id === id), pages: () => get().plugins.filter((p) => p.type === 'page'), - widgets: () => get().plugins.filter((p) => p.type === 'widget'), + widgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot !== 'hero'), + heroWidgets: () => get().plugins.filter((p) => p.type === 'widget' && p.slot === 'hero'), })) diff --git a/client/src/styles/dashboard.css b/client/src/styles/dashboard.css index 4a1d74323..321c6d159 100644 --- a/client/src/styles/dashboard.css +++ b/client/src/styles/dashboard.css @@ -262,6 +262,19 @@ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: xor; mask-composite: exclude; pointer-events: none; } + +/* Hero-slot plugin overlay: a transparent strip whose bottom edge sits on the + pass's top edge, so a plugin (e.g. the mascot) appears to stand ON the bar. + pointer-events none — it must never block the pass or hero interactions. */ +.trek-dash .hero-pass-wrap { position: relative; } +.trek-dash .hero-pass-overlay { + position: absolute; left: 16px; right: 16px; bottom: 100%; height: 110px; + pointer-events: none; z-index: 3; overflow: hidden; +} +.trek-dash .hero-overlay-frame { + position: absolute; inset: 0; width: 100%; height: 100% !important; + border: 0; background: transparent; color-scheme: light; +} .trek-dash .pass-cell { padding: 4px 18px; position: relative; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; gap: 6px; flex: 1; min-width: 0; diff --git a/plugin-sdk/examples/koffi/README.md b/plugin-sdk/examples/koffi/README.md new file mode 100644 index 000000000..013a73421 --- /dev/null +++ b/plugin-sdk/examples/koffi/README.md @@ -0,0 +1,47 @@ +# Koffi + +> A tiny animated suitcase mascot that lives on your boarding pass. + +![Koffi strolling along the boarding-pass bar](./docs/screenshot.png) + +## What it does + +Koffi is TREK's mascot — a little vintage suitcase with travel stickers, a +swinging luggage tag, and big friendly eyes. He walks along the top edge of the +dashboard's boarding-pass bar, looks around, waves at you, reads his map, naps +(only when there's plenty of time), rolls a stretch in trolley mode, and +collects passport-stamp stickers. + +He also reacts to your actual trip: the closer the departure, the more excited +he gets — under 7 days he jumps for joy and his luggage tag flips into an +airport-style split-flap countdown; while the trip is running he puts on tiny +sunglasses. Vacation mode. + +## Screenshots + +![Koffi on the dashboard hero](./docs/screenshot.png) + +## Permissions + +| Permission | Why this plugin needs it | +|---|---| +| `db:read:trips` | To read the current trip's start/end dates so Koffi knows how excited to be. The read is membership-checked by TREK, so he only ever sees a trip you already have access to. | + +No network access, no data storage of its own — Koffi just vibes. + +## Setup + +None. Enable plugins on your instance (`TREK_PLUGINS_ENABLED=true`), install +and activate Koffi, and he appears on the boarding-pass bar of your dashboard +hero. He respects `prefers-reduced-motion` (static pose, no locomotion). + +Requires TREK ≥ 3.2.0 (hero widget slot). + +## Building + +Plain JS/HTML — no build step. `server/index.js` is the plugin entry; +`client/index.html` is the mascot iframe. + +## License + +MIT — see the TREK repository. diff --git a/plugin-sdk/examples/koffi/client/index.html b/plugin-sdk/examples/koffi/client/index.html new file mode 100644 index 000000000..7dd94a850 --- /dev/null +++ b/plugin-sdk/examples/koffi/client/index.html @@ -0,0 +1,956 @@ + + + + + +Koffi + + + + +
+ + +
+
+ +
+
+ + +
+ z + z + z +
+
+ + + + diff --git a/plugin-sdk/examples/koffi/docs/screenshot.png b/plugin-sdk/examples/koffi/docs/screenshot.png new file mode 100644 index 000000000..1b06adcd8 Binary files /dev/null and b/plugin-sdk/examples/koffi/docs/screenshot.png differ diff --git a/plugin-sdk/examples/koffi/server/index.js b/plugin-sdk/examples/koffi/server/index.js new file mode 100644 index 000000000..36d58304e --- /dev/null +++ b/plugin-sdk/examples/koffi/server/index.js @@ -0,0 +1,42 @@ +// Koffi — the TREK suitcase mascot (hero-slot widget). +// +// One route: GET /state?tripId= → { days, ongoing, title }. The mascot's mood is +// driven by how close the trip is. The host membership-checks the trip against +// the acting user, so Trekki can only ever see a trip that user can see. + +const DAY_MS = 86400000; + +function midnight(iso) { + const d = new Date(iso + 'T00:00:00'); + return Number.isNaN(d.getTime()) ? null : d.getTime(); +} + +module.exports = { + routes: [ + { + method: 'GET', + path: '/state', + auth: true, + async handler(req, ctx) { + const json = { 'content-type': 'application/json' }; + const tripId = Number(req.query.tripId); + if (!tripId) return { status: 200, headers: json, body: JSON.stringify({ days: null, ongoing: false, title: null }) }; + + const t = await ctx.trips.getById(tripId, req.user.id); + if (!t) return { status: 200, headers: json, body: JSON.stringify({ days: null, ongoing: false, title: null }) }; + + const now = new Date(); + now.setHours(0, 0, 0, 0); + const start = t.start_date ? midnight(t.start_date) : null; + const end = t.end_date ? midnight(t.end_date) : null; + + const ongoing = start !== null && start <= now.getTime() && (end === null || now.getTime() <= end); + const days = !ongoing && start !== null && start >= now.getTime() + ? Math.round((start - now.getTime()) / DAY_MS) + : null; + + return { status: 200, headers: json, body: JSON.stringify({ days, ongoing, title: t.title ?? null }) }; + }, + }, + ], +}; diff --git a/plugin-sdk/examples/koffi/trek-plugin.json b/plugin-sdk/examples/koffi/trek-plugin.json new file mode 100644 index 000000000..c09e4386c --- /dev/null +++ b/plugin-sdk/examples/koffi/trek-plugin.json @@ -0,0 +1,21 @@ +{ + "id": "koffi", + "name": "Koffi", + "version": "1.0.0", + "apiVersion": 1, + "author": "TREK", + "description": "Koffi, the tiny suitcase mascot, lives on your boarding pass — strolling, rolling, collecting stickers, and getting more excited the closer your trip gets.", + "homepage": "https://github.com/mauriceboe/trek-plugin-koffi", + "license": "MIT", + "icon": "Luggage", + "type": "widget", + "trek": ">=3.2.0 <4.0.0", + "nativeModules": false, + "permissions": ["db:read:trips"], + "capabilities": { + "widget": { "title": "Koffi", "slot": "hero" } + }, + "routes": [ + { "method": "GET", "path": "/state", "auth": true } + ] +} diff --git a/plugin-sdk/examples/trip-countdown/README.md b/plugin-sdk/examples/trip-countdown/README.md deleted file mode 100644 index 049c98d13..000000000 --- a/plugin-sdk/examples/trip-countdown/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Trip Countdown - -> A dashboard widget that counts down the days to your next trip. - -![Trip Countdown widget on the dashboard](./docs/screenshot.png) - -## What it does - -Trip Countdown adds a small dashboard widget showing how many days remain until -your upcoming trip starts. When a trip is under way it shows "in progress" -instead. It's the reference plugin for the TREK plugin system — a minimal but -complete widget that reads trip data through the sandbox and renders it in an -isolated iframe. - -## Screenshots - -![The widget counting down to a trip](./docs/screenshot.png) - -## Permissions - -| Permission | Why this plugin needs it | -|---|---| -| `db:read:trips` | To read the current trip's start date so it can compute the countdown. The read is membership-checked by TREK, so the widget only ever sees a trip you already have access to. | - -It requests **no** network access and stores **no** data of its own. - -## Setup - -None. Enable plugins on your instance (`TREK_PLUGINS_ENABLED=true`), install and -activate Trip Countdown, and the widget appears on your dashboard. - -## Building - -The widget ships plain JS/HTML — no build step. `server/index.js` is the plugin -entry; `client/index.html` is the widget iframe. - -## License - -MIT — see the TREK repository. diff --git a/plugin-sdk/examples/trip-countdown/client/index.html b/plugin-sdk/examples/trip-countdown/client/index.html deleted file mode 100644 index b35d45dea..000000000 --- a/plugin-sdk/examples/trip-countdown/client/index.html +++ /dev/null @@ -1,62 +0,0 @@ - - - - - Trip Countdown - - - -
Loading…
- - - diff --git a/plugin-sdk/examples/trip-countdown/server/index.js b/plugin-sdk/examples/trip-countdown/server/index.js deleted file mode 100644 index 03e48f6bf..000000000 --- a/plugin-sdk/examples/trip-countdown/server/index.js +++ /dev/null @@ -1,43 +0,0 @@ -// Trip Countdown — reference widget plugin. -// -// Demonstrates the full author path: definePlugin, a permission-gated route that -// reads trip data through ctx, and a response the client iframe renders. Runs -// isolated; ctx is the only way to reach TREK. -const { definePlugin } = require('trek-plugin-sdk') - -/** Days (integer, >= 0) between now and an ISO date, or null if past/absent. */ -function daysUntil(iso) { - if (!iso) return null - const then = new Date(iso).getTime() - if (Number.isNaN(then)) return null - const diff = Math.ceil((then - Date.now()) / 86_400_000) - return diff >= 0 ? diff : null -} - -module.exports = definePlugin({ - routes: [ - { - method: 'GET', - path: '/next', - auth: true, - async handler(req, ctx) { - // The client passes the dashboard's current trip id; the host enforces that - // req.user actually has access to it, so a plugin can't read foreign trips. - const tripId = Number(req.query.tripId) - if (!tripId) return { status: 200, headers: json(), body: JSON.stringify({ trip: null }) } - - const t = await ctx.trips.getById(tripId, req.user.id) - const days = daysUntil(t && (t.start_date || t.startDate)) - return { - status: 200, - headers: json(), - body: JSON.stringify({ trip: t ? { name: t.title, days } : null }), - } - }, - }, - ], -}) - -function json() { - return { 'content-type': 'application/json' } -} diff --git a/plugin-sdk/examples/trip-countdown/trek-plugin.json b/plugin-sdk/examples/trip-countdown/trek-plugin.json deleted file mode 100644 index f84fa5e46..000000000 --- a/plugin-sdk/examples/trip-countdown/trek-plugin.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "id": "trip-countdown", - "name": "Trip Countdown", - "version": "1.0.0", - "apiVersion": 1, - "author": "TREK", - "description": "A dashboard widget counting down the days to your next trip.", - "homepage": "https://github.com/mauriceboe/TREK", - "license": "MIT", - "icon": "CalendarClock", - "type": "widget", - "trek": ">=3.2.0 <4.0.0", - "nativeModules": false, - "permissions": ["db:read:trips"], - "capabilities": { - "widget": { "title": "Trip Countdown", "defaultSize": "small" } - }, - "routes": [ - { "method": "GET", "path": "/next", "auth": true } - ] -} diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 58befa6c5..642afa8bf 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -56,6 +56,11 @@ export function validateManifest(raw: unknown): ValidationResult { if (wantsOutbound && egress.length === 0) errors.push('http:outbound declared but egress[] is empty'); if (egress.includes('*')) errors.push('egress[] must not contain a bare "*"'); + const widget = (m.capabilities as { widget?: { slot?: unknown } } | undefined)?.widget; + if (widget?.slot !== undefined && widget.slot !== 'sidebar' && widget.slot !== 'hero') { + errors.push(`widget slot must be "sidebar" or "hero", got "${String(widget.slot)}"`); + } + if (errors.length) return { ok: false, errors }; return { ok: true, diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index eb0b13ec7..362c0c703 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -86,8 +86,8 @@ describe('scaffold + validate CLIs', () => { }); }); -describe('reference plugin (examples/trip-countdown)', () => { - const dir = path.resolve(import.meta.dirname, '..', 'examples', 'trip-countdown'); +describe('reference plugin (examples/koffi)', () => { + const dir = path.resolve(import.meta.dirname, '..', 'examples', 'koffi'); it('passes the same validation authors run', () => { const r = validatePluginDir(dir); @@ -95,10 +95,13 @@ describe('reference plugin (examples/trip-countdown)', () => { expect(r.ok).toBe(true); }); - it('has a valid, minimal-permission manifest', () => { - const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')); + it('has a valid, minimal-permission hero-widget manifest', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trek-plugin.json'), 'utf8')) as { + capabilities?: { widget?: { slot?: string } }; + }; const res = validateManifest(manifest); expect(res.ok).toBe(true); expect(res.manifest?.permissions).toEqual(['db:read:trips']); + expect(manifest.capabilities?.widget?.slot).toBe('hero'); }); }); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 8d4bfd82c..f09621786 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -3357,10 +3357,18 @@ function runMigrations(db: Database.Database): void { // erased the "keep it on" intent, so the plugin never rebooted after a deploy. // Boot now retries every enabled plugin regardless of last status. () => { - db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;"); - // Anything not explicitly deactivated was meant to be on ('inactive' is the - // only status deactivate() sets; a crash/shutdown could leave error/stopped/starting). - db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';"); + try { + db.exec("ALTER TABLE plugins ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;"); + // Anything not explicitly deactivated was meant to be on ('inactive' is the + // only status deactivate() sets; a crash/shutdown could leave error/stopped/starting). + db.exec("UPDATE plugins SET enabled = 1 WHERE status != 'inactive';"); + } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } + }, + // Migration 157: plugin capabilities (from trek-plugin.json) — the client + // needs them to place widgets (e.g. widget.slot 'hero' renders as an overlay + // on the boarding-pass bar instead of the dashboard sidebar). + () => { + try { db.exec("ALTER TABLE plugins ADD COLUMN capabilities TEXT NOT NULL DEFAULT '{}';"); } catch (err) { console.warn('[migrations] Non-fatal migration step failed:', err); } }, ]; diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts index d8e8d5160..662929ab9 100644 --- a/server/src/nest/plugins/install/discovery.ts +++ b/server/src/nest/plugins/install/discovery.ts @@ -45,13 +45,13 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void { if (existing) { db.prepare( `UPDATE plugins SET name = ?, description = ?, type = ?, icon = ?, version = ?, api_version = ?, - min_trek_version = ?, permissions = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - ).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), m.id); + min_trek_version = ?, permissions = ?, capabilities = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + ).run(m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities), m.id); } else { db.prepare( - `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, granted_permissions, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', 'inactive')`, - ).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions)); + `INSERT INTO plugins (id, name, description, type, icon, version, api_version, min_trek_version, permissions, capabilities, granted_permissions, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', 'inactive')`, + ).run(m.id, m.name, m.description ?? null, m.type, m.icon ?? 'Blocks', m.version, m.apiVersion, m.minTrekVersion ?? null, JSON.stringify(m.permissions), JSON.stringify(m.capabilities)); } // Refresh the settings-field descriptors from the manifest. diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts index 96778fa2f..cb60ebd50 100644 --- a/server/src/nest/plugins/install/manifest.ts +++ b/server/src/nest/plugins/install/manifest.ts @@ -20,6 +20,17 @@ export interface ManifestSettingField { oauth?: { initPath?: string; callbackPath?: string }; } +export interface WidgetCapability { + title?: string; + defaultSize?: string; + /** Where the widget mounts: dashboard sidebar (default) or as a hero-bar overlay. */ + slot?: 'sidebar' | 'hero'; +} + +export interface PluginCapabilities { + widget?: WidgetCapability; +} + export interface PluginManifest { id: string; name: string; @@ -36,6 +47,7 @@ export interface PluginManifest { permissions: string[]; egress: string[]; settings: ManifestSettingField[]; + capabilities: PluginCapabilities; } const ID_RE = /^[a-z][a-z0-9-]{2,39}$/; @@ -84,9 +96,27 @@ export function parseManifest(raw: unknown): PluginManifest { permissions, egress, settings: parseSettings(m.settings), + capabilities: parseCapabilities(m.capabilities), }; } +function parseCapabilities(raw: unknown): PluginCapabilities { + if (!raw || typeof raw !== 'object') return {}; + const c = raw as Record; + const out: PluginCapabilities = {}; + if (c.widget && typeof c.widget === 'object') { + const w = c.widget as Record; + const slot = optStr(w.slot); + if (slot && slot !== 'sidebar' && slot !== 'hero') throw new ManifestError(`invalid widget slot "${slot}"`); + out.widget = { + title: optStr(w.title), + defaultSize: optStr(w.defaultSize), + slot: (slot as WidgetCapability['slot']) ?? 'sidebar', + }; + } + return out; +} + function parseSettings(raw: unknown): ManifestSettingField[] { if (!Array.isArray(raw)) return []; return raw diff --git a/server/src/nest/plugins/plugins-feed.controller.ts b/server/src/nest/plugins/plugins-feed.controller.ts index 7a1ee6b93..5d63d2d44 100644 --- a/server/src/nest/plugins/plugins-feed.controller.ts +++ b/server/src/nest/plugins/plugins-feed.controller.ts @@ -15,6 +15,7 @@ interface ActivePlugin { name: string; type: string; icon: string | null; + slot: 'sidebar' | 'hero'; } @Controller('api/plugins') @@ -23,9 +24,19 @@ export class PluginsFeedController { @Get() list(): { plugins: ActivePlugin[] } { if (!pluginsEnabled()) return { plugins: [] }; - const plugins = db - .prepare("SELECT id, name, type, icon FROM plugins WHERE status = 'active' ORDER BY sort_order, name") - .all() as ActivePlugin[]; + const rows = db + .prepare("SELECT id, name, type, icon, capabilities FROM plugins WHERE status = 'active' ORDER BY sort_order, name") + .all() as Array & { capabilities: string }>; + const plugins = rows.map(({ capabilities, ...p }) => ({ ...p, slot: slotOf(capabilities) })); return { plugins }; } } + +function slotOf(capabilities: string): 'sidebar' | 'hero' { + try { + const c = JSON.parse(capabilities || '{}') as { widget?: { slot?: string } }; + return c.widget?.slot === 'hero' ? 'hero' : 'sidebar'; + } catch { + return 'sidebar'; + } +} diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index 4d3cf1cdc..6774eb0c8 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => { const db = new Database(':memory:'); db.exec(` CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, - api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT, + api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT, source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, updated_at TEXT); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts index 2daac5d4f..ab035c54d 100644 --- a/server/tests/unit/plugins/discovery.test.ts +++ b/server/tests/unit/plugins/discovery.test.ts @@ -27,7 +27,7 @@ beforeEach(() => { db = new Database(':memory:'); db.exec(` CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, - api_version INTEGER, min_trek_version TEXT, permissions TEXT, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT); + api_version INTEGER, min_trek_version TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); diff --git a/server/tests/unit/plugins/manifest.test.ts b/server/tests/unit/plugins/manifest.test.ts index b2432fa1d..1ef38eeab 100644 --- a/server/tests/unit/plugins/manifest.test.ts +++ b/server/tests/unit/plugins/manifest.test.ts @@ -47,3 +47,17 @@ describe('parseManifest', () => { expect(() => parseManifest(input)).toThrow(re as RegExp); }); }); + +describe('parseManifest capabilities', () => { + it('parses a hero widget slot and defaults to sidebar', () => { + const hero = parseManifest({ ...base, capabilities: { widget: { slot: 'hero', title: 'T' } } }); + expect(hero.capabilities.widget?.slot).toBe('hero'); + const plain = parseManifest({ ...base, capabilities: { widget: {} } }); + expect(plain.capabilities.widget?.slot).toBe('sidebar'); + expect(parseManifest(base).capabilities).toEqual({}); + }); + + it('rejects an unknown widget slot', () => { + expect(() => parseManifest({ ...base, capabilities: { widget: { slot: 'floating' } } })).toThrow(ManifestError); + }); +}); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 6a3cfb43f..49b343860 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -9,7 +9,7 @@ const { testDb } = vi.hoisted(() => { const db = new Database(':memory:'); db.exec(`CREATE TABLE plugins ( id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, - status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', updated_at TEXT, + status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', capabilities TEXT DEFAULT '{}', updated_at TEXT, sort_order INTEGER DEFAULT 0); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`); @@ -66,11 +66,20 @@ describe('PluginsFeedController (client feed)', () => { process.env.TREK_PLUGINS_ENABLED = 'true'; const active = feed.list(); - expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box' }]); + expect(active.plugins).toEqual([{ id: 'w', name: 'W', type: 'widget', icon: 'Box', slot: 'sidebar' }]); process.env.TREK_PLUGINS_ENABLED = 'false'; expect(feed.list().plugins).toEqual([]); }); + + it('exposes the widget slot from capabilities (hero) and defaults on bad JSON', () => { + testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('h','H','widget','Box','active','{\"widget\":{\"slot\":\"hero\"}}')").run(); + testDb.prepare("INSERT INTO plugins (id, name, type, icon, status, capabilities) VALUES ('b','B','widget','Box','active','not-json')").run(); + process.env.TREK_PLUGINS_ENABLED = 'true'; + const out = new PluginsFeedController().list(); + expect(out.plugins.find((p) => p.id === 'h')?.slot).toBe('hero'); + expect(out.plugins.find((p) => p.id === 'b')?.slot).toBe('sidebar'); + }); }); describe('PluginsController M2 endpoints', () => {