From 90f5aebafb87860cb71181eb303cee256e05dd93 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 25 Oct 2026 00:59:04 -0500 Subject: [PATCH] feat: list plugin engine ranges for the mobile app --- CHANGELOG.md | 1 + packages/plugin-sdk/PLUGIN-GUIDE.md | 5 ++++ src/backend/database/routes/plugins.ts | 13 +++++++- .../tests/database/routes/plugins.test.ts | 30 +++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82d25fea3..806148753 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - The mobile app can sign in through your browser with any login method, including passkeys and security keys - Plugins can say how they show in the mobile app +- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update ## 26.10.5 diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index a96d791f8..65ba63f70 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -210,6 +210,11 @@ How a `webview` plugin runs in the app: - Nothing in `localStorage` survives closing the view. Keep preferences in settings. - Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()` is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app. +- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API + is too old for the range, it asks the user to update the app instead of opening the view. When the range + is for an older API major, it asks to update the plugin. Ask only for the API you use. +- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back + to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`. Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs `"engine": { "termix": ">=26.10.6", "api": "^1.13" }`. diff --git a/src/backend/database/routes/plugins.ts b/src/backend/database/routes/plugins.ts index 63aa1127e..732c96996 100644 --- a/src/backend/database/routes/plugins.ts +++ b/src/backend/database/routes/plugins.ts @@ -244,7 +244,9 @@ export async function listPreLoginPlugins(): Promise< * `assetVersion` is its cache key, `css` says a stylesheet sits beside * it, and `locales` lists the languages it ships ("en" and xx_YY). * `dependencies` and `optionalDependencies` let the loader activate - * frontends in the same order the server does. + * frontends in the same order the server does. `engine` holds the + * manifest's `api` and `termix` ranges, so other clients (the mobile + * app) can tell whether they understand the plugin. * * On a desktop linked to a server, `runsOnServer` marks plugins that * only run on that server and stay off here on purpose, and, for @@ -303,6 +305,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { let features: string[] = []; let docs: string | undefined; let env: PluginEnvVar[] = []; + const engine: { api?: string; termix?: string } = {}; let desktopMode: unknown = loaded ? resolvePluginDesktopMode(loaded.manifest) : undefined; @@ -322,7 +325,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { optionalDependencies?: Record; desktop?: unknown; runtime?: unknown; + engine?: { api?: unknown; termix?: unknown }; }; + if (typeof manifest?.engine?.api === "string") { + engine.api = manifest.engine.api; + } + if (typeof manifest?.engine?.termix === "string") { + engine.termix = manifest.engine.termix; + } desktopMode = loaded ? resolvePluginDesktopMode(loaded.manifest) : (manifest?.desktop ?? @@ -365,6 +375,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => { ? "safe-mode" : (loaded?.state ?? record.state), runsOnServer: linked && desktopMode === "server", + engine, contributes, icon, dependencies, diff --git a/src/backend/tests/database/routes/plugins.test.ts b/src/backend/tests/database/routes/plugins.test.ts index 9e684bdf4..9a0566810 100644 --- a/src/backend/tests/database/routes/plugins.test.ts +++ b/src/backend/tests/database/routes/plugins.test.ts @@ -488,6 +488,35 @@ describe("plugins route", () => { } }); + it("lists a plugin's engine ranges to any user", async () => { + state.plugins.set( + "sample-plugin", + makePlugin({ + manifestJson: JSON.stringify({ + engine: { api: "^1.13", termix: ">=26.10.6", extra: 1 }, + }), + }), + ); + state.plugins.set( + "bad-engine", + makePlugin({ + id: "bad-engine", + manifestJson: JSON.stringify({ engine: { api: 2 } }), + }), + ); + + const res = await fetch(`${baseUrl}/plugins`, { + headers: { "x-test-user-id": "regular-user" }, + }); + const body = (await res.json()) as Array<{ id: string; engine: unknown }>; + + expect(body.find((p) => p.id === "sample-plugin")?.engine).toEqual({ + api: "^1.13", + termix: ">=26.10.6", + }); + expect(body.find((p) => p.id === "bad-engine")?.engine).toEqual({}); + }); + it("reports an enabled plugin that safe mode keeps off", async () => { state.plugins.set("sample-plugin", makePlugin({ state: "enabled" })); @@ -643,6 +672,7 @@ describe("plugins route", () => { enabled: true, state: "enabled", runsOnServer: false, + engine: {}, contributes: null, dependencies: {}, optionalDependencies: {},