mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: shell controller, TabStage, layout and theme plugins with repair view
This commit is contained in:
1 parent
aff29a9581
commit
86161d66e2
120 files changed
+23693
-3381
No files matched your search
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.12
|
||||
|
||||
Plugin API 1.4.0.
|
||||
|
||||
### Added
|
||||
|
||||
- `contributes.layouts` and `app.registerLayout()` for a whole-app layout, with the `ui:layout` capability. Needs `engine.api` 1.4
|
||||
- `contributes.themes` for CSS-only themes, with the `ui:theme` capability. `termix-plugin validate` checks theme CSS and `pack` ships it
|
||||
- `useShell()` gives a layout the live shell: tabs, splits, hosts, docks, the command palette and the registries
|
||||
- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
|
||||
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
|
||||
|
||||
### Fixed
|
||||
|
||||
- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
|
||||
|
||||
## 1.0.11
|
||||
|
||||
Plugin API 1.3.0.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Termix plugin guide
|
||||
|
||||
SDK 1.0.11 | Plugin API 1.3.0
|
||||
SDK 1.0.12 | Plugin API 1.4.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,7 @@ 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` and `uses`. Frontend `register*` calls need their ids declared here first.
|
||||
`uiPresets`, `uses`, `layouts` and `themes`. Frontend `register*` calls need their ids declared here first.
|
||||
|
||||
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.
|
||||
@@ -478,6 +478,7 @@ your plugin's error boundary and Suspense.
|
||||
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
|
||||
| `registerOnboardingStep` | A first run setup step |
|
||||
| `registerLoginMethod`, `registerSecondFactorUI`, `registerSshAuthEditor` | Sign in UI |
|
||||
| `registerLayout` | A whole-app layout (`contributes.layouts`, API 1.4) |
|
||||
| `api` | Axios client for your `/plugin-api/<id>/` routes, auth included |
|
||||
| `fetch(path)`, `wsUrl(path)` | Raw fetch for streams, socket URL and auth |
|
||||
| `docs`, `tabs`, `confirm`, `listHosts`, `getHost`, `t`, `hasPermission`, `desktop` | Helpers |
|
||||
@@ -485,7 +486,7 @@ your plugin's error boundary and Suspense.
|
||||
|
||||
Hooks from `@termix-ssh/plugin-sdk/frontend`: `useTranslation`, `usePermission`, `useSettings`, `useHost`,
|
||||
`useHosts`, `usePluginApi`, `useCurrentUser`, `useTheme`, `useToast`, `useDocsUrl`, `useTabs`,
|
||||
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`.
|
||||
`useSlotContributions`, `useSshAuthTypes`, `useExtensions`, `useShell`.
|
||||
|
||||
Shared from Termix and never bundled: `react`, `react-dom`, `i18next`, `react-i18next`, `sonner` and the
|
||||
SDK's `frontend` and `ui`. Anything else, like `lucide-react`, is bundled. A host your code gets only carries
|
||||
@@ -496,6 +497,64 @@ Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `termin
|
||||
`hosts.panel`, `credentials.badges`, `shell.overlay`, `shell.rightEdge` and `tab.menu`. See
|
||||
https://docs.termix.site/develop/more-extension-points.
|
||||
|
||||
## Layouts and themes
|
||||
|
||||
A plugin can replace the whole app layout or add themes (API 1.4). Users pick both in Settings, Appearance;
|
||||
admins set the default for everyone.
|
||||
|
||||
A layout is declared in `contributes.layouts` (`id`, `label`, optional `description` and `defaultTheme`) and
|
||||
needs the `ui:layout` capability, which admins see as high risk because a layout draws every screen. Register
|
||||
it in `activate`:
|
||||
|
||||
```tsx
|
||||
import { useShell, type TermixApp } from "@termix-ssh/plugin-sdk/frontend";
|
||||
import {
|
||||
ShellConfirmCapture,
|
||||
SurfaceScope,
|
||||
TabStage,
|
||||
} from "@termix-ssh/plugin-sdk/ui";
|
||||
|
||||
function MyLayout() {
|
||||
const shell = useShell();
|
||||
if (!shell) return null;
|
||||
return (
|
||||
<div className="flex h-dvh w-screen flex-col bg-background">
|
||||
<header>
|
||||
{/* tabs from shell.tabs.topLevel, shell.palette.open() and so on */}
|
||||
</header>
|
||||
<SurfaceScope kind="tab">
|
||||
<ShellConfirmCapture />
|
||||
<TabStage />
|
||||
</SurfaceScope>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp) {
|
||||
app.registerLayout({
|
||||
id: "my-layout",
|
||||
label: "layout.name",
|
||||
component: MyLayout,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- `useShell()` is the live shell with nothing drawn: tabs, splits, hosts, the left and right docks, the
|
||||
command palette, the registries as hooks and the user. Core keeps all the state, so switching layouts keeps
|
||||
every session.
|
||||
- `TabStage` is where tabs show. Place it anywhere; tabs are never remounted when it moves, so a terminal
|
||||
keeps its connection. The command palette, overlays and onboarding stay in core.
|
||||
- `mobileComponent` is used on phones and narrow windows. The desktop app always gets `component`.
|
||||
- Only the chosen layout's plugin is loaded. If it fails to load or crashes twice, Termix shows a repair
|
||||
screen where people can switch, reset, update or turn it off. `?safe-layout=1` opens it any time.
|
||||
|
||||
A theme is CSS only. Declare it in `contributes.themes` (`id`, `label`, `mode` light or dark, `css` path,
|
||||
optional `swatch` colors for the picker) with the `ui:theme` capability. The id becomes a class on `<html>`,
|
||||
so the file may only hold rules whose selectors start with `.<id>`, `:root.<id>` or `html.<id>`, optionally
|
||||
inside `@media` or `@supports`. `@import`, `url()` to another host and other at-rules are refused by
|
||||
`termix-plugin validate` and by the server. Set the same tokens core's themes set (`--background`,
|
||||
`--foreground`, `--card`, `--border`, `--muted` and the rest).
|
||||
|
||||
## Looking native
|
||||
|
||||
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
||||
@@ -775,6 +834,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
|
||||
| `device:serial` | high | Open serial and USB ports on the server |
|
||||
| `notify:hub` | high | Receive every alert (the alerts hub only) |
|
||||
| `ui:layout` | high | Replace the whole app layout |
|
||||
| `hosts:write` | medium | Add and edit hosts |
|
||||
| `credentials:use` | medium | Connect with saved credentials without seeing them |
|
||||
| `credentials:write` | medium | Save new credentials |
|
||||
@@ -794,6 +854,7 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
|
||||
| `settings:read-core` | low | Read Termix server settings |
|
||||
| `plugins:read` | low | See installed plugins |
|
||||
| `ui:surface` | low | Add tabs, panels and settings |
|
||||
| `ui:theme` | low | Add color themes, CSS only |
|
||||
|
||||
An update that adds a capability waits for an admin to approve it.
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { collectFiles, createTmxplug } from "../lib/tmxplug.mjs";
|
||||
import {
|
||||
collectFiles,
|
||||
createTmxplug,
|
||||
themeFiles,
|
||||
TMXPLUG_CONTENTS,
|
||||
} from "../lib/tmxplug.mjs";
|
||||
import { sha256 } from "../lib/signing.mjs";
|
||||
import { validate } from "./validate.mjs";
|
||||
|
||||
@@ -32,7 +37,12 @@ export async function pack({ cwd, args = [] }) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const outfile = path.join(outDir, `${pluginId}-${version}.tmxplug`);
|
||||
|
||||
const archive = createTmxplug(cwd, collectFiles(cwd));
|
||||
const archive = createTmxplug(
|
||||
cwd,
|
||||
collectFiles(cwd, [
|
||||
...new Set([...TMXPLUG_CONTENTS, ...themeFiles(manifest)]),
|
||||
]),
|
||||
);
|
||||
fs.writeFileSync(outfile, archive);
|
||||
|
||||
console.log(`packed ${path.basename(outfile)}`);
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function loadSdkModule(name) {
|
||||
|
||||
export async function validate({ cwd }) {
|
||||
const raw = readManifest(cwd);
|
||||
const { parseManifest } = await loadSdkModule("manifest");
|
||||
const { parseManifest, validateThemeCss } = await loadSdkModule("manifest");
|
||||
const { errors } = parseManifest(raw);
|
||||
|
||||
const problems = [...errors];
|
||||
@@ -34,6 +34,7 @@ export async function validate({ cwd }) {
|
||||
}
|
||||
}
|
||||
|
||||
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
|
||||
problems.push(
|
||||
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
||||
);
|
||||
@@ -53,6 +54,30 @@ export async function validate({ cwd }) {
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
|
||||
/** Every declared theme file exists and holds only CSS a theme may hold. */
|
||||
export function validateThemeFiles(cwd, raw, validateThemeCss) {
|
||||
const problems = [];
|
||||
const themes = raw?.contributes?.themes;
|
||||
if (!Array.isArray(themes)) return problems;
|
||||
for (const theme of themes) {
|
||||
if (typeof theme?.css !== "string" || typeof theme?.id !== "string") {
|
||||
continue;
|
||||
}
|
||||
const file = path.join(cwd, theme.css);
|
||||
if (!fs.existsSync(file)) {
|
||||
problems.push(`theme ${theme.id}: ${theme.css} does not exist`);
|
||||
continue;
|
||||
}
|
||||
for (const error of validateThemeCss(
|
||||
fs.readFileSync(file, "utf8"),
|
||||
theme.id,
|
||||
)) {
|
||||
problems.push(`theme ${theme.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||
const CORE_ENV = new Set([
|
||||
"DATA_DIR",
|
||||
|
||||
@@ -2,6 +2,15 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
|
||||
/** Theme CSS files a manifest declares, which travel with the plugin too. */
|
||||
export function themeFiles(manifest) {
|
||||
const themes = manifest?.contributes?.themes;
|
||||
if (!Array.isArray(themes)) return [];
|
||||
return themes
|
||||
.map((theme) => theme?.css)
|
||||
.filter((css) => typeof css === "string");
|
||||
}
|
||||
|
||||
/** What a .tmxplug holds, in the order the spec lists it. */
|
||||
export const TMXPLUG_CONTENTS = [
|
||||
"manifest.json",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.12",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -82,7 +82,11 @@
|
||||
"features": {
|
||||
"type": "array",
|
||||
"maxItems": 20,
|
||||
"items": { "type": "string", "minLength": 1, "maxLength": 160 },
|
||||
"items": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160
|
||||
},
|
||||
"description": "Short lines listing what the plugin does, shown on its page."
|
||||
},
|
||||
"docs": {
|
||||
@@ -98,11 +102,24 @@
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "description"],
|
||||
"properties": {
|
||||
"name": { "type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,63}$" },
|
||||
"description": { "type": "string", "minLength": 1, "maxLength": 300 },
|
||||
"default": { "type": "string" },
|
||||
"required": { "type": "boolean" },
|
||||
"secret": { "type": "boolean" }
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Z][A-Z0-9_]{0,63}$"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 300
|
||||
},
|
||||
"default": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"secret": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -155,7 +172,11 @@
|
||||
"files:own",
|
||||
"secrets:own",
|
||||
"settings:read-core",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"notify:hub",
|
||||
"plugins:read",
|
||||
"ui:layout",
|
||||
"ui:theme"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -944,7 +965,9 @@
|
||||
"defaultLevels": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "enum": ["admin", "user", "folder"] },
|
||||
"items": {
|
||||
"enum": ["admin", "user", "folder"]
|
||||
},
|
||||
"description": "Levels that may set a default for the field. All three by default."
|
||||
},
|
||||
"personal": {
|
||||
@@ -1127,6 +1150,86 @@
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$"
|
||||
}
|
||||
},
|
||||
"layouts": {
|
||||
"type": "array",
|
||||
"description": "Whole-app layouts the frontend registers with app.registerLayout. Needs the ui:layout capability.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "i18n key in the plugin namespace, or plain text."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"defaultTheme": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$",
|
||||
"description": "Theme picked with this layout when nobody chose one."
|
||||
},
|
||||
"default": {
|
||||
"type": "boolean",
|
||||
"description": "The layout a default-look plugin starts with. At most one."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"themes": {
|
||||
"type": "array",
|
||||
"description": "Themes: one CSS file each with token rules scoped under .<id>. Needs the ui:theme capability.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "label", "mode", "css"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9-]{1,39}$"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark"]
|
||||
},
|
||||
"css": {
|
||||
"type": "string",
|
||||
"pattern": ".css$",
|
||||
"description": "Path of the CSS file inside the plugin."
|
||||
},
|
||||
"swatch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["background", "panel", "text"],
|
||||
"properties": {
|
||||
"background": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||
},
|
||||
"panel": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{3,8}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("auth:provide", "high"),
|
||||
entry("device:serial", "high"),
|
||||
entry("notify:hub", "high"),
|
||||
entry("ui:layout", "high"),
|
||||
|
||||
entry("hosts:write", "medium"),
|
||||
entry("credentials:use", "medium"),
|
||||
@@ -70,6 +71,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
|
||||
entry("settings:read-core", "low"),
|
||||
entry("plugins:read", "low"),
|
||||
entry("ui:surface", "low"),
|
||||
entry("ui:theme", "low"),
|
||||
] as const;
|
||||
|
||||
export const CAPABILITY_IDS: readonly string[] = CAPABILITY_CATALOG.map(
|
||||
|
||||
@@ -248,6 +248,129 @@ export interface TabsApi extends Pick<
|
||||
onReady: (listener: () => void) => Disposer;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layouts (plugin API 1.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A whole-app layout. Core renders the chosen one inside its providers and an
|
||||
* error boundary; everything it shows comes from useShell(). Declare it in
|
||||
* manifest contributes.layouts and add the ui:layout capability.
|
||||
*/
|
||||
export interface LayoutContribution {
|
||||
id: string;
|
||||
/** Your own i18n key or plain text, the same as the manifest's label. */
|
||||
label: string;
|
||||
component: ComponentType;
|
||||
/** Shown on phones and narrow windows. The desktop app never uses it. */
|
||||
mobileComponent?: ComponentType;
|
||||
/** Theme to start with when nobody chose one. */
|
||||
defaultTheme?: string;
|
||||
}
|
||||
|
||||
/** A tab as a layout sees it. Splits are tabs too, with a `split` field. */
|
||||
export interface ShellTabState extends PluginTabRecord {
|
||||
instanceId: string;
|
||||
customLabel?: string;
|
||||
/** Set while the tab sits in a split pane. */
|
||||
parentSplitTabId?: string;
|
||||
openedAt?: number;
|
||||
}
|
||||
|
||||
/** What a dock region shows and how it is sized, in Normal-size pixels. */
|
||||
export interface ShellDockState {
|
||||
view: string | null;
|
||||
open: boolean;
|
||||
width: number;
|
||||
dragging: boolean;
|
||||
setView: (view: string | null) => void;
|
||||
setWidth: (width: number) => void;
|
||||
/** Starts a drag from the dock's edge, from a mousedown. */
|
||||
startResize: (event: { clientX: number; preventDefault: () => void }) => void;
|
||||
resetWidth: () => void;
|
||||
}
|
||||
|
||||
/** The live shell: state and actions, nothing drawn. Read with useShell(). */
|
||||
export interface ShellController {
|
||||
tabs: {
|
||||
list: ShellTabState[];
|
||||
/** What a tab bar shows: tabs not sitting in a split. */
|
||||
topLevel: ShellTabState[];
|
||||
activeId: string;
|
||||
/** True once saved tabs were restored after login. */
|
||||
ready: boolean;
|
||||
/** The user turned on tab numbers. */
|
||||
showNumbers: boolean;
|
||||
setActive: (id: string) => void;
|
||||
reorder: (topLevel: ShellTabState[]) => void;
|
||||
close: (id: string) => Promise<void>;
|
||||
rename: (id: string, label: string) => void;
|
||||
/** Reconnects or reloads a session tab. */
|
||||
refresh: (id: string) => void;
|
||||
reconnectAll: () => void;
|
||||
openSingleton: (type: string) => string | undefined;
|
||||
};
|
||||
split: {
|
||||
/** The split shown now, or null. */
|
||||
activeId: string | null;
|
||||
focusedPaneId: string | null;
|
||||
/** Every split with its panes, for a split menu. */
|
||||
summaries: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
full: boolean;
|
||||
panes: Array<{
|
||||
id: string;
|
||||
index: number;
|
||||
tabId: string | null;
|
||||
label: string | null;
|
||||
}>;
|
||||
}>;
|
||||
activeFull: boolean;
|
||||
/** Split a tab, apply a preset, unsplit, add to a pane. */
|
||||
handleAction: (action: Record<string, unknown> & { kind: string }) => void;
|
||||
};
|
||||
hosts: {
|
||||
list: PluginHostRecord[];
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
regions: {
|
||||
left: ShellDockState & { view: string };
|
||||
right: ShellDockState;
|
||||
/** What a rail click does: toggles or switches the left dock. */
|
||||
railClick: (view: string) => void;
|
||||
openInRightDock: (view: string) => void;
|
||||
toggleRightDock: () => void;
|
||||
};
|
||||
palette: { isOpen: boolean; open: () => void; close: () => void };
|
||||
fullscreen: { active: boolean; toggle: () => void };
|
||||
/** The same callbacks tabs and panels get. */
|
||||
actions: ShellApi;
|
||||
env: {
|
||||
isMobile: boolean;
|
||||
isElectron: boolean;
|
||||
user: { username: string; isAdmin: boolean };
|
||||
permissions: string[];
|
||||
branding: {
|
||||
appName: string | null;
|
||||
tagline: string | null;
|
||||
logo: string | null;
|
||||
};
|
||||
};
|
||||
/** Core registries, as hooks a layout calls while rendering. */
|
||||
registries: {
|
||||
useRailItems: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||
usePanels: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||
useTabTypes: () => ReadonlyArray<{ id: string } & Record<string, unknown>>;
|
||||
useDashboardCards: () => ReadonlyArray<
|
||||
{ id: string } & Record<string, unknown>
|
||||
>;
|
||||
};
|
||||
logout: (options?: { manual?: boolean }) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Registrations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -988,6 +1111,12 @@ export interface TermixApp extends TermixAppInfo {
|
||||
registerSshAuthEditor: (editor: SshAuthEditorContribution) => Disposer;
|
||||
registerLoginMethod: (method: LoginMethodContribution) => Disposer;
|
||||
registerSecondFactorUI: (factor: SecondFactorContribution) => Disposer;
|
||||
/**
|
||||
* A whole-app layout. Declared in manifest contributes.layouts with the
|
||||
* ui:layout capability. Only the chosen layout's plugin is loaded, so
|
||||
* register it in activate. Needs plugin API 1.4.
|
||||
*/
|
||||
registerLayout: (layout: LayoutContribution) => Disposer;
|
||||
|
||||
/**
|
||||
* Translates a key from the plugin's own locales, for code outside a
|
||||
@@ -1122,6 +1251,7 @@ export interface PluginHostBridge {
|
||||
useHosts: () => { hosts: PluginHostRecord[]; loaded: boolean };
|
||||
useCurrentUser: () => CurrentUser | null;
|
||||
useTheme: () => { theme: "light" | "dark" };
|
||||
useShell: () => ShellController | null;
|
||||
toast: ToastApi;
|
||||
getApi: (pluginId: string) => PluginApiClient;
|
||||
getApiFor: (pluginId: string, origin: unknown) => PluginApiClient;
|
||||
@@ -1372,6 +1502,14 @@ export function useTheme(): { theme: "light" | "dark" } {
|
||||
return requireHost().useTheme();
|
||||
}
|
||||
|
||||
/**
|
||||
* The live shell, for a layout. Null outside the signed-in app. Needs plugin
|
||||
* API 1.4.
|
||||
*/
|
||||
export function useShell(): ShellController | null {
|
||||
return requireHost().useShell();
|
||||
}
|
||||
|
||||
export function useToast(): ToastApi {
|
||||
return requireHost().toast;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type PluginEnvVar,
|
||||
} from "./docs.js";
|
||||
|
||||
export { validateThemeCss } from "./theme-css.js";
|
||||
export {
|
||||
ENV_NAME_PATTERN,
|
||||
MAX_ENV_DESCRIPTION_LENGTH,
|
||||
@@ -36,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.3.0";
|
||||
export const PLUGIN_API_VERSION = "1.4.0";
|
||||
|
||||
/** The API major, the default engine.api for a new plugin. */
|
||||
export const SUPPORTED_PLUGIN_API_VERSION = String(
|
||||
@@ -426,6 +427,40 @@ export interface PluginAuthContribution {
|
||||
keyboardInteractive?: string[];
|
||||
}
|
||||
|
||||
/** A layout id or theme id: short, lowercase, used as a CSS class for themes. */
|
||||
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
||||
|
||||
/**
|
||||
* A whole-app layout the frontend registers with app.registerLayout. Needs
|
||||
* the ui:layout capability. Declared here so core can list and choose it
|
||||
* before any plugin code runs.
|
||||
*/
|
||||
export interface PluginLayoutContribution {
|
||||
id: string;
|
||||
/** i18n key in the plugin's namespace, or plain text. */
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Theme picked with this layout when nobody chose one. */
|
||||
defaultTheme?: string;
|
||||
/** The layout a default-look plugin starts with. */
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A theme: one CSS file of token rules scoped under `.<id>`, applied as a
|
||||
* class on <html>. Needs the ui:theme capability. No code runs for a theme.
|
||||
*/
|
||||
export interface PluginThemeContribution {
|
||||
id: string;
|
||||
/** i18n key in the plugin's namespace, or plain text. */
|
||||
label: string;
|
||||
mode: "light" | "dark";
|
||||
/** Path of the CSS file inside the plugin. */
|
||||
css: string;
|
||||
/** Colors the theme picker previews it with. */
|
||||
swatch?: { background: string; panel: string; text: string };
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: PluginViewContribution[];
|
||||
@@ -487,6 +522,10 @@ export interface PluginContributions {
|
||||
* ctx.credentials.resolveHostProtocol.
|
||||
*/
|
||||
protocols?: PluginProtocolContribution[];
|
||||
/** Whole-app layouts. Needs the ui:layout capability. */
|
||||
layouts?: PluginLayoutContribution[];
|
||||
/** Themes. Needs the ui:theme capability. */
|
||||
themes?: PluginThemeContribution[];
|
||||
}
|
||||
|
||||
/** A field a protocol login carries besides username and password. */
|
||||
@@ -708,6 +747,8 @@ const ALLOWED_CONTRIBUTES = new Set([
|
||||
"uses",
|
||||
"keybindingActions",
|
||||
"protocols",
|
||||
"layouts",
|
||||
"themes",
|
||||
]);
|
||||
|
||||
const ALLOWED_SETTINGS_FIELD = [
|
||||
@@ -1300,6 +1341,111 @@ function validateContributes(
|
||||
validateUiPresets(contributes.uiPresets, errors);
|
||||
validateKeybindingActions(contributes.keybindingActions, errors);
|
||||
validateProtocols(contributes.protocols, errors);
|
||||
validateLayouts(contributes.layouts, errors);
|
||||
validateThemes(contributes.themes, errors);
|
||||
}
|
||||
|
||||
/** An array of objects with unique look ids; calls `each` for every valid entry. */
|
||||
function validateLookList(
|
||||
value: unknown,
|
||||
field: string,
|
||||
allowed: string[],
|
||||
errors: string[],
|
||||
each: (raw: Record<string, unknown>, where: string) => void,
|
||||
): void {
|
||||
if (value === undefined) return;
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`Field "contributes.${field}" must be an array`);
|
||||
return;
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
value.forEach((raw, index) => {
|
||||
const where = `contributes.${field}[${index}]`;
|
||||
if (!isPlainObject(raw)) {
|
||||
errors.push(`${where} must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(raw, allowed, where, errors);
|
||||
if (typeof raw.id !== "string" || !LOOK_ID_PATTERN.test(raw.id)) {
|
||||
errors.push(
|
||||
`${where}.id must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
|
||||
);
|
||||
} else {
|
||||
if (seen.has(raw.id)) errors.push(`${where}.id duplicates "${raw.id}"`);
|
||||
seen.add(raw.id);
|
||||
}
|
||||
requireString(raw.label, `${where}.label`, errors);
|
||||
each(raw, where);
|
||||
});
|
||||
}
|
||||
|
||||
function validateLayouts(value: unknown, errors: string[]): void {
|
||||
validateLookList(
|
||||
value,
|
||||
"layouts",
|
||||
["id", "label", "description", "defaultTheme", "default"],
|
||||
errors,
|
||||
(raw, where) => {
|
||||
if (raw.description !== undefined)
|
||||
requireString(raw.description, `${where}.description`, errors);
|
||||
if (
|
||||
raw.defaultTheme !== undefined &&
|
||||
(typeof raw.defaultTheme !== "string" ||
|
||||
!LOOK_ID_PATTERN.test(raw.defaultTheme))
|
||||
) {
|
||||
errors.push(`${where}.defaultTheme must be a theme id`);
|
||||
}
|
||||
if (raw.default !== undefined && typeof raw.default !== "boolean") {
|
||||
errors.push(`${where}.default must be a boolean`);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (
|
||||
Array.isArray(value) &&
|
||||
value.filter((raw) => isPlainObject(raw) && raw.default === true).length > 1
|
||||
) {
|
||||
errors.push('Only one entry in "contributes.layouts" may set default');
|
||||
}
|
||||
}
|
||||
|
||||
const HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
||||
|
||||
function validateThemes(value: unknown, errors: string[]): void {
|
||||
validateLookList(
|
||||
value,
|
||||
"themes",
|
||||
["id", "label", "mode", "css", "swatch"],
|
||||
errors,
|
||||
(raw, where) => {
|
||||
if (raw.mode !== "light" && raw.mode !== "dark") {
|
||||
errors.push(`${where}.mode must be "light" or "dark"`);
|
||||
}
|
||||
if (requireString(raw.css, `${where}.css`, errors)) {
|
||||
requireRelativePath(raw.css, `${where}.css`, errors);
|
||||
if (!raw.css.endsWith(".css")) {
|
||||
errors.push(`${where}.css must be a .css file`);
|
||||
}
|
||||
}
|
||||
if (raw.swatch !== undefined) {
|
||||
if (!isPlainObject(raw.swatch)) {
|
||||
errors.push(`${where}.swatch must be an object`);
|
||||
return;
|
||||
}
|
||||
rejectUnknown(
|
||||
raw.swatch,
|
||||
["background", "panel", "text"],
|
||||
`${where}.swatch`,
|
||||
errors,
|
||||
);
|
||||
for (const key of ["background", "panel", "text"]) {
|
||||
const color = raw.swatch[key];
|
||||
if (typeof color !== "string" || !HEX_COLOR.test(color)) {
|
||||
errors.push(`${where}.swatch.${key} must be a hex color`);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export const PROTOCOL_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
|
||||
@@ -2324,6 +2470,23 @@ export function parseManifest(raw: unknown): ParsedManifest {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(manifest.contributes?.layouts?.length ?? 0) > 0 &&
|
||||
!manifest.capabilities.includes("ui:layout")
|
||||
) {
|
||||
errors.push(
|
||||
'contributes.layouts needs the "ui:layout" capability in the capabilities array',
|
||||
);
|
||||
}
|
||||
if (
|
||||
(manifest.contributes?.themes?.length ?? 0) > 0 &&
|
||||
!manifest.capabilities.includes("ui:theme")
|
||||
) {
|
||||
errors.push(
|
||||
'contributes.themes needs the "ui:theme" capability in the capabilities array',
|
||||
);
|
||||
}
|
||||
|
||||
for (const secret of manifest.requiresSecret ?? []) {
|
||||
if (secret.plugin === manifest.id) {
|
||||
errors.push(
|
||||
|
||||
@@ -97,6 +97,7 @@ const offline = () =>
|
||||
useHostStatus: () => null,
|
||||
useCurrentUser: () => null,
|
||||
useTheme: () => ({ theme: "dark" }),
|
||||
useShell: () => null,
|
||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||
getApi: () => ({
|
||||
get: offline,
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Checks a theme's CSS before core serves it.
|
||||
*
|
||||
* A theme is colors and styles only, so its file may hold nothing but rules
|
||||
* whose every selector starts with the theme's own class (`.nord`,
|
||||
* `:root.nord`, `html.nord ...`), optionally inside @media or @supports.
|
||||
* Anything that could load from elsewhere or run code is refused: @import,
|
||||
* url() to another host, other at-rules, expression() and javascript:.
|
||||
*/
|
||||
|
||||
const ALLOWED_BLOCK_AT_RULES = new Set(["media", "supports"]);
|
||||
|
||||
function stripComments(css: string): string {
|
||||
return css.replace(/\/\*[\s\S]*?\*\//g, " ");
|
||||
}
|
||||
|
||||
/** Splits on commas that are not inside parentheses or brackets. */
|
||||
function splitSelectors(prelude: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let depth = 0;
|
||||
let current = "";
|
||||
for (const ch of prelude) {
|
||||
if (ch === "(" || ch === "[") depth += 1;
|
||||
if (ch === ")" || ch === "]") depth -= 1;
|
||||
if (ch === "," && depth === 0) {
|
||||
parts.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
parts.push(current);
|
||||
return parts.map((part) => part.trim());
|
||||
}
|
||||
|
||||
function withoutStrings(css: string): string {
|
||||
return css.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""');
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Index of the brace closing the one at `open`, skipping strings. */
|
||||
function matchingBrace(css: string, open: number): number {
|
||||
let depth = 0;
|
||||
let quote: string | null = null;
|
||||
for (let i = open; i < css.length; i += 1) {
|
||||
const ch = css[i];
|
||||
if (quote) {
|
||||
if (ch === "\\") i += 1;
|
||||
else if (ch === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") quote = ch;
|
||||
else if (ch === "{") depth += 1;
|
||||
else if (ch === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function checkUrls(css: string, errors: string[]): void {
|
||||
const urls = css.matchAll(/url\(\s*(['"]?)([^'")]*)\1\s*\)/gi);
|
||||
for (const match of urls) {
|
||||
const target = match[2].trim();
|
||||
if (target.startsWith("data:")) continue;
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("//")) {
|
||||
errors.push(`url(${target}) points outside the plugin`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkRules(
|
||||
css: string,
|
||||
scope: RegExp,
|
||||
themeId: string,
|
||||
errors: string[],
|
||||
): void {
|
||||
let index = 0;
|
||||
while (index < css.length) {
|
||||
const rest = css.slice(index);
|
||||
if (rest.trim() === "") return;
|
||||
const open = css.indexOf("{", index);
|
||||
const semicolon = css.indexOf(";", index);
|
||||
if (semicolon !== -1 && (open === -1 || semicolon < open)) {
|
||||
const statement = css.slice(index, semicolon).trim();
|
||||
errors.push(
|
||||
statement.startsWith("@")
|
||||
? `${statement.split(/\s/)[0]} is not allowed in a theme`
|
||||
: `Unexpected "${statement}" outside a rule`,
|
||||
);
|
||||
index = semicolon + 1;
|
||||
continue;
|
||||
}
|
||||
if (open === -1) {
|
||||
errors.push(`Unexpected "${rest.trim()}" at the end`);
|
||||
return;
|
||||
}
|
||||
const close = matchingBrace(css, open);
|
||||
if (close === -1) {
|
||||
errors.push("A rule is missing its closing brace");
|
||||
return;
|
||||
}
|
||||
const prelude = css.slice(index, open).trim();
|
||||
const body = css.slice(open + 1, close);
|
||||
|
||||
if (prelude.startsWith("@")) {
|
||||
const name = prelude.slice(1).split(/[\s(]/)[0].toLowerCase();
|
||||
if (ALLOWED_BLOCK_AT_RULES.has(name)) {
|
||||
checkRules(body, scope, themeId, errors);
|
||||
} else {
|
||||
errors.push(`@${name} is not allowed in a theme`);
|
||||
}
|
||||
} else {
|
||||
for (const selector of splitSelectors(prelude)) {
|
||||
if (!scope.test(selector)) {
|
||||
errors.push(
|
||||
`Selector "${selector}" must start with .${themeId}, :root.${themeId} or html.${themeId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (withoutStrings(body).includes("{")) {
|
||||
errors.push(`Nested rules are not allowed in "${prelude}"`);
|
||||
}
|
||||
}
|
||||
index = close + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Every problem with a theme's CSS, empty when it is safe to serve. */
|
||||
export function validateThemeCss(css: string, themeId: string): string[] {
|
||||
const errors: string[] = [];
|
||||
const clean = stripComments(css);
|
||||
if (/@import\b/i.test(clean))
|
||||
errors.push("@import is not allowed in a theme");
|
||||
if (/expression\s*\(/i.test(clean))
|
||||
errors.push("expression() is not allowed in a theme");
|
||||
if (/javascript:/i.test(clean))
|
||||
errors.push("javascript: is not allowed in a theme");
|
||||
if (/-moz-binding|behavior\s*:/i.test(clean))
|
||||
errors.push("Script bindings are not allowed in a theme");
|
||||
checkUrls(clean, errors);
|
||||
const id = escapeRegExp(themeId);
|
||||
const scope = new RegExp(`^(?::root|html)?\\.${id}(?![\\w-])`);
|
||||
checkRules(clean, scope, themeId, errors);
|
||||
return [...new Set(errors)];
|
||||
}
|
||||
@@ -173,6 +173,7 @@ SheetFooter (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<
|
||||
SheetHeader (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
SheetTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
SheetTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ShellConfirmCapture (value): () => null
|
||||
Skeleton (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Slider (value): ({ className, defaultValue, value, min, max, ...props }: SliderProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
||||
@@ -183,6 +184,7 @@ SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }:
|
||||
Switch (value): ({ className, ...props }: SwitchProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SwitchRow (value): ({ label, description, badge, checked, onChange, disabled, defaultKey, rowId, }: { label: string; description?: ReactNode; badge?: string; checked: boolean; onChange: (value: boolean) => void; disabled?: boolean; defaultKey?: string; rowId?: string; }) => Element
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
TabStage (value): ({ className }: { className?: string; }) => Element
|
||||
TabStrip (value): ({ tabs, activeTab, onTabChange, isActive, variant, trailing, className, }: { tabs: TabStripItem[]; activeTab: string; onTabChange: (id: string) => void; isActive?: (id: string) => boolean; variant?: "secondary" | "primary"; trailing?: ReactNode; className?: string; }) => Element
|
||||
TabStripItem (type): TabStripItem
|
||||
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||
|
||||
Reference in new issue
Block a user