From bb448b67535ed11463d31ef82dd81c4cad757b4c Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 24 Oct 2026 22:48:13 -0500 Subject: [PATCH] feat: let plugins say how they show in the mobile app --- CHANGELOG.md | 1 + packages/plugin-sdk/CHANGELOG.md | 9 + packages/plugin-sdk/PLUGIN-GUIDE.md | 27 ++- packages/plugin-sdk/cli/commands/build.mjs | 91 ++++++---- packages/plugin-sdk/cli/commands/validate.mjs | 4 + packages/plugin-sdk/package.json | 2 +- .../plugin-sdk/schema/manifest.schema.json | 42 +++++ packages/plugin-sdk/src/manifest.ts | 158 +++++++++++++++++- scripts/plugin-validate.test.ts | 29 ++++ src/backend/tests/plugins/manifest.test.ts | 104 ++++++++++++ 10 files changed, 427 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7e4da47..82d25fea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - 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 ## 26.10.5 diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md index c85e0b687..3df6a818d 100644 --- a/packages/plugin-sdk/CHANGELOG.md +++ b/packages/plugin-sdk/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.0.22 + +Plugin API 1.13.0. + +### Added + +- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note +- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists + ## 1.0.21 Plugin API 1.12.0. diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md index 5777831cb..ff095d0d3 100644 --- a/packages/plugin-sdk/PLUGIN-GUIDE.md +++ b/packages/plugin-sdk/PLUGIN-GUIDE.md @@ -1,6 +1,6 @@ # Termix plugin guide -SDK 1.0.21 | Plugin API 1.12.0 +SDK 1.0.22 | Plugin API 1.13.0 This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside `@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed. @@ -175,7 +175,30 @@ Unknown fields are refused at every level, so a typo fails validation. The schem `contributes` holds `tabs`, `panels`, `dashboardCards`, `actions`, `actionSlots`, `permissions`, `settings`, `protocols`, `auth`, `syncEntities`, `keybindingActions`, `http`, `guest`, `guestViews`, -`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first. +`uiPresets`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first. + +### Mobile app + +`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the +plugin as having nothing to show (official plugins are mapped by the app itself). + +```json +"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] } +``` + +| Field | Notes | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. | +| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. | +| `sizes` | `webview` only. `phone`, `tablet` or both (the default). | +| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. | +| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. | +| `note` | A locale key or plain text the app shows next to the plugin. | + +`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`. + +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" }`. Capabilities and permissions are different things. A capability (`hosts:read`, with a colon) is what your code may reach. A permission (`hello.use`, with a dot) is what a user may do. diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index b9d754180..b8aad9353 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -25,6 +25,43 @@ const FRONTEND_ENTRIES = [ "src/frontend/index.mjs", "src/frontend/index.js", ]; +const MOBILE_ENTRIES = [ + "src/mobile/index.tsx", + "src/mobile/index.ts", + "src/mobile/index.mjs", + "src/mobile/index.js", +]; + +/** Builds a bundle that runs in a browser against core's shared modules. */ +async function buildBrowserBundle(entry, outfile, outDir) { + await esbuild.build({ + entryPoints: [entry], + outfile, + bundle: true, + format: "esm", + platform: "browser", + target: "es2022", + jsx: "automatic", + sourcemap: true, + logLevel: "warning", + external: FRONTEND_EXTERNALS, + // Vite replaces these in the dev server; a plugin bundle has to have + // them too, or code that reads them throws when the plugin loads. + define: { + "process.env.NODE_ENV": '"production"', + "import.meta.env.DEV": "false", + "import.meta.env.PROD": "true", + "import.meta.env.MODE": '"production"', + "import.meta.env.SSR": "false", + "import.meta.env": + '{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}', + }, + plugins: [ + externalRequireInterop(FRONTEND_EXTERNALS), + staticUrlImports({ outDir }), + ], + }); +} /** * A bundled CommonJS package that calls require("react/jsx-runtime") (or any @@ -192,15 +229,12 @@ export async function buildTailwind(cwd, outDir) { // build() adds to what a compiler already emitted, so each half gets its own. const compiler = () => compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} }); - const scanner = new Scanner({ - sources: [ - { - base: path.join(cwd, "src", "frontend"), - pattern: "**/*", - negated: false, - }, - ], - }); + // The mobile bundle uses the same stylesheet. + const sources = ["frontend", "mobile"] + .map((dir) => path.join(cwd, "src", dir)) + .filter((dir, index) => index === 0 || fs.existsSync(dir)) + .map((base) => ({ base, pattern: "**/*", negated: false })); + const scanner = new Scanner({ sources }); // Flattened and minified the way core's own CSS is, so it does not rely // on native CSS nesting. const emit = async (candidates, options) => @@ -297,33 +331,18 @@ export async function build({ cwd }) { const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES); if (frontendEntry) { - await esbuild.build({ - entryPoints: [frontendEntry], - outfile: path.join(outDir, "frontend.js"), - bundle: true, - format: "esm", - platform: "browser", - target: "es2022", - jsx: "automatic", - sourcemap: true, - logLevel: "warning", - external: FRONTEND_EXTERNALS, - // Vite replaces these in the dev server; a plugin bundle has to have - // them too, or code that reads them throws when the plugin loads. - define: { - "process.env.NODE_ENV": '"production"', - "import.meta.env.DEV": "false", - "import.meta.env.PROD": "true", - "import.meta.env.MODE": '"production"', - "import.meta.env.SSR": "false", - "import.meta.env": - '{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}', - }, - plugins: [ - externalRequireInterop(FRONTEND_EXTERNALS), - staticUrlImports({ outDir }), - ], - }); + await buildBrowserBundle( + frontendEntry, + path.join(outDir, "frontend.js"), + outDir, + ); + } + + // A bundle the mobile app loads in its WebView, next to the frontend one. + const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES); + const mobileOut = manifest.contributes?.mobile?.entry; + if (mobileEntry && typeof mobileOut === "string") { + await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir); } if (frontendEntry) await buildTailwind(cwd, outDir); diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs index 58c069339..b1ae38ea9 100644 --- a/packages/plugin-sdk/cli/commands/validate.mjs +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -28,6 +28,10 @@ export async function validate({ cwd }) { // A theme only plugin has no frontend code. ...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]), raw.locales ?? "locales", + // Built from src/mobile by termix-plugin build. + ...(typeof raw.contributes?.mobile?.entry === "string" + ? [raw.contributes.mobile.entry] + : []), ]; for (const rel of referenced) { if (!fs.existsSync(path.join(cwd, rel))) { diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 5a9ee7651..ee798c3ac 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.21", + "version": "1.0.22", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index ee22422e2..f1b8d76aa 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -1239,6 +1239,48 @@ } } } + }, + "mobile": { + "type": "object", + "description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).", + "additionalProperties": false, + "required": ["mode"], + "properties": { + "mode": { + "type": "string", + "enum": ["webview", "native", "none"], + "description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app." + }, + "entry": { + "type": "string", + "pattern": "\\.js$", + "description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame." + }, + "sizes": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "enum": ["phone", "tablet"] }, + "description": "webview only. Screen sizes the UI works at. Defaults to both." + }, + "views": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "string", "minLength": 1 }, + "description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab." + }, + "feature": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{1,39}$", + "description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id." + }, + "note": { + "type": "string", + "minLength": 1, + "description": "i18n key in the plugin's namespace, or plain text, shown in the app." + } + } } } }, diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index d4a364741..9ae79dcc2 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -37,7 +37,7 @@ export { * "^1.2" needs 1.2 or later). engine.termix is checked against the core * version by the server. */ -export const PLUGIN_API_VERSION = "1.12.0"; +export const PLUGIN_API_VERSION = "1.13.0"; /** The API major, the default engine.api for a new plugin. */ export const SUPPORTED_PLUGIN_API_VERSION = String( @@ -526,6 +526,36 @@ export interface PluginContributions { layouts?: PluginLayoutContribution[]; /** Themes. Needs the ui:theme capability. */ themes?: PluginThemeContribution[]; + /** How the plugin shows in the Termix mobile app. Plugin API 1.13. */ + mobile?: PluginMobileContribution; +} + +/** + * - "webview": the app shows the plugin's UI from the server in a WebView. + * - "native": the app has its own screens for this plugin. + * - "none": nothing to show in the app. + */ +export const MOBILE_MODES = ["webview", "native", "none"] as const; +export type PluginMobileMode = (typeof MOBILE_MODES)[number]; + +export const MOBILE_SIZES = ["phone", "tablet"] as const; +export type PluginMobileSize = (typeof MOBILE_SIZES)[number]; + +export interface PluginMobileContribution { + mode: PluginMobileMode; + /** + * webview: a bundle built for the app, in the same folder as the frontend + * bundle. Unset means the normal frontend runs in a phone sized frame. + */ + entry?: string; + /** webview: screen sizes the UI works at. Defaults to both. */ + sizes?: PluginMobileSize[]; + /** webview: tab, panel or card ids the app offers. Defaults to every tab. */ + views?: string[]; + /** native: the app feature that stands in for it. Defaults to the plugin id. */ + feature?: string; + /** i18n key in the plugin's namespace, or plain text, shown in the app. */ + note?: string; } /** A field a protocol login carries besides username and password. */ @@ -765,6 +795,7 @@ const ALLOWED_CONTRIBUTES = new Set([ "protocols", "layouts", "themes", + "mobile", ]); const ALLOWED_SETTINGS_FIELD = [ @@ -946,10 +977,135 @@ export function validateManifest(manifest: unknown): string[] { typeof m.id === "string" ? m.id : undefined, errors, ); + if (isPlainObject(m.contributes)) { + validateMobile( + m.contributes, + typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY, + errors, + ); + } return errors; } +function folderOf(file: string): string { + const parts = file.split("/").filter((part) => part !== "" && part !== "."); + return parts.slice(0, -1).join("/"); +} + +function validateMobile( + contributes: Record, + frontend: string, + errors: string[], +): void { + const mobile = contributes.mobile; + if (mobile === undefined) return; + const where = "contributes.mobile"; + if (!isPlainObject(mobile)) { + errors.push(`Field "${where}" must be an object`); + return; + } + rejectUnknown( + mobile, + ["mode", "entry", "sizes", "views", "feature", "note"], + `"${where}"`, + errors, + ); + const mode = mobile.mode; + if (!(MOBILE_MODES as readonly unknown[]).includes(mode)) { + errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`); + return; + } + for (const key of ["entry", "sizes", "views"]) { + if (mobile[key] !== undefined && mode !== "webview") { + errors.push(`${where}.${key} is only for mode "webview"`); + } + } + if (mobile.feature !== undefined && mode !== "native") { + errors.push(`${where}.feature is only for mode "native"`); + } + + if ( + mobile.entry !== undefined && + requireString(mobile.entry, `${where}.entry`, errors) + ) { + const before = errors.length; + requireRelativePath(mobile.entry, `${where}.entry`, errors); + if (!mobile.entry.endsWith(".js")) { + errors.push(`${where}.entry must be a .js file`); + } else if ( + errors.length === before && + folderOf(mobile.entry) !== folderOf(frontend) + ) { + // Core only serves the folder the frontend bundle sits in. + errors.push( + `${where}.entry must sit in the same folder as the frontend bundle (${frontend})`, + ); + } + } + + if (mobile.sizes !== undefined) { + const sizes = mobile.sizes; + if (!Array.isArray(sizes) || sizes.length === 0) { + errors.push(`${where}.sizes must be a non-empty array`); + } else { + const seen = new Set(); + for (const size of sizes) { + if (!(MOBILE_SIZES as readonly unknown[]).includes(size)) { + errors.push( + `${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`, + ); + } else if (seen.has(size)) { + errors.push(`${where}.sizes duplicates "${String(size)}"`); + } + seen.add(size); + } + } + } + + if (mobile.views !== undefined) { + const views = mobile.views; + if (!Array.isArray(views) || views.length === 0) { + errors.push(`${where}.views must be a non-empty array`); + } else { + const declared = new Set(); + for (const field of ["tabs", "panels", "dashboardCards"]) { + const list = contributes[field]; + if (!Array.isArray(list)) continue; + for (const view of list) { + if (isPlainObject(view) && typeof view.id === "string") { + declared.add(view.id); + } + } + } + const seen = new Set(); + for (const view of views) { + if (typeof view !== "string" || !declared.has(view)) { + errors.push( + `${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`, + ); + } else if (seen.has(view)) { + errors.push(`${where}.views duplicates "${view}"`); + } + seen.add(view); + } + } + } + + if ( + mobile.feature !== undefined && + (typeof mobile.feature !== "string" || + !LOOK_ID_PATTERN.test(mobile.feature)) + ) { + errors.push( + `${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`, + ); + } + if (mobile.note !== undefined) { + requireString(mobile.note, `${where}.note`, errors); + } +} + function validateFeatures(features: unknown, errors: string[]): void { if (features === undefined) return; if (!Array.isArray(features)) { diff --git a/scripts/plugin-validate.test.ts b/scripts/plugin-validate.test.ts index 16e859956..9f0db367c 100644 --- a/scripts/plugin-validate.test.ts +++ b/scripts/plugin-validate.test.ts @@ -144,3 +144,32 @@ describe("termix-plugin validate layouts and capabilities", () => { expect(output).not.toContain("is declared but"); }); }); + +describe("termix-plugin validate mobile entry", () => { + function mobile(built: boolean) { + const root = plugin([]); + fs.writeFileSync( + path.join(root, "manifest.json"), + JSON.stringify({ + id: "fixture", + version: "1.0.0", + contributes: { mobile: { mode: "webview", entry: "dist/mobile.js" } }, + }), + ); + if (built) { + fs.mkdirSync(path.join(root, "dist"), { recursive: true }); + fs.writeFileSync(path.join(root, "dist", "mobile.js"), ""); + } + return root; + } + + it("reports a mobile entry that was not built", async () => { + expect(await problems(mobile(false))).toContain( + "manifest references dist/mobile.js, which does not exist", + ); + }); + + it("finds a built mobile entry", async () => { + expect(await problems(mobile(true))).not.toContain("dist/mobile.js"); + }); +}); diff --git a/src/backend/tests/plugins/manifest.test.ts b/src/backend/tests/plugins/manifest.test.ts index 5a3ed608e..b9df705c6 100644 --- a/src/backend/tests/plugins/manifest.test.ts +++ b/src/backend/tests/plugins/manifest.test.ts @@ -650,6 +650,110 @@ describe("panels, dashboard cards and guest pages", () => { }); }); +describe("contributes.mobile", () => { + const tabs = [ + { id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] }, + ]; + + it("accepts each mode with its own fields", () => { + for (const mobile of [ + { + mode: "webview", + entry: "dist/mobile.js", + sizes: ["phone", "tablet"], + views: ["sample"], + note: "mobile.note", + }, + { mode: "webview" }, + { mode: "native", feature: "file-manager" }, + { mode: "none", note: "Not on phones" }, + ]) { + expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual( + [], + ); + } + }); + + it("needs a known mode and rejects unknown fields", () => { + const errors = validateManifest( + base({ contributes: { mobile: { mode: "app", width: 3 } } }), + ).join(); + expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/); + expect(errors).toMatch(/contributes\.mobile\.mode must be one of/); + expect( + validateManifest(base({ contributes: { mobile: "webview" } })).join(), + ).toMatch(/"contributes\.mobile" must be an object/); + }); + + it("keeps webview and native fields to their own mode", () => { + const errors = validateManifest( + base({ + contributes: { + tabs, + mobile: { + mode: "native", + entry: "dist/mobile.js", + views: ["sample"], + }, + }, + }), + ).join(); + expect(errors).toMatch(/mobile\.entry is only for mode "webview"/); + expect(errors).toMatch(/mobile\.views is only for mode "webview"/); + expect( + validateManifest( + base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }), + ).join(), + ).toMatch(/mobile\.feature is only for mode "native"/); + }); + + it("puts the entry beside the frontend bundle", () => { + const check = (entry: string, frontend?: string) => + validateManifest( + base({ + ...(frontend ? { frontend } : {}), + contributes: { mobile: { mode: "webview", entry } }, + }), + ).join(); + expect(check("dist/mobile.js")).toBe(""); + expect(check("./dist/mobile.js")).toBe(""); + expect(check("build/mobile.js", "build/frontend.js")).toBe(""); + expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/); + expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/); + expect(check("../dist/mobile.js")).toMatch(/relative path inside/); + }); + + it("checks sizes, views and the feature id", () => { + const errors = validateManifest( + base({ + contributes: { + tabs, + panels: [{ id: "side", titleKey: "nav.side" }], + mobile: { + mode: "webview", + sizes: ["phone", "phone", "watch"], + views: ["sample", "side", "missing"], + }, + }, + }), + ).join(); + expect(errors).toMatch(/sizes duplicates "phone"/); + expect(errors).toMatch(/sizes entries must be one of: phone, tablet/); + expect(errors).toMatch(/views entries must be ids .*"missing"/); + expect(errors).not.toMatch(/"side"/); + expect( + validateManifest( + base({ contributes: { mobile: { mode: "webview", sizes: [] } } }), + ).join(), + ).toMatch(/sizes must be a non-empty array/); + expect( + validateManifest( + base({ contributes: { mobile: { mode: "native", feature: "Files" } } }), + ).join(), + ).toMatch(/mobile\.feature must match/); + }); +}); + describe("named service providers", () => { const provides = (names: unknown) => [ {