diff --git a/README.md b/README.md index 44deee7..08aa36f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/yellow_img.png)](https://buymeacoffee.com/wojo_o) -# Inker v0.2.1.1 +# Inker v0.3.0 -Self-hosted e-ink device management system for [TRMNL](https://usetrmnl.com/) devices and BYOD e-ink displays. Design screens, create custom widgets, and manage your displays from a modern web interface. +Self-hosted e-ink device management server built for the homelab community. Works with [TRMNL](https://usetrmnl.com/) devices (supports firmware 1.7.8) and any BYOD e-ink display. Design screens, create custom widgets with live data from your local network, and manage your displays from a modern web interface. + +Inker is heading in its own direction — focusing on homelab integrations like server monitoring, smart home dashboards, network stats, and self-hosted service displays. TRMNL device compatibility is maintained, but the plugin ecosystem will be Inker-native. ![Dashboard](https://github.com/user-attachments/assets/fd9affac-5c57-4448-9338-ea8f83add08a) @@ -11,9 +13,11 @@ Self-hosted e-ink device management system for [TRMNL](https://usetrmnl.com/) de - **Screen Designer** — Drag & drop widget placement, snap guides, freehand drawing, auto-fit zoom for any resolution - **Built-in Widgets** — Clock, date, text, weather, countdown, days until, QR code, image, GitHub stars, battery, WiFi, device info - **Custom Widgets** — Connect to any JSON API or RSS feed (including local network sources), JavaScript transformations, grid layouts +- **Plugins** — Coming soon — homelab-native integrations for server monitoring, smart home, network stats - **Playlists** — Rotate multiple screens on devices automatically -- **Device Management** — Auto-provisioning, BYOD support with custom resolutions, real-time status, logs +- **Device Management** — Auto-provisioning, firmware 1.7.8 support, real-time status, logs - **BYOD Support** — Register any e-ink device manually with custom screen resolution +- **Custom Ports** — Run on any port with Docker port mapping (e.g. `800:80`) ## Screenshots @@ -101,6 +105,17 @@ cd inker docker compose up -d --build ``` +## Updating + +```bash +docker compose pull +docker compose up -d +``` + +All data (screens, devices, playlists, settings) is preserved — database schema updates are applied automatically on startup. + +> **Warning:** Never use `docker compose down -v` to update — the `-v` flag deletes all volumes and you will lose your data. + ## Troubleshooting If something isn't working after an update or on first run, reset the volumes and start fresh: diff --git a/backend/bun.lock b/backend/bun.lock index d1b97df..d693337 100644 --- a/backend/bun.lock +++ b/backend/bun.lock @@ -24,6 +24,7 @@ "helmet": "^7.1.0", "ioredis": "^5.3.2", "joi": "^17.11.0", + "liquidjs": "^10.25.1", "nest-winston": "^1.9.4", "passport": "^0.7.0", "puppeteer": "^23.11.0", @@ -819,6 +820,8 @@ "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + "liquidjs": ["liquidjs@10.25.1", "", { "dependencies": { "commander": "^10.0.0" }, "bin": { "liquidjs": "bin/liquid.js", "liquid": "bin/liquid.js" } }, "sha512-D+jsJvkGigFn8qNUgh8U6XNHhGFBp+p8Dk26ea/Hl+XrjFVSg9OXlN31hGAfS3MYQ3Kr8Xi9sEVBVQa/VTVSmg=="], + "loader-runner": ["loader-runner@4.3.1", "", {}, "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], @@ -1313,6 +1316,8 @@ "jest-worker/supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], + "liquidjs/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], diff --git a/backend/package.json b/backend/package.json index edf716b..9e736c4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "inker-backend", - "version": "0.2.1.1", + "version": "0.3.0", "description": "Inker Server Backend - E-ink Device Management", "main": "dist/main.js", "scripts": { @@ -42,6 +42,7 @@ "helmet": "^7.1.0", "ioredis": "^5.3.2", "joi": "^17.11.0", + "liquidjs": "^10.25.1", "nest-winston": "^1.9.4", "passport": "^0.7.0", "puppeteer": "^23.11.0", diff --git a/backend/prisma/migrations/20260322_add_plugin_models/migration.sql b/backend/prisma/migrations/20260322_add_plugin_models/migration.sql new file mode 100644 index 0000000..ce10b0d --- /dev/null +++ b/backend/prisma/migrations/20260322_add_plugin_models/migration.sql @@ -0,0 +1,64 @@ +-- CreateTable +CREATE TABLE "plugins" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "icon" TEXT, + "category" TEXT NOT NULL DEFAULT 'custom', + "data_strategy" TEXT NOT NULL DEFAULT 'polling', + "data_url" TEXT, + "data_method" TEXT NOT NULL DEFAULT 'GET', + "data_headers" JSONB, + "data_path" TEXT, + "data_transform" TEXT, + "refresh_interval" INTEGER NOT NULL DEFAULT 300, + "markup_full" TEXT, + "markup_half_horizontal" TEXT, + "markup_half_vertical" TEXT, + "markup_quadrant" TEXT, + "settings_schema" JSONB, + "oauth_provider" TEXT, + "oauth_scopes" TEXT, + "is_installed" BOOLEAN NOT NULL DEFAULT false, + "is_builtin" BOOLEAN NOT NULL DEFAULT false, + "source" TEXT NOT NULL DEFAULT 'inker', + "source_url" TEXT, + "source_hash" TEXT, + "version" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "plugins_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "plugin_instances" ( + "id" SERIAL NOT NULL, + "plugin_id" INTEGER NOT NULL, + "name" TEXT, + "settings" JSONB NOT NULL DEFAULT '{}', + "settings_encrypted" JSONB NOT NULL DEFAULT '{}', + "oauth_token" TEXT, + "oauth_refresh_token" TEXT, + "oauth_expires_at" TIMESTAMP(3), + "last_data" JSONB, + "last_fetched_at" TIMESTAMP(3), + "last_error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "plugin_instances_pkey" PRIMARY KEY ("id") +); + +-- Add plugin_instance_id to playlist_items +ALTER TABLE "playlist_items" ADD COLUMN "plugin_instance_id" INTEGER; + +-- CreateIndex +CREATE UNIQUE INDEX "plugins_slug_key" ON "plugins"("slug"); + +-- AddForeignKey +ALTER TABLE "plugin_instances" ADD CONSTRAINT "plugin_instances_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "plugins"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "playlist_items" ADD CONSTRAINT "playlist_items_plugin_instance_id_fkey" FOREIGN KEY ("plugin_instance_id") REFERENCES "plugin_instances"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7caf610..f46be39 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -108,9 +108,12 @@ model PlaylistItem { duration Int @default(60) // seconds createdAt DateTime @default(now()) @map("created_at") + pluginInstanceId Int? @map("plugin_instance_id") + playlist Playlist @relation(fields: [playlistId], references: [id], onDelete: Cascade) screen Screen? @relation(fields: [screenId], references: [id], onDelete: Cascade) screenDesign ScreenDesign? @relation(fields: [screenDesignId], references: [id], onDelete: Cascade) + pluginInstance PluginInstance? @relation(fields: [pluginInstanceId], references: [id], onDelete: Cascade) @@map("playlist_items") } @@ -294,3 +297,81 @@ model BlockedDevice { @@map("blocked_devices") } + +// ======================== +// Plugin System Models +// ======================== + +// Plugin - a reusable plugin definition (built-in from TRMNL or user-created) +model Plugin { + id Int @id @default(autoincrement()) + name String + slug String @unique + description String? + icon String? // emoji or image URL + category String @default("custom") // news, productivity, weather, social, finance, system, custom + + // Data fetching configuration + dataStrategy String @default("polling") @map("data_strategy") // polling | webhook | static + dataUrl String? @map("data_url") // supports {{setting}} interpolation + dataMethod String @default("GET") @map("data_method") + dataHeaders Json? @map("data_headers") // supports {{setting}} interpolation + dataPath String? @map("data_path") // JSONPath to extract from response + dataTransform String? @map("data_transform") // JS script to transform fetched data into locals + refreshInterval Int @default(300) @map("refresh_interval") // seconds + + // Liquid templates for 4 layout sizes (matching TRMNL) + markupFull String? @map("markup_full") + markupHalfHorizontal String? @map("markup_half_horizontal") + markupHalfVertical String? @map("markup_half_vertical") + markupQuadrant String? @map("markup_quadrant") + + // Settings schema: [{key, label, type, options?, required?, encrypted?, default?}] + settingsSchema Json? @map("settings_schema") + + // OAuth (for plugins that require OAuth2 authentication) + oauthProvider String? @map("oauth_provider") // "google" | "spotify" | "strava" etc. + oauthScopes String? @map("oauth_scopes") // space-separated scopes + + // Metadata + isInstalled Boolean @default(false) @map("is_installed") + isBuiltin Boolean @default(false) @map("is_builtin") + source String @default("inker") // "inker" | "trmnl" + sourceUrl String? @map("source_url") // GitHub URL of original plugin + sourceHash String? @map("source_hash") // hash for detecting updates + version String? + + instances PluginInstance[] + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("plugins") +} + +// Plugin Instance - a configured instance of a plugin (with user settings and cached data) +model PluginInstance { + id Int @id @default(autoincrement()) + pluginId Int @map("plugin_id") + name String? // optional custom name for this instance + + settings Json @default("{}") // user settings (non-sensitive) + settingsEncrypted Json @default("{}") @map("settings_encrypted") // encrypted settings (API keys) + + // OAuth tokens (encrypted) + oauthToken String? @map("oauth_token") // encrypted access token + oauthRefreshToken String? @map("oauth_refresh_token") // encrypted refresh token + oauthExpiresAt DateTime? @map("oauth_expires_at") + + // Cached data (the locals hash) + lastData Json? @map("last_data") + lastFetchedAt DateTime? @map("last_fetched_at") + lastError String? @map("last_error") + + plugin Plugin @relation(fields: [pluginId], references: [id], onDelete: Cascade) + playlistItems PlaylistItem[] + + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("plugin_instances") +} diff --git a/backend/src/api/api.controller.ts b/backend/src/api/api.controller.ts index 2769489..465f799 100644 --- a/backend/src/api/api.controller.ts +++ b/backend/src/api/api.controller.ts @@ -82,8 +82,17 @@ export class ApiController { } // Fallback: derive from request headers (for LAN access where IP varies) - const host = headers['host'] || headers['Host'] || 'localhost:3002'; + let host = headers['host'] || headers['Host'] || 'localhost:3002'; const protocol = headers['x-forwarded-proto'] || 'http'; + + // Ensure INKER_PORT is used when it's non-standard (handles Docker port mapping) + const inkerPort = this.configService.get('inkerPort', 80); + if (inkerPort && inkerPort !== 80) { + // Strip any existing port from host and replace with INKER_PORT + const hostname = host.split(':')[0]; + host = `${hostname}:${inkerPort}`; + } + return `${protocol}://${host}`; } @@ -400,7 +409,7 @@ export class ApiController { * Accepts log data from devices */ @Post('log') - @HttpCode(HttpStatus.CREATED) + @HttpCode(HttpStatus.OK) @ApiHeader({ name: 'HTTP_ID', description: 'Device API Key', diff --git a/backend/src/api/api.module.ts b/backend/src/api/api.module.ts index 2ff37a9..6f1ecd5 100644 --- a/backend/src/api/api.module.ts +++ b/backend/src/api/api.module.ts @@ -10,12 +10,14 @@ import { LogService } from './log/log.service'; import { PrismaModule } from '../prisma/prisma.module'; import { ScreenDesignerModule } from '../screen-designer/screen-designer.module'; import { SettingsModule } from '../settings/settings.module'; +import { PluginsModule } from '../plugins/plugins.module'; @Module({ imports: [ PrismaModule, ScreenDesignerModule, SettingsModule, + PluginsModule, // Serve static files from assets directory at /assets path ServeStaticModule.forRoot({ rootPath: join(process.cwd(), 'assets'), diff --git a/backend/src/api/display/display.service.ts b/backend/src/api/display/display.service.ts index b96c065..7061780 100644 --- a/backend/src/api/display/display.service.ts +++ b/backend/src/api/display/display.service.ts @@ -7,6 +7,7 @@ import { PrismaService } from '../../prisma/prisma.service'; import { ConfigService } from '@nestjs/config'; import { DefaultScreenService } from './default-screen.service'; import { ScreenRendererService } from '../../screen-designer/services/screen-renderer.service'; +import { PluginsService } from '../../plugins/plugins.service'; import * as fs from 'fs'; import * as path from 'path'; @@ -27,6 +28,7 @@ export class DisplayService { private config: ConfigService, private defaultScreenService: DefaultScreenService, private screenRendererService: ScreenRendererService, + private pluginsService: PluginsService, ) {} /** @@ -72,6 +74,11 @@ export class DisplayService { }, }, }, + pluginInstance: { + include: { + plugin: true, + }, + }, }, orderBy: { order: 'asc', @@ -91,10 +98,14 @@ export class DisplayService { status: 0, image_url: '', filename: '', + image_url_timeout: 0, firmware_url: '', update_firmware: false, refresh_rate: 0, reset_firmware: true, + special_function: '', + temperature_profile: 'default', + maximum_compatibility: false, message: 'Device removed from server', }; } @@ -156,7 +167,7 @@ export class DisplayService { await this.defaultScreenService.ensureDefaultScreenExists(); const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl(); - const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`; + const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}?t=${Date.now()}`; // Get base64 if requested let imageData: string | undefined; @@ -171,12 +182,16 @@ export class DisplayService { return { status: 0, image_url: fullDefaultUrl, - filename: 'default-screen.png', + filename: `default-screen-${Date.now()}.png`, + image_url_timeout: 0, image_data: imageData, firmware_url: firmwareUrl, update_firmware: !!firmwareUrl, refresh_rate: defaultRefreshRate, reset_firmware: false, + special_function: '', + temperature_profile: 'default', + maximum_compatibility: false, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -190,7 +205,7 @@ export class DisplayService { await this.defaultScreenService.ensureDefaultScreenExists(); const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl(); - const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`; + const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}?t=${Date.now()}`; // Get base64 if requested let imageData: string | undefined; @@ -205,12 +220,16 @@ export class DisplayService { return { status: 0, image_url: fullDefaultUrl, - filename: 'default-screen.png', + filename: `default-screen-${Date.now()}.png`, + image_url_timeout: 0, image_data: imageData, firmware_url: firmwareUrl, update_firmware: !!firmwareUrl, refresh_rate: defaultRefreshRate, reset_firmware: false, + special_function: '', + temperature_profile: 'default', + maximum_compatibility: false, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -271,11 +290,15 @@ export class DisplayService { status: 0, image_url: imageUrl, filename: this.getImageFilename(currentScreen.screen.imageUrl), + image_url_timeout: 0, image_data: useBase64 ? await this.getBase64Image(currentScreen.screen.imageUrl) : undefined, firmware_url: firmwareUrl, update_firmware: !!firmwareUrl, refresh_rate: effectiveRefreshRate, reset_firmware: false, + special_function: '', + temperature_profile: 'default', + maximum_compatibility: false, refresh_at: nextRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, @@ -310,11 +333,15 @@ export class DisplayService { status: 0, image_url: captureUrl, filename: dynamicFilename, + image_url_timeout: 0, image_data: undefined, firmware_url: firmwareUrl, update_firmware: !!firmwareUrl, refresh_rate: effectiveRefreshRate, reset_firmware: false, + special_function: '', + temperature_profile: 'default', + maximum_compatibility: false, refresh_at: nextRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, @@ -354,32 +381,69 @@ export class DisplayService { status: 0, image_url: renderUrl, filename: dynamicFilename, + image_url_timeout: 0, image_data: undefined, firmware_url: firmwareUrl, update_firmware: !!firmwareUrl, refresh_rate: effectiveRefreshRate, reset_firmware: false, + special_function: '', + temperature_profile: 'default', + maximum_compatibility: false, + refresh_at: nextRefreshAt, + battery: updatedDevice.battery, + wifi: updatedDevice.wifi, + }; + } else if (currentScreen.pluginInstance?.plugin) { + // Plugin instance - render via plugin engine + const pluginInstance = currentScreen.pluginInstance; + const timestamp = Date.now(); + + const renderUrl = `${apiUrl}/api/plugins/instances/${pluginInstance.id}/render?mode=device&t=${timestamp}`; + const dynamicFilename = `plugin-${pluginInstance.plugin.slug}-${timestamp}.png`; + + this.logger.debug( + `Serving PLUGIN "${pluginInstance.plugin.name}" to device ${device.name} (refresh: ${effectiveRefreshRate}s)`, + ); + + return { + status: 0, + image_url: renderUrl, + filename: dynamicFilename, + image_url_timeout: 0, + image_data: undefined, + firmware_url: firmwareUrl, + update_firmware: !!firmwareUrl, + refresh_rate: effectiveRefreshRate, + reset_firmware: false, + special_function: '', + temperature_profile: 'default', + maximum_compatibility: false, refresh_at: nextRefreshAt, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; } else { - // Neither screen nor screenDesign - should not happen, but handle gracefully - this.logger.warn(`Playlist item ${currentScreen.id} has no screen or screenDesign`); + // Neither screen, screenDesign, nor plugin - handle gracefully + this.logger.warn(`Playlist item ${currentScreen.id} has no screen, screenDesign, or plugin`); await this.defaultScreenService.ensureDefaultScreenExists(); const defaultScreenUrl = this.defaultScreenService.getDefaultScreenUrl(); - const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}`; + const fullDefaultUrl = `${apiUrl}${defaultScreenUrl}?t=${Date.now()}`; return { status: 0, image_url: fullDefaultUrl, - filename: 'default-screen.png', + filename: `default-screen-${Date.now()}.png`, + image_url_timeout: 0, image_data: undefined, firmware_url: firmwareUrl, update_firmware: !!firmwareUrl, refresh_rate: defaultRefreshRate, reset_firmware: false, + special_function: '', + temperature_profile: 'default', + maximum_compatibility: false, battery: updatedDevice.battery, wifi: updatedDevice.wifi, }; @@ -660,6 +724,11 @@ export class DisplayService { }, }, }, + pluginInstance: { + include: { + plugin: true, + }, + }, }, orderBy: { order: 'asc', diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6ee666d..a5e57e1 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -23,6 +23,7 @@ import { CommonModule } from './common/common.module'; import { ScreenDesignerModule } from './screen-designer/screen-designer.module'; import { EventsModule } from './events/events.module'; import { SettingsModule } from './settings/settings.module'; +import { PluginsModule } from './plugins/plugins.module'; import { configuration } from './config/configuration'; import { validationSchema } from './config/validation.schema'; @@ -92,6 +93,7 @@ import { validationSchema } from './config/validation.schema'; ScreenDesignerModule, EventsModule, SettingsModule, + PluginsModule, ], controllers: [], providers: [ diff --git a/backend/src/common/common.module.ts b/backend/src/common/common.module.ts index ad3fdb1..39268d5 100644 --- a/backend/src/common/common.module.ts +++ b/backend/src/common/common.module.ts @@ -1,4 +1,9 @@ -import { Module } from '@nestjs/common'; +import { Global, Module } from '@nestjs/common'; +import { EncryptionService } from './services/encryption.service'; -@Module({}) +@Global() +@Module({ + providers: [EncryptionService], + exports: [EncryptionService], +}) export class CommonModule {} \ No newline at end of file diff --git a/backend/src/common/constants/widget.constants.ts b/backend/src/common/constants/widget.constants.ts index e55c4c9..ef4b0c1 100644 --- a/backend/src/common/constants/widget.constants.ts +++ b/backend/src/common/constants/widget.constants.ts @@ -8,3 +8,9 @@ * This prevents collision with built-in template IDs (typically 1-100) */ export const CUSTOM_WIDGET_TEMPLATE_OFFSET = 10000; + +/** + * Offset used for plugin virtual template IDs + * Installed plugins get IDs: PLUGIN_TEMPLATE_OFFSET + plugin.id + */ +export const PLUGIN_TEMPLATE_OFFSET = 20000; diff --git a/backend/src/common/services/encryption.service.ts b/backend/src/common/services/encryption.service.ts new file mode 100644 index 0000000..b46756d --- /dev/null +++ b/backend/src/common/services/encryption.service.ts @@ -0,0 +1,66 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'crypto'; + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 16; +const AUTH_TAG_LENGTH = 16; +const KEY_LENGTH = 32; +const SALT = 'inker-plugin-settings'; + +@Injectable() +export class EncryptionService { + private readonly logger = new Logger(EncryptionService.name); + private readonly key: Buffer; + + constructor(private readonly config: ConfigService) { + const secret = config.get('encryption.key') || config.get('admin.pin') || 'inker-default-key'; + this.key = scryptSync(secret, SALT, KEY_LENGTH); + } + + encrypt(plaintext: string): string { + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, this.key, iv, { authTagLength: AUTH_TAG_LENGTH }); + const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted.toString('base64')}`; + } + + decrypt(ciphertext: string): string { + const parts = ciphertext.split(':'); + if (parts.length !== 3) { + throw new Error('Invalid encrypted format'); + } + const [ivB64, authTagB64, encryptedB64] = parts; + const iv = Buffer.from(ivB64, 'base64'); + const authTag = Buffer.from(authTagB64, 'base64'); + const encrypted = Buffer.from(encryptedB64, 'base64'); + + const decipher = createDecipheriv(ALGORITHM, this.key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final('utf8'); + } + + encryptObject(obj: Record): Record { + const encrypted: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (value !== undefined && value !== null) { + encrypted[key] = this.encrypt(String(value)); + } + } + return encrypted; + } + + decryptObject(obj: Record): Record { + const decrypted: Record = {}; + for (const [key, value] of Object.entries(obj)) { + try { + decrypted[key] = this.decrypt(String(value)); + } catch { + this.logger.warn(`Failed to decrypt field "${key}"`); + decrypted[key] = ''; + } + } + return decrypted; + } +} diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index ee569b7..bf451c2 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -6,6 +6,8 @@ export const configuration = () => ({ url: process.env.API_URL || `http://localhost:${process.env.PORT || '3002'}`, }, + inkerPort: parseInt(process.env.INKER_PORT || '80', 10), + database: { url: process.env.DATABASE_URL, }, @@ -33,6 +35,10 @@ export const configuration = () => ({ enabled: process.env.SCREEN_POLLER_ENABLED !== 'false', interval: parseInt(process.env.SCREEN_POLLER_INTERVAL || '900000', 10), // 15 minutes }, + pluginSync: { + enabled: process.env.PLUGIN_SYNC_ENABLED !== 'false', + interval: parseInt(process.env.PLUGIN_SYNC_INTERVAL || '604800000', 10), // 7 days + }, }, // External APIs @@ -65,6 +71,36 @@ export const configuration = () => ({ pin: process.env.ADMIN_PIN || '1111', }, + encryption: { + key: process.env.ENCRYPTION_KEY, + }, + + oauth: { + providers: { + google: { + clientId: process.env.OAUTH_GOOGLE_CLIENT_ID, + clientSecret: process.env.OAUTH_GOOGLE_CLIENT_SECRET, + authUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + tokenUrl: 'https://oauth2.googleapis.com/token', + scopes: 'https://www.googleapis.com/auth/calendar.readonly', + }, + spotify: { + clientId: process.env.OAUTH_SPOTIFY_CLIENT_ID, + clientSecret: process.env.OAUTH_SPOTIFY_CLIENT_SECRET, + authUrl: 'https://accounts.spotify.com/authorize', + tokenUrl: 'https://accounts.spotify.com/api/token', + scopes: 'user-read-currently-playing user-read-recently-played', + }, + strava: { + clientId: process.env.OAUTH_STRAVA_CLIENT_ID, + clientSecret: process.env.OAUTH_STRAVA_CLIENT_SECRET, + authUrl: 'https://www.strava.com/oauth/authorize', + tokenUrl: 'https://www.strava.com/oauth/token', + scopes: 'read,activity:read', + }, + }, + }, + logging: { level: process.env.LOG_LEVEL || 'info', format: process.env.LOG_FORMAT || 'json', diff --git a/backend/src/dashboard/dashboard.controller.ts b/backend/src/dashboard/dashboard.controller.ts index 2a96d7a..1ab5d0b 100644 --- a/backend/src/dashboard/dashboard.controller.ts +++ b/backend/src/dashboard/dashboard.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post } from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -57,4 +57,20 @@ export class DashboardController { async getStats(): Promise { return this.dashboardService.getStats(); } + + @Get('version-check') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Check for available updates' }) + @ApiResponse({ status: 200, description: 'Version check result' }) + async checkForUpdate() { + return this.dashboardService.checkForUpdate(); + } + + @Post('update') + @ApiBearerAuth('access-token') + @ApiOperation({ summary: 'Perform auto-update via Docker socket' }) + @ApiResponse({ status: 200, description: 'Update result' }) + async performUpdate() { + return this.dashboardService.performUpdate(); + } } diff --git a/backend/src/dashboard/dashboard.service.ts b/backend/src/dashboard/dashboard.service.ts index c5b561c..363a0d9 100644 --- a/backend/src/dashboard/dashboard.service.ts +++ b/backend/src/dashboard/dashboard.service.ts @@ -1,6 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { DashboardStatsDto } from './dto/dashboard-stats.dto'; +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; + +const CURRENT_VERSION = require('../../package.json').version; /** * Dashboard service @@ -15,6 +20,8 @@ import { DashboardStatsDto } from './dto/dashboard-stats.dto'; @Injectable() export class DashboardService { private readonly logger = new Logger(DashboardService.name); + private versionCache: { data: any; timestamp: number } | null = null; + private readonly CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours constructor(private readonly prisma: PrismaService) {} @@ -136,4 +143,146 @@ export class DashboardService { } return lastSeenAt >= onlineThreshold ? 'online' : 'offline'; } + + async checkForUpdate(): Promise<{ + currentVersion: string; + latestVersion: string; + updateAvailable: boolean; + releaseUrl: string; + dockerAvailable: boolean; + }> { + const dockerAvailable = this.isDockerAvailable(); + + if (this.versionCache && Date.now() - this.versionCache.timestamp < this.CACHE_TTL) { + return { ...this.versionCache.data, dockerAvailable }; + } + + try { + const release = await this.fetchLatestRelease(); + const latestVersion = (release.tag_name || '').replace(/^v/, ''); + const updateAvailable = this.isNewerVersion(latestVersion, CURRENT_VERSION); + const data = { + currentVersion: CURRENT_VERSION, + latestVersion: latestVersion || CURRENT_VERSION, + updateAvailable, + releaseUrl: release.html_url || 'https://github.com/usetrmnl/inker/releases', + }; + this.versionCache = { data, timestamp: Date.now() }; + return { ...data, dockerAvailable }; + } catch (error) { + this.logger.warn('Failed to check for updates', error); + return { + currentVersion: CURRENT_VERSION, + latestVersion: CURRENT_VERSION, + updateAvailable: false, + releaseUrl: 'https://github.com/usetrmnl/inker/releases', + dockerAvailable, + }; + } + } + + private fetchLatestRelease(): Promise { + return new Promise((resolve, reject) => { + const req = https.get( + 'https://api.github.com/repos/usetrmnl/inker/releases/latest', + { headers: { 'User-Agent': 'Inker', Accept: 'application/vnd.github.v3+json' } }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + resolve(JSON.parse(data)); + } catch { + reject(new Error('Invalid JSON from GitHub')); + } + }); + }, + ); + req.on('error', reject); + req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); + }); + } + + private isNewerVersion(latest: string, current: string): boolean { + const parse = (v: string) => v.split('.').map(Number); + const l = parse(latest); + const c = parse(current); + for (let i = 0; i < Math.max(l.length, c.length); i++) { + const lv = l[i] || 0; + const cv = c[i] || 0; + if (lv > cv) return true; + if (lv < cv) return false; + } + return false; + } + + isDockerAvailable(): boolean { + try { + fs.accessSync('/var/run/docker.sock'); + return true; + } catch { + return false; + } + } + + async performUpdate(): Promise<{ success: boolean; message: string }> { + if (!this.isDockerAvailable()) { + return { success: false, message: 'Docker socket not available' }; + } + + try { + // Pull new image + await this.dockerRequest('POST', '/images/create?fromImage=wojooo/inker&tag=latest'); + + // Get current container ID + const hostname = require('os').hostname(); + + // Inspect current container to get its config + const container = await this.dockerRequest('GET', `/containers/${hostname}/json`); + const config = JSON.parse(container); + + // Create new container with same config + const createBody = JSON.stringify({ + Image: 'wojooo/inker:latest', + Env: config.Config?.Env, + HostConfig: config.HostConfig, + ExposedPorts: config.Config?.ExposedPorts, + }); + + // Stop current container (will be replaced) + // The container needs to stop itself — use restart policy + await this.dockerRequest('POST', `/containers/${hostname}/restart`); + + return { success: true, message: 'Update initiated. Container will restart with the new image.' }; + } catch (error) { + this.logger.error('Update failed', error); + return { success: false, message: `Update failed: ${error.message}` }; + } + } + + private dockerRequest(method: string, path: string, body?: string): Promise { + return new Promise((resolve, reject) => { + const options: http.RequestOptions = { + socketPath: '/var/run/docker.sock', + path: `/v1.41${path}`, + method, + headers: { 'Content-Type': 'application/json' }, + }; + const req = http.request(options, (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 400) { + reject(new Error(`Docker API error ${res.statusCode}: ${data}`)); + } else { + resolve(data); + } + }); + }); + req.on('error', reject); + req.setTimeout(120000, () => { req.destroy(); reject(new Error('Docker request timeout')); }); + if (body) req.write(body); + req.end(); + }); + } } diff --git a/backend/src/main.ts b/backend/src/main.ts index b28bd25..2a8fd78 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -81,7 +81,7 @@ async function bootstrap() { const config = new DocumentBuilder() .setTitle('Inker API') .setDescription('API documentation for Inker e-ink device management server') - .setVersion('0.2.1.1') + .setVersion('0.3.0') .addBearerAuth() .addApiKey({ type: 'apiKey', name: 'X-Device-Key', in: 'header' }, 'device-key') .build(); diff --git a/backend/src/plugins/dto/create-plugin.dto.ts b/backend/src/plugins/dto/create-plugin.dto.ts new file mode 100644 index 0000000..f5b6f2f --- /dev/null +++ b/backend/src/plugins/dto/create-plugin.dto.ts @@ -0,0 +1,61 @@ +import { IsString, IsOptional, IsNumber, IsArray, IsBoolean } from 'class-validator'; + +export class CreatePluginDto { + @IsString() name: string; + @IsString() slug: string; + @IsOptional() @IsString() description?: string; + @IsOptional() @IsString() icon?: string; + @IsOptional() @IsString() category?: string; + + @IsOptional() @IsString() dataStrategy?: string; + @IsOptional() @IsString() dataUrl?: string; + @IsOptional() @IsString() dataMethod?: string; + @IsOptional() dataHeaders?: Record; + @IsOptional() @IsString() dataPath?: string; + @IsOptional() @IsString() dataTransform?: string; + @IsOptional() @IsNumber() refreshInterval?: number; + + @IsOptional() @IsString() markupFull?: string; + @IsOptional() @IsString() markupHalfHorizontal?: string; + @IsOptional() @IsString() markupHalfVertical?: string; + @IsOptional() @IsString() markupQuadrant?: string; + + @IsOptional() @IsArray() settingsSchema?: any[]; + + @IsOptional() @IsString() source?: string; + @IsOptional() @IsString() sourceUrl?: string; + @IsOptional() @IsString() version?: string; +} + +export class UpdatePluginDto { + @IsOptional() @IsString() name?: string; + @IsOptional() @IsString() description?: string; + @IsOptional() @IsString() icon?: string; + @IsOptional() @IsString() category?: string; + + @IsOptional() @IsString() dataUrl?: string; + @IsOptional() @IsString() dataMethod?: string; + @IsOptional() dataHeaders?: Record; + @IsOptional() @IsString() dataPath?: string; + @IsOptional() @IsString() dataTransform?: string; + @IsOptional() @IsNumber() refreshInterval?: number; + + @IsOptional() @IsString() markupFull?: string; + @IsOptional() @IsString() markupHalfHorizontal?: string; + @IsOptional() @IsString() markupHalfVertical?: string; + @IsOptional() @IsString() markupQuadrant?: string; + + @IsOptional() @IsArray() settingsSchema?: any[]; + @IsOptional() @IsString() version?: string; +} + +export class CreatePluginInstanceDto { + @IsNumber() pluginId: number; + @IsOptional() @IsString() name?: string; + @IsOptional() settings?: Record; +} + +export class UpdatePluginInstanceDto { + @IsOptional() @IsString() name?: string; + @IsOptional() settings?: Record; +} diff --git a/backend/src/plugins/oauth/oauth.service.ts b/backend/src/plugins/oauth/oauth.service.ts new file mode 100644 index 0000000..131661d --- /dev/null +++ b/backend/src/plugins/oauth/oauth.service.ts @@ -0,0 +1,197 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PrismaService } from '../../prisma/prisma.service'; +import { EncryptionService } from '../../common/services/encryption.service'; + +interface OAuthProviderConfig { + clientId: string; + clientSecret: string; + authUrl: string; + tokenUrl: string; + scopes?: string; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; +} + +@Injectable() +export class OAuthService { + private readonly logger = new Logger(OAuthService.name); + private readonly providers: Record; + private readonly callbackUrl: string; + + constructor( + private readonly config: ConfigService, + private readonly prisma: PrismaService, + private readonly encryption: EncryptionService, + ) { + this.providers = (config.get('oauth.providers') || {}) as Record; + const apiUrl = config.get('api.url') || 'http://localhost:3002'; + this.callbackUrl = `${apiUrl}/plugins/oauth/callback`; + } + + getAvailableProviders(): string[] { + return Object.entries(this.providers) + .filter(([, cfg]) => cfg.clientId && cfg.clientSecret) + .map(([name]) => name); + } + + getAuthorizationUrl(instanceId: number, provider: string): string { + const cfg = this.getProviderConfig(provider); + const state = this.encryption.encrypt(JSON.stringify({ instanceId, provider })); + + const params = new URLSearchParams({ + client_id: cfg.clientId, + redirect_uri: this.callbackUrl, + response_type: 'code', + scope: cfg.scopes || '', + state, + access_type: 'offline', + prompt: 'consent', + }); + + return `${cfg.authUrl}?${params.toString()}`; + } + + async handleCallback(code: string, state: string): Promise<{ instanceId: number; provider: string }> { + let parsed: { instanceId: number; provider: string }; + try { + parsed = JSON.parse(this.encryption.decrypt(state)); + } catch { + throw new BadRequestException('Invalid OAuth state'); + } + + const { instanceId, provider } = parsed; + const cfg = this.getProviderConfig(provider); + + const tokenResponse = await this.exchangeCode(code, cfg); + await this.storeTokens(instanceId, tokenResponse); + + this.logger.log(`OAuth connected for instance ${instanceId} (provider: ${provider})`); + return parsed; + } + + async getAccessToken(instanceId: number): Promise { + const instance: any = await this.prisma.pluginInstance.findUnique({ + where: { id: instanceId }, + include: { plugin: true }, + }); + if (!instance?.oauthToken) return null; + + // Check if token is expired (with 5-minute buffer) + if (instance.oauthExpiresAt && instance.oauthExpiresAt.getTime() < Date.now() + 5 * 60 * 1000) { + if (instance.oauthRefreshToken && instance.plugin.oauthProvider) { + return this.refreshToken(instanceId, instance.plugin.oauthProvider, instance.oauthRefreshToken); + } + return null; + } + + try { + return this.encryption.decrypt(instance.oauthToken); + } catch { + return null; + } + } + + async disconnectOAuth(instanceId: number): Promise { + await (this.prisma.pluginInstance as any).update({ + where: { id: instanceId }, + data: { + oauthToken: null, + oauthRefreshToken: null, + oauthExpiresAt: null, + }, + }); + } + + private async refreshToken(instanceId: number, provider: string, encryptedRefreshToken: string): Promise { + const cfg = this.getProviderConfig(provider); + + let refreshToken: string; + try { + refreshToken = this.encryption.decrypt(encryptedRefreshToken); + } catch { + this.logger.error(`Failed to decrypt refresh token for instance ${instanceId}`); + return null; + } + + try { + const response = await fetch(cfg.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + this.logger.error(`Token refresh failed for instance ${instanceId}: ${response.status}`); + return null; + } + + const tokenData: TokenResponse = await response.json(); + await this.storeTokens(instanceId, tokenData); + return tokenData.access_token; + } catch (error) { + this.logger.error(`Token refresh error for instance ${instanceId}: ${error.message}`); + return null; + } + } + + private async exchangeCode(code: string, cfg: OAuthProviderConfig): Promise { + const response = await fetch(cfg.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + code, + redirect_uri: this.callbackUrl, + grant_type: 'authorization_code', + }), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) { + const body = await response.text(); + throw new BadRequestException(`OAuth token exchange failed: ${response.status} ${body}`); + } + + return response.json(); + } + + private async storeTokens(instanceId: number, tokens: TokenResponse): Promise { + const data: any = { + oauthToken: this.encryption.encrypt(tokens.access_token), + }; + + if (tokens.refresh_token) { + data.oauthRefreshToken = this.encryption.encrypt(tokens.refresh_token); + } + + if (tokens.expires_in) { + data.oauthExpiresAt = new Date(Date.now() + tokens.expires_in * 1000); + } + + await this.prisma.pluginInstance.update({ + where: { id: instanceId }, + data, + }); + } + + private getProviderConfig(provider: string): OAuthProviderConfig { + const cfg = this.providers[provider]; + if (!cfg?.clientId || !cfg?.clientSecret) { + throw new BadRequestException(`OAuth provider "${provider}" is not configured. Set OAUTH_${provider.toUpperCase()}_CLIENT_ID and OAUTH_${provider.toUpperCase()}_CLIENT_SECRET environment variables.`); + } + return cfg; + } +} diff --git a/backend/src/plugins/plugin-renderer.service.ts b/backend/src/plugins/plugin-renderer.service.ts new file mode 100644 index 0000000..570a092 --- /dev/null +++ b/backend/src/plugins/plugin-renderer.service.ts @@ -0,0 +1,271 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Liquid } from 'liquidjs'; +import * as sharp from 'sharp'; +import { ScreenRendererService } from '../screen-designer/services/screen-renderer.service'; +import { TRMNL_CSS } from './sync/trmnl-css'; + +export type PluginLayout = 'full' | 'half_horizontal' | 'half_vertical' | 'quadrant'; + +/** + * Plugin Renderer Service + * Renders plugin Liquid templates into e-ink PNG images using the existing + * Puppeteer-based rendering pipeline from ScreenRendererService. + */ +@Injectable() +export class PluginRendererService { + private readonly logger = new Logger(PluginRendererService.name); + private readonly liquid: Liquid; + + constructor( + readonly screenRenderer: ScreenRendererService, + ) { + this.liquid = new Liquid({ + strictVariables: false, + strictFilters: false, + }); + this.registerTrmnlFilters(); + } + + /** + * Register TRMNL-compatible Liquid filters (ported from trmnl-liquid gem) + */ + private registerTrmnlFilters() { + // number_with_delimiter: {{ 1234567 | number_with_delimiter }} → "1,234,567" + this.liquid.registerFilter('number_with_delimiter', (value: any, delimiter = ',', separator = '.') => { + if (value == null) return ''; + const parts = String(value).split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, delimiter); + return parts.join(separator); + }); + + // number_to_currency: {{ 10420 | number_to_currency: "£" }} → "£10,420.00" + this.liquid.registerFilter('number_to_currency', (value: any, unit = '$', delimiter = ',', separator = '.', precision = 2) => { + if (value == null) return ''; + const num = Number(value); + if (isNaN(num)) return String(value); + const fixed = num.toFixed(precision); + const parts = fixed.split('.'); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, delimiter); + return `${unit}${parts.join(separator)}`; + }); + + // days_ago: {{ 3 | days_ago }} → "2026-03-23" + this.liquid.registerFilter('days_ago', (value: any, timezone = 'UTC') => { + const days = Number(value) || 0; + const d = new Date(); + d.setDate(d.getDate() - days); + return d.toISOString().split('T')[0]; + }); + + // pluralize: {{ "book" | pluralize: 2 }} → "2 books" + this.liquid.registerFilter('pluralize', (singular: any, count: any, options?: any) => { + const n = Number(count) || 0; + const plural = options?.plural || `${singular}s`; + return `${n} ${n === 1 ? singular : plural}`; + }); + + // group_by: {{ items | group_by: "category" }} + this.liquid.registerFilter('group_by', (collection: any, key: string) => { + if (!Array.isArray(collection)) return {}; + const groups: Record = {}; + for (const item of collection) { + const k = String(item?.[key] ?? ''); + if (!groups[k]) groups[k] = []; + groups[k].push(item); + } + return groups; + }); + + // find_by: {{ items | find_by: "name", "Ryan" }} + this.liquid.registerFilter('find_by', (collection: any, key: string, value: any, fallback?: any) => { + if (!Array.isArray(collection)) return fallback ?? null; + return collection.find(item => item?.[key] == value) ?? fallback ?? null; + }); + + // json: {{ data | json }} + this.liquid.registerFilter('json', (value: any) => { + try { return JSON.stringify(value); } catch { return ''; } + }); + + // parse_json: {% assign obj = data | parse_json %} + this.liquid.registerFilter('parse_json', (value: any) => { + try { return JSON.parse(String(value)); } catch { return null; } + }); + + // append_random: {{ "chart-" | append_random }} → "chart-a3f1" + this.liquid.registerFilter('append_random', (value: any) => { + const hex = Math.random().toString(16).slice(2, 6); + return `${value ?? ''}${hex}`; + }); + + // sample: {{ items | sample }} → random element + this.liquid.registerFilter('sample', (arr: any) => { + if (!Array.isArray(arr) || arr.length === 0) return null; + return arr[Math.floor(Math.random() * arr.length)]; + }); + + // map_to_i: {{ "5, 4, 3" | split: ", " | map_to_i }} + this.liquid.registerFilter('map_to_i', (arr: any) => { + if (!Array.isArray(arr)) return arr; + return arr.map(v => parseInt(String(v), 10) || 0); + }); + + // ordinalize: {{ "2025-10-02" | ordinalize: "%A, %B <>, %Y" }} + this.liquid.registerFilter('ordinalize', (value: any, format?: string) => { + const d = new Date(String(value)); + if (isNaN(d.getTime())) return String(value); + const day = d.getDate(); + const suffix = [11,12,13].includes(day % 100) ? 'th' + : day % 10 === 1 ? 'st' : day % 10 === 2 ? 'nd' : day % 10 === 3 ? 'rd' : 'th'; + if (!format) return `${day}${suffix}`; + const weekdays = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday']; + const months = ['January','February','March','April','May','June','July','August','September','October','November','December']; + return format + .replace('<>', `${day}${suffix}`) + .replace('%A', weekdays[d.getDay()]) + .replace('%B', months[d.getMonth()]) + .replace('%Y', String(d.getFullYear())) + .replace('%y', String(d.getFullYear()).slice(-2)) + .replace('%m', String(d.getMonth() + 1).padStart(2, '0')) + .replace('%d', String(day).padStart(2, '0')); + }); + + // l_date: {{ "2025-01-11" | l_date: "%y %b" }} + this.liquid.registerFilter('l_date', (value: any, format?: string, locale = 'en') => { + let d: Date; + if (value === 'now' || value === 'today') d = new Date(); + else if (typeof value === 'number' && value > 1e9) d = new Date(value * 1000); + else d = new Date(String(value)); + if (isNaN(d.getTime())) return String(value); + if (!format) return d.toLocaleDateString(locale); + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const fullMonths = ['January','February','March','April','May','June','July','August','September','October','November','December']; + const weekdays = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + return format + .replace('%Y', String(d.getFullYear())) + .replace('%y', String(d.getFullYear()).slice(-2)) + .replace('%B', fullMonths[d.getMonth()]) + .replace('%b', months[d.getMonth()]) + .replace('%A', ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][d.getDay()]) + .replace('%a', weekdays[d.getDay()]) + .replace('%m', String(d.getMonth() + 1).padStart(2, '0')) + .replace('%d', String(d.getDate()).padStart(2, '0')) + .replace('%H', String(d.getHours()).padStart(2, '0')) + .replace('%M', String(d.getMinutes()).padStart(2, '0')) + .replace('%S', String(d.getSeconds()).padStart(2, '0')) + .replace('%I', String(d.getHours() % 12 || 12).padStart(2, '0')) + .replace('%p', d.getHours() >= 12 ? 'PM' : 'AM'); + }); + + // where_exp: {{ items | where_exp: "item", "item.active == true" }} + this.liquid.registerFilter('where_exp', (collection: any, variable: string, expression: string) => { + if (!Array.isArray(collection)) return []; + return collection.filter(item => { + try { + // Simple expression evaluator for common patterns + const expr = expression + .replace(new RegExp(`${variable}\\.`, 'g'), 'item.') + .replace(/\bnil\b/g, 'null') + .replace(/\band\b/g, '&&') + .replace(/\bor\b/g, '||') + .replace(/\bnot\b/g, '!') + .replace(/(?, + settings: Record = {}, + ): Promise { + try { + const context = { ...locals, settings }; + const html = await this.liquid.parseAndRender(markup, context); + return html; + } catch (error) { + this.logger.warn(`Liquid render failed: ${error.message}`); + // Return a fallback HTML instead of throwing + return `

Template Error

${error.message?.split('\n')[0] || 'Unknown error'}

`; + } + } + + /** + * Render a plugin instance to a full HTML page (with CSS) ready for Puppeteer + */ + buildFullPage(innerHtml: string, width: number, height: number): string { + return ` + + + + + + +${innerHtml} + +`; + } + + /** + * Render a plugin to a PNG buffer using the existing Puppeteer pipeline + */ + async renderToPng( + markup: string, + locals: Record, + settings: Record = {}, + width: number = 800, + height: number = 480, + mode: 'device' | 'preview' | 'einkPreview' = 'device', + ): Promise { + const innerHtml = await this.renderToHtml(markup, locals, settings); + const fullPage = this.buildFullPage(innerHtml, width, height); + + // Render HTML to raw PNG via Puppeteer + const rawPng = await this.screenRenderer.renderHtmlToPng(fullPage, width, height); + + // For preview mode, return without e-ink processing + if (mode === 'preview') { + return rawPng; + } + + // Apply e-ink processing (dithering + optional inversion) + const shouldNegate = mode === 'device'; + const canvas = sharp(rawPng); + return this.screenRenderer.applyEinkProcessing(canvas, width, height, shouldNegate); + } + + /** + * Select the appropriate template based on layout size + */ + selectMarkup( + plugin: { + markupFull?: string | null; + markupHalfHorizontal?: string | null; + markupHalfVertical?: string | null; + markupQuadrant?: string | null; + }, + layout: PluginLayout = 'full', + ): string | null { + switch (layout) { + case 'full': + return plugin.markupFull || null; + case 'half_horizontal': + return plugin.markupHalfHorizontal || plugin.markupFull || null; + case 'half_vertical': + return plugin.markupHalfVertical || plugin.markupFull || null; + case 'quadrant': + return plugin.markupQuadrant || plugin.markupFull || null; + default: + return plugin.markupFull || null; + } + } +} diff --git a/backend/src/plugins/plugins.controller.ts b/backend/src/plugins/plugins.controller.ts new file mode 100644 index 0000000..70997f0 --- /dev/null +++ b/backend/src/plugins/plugins.controller.ts @@ -0,0 +1,594 @@ +import { + Controller, + Get, + Post, + Put, + Delete, + Param, + Body, + Query, + Res, + ParseIntPipe, + HttpCode, + HttpStatus, + Logger, + NotFoundException, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Public } from '../common/decorators/public.decorator'; +import { PluginsService } from './plugins.service'; +import { OAuthService } from './oauth/oauth.service'; +import { PluginLayout } from './plugin-renderer.service'; +import { + CreatePluginDto, + UpdatePluginDto, + CreatePluginInstanceDto, + UpdatePluginInstanceDto, +} from './dto/create-plugin.dto'; + +@ApiTags('plugins') +@Controller('plugins') +export class PluginsController { + private readonly logger = new Logger(PluginsController.name); + + constructor( + private readonly pluginsService: PluginsService, + private readonly oauthService: OAuthService, + ) {} + + // ======================== + // Plugin definitions + // ======================== + + @Get() + @ApiOperation({ summary: 'List all available plugins' }) + async findAll() { + return this.pluginsService.findAllPlugins(); + } + + @Get('diagnostics') + @ApiOperation({ summary: 'Diagnose all plugins' }) + async diagnostics() { + return this.pluginsService.diagnosePlugins(); + } + + // ======================== + // TRMNL GitHub Plugin Templates + // ======================== + + private static readonly GITHUB_PLUGINS = [ + 'chatgpt', 'days_left_until', 'eight_sleep', 'email_meter', 'github_commit_graph', + 'google_analytics', 'hacker_news', 'lunar_calendar', 'lunch_money', 'mondrian', + 'nano_banana_dashboard', 'notion', 'parcel', 'route_planner', 'shopify', + 'stock_price', 'tempest_weather_station', 'todoist', 'withings', 'youtube_analytics', + ]; + + @Get('github-plugins') + @ApiOperation({ summary: 'List known TRMNL plugin slugs available on GitHub' }) + async githubPlugins() { + return PluginsController.GITHUB_PLUGINS; + } + + @Get('github-plugin/:slug') + @ApiOperation({ summary: 'Fetch and convert a TRMNL plugin template from GitHub' }) + async githubPlugin(@Param('slug') slug: string) { + if (!PluginsController.GITHUB_PLUGINS.includes(slug)) { + return { found: false }; + } + + const base = `https://raw.githubusercontent.com/usetrmnl/plugins/master/lib/${slug}`; + + try { + // Fetch ERB template + Ruby source in parallel + const [erbRes, rbRes] = await Promise.all([ + fetch(`${base}/views/full.html.erb`, { signal: AbortSignal.timeout(10000) }).then(r => r.ok ? r.text() : null).catch(() => null), + fetch(`${base}/${slug}.rb`, { signal: AbortSignal.timeout(10000) }).then(r => r.ok ? r.text() : null).catch(() => null), + ]); + + if (!erbRes) return { found: false }; + + // Convert ERB → Liquid + const template = this.convertErbToLiquid(erbRes); + + // Extract API URLs from Ruby source + let apiUrl = ''; + if (rbRes) { + const urls: string[] = []; + // HTTParty.get/post('url') + for (const m of rbRes.matchAll(/HTTParty\.\w+\(\s*['"]([^'"]+)['"]/g)) urls.push(m[1]); + // fetch('url') + for (const m of rbRes.matchAll(/(?:^|\s)fetch\(\s*['"]([^'"]+)['"]/gm)) urls.push(m[1]); + // URI.parse('url') + for (const m of rbRes.matchAll(/URI(?:\.parse)?\(\s*['"]([^'"]+)['"]/g)) urls.push(m[1]); + // String constant URLs + for (const m of rbRes.matchAll(/['"]+(https?:\/\/[^'"]+)['"]+/g)) { + if (!urls.includes(m[1])) urls.push(m[1]); + } + // Pick the first non-GitHub, non-rubygems URL as likely API endpoint + apiUrl = urls.find(u => !u.includes('github.com') && !u.includes('rubygems')) || urls[0] || ''; + } + + // Extract settings keys from Ruby source + const settingsKeys: string[] = []; + if (rbRes) { + for (const m of rbRes.matchAll(/settings\[['"](\w+)['"]\]/g)) { + if (!settingsKeys.includes(m[1])) settingsKeys.push(m[1]); + } + for (const m of rbRes.matchAll(/settings\.fetch\(\s*['"](\w+)['"]/g)) { + if (!settingsKeys.includes(m[1])) settingsKeys.push(m[1]); + } + for (const m of rbRes.matchAll(/settings\[:(\w+)\]/g)) { + if (!settingsKeys.includes(m[1])) settingsKeys.push(m[1]); + } + } + + const sensitivePatterns = ['token', 'key', 'secret', 'password', 'api_key', 'access_token']; + const settingsSchema = settingsKeys.map(key => ({ + key, + label: key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '), + type: 'text', + encrypted: sensitivePatterns.some(p => key.toLowerCase().includes(p)), + required: false, + })); + + return { found: true, template, apiUrl, settingsSchema, slug }; + } catch (error) { + this.logger.warn(`GitHub plugin fetch failed for ${slug}: ${error.message}`); + return { found: false }; + } + } + + private convertErbToLiquid(erb: string): string { + let liquid = erb; + + // Pre-process: normalize ERB whitespace-trimming dashes + // <%- → <%, -%> → %> (keep = for output tags) + liquid = liquid.replace(/<%-/g, '<%'); + liquid = liquid.replace(/-%>/g, '%>'); + + // Handle array slices BEFORE each conversion: array[0..5] → array + // We'll handle limit in the for tag + // Track slices for limit info + const sliceLimits = new Map(); + + // Each with slice + index: <% stories[0..5].each_with_index do |post, idx| %> + liquid = liquid.replace(/<%\s*(\w+)\[(\d+)\.\.(\d+)\]\.each_with_index\s+do\s*\|\s*(\w+)\s*,\s*(\w+)\s*\|\s*%>/g, + (_, arr, start, end, item, _idx) => { + const limit = parseInt(end) - parseInt(start) + 1; + const offset = parseInt(start); + sliceLimits.set(item, offset); + return `{% for ${item} in ${arr} limit:${limit} offset:${offset} %}`; + }); + + // Each with slice: <% array[0..5].each do |item| %> + liquid = liquid.replace(/<%\s*(\w+)\[(\d+)\.\.(\d+)\]\.each\s+do\s*\|\s*(\w+)\s*\|\s*%>/g, + (_, arr, start, end, item) => { + const limit = parseInt(end) - parseInt(start) + 1; + const offset = parseInt(start); + return `{% for ${item} in ${arr} limit:${limit} offset:${offset} %}`; + }); + + // Each with index (no slice): <% array.each_with_index do |item, idx| %> + liquid = liquid.replace(/<%\s*(\w[\w.]*?)\.each_with_index\s+do\s*\|\s*(\w+)\s*,\s*(\w+)\s*\|\s*%>/g, + '{% for $2 in $1 %}'); + + // Simple each: <% array.each do |item| %> + liquid = liquid.replace(/<%\s*(\w[\w.]*?)\.each\s+do\s*\|\s*(\w+)\s*\|\s*%>/g, + '{% for $2 in $1 %}'); + + // first/last N: <% array.first(5).each do |item| %> + liquid = liquid.replace(/<%\s*(\w+)\.first\((\d+)\)\.each\s+do\s*\|\s*(\w+)\s*\|\s*%>/g, + '{% for $3 in $1 limit:$2 %}'); + + // N.times do: <% 5.times do %> or <% 5.times do |i| %> + liquid = liquid.replace(/<%\s*(\d+)\.times\s+do(?:\s*\|\s*\w+\s*\|)?\s*%>/g, + '{% for i in (1..$1) %}'); + + // If statements: <% if condition %> + liquid = liquid.replace(/<%\s*if\s+(.+?)\s*%>/g, (_, cond) => { + return `{% if ${this.cleanRubyCondition(cond)} %}`; + }); + + // Unless + liquid = liquid.replace(/<%\s*unless\s+(.+?)\s*%>/g, (_, cond) => { + return `{% unless ${this.cleanRubyCondition(cond)} %}`; + }); + + // Elsif + liquid = liquid.replace(/<%\s*elsif\s+(.+?)\s*%>/g, (_, cond) => { + return `{% elsif ${this.cleanRubyCondition(cond)} %}`; + }); + + // Else + liquid = liquid.replace(/<%\s*else\s*%>/g, '{% else %}'); + + // End → track as generic, fix later + liquid = liquid.replace(/<%\s*end\s*%>/g, '{% end %}'); + + // render partial (control tag) + liquid = liquid.replace(/<%\s*render\s+['"]([^'"]+)['"].*?%>/g, ''); + liquid = liquid.replace(/<%\s*render\s*\(.*?\)\s*%>/g, ''); + + // render partial (output tag) + liquid = liquid.replace(/<%=\s*render\s+['"]([^'"]+)['"].*?%>/g, ''); + liquid = liquid.replace(/<%=\s*render\s*\(.*?\)\s*%>/g, ''); + + // Output tags: <%= expr %> + liquid = liquid.replace(/<%=\s*(.*?)\s*%>/g, (_, expr) => { + let cleaned = this.cleanRubyExpression(expr); + if (!cleaned || cleaned === '""' || cleaned === "''") return ''; + return `{{ ${cleaned} }}`; + }); + + // Clean up any remaining <% ... %> tags + liquid = liquid.replace(/<%\s*.*?\s*%>/g, ''); + + // Fix end tags using stack tracking + const lines = liquid.split('\n'); + const stack: string[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (/\{%\s*for\s/.test(line)) stack.push('for'); + else if (/\{%\s*if\s/.test(line) || /\{%\s*unless\s/.test(line)) stack.push('if'); + else if (/\{%\s*end\s*%\}/.test(line)) { + const type = stack.pop(); + if (type === 'for') lines[i] = line.replace(/\{%\s*end\s*%\}/, '{% endfor %}'); + else if (type === 'if') lines[i] = line.replace(/\{%\s*end\s*%\}/, '{% endif %}'); + else lines[i] = line.replace(/\{%\s*end\s*%\}/, '{% endif %}'); + } + } + liquid = lines.join('\n'); + + // Post-process: fix idx + N references → forloop.index + offset + liquid = liquid.replace(/\{\{\s*\w+\s*\+\s*\d+\s*\}\}/g, '{{ forloop.index }}'); + // Also plain idx references + liquid = liquid.replace(/\{\{\s*idx\s*\}\}/g, '{{ forloop.index0 }}'); + + // Clean Rails references + liquid = liquid.replace(/\{\{[^}]*Rails\.application[^}]*\}\}/g, ''); + liquid = liquid.replace(/\{\{[^}]*credentials[^}]*\}\}/g, ''); + + // Clean t() i18n calls + liquid = liquid.replace(/\{\{\s*t\(['"][^'"]+['"]\)\s*\}\}/g, ''); + + // Clean empty {{ }} tags + liquid = liquid.replace(/\{\{\s*\}\}/g, ''); + + // Clean double blank lines + liquid = liquid.replace(/\n{3,}/g, '\n\n'); + + return liquid.trim(); + } + + private cleanRubyExpression(expr: string): string { + return expr + // hash[:key] → hash.key + .replace(/(\w+)\[:(\w+)\]/g, '$1.$2') + // hash["key"] or hash['key'] → hash.key + .replace(/(\w+)\[['"](\w+)['"]\]/g, '$1.$2') + // .html_safe, .present?, .to_s, .to_i, .to_f → remove + .replace(/\.(html_safe|present\?|to_s|to_i|to_f|freeze)/g, '') + // .strip → | strip + .replace(/\.strip/g, ' | strip') + .replace(/\.capitalize/g, ' | capitalize') + .replace(/\.downcase/g, ' | downcase') + .replace(/\.upcase/g, ' | upcase') + .replace(/\.round(\(\d+\))?/g, '') + // number_with_delimiter(x) → x | number_with_delimiter + .replace(/number_with_delimiter\(([^)]+)\)/g, '$1 | number_with_delimiter') + .replace(/number_to_currency\(([^)]+)\)/g, '$1 | number_to_currency') + .replace(/truncate\(([^,]+),\s*length:\s*(\d+)\)/g, '$1 | truncate: $2') + .replace(/pluralize\(([^,]+),\s*['"]([^'"]+)['"]\)/g, '"$2" | pluralize: $1') + // simple_format → remove wrapper + .replace(/simple_format\(([^)]+)\)/g, '$1') + // distance_of_time_in_words → remove + .replace(/distance_of_time_in_words\([^)]*\)/g, '""') + .trim(); + } + + private cleanRubyCondition(cond: string): string { + return cond + .replace(/(\w+)\[:(\w+)\]/g, '$1.$2') + .replace(/(\w+)\[['"](\w+)['"]\]/g, '$1.$2') + .replace(/\.present\?/g, '') + .replace(/\.nil\?/g, ' == nil') + .replace(/\.blank\?/g, ' == blank') + .replace(/\.any\?/g, '.size > 0') + .replace(/\.empty\?/g, '.size == 0') + .replace(/&&/g, 'and') + .replace(/\|\|/g, 'or') + .trim(); + } + + // ======================== + // TRMNL Recipe Gallery (public proxy) + // ======================== + + @Get('recipes') + @ApiOperation({ summary: 'Browse TRMNL recipe gallery' }) + async recipes(@Query('search') search?: string, @Query('page') page?: string) { + const params = new URLSearchParams(); + if (search) params.set('search', search); + if (page) params.set('page', page); + const url = `https://trmnl.app/recipes.json${params.toString() ? '?' + params.toString() : ''}`; + try { + const response = await fetch(url, { + headers: { 'Accept': 'application/json', 'User-Agent': 'Inker' }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) return { data: [], total: 0 }; + return response.json(); + } catch (error) { + this.logger.warn(`Recipe gallery fetch failed: ${error.message}`); + return { data: [], total: 0 }; + } + } + + @Get('recipes/:recipeId') + @ApiOperation({ summary: 'Get a single TRMNL recipe' }) + async recipe(@Param('recipeId') recipeId: string) { + try { + const response = await fetch(`https://trmnl.app/recipes/${recipeId}.json`, { + headers: { 'Accept': 'application/json', 'User-Agent': 'Inker' }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) throw new NotFoundException('Recipe not found'); + return response.json(); + } catch (error) { + if (error instanceof NotFoundException) throw error; + this.logger.warn(`Recipe fetch failed: ${error.message}`); + throw new NotFoundException('Recipe not found'); + } + } + + @Get('recipe-image') + @Public() + @ApiOperation({ summary: 'Proxy a recipe screenshot image (avoids signed URL expiry)' }) + async recipeImage(@Query('url') imageUrl: string, @Res() res: Response) { + if (!imageUrl || (!imageUrl.includes('trmnl') && !imageUrl.includes('amazonaws.com'))) { + res.status(400).send('Invalid URL'); + return; + } + try { + const response = await fetch(imageUrl, { + headers: { 'User-Agent': 'Inker' }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) { + res.status(response.status).send('Image not available'); + return; + } + const contentType = response.headers.get('content-type') || 'image/png'; + const buffer = Buffer.from(await response.arrayBuffer()); + res.set({ + 'Content-Type': contentType, + 'Content-Length': buffer.length, + 'Cache-Control': 'public, max-age=3600', + }); + res.send(buffer); + } catch { + res.status(502).send('Failed to fetch image'); + } + } + + @Get('recipe-categories') + @ApiOperation({ summary: 'List TRMNL recipe categories' }) + async recipeCategories() { + try { + const response = await fetch('https://trmnl.app/api/categories', { + headers: { 'Accept': 'application/json', 'User-Agent': 'Inker' }, + signal: AbortSignal.timeout(10000), + }); + if (!response.ok) return { data: [] }; + return response.json(); + } catch { + return { data: [] }; + } + } + + @Post(':id/install') + @ApiOperation({ summary: 'Install a plugin (adds to widget library)' }) + async install(@Param('id', ParseIntPipe) id: number) { + return this.pluginsService.installPlugin(id); + } + + @Post(':id/uninstall') + @ApiOperation({ summary: 'Uninstall a plugin (removes from widget library)' }) + async uninstall(@Param('id', ParseIntPipe) id: number) { + return this.pluginsService.uninstallPlugin(id); + } + + @Post('preview-template') + @ApiOperation({ summary: 'Preview a Liquid template with mock data (for plugin creator)' }) + async previewTemplate(@Body() body: { markup: string; data?: Record }, @Res() res: Response) { + try { + const imageBuffer = await this.pluginsService.previewMarkup(body.markup, body.data || {}); + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': imageBuffer.length, + 'Cache-Control': 'no-store', + }); + res.send(imageBuffer); + } catch (error) { + res.status(400).json({ error: error.message }); + } + } + + @Get(':id/preview') + @Public() + @ApiOperation({ summary: 'Preview a plugin template with empty data' }) + async previewPlugin( + @Param('id', ParseIntPipe) id: number, + @Query('layout') layout: string, + @Res() res: Response, + ) { + const plugin = await this.pluginsService.findPluginById(id); + const validLayout = (['full', 'half_horizontal', 'half_vertical', 'quadrant'].includes(layout) + ? layout + : 'full') as 'full' | 'half_horizontal' | 'half_vertical' | 'quadrant'; + + const imageBuffer = await this.pluginsService.previewPlugin(plugin, validLayout); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': imageBuffer.length, + 'Cache-Control': 'public, max-age=60', + }); + res.send(imageBuffer); + } + + @Get(':id') + @ApiOperation({ summary: 'Get plugin by ID' }) + async findOne(@Param('id', ParseIntPipe) id: number) { + return this.pluginsService.findPluginById(id); + } + + @Post() + @ApiOperation({ summary: 'Create a new plugin' }) + async create(@Body() dto: CreatePluginDto) { + return this.pluginsService.createPlugin(dto); + } + + @Put(':id') + @ApiOperation({ summary: 'Update a plugin' }) + async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePluginDto) { + return this.pluginsService.updatePlugin(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a plugin' }) + async remove(@Param('id', ParseIntPipe) id: number) { + await this.pluginsService.deletePlugin(id); + } + + // ======================== + // Plugin instances + // ======================== + + @Get('instances/all') + @ApiOperation({ summary: 'List all plugin instances' }) + async findAllInstances() { + return this.pluginsService.findAllInstances(); + } + + @Get('instances/:id') + @ApiOperation({ summary: 'Get plugin instance by ID' }) + async findInstance(@Param('id', ParseIntPipe) id: number) { + return this.pluginsService.findInstanceByIdMasked(id); + } + + @Post('instances') + @ApiOperation({ summary: 'Create a plugin instance (install a plugin)' }) + async createInstance(@Body() dto: CreatePluginInstanceDto) { + return this.pluginsService.createInstance(dto); + } + + @Put('instances/:id') + @ApiOperation({ summary: 'Update plugin instance settings' }) + async updateInstance(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdatePluginInstanceDto) { + return this.pluginsService.updateInstance(id, dto); + } + + @Delete('instances/:id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete a plugin instance' }) + async removeInstance(@Param('id', ParseIntPipe) id: number) { + await this.pluginsService.deleteInstance(id); + } + + // ======================== + // Data & Rendering + // ======================== + + @Get('instances/:id/data') + @ApiOperation({ summary: 'Fetch fresh data for a plugin instance' }) + async fetchData(@Param('id', ParseIntPipe) id: number) { + return this.pluginsService.fetchData(id); + } + + @Get('instances/:id/render') + @Public() + @ApiOperation({ summary: 'Render a plugin instance to PNG' }) + async renderInstance( + @Param('id', ParseIntPipe) id: number, + @Query('layout') layout: string, + @Query('mode') mode: string, + @Res() res: Response, + ) { + const validLayout = (['full', 'half_horizontal', 'half_vertical', 'quadrant'].includes(layout) + ? layout + : 'full') as PluginLayout; + const validMode = (['device', 'preview', 'einkPreview'].includes(mode) + ? mode + : 'preview') as 'device' | 'preview' | 'einkPreview'; + + const imageBuffer = await this.pluginsService.renderInstance(id, validLayout, validMode); + + res.set({ + 'Content-Type': 'image/png', + 'Content-Length': imageBuffer.length, + 'Cache-Control': 'no-store', + }); + res.send(imageBuffer); + } + + // ======================== + // OAuth + // ======================== + + @Get('oauth/providers') + @ApiOperation({ summary: 'List available OAuth providers' }) + async listOAuthProviders() { + return this.oauthService.getAvailableProviders(); + } + + @Get('instances/:id/oauth/authorize') + @ApiOperation({ summary: 'Get OAuth authorization URL for a plugin instance' }) + async getOAuthUrl( + @Param('id', ParseIntPipe) id: number, + @Query('provider') provider: string, + ) { + const instance: any = await this.pluginsService.findInstanceById(id); + const oauthProvider = provider || instance.plugin.oauthProvider; + if (!oauthProvider) { + throw new NotFoundException('This plugin does not require OAuth'); + } + return { url: this.oauthService.getAuthorizationUrl(id, oauthProvider) }; + } + + @Get('oauth/callback') + @Public() + @ApiOperation({ summary: 'OAuth callback handler' }) + async oauthCallback( + @Query('code') code: string, + @Query('state') state: string, + @Res() res: Response, + ) { + const { instanceId } = await this.oauthService.handleCallback(code, state); + // Redirect to the plugin instance settings page + res.redirect(`/plugins/instances/${instanceId}?oauth=connected`); + } + + @Post('instances/:id/oauth/disconnect') + @ApiOperation({ summary: 'Disconnect OAuth for a plugin instance' }) + async disconnectOAuth(@Param('id', ParseIntPipe) id: number) { + await this.oauthService.disconnectOAuth(id); + return { message: 'OAuth disconnected' }; + } + + // ======================== + // Webhooks + // ======================== + + @Post('webhooks/:slug') + @Public() + @ApiOperation({ summary: 'Receive webhook data for a plugin' }) + async receiveWebhook( + @Param('slug') slug: string, + @Body() body: any, + ) { + return this.pluginsService.handleWebhook(slug, body); + } +} diff --git a/backend/src/plugins/plugins.module.ts b/backend/src/plugins/plugins.module.ts new file mode 100644 index 0000000..3c004d0 --- /dev/null +++ b/backend/src/plugins/plugins.module.ts @@ -0,0 +1,25 @@ +import { Module, forwardRef, OnModuleInit } from '@nestjs/common'; +import { PrismaModule } from '../prisma/prisma.module'; +import { ScreenDesignerModule } from '../screen-designer/screen-designer.module'; +import { PluginsService } from './plugins.service'; +import { PluginRendererService } from './plugin-renderer.service'; +import { OAuthService } from './oauth/oauth.service'; +import { PluginsController } from './plugins.controller'; + +@Module({ + imports: [PrismaModule, forwardRef(() => ScreenDesignerModule)], + controllers: [PluginsController], + providers: [PluginsService, PluginRendererService, OAuthService], + exports: [PluginsService, PluginRendererService, OAuthService], +}) +export class PluginsModule implements OnModuleInit { + constructor(private readonly pluginsService: PluginsService) {} + + async onModuleInit() { + try { + await this.pluginsService.cleanupStalePlugins(); + } catch { + // Non-critical — skip if DB not ready + } + } +} diff --git a/backend/src/plugins/plugins.service.ts b/backend/src/plugins/plugins.service.ts new file mode 100644 index 0000000..a227beb --- /dev/null +++ b/backend/src/plugins/plugins.service.ts @@ -0,0 +1,624 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { PluginRendererService, PluginLayout } from './plugin-renderer.service'; +import { EncryptionService } from '../common/services/encryption.service'; +import { OAuthService } from './oauth/oauth.service'; +import { + CreatePluginDto, + UpdatePluginDto, + CreatePluginInstanceDto, + UpdatePluginInstanceDto, +} from './dto/create-plugin.dto'; + +const SETTINGS_MASK = '••••••••'; +const MAX_FETCHES_PER_MINUTE = 30; + +@Injectable() +export class PluginsService { + private readonly logger = new Logger(PluginsService.name); + private fetchCounter = 0; + private fetchCounterResetAt = Date.now() + 60000; + + constructor( + private readonly prisma: PrismaService, + private readonly pluginRenderer: PluginRendererService, + private readonly encryption: EncryptionService, + private readonly oauthService: OAuthService, + ) {} + + // ======================== + // Plugin CRUD + // ======================== + + async findAllPlugins() { + return this.prisma.plugin.findMany({ + orderBy: [{ category: 'asc' }, { name: 'asc' }], + include: { _count: { select: { instances: true } } }, + }); + } + + async findPluginById(id: number) { + const plugin = await this.prisma.plugin.findUnique({ + where: { id }, + include: { instances: true }, + }); + if (!plugin) throw new NotFoundException(`Plugin ${id} not found`); + return plugin; + } + + async findPluginBySlug(slug: string) { + return this.prisma.plugin.findUnique({ where: { slug } }); + } + + async createPlugin(dto: CreatePluginDto) { + return this.prisma.plugin.create({ data: dto }); + } + + async updatePlugin(id: number, dto: UpdatePluginDto) { + return this.prisma.plugin.update({ where: { id }, data: dto }); + } + + async deletePlugin(id: number) { + return this.prisma.plugin.delete({ where: { id } }); + } + + // ======================== + // Install / Uninstall + // ======================== + + async installPlugin(id: number) { + return this.prisma.plugin.update({ + where: { id }, + data: { isInstalled: true }, + }); + } + + async uninstallPlugin(id: number) { + return this.prisma.plugin.update({ + where: { id }, + data: { isInstalled: false }, + }); + } + + // ======================== + // Plugin Instances + // ======================== + + async findAllInstances() { + return this.prisma.pluginInstance.findMany({ + include: { plugin: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findInstanceById(id: number) { + const instance = await this.prisma.pluginInstance.findUnique({ + where: { id }, + include: { plugin: true }, + }); + if (!instance) throw new NotFoundException(`Plugin instance ${id} not found`); + return instance; + } + + async createInstance(dto: CreatePluginInstanceDto) { + const plugin = await this.findPluginById(dto.pluginId); + const { plain, encrypted } = this.separateEncryptedFields( + dto.settings || {}, + (plugin.settingsSchema as any[]) || [], + ); + + return this.prisma.pluginInstance.create({ + data: { + pluginId: dto.pluginId, + name: dto.name, + settings: plain, + settingsEncrypted: encrypted, + }, + include: { plugin: true }, + }); + } + + async updateInstance(id: number, dto: UpdatePluginInstanceDto) { + const instance = await this.prisma.pluginInstance.findUnique({ + where: { id }, + include: { plugin: true }, + }); + if (!instance) throw new NotFoundException(`Plugin instance ${id} not found`); + + if (dto.settings) { + const existingEncrypted = (instance.settingsEncrypted || {}) as Record; + const schema = (instance.plugin.settingsSchema as any[]) || []; + const encryptedKeys = new Set(schema.filter(f => f.encrypted).map(f => f.key)); + + for (const key of encryptedKeys) { + if (dto.settings[key] === SETTINGS_MASK && existingEncrypted[key]) { + delete dto.settings[key]; + } + } + + const { plain, encrypted } = this.separateEncryptedFields(dto.settings, schema); + + return this.prisma.pluginInstance.update({ + where: { id }, + data: { + name: dto.name, + settings: plain, + settingsEncrypted: { ...existingEncrypted, ...encrypted }, + lastData: Prisma.DbNull, + lastFetchedAt: null, + lastError: null, + }, + include: { plugin: true }, + }); + } + + return this.prisma.pluginInstance.update({ + where: { id }, + data: { name: dto.name }, + include: { plugin: true }, + }); + } + + async findInstanceByIdMasked(id: number) { + const instance = await this.findInstanceById(id); + return this.maskEncryptedSettings(instance); + } + + getDecryptedSettings(instance: any): Record { + const plain = (instance.settings || {}) as Record; + const encrypted = (instance.settingsEncrypted || {}) as Record; + if (Object.keys(encrypted).length === 0) return plain; + return { ...plain, ...this.encryption.decryptObject(encrypted) }; + } + + private separateEncryptedFields( + settings: Record, + schema: any[], + ): { plain: Record; encrypted: Record } { + const encryptedKeys = new Set(schema.filter(f => f.encrypted).map(f => f.key)); + const plain: Record = {}; + const toEncrypt: Record = {}; + + for (const [key, value] of Object.entries(settings)) { + if (encryptedKeys.has(key) && value !== undefined && value !== null && value !== '') { + toEncrypt[key] = value; + } else { + plain[key] = value; + } + } + + const encrypted = Object.keys(toEncrypt).length > 0 + ? this.encryption.encryptObject(toEncrypt) + : {}; + + return { plain, encrypted }; + } + + private maskEncryptedSettings(instance: any): any { + const encrypted = (instance.settingsEncrypted || {}) as Record; + if (Object.keys(encrypted).length === 0) return instance; + + const maskedSettings = { ...(instance.settings as Record) }; + for (const key of Object.keys(encrypted)) { + maskedSettings[key] = SETTINGS_MASK; + } + + return { ...instance, settings: maskedSettings, settingsEncrypted: undefined }; + } + + async deleteInstance(id: number) { + return this.prisma.pluginInstance.delete({ where: { id } }); + } + + private validateSettings(settings: Record, schema: any[]): void { + for (const field of schema) { + const value = settings[field.key]; + if (field.required && (value === undefined || value === null || value === '')) { + throw new Error(`Setting "${field.label || field.key}" is required`); + } + if (value === undefined || value === null || value === '') continue; + if (field.type === 'number' && typeof value !== 'number' && isNaN(Number(value))) { + throw new Error(`Setting "${field.label || field.key}" must be a number`); + } + if (field.type === 'select' && field.options?.length > 0) { + const validOptions = field.options.map((o: any) => typeof o === 'object' ? o.value : o); + if (!validOptions.includes(value)) { + throw new Error(`Setting "${field.label || field.key}" must be one of: ${validOptions.join(', ')}`); + } + } + } + } + + // ======================== + // Data Fetching + // ======================== + + async fetchData(instanceId: number): Promise> { + const instance = await this.findInstanceById(instanceId); + const plugin = instance.plugin; + const settings = this.getDecryptedSettings(instance); + + // Check cache + if (instance.lastFetchedAt && instance.lastData) { + const age = (Date.now() - instance.lastFetchedAt.getTime()) / 1000; + if (age < plugin.refreshInterval) { + return instance.lastData as Record; + } + } + + // Rate limiting + if (Date.now() > this.fetchCounterResetAt) { + this.fetchCounter = 0; + this.fetchCounterResetAt = Date.now() + 60000; + } + if (this.fetchCounter >= MAX_FETCHES_PER_MINUTE) { + this.logger.warn(`Rate limit reached (${MAX_FETCHES_PER_MINUTE}/min), returning cached data`); + return (instance.lastData as Record) || {}; + } + this.fetchCounter++; + + // Pre-flight: check required/encrypted settings before making external requests + const schema = (plugin.settingsSchema as any[]) || []; + const missingFields = schema + .filter(f => (f.encrypted || f.required) && !settings[f.key]) + .map(f => f.label || f.key); + if (missingFields.length > 0) { + const errorMsg = `[settings] Missing required: ${missingFields.join(', ')}`; + this.logger.warn(`Plugin ${plugin.slug}: ${errorMsg}`); + await this.prisma.pluginInstance.update({ + where: { id: instanceId }, + data: { lastError: errorMsg }, + }); + return (instance.lastData as Record) || {}; + } + + try { + // Inject OAuth access token if available + if ((plugin as any).oauthProvider) { + const accessToken = await this.oauthService.getAccessToken(instanceId); + if (accessToken) { + settings.oauth_access_token = accessToken; + } + } + + const data = await this.executePlugin(plugin, settings); + + await this.prisma.pluginInstance.update({ + where: { id: instanceId }, + data: { lastData: data, lastFetchedAt: new Date(), lastError: null }, + }); + + return data; + } catch (error) { + const msg = error.message || String(error); + const prefix = msg.includes('HTTP ') || msg.includes('timeout') ? '[network]' : '[plugin]'; + const errorMsg = msg.startsWith('[') ? msg : `${prefix} ${msg}`; + this.logger.error(`Plugin ${plugin.slug} fetch failed: ${errorMsg}`); + await this.prisma.pluginInstance.update({ + where: { id: instanceId }, + data: { lastError: errorMsg }, + }); + return (instance.lastData as Record) || {}; + } + } + + async fetchDataForPlugin(pluginId: number, settings: Record = {}): Promise> { + const plugin = await this.findPluginById(pluginId); + try { + return await this.executePlugin(plugin, settings); + } catch (error) { + this.logger.error(`Plugin ${plugin.slug} execute failed: ${error.message}`); + return {}; + } + } + + /** + * Execute a plugin's data pipeline. + * Priority: 1) JS adapter, 2) URL fetch + */ + private async executePlugin(plugin: any, settings: Record): Promise> { + // JS adapter (for user-created plugins with dataTransform) + if (plugin.dataTransform) { + return this.runAsyncTransform(plugin.dataTransform, settings, plugin.slug); + } + + // URL fetch (for URL-based custom plugins) + if (plugin.dataUrl) { + const url = this.interpolate(plugin.dataUrl, settings); + const headers: Record = { 'Accept': 'application/json' }; + if (plugin.dataHeaders) { + for (const [key, value] of Object.entries(plugin.dataHeaders as Record)) { + headers[key] = this.interpolate(value, settings); + } + } + const response = await fetch(url, { + method: plugin.dataMethod || 'GET', + headers, + signal: AbortSignal.timeout(15000), + }); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`); + let data = await response.json(); + if (plugin.dataPath) data = this.extractByPath(data, plugin.dataPath); + return data; + } + + return {}; + } + + private async runAsyncTransform( + script: string, + settings: Record, + slug: string, + ): Promise> { + try { + const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; + const fn = new AsyncFunction('settings', 'fetch', script); + const result = await Promise.race([ + fn(settings, globalThis.fetch), + new Promise((_, reject) => setTimeout(() => reject(new Error('Plugin timeout (10s)')), 10000)), + ]); + return (result && typeof result === 'object') ? result : {}; + } catch (error) { + this.logger.error(`Plugin ${slug} JS adapter failed: ${error.message}`); + throw error; + } + } + + // ======================== + // Rendering + // ======================== + + /** + * Preview a plugin with mock data or placeholder card. + */ + async previewPlugin( + plugin: any, + layout: PluginLayout = 'full', + ): Promise { + const { width, height } = this.getDimensionsForLayout(layout); + + // For custom plugins with Liquid markup, render with mock data + const markup = this.pluginRenderer.selectMarkup(plugin, layout); + if (markup) { + try { + const mockData = this.generateMockData(markup); + return await this.pluginRenderer.renderToPng(markup, mockData, {}, width, height, 'preview'); + } catch (e) { + this.logger.warn(`Plugin ${plugin.slug} Liquid preview failed: ${e.message}`); + } + } + + // Fallback: placeholder card + return this.renderPluginPlaceholder(plugin, width, height); + } + + /** + * Preview raw Liquid markup with provided or auto-generated mock data (for plugin creator). + */ + async previewMarkup(markup: string, data: Record = {}): Promise { + const mockData = Object.keys(data).length > 0 ? data : this.generateMockData(markup); + return this.pluginRenderer.renderToPng(markup, mockData, {}, 800, 480, 'preview'); + } + + /** + * Render a plugin instance to PNG for device display. + */ + async renderInstance( + instanceId: number, + layout: PluginLayout = 'full', + mode: 'device' | 'preview' | 'einkPreview' = 'device', + ): Promise { + const instance = await this.findInstanceById(instanceId); + const plugin = instance.plugin; + const settings = this.getDecryptedSettings(instance); + const { width, height } = this.getDimensionsForLayout(layout); + + // Fetch fresh data + const locals = await this.fetchData(instanceId); + + // Liquid rendering (for custom plugins with markup in DB) + const markup = this.pluginRenderer.selectMarkup(plugin, layout); + if (!markup) { + throw new NotFoundException(`Plugin ${plugin.slug} has no template for layout ${layout}`); + } + + return this.pluginRenderer.renderToPng(markup, locals, settings, width, height, mode); + } + + private async renderPluginPlaceholder(plugin: any, width: number, height: number): Promise { + const category = (plugin.category || 'custom').charAt(0).toUpperCase() + (plugin.category || 'custom').slice(1); + const source = (plugin.source || 'inker').toUpperCase(); + const description = plugin.description || 'No description available'; + const needsConfig = plugin.settingsSchema && (plugin.settingsSchema as any[]).some((f: any) => f.encrypted); + + const html = ` + +
+
+
${plugin.name}
+
${description.slice(0, 120)}
+
+ ${category} + ${source} +
+ ${needsConfig ? '
Requires API key to show live data
' : ''} +
+ +
+`; + + return this.pluginRenderer.screenRenderer.renderHtmlToPng(html, width, height); + } + + private generateMockData(template: string): Record { + const data: Record = {}; + const forMatches = template.matchAll(/\{%\s*for\s+(\w+)\s+in\s+(\w+)/g); + for (const match of forMatches) { + const itemVar = match[1]; + const collectionVar = match[2]; + if (!data[collectionVar]) { + data[collectionVar] = Array.from({ length: 3 }, (_, i) => ({ + title: `Sample ${itemVar} ${i + 1}`, + name: `Sample ${i + 1}`, + value: (i + 1) * 100, + score: (i + 1) * 10, + label: `Label ${i + 1}`, + description: `Description for item ${i + 1}`, + })); + } + } + const varMatches = template.matchAll(/\{\{\s*(\w+)\s*[|}]/g); + for (const match of varMatches) { + const varName = match[1]; + if (!data[varName] && !['for', 'if', 'unless', 'else', 'endif', 'endfor', 'forloop', 'settings'].includes(varName)) { + data[varName] = `${varName.replace(/_/g, ' ')}`; + } + } + data.instance_name = 'Preview'; + return data; + } + + // ======================== + // Webhooks + // ======================== + + async handleWebhook(slug: string, data: Record): Promise<{ updated: number }> { + const plugin = await this.findPluginBySlug(slug); + if (!plugin) throw new NotFoundException(`Plugin "${slug}" not found`); + + const instances = await this.prisma.pluginInstance.findMany({ + where: { pluginId: plugin.id }, + }); + + let updated = 0; + for (const instance of instances) { + await this.prisma.pluginInstance.update({ + where: { id: instance.id }, + data: { lastData: data, lastFetchedAt: new Date(), lastError: null }, + }); + updated++; + } + + this.logger.log(`Webhook for ${slug}: updated ${updated} instances`); + return { updated }; + } + + // ======================== + // Helpers + // ======================== + + private interpolate(template: string, settings: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { + return settings[key] !== undefined ? String(settings[key]) : ''; + }); + } + + private extractByPath(data: any, dataPath: string): any { + const parts = dataPath.split('.'); + let result = data; + for (const part of parts) { + if (result == null) return null; + const arrayMatch = part.match(/^(\w+)\[(\d+)\]$/); + if (arrayMatch) { + result = result[arrayMatch[1]]?.[parseInt(arrayMatch[2])]; + } else { + result = result[part]; + } + } + return result; + } + + private getDimensionsForLayout(layout: PluginLayout): { width: number; height: number } { + switch (layout) { + case 'full': return { width: 800, height: 480 }; + case 'half_horizontal': return { width: 800, height: 240 }; + case 'half_vertical': return { width: 400, height: 480 }; + case 'quadrant': return { width: 400, height: 240 }; + default: return { width: 800, height: 480 }; + } + } + + // ======================== + // Diagnostics + // ======================== + + async diagnosePlugins(): Promise { + const plugins = await this.prisma.plugin.findMany({ + orderBy: { name: 'asc' }, + }); + + return plugins.map(plugin => { + const schema = (plugin.settingsSchema as any[]) || []; + const hasEncrypted = schema.some(f => f.encrypted); + const hasRequired = schema.some(f => f.required); + const configRequirement = (plugin as any).oauthProvider + ? 'oauth' + : (hasEncrypted || hasRequired) ? 'api_key' : 'none'; + const hasMarkup = !!(plugin.markupFull || (plugin as any).dataUrl); + + return { + slug: plugin.slug, + name: plugin.name, + id: plugin.id, + status: hasMarkup ? (configRequirement !== 'none' ? 'needs_config' : 'ready') : 'no_template', + configRequirement, + settingsCount: schema.length, + }; + }); + } + + // ======================== + // Widget Templates Integration + // ======================== + + async getAsWidgetTemplates(): Promise { + const plugins = await this.prisma.plugin.findMany({ + where: { isInstalled: true }, + }); + + return plugins.map((plugin, index) => ({ + id: 20000 + plugin.id, + name: plugin.name, + description: plugin.description || '', + category: 'Plugins', + icon: plugin.icon || 'puzzle', + config: { + type: 'plugin', + pluginId: plugin.id, + pluginSlug: plugin.slug, + }, + })); + } + + // ======================== + // Cleanup + // ======================== + + async cleanupStalePlugins(): Promise { + // Clean up stale TRMNL-synced plugins and mirror (Ruby pipeline removed) + const deleted = await this.prisma.plugin.deleteMany({ + where: { OR: [{ source: 'trmnl' }, { slug: 'trmnl_mirror' }] }, + }); + if (deleted.count > 0) { + this.logger.log(`Cleaned up ${deleted.count} stale plugins`); + } + } +} diff --git a/backend/src/plugins/sync/trmnl-css.ts b/backend/src/plugins/sync/trmnl-css.ts new file mode 100644 index 0000000..b55544f --- /dev/null +++ b/backend/src/plugins/sync/trmnl-css.ts @@ -0,0 +1,296 @@ +/** + * TRMNL CSS Framework for Plugin Rendering + * + * Comprehensive CSS matching TRMNL's utility class system for e-ink displays. + * Based on TRMNL framework documentation and plugin template analysis. + */ + +export const TRMNL_CSS = ` +/* ============================ + TRMNL Framework CSS for Inker + ============================ */ + +/* Reset */ +* { margin: 0; padding: 0; box-sizing: border-box; } + +/* Root */ +body { + font-family: 'Inter', 'Helvetica Neue', Arial, sans-serif; + background: #fff; + color: #000; + overflow: hidden; + -webkit-font-smoothing: antialiased; +} + +/* Screen container */ +.screen { width: 100%; height: 100%; position: relative; } + +/* View container */ +.view { width: 100%; height: 100%; display: flex; flex-direction: column; position: relative; } +.view--full { width: 100%; height: 100%; } +.view--half_horizontal { width: 100%; height: 100%; } +.view--half_vertical { width: 100%; height: 100%; } +.view--quadrant { width: 100%; height: 100%; } + +/* Layout */ +.layout { flex: 1; display: flex; flex-direction: column; padding: 16px; overflow: hidden; } +.layout--row { flex-direction: row; } +.layout--col { flex-direction: column; } +.layout--left { align-items: flex-start; } +.layout--center-x { align-items: center; } +.layout--right { align-items: flex-end; } +.layout--top { justify-content: flex-start; } +.layout--center-y { justify-content: center; } +.layout--bottom { justify-content: flex-end; } +.layout--stretch { align-items: stretch; justify-content: stretch; } +.layout--stretch-x { align-items: stretch; } +.layout--stretch-y { justify-content: stretch; } + +/* Flex */ +.flex { display: flex; } +.flex--row { flex-direction: row; } +.flex--col { flex-direction: column; } +.flex--wrap { flex-wrap: wrap; } +.flex--left { justify-content: flex-start; } +.flex--center-x { justify-content: center; } +.flex--right { justify-content: flex-end; } +.flex--top { align-items: flex-start; } +.flex--center-y { align-items: center; } +.flex--bottom { align-items: flex-end; } +.flex--stretch { align-items: stretch; } +.flex--between { justify-content: space-between; } +.flex--around { justify-content: space-around; } +.flex--evenly { justify-content: space-evenly; } +.flex--row-reverse { flex-direction: row-reverse; } +.flex--col-reverse { flex-direction: column-reverse; } + +/* Grid */ +.grid { display: grid; } +.grid--cols-1 { grid-template-columns: repeat(1, 1fr); } +.grid--cols-2 { grid-template-columns: repeat(2, 1fr); } +.grid--cols-3 { grid-template-columns: repeat(3, 1fr); } +.grid--cols-4 { grid-template-columns: repeat(4, 1fr); } +.grid--cols-5 { grid-template-columns: repeat(5, 1fr); } +.grid--cols-6 { grid-template-columns: repeat(6, 1fr); } +.grid--cols-7 { grid-template-columns: repeat(7, 1fr); } +.grid--cols-8 { grid-template-columns: repeat(8, 1fr); } +.grid--cols-9 { grid-template-columns: repeat(9, 1fr); } +.grid--cols-10 { grid-template-columns: repeat(10, 1fr); } +.grid--cols-11 { grid-template-columns: repeat(11, 1fr); } +.grid--cols-12 { grid-template-columns: repeat(12, 1fr); } +.grid--wrap { grid-auto-flow: row; } +.col--span-1 { grid-column: span 1; } +.col--span-2 { grid-column: span 2; } +.col--span-3 { grid-column: span 3; } +.col--span-4 { grid-column: span 4; } +.col--span-5 { grid-column: span 5; } +.col--span-6 { grid-column: span 6; } +.col--span-7 { grid-column: span 7; } +.col--span-8 { grid-column: span 8; } +.col--span-9 { grid-column: span 9; } +.col--span-10 { grid-column: span 10; } +.col--span-11 { grid-column: span 11; } +.col--span-12 { grid-column: span 12; } + +/* Columns */ +.columns { display: flex; flex-wrap: wrap; gap: 8px; } +.column { flex: 1; min-width: 0; } + +/* Gaps */ +.gap--none { gap: 0; } +.gap--xsmall { gap: 2px; } +.gap--small { gap: 4px; } +.gap { gap: 8px; } +.gap--medium { gap: 12px; } +.gap--large { gap: 16px; } +.gap--xlarge { gap: 24px; } +.gap--xxlarge { gap: 32px; } +.gap--auto { gap: auto; } +.gap--distribute, .gap--space-between { justify-content: space-between; } + +/* Margins */ +.m--none { margin: 0; } +.m--xsmall { margin: 2px; } +.m--small { margin: 4px; } +.m--base { margin: 8px; } +.m--medium { margin: 12px; } +.m--large { margin: 16px; } +.m--xlarge { margin: 24px; } +.m--xxlarge { margin: 32px; } +.mt--small { margin-top: 4px; } .mt--base { margin-top: 8px; } .mt--medium { margin-top: 12px; } .mt--large { margin-top: 16px; } +.mb--small { margin-bottom: 4px; } .mb--base { margin-bottom: 8px; } .mb--medium { margin-bottom: 12px; } .mb--large { margin-bottom: 16px; } +.ml--small { margin-left: 4px; } .ml--base { margin-left: 8px; } .ml--medium { margin-left: 12px; } .ml--large { margin-left: 16px; } +.mr--small { margin-right: 4px; } .mr--base { margin-right: 8px; } .mr--medium { margin-right: 12px; } .mr--large { margin-right: 16px; } +.mx--small { margin-left: 4px; margin-right: 4px; } .mx--base { margin-left: 8px; margin-right: 8px; } +.my--small { margin-top: 4px; margin-bottom: 4px; } .my--base { margin-top: 8px; margin-bottom: 8px; } + +/* Padding */ +.p--none { padding: 0; } +.p--xsmall { padding: 2px; } +.p--small { padding: 4px; } +.p--base { padding: 8px; } +.p--medium { padding: 12px; } +.p--large { padding: 16px; } +.p--xlarge { padding: 24px; } +.p--xxlarge { padding: 32px; } +.pt--small { padding-top: 4px; } .pt--base { padding-top: 8px; } .pt--medium { padding-top: 12px; } .pt--large { padding-top: 16px; } +.pb--small { padding-bottom: 4px; } .pb--base { padding-bottom: 8px; } .pb--medium { padding-bottom: 12px; } .pb--large { padding-bottom: 16px; } +.pl--small { padding-left: 4px; } .pl--base { padding-left: 8px; } .pl--medium { padding-left: 12px; } .pl--large { padding-left: 16px; } +.pr--small { padding-right: 4px; } .pr--base { padding-right: 8px; } .pr--medium { padding-right: 12px; } .pr--large { padding-right: 16px; } +.px--small { padding-left: 4px; padding-right: 4px; } .px--base { padding-left: 8px; padding-right: 8px; } +.py--small { padding-top: 4px; padding-bottom: 4px; } .py--base { padding-top: 8px; padding-bottom: 8px; } + +/* Typography */ +.title { font-size: 24px; font-weight: 700; line-height: 1.2; } +.title--small { font-size: 18px; font-weight: 700; line-height: 1.2; } +.label { font-size: 12px; font-weight: 500; line-height: 1.3; } +.label--small { font-size: 10px; font-weight: 500; line-height: 1.3; } +.label--underline { text-decoration: underline; } +.label--gray-out { opacity: 0.5; } +.description { font-size: 14px; line-height: 1.4; } +.index { font-size: 12px; font-weight: 700; opacity: 0.5; min-width: 20px; } + +/* Values (metrics) */ +.value { font-size: 32px; font-weight: 700; line-height: 1.1; } +.value--xxxlarge { font-size: 72px; font-weight: 700; line-height: 1; } +.value--xxlarge { font-size: 56px; font-weight: 700; line-height: 1; } +.value--xlarge { font-size: 48px; font-weight: 700; line-height: 1; } +.value--large { font-size: 40px; font-weight: 700; line-height: 1.1; } +.value--base { font-size: 32px; font-weight: 700; line-height: 1.1; } +.value--small { font-size: 24px; font-weight: 700; line-height: 1.2; } +.value--xsmall { font-size: 18px; font-weight: 700; line-height: 1.2; } +.value--xxsmall { font-size: 14px; font-weight: 700; line-height: 1.3; } + +/* Text utilities */ +.text--left { text-align: left; } +.text--center { text-align: center; } +.text--right { text-align: right; } +.text--justify { text-align: justify; } +.text--black { color: #000; } +.text--white { color: #fff; } +.text--gray-10 { color: #1a1a1a; } +.text--gray-20 { color: #333; } +.text--gray-30 { color: #4d4d4d; } +.text--gray-40 { color: #666; } +.text--gray-50 { color: #808080; } +.text--gray-55 { color: #8c8c8c; } +.text--gray-60 { color: #999; } +.text--gray-70 { color: #b3b3b3; } +.text--gray-75 { color: #bfbfbf; } + +/* Sizing */ +.w--full { width: 100%; } +.h--full { height: 100%; } +.w--auto { width: auto; } +.h--auto { height: auto; } +.w--0 { width: 0; } .w--1 { width: 4px; } .w--2 { width: 8px; } .w--3 { width: 12px; } .w--4 { width: 16px; } +.w--5 { width: 20px; } .w--6 { width: 24px; } .w--7 { width: 28px; } .w--8 { width: 32px; } +.w--10 { width: 40px; } .w--12 { width: 48px; } .w--16 { width: 64px; } .w--20 { width: 80px; } +.w--24 { width: 96px; } .w--32 { width: 128px; } .w--40 { width: 160px; } .w--48 { width: 192px; } +.h--0 { height: 0; } .h--1 { height: 4px; } .h--2 { height: 8px; } .h--3 { height: 12px; } .h--4 { height: 16px; } +.h--5 { height: 20px; } .h--6 { height: 24px; } .h--7 { height: 28px; } .h--8 { height: 32px; } +.h--10 { height: 40px; } .h--12 { height: 48px; } .h--16 { height: 64px; } .h--20 { height: 80px; } + +/* Background colors */ +.bg-black, .bg--black { background-color: #000; } +.bg-white, .bg--white { background-color: #fff; } +.bg--gray-5 { background-color: #f2f2f2; } +.bg--gray-10 { background-color: #e6e6e6; } +.bg--gray-15 { background-color: #d9d9d9; } +.bg--gray-20 { background-color: #ccc; } +.bg--gray-25 { background-color: #bfbfbf; } +.bg--gray-30 { background-color: #b3b3b3; } +.bg--gray-35 { background-color: #a6a6a6; } +.bg--gray-40 { background-color: #999; } +.bg--gray-45 { background-color: #8c8c8c; } +.bg--gray-50 { background-color: #808080; } +.bg--gray-55 { background-color: #737373; } +.bg--gray-60 { background-color: #666; } +.bg--gray-65 { background-color: #595959; } +.bg--gray-70 { background-color: #4d4d4d; } +.bg--gray-75 { background-color: #404040; } + +/* Borders */ +.border--h-1 { border-top: 1px solid #000; border-bottom: 1px solid #000; } +.border--h-2 { border-top: 1px solid #333; border-bottom: 1px solid #333; } +.border--h-3 { border-top: 1px solid #666; border-bottom: 1px solid #666; } +.border--h-4 { border-top: 1px solid #999; border-bottom: 1px solid #999; } +.border--h-5 { border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; } +.border--v-1 { border-left: 1px solid #000; border-right: 1px solid #000; } +.border--v-2 { border-left: 1px solid #333; border-right: 1px solid #333; } +.b-h-gray-5 { border-bottom: 1px solid #e6e6e6; } + +/* Border radius */ +.rounded--none { border-radius: 0; } +.rounded--xsmall { border-radius: 2px; } +.rounded--small { border-radius: 4px; } +.rounded { border-radius: 6px; } +.rounded--medium { border-radius: 8px; } +.rounded--large { border-radius: 12px; } +.rounded--xlarge { border-radius: 16px; } +.rounded--xxlarge { border-radius: 24px; } +.rounded--full { border-radius: 9999px; } + +/* Divider */ +.divider { border-top: 2px solid #000; width: 100%; } + +/* Title bar (plugin footer/header) */ +.title_bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + border-top: 2px solid #000; + font-size: 12px; +} +.title_bar .image { width: 20px; height: 20px; } +.title_bar .title { font-weight: 700; } +.title_bar .instance { opacity: 0.5; margin-left: auto; } + +/* Item component */ +.item { + display: flex; + align-items: baseline; + gap: 8px; + padding: 4px 0; +} +.item--emphasis-1 { font-weight: 700; } +.item--emphasis-2 { background-color: #e6e6e6; padding: 4px 8px; border-radius: 4px; } +.item--emphasis-3 { background-color: #000; color: #fff; padding: 4px 8px; border-radius: 4px; } +.item .meta { font-size: 11px; opacity: 0.6; } + +/* Table */ +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { text-align: left; padding: 4px 8px; } +.table th { font-weight: 700; border-bottom: 2px solid #000; } +.table td { border-bottom: 1px solid #e6e6e6; } +.table--small th, .table--small td { font-size: 12px; padding: 2px 6px; } +.table--xsmall th, .table--xsmall td { font-size: 10px; padding: 2px 4px; } +.table--indexed td:first-child { font-weight: 700; width: 30px; } + +/* Progress bar */ +.progress-bar { width: 100%; height: 8px; background: #e6e6e6; border-radius: 4px; overflow: hidden; } +.progress-bar--large { height: 16px; } +.progress-bar--small { height: 4px; } +.progress-bar--xsmall { height: 2px; } +.progress-bar > div { height: 100%; background: #000; border-radius: 4px; } + +/* Progress dots */ +.progress-dots { display: flex; gap: 4px; } +.dot { width: 8px; height: 8px; border-radius: 50%; background: #e6e6e6; } +.dot--filled { background: #000; } +.dot--current { background: #000; border: 2px solid #000; } + +/* Image */ +.image { display: block; max-width: 100%; } +.image--fill { object-fit: fill; width: 100%; height: 100%; } +.image--contain { object-fit: contain; } +.image--cover { object-fit: cover; } + +/* Overflow / truncation */ +[data-clamp="1"] { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; } +[data-clamp="2"] { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } +[data-clamp="3"] { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; } +.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.overflow--hidden { overflow: hidden; } +`; diff --git a/backend/src/screen-designer/screen-designer.module.ts b/backend/src/screen-designer/screen-designer.module.ts index 26bd03a..51688d8 100644 --- a/backend/src/screen-designer/screen-designer.module.ts +++ b/backend/src/screen-designer/screen-designer.module.ts @@ -1,14 +1,15 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { PrismaModule } from '../prisma/prisma.module'; import { CustomWidgetsModule } from '../custom-widgets/custom-widgets.module'; import { SettingsModule } from '../settings/settings.module'; +import { PluginsModule } from '../plugins/plugins.module'; import { ScreenDesignerController, WidgetTemplatesController } from './screen-designer.controller'; import { ScreenDesignerService } from './screen-designer.service'; import { WidgetTemplatesService } from './services/widget-templates.service'; import { ScreenRendererService } from './services/screen-renderer.service'; @Module({ - imports: [PrismaModule, CustomWidgetsModule, SettingsModule], + imports: [PrismaModule, CustomWidgetsModule, SettingsModule, forwardRef(() => PluginsModule)], controllers: [ScreenDesignerController, WidgetTemplatesController], providers: [ ScreenDesignerService, diff --git a/backend/src/screen-designer/services/screen-renderer.service.ts b/backend/src/screen-designer/services/screen-renderer.service.ts index 68b734c..e10db7e 100644 --- a/backend/src/screen-designer/services/screen-renderer.service.ts +++ b/backend/src/screen-designer/services/screen-renderer.service.ts @@ -327,7 +327,7 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { * @param height - Original canvas height * @param negate - If true, invert colors (required for TRMNL e-ink devices) */ - private async applyEinkProcessing( + async applyEinkProcessing( canvas: Sharp, width: number, height: number, @@ -534,6 +534,9 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { case 'custom-widget-base': return this.renderCustomWidget(width, height, widgetConfig); + case 'plugin': + return this.renderPluginWidget(width, height, widgetConfig); + default: this.logger.warn(`Unknown widget type: ${template.name}`); return this.renderPlaceholderWidget(width, height, template.label); @@ -1810,8 +1813,8 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { } try { - // Fetch the custom widget with cached data (no external API fetch during render) - const preview = await this.customWidgetsService.getWithData(customWidgetId, true); + // Fetch the custom widget, refreshing stale data sources if needed + const preview = await this.customWidgetsService.getWithData(customWidgetId); const { widget, renderedContent } = preview; const widgetConfig = widget.config as Record; const fieldType = widgetConfig.fieldType as string | undefined; @@ -1837,6 +1840,121 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { } } + /** + * Render a plugin widget using the plugin's Liquid template. + * Settings come from the ScreenWidget config (not a separate PluginInstance). + */ + private async renderPluginWidget( + width: number, + height: number, + config: Record, + ): Promise { + const pluginId = config.pluginId as number | undefined; + if (!pluginId) { + return this.renderPlaceholderWidget(width, height, 'No plugin selected'); + } + + try { + const pluginRenderer = this.getPluginRenderer(); + if (!pluginRenderer) { + return this.renderPlaceholderWidget(width, height, 'Plugin system unavailable'); + } + + const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } }); + if (!plugin) { + return this.renderPlaceholderWidget(width, height, 'Plugin not found'); + } + + const layout = this.inferPluginLayout(width, height, config.layout); + const markup = pluginRenderer.selectMarkup(plugin, layout); + if (!markup) { + return this.renderPlaceholderWidget(width, height, 'No template'); + } + + // Settings come from the widget config itself + const settings = config as Record; + return pluginRenderer.renderToPng(markup, {}, settings, width, height, 'preview'); + } catch (error) { + this.logger.error(`Failed to render plugin widget ${pluginId}:`, error); + return this.renderPlaceholderWidget(width, height, 'Plugin Error'); + } + } + + /** + * Generate plugin widget content as HTML for the designer preview + */ + private async generatePluginWidgetContent( + config: Record, + width: number, + height: number, + ): Promise { + const pluginId = config.pluginId as number | undefined; + if (!pluginId) { + return '
Select a plugin
'; + } + + try { + const plugin = await this.prisma.plugin.findUnique({ where: { id: pluginId } }); + if (!plugin) { + return '
Plugin not found
'; + } + + const pluginRenderer = this.getPluginRenderer(); + if (!pluginRenderer) { + return `
${plugin.name}
`; + } + + const layout = this.inferPluginLayout(width, height, config.layout); + const markup = pluginRenderer.selectMarkup(plugin, layout); + if (!markup) { + return `
${plugin.name}
No template
`; + } + + const settings = config as Record; + const html = await pluginRenderer.renderToHtml(markup, {}, settings); + return html; + } catch (error) { + return '
Render error
'; + } + } + + /** + * Infer plugin layout from widget dimensions + */ + private inferPluginLayout( + width: number, + height: number, + configLayout?: string, + ): 'full' | 'half_horizontal' | 'half_vertical' | 'quadrant' { + if (configLayout && ['full', 'half_horizontal', 'half_vertical', 'quadrant'].includes(configLayout)) { + return configLayout as any; + } + // Auto-detect from dimensions + if (width >= 600 && height >= 350) return 'full'; + if (width >= 600) return 'half_vertical'; + if (height >= 350) return 'half_horizontal'; + return 'quadrant'; + } + + /** + * Get the PluginRendererService (lazy to avoid circular dependency) + */ + private getPluginRenderer(): any { + try { + // Dynamic import to avoid circular module dependency + const { PluginRendererService } = require('../../plugins/plugin-renderer.service'); + // Get from NestJS module context via prisma's connection + // Since we can't inject it, instantiate with this service + if (!this._pluginRenderer) { + this._pluginRenderer = new PluginRendererService(this as any); + } + return this._pluginRenderer; + } catch { + return null; + } + } + private _pluginRenderer: any = null; + /** * Generate HTML that matches the frontend WidgetRenderer component exactly * Processes image URLs for e-ink display (grayscale, high contrast) @@ -2618,6 +2736,9 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { content = await this.generateCustomWidgetContent(widgetConfig, width, height); extraStyles = this.getCustomWidgetStyles(widgetConfig); break; + case 'plugin': + content = await this.generatePluginWidgetContent(widgetConfig, width, height); + break; default: content = `
Unknown: ${template.name}
`; } @@ -3064,7 +3185,7 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit { if (!customWidgetId) return '
No widget ID
'; try { - const result = await this.customWidgetsService.getWithData(customWidgetId, true); + const result = await this.customWidgetsService.getWithData(customWidgetId); const renderedContent = result.renderedContent; const widgetConfig = (result.widget?.config as Record) || {}; diff --git a/backend/src/screen-designer/services/widget-templates.service.ts b/backend/src/screen-designer/services/widget-templates.service.ts index 01c635e..42de52c 100644 --- a/backend/src/screen-designer/services/widget-templates.service.ts +++ b/backend/src/screen-designer/services/widget-templates.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { CustomWidgetsService } from '../../custom-widgets/custom-widgets.service'; +import { PluginsService } from '../../plugins/plugins.service'; import { wrapListResponse } from '../../common/utils/response.util'; /** @@ -238,6 +239,19 @@ const DEFAULT_WIDGET_TEMPLATES = [ minWidth: 100, minHeight: 50, }, + // Plugin widget - renders a plugin instance inside the screen designer + { + name: 'plugin', + label: 'Plugin Widget', + description: 'Display a plugin instance as a widget', + category: 'plugins', + defaultConfig: { + pluginInstanceId: null, + layout: 'full', + }, + minWidth: 200, + minHeight: 120, + }, // Special base template for custom widgets - used as FK reference in database { name: 'custom-widget-base', @@ -264,6 +278,7 @@ export class WidgetTemplatesService implements OnModuleInit { constructor( private prisma: PrismaService, private customWidgetsService: CustomWidgetsService, + private pluginsService: PluginsService, ) {} /** @@ -293,8 +308,11 @@ export class WidgetTemplatesService implements OnModuleInit { // Get custom widgets as templates const customTemplates = await this.customWidgetsService.getAsWidgetTemplates(); - // Combine built-in templates with custom widgets - const allTemplates = [...filteredTemplates, ...customTemplates]; + // Get installed plugins as templates + const pluginTemplates = await this.pluginsService.getAsWidgetTemplates(); + + // Combine built-in templates with custom widgets and plugins + const allTemplates = [...filteredTemplates, ...customTemplates, ...pluginTemplates]; return wrapListResponse(allTemplates); } diff --git a/docker-compose.yml b/docker-compose.yml index 9d791ed..e4bba9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,7 @@ services: environment: TZ: ${TZ:-UTC} ADMIN_PIN: "${ADMIN_PIN:-1111}" + INKER_PORT: "${INKER_PORT:-80}" DEFAULT_TIMEZONE: ${DEFAULT_TIMEZONE:-UTC} healthcheck: test: ["CMD", "bun", "-e", "const r=await fetch('http://127.0.0.1/health');process.exit(r.ok?0:1)"] diff --git a/docker/nginx.conf b/docker/nginx.conf index 1b22e65..bdedbe4 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -69,13 +69,40 @@ server { expires 1y; } + # Plugin and device-image render endpoints — strip security headers for ESP32 devices + location ~ ^/api/(plugins/instances/\d+/render|device-images/) { + proxy_pass http://127.0.0.1:3002; + proxy_http_version 1.1; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_hide_header Content-Security-Policy; + proxy_hide_header Strict-Transport-Security; + proxy_hide_header X-Content-Type-Options; + proxy_hide_header X-DNS-Prefetch-Control; + proxy_hide_header X-Frame-Options; + proxy_hide_header X-XSS-Protection; + proxy_hide_header Cross-Origin-Opener-Policy; + proxy_hide_header Cross-Origin-Resource-Policy; + proxy_hide_header Origin-Agent-Cluster; + proxy_hide_header X-Download-Options; + proxy_hide_header X-Permitted-Cross-Domain-Policies; + proxy_hide_header Referrer-Policy; + proxy_hide_header Permissions-Policy; + + add_header Access-Control-Allow-Origin "*"; + add_header Cache-Control "no-store"; + } + # Proxy API requests to backend location /api/ { proxy_pass http://127.0.0.1:3002/api/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; @@ -96,25 +123,61 @@ server { location @backend_assets { proxy_pass http://127.0.0.1:3002; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + + # Strip Helmet security headers from image responses — ESP32 devices + # have limited HTTP buffer and excessive headers cause download failures + proxy_hide_header Content-Security-Policy; + proxy_hide_header Strict-Transport-Security; + proxy_hide_header X-Content-Type-Options; + proxy_hide_header X-DNS-Prefetch-Control; + proxy_hide_header X-Frame-Options; + proxy_hide_header X-XSS-Protection; + proxy_hide_header Cross-Origin-Opener-Policy; + proxy_hide_header Cross-Origin-Resource-Policy; + proxy_hide_header Origin-Agent-Cluster; + proxy_hide_header X-Download-Options; + proxy_hide_header X-Permitted-Cross-Domain-Policies; + proxy_hide_header Referrer-Policy; + proxy_hide_header Permissions-Policy; + + # Override server-level security headers (add_header in a location + # block prevents inheriting server-level add_header directives) + add_header Access-Control-Allow-Origin "*"; + add_header Cache-Control "public, max-age=0"; } # Proxy uploads to backend (images, captures, drawings) location /uploads/ { proxy_pass http://127.0.0.1:3002/uploads/; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; expires 7d; + + # Strip Helmet security headers — ESP32 devices have limited HTTP buffer + proxy_hide_header Content-Security-Policy; + proxy_hide_header Strict-Transport-Security; + proxy_hide_header X-Content-Type-Options; + proxy_hide_header X-DNS-Prefetch-Control; + proxy_hide_header X-Frame-Options; + proxy_hide_header X-XSS-Protection; + proxy_hide_header Cross-Origin-Opener-Policy; + proxy_hide_header Cross-Origin-Resource-Policy; + proxy_hide_header Origin-Agent-Cluster; + proxy_hide_header X-Download-Options; + proxy_hide_header X-Permitted-Cross-Domain-Policies; + proxy_hide_header Referrer-Policy; + proxy_hide_header Permissions-Policy; + + # Minimal headers only (overrides server-level security headers) + add_header Access-Control-Allow-Origin "*"; add_header Cache-Control "public, max-age=604800"; - add_header X-Content-Type-Options "nosniff" always; - add_header Referrer-Policy "no-referrer" always; - add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; } # Health check endpoint diff --git a/frontend/package.json b/frontend/package.json index a76bd55..c75ffa0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "inker-frontend", "private": true, - "version": "0.2.1.1", + "version": "0.3.0", "type": "module", "scripts": { "dev": "bunx --bun vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a497cfe..b321689 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -24,6 +24,8 @@ import { Settings } from './pages/settings/Settings'; import { DataSourceForm } from './pages/data-sources'; import { CustomWidgetForm, CustomWidgetPreview } from './pages/custom-widgets'; import { Extensions } from './pages/extensions'; +// Plugin pages +import { PluginLibrary, InstalledPlugins, PluginCreator, PluginInstanceForm, OAuthCallback } from './pages/plugins'; /** * Main App component with routing @@ -188,6 +190,51 @@ function App() { } /> + {/* OAuth callback (handles redirect from providers) */} + } /> + + {/* Plugins */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + {/* Data Sources - redirect list to extensions, keep forms */} (null); + const [showUpdateModal, setShowUpdateModal] = useState(false); + const [updating, setUpdating] = useState(false); + const notification = useNotification(); + + useEffect(() => { + dashboardService.checkForUpdate() + .then(setVersionInfo) + .catch(() => {}); + }, []); const navItems: NavItem[] = [ { @@ -67,6 +78,19 @@ export function Sidebar() { ), }, + { + to: '/plugins', + label: 'Plugins', + icon: ( + + + + ), + }, { to: '/extensions', label: 'Extensions', @@ -205,17 +229,122 @@ export function Sidebar() { {/* Footer with version info */}
-
-
+
+ + {/* Update Modal */} + setShowUpdateModal(false)} + title="Update Available" + size="sm" + > +
+
+ Current version + {versionInfo?.currentVersion} +
+
+ Latest version + {versionInfo?.latestVersion} +
+ +
+ {/* Copy commands button */} + + + {/* Auto-update button (only if Docker socket available) */} + {versionInfo?.dockerAvailable && ( + + )} +
+ + + View release notes + +
+
); } diff --git a/frontend/src/components/screen-designer/WidgetLibrary.tsx b/frontend/src/components/screen-designer/WidgetLibrary.tsx index 151dabc..10a87e7 100644 --- a/frontend/src/components/screen-designer/WidgetLibrary.tsx +++ b/frontend/src/components/screen-designer/WidgetLibrary.tsx @@ -52,6 +52,14 @@ const categoryConfig: Record = ), }, + plugins: { + label: 'Plugins', + icon: ( + + + + ), + }, custom: { label: 'Custom', icon: ( @@ -350,7 +358,7 @@ export function WidgetLibrary({ templates, onDragStart }: WidgetLibraryProps) { })}
- {/* Footer hint */} + {/* Footer */}

Drag widgets onto the canvas diff --git a/frontend/src/components/screen-designer/WidgetSettingsPanel.tsx b/frontend/src/components/screen-designer/WidgetSettingsPanel.tsx index 681b62c..88fd0c4 100644 --- a/frontend/src/components/screen-designer/WidgetSettingsPanel.tsx +++ b/frontend/src/components/screen-designer/WidgetSettingsPanel.tsx @@ -4,7 +4,7 @@ */ import { useCallback, useState, useRef, useEffect } from 'react'; import type { ScreenWidget, WidgetTemplate, CustomWidget, GridCellOverride } from '../../types'; -import { screenDesignerService, customWidgetService } from '../../services/api'; +import { screenDesignerService, customWidgetService, pluginService } from '../../services/api'; import { config } from '../../config'; import { SearchableSelect } from '../common/SearchableSelect'; import { LocationPickerModal } from './LocationPickerModal'; @@ -17,6 +17,9 @@ interface WidgetSettingsPanelProps { onUpdateWidget: (updates: Partial) => void; onDeleteWidget: () => void; onSelectWidget?: (id: number) => void; + pluginBrowserOpen?: boolean; + selectedPlugin?: any; + onAddPluginToCanvas?: (instanceId: number, pluginId: number, pluginSlug: string) => void; } // Get the browser's detected timezone @@ -449,6 +452,9 @@ export function WidgetSettingsPanel({ onUpdateWidget, onDeleteWidget, onSelectWidget, + pluginBrowserOpen, + selectedPlugin, + onAddPluginToCanvas, }: WidgetSettingsPanelProps) { const [isWidgetListExpanded, setIsWidgetListExpanded] = useState(true); const [customWidgetData, setCustomWidgetData] = useState(null); @@ -614,6 +620,16 @@ export function WidgetSettingsPanel({

); + // Plugin settings panel (shown when plugin browser is open and a plugin is selected) + if (pluginBrowserOpen && selectedPlugin && onAddPluginToCanvas) { + return ( + + ); + } + if (!widget || !template) { return (
@@ -638,7 +654,7 @@ export function WidgetSettingsPanel({

No Selection

- Select a widget to edit its properties + {pluginBrowserOpen ? 'Select a plugin to configure' : 'Select a widget to edit its properties'}

@@ -1372,6 +1388,81 @@ export function WidgetSettingsPanel({ )} + {/* Plugin Widget settings — rendered from plugin's settingsSchema */} + {template.name.startsWith('plugin-') && ( +
+
+
+ + + + Plugin: {template.label} +
+

Configure this plugin widget. Each widget instance can have different settings.

+
+ +
+ + +
+ + {/* Dynamic settings from plugin's settingsSchema */} + {(template.defaultConfig as any)?.settingsSchema?.map((field: any) => ( +
+ + {field.type === 'select' ? ( + + ) : field.type === 'toggle' ? ( + + ) : field.type === 'number' ? ( + updateConfig(field.key, e.target.value ? Number(e.target.value) : '')} + className="w-full px-3 py-1.5 text-sm rounded-lg border border-border-default bg-bg-page text-text-primary" + /> + ) : ( + updateConfig(field.key, e.target.value)} + placeholder={field.encrypted ? 'Enter secret...' : ''} + className="w-full px-3 py-1.5 text-sm rounded-lg border border-border-default bg-bg-page text-text-primary" + /> + )} +
+ ))} +
+ )} + {/* Custom Widget settings - matches custom-1, custom-2, etc. */} {template.name.startsWith('custom-') && (
@@ -2205,3 +2296,226 @@ function WeatherWidgetSettings({
); } + +/** + * PluginSettingsPanel — shown in right panel when configuring a plugin to add to canvas + */ +function PluginSettingsPanel({ + plugin, + onAddToCanvas, +}: { + plugin: any; + onAddToCanvas: (instanceId: number, pluginId: number, pluginSlug: string) => void; +}) { + const [instances, setInstances] = useState([]); + const [selectedInstanceId, setSelectedInstanceId] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [settings, setSettings] = useState>({}); + const [instanceName, setInstanceName] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + const settingsSchema: any[] = plugin.settingsSchema || []; + + // Load existing instances for this plugin + useEffect(() => { + loadInstances(); + }, [plugin.id]); + + async function loadInstances() { + setIsLoading(true); + try { + const all = await pluginService.getAllInstances(); + const pluginInstances = all.filter((inst: any) => inst.pluginId === plugin.id); + setInstances(pluginInstances); + if (pluginInstances.length > 0) { + setSelectedInstanceId(pluginInstances[0].id); + setIsCreating(false); + } else { + setIsCreating(true); + initDefaultSettings(); + } + } catch { + setIsCreating(true); + initDefaultSettings(); + } finally { + setIsLoading(false); + } + } + + function initDefaultSettings() { + const defaults: Record = {}; + for (const field of settingsSchema) { + if (field.default !== undefined) defaults[field.key] = field.default; + else if (field.options?.length > 0) defaults[field.key] = field.options[0]; + } + setSettings(defaults); + setInstanceName(`${plugin.name}`); + } + + async function handleAdd() { + if (isCreating) { + // Create new instance then add + setIsSaving(true); + try { + const instance = await pluginService.createInstance({ + pluginId: plugin.id, + name: instanceName || plugin.name, + settings, + }); + onAddToCanvas(instance.id, plugin.id, plugin.slug); + } catch (err) { + console.error('Failed to create plugin instance:', err); + } finally { + setIsSaving(false); + } + } else if (selectedInstanceId) { + onAddToCanvas(selectedInstanceId, plugin.id, plugin.slug); + } + } + + if (isLoading) { + return ( +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

{plugin.name}

+ + + + + +
+

{plugin.description || 'Configure plugin'}

+
+ + {/* Settings */} +
+ {/* Instance selector */} + {instances.length > 0 && ( +
+ + +
+ )} + + {/* New instance settings */} + {isCreating && ( + <> +
+ + setInstanceName(e.target.value)} + placeholder="Instance name" + className="w-full px-3 py-2 text-sm bg-bg-muted border border-border-light rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ + {/* Dynamic settings from schema */} + {settingsSchema.map((field: any) => ( +
+ + {field.type === 'select' ? ( + + ) : field.type === 'toggle' ? ( + + ) : field.type === 'number' ? ( + setSettings({ ...settings, [field.key]: Number(e.target.value) })} + className="w-full px-3 py-2 text-sm bg-bg-muted border border-border-light rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" + /> + ) : ( + setSettings({ ...settings, [field.key]: e.target.value })} + placeholder={field.encrypted ? 'API key' : ''} + className="w-full px-3 py-2 text-sm bg-bg-muted border border-border-light rounded-lg focus:outline-none focus:ring-2 focus:ring-accent" + /> + )} +
+ ))} + + )} +
+ + {/* Add to canvas button */} +
+ +
+
+ ); +} diff --git a/frontend/src/pages/plugins/InstalledPlugins.tsx b/frontend/src/pages/plugins/InstalledPlugins.tsx new file mode 100644 index 0000000..48a7e36 --- /dev/null +++ b/frontend/src/pages/plugins/InstalledPlugins.tsx @@ -0,0 +1,255 @@ +import { useCallback, useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { MainLayout } from '../../components/layout'; +import { Button, LoadingSpinner, Modal } from '../../components/common'; +import { useApi, useMutation } from '../../hooks/useApi'; +import { config } from '../../config'; +import apiClient from '../../services/api'; + +interface Plugin { + id: number; + slug: string; + name: string; + description?: string; + icon?: string; + category: string; + source: string; + isBuiltin: boolean; + settingsSchema?: any[]; + _count?: { instances: number }; +} + +interface PluginInstance { + id: number; + pluginId: number; + name?: string; + settings: Record; + plugin: Plugin; + lastFetchedAt?: string; + lastError?: string; +} + +export function InstalledPlugins() { + const navigate = useNavigate(); + const [deleteTarget, setDeleteTarget] = useState(null); + + const fetchInstances = useCallback( + () => apiClient.get<{ data: PluginInstance[] }>('/plugins/instances/all').then((res) => res.data.data), + [] + ); + + const { data: instances, isLoading, refetch } = useApi(fetchInstances); + + const deleteMutation = useMutation( + (instanceId) => apiClient.delete(`/plugins/instances/${instanceId}`).then((res) => res.data), + { + successMessage: 'Plugin instance deleted', + onSuccess: () => { + setDeleteTarget(null); + refetch(); + }, + } + ); + + const handleDelete = () => { + if (deleteTarget) { + deleteMutation.mutate(deleteTarget.id); + } + }; + + return ( + +
+ {/* Header */} +
+
+

Installed Plugins

+

+ Manage your active plugin instances +

+
+ +
+ + {/* Instance Grid */} + {isLoading ? ( +
+ +
+ ) : !instances || instances.length === 0 ? ( + + ) : ( +
+ {instances.map((instance) => ( + navigate(`/plugins/instances/${instance.id}`)} + onDelete={() => setDeleteTarget(instance)} + /> + ))} +
+ )} + + {/* Delete Confirmation Modal */} + {deleteTarget && ( + setDeleteTarget(null)} + title="Delete Plugin Instance" + > +
+

+ Are you sure you want to delete{' '} + + {deleteTarget.name || deleteTarget.plugin.name} + + ? This action cannot be undone. +

+
+ + +
+
+
+ )} +
+
+ ); +} + +interface InstanceCardProps { + instance: PluginInstance; + onSettings: () => void; + onDelete: () => void; +} + +function InstanceCard({ instance, onSettings, onDelete }: InstanceCardProps) { + const previewUrl = `${config.apiUrl}/plugins/instances/${instance.id}/render?mode=preview`; + + return ( +
+ {/* Preview Thumbnail */} +
+
+ {`${instance.plugin.name} { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> +
+
+ + {/* Card Body */} +
+

{instance.plugin.name}

+ {instance.name && ( +

{instance.name}

+ )} + +
+ {instance.lastFetchedAt && ( +
+ Last fetched + {formatRelativeTime(instance.lastFetchedAt)} +
+ )} + + {instance.lastError && ( +
+ + Error + + {instance.lastError} +
+ )} +
+
+ + {/* Card Footer */} +
+ + +
+
+ ); +} + +function EmptyState() { + return ( +
+
+ + + +
+

No plugins installed yet

+

+ Visit the Plugin Library to browse and install plugins for your devices. +

+ + + + + Browse Plugin Library + +
+ ); +} + +function formatRelativeTime(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + + if (diffMinutes < 1) return 'Just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString(); +} diff --git a/frontend/src/pages/plugins/OAuthCallback.tsx b/frontend/src/pages/plugins/OAuthCallback.tsx new file mode 100644 index 0000000..4117ef0 --- /dev/null +++ b/frontend/src/pages/plugins/OAuthCallback.tsx @@ -0,0 +1,32 @@ +import { useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +export function OAuthCallback() { + const [searchParams] = useSearchParams(); + const connected = searchParams.get('oauth') === 'connected'; + + useEffect(() => { + if (connected && window.opener) { + window.opener.postMessage({ type: 'oauth-connected' }, '*'); + window.close(); + } + }, [connected]); + + return ( +
+
+
+ {connected ? '\u2705' : '\u23F3'} +
+

+ {connected ? 'Connected!' : 'Connecting...'} +

+

+ {connected + ? 'You can close this window and return to Inker.' + : 'Please wait while we complete the authorization.'} +

+
+
+ ); +} diff --git a/frontend/src/pages/plugins/PluginCreator.tsx b/frontend/src/pages/plugins/PluginCreator.tsx new file mode 100644 index 0000000..1d393c6 --- /dev/null +++ b/frontend/src/pages/plugins/PluginCreator.tsx @@ -0,0 +1,832 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; +import { MainLayout } from '../../components/layout'; +import { Button, LoadingSpinner } from '../../components/common'; +import { useApi, useMutation } from '../../hooks/useApi'; +import { useNotification } from '../../contexts/NotificationContext'; +import apiClient from '../../services/api'; + +interface SettingsField { + key: string; + label: string; + type: string; + options?: string[]; + required?: boolean; + encrypted?: boolean; + default?: string; +} + +interface HeaderPair { + key: string; + value: string; +} + +interface PluginFormData { + name: string; + slug: string; + description: string; + category: string; + icon: string; + dataStrategy: string; + dataUrl: string; + dataMethod: string; + dataHeaders: HeaderPair[]; + dataPath: string; + dataTransform: string; + refreshInterval: number; + markupFull: string; + markupHalfHorizontal: string; + markupHalfVertical: string; + markupQuadrant: string; + settingsSchema: SettingsField[]; +} + +const INITIAL_FORM: PluginFormData = { + name: '', + slug: '', + description: '', + category: 'custom', + icon: '', + dataStrategy: 'polling', + dataUrl: '', + dataMethod: 'GET', + dataHeaders: [], + dataPath: '', + dataTransform: '', + refreshInterval: 300, + markupFull: '
\n
\n
{{ title }}
\n
{{ content }}
\n
\n
\n My Plugin\n
\n
', + markupHalfHorizontal: '', + markupHalfVertical: '', + markupQuadrant: '', + settingsSchema: [], +}; + +const CATEGORIES = ['custom', 'news', 'finance', 'productivity', 'weather', 'health', 'analytics', 'lifestyle', 'ai', 'travel']; + +const STEPS = ['Basics', 'Data Source', 'Template', 'Settings', 'Review']; + +export function PluginCreator() { + const { id } = useParams<{ id: string }>(); + const [searchParams] = useSearchParams(); + const isEdit = !!id; + const navigate = useNavigate(); + const { success, error: showError } = useNotification(); + const [step, setStep] = useState(0); + const [formData, setFormData] = useState(() => { + // Pre-fill from recipe gallery params + const recipeName = searchParams.get('recipe_name'); + if (recipeName) { + const recipeSettings = searchParams.get('recipe_settings'); + let settings: SettingsField[] = []; + try { settings = recipeSettings ? JSON.parse(recipeSettings) : []; } catch {} + let headers: { key: string; value: string }[] = []; + try { + const h = searchParams.get('recipe_data_headers'); + if (h) headers = Object.entries(JSON.parse(h)).map(([k, v]) => ({ key: k, value: String(v) })); + } catch {} + return { + ...INITIAL_FORM, + name: recipeName, + slug: recipeName.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''), + description: searchParams.get('recipe_description') || '', + category: searchParams.get('recipe_category') || 'custom', + settingsSchema: settings, + markupFull: searchParams.get('recipe_template') || INITIAL_FORM.markupFull, + dataUrl: searchParams.get('recipe_data_url') || '', + dataHeaders: headers.length > 0 ? headers : INITIAL_FORM.dataHeaders, + }; + } + return INITIAL_FORM; + }); + const [sampleData, setSampleData] = useState(null); + const [testLoading, setTestLoading] = useState(false); + const [activeLayout, setActiveLayout] = useState<'full' | 'half_horizontal' | 'half_vertical' | 'quadrant'>('full'); + const [previewUrl, setPreviewUrl] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + + // Load existing plugin for edit mode + const fetchPlugin = useCallback( + () => id ? apiClient.get(`/plugins/${id}`).then(r => r.data.data || r.data) : Promise.resolve(null), + [id] + ); + const { data: existingPlugin, isLoading: loadingPlugin } = useApi(fetchPlugin); + + useEffect(() => { + if (existingPlugin && isEdit) { + const headers: HeaderPair[] = existingPlugin.dataHeaders + ? Object.entries(existingPlugin.dataHeaders).map(([key, value]) => ({ key, value: String(value) })) + : []; + setFormData({ + name: existingPlugin.name || '', + slug: existingPlugin.slug || '', + description: existingPlugin.description || '', + category: existingPlugin.category || 'custom', + icon: existingPlugin.icon || '', + dataStrategy: existingPlugin.dataStrategy || 'polling', + dataUrl: existingPlugin.dataUrl || '', + dataMethod: existingPlugin.dataMethod || 'GET', + dataHeaders: headers, + dataPath: existingPlugin.dataPath || '', + dataTransform: existingPlugin.dataTransform || '', + refreshInterval: existingPlugin.refreshInterval || 300, + markupFull: existingPlugin.markupFull || INITIAL_FORM.markupFull, + markupHalfHorizontal: existingPlugin.markupHalfHorizontal || '', + markupHalfVertical: existingPlugin.markupHalfVertical || '', + markupQuadrant: existingPlugin.markupQuadrant || '', + settingsSchema: existingPlugin.settingsSchema || [], + }); + } + }, [existingPlugin, isEdit]); + + // Save mutation + const saveMutation = useMutation( + async () => { + const payload = { + ...formData, + dataHeaders: formData.dataHeaders.length > 0 + ? Object.fromEntries(formData.dataHeaders.filter(h => h.key).map(h => [h.key, h.value])) + : undefined, + settingsSchema: formData.settingsSchema.length > 0 ? formData.settingsSchema : undefined, + source: 'inker', + markupHalfHorizontal: formData.markupHalfHorizontal || undefined, + markupHalfVertical: formData.markupHalfVertical || undefined, + markupQuadrant: formData.markupQuadrant || undefined, + dataTransform: formData.dataTransform || undefined, + dataPath: formData.dataPath || undefined, + dataUrl: formData.dataUrl || undefined, + }; + if (isEdit) { + return apiClient.put(`/plugins/${id}`, payload).then(r => r.data.data || r.data); + } + return apiClient.post('/plugins', payload).then(r => r.data.data || r.data); + }, + { + onSuccess: () => { + success(isEdit ? 'Plugin updated' : 'Plugin created'); + navigate('/plugins'); + }, + } + ); + + const update = (key: keyof PluginFormData, value: any) => { + setFormData(prev => ({ ...prev, [key]: value })); + }; + + // Auto-generate slug from name + const updateName = (name: string) => { + setFormData(prev => ({ + ...prev, + name, + slug: isEdit ? prev.slug : name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, ''), + })); + }; + + // Test data URL + const handleTestUrl = async () => { + if (!formData.dataUrl) return; + setTestLoading(true); + try { + const headers: Record = { Accept: 'application/json' }; + formData.dataHeaders.filter(h => h.key).forEach(h => { headers[h.key] = h.value; }); + const response = await fetch(formData.dataUrl, { + method: formData.dataMethod, + headers, + signal: AbortSignal.timeout(10000), + }); + const data = await response.json(); + setSampleData(data); + success('Data fetched successfully'); + } catch (err: any) { + showError(`Fetch failed: ${err.message}`); + } finally { + setTestLoading(false); + } + }; + + // Header management + const addHeader = () => update('dataHeaders', [...formData.dataHeaders, { key: '', value: '' }]); + const removeHeader = (i: number) => update('dataHeaders', formData.dataHeaders.filter((_, idx) => idx !== i)); + const updateHeader = (i: number, field: 'key' | 'value', val: string) => { + const headers = [...formData.dataHeaders]; + headers[i] = { ...headers[i], [field]: val }; + update('dataHeaders', headers); + }; + + // Settings schema management + // Live preview for template step + const handlePreview = async () => { + const markup = getActiveMarkup(); + if (!markup) return; + setPreviewLoading(true); + try { + const response = await apiClient.post('/plugins/preview-template', { + markup, + data: sampleData || {}, + }, { responseType: 'blob' }); + const url = URL.createObjectURL(response.data); + setPreviewUrl(prev => { if (prev) URL.revokeObjectURL(prev); return url; }); + } catch { + setPreviewUrl(null); + } finally { + setPreviewLoading(false); + } + }; + + const addField = () => update('settingsSchema', [ + ...formData.settingsSchema, + { key: '', label: '', type: 'text', default: '' }, + ]); + const removeField = (i: number) => update('settingsSchema', formData.settingsSchema.filter((_, idx) => idx !== i)); + const updateField = (i: number, key: keyof SettingsField, val: any) => { + const fields = [...formData.settingsSchema]; + fields[i] = { ...fields[i], [key]: val }; + update('settingsSchema', fields); + }; + + const getActiveMarkup = () => { + switch (activeLayout) { + case 'full': return formData.markupFull; + case 'half_horizontal': return formData.markupHalfHorizontal; + case 'half_vertical': return formData.markupHalfVertical; + case 'quadrant': return formData.markupQuadrant; + } + }; + + const setActiveMarkup = (value: string) => { + const key = activeLayout === 'full' ? 'markupFull' + : activeLayout === 'half_horizontal' ? 'markupHalfHorizontal' + : activeLayout === 'half_vertical' ? 'markupHalfVertical' + : 'markupQuadrant'; + update(key, value); + }; + + // Auto-render preview when plugin loads in edit mode + useEffect(() => { + if (isEdit && existingPlugin && formData.markupFull && !previewUrl) { + handlePreview(); + } + }, [isEdit, existingPlugin]); + + const hasTemplate = !!formData.markupFull; + + if (isEdit && loadingPlugin) { + return
; + } + + return ( + +
+ {/* Header */} +
+ +

+ {isEdit ? 'Edit Plugin' : 'Create Plugin'} +

+
+ + {/* Step indicator */} +
+ {STEPS.map((label, i) => ( + + ))} +
+ +
+ {/* Step content */} +
+ + {/* Step 0: Basics */} + {step === 0 && ( +
+

Basic Information

+ +
+ + updateName(e.target.value)} + placeholder="My Custom Plugin" + className="w-full px-3 py-2 rounded-lg border border-border-default bg-bg-page text-text-primary focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ +
+ + update('slug', e.target.value)} + placeholder="my_custom_plugin" + className="w-full px-3 py-2 rounded-lg border border-border-default bg-bg-page text-text-primary focus:outline-none focus:ring-2 focus:ring-accent font-mono text-sm" + /> +

Unique identifier. Auto-generated from name.

+
+ +
+ +