mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
feat(plugins): hero widget slot + Koffi reference plugin
Widget plugins can now declare capabilities.widget.slot 'hero' to render as a transparent, click-through overlay sitting on the boarding-pass bar's top edge (migration 157 persists capabilities; manifest validation server+SDK, feed exposes the slot, dashboard mounts hero frames above the pass). Sidebar stays the default slot. Replaces the trip-countdown example with Koffi, the TREK mascot: an animated suitcase with a 14-state behavior engine driven by real trip data — walking, waving, napping, trolley rolls, passport-stamp stickers, a split-flap luggage- tag countdown under 7 days, and sunglasses while the trip runs. Validated by the SDK suite like any author plugin; published as mauriceboe/trek-plugin-koffi in the registry. Migrations 156/157 follow the idempotent ALTER pattern (the reconciliation test re-runs everything from v135, so duplicate-column must stay non-fatal).
This commit is contained in:
@@ -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); }
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
const out: PluginCapabilities = {};
|
||||
if (c.widget && typeof c.widget === 'object') {
|
||||
const w = c.widget as Record<string, unknown>;
|
||||
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
|
||||
|
||||
@@ -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<Omit<ActivePlugin, 'slot'> & { 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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user