mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
Compare commits
5
Commits
v26.10.5
...
sdk-v1.0.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec3cc9cf18 | ||
|
|
4c235bb23f | ||
|
|
b414b85746 | ||
|
|
d049588f7f | ||
|
|
86161d66e2 |
No files matched your search
@@ -11,4 +11,3 @@ db
|
||||
*.min.css
|
||||
openapi.json
|
||||
drizzle/
|
||||
src/ui/settings/settings-search-index.ts
|
||||
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## 26.10.3
|
||||
|
||||
### Added
|
||||
|
||||
- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone
|
||||
- Plugins can add layouts and themes
|
||||
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
|
||||
- Hovering a theme in Settings previews it
|
||||
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
|
||||
- Switching layouts closes the tabs only the old layout could show, like its Settings tab
|
||||
- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen
|
||||
|
||||
### Changed
|
||||
|
||||
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
|
||||
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
|
||||
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
|
||||
- A theme that no longer exists shows as System instead of an unstyled page
|
||||
|
||||
## 26.10.2
|
||||
|
||||
### Added
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
|
||||
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
|
||||
},
|
||||
{
|
||||
"id": "classic-layout",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
|
||||
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "classic-themes",
|
||||
"source": "tmxplug",
|
||||
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
|
||||
"sha256": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
|
||||
"defaultLook": true
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": "tmxplug",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -512,6 +512,13 @@
|
||||
"when": 1791935283211,
|
||||
"tag": "0072_unique_username",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "5",
|
||||
"when": 1792357129912,
|
||||
"tag": "0073_user_look_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -512,6 +512,13 @@
|
||||
"when": 1791935282340,
|
||||
"tag": "0072_unique_username",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 73,
|
||||
"version": "7",
|
||||
"when": 1792357128746,
|
||||
"tag": "0073_user_look_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `user_preferences` ADD `theme_id` text;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -477,6 +477,13 @@
|
||||
"when": 1791935281343,
|
||||
"tag": "0067_unique_username",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 68,
|
||||
"version": "6",
|
||||
"when": 1792357127581,
|
||||
"tag": "0068_user_look_preferences",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -370,6 +370,12 @@
|
||||
"default": "false",
|
||||
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_SAFE_LAYOUT",
|
||||
"group": "plugins",
|
||||
"default": "false",
|
||||
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
|
||||
},
|
||||
{
|
||||
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
|
||||
"group": "plugins",
|
||||
|
||||
Generated
+3
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "26.10.2",
|
||||
"version": "26.10.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "26.10.2",
|
||||
"version": "26.10.3",
|
||||
"hasInstallScript": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -15058,7 +15058,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.12",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@deadendjs/swagger-jsdoc": "^8.1.2",
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
"version": "26.10.2",
|
||||
"version": "26.10.3",
|
||||
"description": "Self-hosted, plugin-based server management.",
|
||||
"author": "Karmaa",
|
||||
"main": "electron/main.cjs",
|
||||
@@ -18,7 +18,7 @@
|
||||
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
|
||||
"prepare": "husky || true",
|
||||
"prebuild": "node scripts/write-electron-build-info.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
|
||||
"lint:fix": "eslint --fix .",
|
||||
"type-check": "npm run build:sdk && tsc -b --force",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# 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. The shell's home tab is left out until some plugin registers a `dashboard` tab type
|
||||
- `TabStage` and `ShellConfirmCapture` in `@termix-ssh/plugin-sdk/ui`, for a layout to place tabs
|
||||
- `validateThemeCss()` in `@termix-ssh/plugin-sdk/manifest`
|
||||
- `@termix-ssh/plugin-sdk/ui` also exports what a whole layout is built from: the API client, shared types, hooks, `useShellController()` and `APP_VERSION`. These reach into core, so they can change with a core release
|
||||
- `@termix-ssh/plugin-sdk/testing-host` exports the registries and permission seam a layout's own tests fill directly
|
||||
- A plugin that only holds themes needs no source: `termix-plugin build` writes an empty backend and `validate` does not ask for a frontend
|
||||
- `termix-plugin validate` flags `ui:layout` or `ui:theme` with nothing contributed, and a layout plugin with no frontend
|
||||
|
||||
### Fixed
|
||||
|
||||
- The manifest JSON schema lists the `notify:hub` and `plugins:read` capabilities
|
||||
- Plugin tests and type checks work with the SDK linked from a local Termix checkout
|
||||
|
||||
## 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,95 @@ 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. Wrap it in `SurfaceScope kind="tab"` with a `ShellConfirmCapture` inside, so
|
||||
close questions and inline views show over the tab area. Overlays and onboarding stay in core.
|
||||
- The layout draws the command palette. Core keeps whether it is open (`palette.isOpen`, Ctrl+K calls
|
||||
`palette.open()`) and what is in it (`usePaletteEntries`, `usePaletteGroups`, rail items, hosts).
|
||||
- The shell always keeps a home tab with the id and type `dashboard`. `useShell()` leaves it out until some
|
||||
plugin registers a `dashboard` tab type, so a layout without a home screen has no empty tab to draw.
|
||||
- Settings, Manage and the host editor belong to Classic Layout. A layout without them should still render
|
||||
`LookSettings` somewhere so people can switch back. `tabs.openSingleton("plugins")` opens core's own
|
||||
Plugins tab in any layout. Switching layouts closes the tabs the old layout's plugin declared.
|
||||
- `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, turn it off or (for a bundled plugin) restore the copy that
|
||||
shipped. `?safe-layout=1` opens it any time.
|
||||
- Termix has no layout of its own. The default is the bundled Classic Layout plugin
|
||||
(https://github.com/Termix-SSH/Plugin-Classic-Layout), which is also a full example. It owns the rail,
|
||||
sidebar, tab bar, docks, Dashboard, Manage, Settings and command palette, registers the `dashboard`,
|
||||
`host-manager` and `settings` tab types, and offers the host list and dashboard as the components
|
||||
`classic-layout.hostTree` and `classic-layout.dashboard` for other plugins to place with `PluginComponent`.
|
||||
It reads the shell with `useShellController()` from `@termix-ssh/plugin-sdk/ui`, which exposes more than
|
||||
`useShell()` and may change with Termix. A third party layout should start from `useShell()`.
|
||||
- Example Top Bar Layout (https://github.com/Termix-SSH/Plugin-Example-Topbar-Layout) is a small third party
|
||||
style layout built only on `useShell()`, `TabStage` and `@termix-ssh/plugin-sdk/ui`, with a phone version.
|
||||
|
||||
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).
|
||||
|
||||
A plugin that only holds themes needs no code. Leave out `src/`: `termix-plugin build` writes an empty backend and
|
||||
`validate` does not ask for a frontend. Keep the CSS in `themes/` and a test that checks it with
|
||||
`validateThemeCss()`. Notes:
|
||||
|
||||
- A theme with the id of a built-in (`light` or `dark`) replaces core's basic copy while the plugin runs.
|
||||
- The first theme a bundled default-look plugin lists is the default for new installs.
|
||||
- Themes show in the picker in the order core lists its own, then the plugin's order.
|
||||
- A theme someone saved that no longer exists shows as System. It comes back when the plugin does.
|
||||
- Accent color, UI font, font size and density are not part of a theme. Core applies them on top.
|
||||
- Bundled Classic Themes (https://github.com/Termix-SSH/Plugin-Classic-Themes) is a full example, and
|
||||
Example Theme (https://github.com/Termix-SSH/Plugin-Example-Theme) a small one with one light and one dark
|
||||
theme.
|
||||
|
||||
## Looking native
|
||||
|
||||
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
|
||||
@@ -775,6 +865,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 +885,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.
|
||||
|
||||
|
||||
@@ -5,7 +5,12 @@ import { fileURLToPath } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
import { readManifest, resolveEntry, copyDir } from "../lib/plugin-dir.mjs";
|
||||
import {
|
||||
readManifest,
|
||||
resolveEntry,
|
||||
copyDir,
|
||||
isThemeOnly,
|
||||
} from "../lib/plugin-dir.mjs";
|
||||
import { openapi } from "./openapi.mjs";
|
||||
import { splitByCoreClasses } from "../lib/css-classes.mjs";
|
||||
|
||||
@@ -232,32 +237,41 @@ export async function build({ cwd }) {
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const backendEntry = resolveEntry(cwd, BACKEND_ENTRIES);
|
||||
if (!backendEntry) {
|
||||
if (!backendEntry && !isThemeOnly(manifest)) {
|
||||
throw new Error(
|
||||
`${pluginId}: no backend entry (looked for ${BACKEND_ENTRIES.join(", ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [backendEntry],
|
||||
outfile: path.join(outDir, "backend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
// A native dependency's compiled .node binary is resolved by the
|
||||
// package's own relative paths, which break once esbuild inlines its JS
|
||||
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
||||
// node_modules import instead, resolved at runtime like a host-provided
|
||||
// package.
|
||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||
loader: BINARY_LOADERS,
|
||||
// Bundled CJS deps still call require() for Node builtins at runtime.
|
||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
||||
banner: { js: REQUIRE_BANNER },
|
||||
});
|
||||
if (backendEntry) {
|
||||
await esbuild.build({
|
||||
entryPoints: [backendEntry],
|
||||
outfile: path.join(outDir, "backend.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
logLevel: "warning",
|
||||
// A native dependency's compiled .node binary is resolved by the
|
||||
// package's own relative paths, which break once esbuild inlines its JS
|
||||
// elsewhere. Declaring it in nativeDependencies keeps it a real
|
||||
// node_modules import instead, resolved at runtime like a host-provided
|
||||
// package.
|
||||
external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])],
|
||||
loader: BINARY_LOADERS,
|
||||
// Bundled CJS deps still call require() for Node builtins at runtime.
|
||||
// ESM has no ambient require, so give esbuild's require shim a real one.
|
||||
banner: { js: REQUIRE_BANNER },
|
||||
});
|
||||
} else {
|
||||
// Core loads every plugin through a backend entry, so a theme only
|
||||
// plugin gets an empty one.
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "backend.js"),
|
||||
"export function activate() {}\n",
|
||||
);
|
||||
}
|
||||
|
||||
for (const worker of readWorkerEntries(cwd, pluginId)) {
|
||||
await esbuild.build({
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { readManifest, isThemeOnly } from "../lib/plugin-dir.mjs";
|
||||
|
||||
/**
|
||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||
@@ -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];
|
||||
@@ -25,7 +25,8 @@ export async function validate({ cwd }) {
|
||||
// The manifest names files. They have to be there.
|
||||
const referenced = [
|
||||
raw.backend ?? "dist/backend.js",
|
||||
raw.frontend ?? "dist/frontend.js",
|
||||
// A theme only plugin has no frontend code.
|
||||
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
|
||||
raw.locales ?? "locales",
|
||||
];
|
||||
for (const rel of referenced) {
|
||||
@@ -34,6 +35,8 @@ export async function validate({ cwd }) {
|
||||
}
|
||||
}
|
||||
|
||||
problems.push(...validateThemeFiles(cwd, raw, validateThemeCss));
|
||||
problems.push(...validateLookCapabilities(cwd, raw));
|
||||
problems.push(
|
||||
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
|
||||
);
|
||||
@@ -53,6 +56,63 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* ui:layout and ui:theme must match what the manifest contributes, both
|
||||
* ways. The manifest check already flags a contribution without its
|
||||
* capability; this flags a capability with nothing behind it, and a layout
|
||||
* with no frontend to register it.
|
||||
*/
|
||||
export function validateLookCapabilities(cwd, raw) {
|
||||
const problems = [];
|
||||
const capabilities = Array.isArray(raw?.capabilities) ? raw.capabilities : [];
|
||||
const layouts = raw?.contributes?.layouts;
|
||||
const themes = raw?.contributes?.themes;
|
||||
const hasLayouts = Array.isArray(layouts) && layouts.length > 0;
|
||||
const hasThemes = Array.isArray(themes) && themes.length > 0;
|
||||
if (capabilities.includes("ui:layout") && !hasLayouts) {
|
||||
problems.push('"ui:layout" is declared but contributes.layouts is empty');
|
||||
}
|
||||
if (capabilities.includes("ui:theme") && !hasThemes) {
|
||||
problems.push('"ui:theme" is declared but contributes.themes is empty');
|
||||
}
|
||||
if (hasLayouts) {
|
||||
const entries = ["index.tsx", "index.ts", "index.mjs", "index.js"];
|
||||
const frontend = entries.some((entry) =>
|
||||
fs.existsSync(path.join(cwd, "src", "frontend", entry)),
|
||||
);
|
||||
if (!frontend && raw.frontend === undefined) {
|
||||
problems.push(
|
||||
"contributes.layouts needs a frontend that calls app.registerLayout",
|
||||
);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/** Env vars core sets for everyone, so a plugin need not list them. */
|
||||
const CORE_ENV = new Set([
|
||||
"DATA_DIR",
|
||||
|
||||
@@ -14,6 +14,17 @@ export function readManifest(cwd) {
|
||||
}
|
||||
|
||||
/** The first of these that exists, or null. */
|
||||
/** Only themes: CSS files and no code, so no source entry is needed. */
|
||||
export function isThemeOnly(manifest) {
|
||||
const capabilities = manifest.capabilities ?? [];
|
||||
return (
|
||||
capabilities.length > 0 &&
|
||||
capabilities.every((capability) => capability === "ui:theme") &&
|
||||
Array.isArray(manifest.contributes?.themes) &&
|
||||
manifest.contributes.themes.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveEntry(cwd, candidates) {
|
||||
for (const candidate of candidates) {
|
||||
const full = path.join(cwd, candidate);
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -117,9 +117,19 @@ export function pluginVitestConfig(
|
||||
|
||||
const plugins = [wasmBinaryPlugin()];
|
||||
|
||||
// A local SDK linked into node_modules sits outside the plugin folder, so
|
||||
// Vite must be allowed to read it, and React must come from the plugin.
|
||||
const sdkDir = fs.realpathSync(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const server = { fs: { allow: [pluginDir, sdkDir] } };
|
||||
const resolve = {
|
||||
alias,
|
||||
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
|
||||
};
|
||||
|
||||
return {
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
server,
|
||||
resolve,
|
||||
test: {
|
||||
globals: true,
|
||||
setupFiles: [setupFile],
|
||||
@@ -127,7 +137,8 @@ export function pluginVitestConfig(
|
||||
projects: [
|
||||
{
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
server,
|
||||
resolve,
|
||||
test: {
|
||||
name: `${pluginId}:backend`,
|
||||
root: pluginDir,
|
||||
@@ -140,7 +151,8 @@ export function pluginVitestConfig(
|
||||
},
|
||||
{
|
||||
plugins,
|
||||
resolve: { alias },
|
||||
server,
|
||||
resolve,
|
||||
test: {
|
||||
name: `${pluginId}:frontend`,
|
||||
root: pluginDir,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"moduleDetection": "force",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"preserveSymlinks": true,
|
||||
"noEmit": true,
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
ACCENT_PRESET_COLORS (value): { label: string; value: string; }[]
|
||||
APP_VERSION (value): string
|
||||
AccessRecord (type): AccessRecord
|
||||
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
||||
ActiveSessionInfo (type): ActiveSessionInfo
|
||||
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
AdminSection (type): AdminSection
|
||||
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
||||
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
||||
@@ -14,14 +19,23 @@ AlertDialogPortal (value): ({ ...props }: AlertDialogPortalProps) => Element
|
||||
AlertDialogTitle (value): ({ className, ...props }: AlertDialogTitleProps & RefAttributes<HTMLHeadingElement>) => Element
|
||||
AlertDialogTrigger (value): ({ ...props }: AlertDialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
AlertTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
ApiKey (type): ApiKey
|
||||
AuditLog (type): AuditLog
|
||||
AuditLogFilters (type): AuditLogFilters
|
||||
AuthEnrollmentSections (value): () => Element
|
||||
AuthOverrideProtocol (type): string
|
||||
BackButton (value): ({ onClick, label, className, }: { onClick: () => void; label?: string; className?: string; }) => Element
|
||||
Badge (value): ({ className, variant, asChild, ...props }: ClassAttributes<HTMLSpanElement> & HTMLAttributes<HTMLSpanElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; secondary: string; destructive: string; outline: string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||
BarSeries (value): ({ items, max, className, colorClassName, }: BarSeriesProps) => Element
|
||||
BarSeriesItem (type): BarSeriesItem
|
||||
BarSeriesProps (type): BarSeriesProps
|
||||
BrandingSettings (type): BrandingSettings
|
||||
BrowserSignInDialog (value): ({ isOpen, label, url, code, onContinue, onCancel, onOpenUrl, backgroundColor, }: BrowserSignInDialogProps) => Element
|
||||
Button (value): ({ className, variant, size, asChild, ...props }: ClassAttributes<HTMLButtonElement> & ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; outline: string; secondary: string; ghost: string; destructive: string; link: string; }; size: { default: string; xs: string; sm: string; lg: string; icon: string; "icon-xs": string; "icon-sm": string; "icon-lg": string; }; }> & ClassProp) => string> & { asChild?: boolean; }) => Element
|
||||
ButtonProps (type): ButtonProps
|
||||
CORE_HOST_DEFAULTS (value): readonly CoreHostDefault[]
|
||||
CORE_NAMESPACE (value): "core"
|
||||
CREDENTIAL_ROW_FIELD_KEYS (value): (keyof CredentialRowFields)[]
|
||||
Card (value): ({ className, size, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & { size?: "default" | "sm"; }) => Element
|
||||
CardAction (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
CardContent (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
@@ -33,6 +47,12 @@ CardMasonry (value): ({ columns, stack, className, children, }: { columns: numbe
|
||||
CardTitle (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
Checkbox (value): ({ className, ...props }: CheckboxProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ColumnCountStepper (value): ({ columns, onChange, }: { columns: number; onChange: (columns: number) => void; }) => Element
|
||||
Command (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandEmpty (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandGroup (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandItem (value): ForwardRefExoticComponent<Omit<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandList (value): ForwardRefExoticComponent<Omit<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>, "ref"> & RefAttributes<HTMLDivElement>>
|
||||
CommandPrimitive (value): ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & RefAttributes<HTMLDivElement>> & { List: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; } & RefAttributes<HTMLDivElement>>; Item: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "disabled" | "value" | "onSelect"> & { disabled?: boolean; onSelect?: (value: string) => void; value?: string; keywords?: string[]; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Input: ForwardRefExoticComponent<Omit<Pick<Pick<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "key" | keyof InputHTMLAttributes<HTMLInputElement>> & { ref?: Ref<HTMLInputElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof InputHTMLAttributes<HTMLInputElement>>, "type" | "value" | "onChange"> & { value?: string; onValueChange?: (search: string) => void; } & RefAttributes<HTMLInputElement>>; Group: ForwardRefExoticComponent<Children & Omit<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>>, "value" | "heading"> & { heading?: ReactNode; value?: string; forceMount?: boolean; } & RefAttributes<HTMLDivElement>>; Separator: ForwardRefExoticComponent<Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { alwaysRender?: boolean; } & RefAttributes<HTMLDivElement>>; Dialog: ForwardRefExoticComponent<DialogProps & Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { label?: string; shouldFilter?: boolean; filter?: CommandFilter; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; loop?: boolean; disablePointerSelection?: boolean; vimBindings?: boolean; } & { overlayClassName?: string; contentClassName?: string; container?: HTMLElement; } & RefAttributes<HTMLDivElement>>; Empty: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & RefAttributes<HTMLDivElement>>; Loading: ForwardRefExoticComponent<Children & Pick<Pick<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "key" | keyof HTMLAttributes<HTMLDivElement>> & { ref?: Ref<HTMLDivElement>; } & { asChild?: boolean; }, "key" | "asChild" | keyof HTMLAttributes<HTMLDivElement>> & { progress?: number; label?: string; } & RefAttributes<HTMLDivElement>>; }
|
||||
ComponentSlot (value): ({ slotId, props, when, }: { slotId: string; props?: Record<string, unknown>; when?: Record<string, unknown>; }) => Element
|
||||
ConfirmOptions (type): ConfirmOptions
|
||||
ConnectionGate (value): (props: ConnectionGateProps) => Element
|
||||
@@ -45,9 +65,26 @@ ConnectionScreen (value): ({ status, message, detail, errorDetail, unavailable,
|
||||
ConnectionStage (type): ConnectionStage
|
||||
ConnectionStatus (type): ConnectionStatus
|
||||
ConnectionUnavailable (type): ConnectionUnavailable
|
||||
CreatedApiKey (type): CreatedApiKey
|
||||
Credential (type): Credential
|
||||
CredentialDensity (type): CredentialDensity
|
||||
CredentialPermissionLevel (type): CredentialPermissionLevel
|
||||
CredentialRowFields (type): CredentialRowFields
|
||||
CredentialSidebarFilterState (type): CredentialSidebarFilterState
|
||||
CredentialSidebarPreferences (type): CredentialSidebarPreferences
|
||||
CredentialSortKey (type): CredentialSortKey
|
||||
CredentialTrayTrigger (type): CredentialTrayTrigger
|
||||
CustomKeybinding (type): CustomKeybinding
|
||||
DASHBOARD_CARDS (value): DashboardCardConfig[]
|
||||
DEFAULT_FOLDER_COLOR (value): "#f59145"
|
||||
DEFAULT_FOLDER_ICON (value): "folder"
|
||||
DashboardCardId (type): DashboardCardId
|
||||
DataColumn (type): DataColumn<T>
|
||||
DataView (value): <T>({ items, view, density, getKey, renderCard, columns, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
|
||||
DefaultOverrides (type): DefaultOverrides
|
||||
DefaultsOnly (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
DesktopUpdateMode (type): DesktopUpdateMode
|
||||
DesktopUpdateState (type): DesktopUpdateState
|
||||
Dialog (value): ({ ...props }: DialogProps) => Element
|
||||
DialogClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DialogContent (value): ({ className, children, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { showCloseButton?: boolean; }) => Element
|
||||
@@ -60,6 +97,7 @@ DialogTitle (value): ({ className, ...props }: DialogTitleProps & RefAttributes<
|
||||
DialogTrigger (value): ({ ...props }: DialogTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
DocsLink (value): ({ core, page, anchor, href, variant, children, className, }: DocsLinkProps) => Element
|
||||
DocsLinkProps (type): DocsLinkProps
|
||||
DocsPage (type): "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry"
|
||||
DropdownMenu (value): ({ ...props }: DropdownMenuProps) => Element
|
||||
DropdownMenuCheckboxItem (value): ({ className, children, checked, inset, ...props }: DropdownMenuCheckboxItemProps & RefAttributes<HTMLDivElement> & { inset?: boolean; }) => Element
|
||||
DropdownMenuContent (value): ({ className, align, sideOffset, ...props }: DropdownMenuContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
@@ -77,11 +115,17 @@ DropdownMenuSubTrigger (value): ({ className, inset, children, ...props }: Dropd
|
||||
DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
EditingWidth (type): EditingWidth
|
||||
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
|
||||
ExportPayload (type): ExportPayload
|
||||
FOLDER_COLORS (value): string[]
|
||||
FONT_SIZES (value): { id: FontSizeId; label: string; }[]
|
||||
Facts (value): ({ className, children, }: { className?: string; children: ReactNode; }) => Element
|
||||
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
||||
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
|
||||
FieldGroup (type): FieldGroup
|
||||
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
|
||||
FolderAccessRule (type): FolderAccessRule
|
||||
FolderIconEl (value): ({ icon, className, style, }: { icon?: string; className?: string; style?: CSSProperties; }) => Element
|
||||
FontSizeId (type): FontSizeId
|
||||
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
|
||||
FullScreenAppPhase (type): FullScreenAppPhase
|
||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||
@@ -91,11 +135,33 @@ GridColumns (type): GridColumns
|
||||
GridLayout (type): GridLayout
|
||||
GridSlot (type): GridSlot
|
||||
GroupHeading (value): ({ title, count, action, className, }: { title: string; count?: number; action?: ReactNode; className?: string; }) => Element
|
||||
HexColorPicker (value): ({ value, onChange, }: { value: string; onChange: (hex: string) => void; }) => Element
|
||||
Host (type): Host
|
||||
HostActionDef (type): HostActionDef
|
||||
HostClickBehavior (type): HostClickBehavior
|
||||
HostData (type): HostData
|
||||
HostDefaultBadge (value): ({ settingKey }: { settingKey: string; }) => Element
|
||||
HostDefaultField (value): ({ settingKey, children, }: { settingKey: string; children: ReactNode; }) => Element
|
||||
HostDefaultsChange (type): HostDefaultsChange
|
||||
HostDefaultsContext (value): Context<HostDefaultsContextValue>
|
||||
HostDefaultsContextValue (type): HostDefaultsContextValue
|
||||
HostDefaultsLevel (type): HostDefaultsLevel
|
||||
HostDefaultsTarget (type): HostDefaultsTarget
|
||||
HostDensity (type): HostDensity
|
||||
HostFeatureFields (value): ({ form, updateForm, }: HostFeatureFieldsProps) => Element
|
||||
HostFeatureFieldsProps (type): HostFeatureFieldsProps
|
||||
HostFolder (type): HostFolder
|
||||
HostGroupKey (type): HostGroupKey
|
||||
HostKeyVerificationDialog (value): ({ isOpen, scenario, ip, port, hostname, fingerprint, oldFingerprint, onAccept, onReject, backgroundColor, }: HostKeyVerificationDialogProps) => Element
|
||||
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
|
||||
HostProtocolAuthInput (type): HostProtocolAuthInput
|
||||
HostProtocolDef (type): HostProtocolDef
|
||||
HostProtocols (type): HostProtocols
|
||||
HostRowFields (type): HostRowFields
|
||||
HostSidebarFilterState (type): HostSidebarFilterState
|
||||
HostSidebarPreferences (type): HostSidebarPreferences
|
||||
HostTrayTrigger (type): HostTrayTrigger
|
||||
IconPicker (value): ({ value, color, onChange, }: { value: string; color?: string; onChange: (icon: string) => void; }) => Element
|
||||
InlineView (value): ({ open, onOpenChange, title, icon, status, actions, toolbar, footer, width, onBeforeClose, scroll, bare, className, children, }: { open: boolean; onOpenChange: (open: boolean) => void; title: ReactNode; icon?: ReactNode; status?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; footer?: ReactNode; width?: EditingWidth; onBeforeClose?: () => boolean | Promise<boolean>; scroll?: boolean; bare?: boolean; className?: string; children: ReactNode; }) => ReactPortal
|
||||
Input (value): ({ className, type, ...props }: DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>) => Element
|
||||
Kbd (value): ForwardRefExoticComponent<KbdProps & RefAttributes<HTMLElement>>
|
||||
@@ -103,10 +169,14 @@ KbdKey (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): El
|
||||
KbdSeparator (value): { ({ className, ...props }: HTMLAttributes<HTMLSpanElement>): Element; displayName: string; }
|
||||
KeyCombo (type): KeyCombo
|
||||
KeybindingAction (type): KeybindingAction
|
||||
KeybindingActionDef (type): KeybindingActionDef
|
||||
KeybindingActionType (type): string
|
||||
LANGUAGES (value): readonly { code: string; label: string; }[]
|
||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
LinkServerDialog (value): ({ open, onOpenChange, onLinked, relogin, }: LinkServerDialogProps) => Element
|
||||
LinkedAccountInfo (type): LinkedAccountInfo
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
@@ -114,7 +184,12 @@ ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dr
|
||||
ListRowTone (type): ListRowTone
|
||||
LogContext (type): LogContext
|
||||
LogEntry (type): LogEntry
|
||||
LookSettings (value): ({ isAdmin }: { isAdmin: boolean; }) => Element
|
||||
MANAGE_REQUEST_EVENT (value): "manage:request"
|
||||
MAX_PANES (value): 8
|
||||
MFAPromptMode (type): MFAPromptMode
|
||||
ManageMode (type): ManageMode
|
||||
ManageRequest (type): ManageRequest
|
||||
ManagerCardError (type): ManagerCardError
|
||||
ManagerCardShell (value): ({ title, icon, loading, error, onRefresh, empty, emptyMessage, children, headerExtra, }: { title: string; icon: ReactNode; loading: boolean; error: ManagerCardError; onRefresh: () => void; empty?: boolean; emptyMessage?: string; children: ReactNode; headerExtra?: ReactNode; }) => Element
|
||||
ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value: string; onChange: (value: string) => void; placeholder?: string; count?: number; extra?: ReactNode; }) => Element
|
||||
@@ -122,10 +197,13 @@ Meter (value): ({ percent, className, }: { percent: number; className?: string;
|
||||
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
|
||||
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
|
||||
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
|
||||
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
|
||||
OpenTabRecord (type): OpenTabRecord
|
||||
PANEL (value): { readonly body: "p-2.5"; readonly gap: "gap-2"; readonly band: "px-3 py-2"; readonly cardHead: "px-3 py-2.5"; readonly header: "h-12.5"; }
|
||||
PROMPT_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PaletteItemDef (type): PaletteItemDef
|
||||
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
||||
PanelDensity (type): PanelDensity
|
||||
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
@@ -134,18 +212,35 @@ PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, t
|
||||
PanelViewMode (type): PanelViewMode
|
||||
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||
PasswordInput (value): ForwardRefExoticComponent<PasswordInputProps & RefAttributes<HTMLInputElement>>
|
||||
PermissionCatalogEntry (type): PermissionCatalogEntry
|
||||
PluginComponent (value): ({ id, fallback, ...props }: { [prop: string]: unknown; id: string; fallback?: ReactNode; }) => ReactNode
|
||||
PluginIcon (value): ({ name, ...props }: { name?: string; } & LucideProps) => Element
|
||||
PluginSettingsErrors (type): PluginSettingsErrors
|
||||
PluginSettingsField (type): PluginSettingsField
|
||||
PluginSettingsValidationError (value): typeof PluginSettingsValidationError
|
||||
PluginSummary (type): PluginSummary
|
||||
PluginViewPlaceholder (value): ({ kind, viewId, compact, }: { kind: ViewKind; viewId: string; compact?: boolean; }) => Element
|
||||
Popover (value): ({ ...props }: PopoverProps) => Element
|
||||
PopoverAnchor (value): ({ ...props }: PopoverAnchorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverContent (value): ({ className, align, sideOffset, ...props }: PopoverContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
PopoverTrigger (value): ({ ...props }: PopoverTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
ProxyNode (type): ProxyNode
|
||||
ProxySettingsFields (value): ({ customHeaders, basicAuth, onChange, }: ProxySettingsFieldsProps) => Element
|
||||
RadialGauge (value): ({ value, label, caption, size, strokeWidth, className, colorClassName, }: RadialGaugeProps) => Element
|
||||
RadialGaugeProps (type): RadialGaugeProps
|
||||
RailGroup (type): RailGroup
|
||||
RailItemDef (type): RailItemDef
|
||||
RecentActivityItem (type): RecentActivityItem
|
||||
ReleaseInfo (type): ReleaseInfo
|
||||
Repeater (value): <T>({ label, items, onChange, makeItem, empty, addLabel, hint, renderItem, }: { label: string; items: T[]; onChange: (items: T[]) => void; makeItem: () => T; empty: string; addLabel: string; hint?: ReactNode; renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode; }) => Element
|
||||
ResolvedHostDefault (type): ResolvedHostDefault
|
||||
RobustClipboardProvider (value): typeof RobustClipboardProvider
|
||||
Role (type): Role
|
||||
SSHAuthDialog (value): ({ isOpen, reason, onSubmit, onCancel, hostInfo, backgroundColor, }: SSHAuthDialogProps) => Element
|
||||
SSHHost (type): Host
|
||||
SSHHostData (type): HostData
|
||||
SSHHostWithStatus (type): SSHHostWithStatus
|
||||
SSH_AUTH_PROTOCOL (value): "ssh"
|
||||
ScrollArea (value): ({ className, children, ...props }: ScrollAreaProps & RefAttributes<HTMLDivElement>) => Element
|
||||
ScrollBar (value): ({ className, orientation, ...props }: ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SecretField (value): ({ label, value, onChange, placeholder, hint, error, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; disabled?: boolean; className?: string; }) => Element
|
||||
@@ -165,6 +260,9 @@ SelectTrigger (value): ({ className, size, children, ...props }: SelectTriggerPr
|
||||
SelectValue (value): ({ ...props }: SelectValueProps & RefAttributes<HTMLSpanElement>) => Element
|
||||
Separator (value): ({ className, orientation, decorative, ...props }: SeparatorProps & RefAttributes<HTMLDivElement>) => Element
|
||||
SettingRow (value): ({ label, badge, description, defaultKey, rowId, children, }: { label: string; badge?: string; description?: ReactNode; defaultKey?: string; rowId?: string; children: ReactNode; }) => Element
|
||||
SettingsFieldRow (value): ({ pluginId, field, values, setValue, running, error, disabled, defaultKey, }: SettingsFieldProps) => Element
|
||||
SharePermissionLevel (type): SharePermissionLevel
|
||||
ShareTarget (type): ShareTarget
|
||||
Sheet (value): ({ ...props }: DialogProps) => Element
|
||||
SheetClose (value): ({ ...props }: DialogCloseProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
SheetContent (value): ({ className, children, side, showCloseButton, ...props }: DialogContentProps & RefAttributes<HTMLDivElement> & { side?: "top" | "right" | "bottom" | "left"; showCloseButton?: boolean; }) => Element
|
||||
@@ -173,71 +271,381 @@ 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
|
||||
SortKey (type): SortKey
|
||||
Sparkline (value): ({ data, domain, className, colorClassName, showLastDot, height, }: SparklineProps) => Element
|
||||
SparklineProps (type): SparklineProps
|
||||
SplitLayoutMenu (value): ({ activeIsSplit, canAddPane, onSplit, onPreset, }: { activeIsSplit: boolean; canAddPane: boolean; onSplit: (edge: "right" | "bottom") => void; onPreset: (id: SplitPresetId) => void; }) => Element
|
||||
SplitOpenTarget (type): SplitOpenTarget
|
||||
SplitSummary (type): SplitSummary
|
||||
StatRow (value): ({ label, value, mono, className, }: { label: ReactNode; value: ReactNode; mono?: boolean; className?: string; }) => Element
|
||||
StatusValue (type): StatusValue
|
||||
SurfaceKind (type): SurfaceKind
|
||||
SurfaceScope (value): ({ kind, onEditingChange, onClose, className, children, }: { kind?: SurfaceKind; onClose?: () => void; onEditingChange?: (editing: EditingWidth) => void; className?: string; children: ReactNode; }) => Element
|
||||
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
|
||||
SyncBasicAuth (type): SyncBasicAuth
|
||||
SyncConflictItem (type): SyncConflictItem
|
||||
SyncEntityInfo (type): SyncEntityInfo
|
||||
SyncErrorItem (type): SyncErrorItem
|
||||
SyncProxyHeader (type): SyncProxyHeader
|
||||
SyncStatus (type): SyncStatus
|
||||
TOTPDialog (value): ({ isOpen, prompt, mode, waiting, onSubmit, onCancel, backgroundColor, }: TOTPDialogProps) => Element
|
||||
Tab (type): Tab
|
||||
TabShellCallbacks (type): TabShellCallbacks
|
||||
TabSplitAction (type): TabSplitAction
|
||||
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
|
||||
TabType (type): TabType
|
||||
TagInput (value): ({ tags, input, placeholder, onTagsChange, onInputChange, }: { tags: string[]; input: string; placeholder: string; onTagsChange: (tags: string[]) => void; onInputChange: (value: string) => void; }) => Element
|
||||
TextAreaField (value): ({ label, value, onChange, placeholder, rows, hint, error, aside, mono, disabled, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; hint?: ReactNode; error?: string; aside?: ReactNode; mono?: boolean; disabled?: boolean; className?: string; }) => Element
|
||||
TextField (value): ({ label, value, onChange, placeholder, hint, error, aside, disabled, mono, className, }: { label: string; value: string; onChange: (value: string) => void; placeholder?: string; hint?: ReactNode; error?: string; aside?: ReactNode; disabled?: boolean; mono?: boolean; className?: string; }) => Element
|
||||
Textarea (value): ForwardRefExoticComponent<TextareaProps & RefAttributes<HTMLTextAreaElement>>
|
||||
TextareaProps (type): TextareaProps
|
||||
ThemeId (type): string
|
||||
TlsStatus (type): TlsStatus
|
||||
ToolsTab (type): ToolsTab
|
||||
Tooltip (value): ({ ...props }: TooltipProps) => Element
|
||||
TooltipContent (value): ({ className, sideOffset, children, ...props }: TooltipContentProps & RefAttributes<HTMLDivElement>) => Element
|
||||
TooltipProvider (value): ({ delayDuration, ...props }: TooltipProviderProps) => Element
|
||||
TooltipTrigger (value): ({ ...props }: TooltipTriggerProps & RefAttributes<HTMLButtonElement>) => Element
|
||||
UI_AREA_KEYS (value): UiAreaKey[]
|
||||
UI_FONTS (value): { id: UiFontId; label: string; family: string; }[]
|
||||
UiAreaKey (type): UiAreaKey
|
||||
UiFontId (type): UiFontId
|
||||
UiPreset (type): UiPreset
|
||||
UpdateChannelState (type): UpdateChannelState
|
||||
UptimeInfo (type): UptimeInfo
|
||||
UsagePair (value): ({ label, percent, detail, }: { label?: string; percent: number; detail?: string; }) => Element
|
||||
UserPreferences (type): UserPreferences
|
||||
UserRole (type): UserRole
|
||||
UserSecondFactorSummary (type): UserSecondFactorSummary
|
||||
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
|
||||
VersionInfo (type): VersionInfo
|
||||
ViewToggle (value): ({ view, onView, density, onDensity, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; }) => Element
|
||||
WebSocketConnectionTarget (type): WebSocketConnectionTarget
|
||||
WidgetTitle (value): ({ title, icon, fallback }: WidgetTitleProps) => Element
|
||||
actionOrder (value): (action: HostActionDef) => number
|
||||
activityTarget (value): (type: string) => ActivityTarget
|
||||
adminCreateUser (value): (username: string, password: string) => Promise<Record<string, unknown>>
|
||||
adminCreateUserCredential (value): (targetUserId: string, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
adminCreateUserHost (value): (targetUserId: string, hostData: HostData) => Promise<Host>
|
||||
adminDeleteUserCredential (value): (targetUserId: string, credentialId: number) => Promise<Record<string, unknown>>
|
||||
adminDeleteUserHost (value): (targetUserId: string, hostId: number) => Promise<Record<string, unknown>>
|
||||
adminExportUserData (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
adminGetHostPassword (value): (targetUserId: string, hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||
adminGetUserCredentials (value): (targetUserId: string) => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||
adminGetUserHosts (value): (targetUserId: string) => Promise<Host[]>
|
||||
adminResetUserPassword (value): (userId: string, newPassword: string, confirmDataWipe?: boolean) => Promise<{ message: string; dataWiped?: boolean; }>
|
||||
adminUpdateUserCredential (value): (targetUserId: string, credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
adminUpdateUserHost (value): (targetUserId: string, hostId: number, hostData: HostData) => Promise<Host>
|
||||
applyAccentColor (value): (colorValue: string) => void
|
||||
applyFontSize (value): (id: FontSizeId) => void
|
||||
applyPresetSideEffects (value): (preset: "advanced" | "simple" | "balanced") => Promise<void>
|
||||
applyUiFont (value): (id: UiFontId) => void
|
||||
assignRoleToUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||
beginSplitDrag (value): (source: SplitDragSource, x: number, y: number) => void
|
||||
buildExportPayload (value): (raw: ExportPayload, selected: Set<string>, groups: Set<FieldGroup>, withCredentials: boolean, pluginSecrets?: PluginSecretKeys) => ExportPayload
|
||||
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
|
||||
bulkImportSSHHosts (value): (hosts: HostData[], overwrite?: boolean, credentials?: Record<string, unknown>[]) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }>
|
||||
bulkUpdateSSHHosts (value): (hostIds: number[], updates: Record<string, unknown>) => Promise<{ updated: number; failed: number; errors: string[]; }>
|
||||
canConfigureUpdates (value): (state: DesktopUpdateState) => state is DesktopUpdateState
|
||||
canJoinSplit (value): (tab: Tab) => boolean
|
||||
changeAppLanguage (value): (language: string) => Promise<string>
|
||||
changePassword (value): (oldPassword: string, newPassword: string) => Promise<any>
|
||||
clearLocalAdaptivePreferences (value): () => void
|
||||
cn (value): (...inputs: ClassValue[]) => string
|
||||
copyToClipboard (value): (text: string) => Promise<boolean>
|
||||
createApiKey (value): (name: string, userId: string, expiresAt?: string) => Promise<CreatedApiKey>
|
||||
createCredential (value): (credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
createFrontendLogger (value): (serviceName: string, serviceIcon?: string, serviceColor?: string) => FrontendLogger
|
||||
createQuickConnectHost (value): (input: QuickConnectInput) => Host
|
||||
createRole (value): (roleData: { name: string; displayName: string; description?: string; }) => Promise<{ role: Role; }>
|
||||
createSSHHost (value): (hostData: HostData) => Promise<Host>
|
||||
defaultConnectAction (value): (all: HostActionDef[], host: Host) => HostActionDef
|
||||
defaultCredentialRowFields (value): () => CredentialRowFields
|
||||
defaultCredentialSidebarPreferences (value): () => CredentialSidebarPreferences
|
||||
defaultHostRowFields (value): () => HostRowFields
|
||||
defaultHostSidebarPreferences (value): () => HostSidebarPreferences
|
||||
defaultValuesEqual (value): (a: unknown, b: unknown) => boolean
|
||||
deleteAccount (value): (password: string) => Promise<Record<string, unknown>>
|
||||
deleteAllHostsInFolder (value): (folderName: string) => Promise<{ deletedCount: number; }>
|
||||
deleteApiKey (value): (keyId: string) => Promise<{ success: boolean; }>
|
||||
deleteCredential (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||
deleteOpenTab (value): (instanceId: string) => Promise<void>
|
||||
deleteRole (value): (roleId: number) => Promise<{ success: boolean; }>
|
||||
deleteSSHHost (value): (hostId: number) => Promise<Record<string, unknown>>
|
||||
deleteUser (value): (username: string, successorUserId?: string) => Promise<Record<string, unknown>>
|
||||
deployCredentialToHost (value): (credentialId: number, targetHostId: number) => Promise<Record<string, unknown>>
|
||||
desktopUpdates (value): () => DesktopUpdatesBridge
|
||||
docsUrl (value): (page?: "dashboard" | "users" | "sessions" | "roles" | "branding" | "database" | "manage" | "settings" | "plugins" | "install" | "cli" | "develop" | "home" | "updating" | "betas" | "envVars" | "https" | "reverseProxy" | "desktopSync" | "interface" | "hosts" | "portKnocking" | "hostDefaults" | "credentials" | "sharing" | "account" | "accountSecurity" | "accountData" | "apiKeys" | "commandPalette" | "interfaceSettings" | "appearance" | "keybindings" | "adminGeneral" | "adminDatabase" | "adminSsl" | "auditLog" | "importExport" | "pluginCatalog" | "communityRegistry" | "pluginRegistries" | "hostRegistry") => string
|
||||
duplicateCredential (value): (credentialId: number, data: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
enabledHostProtocols (value): (host: { pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocolDef[]
|
||||
endSplitDrag (value): (commit: boolean) => void
|
||||
exportAllSSHHosts (value): { (): Promise<{ hosts: Host[]; }>; (options: { share: true; }): Promise<{ version: string; exportedAt: string; credentials: Record<string, unknown>[]; hosts: Host[]; }>; }
|
||||
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
|
||||
findCoreHostDefault (value): (key: string) => CoreHostDefault
|
||||
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
|
||||
generateKeyPair (value): (keyType: "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256", keySize?: number, passphrase?: string) => Promise<GeneratedKeyPair>
|
||||
generatePublicKeyFromPrivate (value): (privateKey: string, keyPassword?: string) => Promise<GeneratedPublicKey>
|
||||
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
|
||||
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
|
||||
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
|
||||
getAuditForwarding (value): () => Promise<AuditForwardingSettings>
|
||||
getAuditLogActions (value): () => Promise<{ actions: string[]; }>
|
||||
getAuditLogs (value): (filters?: AuditLogFilters) => Promise<AuditLogResponse>
|
||||
getBasePath (value): () => string
|
||||
getBranding (value): () => Promise<BrandingSettings>
|
||||
getChannelReleases (value): () => Promise<{ localVersion: string; stable: ReleaseInfo; beta: ReleaseInfo; }>
|
||||
getConnectedRemoteApi (value): () => Promise<AxiosInstance>
|
||||
getCredentialAccess (value): (credentialId: number) => Promise<{ access: AccessRecord[]; }>
|
||||
getCredentialDetails (value): (credentialId: number) => Promise<Record<string, unknown>>
|
||||
getCredentialSidebarPreferences (value): () => Promise<CredentialSidebarPreferences>
|
||||
getCredentials (value): () => Promise<Record<string, unknown> | Record<string, unknown>[]>
|
||||
getDatabaseHealth (value): () => Promise<Record<string, unknown>>
|
||||
getDatabaseTransferUrl (value): (operation: "export" | "import", { electron, configuredServerUrl, location }: DatabaseTransferUrlOptions) => string
|
||||
getDefaultConnectionTab (value): (host: Host) => TabType
|
||||
getDeveloperMode (value): () => Promise<DeveloperModeState>
|
||||
getDeviceId (value): () => string
|
||||
getErrorMessage (value): (error: unknown, fallback?: string) => string
|
||||
getExternalAutoProvision (value): () => Promise<{ enabled: boolean; }>
|
||||
getFolderAccess (value): (folder: string) => Promise<{ rules: FolderAccessRule[]; }>
|
||||
getFolderDefaultsId (value): (name: string) => Promise<number>
|
||||
getHostAccess (value): (hostId: number, syncId?: string) => Promise<{ accessList: AccessRecord[]; isOwner?: boolean; }>
|
||||
getHostAuthOverride (value): (hostId: number, protocol: string, sharedCopySyncId?: string) => Promise<{ protocol: string; credentialId: number; }>
|
||||
getHostDefaultsLevel (value): (target: HostDefaultsTarget) => Promise<HostDefaultsLevelData>
|
||||
getHostEditorSection (value): (id: string) => HostEditorSectionDef
|
||||
getHostPassword (value): (hostId: number, field?: "key" | "password" | "sudoPassword" | "keyPassword") => Promise<string>
|
||||
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
|
||||
getHostTags (value): () => Promise<string[]>
|
||||
getLiveHostStatus (value): (hostId: number) => StatusValue
|
||||
getLogLevel (value): () => Promise<{ level: string; }>
|
||||
getNotificationPrivateEndpoints (value): () => Promise<string[]>
|
||||
getPanel (value): (id: string) => PanelDef
|
||||
getPasswordLoginAllowed (value): () => Promise<{ allowed: boolean; forced?: boolean; }>
|
||||
getPasswordResetAllowed (value): () => Promise<boolean>
|
||||
getPermissionsCatalog (value): () => Promise<{ catalog: PermissionCatalogEntry[]; }>
|
||||
getPluginAdminSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||
getPluginHostSettings (value): (pluginId: string, hostId: number) => Promise<PluginSettingsValues>
|
||||
getPluginUserSettings (value): (pluginId: string) => Promise<PluginSettingsValues>
|
||||
getPlugins (value): () => Promise<PluginSummary[]>
|
||||
getPollingEnvironmentMultiplier (value): (connection?: NetworkInformationLike) => number
|
||||
getPreferredHostAction (value): (hostId: string, candidates: readonly TabType[], fallback: TabType) => TabType
|
||||
getRecentActivity (value): (limit?: number) => Promise<RecentActivityItem[]>
|
||||
getRegisteredDashboardCard (value): (id: string) => RegisteredDashboardCard
|
||||
getRegistrationAllowed (value): () => Promise<{ allowed: boolean; }>
|
||||
getRoles (value): () => Promise<{ roles: Role[]; }>
|
||||
getSSHFolders (value): () => Promise<SSHFolder[]>
|
||||
getSSHHosts (value): () => Promise<SSHHostWithStatus[]>
|
||||
getSecondFactorAfterExternalLogin (value): () => Promise<{ enabled: boolean; }>
|
||||
getSessionTimeout (value): () => Promise<{ timeoutHours: number; }>
|
||||
getSessionTimeoutMinutes (value): () => Promise<number>
|
||||
getSessions (value): () => Promise<{ sessions: { id: string; userId: string; username?: string; deviceType: string; deviceInfo: string; createdAt: string; expiresAt: string; lastActiveAt: string; isRevoked?: boolean; isCurrentSession?: boolean; }[]; }>
|
||||
getStatusCheckSettings (value): () => Promise<{ statusCheckInterval: number; }>
|
||||
getStatusClasses (value): (status: boolean | "online" | "offline" | "unknown", scheme: StatusColorScheme, variant: "dot" | "stripe" | "badge", loading?: boolean) => string
|
||||
getSyncConflicts (value): () => Promise<SyncConflictItem[]>
|
||||
getSyncErrors (value): () => Promise<SyncErrorItem[]>
|
||||
getTabType (value): (id: string) => TabTypeDef
|
||||
getTlsStatus (value): () => Promise<TlsStatus>
|
||||
getUpdateChannel (value): () => Promise<UpdateChannelState>
|
||||
getUptime (value): () => Promise<UptimeInfo>
|
||||
getUserInfo (value): () => Promise<UserInfo>
|
||||
getUserList (value): (options?: UserListOptions) => Promise<{ users: UserInfo[]; total?: number; }>
|
||||
getUserPreferences (value): () => Promise<UserPreferences>
|
||||
getUserRoles (value): (userId: string) => Promise<{ roles: UserRole[]; }>
|
||||
getUserSecondFactors (value): (userId: string) => Promise<UserSecondFactorSummary[]>
|
||||
getVersionInfo (value): (checkRemote?: boolean) => Promise<VersionInfo>
|
||||
globalShortcutHandler (value): { current: (e: KeyboardEvent) => void; }
|
||||
groupRailItems (value): (items: RailItemDef[]) => { group: RailGroup; items: RailItemDef[]; }[]
|
||||
hasVisibleFields (value): (fields: PluginSettingsField[]) => boolean
|
||||
hostActionsFor (value): (all: HostActionDef[], host: Host, kind?: "open" | "connect") => HostActionDef[]
|
||||
hostBadgesFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
|
||||
hostKey (value): (host: Record<string, unknown>) => string
|
||||
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
|
||||
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
|
||||
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
|
||||
importSSHConfigHosts (value): (content: string, overwrite?: boolean) => Promise<{ message: string; success: number; updated: number; skipped: number; failed: number; errors: string[]; }>
|
||||
invalidateHostsAndStatusCaches (value): () => void
|
||||
invalidateServerStatusCache (value): () => void
|
||||
isCoreRailView (value): (id: string) => boolean
|
||||
isElectron (value): () => boolean
|
||||
isFieldShown (value): (field: PluginSettingsField, values: Record<string, unknown>) => boolean
|
||||
isMacPlatform (value): () => boolean
|
||||
isPersistentTabType (value): (type: string) => boolean
|
||||
isPrerelease (value): (version: string) => boolean
|
||||
isSessionTabType (value): (type: string) => boolean
|
||||
isSplitDragging (value): () => boolean
|
||||
isSplitTab (value): (tab: Tab) => tab is SplitTab
|
||||
isTabJumpHotkey (value): (e: KeyboardEvent) => boolean
|
||||
linkExternalToPasswordAccount (value): (externalUserId: string, targetUsername: string) => Promise<{ success: boolean; message: string; }>
|
||||
linkedServerUrl (value): () => Promise<string>
|
||||
listHostActions (value): () => HostActionDef[]
|
||||
listHostProtocols (value): () => HostProtocolDef[]
|
||||
loadPaletteGroup (value): (group: PaletteGroupDef) => Promise<PaletteItemDef[]>
|
||||
logoutUser (value): () => Promise<{ success: boolean; message: string; }>
|
||||
makeHostSshSubTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||
makeHostTabs (value): (t: (key: string, options?: { defaultValue?: string; }) => string, protocols?: Record<string, boolean>, defaultsMode?: boolean) => HostTab[]
|
||||
makeUserAdmin (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
mapCredentials (value): (res: unknown) => Credential[]
|
||||
markAdaptiveResourceUsed (value): (kind: AdaptiveTaskKind, action: string, now?: number) => void
|
||||
markTabSurfaceUsed (value): (type: TabType) => void
|
||||
maskSecrets (value): (payload: ExportPayload, pluginSecrets?: PluginSecretKeys) => ExportPayload
|
||||
moveRailItem (value): (items: RailItemDef[], id: string, beforeId: string) => string[]
|
||||
moveSplitDrag (value): (x: number, y: number) => void
|
||||
normalizeLanguageCode (value): (language?: string) => string
|
||||
notifySyncChanged (value): () => void
|
||||
openInSplit (value): (target: SplitOpenTarget, open: () => void) => void
|
||||
paletteEntriesFor (value): (all: PaletteEntryDef[], scope: "global" | "host", host?: Host) => PaletteEntryDef[]
|
||||
parseCustomKeybindings (value): (raw?: string) => CustomKeybinding[]
|
||||
parseServerTime (value): (ts: string) => Date
|
||||
pluginKey (value): { (pluginId: string, key: string): string; (pluginId: string, key: string): string; }
|
||||
pluginName (value): (t: Translator, plugin: { id: string; name: string; }) => string
|
||||
pluginSecretKeys (value): (summaries: { id: string; contributes?: { settings?: { host?: { fields?: { key: string; secretKeys?: string[]; }[]; }; }; }; }[]) => PluginSecretKeys
|
||||
pluginWsUrl (value): (pluginId: string, path: string, options?: { origin?: ConnectionOrigin; }) => Promise<WebSocketConnectionTarget>
|
||||
preloadTabSurface (value): (type: TabType) => void
|
||||
previewHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts: number; }>
|
||||
promotableIds (value): () => string[]
|
||||
protocolEnabled (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => boolean
|
||||
protocolPort (value): (pluginSettings: Record<string, Record<string, unknown>>, protocol: HostProtocolDef) => number
|
||||
quickConnectTargets (value): (all: HostActionDef[], host: Host) => HostActionDef[]
|
||||
railItemLabel (value): (id: string, t: (key: string) => string) => string
|
||||
readFromClipboard (value): () => Promise<string>
|
||||
recordHostActionPreference (value): (hostId: string, action: TabType) => void
|
||||
refreshSyncStatus (value): () => void
|
||||
registerLayout (value): (item: RegisteredLayout) => () => void
|
||||
registerTabCloseGuard (value): (tabId: string, guard: Guard) => () => void
|
||||
releaseUrlFrom (value): (info: VersionInfo) => string
|
||||
rem (value): (px: number) => string
|
||||
removeAdminStatus (value): (userId: string) => Promise<Record<string, unknown>>
|
||||
removeRoleFromUser (value): (userId: string, roleId: number) => Promise<{ success: boolean; }>
|
||||
renameCredentialFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
renameFolder (value): (oldName: string, newName: string) => Promise<Record<string, unknown>>
|
||||
reorderCredentials (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
reorderFolders (value): (positions: { name: string; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
reorderSSHHosts (value): (positions: { id: number; sortOrder: number; }[]) => Promise<{ updated: number; }>
|
||||
reportBetaFeedbackUrl (value): (termixVersion?: string, betaPlugins?: { id: string; version: string; }[]) => string
|
||||
reportCoreIssueUrl (value): (termixVersion?: string) => string
|
||||
requestFromLegacyEvent (value): (name: string, detail?: unknown) => ManageRequest
|
||||
requestManage (value): (request: ManageRequest) => void
|
||||
resetHostToDefaults (value): (hostIds: number[], reset: { all?: boolean; namespaces?: string[]; keys?: string[]; }) => Promise<void>
|
||||
resetRecentActivity (value): () => Promise<{ message: string; }>
|
||||
resetUserSecondFactors (value): (userId: string) => Promise<void>
|
||||
resolveConnectionOrigin (value): (host: OriginResolvableHost, options?: { defaultRemote?: boolean; }) => Promise<ConnectionOrigin>
|
||||
resolveHostDefaults (value): (where: { hostId?: number; folder?: string; parentHostId?: number; }, adminTargetUserId?: string) => Promise<Record<string, ResolvedHostDefault>>
|
||||
resolveHostTabType (value): (host: Host, preferredType?: TabType) => TabType
|
||||
resolveRemoteHostId (value): (syncId: string) => Promise<number>
|
||||
retrySyncErrors (value): () => Promise<SyncStatus>
|
||||
revokeAllUserSessions (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
revokeCredentialAccess (value): (credentialId: number, accessId: number) => Promise<void>
|
||||
revokeFolderAccess (value): (ruleId: number) => Promise<void>
|
||||
revokeHostAccess (value): (hostId: number, accessId: number, syncId?: string) => Promise<{ success: boolean; }>
|
||||
revokeSession (value): (sessionId: string) => Promise<{ success: boolean; message: string; }>
|
||||
rightDockableIds (value): () => string[]
|
||||
roleLabel (value): <T extends string | null | undefined>(t: Translator, displayName: T) => string | T
|
||||
runAdaptiveBackgroundTask (value): (kind: AdaptiveTaskKind, action: string, task: () => Promise<unknown>, options?: AdaptiveTaskOptions) => boolean
|
||||
runAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, options?: { enabled?: () => boolean; visible?: () => boolean; runImmediately?: boolean; random?: () => number; intervalMultiplier?: () => number; onError?: (error: unknown) => void; }) => () => void
|
||||
runHostAction (value): (action: HostActionDef, host: Host, shell: TabShellCallbacks) => void
|
||||
runVisibleInterval (value): (tick: () => void, intervalMs: number) => () => void
|
||||
sanitizeCredentialSidebarPreferences (value): (input: unknown) => CredentialSidebarPreferences
|
||||
sanitizeHostSidebarPreferences (value): (input: unknown) => HostSidebarPreferences
|
||||
saveCredentialSidebarPreferences (value): (preferences: CredentialSidebarPreferences) => Promise<void>
|
||||
saveHostDefaultsLevel (value): (target: HostDefaultsTarget, change: HostDefaultsChange) => Promise<{ changedHosts?: number; jobId?: string; }>
|
||||
saveHostSidebarPreferences (value): (preferences: HostSidebarPreferences) => Promise<void>
|
||||
saveHostTags (value): (tags: string[]) => Promise<string[]>
|
||||
saveUserPreferences (value): (prefs: Partial<UserPreferences>) => Promise<void>
|
||||
setAllPluginChannels (value): (channel: PluginChannel) => Promise<{ changed: string[]; }>
|
||||
setDeveloperMode (value): (enabled: boolean) => Promise<void>
|
||||
setHostAuthOverride (value): (hostId: number, protocol: string, credentialId: number, sharedCopySyncId?: string) => Promise<{ success: boolean; protocol: string; credentialId: number; }>
|
||||
setNotificationPrivateEndpoints (value): (hosts: string[]) => Promise<string[]>
|
||||
setPluginOptions (value): (pluginId: string, options: { autoUpdate?: boolean; pinned?: boolean; channel?: PluginChannel; }) => Promise<{ autoUpdate: boolean; pinnedVersion: string; channel: PluginChannel; }>
|
||||
setSyncStatus (value): (next: SyncStatus) => void
|
||||
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
|
||||
settleSyncConflict (value): (id: number, keep: "mine" | "server") => Promise<void>
|
||||
shareCredential (value): (credentialId: number, targets: ShareTarget[], permissionLevel: CredentialPermissionLevel, durationHours?: number) => Promise<{ success: boolean; expiresAt: string; }>
|
||||
shareFolder (value): (folder: string, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }) => Promise<{ success: boolean; expiresAt: string; hostsShared: number; hostsTotal: number; hostResults: { hostId: number; shared: boolean; reason?: string; }[]; }>
|
||||
shareHost (value): (hostId: number, shareData: { targets: ShareTarget[]; permissionLevel: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; results: { type: "role" | "user"; id: string | number; accessId: number; created: boolean; }[]; }>
|
||||
shell (value): TabShellCallbacks
|
||||
splitDefaultKey (value): (fullKey: string) => [string, string]
|
||||
sshHostToHost (value): (h: SSHHostWithStatus) => Host
|
||||
syncNow (value): () => Promise<SyncStatus>
|
||||
tabIcon (value): (type: TabType) => Element
|
||||
tabJumpHotkeyKeys (value): () => string[]
|
||||
takePendingManageRequest (value): () => ManageRequest
|
||||
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
|
||||
unlinkServer (value): (keepData: boolean) => Promise<SyncStatus>
|
||||
updateAuditForwarding (value): (url: string, token?: string) => Promise<{ url: string; hasToken: boolean; }>
|
||||
updateBranding (value): (update: BrandingSettingsUpdate) => Promise<BrandingSettings>
|
||||
updateCredential (value): (credentialId: number, credentialData: Record<string, unknown>) => Promise<Record<string, unknown>>
|
||||
updateExternalAutoProvision (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateFolderMetadata (value): (name: string, color?: string, icon?: string, credentialId?: number, localOnly?: boolean) => Promise<void>
|
||||
updateHostAccess (value): (hostId: number, accessId: number, update: { permissionLevel?: SharePermissionLevel; durationHours?: number; }, syncId?: string) => Promise<{ success: boolean; expiresAt: string; }>
|
||||
updateLogLevel (value): (level: string) => Promise<void>
|
||||
updatePasswordLoginAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||
updatePasswordResetAllowed (value): (allowed: boolean) => Promise<{ allowed: boolean; }>
|
||||
updatePluginAdminSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||
updatePluginHostSettings (value): (pluginId: string, hostId: number, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||
updatePluginUserSettings (value): (pluginId: string, values: PluginSettingsValues) => Promise<PluginSettingsValues>
|
||||
updateRegistrationAllowed (value): (allowed: boolean) => Promise<Record<string, unknown>>
|
||||
updateRole (value): (roleId: number, roleData: { displayName?: string; description?: string; permissions?: string[]; }) => Promise<{ role: Role; }>
|
||||
updateSSHHost (value): (hostId: number, hostData: HostData) => Promise<Host>
|
||||
updateSecondFactorAfterExternalLogin (value): (enabled: boolean) => Promise<Record<string, unknown>>
|
||||
updateSessionTimeout (value): (timeoutHours: number) => Promise<void>
|
||||
updateStatusCheckSettings (value): (settings: { statusCheckInterval: number; }) => Promise<void>
|
||||
updateSyncSettings (value): (settings: { disabledTypes?: string[]; customHeaders?: SyncProxyHeader[]; basicAuth?: SyncBasicAuth; allowInvalidCertificate?: boolean; }) => Promise<SyncStatus>
|
||||
uploadTlsCertificate (value): (payload: { certificate: string; privateKey: string; }) => Promise<TlsStatus & { success: boolean; reloadMessage?: string; }>
|
||||
usageBarColor (value): (percent: number) => string
|
||||
usageColor (value): (percent: number) => string
|
||||
useActionSlot (value): (slotId: string, context?: Record<string, unknown>) => SlotContribution[]
|
||||
useAdaptivePolling (value): (poll: () => AdaptivePollResult | Promise<AdaptivePollResult>, policy: AdaptivePollingPolicy, enabled?: boolean, options?: { runImmediately?: boolean; onError?: (error: unknown) => void; }) => void
|
||||
useAppTheme (value): () => ThemeProviderState
|
||||
useAreaPreferences (value): <A extends UiAreaKey>(area: A) => UiAreaPreferences[A]
|
||||
useBranding (value): () => BrandingContextValue
|
||||
useChooseTheme (value): () => (themeId: string) => void
|
||||
useConfirm (value): () => (options: ConfirmOptions) => Promise<boolean>
|
||||
useConnectionGate (value): () => ConnectionGateController
|
||||
useConnectionLog (value): () => ConnectionLogContextType
|
||||
useDesktopUpdate (value): () => DesktopUpdateState
|
||||
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
|
||||
useHostActions (value): () => HostActionDef[]
|
||||
useHostBadges (value): () => HostBadgeDef[]
|
||||
useHostContextMenuItems (value): () => HostContextMenuItemDef[]
|
||||
useHostEditorSections (value): () => HostEditorSectionDef[]
|
||||
useHostProtocols (value): () => HostProtocolDef[]
|
||||
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
|
||||
useInstalledPlugins (value): () => PluginSummary[]
|
||||
useIsDefaultsEditor (value): () => boolean
|
||||
useIsMobile (value): () => boolean
|
||||
useKeybindingActions (value): () => KeybindingActionDef[]
|
||||
useKeybindingDefaults (value): () => KeybindingDefaultDef[]
|
||||
useOptionalConnectionLog (value): () => ConnectionLogContextType
|
||||
usePageVisibleInterval (value): (callback: () => void, intervalMs: number, enabled?: boolean, options?: Options) => void
|
||||
usePaletteEntries (value): () => PaletteEntryDef[]
|
||||
usePaletteGroups (value): () => PaletteGroupDef[]
|
||||
usePanels (value): () => PanelDef[]
|
||||
usePermissions (value): () => PermissionsState
|
||||
usePluginDocsUrl (value): (pluginId: string, page?: string, anchor?: string) => string
|
||||
usePluginHostSections (value): () => PluginSummary[]
|
||||
usePluginStore (value): () => StoreState
|
||||
useRailItems (value): () => RailItemDef[]
|
||||
useRegisteredDashboardCards (value): () => RegisteredDashboardCard[]
|
||||
useServerStatus (value): () => ServerStatusContextType
|
||||
useServerStatusMeta (value): () => { initialLoadComplete: boolean; }
|
||||
useShellController (value): () => ShellController
|
||||
useSplitTargets (value): () => SplitTargetsSnapshot
|
||||
useSshAuthEditors (value): () => SshAuthEditorDef[]
|
||||
useSshAuthProviders (value): () => { providers: SshAuthTypeOption[]; loaded: boolean; find: (type: string) => SshAuthTypeOption; }
|
||||
useStatusColorScheme (value): () => StatusColorScheme
|
||||
useSurfaceKind (value): () => SurfaceKind
|
||||
useSyncStatus (value): () => any
|
||||
useTabHandlesVersion (value): () => number
|
||||
useTabs (value): () => TabContextType
|
||||
useTabsSafe (value): () => TabContextType
|
||||
useTheme (value): () => ThemeProviderState
|
||||
useThemeOptions (value): () => ThemeOption[]
|
||||
useUiPreference (value): <A extends UiAreaKey, K extends keyof UiAreaPreferences[A]>(area: A, key: K) => UiAreaPreferences[A][K]
|
||||
useUiPreferencesContext (value): () => UiPreferencesContextValue
|
||||
waitForDefaultsJob (value): (id: string, intervalMs?: number, maxWaitMs?: number) => Promise<number>
|
||||
withProtocolSettings (value): (pluginSettings: Record<string, Record<string, unknown>>, flags: HostProtocols, list?: HostProtocolDef[]) => Record<string, Record<string, unknown>>
|
||||
@@ -416,3 +416,71 @@ describe("rebuilding local plugins", () => {
|
||||
expect(run.sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("default look", () => {
|
||||
const entry = { source: "tmxplug", path: "a", sha256: SHA };
|
||||
|
||||
it("reads the defaultLook flag and refuses anything but a boolean", () => {
|
||||
const { plugins, problems } = parseBundledPlugins({
|
||||
plugins: [
|
||||
{ ...entry, id: "look", defaultLook: true },
|
||||
{ ...entry, id: "plain" },
|
||||
{ ...entry, id: "odd", defaultLook: "yes" },
|
||||
],
|
||||
});
|
||||
expect(problems).toEqual(["odd defaultLook must be a boolean"]);
|
||||
expect(
|
||||
plugins.map((p: { id: string; defaultLook: boolean }) => [
|
||||
p.id,
|
||||
p.defaultLook,
|
||||
]),
|
||||
).toEqual([
|
||||
["look", true],
|
||||
["plain", false],
|
||||
]);
|
||||
});
|
||||
|
||||
it("carries it into the bundled index and through a re-pin", () => {
|
||||
expect(
|
||||
buildBundledIndex(
|
||||
[
|
||||
{ id: "look", onboarding: null, defaultLook: true },
|
||||
{ id: "plain", onboarding: null, defaultLook: false },
|
||||
],
|
||||
["look", "plain"],
|
||||
),
|
||||
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
|
||||
|
||||
const index = {
|
||||
plugins: [
|
||||
{
|
||||
id: "look",
|
||||
versions: [
|
||||
{ version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(
|
||||
pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0],
|
||||
).toMatchObject({ id: "look", defaultLook: true });
|
||||
});
|
||||
|
||||
it("copies a local build's theme files", () => {
|
||||
const repo = tempDir();
|
||||
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
|
||||
fs.mkdirSync(path.join(repo, "themes"), { recursive: true });
|
||||
fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}");
|
||||
fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}");
|
||||
fs.writeFileSync(
|
||||
path.join(repo, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "inks",
|
||||
contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] },
|
||||
}),
|
||||
);
|
||||
const out = path.join(tempDir(), "inks");
|
||||
copyLocalBuild(repo, out);
|
||||
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,8 @@ const OFFICIAL_IDS = [
|
||||
"ai",
|
||||
"alerts",
|
||||
"automations",
|
||||
"classic-layout",
|
||||
"classic-themes",
|
||||
"docker",
|
||||
"file-manager",
|
||||
"fleets",
|
||||
|
||||
@@ -19,8 +19,13 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
|
||||
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||
|
||||
const ALLOWED = [
|
||||
"ACCENT_PRESET_COLORS",
|
||||
"APP_VERSION",
|
||||
"AccessRecord",
|
||||
"ActionSlot",
|
||||
"ActiveSessionInfo",
|
||||
"AddButton",
|
||||
"AdminSection",
|
||||
"Alert",
|
||||
"AlertDescription",
|
||||
"AlertDialog",
|
||||
@@ -35,14 +40,23 @@ const ALLOWED = [
|
||||
"AlertDialogTitle",
|
||||
"AlertDialogTrigger",
|
||||
"AlertTitle",
|
||||
"ApiKey",
|
||||
"AuditLog",
|
||||
"AuditLogFilters",
|
||||
"AuthEnrollmentSections",
|
||||
"AuthOverrideProtocol",
|
||||
"BackButton",
|
||||
"Badge",
|
||||
"BarSeries",
|
||||
"BarSeriesItem",
|
||||
"BarSeriesProps",
|
||||
"BrandingSettings",
|
||||
"BrowserSignInDialog",
|
||||
"Button",
|
||||
"ButtonProps",
|
||||
"CORE_HOST_DEFAULTS",
|
||||
"CORE_NAMESPACE",
|
||||
"CREDENTIAL_ROW_FIELD_KEYS",
|
||||
"Card",
|
||||
"CardAction",
|
||||
"CardContent",
|
||||
@@ -54,6 +68,12 @@ const ALLOWED = [
|
||||
"CardTitle",
|
||||
"Checkbox",
|
||||
"ColumnCountStepper",
|
||||
"Command",
|
||||
"CommandEmpty",
|
||||
"CommandGroup",
|
||||
"CommandItem",
|
||||
"CommandList",
|
||||
"CommandPrimitive",
|
||||
"ComponentSlot",
|
||||
"ConfirmOptions",
|
||||
"ConnectionGate",
|
||||
@@ -66,9 +86,26 @@ const ALLOWED = [
|
||||
"ConnectionStage",
|
||||
"ConnectionStatus",
|
||||
"ConnectionUnavailable",
|
||||
"CreatedApiKey",
|
||||
"Credential",
|
||||
"CredentialDensity",
|
||||
"CredentialPermissionLevel",
|
||||
"CredentialRowFields",
|
||||
"CredentialSidebarFilterState",
|
||||
"CredentialSidebarPreferences",
|
||||
"CredentialSortKey",
|
||||
"CredentialTrayTrigger",
|
||||
"CustomKeybinding",
|
||||
"DASHBOARD_CARDS",
|
||||
"DEFAULT_FOLDER_COLOR",
|
||||
"DEFAULT_FOLDER_ICON",
|
||||
"DashboardCardId",
|
||||
"DataColumn",
|
||||
"DataView",
|
||||
"DefaultOverrides",
|
||||
"DefaultsOnly",
|
||||
"DesktopUpdateMode",
|
||||
"DesktopUpdateState",
|
||||
"Dialog",
|
||||
"DialogClose",
|
||||
"DialogContent",
|
||||
@@ -81,6 +118,7 @@ const ALLOWED = [
|
||||
"DialogTrigger",
|
||||
"DocsLink",
|
||||
"DocsLinkProps",
|
||||
"DocsPage",
|
||||
"DropdownMenu",
|
||||
"DropdownMenuCheckboxItem",
|
||||
"DropdownMenuContent",
|
||||
@@ -98,11 +136,17 @@ const ALLOWED = [
|
||||
"DropdownMenuTrigger",
|
||||
"EditingWidth",
|
||||
"EmptyState",
|
||||
"ExportPayload",
|
||||
"FOLDER_COLORS",
|
||||
"FONT_SIZES",
|
||||
"Facts",
|
||||
"FakeSwitch",
|
||||
"Field",
|
||||
"FieldGroup",
|
||||
"FieldPair",
|
||||
"FolderAccessRule",
|
||||
"FolderIconEl",
|
||||
"FontSizeId",
|
||||
"FormFooter",
|
||||
"FullScreenAppPhase",
|
||||
"FullScreenAppWrapper",
|
||||
@@ -112,11 +156,33 @@ const ALLOWED = [
|
||||
"GridLayout",
|
||||
"GridSlot",
|
||||
"GroupHeading",
|
||||
"HexColorPicker",
|
||||
"Host",
|
||||
"HostActionDef",
|
||||
"HostClickBehavior",
|
||||
"HostData",
|
||||
"HostDefaultBadge",
|
||||
"HostDefaultField",
|
||||
"HostDefaultsChange",
|
||||
"HostDefaultsContext",
|
||||
"HostDefaultsContextValue",
|
||||
"HostDefaultsLevel",
|
||||
"HostDefaultsTarget",
|
||||
"HostDensity",
|
||||
"HostFeatureFields",
|
||||
"HostFeatureFieldsProps",
|
||||
"HostFolder",
|
||||
"HostGroupKey",
|
||||
"HostKeyVerificationDialog",
|
||||
"HostOnly",
|
||||
"HostProtocolAuthInput",
|
||||
"HostProtocolDef",
|
||||
"HostProtocols",
|
||||
"HostRowFields",
|
||||
"HostSidebarFilterState",
|
||||
"HostSidebarPreferences",
|
||||
"HostTrayTrigger",
|
||||
"IconPicker",
|
||||
"InlineView",
|
||||
"Input",
|
||||
"Kbd",
|
||||
@@ -124,10 +190,14 @@ const ALLOWED = [
|
||||
"KbdSeparator",
|
||||
"KeyCombo",
|
||||
"KeybindingAction",
|
||||
"KeybindingActionDef",
|
||||
"KeybindingActionType",
|
||||
"LANGUAGES",
|
||||
"Label",
|
||||
"LineChart",
|
||||
"LineChartSeries",
|
||||
"LinkServerDialog",
|
||||
"LinkedAccountInfo",
|
||||
"ListBadge",
|
||||
"ListRow",
|
||||
"ListRowAction",
|
||||
@@ -135,7 +205,12 @@ const ALLOWED = [
|
||||
"ListRowTone",
|
||||
"LogContext",
|
||||
"LogEntry",
|
||||
"LookSettings",
|
||||
"MANAGE_REQUEST_EVENT",
|
||||
"MAX_PANES",
|
||||
"MFAPromptMode",
|
||||
"ManageMode",
|
||||
"ManageRequest",
|
||||
"ManagerCardError",
|
||||
"ManagerCardShell",
|
||||
"ManagerSearch",
|
||||
@@ -143,11 +218,14 @@ const ALLOWED = [
|
||||
"MetricCard",
|
||||
"MiniStat",
|
||||
"NumberField",
|
||||
"OPEN_PLUGINS_EVENT",
|
||||
"OpenTabRecord",
|
||||
"PANEL",
|
||||
"PanePrompt",
|
||||
"PROMPT_BUTTON",
|
||||
"PROMPT_DESTRUCTIVE_BUTTON",
|
||||
"PROMPT_PRIMARY_BUTTON",
|
||||
"PaletteItemDef",
|
||||
"PanePrompt",
|
||||
"PanelDensity",
|
||||
"PanelList",
|
||||
"PanelSearch",
|
||||
@@ -155,18 +233,35 @@ const ALLOWED = [
|
||||
"PanelViewMode",
|
||||
"PassphraseDialog",
|
||||
"PasswordInput",
|
||||
"PermissionCatalogEntry",
|
||||
"PluginComponent",
|
||||
"PluginIcon",
|
||||
"PluginSettingsErrors",
|
||||
"PluginSettingsField",
|
||||
"PluginSettingsValidationError",
|
||||
"PluginSummary",
|
||||
"PluginViewPlaceholder",
|
||||
"Popover",
|
||||
"PopoverAnchor",
|
||||
"PopoverContent",
|
||||
"PopoverTrigger",
|
||||
"ProxyNode",
|
||||
"ProxySettingsFields",
|
||||
"RadialGauge",
|
||||
"RadialGaugeProps",
|
||||
"RailGroup",
|
||||
"RailItemDef",
|
||||
"RecentActivityItem",
|
||||
"ReleaseInfo",
|
||||
"Repeater",
|
||||
"ResolvedHostDefault",
|
||||
"RobustClipboardProvider",
|
||||
"Role",
|
||||
"SSHAuthDialog",
|
||||
"SSHHost",
|
||||
"SSHHostData",
|
||||
"SSHHostWithStatus",
|
||||
"SSH_AUTH_PROTOCOL",
|
||||
"ScrollArea",
|
||||
"ScrollBar",
|
||||
"SecretField",
|
||||
@@ -186,6 +281,9 @@ const ALLOWED = [
|
||||
"SelectValue",
|
||||
"Separator",
|
||||
"SettingRow",
|
||||
"SettingsFieldRow",
|
||||
"SharePermissionLevel",
|
||||
"ShareTarget",
|
||||
"Sheet",
|
||||
"SheetClose",
|
||||
"SheetContent",
|
||||
@@ -194,74 +292,384 @@ const ALLOWED = [
|
||||
"SheetHeader",
|
||||
"SheetTitle",
|
||||
"SheetTrigger",
|
||||
"ShellConfirmCapture",
|
||||
"Skeleton",
|
||||
"Slider",
|
||||
"SortKey",
|
||||
"Sparkline",
|
||||
"SparklineProps",
|
||||
"SplitLayoutMenu",
|
||||
"SplitOpenTarget",
|
||||
"SplitSummary",
|
||||
"StatRow",
|
||||
"StatusValue",
|
||||
"SurfaceKind",
|
||||
"SurfaceScope",
|
||||
"Switch",
|
||||
"SwitchRow",
|
||||
"SyncBasicAuth",
|
||||
"SyncConflictItem",
|
||||
"SyncEntityInfo",
|
||||
"SyncErrorItem",
|
||||
"SyncProxyHeader",
|
||||
"SyncStatus",
|
||||
"TOTPDialog",
|
||||
"Tab",
|
||||
"TabShellCallbacks",
|
||||
"TabSplitAction",
|
||||
"TabStage",
|
||||
"TabStrip",
|
||||
"TabStripItem",
|
||||
"TabType",
|
||||
"TagInput",
|
||||
"TextAreaField",
|
||||
"TextField",
|
||||
"Textarea",
|
||||
"TextareaProps",
|
||||
"ThemeId",
|
||||
"TlsStatus",
|
||||
"ToolsTab",
|
||||
"Tooltip",
|
||||
"TooltipContent",
|
||||
"TooltipProvider",
|
||||
"TooltipTrigger",
|
||||
"UI_AREA_KEYS",
|
||||
"UI_FONTS",
|
||||
"UiAreaKey",
|
||||
"UiFontId",
|
||||
"UiPreset",
|
||||
"UpdateChannelState",
|
||||
"UptimeInfo",
|
||||
"UsagePair",
|
||||
"UserPreferences",
|
||||
"UserRole",
|
||||
"UserSecondFactorSummary",
|
||||
"VersionBadge",
|
||||
"VersionInfo",
|
||||
"ViewToggle",
|
||||
"WebSocketConnectionTarget",
|
||||
"WidgetTitle",
|
||||
"actionOrder",
|
||||
"activityTarget",
|
||||
"adminCreateUser",
|
||||
"adminCreateUserCredential",
|
||||
"adminCreateUserHost",
|
||||
"adminDeleteUserCredential",
|
||||
"adminDeleteUserHost",
|
||||
"adminExportUserData",
|
||||
"adminGetHostPassword",
|
||||
"adminGetUserCredentials",
|
||||
"adminGetUserHosts",
|
||||
"adminResetUserPassword",
|
||||
"adminUpdateUserCredential",
|
||||
"adminUpdateUserHost",
|
||||
"applyAccentColor",
|
||||
"applyFontSize",
|
||||
"applyPresetSideEffects",
|
||||
"applyUiFont",
|
||||
"assignRoleToUser",
|
||||
"beginSplitDrag",
|
||||
"buildExportPayload",
|
||||
"buildOriginWsUrl",
|
||||
"bulkImportSSHHosts",
|
||||
"bulkUpdateSSHHosts",
|
||||
"canConfigureUpdates",
|
||||
"canJoinSplit",
|
||||
"changeAppLanguage",
|
||||
"changePassword",
|
||||
"clearLocalAdaptivePreferences",
|
||||
"cn",
|
||||
"copyToClipboard",
|
||||
"createApiKey",
|
||||
"createCredential",
|
||||
"createFrontendLogger",
|
||||
"createQuickConnectHost",
|
||||
"createRole",
|
||||
"createSSHHost",
|
||||
"defaultConnectAction",
|
||||
"defaultCredentialRowFields",
|
||||
"defaultCredentialSidebarPreferences",
|
||||
"defaultHostRowFields",
|
||||
"defaultHostSidebarPreferences",
|
||||
"defaultValuesEqual",
|
||||
"deleteAccount",
|
||||
"deleteAllHostsInFolder",
|
||||
"deleteApiKey",
|
||||
"deleteCredential",
|
||||
"deleteOpenTab",
|
||||
"deleteRole",
|
||||
"deleteSSHHost",
|
||||
"deleteUser",
|
||||
"deployCredentialToHost",
|
||||
"desktopUpdates",
|
||||
"docsUrl",
|
||||
"duplicateCredential",
|
||||
"enabledHostProtocols",
|
||||
"endSplitDrag",
|
||||
"exportAllSSHHosts",
|
||||
"filterPaletteItems",
|
||||
"findCoreHostDefault",
|
||||
"findMatchingKeybinding",
|
||||
"generateKeyPair",
|
||||
"generatePublicKeyFromPrivate",
|
||||
"getActiveSessions",
|
||||
"getAdaptiveResourceBudget",
|
||||
"getApiKeys",
|
||||
"getAuditForwarding",
|
||||
"getAuditLogActions",
|
||||
"getAuditLogs",
|
||||
"getBasePath",
|
||||
"getBranding",
|
||||
"getChannelReleases",
|
||||
"getConnectedRemoteApi",
|
||||
"getCredentialAccess",
|
||||
"getCredentialDetails",
|
||||
"getCredentialSidebarPreferences",
|
||||
"getCredentials",
|
||||
"getDatabaseHealth",
|
||||
"getDatabaseTransferUrl",
|
||||
"getDefaultConnectionTab",
|
||||
"getDeveloperMode",
|
||||
"getDeviceId",
|
||||
"getErrorMessage",
|
||||
"getExternalAutoProvision",
|
||||
"getFolderAccess",
|
||||
"getFolderDefaultsId",
|
||||
"getHostAccess",
|
||||
"getHostAuthOverride",
|
||||
"getHostDefaultsLevel",
|
||||
"getHostEditorSection",
|
||||
"getHostPassword",
|
||||
"getHostSidebarPreferences",
|
||||
"getHostTags",
|
||||
"getLiveHostStatus",
|
||||
"getLogLevel",
|
||||
"getNotificationPrivateEndpoints",
|
||||
"getPanel",
|
||||
"getPasswordLoginAllowed",
|
||||
"getPasswordResetAllowed",
|
||||
"getPermissionsCatalog",
|
||||
"getPluginAdminSettings",
|
||||
"getPluginHostSettings",
|
||||
"getPluginUserSettings",
|
||||
"getPlugins",
|
||||
"getPollingEnvironmentMultiplier",
|
||||
"getPreferredHostAction",
|
||||
"getRecentActivity",
|
||||
"getRegisteredDashboardCard",
|
||||
"getRegistrationAllowed",
|
||||
"getRoles",
|
||||
"getSSHFolders",
|
||||
"getSSHHosts",
|
||||
"getSecondFactorAfterExternalLogin",
|
||||
"getSessionTimeout",
|
||||
"getSessionTimeoutMinutes",
|
||||
"getSessions",
|
||||
"getStatusCheckSettings",
|
||||
"getStatusClasses",
|
||||
"getSyncConflicts",
|
||||
"getSyncErrors",
|
||||
"getTabType",
|
||||
"getTlsStatus",
|
||||
"getUpdateChannel",
|
||||
"getUptime",
|
||||
"getUserInfo",
|
||||
"getUserList",
|
||||
"getUserPreferences",
|
||||
"getUserRoles",
|
||||
"getUserSecondFactors",
|
||||
"getVersionInfo",
|
||||
"globalShortcutHandler",
|
||||
"groupRailItems",
|
||||
"hasVisibleFields",
|
||||
"hostActionsFor",
|
||||
"hostBadgesFor",
|
||||
"hostKey",
|
||||
"hostMenuItemsFor",
|
||||
"hostProtocolFlags",
|
||||
"hydrateLocalSharedHostAuth",
|
||||
"importSSHConfigHosts",
|
||||
"invalidateHostsAndStatusCaches",
|
||||
"invalidateServerStatusCache",
|
||||
"isCoreRailView",
|
||||
"isElectron",
|
||||
"isFieldShown",
|
||||
"isMacPlatform",
|
||||
"isPersistentTabType",
|
||||
"isPrerelease",
|
||||
"isSessionTabType",
|
||||
"isSplitDragging",
|
||||
"isSplitTab",
|
||||
"isTabJumpHotkey",
|
||||
"linkExternalToPasswordAccount",
|
||||
"linkedServerUrl",
|
||||
"listHostActions",
|
||||
"listHostProtocols",
|
||||
"loadPaletteGroup",
|
||||
"logoutUser",
|
||||
"makeHostSshSubTabs",
|
||||
"makeHostTabs",
|
||||
"makeUserAdmin",
|
||||
"mapCredentials",
|
||||
"markAdaptiveResourceUsed",
|
||||
"markTabSurfaceUsed",
|
||||
"maskSecrets",
|
||||
"moveRailItem",
|
||||
"moveSplitDrag",
|
||||
"normalizeLanguageCode",
|
||||
"notifySyncChanged",
|
||||
"openInSplit",
|
||||
"paletteEntriesFor",
|
||||
"parseCustomKeybindings",
|
||||
"parseServerTime",
|
||||
"pluginKey",
|
||||
"pluginName",
|
||||
"pluginSecretKeys",
|
||||
"pluginWsUrl",
|
||||
"preloadTabSurface",
|
||||
"previewHostDefaultsLevel",
|
||||
"promotableIds",
|
||||
"protocolEnabled",
|
||||
"protocolPort",
|
||||
"quickConnectTargets",
|
||||
"railItemLabel",
|
||||
"readFromClipboard",
|
||||
"recordHostActionPreference",
|
||||
"refreshSyncStatus",
|
||||
"registerLayout",
|
||||
"registerTabCloseGuard",
|
||||
"releaseUrlFrom",
|
||||
"rem",
|
||||
"removeAdminStatus",
|
||||
"removeRoleFromUser",
|
||||
"renameCredentialFolder",
|
||||
"renameFolder",
|
||||
"reorderCredentials",
|
||||
"reorderFolders",
|
||||
"reorderSSHHosts",
|
||||
"reportBetaFeedbackUrl",
|
||||
"reportCoreIssueUrl",
|
||||
"requestFromLegacyEvent",
|
||||
"requestManage",
|
||||
"resetHostToDefaults",
|
||||
"resetRecentActivity",
|
||||
"resetUserSecondFactors",
|
||||
"resolveConnectionOrigin",
|
||||
"resolveHostDefaults",
|
||||
"resolveHostTabType",
|
||||
"resolveRemoteHostId",
|
||||
"retrySyncErrors",
|
||||
"revokeAllUserSessions",
|
||||
"revokeCredentialAccess",
|
||||
"revokeFolderAccess",
|
||||
"revokeHostAccess",
|
||||
"revokeSession",
|
||||
"rightDockableIds",
|
||||
"roleLabel",
|
||||
"runAdaptiveBackgroundTask",
|
||||
"runAdaptivePolling",
|
||||
"runHostAction",
|
||||
"runVisibleInterval",
|
||||
"sanitizeCredentialSidebarPreferences",
|
||||
"sanitizeHostSidebarPreferences",
|
||||
"saveCredentialSidebarPreferences",
|
||||
"saveHostDefaultsLevel",
|
||||
"saveHostSidebarPreferences",
|
||||
"saveHostTags",
|
||||
"saveUserPreferences",
|
||||
"setAllPluginChannels",
|
||||
"setDeveloperMode",
|
||||
"setHostAuthOverride",
|
||||
"setNotificationPrivateEndpoints",
|
||||
"setPluginOptions",
|
||||
"setSyncStatus",
|
||||
"setUpdateChannel",
|
||||
"settleSyncConflict",
|
||||
"shareCredential",
|
||||
"shareFolder",
|
||||
"shareHost",
|
||||
"shell",
|
||||
"splitDefaultKey",
|
||||
"sshHostToHost",
|
||||
"syncNow",
|
||||
"tabIcon",
|
||||
"tabJumpHotkeyKeys",
|
||||
"takePendingManageRequest",
|
||||
"unlinkExternalFromPasswordAccount",
|
||||
"unlinkServer",
|
||||
"updateAuditForwarding",
|
||||
"updateBranding",
|
||||
"updateCredential",
|
||||
"updateExternalAutoProvision",
|
||||
"updateFolderMetadata",
|
||||
"updateHostAccess",
|
||||
"updateLogLevel",
|
||||
"updatePasswordLoginAllowed",
|
||||
"updatePasswordResetAllowed",
|
||||
"updatePluginAdminSettings",
|
||||
"updatePluginHostSettings",
|
||||
"updatePluginUserSettings",
|
||||
"updateRegistrationAllowed",
|
||||
"updateRole",
|
||||
"updateSSHHost",
|
||||
"updateSecondFactorAfterExternalLogin",
|
||||
"updateSessionTimeout",
|
||||
"updateStatusCheckSettings",
|
||||
"updateSyncSettings",
|
||||
"uploadTlsCertificate",
|
||||
"usageBarColor",
|
||||
"usageColor",
|
||||
"useActionSlot",
|
||||
"useAdaptivePolling",
|
||||
"useAppTheme",
|
||||
"useAreaPreferences",
|
||||
"useBranding",
|
||||
"useChooseTheme",
|
||||
"useConfirm",
|
||||
"useConnectionGate",
|
||||
"useConnectionLog",
|
||||
"useDesktopUpdate",
|
||||
"useEditorDefaultKeys",
|
||||
"useHostActions",
|
||||
"useHostBadges",
|
||||
"useHostContextMenuItems",
|
||||
"useHostEditorSections",
|
||||
"useHostProtocols",
|
||||
"useHostStatus",
|
||||
"useInstalledPlugins",
|
||||
"useIsDefaultsEditor",
|
||||
"useIsMobile",
|
||||
"useKeybindingActions",
|
||||
"useKeybindingDefaults",
|
||||
"useOptionalConnectionLog",
|
||||
"usePageVisibleInterval",
|
||||
"usePaletteEntries",
|
||||
"usePaletteGroups",
|
||||
"usePanels",
|
||||
"usePermissions",
|
||||
"usePluginDocsUrl",
|
||||
"usePluginHostSections",
|
||||
"usePluginStore",
|
||||
"useRailItems",
|
||||
"useRegisteredDashboardCards",
|
||||
"useServerStatus",
|
||||
"useServerStatusMeta",
|
||||
"useShellController",
|
||||
"useSplitTargets",
|
||||
"useSshAuthEditors",
|
||||
"useSshAuthProviders",
|
||||
"useStatusColorScheme",
|
||||
"useSurfaceKind",
|
||||
"useSyncStatus",
|
||||
"useTabHandlesVersion",
|
||||
"useTabs",
|
||||
"useTabsSafe",
|
||||
"useTheme",
|
||||
"useThemeOptions",
|
||||
"useUiPreference",
|
||||
"useUiPreferencesContext",
|
||||
"waitForDefaultsJob",
|
||||
"withProtocolSettings",
|
||||
];
|
||||
|
||||
/** Every name a module exports, resolved the way the compiler sees it. */
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Builds src/ui/settings/settings-search-index.ts: the i18n keys each core
|
||||
* settings section shows, so the Settings search can match on any label in
|
||||
* a section without mounting it. Run it after changing a settings section.
|
||||
*
|
||||
* --check fails when the committed file is out of date.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar");
|
||||
const OUT = path.join(
|
||||
ROOT,
|
||||
"src",
|
||||
"ui",
|
||||
"settings",
|
||||
"settings-search-index.ts",
|
||||
);
|
||||
|
||||
function flatKeys(obj, prefix = "") {
|
||||
return Object.entries(obj).flatMap(([key, value]) =>
|
||||
typeof value === "object"
|
||||
? flatKeys(value, `${prefix}${key}.`)
|
||||
: [`${prefix}${key}`],
|
||||
);
|
||||
}
|
||||
|
||||
const EN_KEYS = new Set(
|
||||
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
|
||||
);
|
||||
|
||||
// t("x", { count }) reads x_one or x_other, so index the plural key.
|
||||
function realKey(key) {
|
||||
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
|
||||
? key
|
||||
: `${key}_other`;
|
||||
}
|
||||
|
||||
function keysIn(text) {
|
||||
return [
|
||||
...new Set(
|
||||
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(path.join(SIDEBAR, file), "utf8");
|
||||
}
|
||||
|
||||
/** The body of one exported or local function, by name. */
|
||||
function functionBody(source, name) {
|
||||
const start = source.search(new RegExp(`function ${name}\\b`));
|
||||
if (start < 0) throw new Error(`No function ${name}`);
|
||||
const next = source.slice(start + 1).search(/\n(export )?function \w+/);
|
||||
return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
|
||||
}
|
||||
|
||||
/** Profile sections, split on their AccordionSection ids. */
|
||||
function profileSections() {
|
||||
const source = read("UserProfilePanel.tsx");
|
||||
const lines = source.split("\n");
|
||||
const starts = [];
|
||||
lines.forEach((line, i) => {
|
||||
if (/^\s{6}<AccordionSection\b/.test(line)) starts.push(i);
|
||||
});
|
||||
const out = {};
|
||||
starts.forEach((start, k) => {
|
||||
const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
|
||||
const chunk = lines.slice(start, end).join("\n");
|
||||
const id = (chunk.match(/\bid="([^"]+)"/) || [])[1];
|
||||
if (id) out[id] = keysIn(chunk);
|
||||
});
|
||||
out.interface = [
|
||||
...new Set([
|
||||
...(out.interface ?? []),
|
||||
...keysIn(read("InterfacePresetSettings.tsx")),
|
||||
]),
|
||||
];
|
||||
out.security = [
|
||||
...new Set([
|
||||
...(out.security ?? []),
|
||||
...keysIn(read("AuthEnrollmentSections.tsx")),
|
||||
...keysIn(functionBody(source, "PasswordChangeSection")),
|
||||
]),
|
||||
];
|
||||
out["api-keys"] = [
|
||||
...new Set([
|
||||
...(out["api-keys"] ?? []),
|
||||
...keysIn(functionBody(source, "NewApiKeyDialog")),
|
||||
]),
|
||||
];
|
||||
out.keybindings = keysIn(
|
||||
fs.readFileSync(
|
||||
path.join(ROOT, "src", "ui", "settings", "KeybindingsSettings.tsx"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
function adminSections() {
|
||||
const settings = read("AdminSettingsSections.tsx");
|
||||
const management = read("AdminManagementSections.tsx");
|
||||
return {
|
||||
"admin-general": [
|
||||
...new Set([
|
||||
...keysIn(functionBody(settings, "AdminGeneralSettingsSection")),
|
||||
...keysIn(read("AdminPluginDeveloperMode.tsx")),
|
||||
]),
|
||||
],
|
||||
"admin-users": [
|
||||
...new Set([
|
||||
...keysIn(functionBody(management, "AdminUsersSection")),
|
||||
...keysIn(read("AdminUserDialogs.tsx")),
|
||||
]),
|
||||
],
|
||||
"admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")),
|
||||
"admin-roles": keysIn(functionBody(management, "AdminRolesSection")),
|
||||
"admin-host-defaults": [
|
||||
...new Set([
|
||||
...keysIn(functionBody(settings, "AdminHostDefaultsSection")),
|
||||
...keysIn(read("AdminHostTags.tsx")),
|
||||
]),
|
||||
],
|
||||
"admin-branding": keysIn(read("AdminBrandingSection.tsx")),
|
||||
"admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")),
|
||||
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
|
||||
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
|
||||
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
|
||||
updates: [
|
||||
...new Set(
|
||||
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
|
||||
keysIn(
|
||||
fs.readFileSync(
|
||||
path.join(ROOT, "src", "ui", "settings", file),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function render() {
|
||||
const index = { ...profileSections(), ...adminSections() };
|
||||
const body = Object.entries(index)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(
|
||||
([id, keys]) =>
|
||||
` ${JSON.stringify(id)}: [\n${keys
|
||||
.sort()
|
||||
.map((key) => ` ${JSON.stringify(key)},`)
|
||||
.join("\n")}\n ],`,
|
||||
)
|
||||
.join("\n");
|
||||
return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {\n${body}\n};\n`;
|
||||
}
|
||||
|
||||
const next = render();
|
||||
if (process.argv.includes("--check")) {
|
||||
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, "utf8") : "";
|
||||
if (current !== next) {
|
||||
console.error(
|
||||
"src/ui/settings/settings-search-index.ts is out of date. Run: node scripts/generate-settings-search-index.cjs",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(OUT, next);
|
||||
console.log(`Wrote ${path.relative(ROOT, OUT)}`);
|
||||
}
|
||||
@@ -15,6 +15,11 @@
|
||||
* enabledByEnv counts the plugin as recommended when any of those
|
||||
* environment variables is set, for a server configured before first boot.
|
||||
* Plugins without it start unchecked.
|
||||
*
|
||||
* "defaultLook": true marks the plugin whose layout (and that layout's
|
||||
* theme) a fresh install shows. Core finds the default look this way, so it
|
||||
* never names a plugin. Such a plugin always starts enabled and cannot be
|
||||
* uninstalled, only swapped for another look.
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
@@ -110,6 +115,12 @@ function parseBundledPlugins(raw) {
|
||||
}
|
||||
const before = problems.length;
|
||||
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
|
||||
if (
|
||||
entry.defaultLook !== undefined &&
|
||||
typeof entry.defaultLook !== "boolean"
|
||||
) {
|
||||
problems.push(`${entry.id} defaultLook must be a boolean`);
|
||||
}
|
||||
if (problems.length > before) continue;
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
@@ -118,6 +129,7 @@ function parseBundledPlugins(raw) {
|
||||
path: hasPath ? entry.path : null,
|
||||
sha256: entry.sha256,
|
||||
onboarding,
|
||||
defaultLook: entry.defaultLook === true,
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
@@ -218,8 +230,12 @@ function buildBundledIndex(plugins, stagedIds) {
|
||||
const staged = new Set(stagedIds);
|
||||
const out = {};
|
||||
for (const plugin of plugins) {
|
||||
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
|
||||
out[plugin.id] = { onboarding: plugin.onboarding };
|
||||
if (!staged.has(plugin.id)) continue;
|
||||
if (!plugin.onboarding && !plugin.defaultLook) continue;
|
||||
out[plugin.id] = {
|
||||
...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}),
|
||||
...(plugin.defaultLook ? { defaultLook: true } : {}),
|
||||
};
|
||||
}
|
||||
return { version: 1, plugins: out };
|
||||
}
|
||||
@@ -238,12 +254,14 @@ function pinsFromIndex(index, ids, current = []) {
|
||||
const latest = byId.get(id)?.versions?.[0];
|
||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
||||
const onboarding = currentById.get(id)?.onboarding;
|
||||
const defaultLook = currentById.get(id)?.defaultLook === true;
|
||||
return {
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: latest.url,
|
||||
sha256: latest.sha256,
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
...(defaultLook ? { defaultLook } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -357,9 +375,26 @@ function rebuildLocalPlugins(dir, ids, run) {
|
||||
}
|
||||
|
||||
/** Copies a local plugin build into the staging folder. */
|
||||
/** Theme CSS files the repo's manifest declares, which ship with it too. */
|
||||
function localThemeFiles(repo) {
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(repo, "manifest.json"), "utf8"),
|
||||
);
|
||||
const themes = manifest?.contributes?.themes;
|
||||
return Array.isArray(themes)
|
||||
? themes
|
||||
.map((theme) => theme?.css)
|
||||
.filter((css) => typeof css === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function copyLocalBuild(repo, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of LOCAL_FILES) {
|
||||
for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) {
|
||||
const from = path.join(repo, entry);
|
||||
if (fs.existsSync(from)) {
|
||||
fs.cpSync(from, path.join(destination, entry), { recursive: true });
|
||||
|
||||
@@ -35,6 +35,42 @@ function fixturePlugin(): string {
|
||||
}
|
||||
|
||||
describe("termix-plugin build", () => {
|
||||
it("builds a theme only plugin with an empty backend and no source", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "theme-fixture",
|
||||
capabilities: ["ui:theme"],
|
||||
contributes: { themes: [{ id: "paper" }] },
|
||||
}),
|
||||
);
|
||||
|
||||
await build({ cwd: dir });
|
||||
|
||||
expect(
|
||||
fs.readFileSync(path.join(dir, "dist", "backend.js"), "utf8"),
|
||||
).toContain("export function activate()");
|
||||
expect(fs.existsSync(path.join(dir, "dist", "frontend.js"))).toBe(false);
|
||||
});
|
||||
|
||||
it("still needs a backend entry once a plugin holds more than themes", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
|
||||
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "mixed-fixture",
|
||||
capabilities: ["ui:theme", "ui:layout"],
|
||||
contributes: { themes: [{ id: "paper" }] },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(build({ cwd: dir })).rejects.toThrow("no backend entry");
|
||||
});
|
||||
|
||||
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
const dir = fixturePlugin();
|
||||
|
||||
@@ -142,3 +142,29 @@ describe("termix-plugin pack", () => {
|
||||
expect(fs.readdirSync(out)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin pack themes", () => {
|
||||
it("ships the CSS files the manifest's themes declare", async () => {
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const dir = fixturePlugin({
|
||||
capabilities: ["ui:theme"],
|
||||
contributes: {
|
||||
themes: [
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
css: "styles/paper.css",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n");
|
||||
writeFile(dir, "styles/draft.css", ".draft {}\n");
|
||||
const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] });
|
||||
const names = await entries(file);
|
||||
expect(names).toContain("styles/paper.css");
|
||||
expect(names).not.toContain("styles/draft.css");
|
||||
});
|
||||
});
|
||||
@@ -49,3 +49,98 @@ describe("termix-plugin validate migrations", () => {
|
||||
expect(output).not.toMatch(/migrations\/\w+ is missing/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin validate themes", () => {
|
||||
function themed(css: string | null) {
|
||||
const root = plugin([]);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "manifest.json"),
|
||||
JSON.stringify({
|
||||
id: "fixture",
|
||||
version: "1.0.0",
|
||||
capabilities: ["ui:theme"],
|
||||
contributes: {
|
||||
themes: [
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
css: "themes/paper.css",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (css !== null) {
|
||||
fs.mkdirSync(path.join(root, "themes"));
|
||||
fs.writeFileSync(path.join(root, "themes", "paper.css"), css);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
it("reports a missing theme file", async () => {
|
||||
expect(await problems(themed(null))).toContain(
|
||||
"theme paper: themes/paper.css does not exist",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports theme CSS that reaches outside or leaves its class", async () => {
|
||||
const output = await problems(
|
||||
themed('@import "x.css"; body { color: red }'),
|
||||
);
|
||||
expect(output).toContain("theme paper: @import is not allowed in a theme");
|
||||
expect(output).toContain(
|
||||
'theme paper: Selector "body" must start with .paper',
|
||||
);
|
||||
});
|
||||
|
||||
it("says nothing about CSS that is fine", async () => {
|
||||
const output = await problems(themed(".paper { --background: #fff; }"));
|
||||
expect(output).not.toContain("theme paper");
|
||||
});
|
||||
});
|
||||
|
||||
describe("termix-plugin validate layouts and capabilities", () => {
|
||||
function withManifest(extra: Record<string, unknown>) {
|
||||
const root = plugin([]);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "manifest.json"),
|
||||
JSON.stringify({ id: "fixture", version: "1.0.0", ...extra }),
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
it("flags ui:theme with no themes and ui:layout with no layouts", async () => {
|
||||
const output = await problems(
|
||||
withManifest({ capabilities: ["ui:theme", "ui:layout"] }),
|
||||
);
|
||||
expect(output).toContain(
|
||||
'"ui:theme" is declared but contributes.themes is empty',
|
||||
);
|
||||
expect(output).toContain(
|
||||
'"ui:layout" is declared but contributes.layouts is empty',
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a layout plugin with no frontend source", async () => {
|
||||
const output = await problems(
|
||||
withManifest({
|
||||
capabilities: ["ui:layout"],
|
||||
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
|
||||
}),
|
||||
);
|
||||
expect(output).toContain("contributes.layouts needs a frontend");
|
||||
});
|
||||
|
||||
it("accepts a layout plugin that has a frontend entry", async () => {
|
||||
const root = withManifest({
|
||||
capabilities: ["ui:layout"],
|
||||
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
|
||||
});
|
||||
fs.mkdirSync(path.join(root, "src", "frontend"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "src", "frontend", "index.tsx"), "");
|
||||
const output = await problems(root);
|
||||
expect(output).not.toContain("contributes.layouts needs a frontend");
|
||||
expect(output).not.toContain("is declared but");
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import sshAuthRoutes from "./routes/ssh-auth-routes.js";
|
||||
import rbacRoutes from "./routes/rbac.js";
|
||||
import openTabsRoutes from "./routes/open-tabs.js";
|
||||
import userPreferencesRoutes from "./routes/user-preferences.js";
|
||||
import lookRoutes from "./routes/look.js";
|
||||
import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
@@ -1616,6 +1617,7 @@ app.use("/credentials", credentialsRoutes);
|
||||
app.use("/ssh-auth", sshAuthRoutes);
|
||||
app.use("/rbac", rbacRoutes);
|
||||
app.use("/open-tabs", openTabsRoutes);
|
||||
app.use("/user-preferences/look", lookRoutes);
|
||||
app.use("/user-preferences", userPreferencesRoutes);
|
||||
app.use("/host-sidebar/preferences", hostSidebarPreferencesRoutes);
|
||||
app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
|
||||
@@ -985,6 +985,8 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("user_preferences", "compact_host_view", "INTEGER");
|
||||
addColumnIfNotExists("user_preferences", "status_color_scheme", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "custom_keybindings", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "layout_id", "TEXT");
|
||||
addColumnIfNotExists("user_preferences", "theme_id", "TEXT");
|
||||
|
||||
addColumnIfNotExists("users", "is_admin", "INTEGER NOT NULL DEFAULT 0");
|
||||
|
||||
|
||||
@@ -709,6 +709,8 @@ export const userPreferences = mysqlTable("user_preferences", {
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
layoutId: text("layout_id"),
|
||||
themeId: text("theme_id"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
|
||||
@@ -709,6 +709,8 @@ export const userPreferences = pgTable("user_preferences", {
|
||||
compactHostView: boolean("compact_host_view"),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
layoutId: text("layout_id"),
|
||||
themeId: text("theme_id"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -703,6 +703,8 @@ export const userPreferences = sqliteTable("user_preferences", {
|
||||
compactHostView: integer("compact_host_view", { mode: "boolean" }),
|
||||
statusColorScheme: text("status_color_scheme"),
|
||||
customKeybindings: text("custom_keybindings"),
|
||||
layoutId: text("layout_id"),
|
||||
themeId: text("theme_id"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserPreferenceRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import {
|
||||
LAYOUT_SETTING,
|
||||
LOOK_ID_PATTERN,
|
||||
THEME_SETTING,
|
||||
installedLayoutIds,
|
||||
resolveDefaultLook,
|
||||
safeLayoutForced,
|
||||
unavailableLooks,
|
||||
} from "../../plugins/looks.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
async function adminDefaults() {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
return {
|
||||
layout: await settings.get(LAYOUT_SETTING),
|
||||
theme: await settings.get(THEME_SETTING),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences/look:
|
||||
* get:
|
||||
* summary: Get the layout and theme to show the current user
|
||||
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* responses:
|
||||
* 200:
|
||||
* description: What the look is chosen from.
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* layoutId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* themeId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* defaults:
|
||||
* type: object
|
||||
* description: Admin defaults, null where unset.
|
||||
* properties:
|
||||
* layout:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* defaultLook:
|
||||
* type: object
|
||||
* description: The default-look plugin's layout and theme, null where none is installed.
|
||||
* properties:
|
||||
* layout:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* safeLayout:
|
||||
* type: boolean
|
||||
* unavailable:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* id:
|
||||
* type: string
|
||||
* version:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* layouts:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* themes:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* error:
|
||||
* type: string
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const preferences =
|
||||
await createCurrentUserPreferenceRepository().findByUserId(userId);
|
||||
const { loader } = getPluginRuntime();
|
||||
const installed = installedLayoutIds(loader);
|
||||
const known = (id: string | null | undefined) =>
|
||||
id && installed.has(id) ? id : null;
|
||||
const defaults = await adminDefaults();
|
||||
res.json({
|
||||
layoutId: known(preferences?.layoutId),
|
||||
themeId: preferences?.themeId ?? null,
|
||||
defaults: { ...defaults, layout: known(defaults.layout) },
|
||||
defaultLook: resolveDefaultLook(loader),
|
||||
safeLayout: safeLayoutForced(),
|
||||
unavailable: unavailableLooks(loader),
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to get the look", error, {
|
||||
operation: "get_look",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to get the look" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences/look/defaults:
|
||||
* put:
|
||||
* summary: Set the default layout and theme (admin only)
|
||||
* description: What everyone sees until they choose their own. Null clears a default so the default look applies.
|
||||
* tags:
|
||||
* - User Preferences
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* layout:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* theme:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The defaults now in effect.
|
||||
* 400:
|
||||
* description: Not a layout or theme id.
|
||||
* 403:
|
||||
* description: Not an admin.
|
||||
*/
|
||||
router.put(
|
||||
"/defaults",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await createCurrentUserRepository().findById(userId);
|
||||
if (!actor?.isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const body = (req.body ?? {}) as { layout?: unknown; theme?: unknown };
|
||||
const changes: Array<[string, unknown]> = [
|
||||
[LAYOUT_SETTING, body.layout],
|
||||
[THEME_SETTING, body.theme],
|
||||
];
|
||||
for (const [key, value] of changes) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
(typeof value !== "string" || !LOOK_ID_PATTERN.test(value))
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `${key} must be a layout or theme id, or null` });
|
||||
}
|
||||
}
|
||||
const settings = createCurrentSettingsRepository();
|
||||
for (const [key, value] of changes) {
|
||||
if (value === undefined) continue;
|
||||
if (value === null) await settings.delete(key);
|
||||
else await settings.set(key, value as string);
|
||||
}
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_default_look",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify({ layout: body.layout, theme: body.theme }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
res.json(await adminDefaults());
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to set the default look", error, {
|
||||
operation: "set_default_look",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to set the default look" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
@@ -352,6 +353,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
dependencies,
|
||||
optionalDependencies,
|
||||
docs,
|
||||
defaultLook: isDefaultLookPlugin(record.id),
|
||||
...describePluginFrontend(loaded),
|
||||
};
|
||||
|
||||
@@ -2279,6 +2281,63 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/restore-bundled:
|
||||
* post:
|
||||
* summary: Restore the bundled copy of a plugin
|
||||
* description: >
|
||||
* Drops any update downloaded over a plugin that ships with Termix and
|
||||
* loads the copy from the image again, so a broken release can be rolled
|
||||
* back from the layout repair view. The shipped version is pinned so
|
||||
* auto update does not bring the release back. Dependents restart.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The restored version and the plugin's state.
|
||||
* 400:
|
||||
* description: Invalid plugin id.
|
||||
* 403:
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 409:
|
||||
* description: The plugin has no bundled copy, or a linked server manages it.
|
||||
*/
|
||||
router.post(
|
||||
"/:id/restore-bundled",
|
||||
authenticateJWT,
|
||||
requireManagePlugins,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
if (!ID_PATTERN.test(pluginId)) {
|
||||
res.status(400).json({ error: "Invalid plugin id" });
|
||||
return;
|
||||
}
|
||||
if (await refuseOnLinkedDesktop(res)) return;
|
||||
try {
|
||||
const { restoreBundledPlugin } = await import("../../plugins/manage.js");
|
||||
const result = await restoreBundledPlugin(pluginId);
|
||||
await auditPluginAction(req, "restore_bundled_plugin", pluginId, {
|
||||
version: result.version,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
sendManageError(
|
||||
res,
|
||||
error,
|
||||
"Failed to restore the bundled copy",
|
||||
"plugin_restore_bundled",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}:
|
||||
@@ -2306,7 +2365,7 @@ router.post(
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
* 409:
|
||||
* description: Other running plugins depend on it, or managed by a linked server.
|
||||
* description: Other running plugins depend on it, it is the default look, or a linked server manages it.
|
||||
*/
|
||||
router.delete(
|
||||
"/:id",
|
||||
|
||||
@@ -32,8 +32,13 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
compactHostView: row?.compactHostView ?? null,
|
||||
statusColorScheme: row?.statusColorScheme ?? null,
|
||||
customKeybindings: row?.customKeybindings ?? null,
|
||||
layoutId: row?.layoutId ?? null,
|
||||
themeId: row?.themeId ?? null,
|
||||
});
|
||||
|
||||
/** Layout and theme ids, the same shape the plugin manifest allows. */
|
||||
const LOOK_ID = /^[a-z][a-z0-9-]{1,39}$/;
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /user-preferences:
|
||||
@@ -104,6 +109,14 @@ const pickPreferences = (row?: UserPreferenceRecord | null) => ({
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: JSON-encoded array of the user's custom keybindings. An action's type is one of the shell's own or one a plugin declares in contributes.keybindingActions.
|
||||
* layoutId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: The layout this user chose, overriding the admin default. Null follows the default.
|
||||
* themeId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: The theme this user chose, overriding the admin default. Null follows the default.
|
||||
*/
|
||||
router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -165,6 +178,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* customKeybindings:
|
||||
* type: string
|
||||
* description: JSON-encoded array of the user's custom keybindings. Each action is checked against the shell's own types and the parameters a plugin declares for its type.
|
||||
* layoutId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: A layout id, or null to follow the admin default.
|
||||
* themeId:
|
||||
* type: string
|
||||
* nullable: true
|
||||
* description: A theme id, or null to follow the admin default.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Preferences updated successfully.
|
||||
@@ -186,6 +207,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
confirmTabClose,
|
||||
hiddenRailTabs,
|
||||
customKeybindings,
|
||||
layoutId,
|
||||
themeId,
|
||||
} = req.body as {
|
||||
reopenTabsOnLogin?: boolean;
|
||||
theme?: string | null;
|
||||
@@ -201,6 +224,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
confirmTabClose?: boolean | null;
|
||||
hiddenRailTabs?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
layoutId?: string | null;
|
||||
themeId?: string | null;
|
||||
};
|
||||
// showHostTags, hostTrayOnClick, compactHostView, statusColorScheme are no
|
||||
// longer writable here -- they moved to /host-sidebar/preferences as of the
|
||||
@@ -258,6 +283,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries({ layoutId, themeId })) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== null &&
|
||||
(typeof value !== "string" || !LOOK_ID.test(value))
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: `${key} must be a layout or theme id, or null` });
|
||||
}
|
||||
}
|
||||
|
||||
const boolFields: Record<string, boolean | null | undefined> = {
|
||||
commandPaletteEnabled,
|
||||
pinAppRail,
|
||||
@@ -290,6 +327,8 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
if (confirmTabClose !== undefined) updates.confirmTabClose = confirmTabClose;
|
||||
if (customKeybindings !== undefined)
|
||||
updates.customKeybindings = customKeybindings;
|
||||
if (layoutId !== undefined) updates.layoutId = layoutId;
|
||||
if (themeId !== undefined) updates.themeId = themeId;
|
||||
|
||||
if (Object.keys(updates).length === 1) {
|
||||
return res.status(400).json({ error: "No preferences provided" });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Serves a plugin's built frontend: its bundle, stylesheet and locales.
|
||||
// Serves a plugin's built frontend: its bundle, stylesheet, locales and the
|
||||
// CSS files its themes declare.
|
||||
//
|
||||
// Unauthenticated on purpose, the same as /assets: import() cannot carry the
|
||||
// bearer header, and nothing here is secret. It only ever serves files from
|
||||
@@ -28,6 +29,22 @@ export interface PluginFrontendInfo {
|
||||
assetVersion: string | null;
|
||||
/** Language codes with a locale file, "en" plus xx_YY names. */
|
||||
locales: string[];
|
||||
/** Changes whenever a theme's CSS does, for its ?v= link. */
|
||||
themeVersion: string | null;
|
||||
}
|
||||
|
||||
/** Where a theme's CSS is served: /plugin-assets/<id>/_themes/<theme>.css. */
|
||||
export const THEME_ASSET_PREFIX = "_themes/";
|
||||
|
||||
/** The declared CSS file of each of the plugin's themes, by theme id. */
|
||||
function themeFiles(plugin: AssetPlugin): Map<string, string> {
|
||||
const files = new Map<string, string>();
|
||||
const manifest = plugin.manifest as PluginManifest;
|
||||
for (const theme of manifest.contributes?.themes ?? []) {
|
||||
const file = path.resolve(plugin.dir, theme.css);
|
||||
if (isInside(plugin.dir, file)) files.set(theme.id, file);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function frontendEntry(
|
||||
@@ -72,9 +89,16 @@ export function describePluginFrontend(
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
themeVersion: null,
|
||||
};
|
||||
if (!plugin) return none;
|
||||
|
||||
const themes = [...themeFiles(plugin).values()].filter(fileExists);
|
||||
const themeVersion =
|
||||
themes.length > 0
|
||||
? contentVersion(themes.sort(), plugin.manifest.version)
|
||||
: null;
|
||||
|
||||
const locales: string[] = [];
|
||||
const localeFiles: string[] = [];
|
||||
const dir = localesDir(plugin);
|
||||
@@ -96,7 +120,8 @@ export function describePluginFrontend(
|
||||
}
|
||||
|
||||
const entry = frontendEntry(plugin);
|
||||
if (!entry || !fileExists(entry.file)) return { ...none, locales };
|
||||
if (!entry || !fileExists(entry.file))
|
||||
return { ...none, locales, themeVersion };
|
||||
|
||||
const cssFile = path.join(entry.dir, "frontend.css");
|
||||
const css = fileExists(cssFile);
|
||||
@@ -111,6 +136,7 @@ export function describePluginFrontend(
|
||||
css,
|
||||
assetVersion,
|
||||
locales: locales.sort(),
|
||||
themeVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,7 +184,14 @@ export function resolvePluginAsset(
|
||||
|
||||
let root: string | null;
|
||||
let rest: string;
|
||||
if (clean.startsWith("locales/")) {
|
||||
if (clean.startsWith(THEME_ASSET_PREFIX)) {
|
||||
// Only a declared theme file, which the loader checked on load.
|
||||
const themeId = clean.slice(THEME_ASSET_PREFIX.length, -".css".length);
|
||||
const file = themeFiles(plugin).get(themeId);
|
||||
if (!file || !clean.endsWith(".css")) return null;
|
||||
root = path.dirname(file);
|
||||
rest = path.basename(file);
|
||||
} else if (clean.startsWith("locales/")) {
|
||||
root = localesDir(plugin);
|
||||
rest = clean.slice("locales/".length);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The onboarding defaults of the bundled plugins, from
|
||||
* The onboarding defaults and the default look of the bundled plugins, from
|
||||
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
|
||||
* docker/bundled-plugins.json). Core never names plugins, so which ones a
|
||||
* fresh install keeps lives in that file and not here.
|
||||
@@ -27,6 +27,7 @@ interface BundledIndex {
|
||||
consent?: boolean;
|
||||
enabledByEnv?: string[];
|
||||
};
|
||||
defaultLook?: boolean;
|
||||
}
|
||||
>;
|
||||
}
|
||||
@@ -52,6 +53,23 @@ export function readBundledIndex(): BundledIndex | null {
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundled plugins whose layout and theme a fresh install shows. Core reads
|
||||
* the default look from here so it never has to name a plugin.
|
||||
*/
|
||||
export function defaultLookPluginIds(): string[] {
|
||||
const index = readBundledIndex();
|
||||
if (!index) return [];
|
||||
return Object.entries(index.plugins)
|
||||
.filter(([, entry]) => entry?.defaultLook === true)
|
||||
.map(([id]) => id)
|
||||
.sort();
|
||||
}
|
||||
|
||||
export function isDefaultLookPlugin(pluginId: string): boolean {
|
||||
return defaultLookPluginIds().includes(pluginId);
|
||||
}
|
||||
|
||||
export function resetBundledIndexCache(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -96,6 +114,8 @@ export function initialPluginState(
|
||||
): "enabled" | "disabled" {
|
||||
if (source !== "bundled") return "disabled";
|
||||
if (!freshInstall) return "enabled";
|
||||
// The default look is what the app is drawn with.
|
||||
if (isDefaultLookPlugin(pluginId)) return "enabled";
|
||||
const { recommended, consent } = onboardingDefaults(pluginId);
|
||||
return recommended && !consent ? "enabled" : "disabled";
|
||||
}
|
||||
@@ -18,7 +18,11 @@ import path from "node:path";
|
||||
import semver from "semver";
|
||||
import * as tar from "tar";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { isTermixCompatible, parseManifest } from "./manifest.js";
|
||||
import {
|
||||
isTermixCompatible,
|
||||
parseManifest,
|
||||
validateThemeCss,
|
||||
} from "./manifest.js";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import {
|
||||
@@ -85,6 +89,19 @@ export interface LoadedPlugin {
|
||||
signedTier?: KeyTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin folder that did not load. Kept so the layout repair view can say
|
||||
* why a chosen layout or theme is missing instead of only "not installed".
|
||||
*/
|
||||
export interface SkippedPlugin {
|
||||
/** Manifest id when it could be read, else the folder or file name. */
|
||||
id: string;
|
||||
version: string | null;
|
||||
layouts: string[];
|
||||
themes: string[];
|
||||
error: string;
|
||||
}
|
||||
|
||||
export interface PluginLoaderOptions {
|
||||
errorThreshold?: number;
|
||||
errorWindowMs?: number;
|
||||
@@ -92,8 +109,70 @@ export interface PluginLoaderOptions {
|
||||
onFailed?: (plugin: LoadedPlugin) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every theme file a manifest declares must sit inside the plugin and hold
|
||||
* only what a theme may hold. Refusing the plugin keeps a bad file from ever
|
||||
* being served.
|
||||
*/
|
||||
function validatePluginThemes(dir: string, manifest: PluginManifest): void {
|
||||
for (const theme of manifest.contributes?.themes ?? []) {
|
||||
const file = path.join(dir, theme.css);
|
||||
if (!fs.existsSync(file) || !isRealPathInside(dir, file)) {
|
||||
throw new Error(
|
||||
`Plugin ${manifest.id} theme ${theme.id} file ${theme.css} is missing`,
|
||||
);
|
||||
}
|
||||
const errors = validateThemeCss(fs.readFileSync(file, "utf8"), theme.id);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`Plugin ${manifest.id} theme ${theme.id} is not allowed: ${errors.join("; ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What can still be said about a plugin folder that failed to load. */
|
||||
function describeSkipped(
|
||||
dir: string,
|
||||
name: string,
|
||||
isArtifact: boolean,
|
||||
error: unknown,
|
||||
): SkippedPlugin {
|
||||
const skipped: SkippedPlugin = {
|
||||
id: isArtifact ? name.replace(/(-\d[^/]*)?\.tmxplug$/, "") : name,
|
||||
version: null,
|
||||
layouts: [],
|
||||
themes: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
if (isArtifact) return skipped;
|
||||
try {
|
||||
const raw = JSON.parse(
|
||||
fs.readFileSync(getPluginManifestPath(dir), "utf8"),
|
||||
) as {
|
||||
id?: unknown;
|
||||
version?: unknown;
|
||||
contributes?: { layouts?: unknown; themes?: unknown };
|
||||
};
|
||||
const ids = (list: unknown) =>
|
||||
Array.isArray(list)
|
||||
? list
|
||||
.map((entry) => (entry as { id?: unknown })?.id)
|
||||
.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
if (typeof raw.id === "string") skipped.id = raw.id;
|
||||
if (typeof raw.version === "string") skipped.version = raw.version;
|
||||
skipped.layouts = ids(raw.contributes?.layouts);
|
||||
skipped.themes = ids(raw.contributes?.themes);
|
||||
} catch {
|
||||
// An unreadable manifest leaves only the folder name.
|
||||
}
|
||||
return skipped;
|
||||
}
|
||||
|
||||
export class PluginLoader {
|
||||
private readonly plugins = new Map<string, LoadedPlugin>();
|
||||
private skippedPlugins: SkippedPlugin[] = [];
|
||||
/** Version of each bundled plugin as shipped, the floor for any update. */
|
||||
private readonly bundledVersions = new Map<string, string>();
|
||||
/** Activation order, so shutdown can run it backwards. */
|
||||
@@ -113,6 +192,11 @@ export class PluginLoader {
|
||||
return [...this.plugins.values()];
|
||||
}
|
||||
|
||||
/** Plugin folders the last full load skipped, with the reason. */
|
||||
skipped(): SkippedPlugin[] {
|
||||
return [...this.skippedPlugins];
|
||||
}
|
||||
|
||||
get(pluginId: string): LoadedPlugin | undefined {
|
||||
return this.plugins.get(pluginId);
|
||||
}
|
||||
@@ -210,6 +294,8 @@ export class PluginLoader {
|
||||
);
|
||||
}
|
||||
|
||||
validatePluginThemes(dir, manifest);
|
||||
|
||||
if (this.plugins.has(manifest.id)) {
|
||||
throw new Error(`another plugin already uses the id "${manifest.id}"`);
|
||||
}
|
||||
@@ -258,6 +344,7 @@ export class PluginLoader {
|
||||
async loadAll(): Promise<LoadedPlugin[]> {
|
||||
this.plugins.clear();
|
||||
this.bundledVersions.clear();
|
||||
this.skippedPlugins = [];
|
||||
await removeStaleStaging();
|
||||
const loaded: LoadedPlugin[] = [];
|
||||
// Every bundled folder name, loaded or not: a bundled plugin that fails
|
||||
@@ -322,6 +409,9 @@ export class PluginLoader {
|
||||
}
|
||||
loaded.push(plugin);
|
||||
} catch (error) {
|
||||
this.skippedPlugins.push(
|
||||
describeSkipped(dir, entry.name, isArtifact, error),
|
||||
);
|
||||
pluginLogger.error(
|
||||
`Skipping plugin directory ${entry.name} in ${root}`,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Layouts and themes on the server side: which look a fresh install shows,
|
||||
* and which installed looks could not load. Core names no plugin here; the
|
||||
* default look comes from the bundled index's defaultLook flag.
|
||||
*/
|
||||
|
||||
import { defaultLookPluginIds } from "./bundled-index.js";
|
||||
import type { LoadedPlugin, SkippedPlugin } from "./loader.js";
|
||||
|
||||
export interface DefaultLook {
|
||||
layout: string | null;
|
||||
theme: string | null;
|
||||
}
|
||||
|
||||
interface LookLoader {
|
||||
get(pluginId: string): Pick<LoadedPlugin, "manifest"> | undefined;
|
||||
list(): Array<Pick<LoadedPlugin, "manifest">>;
|
||||
skipped(): SkippedPlugin[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The first default-look plugin's default layout (else its first one) and
|
||||
* that layout's theme. A default-look plugin with themes only fills in the
|
||||
* theme. Null where none is installed, and the browser uses its built-in.
|
||||
*/
|
||||
export function resolveDefaultLook(loader: LookLoader): DefaultLook {
|
||||
const look: DefaultLook = { layout: null, theme: null };
|
||||
for (const id of defaultLookPluginIds()) {
|
||||
const contributes = loader.get(id)?.manifest.contributes;
|
||||
const layouts = contributes?.layouts ?? [];
|
||||
const layout = layouts.find((entry) => entry.default) ?? layouts[0];
|
||||
if (layout && !look.layout) {
|
||||
look.layout = layout.id;
|
||||
look.theme ??= layout.defaultTheme ?? null;
|
||||
}
|
||||
if (!look.theme && contributes?.themes?.length) {
|
||||
look.theme = contributes.themes[0].id;
|
||||
}
|
||||
}
|
||||
return look;
|
||||
}
|
||||
|
||||
/** Plugins with a layout or theme that were found but did not load. */
|
||||
export function unavailableLooks(loader: LookLoader): SkippedPlugin[] {
|
||||
return loader
|
||||
.skipped()
|
||||
.filter((plugin) => plugin.layouts.length > 0 || plugin.themes.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout ids some installed plugin brings, whether it runs, is turned off or
|
||||
* failed to load. A choice outside this set was uninstalled, so the next
|
||||
* choice down applies instead of the repair view.
|
||||
*/
|
||||
export function installedLayoutIds(loader: LookLoader): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const plugin of loader.list()) {
|
||||
for (const layout of plugin.manifest.contributes?.layouts ?? []) {
|
||||
ids.add(layout.id);
|
||||
}
|
||||
}
|
||||
for (const plugin of loader.skipped()) {
|
||||
for (const id of plugin.layouts) ids.add(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** TERMIX_SAFE_LAYOUT=1 starts everyone in the layout repair view. */
|
||||
export function safeLayoutForced(): boolean {
|
||||
const value = process.env.TERMIX_SAFE_LAYOUT?.trim().toLowerCase();
|
||||
return value === "1" || value === "true";
|
||||
}
|
||||
|
||||
export const LOOK_ID_PATTERN = /^[a-z][a-z0-9-]{1,39}$/;
|
||||
export const LAYOUT_SETTING = "ui.layout";
|
||||
export const THEME_SETTING = "ui.theme";
|
||||
@@ -7,7 +7,9 @@
|
||||
* sha256 and signature checks and stays marked unverified. Bundled plugins ship in the
|
||||
* image and can still be uninstalled: their files are deleted and the id is
|
||||
* recorded in the settings table, so the shipped copy is ignored from then
|
||||
* on and a reinstall downloads the plugin like any other.
|
||||
* on and a reinstall downloads the plugin like any other. The one exception
|
||||
* is a default-look plugin (bundled-index.ts): the app is drawn with it, so
|
||||
* it can be disabled or swapped but never uninstalled.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
@@ -15,6 +17,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import semver from "semver";
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { isDefaultLookPlugin } from "./bundled-index.js";
|
||||
import {
|
||||
activatePlugin,
|
||||
deactivatePlugin,
|
||||
@@ -1154,6 +1157,13 @@ export async function uninstallPluginUnlocked(
|
||||
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
|
||||
}
|
||||
const shipped = loader.bundledIds().has(pluginId);
|
||||
if (shipped && isDefaultLookPlugin(pluginId)) {
|
||||
throw new PluginManageError(
|
||||
"The default look cannot be uninstalled",
|
||||
409,
|
||||
"DEFAULT_LOOK",
|
||||
);
|
||||
}
|
||||
|
||||
const dependents = dependentChain(pluginId, await enabledIds());
|
||||
if (dependents.length > 0) {
|
||||
@@ -1634,41 +1644,94 @@ export function installUpload(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the copy in the bundled folder again. Running dependents stop first
|
||||
* and start again after. `prepare` runs once the plugin is stopped, for work
|
||||
* that needs its files out of use.
|
||||
*/
|
||||
async function reloadBundledUnlocked(
|
||||
pluginId: string,
|
||||
prepare?: (previous: LoadedPlugin | undefined) => Promise<void>,
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
const { loader } = getPluginRuntime();
|
||||
const previous = loader.get(pluginId);
|
||||
if (previous && previous.source !== "bundled" && !prepare) {
|
||||
throw new PluginManageError(
|
||||
`${pluginId} is not loaded from the bundled folder`,
|
||||
409,
|
||||
"NOT_BUNDLED",
|
||||
);
|
||||
}
|
||||
const dependents = previous
|
||||
? dependentChain(pluginId, await enabledIds()).filter(
|
||||
(id) => loader.get(id)?.state === "active",
|
||||
)
|
||||
: [];
|
||||
for (const id of dependents) await deactivatePlugin(id);
|
||||
await unloadPlugin(pluginId);
|
||||
if (prepare) await prepare(previous);
|
||||
|
||||
const plugin = await loader.loadBundled(pluginId);
|
||||
await registerLoadedPlugins([plugin]);
|
||||
|
||||
let state = "stopped";
|
||||
if ((await enabledIds()).has(pluginId)) {
|
||||
const { missing } = dependencyChain(pluginId);
|
||||
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
|
||||
}
|
||||
for (const id of [...dependents].reverse()) await startAndRecord(id);
|
||||
return { id: pluginId, version: plugin.manifest.version, state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the copy in the bundled folder again after it was rebuilt, for the
|
||||
* dev runner. Running dependents stop first and start again after.
|
||||
* dev runner.
|
||||
*/
|
||||
export function reloadBundledPlugin(
|
||||
pluginId: string,
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
return serialized(() => reloadBundledUnlocked(pluginId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a bundled plugin back to the copy that shipped with this install,
|
||||
* dropping any update downloaded over it. The shipped version is pinned so
|
||||
* auto update does not bring a broken release straight back; unpin it in the
|
||||
* Plugins tab to follow updates again.
|
||||
*/
|
||||
export function restoreBundledPlugin(
|
||||
pluginId: string,
|
||||
): Promise<{ id: string; version: string; state: string }> {
|
||||
return serialized(async () => {
|
||||
if (!ID_PATTERN.test(pluginId)) {
|
||||
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
|
||||
}
|
||||
const { loader } = getPluginRuntime();
|
||||
const previous = loader.get(pluginId);
|
||||
if (previous && previous.source !== "bundled") {
|
||||
if (!loader.bundledIds().has(pluginId) || !isBundledOnDisk(pluginId)) {
|
||||
throw new PluginManageError(
|
||||
`${pluginId} is not loaded from the bundled folder`,
|
||||
"This plugin has no bundled copy",
|
||||
409,
|
||||
"NOT_BUNDLED",
|
||||
"NO_BUNDLED_COPY",
|
||||
);
|
||||
}
|
||||
const dependents = previous
|
||||
? dependentChain(pluginId, await enabledIds()).filter(
|
||||
(id) => loader.get(id)?.state === "active",
|
||||
)
|
||||
: [];
|
||||
for (const id of dependents) await deactivatePlugin(id);
|
||||
await unloadPlugin(pluginId);
|
||||
|
||||
const plugin = await loader.loadBundled(pluginId);
|
||||
await registerLoadedPlugins([plugin]);
|
||||
|
||||
let state = "stopped";
|
||||
if ((await enabledIds()).has(pluginId)) {
|
||||
const { missing } = dependencyChain(pluginId);
|
||||
state = missing.length > 0 ? "blocked" : await startAndRecord(pluginId);
|
||||
}
|
||||
for (const id of [...dependents].reverse()) await startAndRecord(id);
|
||||
return { id: pluginId, version: plugin.manifest.version, state };
|
||||
const { createCurrentPluginRepository } = await repos();
|
||||
const repository = createCurrentPluginRepository();
|
||||
const result = await reloadBundledUnlocked(pluginId, async (previous) => {
|
||||
await removeArtifactFiles(pluginId, previous?.artifact);
|
||||
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
await repository.update(pluginId, {
|
||||
autoUpdate: false,
|
||||
pinnedVersion: result.version,
|
||||
});
|
||||
await applyPin(pluginId, result.version);
|
||||
pluginLogger.info(`Restored the bundled copy of ${pluginId}`, {
|
||||
operation: "plugin_restore_bundled",
|
||||
});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
pluginFeatures,
|
||||
pluginDocsUrl,
|
||||
pluginEnvVars,
|
||||
validateThemeCss,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
|
||||
export type {
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
userId: "user-1",
|
||||
admins: new Set(["admin-1"]),
|
||||
prefs: null as { layoutId: string | null; themeId: string | null } | null,
|
||||
settings: new Map<string, string>(),
|
||||
audits: [] as unknown[],
|
||||
skipped: [] as unknown[],
|
||||
defaultLookIds: [] as string[],
|
||||
installed: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: unknown) => {
|
||||
state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: express.Request, _res: unknown, next: () => void) => {
|
||||
(req as unknown as { userId: string }).userId = state.userId;
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async () => state.prefs,
|
||||
}),
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => ({
|
||||
id,
|
||||
username: id,
|
||||
isAdmin: state.admins.has(id),
|
||||
}),
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
delete: async (key: string) => {
|
||||
state.settings.delete(key);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../plugins/index.js", () => ({
|
||||
getPluginRuntime: () => ({
|
||||
loader: {
|
||||
get: (id: string) =>
|
||||
id === "shipped-look"
|
||||
? {
|
||||
manifest: {
|
||||
contributes: {
|
||||
layouts: [
|
||||
{ id: "shipped", label: "Shipped", defaultTheme: "ink" },
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
list: () => [
|
||||
{
|
||||
manifest: {
|
||||
contributes: {
|
||||
layouts: state.installed.map((id) => ({ id, label: id })),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
skipped: () => state.skipped,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../plugins/bundled-index.js", () => ({
|
||||
defaultLookPluginIds: () => state.defaultLookIds,
|
||||
}));
|
||||
|
||||
const { default: lookRoutes } =
|
||||
await import("../../../database/routes/look.js");
|
||||
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
state.userId = "user-1";
|
||||
state.prefs = null;
|
||||
state.settings.clear();
|
||||
state.audits = [];
|
||||
state.skipped = [];
|
||||
state.defaultLookIds = [];
|
||||
state.installed = [];
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/user-preferences/look", lookRoutes);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function getLook() {
|
||||
const res = await fetch(`${baseUrl}/user-preferences/look`);
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
async function putDefaults(body: unknown) {
|
||||
const res = await fetch(`${baseUrl}/user-preferences/look/defaults`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
describe("GET /user-preferences/look", () => {
|
||||
it("is all empty on a fresh install", async () => {
|
||||
expect((await getLook()).body).toEqual({
|
||||
layoutId: null,
|
||||
themeId: null,
|
||||
defaults: { layout: null, theme: null },
|
||||
defaultLook: { layout: null, theme: null },
|
||||
safeLayout: false,
|
||||
unavailable: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the user's pick, the admin defaults and the default look", async () => {
|
||||
state.prefs = { layoutId: "mine", themeId: "nord" };
|
||||
state.installed = ["mine", "admin-layout"];
|
||||
state.settings.set("ui.layout", "admin-layout");
|
||||
state.settings.set("ui.theme", "gruvbox");
|
||||
state.defaultLookIds = ["shipped-look"];
|
||||
state.skipped = [
|
||||
{
|
||||
id: "broken",
|
||||
version: "1.0.0",
|
||||
layouts: ["b"],
|
||||
themes: [],
|
||||
error: "x",
|
||||
},
|
||||
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
|
||||
];
|
||||
process.env.TERMIX_SAFE_LAYOUT = "1";
|
||||
|
||||
const { body } = await getLook();
|
||||
expect(body).toMatchObject({
|
||||
layoutId: "mine",
|
||||
themeId: "nord",
|
||||
defaults: { layout: "admin-layout", theme: "gruvbox" },
|
||||
defaultLook: { layout: "shipped", theme: "ink" },
|
||||
safeLayout: true,
|
||||
});
|
||||
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
|
||||
["broken"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /user-preferences/look with an uninstalled layout", () => {
|
||||
it("drops a choice no installed plugin brings, so the next one applies", async () => {
|
||||
state.prefs = { layoutId: "deleted", themeId: null };
|
||||
state.settings.set("ui.layout", "also-deleted");
|
||||
state.installed = ["kept"];
|
||||
const { body } = await getLook();
|
||||
expect(body.layoutId).toBeNull();
|
||||
expect(body.defaults.layout).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a choice whose plugin failed to load, for the repair view", async () => {
|
||||
state.prefs = { layoutId: "b", themeId: null };
|
||||
state.skipped = [
|
||||
{
|
||||
id: "broken",
|
||||
version: "1.0.0",
|
||||
layouts: ["b"],
|
||||
themes: [],
|
||||
error: "x",
|
||||
},
|
||||
];
|
||||
expect((await getLook()).body.layoutId).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /user-preferences/look/defaults", () => {
|
||||
it("is for admins only", async () => {
|
||||
const { status } = await putDefaults({ layout: "x" });
|
||||
expect(status).toBe(403);
|
||||
expect(state.settings.size).toBe(0);
|
||||
});
|
||||
|
||||
it("sets and clears the defaults, and audits it", async () => {
|
||||
state.userId = "admin-1";
|
||||
let res = await putDefaults({ layout: "fancy", theme: "nord" });
|
||||
expect(res).toEqual({
|
||||
status: 200,
|
||||
body: { layout: "fancy", theme: "nord" },
|
||||
});
|
||||
res = await putDefaults({ layout: null });
|
||||
expect(res.body).toEqual({ layout: null, theme: "nord" });
|
||||
expect(state.audits).toHaveLength(2);
|
||||
expect(state.audits[0]).toMatchObject({ action: "update_default_look" });
|
||||
});
|
||||
|
||||
it("refuses something that is not an id", async () => {
|
||||
state.userId = "admin-1";
|
||||
expect((await putDefaults({ theme: "../evil" })).status).toBe(400);
|
||||
expect((await putDefaults({ layout: 5 })).status).toBe(400);
|
||||
expect(state.settings.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -169,6 +169,7 @@ const manageMock = vi.hoisted(() => ({
|
||||
updatePlugin: vi.fn(),
|
||||
updateAllPlugins: vi.fn(),
|
||||
uninstallPlugin: vi.fn(),
|
||||
restoreBundledPlugin: vi.fn(),
|
||||
setPluginOptions: vi.fn(),
|
||||
setAllPluginChannels: vi.fn(),
|
||||
getPluginDataSummary: vi.fn(),
|
||||
@@ -433,6 +434,7 @@ describe("plugins route", () => {
|
||||
"name",
|
||||
"optionalDependencies",
|
||||
"state",
|
||||
"themeVersion",
|
||||
"version",
|
||||
].sort(),
|
||||
);
|
||||
@@ -624,10 +626,12 @@ describe("plugins route", () => {
|
||||
contributes: null,
|
||||
dependencies: {},
|
||||
optionalDependencies: {},
|
||||
defaultLook: false,
|
||||
frontend: false,
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
themeVersion: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1138,6 +1142,35 @@ describe("plugins route", () => {
|
||||
expect(manageMock.uninstallPlugin).toHaveBeenCalledWith("docker");
|
||||
});
|
||||
|
||||
it("restores the bundled copy", async () => {
|
||||
manageMock.restoreBundledPlugin.mockResolvedValue({
|
||||
id: "docker",
|
||||
version: "1.0.0",
|
||||
state: "active",
|
||||
});
|
||||
const res = await post("/plugins/docker/restore-bundled");
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
id: "docker",
|
||||
version: "1.0.0",
|
||||
state: "active",
|
||||
});
|
||||
expect(manageMock.restoreBundledPlugin).toHaveBeenCalledWith("docker");
|
||||
});
|
||||
|
||||
it("answers 409 when a plugin has no bundled copy", async () => {
|
||||
manageMock.restoreBundledPlugin.mockRejectedValue(
|
||||
new PluginManageError(
|
||||
"This plugin has no bundled copy",
|
||||
409,
|
||||
"NO_BUNDLED_COPY",
|
||||
),
|
||||
);
|
||||
const res = await post("/plugins/docker/restore-bundled");
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).code).toBe("NO_BUNDLED_COPY");
|
||||
});
|
||||
|
||||
it("refuses changes on a linked desktop", async () => {
|
||||
manageMock.isLinkedDesktop.mockResolvedValue(true);
|
||||
const res = await post("/plugins/docker/install");
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
row: null as Record<string, unknown> | null,
|
||||
upserts: [] as Record<string, unknown>[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
databaseLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({
|
||||
createAuthMiddleware:
|
||||
() => (req: express.Request, _res: unknown, next: () => void) => {
|
||||
(req as unknown as { userId: string }).userId = "user-1";
|
||||
next();
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserPreferenceRepository: () => ({
|
||||
findByUserId: async () => state.row,
|
||||
upsert: async (_userId: string, updates: Record<string, unknown>) => {
|
||||
state.upserts.push(updates);
|
||||
state.row = { ...(state.row ?? {}), ...updates };
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../database/routes/keybinding-validation.js", () => ({
|
||||
isValidKeybinding: () => true,
|
||||
}));
|
||||
|
||||
const { default: routes } =
|
||||
await import("../../../database/routes/user-preferences.js");
|
||||
|
||||
let server: http.Server;
|
||||
let baseUrl: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
state.row = null;
|
||||
state.upserts = [];
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/user-preferences", routes);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
async function put(body: unknown) {
|
||||
const res = await fetch(`${baseUrl}/user-preferences`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return { status: res.status, body: await res.json() };
|
||||
}
|
||||
|
||||
describe("user preferences: layout and theme", () => {
|
||||
it("reads null for both until chosen", async () => {
|
||||
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
|
||||
expect(body).toMatchObject({ layoutId: null, themeId: null });
|
||||
});
|
||||
|
||||
it("saves a layout and theme id, and null to follow the default", async () => {
|
||||
expect((await put({ layoutId: "fancy", themeId: "nord" })).status).toBe(
|
||||
200,
|
||||
);
|
||||
expect(state.upserts[0]).toMatchObject({
|
||||
layoutId: "fancy",
|
||||
themeId: "nord",
|
||||
});
|
||||
const body = await (await fetch(`${baseUrl}/user-preferences`)).json();
|
||||
expect(body).toMatchObject({ layoutId: "fancy", themeId: "nord" });
|
||||
|
||||
await put({ layoutId: null });
|
||||
expect(state.upserts[1]).toMatchObject({ layoutId: null });
|
||||
expect(state.upserts[1]).not.toHaveProperty("themeId");
|
||||
});
|
||||
|
||||
it("refuses anything that is not an id", async () => {
|
||||
for (const bad of ["Fancy", "../x", "a", 7]) {
|
||||
expect((await put({ themeId: bad })).status).toBe(400);
|
||||
}
|
||||
expect(state.upserts).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ const main = fs.readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
const shell = fs.readFileSync(
|
||||
new URL("../../../ui/AppShell.tsx", import.meta.url),
|
||||
new URL("../../../ui/shell/controller/use-tabs.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ describe("describePluginFrontend", () => {
|
||||
css: false,
|
||||
assetVersion: null,
|
||||
locales: [],
|
||||
themeVersion: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -175,3 +176,48 @@ describe("GET /plugin-assets", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("theme stylesheets", () => {
|
||||
let themed: { id: string; dir: string; manifest: never };
|
||||
|
||||
beforeAll(() => {
|
||||
const dir = path.join(root, "inks");
|
||||
fs.mkdirSync(path.join(dir, "themes"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "themes", "paper.css"), ".paper{}");
|
||||
fs.writeFileSync(path.join(dir, "themes", "secret.css"), ".secret{}");
|
||||
themed = {
|
||||
id: "inks",
|
||||
dir,
|
||||
manifest: {
|
||||
id: "inks",
|
||||
version: "1.0.0",
|
||||
contributes: {
|
||||
themes: [
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
css: "themes/paper.css",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never,
|
||||
};
|
||||
});
|
||||
|
||||
it("serves a declared theme's CSS under _themes/<id>.css only", () => {
|
||||
expect(resolvePluginAsset(themed, "_themes/paper.css")).toBe(
|
||||
path.join(themed.dir, "themes", "paper.css"),
|
||||
);
|
||||
expect(resolvePluginAsset(themed, "_themes/secret.css")).toBeNull();
|
||||
expect(resolvePluginAsset(themed, "themes/paper.css")).toBeNull();
|
||||
expect(resolvePluginAsset(themed, "_themes/paper.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("versions theme files even without a frontend bundle", () => {
|
||||
const info = describePluginFrontend(themed);
|
||||
expect(info.frontend).toBe(false);
|
||||
expect(info.themeVersion).toMatch(/^[0-9a-f]{12}$/);
|
||||
expect(describePluginFrontend(plugin).themeVersion).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,9 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
defaultLookPluginIds,
|
||||
initialPluginState,
|
||||
isDefaultLookPlugin,
|
||||
onboardingDefaults,
|
||||
resetBundledIndexCache,
|
||||
} from "../../plugins/bundled-index.js";
|
||||
@@ -103,3 +105,28 @@ describe("initialPluginState", () => {
|
||||
expect(initialPluginState("kept", "user", false)).toBe("disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("default look", () => {
|
||||
it("lists the plugins flagged as the default look", () => {
|
||||
writeIndex({
|
||||
"some-layout": { defaultLook: true },
|
||||
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
|
||||
other: { onboarding: { recommended: true } },
|
||||
});
|
||||
expect(defaultLookPluginIds()).toEqual(["some-layout", "some-themes"]);
|
||||
expect(isDefaultLookPlugin("some-themes")).toBe(true);
|
||||
expect(isDefaultLookPlugin("other")).toBe(false);
|
||||
});
|
||||
|
||||
it("has none without an index", () => {
|
||||
expect(defaultLookPluginIds()).toEqual([]);
|
||||
});
|
||||
|
||||
it("starts the default look enabled even on a fresh install", () => {
|
||||
writeIndex({
|
||||
"some-themes": { defaultLook: true, onboarding: { recommended: false } },
|
||||
});
|
||||
expect(initialPluginState("some-themes", "bundled", true)).toBe("enabled");
|
||||
expect(initialPluginState("some-themes", "user", true)).toBe("disabled");
|
||||
});
|
||||
});
|
||||
@@ -132,6 +132,85 @@ describe("PluginLoader discovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader themes and skipped looks", () => {
|
||||
const themed = {
|
||||
capabilities: ["ui:theme", "ui:layout"],
|
||||
manifestOverrides: {
|
||||
contributes: {
|
||||
themes: [
|
||||
{
|
||||
id: "paper",
|
||||
label: "Paper",
|
||||
mode: "light",
|
||||
css: "themes/paper.css",
|
||||
},
|
||||
],
|
||||
layouts: [{ id: "fancy", label: "Fancy" }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("loads a plugin whose theme CSS is allowed", async () => {
|
||||
const fixture = createFixturePlugin(themed);
|
||||
cleanups.push(fixture.cleanup);
|
||||
fs.mkdirSync(path.join(fixture.dir, "themes"));
|
||||
fs.writeFileSync(
|
||||
path.join(fixture.dir, "themes", "paper.css"),
|
||||
".paper { --background: #fff; }",
|
||||
);
|
||||
const plugin = await new PluginLoader().load(fixture.dir, "bundled");
|
||||
expect(plugin.manifest.contributes?.themes?.[0].id).toBe("paper");
|
||||
});
|
||||
|
||||
it("refuses a plugin whose theme CSS reaches outside", async () => {
|
||||
const fixture = createFixturePlugin(themed);
|
||||
cleanups.push(fixture.cleanup);
|
||||
fs.mkdirSync(path.join(fixture.dir, "themes"));
|
||||
fs.writeFileSync(
|
||||
path.join(fixture.dir, "themes", "paper.css"),
|
||||
'@import url("https://example.com/x.css");',
|
||||
);
|
||||
await expect(
|
||||
new PluginLoader().load(fixture.dir, "bundled"),
|
||||
).rejects.toThrow(/theme paper is not allowed: @import/);
|
||||
});
|
||||
|
||||
it("refuses a plugin whose theme file is missing", async () => {
|
||||
const fixture = createFixturePlugin(themed);
|
||||
cleanups.push(fixture.cleanup);
|
||||
await expect(
|
||||
new PluginLoader().load(fixture.dir, "bundled"),
|
||||
).rejects.toThrow(/theme paper file themes\/paper.css is missing/);
|
||||
});
|
||||
|
||||
it("remembers why a layout plugin was skipped", async () => {
|
||||
const bundled = tempRoot("termix-bundled-");
|
||||
const user = tempRoot("termix-data-");
|
||||
createFixturePlugin({
|
||||
...themed,
|
||||
id: "fancy-layout",
|
||||
root: bundled,
|
||||
manifestOverrides: {
|
||||
...themed.manifestOverrides,
|
||||
version: "2.0.0",
|
||||
engine: { termix: ">=99.0.0", api: "1" },
|
||||
},
|
||||
});
|
||||
setRoots(bundled, user);
|
||||
|
||||
const loader = new PluginLoader();
|
||||
await loader.loadAll();
|
||||
const [skipped] = loader.skipped();
|
||||
expect(skipped).toMatchObject({
|
||||
id: "fancy-layout",
|
||||
version: "2.0.0",
|
||||
layouts: ["fancy"],
|
||||
themes: ["paper"],
|
||||
});
|
||||
expect(skipped.error).toMatch(/needs Termix >=99.0.0/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PluginLoader dependency resolution", () => {
|
||||
async function loadAll(
|
||||
specs: Array<{ id: string; manifestOverrides?: Record<string, unknown> }>,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const index = vi.hoisted(() => ({ ids: [] as string[] }));
|
||||
vi.mock("../../plugins/bundled-index.js", () => ({
|
||||
defaultLookPluginIds: () => index.ids,
|
||||
}));
|
||||
|
||||
import {
|
||||
installedLayoutIds,
|
||||
resolveDefaultLook,
|
||||
safeLayoutForced,
|
||||
unavailableLooks,
|
||||
} from "../../plugins/looks.js";
|
||||
import type { SkippedPlugin } from "../../plugins/loader.js";
|
||||
|
||||
type Contributes = Record<string, unknown>;
|
||||
|
||||
function loaderWith(
|
||||
plugins: Record<string, Contributes>,
|
||||
skipped: SkippedPlugin[] = [],
|
||||
) {
|
||||
return {
|
||||
get: (id: string) =>
|
||||
plugins[id]
|
||||
? ({ manifest: { contributes: plugins[id] } } as never)
|
||||
: undefined,
|
||||
list: () =>
|
||||
Object.values(plugins).map(
|
||||
(contributes) => ({ manifest: { contributes } }) as never,
|
||||
),
|
||||
skipped: () => skipped,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
index.ids = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
delete process.env.TERMIX_SAFE_LAYOUT;
|
||||
});
|
||||
|
||||
describe("resolveDefaultLook", () => {
|
||||
it("is empty without a default-look plugin", () => {
|
||||
expect(resolveDefaultLook(loaderWith({}))).toEqual({
|
||||
layout: null,
|
||||
theme: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the layout marked default and its theme", () => {
|
||||
index.ids = ["look-a"];
|
||||
const loader = loaderWith({
|
||||
"look-a": {
|
||||
layouts: [
|
||||
{ id: "first", label: "First", defaultTheme: "one" },
|
||||
{ id: "main", label: "Main", defaultTheme: "two", default: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(resolveDefaultLook(loader)).toEqual({
|
||||
layout: "main",
|
||||
theme: "two",
|
||||
});
|
||||
});
|
||||
|
||||
it("takes the first layout, and a theme from a themes-only plugin", () => {
|
||||
index.ids = ["look-a", "look-b"];
|
||||
const loader = loaderWith({
|
||||
"look-a": { themes: [{ id: "paper", label: "Paper" }] },
|
||||
"look-b": { layouts: [{ id: "solo", label: "Solo" }] },
|
||||
});
|
||||
expect(resolveDefaultLook(loader)).toEqual({
|
||||
layout: "solo",
|
||||
theme: "paper",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips a default-look plugin that did not load", () => {
|
||||
index.ids = ["gone"];
|
||||
expect(resolveDefaultLook(loaderWith({}))).toEqual({
|
||||
layout: null,
|
||||
theme: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("unavailableLooks", () => {
|
||||
it("lists only skipped plugins that brought a layout or theme", () => {
|
||||
const skipped: SkippedPlugin[] = [
|
||||
{ id: "a", version: "1.0.0", layouts: ["x"], themes: [], error: "e" },
|
||||
{ id: "b", version: null, layouts: [], themes: [], error: "e" },
|
||||
{ id: "c", version: "2.0.0", layouts: [], themes: ["t"], error: "e" },
|
||||
];
|
||||
expect(
|
||||
unavailableLooks(loaderWith({}, skipped)).map((plugin) => plugin.id),
|
||||
).toEqual(["a", "c"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("safeLayoutForced", () => {
|
||||
it("reads TERMIX_SAFE_LAYOUT", () => {
|
||||
expect(safeLayoutForced()).toBe(false);
|
||||
process.env.TERMIX_SAFE_LAYOUT = "1";
|
||||
expect(safeLayoutForced()).toBe(true);
|
||||
process.env.TERMIX_SAFE_LAYOUT = "true";
|
||||
expect(safeLayoutForced()).toBe(true);
|
||||
process.env.TERMIX_SAFE_LAYOUT = "0";
|
||||
expect(safeLayoutForced()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installedLayoutIds", () => {
|
||||
it("lists layouts of loaded and skipped plugins", () => {
|
||||
const ids = installedLayoutIds(
|
||||
loaderWith({ a: { layouts: [{ id: "one" }], themes: [{ id: "t" }] } }, [
|
||||
{ id: "b", version: null, layouts: ["two"], themes: [], error: "x" },
|
||||
]),
|
||||
);
|
||||
expect([...ids].sort()).toEqual(["one", "two"]);
|
||||
});
|
||||
});
|
||||
@@ -289,6 +289,32 @@ describe("uninstall and reinstall of a bundled plugin", () => {
|
||||
expect(loader.get("docker")?.source).toBe("user");
|
||||
});
|
||||
|
||||
it("refuses to uninstall the default look but lets it be turned off", async () => {
|
||||
createFixturePlugin({ id: "some-look", root: bundled });
|
||||
fs.writeFileSync(
|
||||
path.join(bundled, "bundled-index.json"),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
plugins: { "some-look": { defaultLook: true } },
|
||||
}),
|
||||
);
|
||||
const { resetBundledIndexCache } =
|
||||
await import("../../plugins/bundled-index.js");
|
||||
resetBundledIndexCache();
|
||||
try {
|
||||
const loader = await boot();
|
||||
await expect(manage.uninstallPlugin("some-look")).rejects.toMatchObject({
|
||||
code: "DEFAULT_LOOK",
|
||||
status: 409,
|
||||
});
|
||||
expect(loader.get("some-look")?.state).toBe("active");
|
||||
expect(fs.existsSync(path.join(bundled, "some-look"))).toBe(true);
|
||||
} finally {
|
||||
fs.rmSync(path.join(bundled, "bundled-index.json"), { force: true });
|
||||
resetBundledIndexCache();
|
||||
}
|
||||
});
|
||||
|
||||
it("deletes a plugin dropped in as a plain folder", async () => {
|
||||
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
|
||||
const loader = await boot();
|
||||
@@ -635,6 +661,54 @@ describe("reloading a bundled plugin for the dev runner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoring the bundled copy", () => {
|
||||
it("drops a downloaded update, loads the shipped copy and pins it", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
const loader = await boot();
|
||||
// What a signed update leaves behind next to the shipped copy.
|
||||
const artifact = path.join(data, "plugins", "docker.tmxplug");
|
||||
fs.mkdirSync(path.dirname(artifact), { recursive: true });
|
||||
fs.writeFileSync(artifact, "update");
|
||||
fs.writeFileSync(`${artifact}.sig`, "sig");
|
||||
|
||||
const result = await manage.restoreBundledPlugin("docker");
|
||||
|
||||
expect(result).toEqual({ id: "docker", version: "1.0.0", state: "active" });
|
||||
expect(loader.get("docker")?.manifest.version).toBe("1.0.0");
|
||||
expect(fs.existsSync(artifact)).toBe(false);
|
||||
expect(fs.existsSync(`${artifact}.sig`)).toBe(false);
|
||||
expect(db.rows.get("docker")).toMatchObject({
|
||||
pinnedVersion: "1.0.0",
|
||||
autoUpdate: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a plugin that is turned off stopped", async () => {
|
||||
createFixturePlugin({ id: "docker", root: bundled });
|
||||
const loader = await boot();
|
||||
await manage.setPluginState("docker", false);
|
||||
const result = await manage.restoreBundledPlugin("docker");
|
||||
expect(result.state).toBe("stopped");
|
||||
expect(loader.get("docker")?.state).toBe("loaded");
|
||||
});
|
||||
|
||||
it("refuses a plugin that has no bundled copy", async () => {
|
||||
createFixturePlugin({ id: "notes", root: path.join(data, "plugins") });
|
||||
await boot();
|
||||
await expect(manage.restoreBundledPlugin("notes")).rejects.toMatchObject({
|
||||
code: "NO_BUNDLED_COPY",
|
||||
status: 409,
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses a bad id", async () => {
|
||||
await expect(manage.restoreBundledPlugin("../x")).rejects.toMatchObject({
|
||||
code: "INVALID_ID",
|
||||
status: 400,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("consent", () => {
|
||||
it("refuses an install without the consented list, before any download", async () => {
|
||||
registry.index.plugins = [registryEntry("notes", [{ version: "1.0.0" }])];
|
||||
|
||||
@@ -1002,3 +1002,79 @@ describe("docs and env fields", () => {
|
||||
expect(pluginDocsPage(base, "", "top")).toBe(`${base}#top`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layouts and themes", () => {
|
||||
const layouts = [
|
||||
{
|
||||
id: "fancy",
|
||||
label: "layout.fancy",
|
||||
defaultTheme: "paper",
|
||||
default: true,
|
||||
},
|
||||
];
|
||||
const themes = [
|
||||
{
|
||||
id: "paper",
|
||||
label: "theme.paper",
|
||||
mode: "light",
|
||||
css: "themes/paper.css",
|
||||
swatch: { background: "#ffffff", panel: "#eeeeee", text: "#111111" },
|
||||
},
|
||||
];
|
||||
|
||||
it("accepts layouts and themes with their capabilities", () => {
|
||||
const { manifest, errors } = parseManifest(
|
||||
base({
|
||||
capabilities: ["ui:layout", "ui:theme"],
|
||||
contributes: { layouts, themes },
|
||||
}),
|
||||
);
|
||||
expect(errors).toEqual([]);
|
||||
expect(manifest?.contributes?.layouts?.[0].id).toBe("fancy");
|
||||
expect(manifest?.contributes?.themes?.[0].mode).toBe("light");
|
||||
});
|
||||
|
||||
it("needs ui:layout for layouts and ui:theme for themes", () => {
|
||||
const { errors } = parseManifest(
|
||||
base({ capabilities: ["kv:own"], contributes: { layouts, themes } }),
|
||||
);
|
||||
expect(errors.some((e) => e.includes('"ui:layout"'))).toBe(true);
|
||||
expect(errors.some((e) => e.includes('"ui:theme"'))).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses bad ids, modes, paths and swatches", () => {
|
||||
const errors = validateManifest(
|
||||
base({
|
||||
capabilities: ["ui:layout", "ui:theme"],
|
||||
contributes: {
|
||||
layouts: [
|
||||
{ id: "Fancy", label: "x" },
|
||||
{ id: "two", label: "x", default: true },
|
||||
{ id: "three", label: "x", default: true, extra: 1 },
|
||||
],
|
||||
themes: [
|
||||
{ id: "paper", label: "x", mode: "sepia", css: "../paper.css" },
|
||||
{ id: "ink", label: "x", mode: "dark", css: "ink.scss" },
|
||||
{
|
||||
id: "dusk",
|
||||
label: "x",
|
||||
mode: "dark",
|
||||
css: "dusk.css",
|
||||
swatch: { background: "red", panel: "#000", text: "#fff" },
|
||||
},
|
||||
{ id: "dusk", label: "x", mode: "dark", css: "dusk2.css" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const has = (text: string) => errors.some((e) => e.includes(text));
|
||||
expect(has("contributes.layouts[0].id must match")).toBe(true);
|
||||
expect(has("Only one entry")).toBe(true);
|
||||
expect(has('Unknown field "extra"')).toBe(true);
|
||||
expect(has('mode must be "light" or "dark"')).toBe(true);
|
||||
expect(has("relative path inside the plugin")).toBe(true);
|
||||
expect(has("must be a .css file")).toBe(true);
|
||||
expect(has("swatch.background must be a hex color")).toBe(true);
|
||||
expect(has('duplicates "dusk"')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateThemeCss } from "../../plugins/manifest.js";
|
||||
|
||||
describe("validateThemeCss", () => {
|
||||
it("accepts token rules scoped under the theme class", () => {
|
||||
const css = `
|
||||
/* Paper */
|
||||
.paper { --background: #fff; --foreground: #111; }
|
||||
:root.paper, html.paper .card { --card: #f8f8f8; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.paper * { transition: none; }
|
||||
}
|
||||
@supports (color: oklch(0 0 0)) { .paper { --border: oklch(0.9 0 0); } }
|
||||
.paper .logo { background: url("logo.svg") no-repeat; }
|
||||
.paper .dot { background: url(data:image/png;base64,AAAA); }
|
||||
`;
|
||||
expect(validateThemeCss(css, "paper")).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses selectors outside the theme", () => {
|
||||
const errors = validateThemeCss(
|
||||
".paper { --a: 1 } body { color: red } .paperish { --b: 2 } .dark { --c: 3 }",
|
||||
"paper",
|
||||
);
|
||||
expect(errors).toHaveLength(3);
|
||||
expect(errors[0]).toContain('"body"');
|
||||
});
|
||||
|
||||
it("refuses anything that loads or runs", () => {
|
||||
const errors = validateThemeCss(
|
||||
`@import url("https://evil.example/x.css");
|
||||
.paper { background: url(https://evil.example/a.png); }
|
||||
.paper { background: url(//evil.example/a.png); }
|
||||
.paper { width: expression(alert(1)); }
|
||||
.paper { background: url(javascript:alert(1)); }
|
||||
.paper { behavior: url(x.htc); }
|
||||
@font-face { font-family: x; src: url(x.woff2); }
|
||||
@keyframes spin { from { opacity: 0 } }`,
|
||||
"paper",
|
||||
);
|
||||
const has = (text: string) => errors.some((e) => e.includes(text));
|
||||
expect(has("@import is not allowed")).toBe(true);
|
||||
expect(has("url(https://evil.example/a.png) points outside")).toBe(true);
|
||||
expect(has("url(//evil.example/a.png) points outside")).toBe(true);
|
||||
expect(has("expression() is not allowed")).toBe(true);
|
||||
expect(has("javascript: is not allowed")).toBe(true);
|
||||
expect(has("Script bindings")).toBe(true);
|
||||
expect(has("@font-face is not allowed")).toBe(true);
|
||||
expect(has("@keyframes is not allowed")).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses nested rules and broken braces", () => {
|
||||
expect(validateThemeCss(".paper { .x { color: red } }", "paper")).toEqual([
|
||||
'Nested rules are not allowed in ".paper"',
|
||||
]);
|
||||
expect(validateThemeCss(".paper { color: red", "paper")).toEqual([
|
||||
"A rule is missing its closing brace",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not trip on braces inside strings", () => {
|
||||
expect(
|
||||
validateThemeCss('.paper .x::after { content: "{ }"; }', "paper"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,9 @@ import {
|
||||
} from "@/plugin-host/loader";
|
||||
import { settledPromise } from "@/plugin-host/plugin-store";
|
||||
import { resetShellBridge } from "@/plugin-host/shell-bridge";
|
||||
import { loadLook, resetLook } from "@/look/look-store";
|
||||
import { resetPluginThemes } from "@/look/theme-registry";
|
||||
import { resetPluginLayouts } from "@/look/layout-registry";
|
||||
import { preloadPermissions } from "@/hooks/use-permissions";
|
||||
import { getUserPreferences } from "@/api/open-tabs-api";
|
||||
import { standaloneViewFor } from "@/shell/tab-registry";
|
||||
@@ -209,6 +212,7 @@ function App() {
|
||||
.then(() => settledPromise()),
|
||||
preloadPermissions(),
|
||||
getUserPreferences().catch(() => undefined),
|
||||
loadLook(),
|
||||
]);
|
||||
await enterApp();
|
||||
})
|
||||
@@ -296,6 +300,7 @@ function App() {
|
||||
.then(() => settledPromise()),
|
||||
preloadPermissions(),
|
||||
getUserPreferences().catch(() => undefined),
|
||||
loadLook(),
|
||||
]);
|
||||
await enterApp();
|
||||
})();
|
||||
@@ -321,6 +326,9 @@ function App() {
|
||||
// should survive into the next session.
|
||||
void stopPluginRuntime();
|
||||
resetShellBridge();
|
||||
resetLook();
|
||||
resetPluginThemes();
|
||||
resetPluginLayouts();
|
||||
clearStoredAuth();
|
||||
localStorage.removeItem("jwt");
|
||||
if (isElectron() && options?.manual) {
|
||||
|
||||
@@ -223,7 +223,8 @@ export type AdminSection =
|
||||
| "api-keys"
|
||||
| "audit-log"
|
||||
| "ssl";
|
||||
export type ThemeId =
|
||||
export type ThemeId = string;
|
||||
export type LegacyThemeId =
|
||||
| "dark"
|
||||
| "light"
|
||||
| "system"
|
||||
|
||||
+3
-3174
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
||||
import { authApi } from "@/main-axios";
|
||||
|
||||
/** A layout or theme plugin the server found but could not load. */
|
||||
export interface UnavailableLook {
|
||||
id: string;
|
||||
version: string | null;
|
||||
layouts: string[];
|
||||
themes: string[];
|
||||
error: string;
|
||||
}
|
||||
|
||||
/** Everything the browser picks the layout and theme from. */
|
||||
export interface LookResponse {
|
||||
layoutId: string | null;
|
||||
themeId: string | null;
|
||||
/** Admin defaults. */
|
||||
defaults: { layout: string | null; theme: string | null };
|
||||
/** The default-look plugin's layout and theme. */
|
||||
defaultLook: { layout: string | null; theme: string | null };
|
||||
/** TERMIX_SAFE_LAYOUT is set on the server. */
|
||||
safeLayout: boolean;
|
||||
unavailable: UnavailableLook[];
|
||||
}
|
||||
|
||||
export async function getLook(): Promise<LookResponse> {
|
||||
const response = await authApi.get("/user-preferences/look");
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/** The user's own layout and theme; null follows the default. */
|
||||
export async function saveUserLook(look: {
|
||||
layoutId?: string | null;
|
||||
themeId?: string | null;
|
||||
}): Promise<void> {
|
||||
await authApi.put("/user-preferences", look);
|
||||
}
|
||||
|
||||
/** Admin only. Null clears a default. */
|
||||
export async function saveDefaultLook(defaults: {
|
||||
layout?: string | null;
|
||||
theme?: string | null;
|
||||
}): Promise<{ layout: string | null; theme: string | null }> {
|
||||
const response = await authApi.put(
|
||||
"/user-preferences/look/defaults",
|
||||
defaults,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
@@ -116,6 +116,10 @@ export interface UserPreferences {
|
||||
compactHostView?: boolean | null;
|
||||
statusColorScheme?: string | null;
|
||||
customKeybindings?: string | null;
|
||||
/** Layout this user chose; null follows the admin default. */
|
||||
layoutId?: string | null;
|
||||
/** Theme this user chose; null follows the admin default. */
|
||||
themeId?: string | null;
|
||||
}
|
||||
|
||||
export function parseCustomKeybindings(
|
||||
|
||||
@@ -78,6 +78,22 @@ export interface PluginViewContribution {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface PluginLayoutContribution {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
defaultTheme?: string;
|
||||
default?: boolean;
|
||||
}
|
||||
|
||||
export interface PluginThemeContribution {
|
||||
id: string;
|
||||
label: string;
|
||||
mode: "light" | "dark";
|
||||
css: string;
|
||||
swatch?: { background: string; panel: string; text: string };
|
||||
}
|
||||
|
||||
export interface PluginContributions {
|
||||
tabs?: PluginTabContribution[];
|
||||
panels?: PluginViewContribution[];
|
||||
@@ -87,6 +103,8 @@ export interface PluginContributions {
|
||||
settings?: PluginSettingsContribution;
|
||||
permissions?: { name: string; titleKey: string; descriptionKey: string }[];
|
||||
uiPresets?: PluginUiPresets;
|
||||
layouts?: PluginLayoutContribution[];
|
||||
themes?: PluginThemeContribution[];
|
||||
}
|
||||
|
||||
export {
|
||||
@@ -236,6 +254,10 @@ export interface PluginSummary {
|
||||
assetVersion?: string | null;
|
||||
/** "en" plus the xx_YY names of shipped translations. */
|
||||
locales?: string[];
|
||||
/** Cache key for the theme CSS files; null without themes. */
|
||||
themeVersion?: string | null;
|
||||
/** The app is drawn with this plugin by default; it cannot be uninstalled. */
|
||||
defaultLook?: boolean;
|
||||
|
||||
tier?: string;
|
||||
source?: string;
|
||||
@@ -497,6 +519,15 @@ export async function uninstallPlugin(pluginId: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts a bundled plugin back to the copy that shipped with this install. */
|
||||
export async function restoreBundledPlugin(pluginId: string): Promise<void> {
|
||||
try {
|
||||
await rbacApi.post(`${pluginPath(pluginId)}/restore-bundled`);
|
||||
} finally {
|
||||
announcePluginsChanged();
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewPluginState(
|
||||
pluginId: string,
|
||||
enabled: boolean,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import type { ThemeId } from "@/types/ui-types";
|
||||
import {
|
||||
SYSTEM_THEME,
|
||||
effectiveThemeId,
|
||||
getTheme,
|
||||
useThemes,
|
||||
} from "@/look/theme-registry";
|
||||
import { applyThemeCss } from "@/look/theme-css";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
@@ -10,22 +17,11 @@ type ThemeProviderProps = {
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: ThemeId;
|
||||
setTheme: (theme: ThemeId) => void;
|
||||
/** Pass persist false to show a theme without keeping it in this browser. */
|
||||
setTheme: (theme: ThemeId, persist?: boolean) => void;
|
||||
setThemePreview: (theme: ThemeId | null) => void;
|
||||
};
|
||||
|
||||
const ALL_THEME_CLASSES = [
|
||||
"light",
|
||||
"dark",
|
||||
"dracula",
|
||||
"catppuccin",
|
||||
"nord",
|
||||
"solarized",
|
||||
"tokyo-night",
|
||||
"one-dark",
|
||||
"gruvbox",
|
||||
];
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "dark",
|
||||
setTheme: () => null,
|
||||
@@ -34,6 +30,9 @@ const initialState: ThemeProviderState = {
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
/** The class last put on <html>, in case its theme has since gone away. */
|
||||
let appliedClass: string | null = null;
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "dark",
|
||||
@@ -44,29 +43,33 @@ export function ThemeProvider({
|
||||
() => (localStorage.getItem(storageKey) as ThemeId) || defaultTheme,
|
||||
);
|
||||
const [previewTheme, setPreviewTheme] = useState<ThemeId | null>(null);
|
||||
// Plugin themes arrive after login; the class and CSS follow them.
|
||||
const themes = useThemes();
|
||||
|
||||
const activeTheme = previewTheme ?? theme;
|
||||
// A saved theme whose plugin is gone shows as System.
|
||||
const shownTheme = effectiveThemeId(theme);
|
||||
const activeTheme = previewTheme ?? shownTheme;
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove(...ALL_THEME_CLASSES);
|
||||
root.classList.remove(...themes.map((entry) => entry.id));
|
||||
if (appliedClass) root.classList.remove(appliedClass);
|
||||
|
||||
if (activeTheme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(activeTheme);
|
||||
}, [activeTheme]);
|
||||
const applied =
|
||||
activeTheme === SYSTEM_THEME
|
||||
? window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light"
|
||||
: activeTheme;
|
||||
root.classList.add(applied);
|
||||
appliedClass = applied;
|
||||
applyThemeCss(applied, getTheme(applied));
|
||||
}, [activeTheme, themes]);
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: ThemeId) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
theme: shownTheme,
|
||||
setTheme: (theme: ThemeId, persist = true) => {
|
||||
if (persist) localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
setThemePreview: (preview: ThemeId | null) => {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,448 +0,0 @@
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ExternalLink,
|
||||
KeyRound,
|
||||
Plus,
|
||||
Server,
|
||||
Settings,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { VersionBadge } from "@/components/version-badge";
|
||||
import { Facts } from "@/components/panel-layout";
|
||||
import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
|
||||
import { activityTarget } from "@/lib/activity-types";
|
||||
import {
|
||||
defaultConnectAction,
|
||||
hostActionsFor,
|
||||
listHostActions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
import {
|
||||
getStatusClasses,
|
||||
useStatusColorScheme,
|
||||
} from "@/hooks/use-status-color-scheme";
|
||||
import { getDefaultConnectionTab } from "@/lib/host-connection-tabs";
|
||||
import type { RecentActivityItem } from "@/main-axios";
|
||||
import type { Host, TabType } from "@/types/ui-types";
|
||||
import { quarterBorders } from "./quarter-borders";
|
||||
import { parseServerTime } from "@/lib/server-time";
|
||||
|
||||
export type VersionStatus =
|
||||
"up_to_date" | "requires_update" | "beta" | "unknown";
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
aside,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
/** Sits beside the label, like the release channel. */
|
||||
aside?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
|
||||
{children}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{aside}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsStrip({
|
||||
hosts,
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
releaseUrl,
|
||||
dbHealth,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: VersionStatus;
|
||||
releaseUrl: string;
|
||||
dbHealth: "healthy" | "error";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const online = hosts.filter((h) => h.status === "online").length;
|
||||
const big = "text-2xl font-bold leading-none tracking-tight";
|
||||
return (
|
||||
<div className="grid h-full grid-cols-2 divide-x divide-border md:grid-cols-4">
|
||||
<Stat
|
||||
label={t("dashboard.version")}
|
||||
aside={
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
releaseUrl={releaseUrl}
|
||||
className="shrink-0 px-1 py-px text-[9px]"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className={`${big} truncate`} title={versionText || undefined}>
|
||||
{versionText || "-"}
|
||||
</span>
|
||||
</Stat>
|
||||
<Stat label={t("dashboard.uptime")}>
|
||||
<span className={big}>{uptimeFormatted || "-"}</span>
|
||||
</Stat>
|
||||
<Stat label={t("dashboard.database")}>
|
||||
<span
|
||||
className={`${big} ${dbHealth === "healthy" ? "text-accent-brand" : "text-destructive"}`}
|
||||
>
|
||||
{dbHealth === "healthy"
|
||||
? t("dashboard.healthy")
|
||||
: t("dashboard.error")}
|
||||
</span>
|
||||
</Stat>
|
||||
<Stat label={t("dashboardTab.hostsAvailable")}>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={big}>{online}</span>
|
||||
<span className="text-sm leading-none text-muted-foreground">
|
||||
/{hosts.length}
|
||||
</span>
|
||||
</div>
|
||||
</Stat>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CountersStrip({
|
||||
hosts,
|
||||
credentialCount,
|
||||
onOpenSingletonTab,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
credentialCount: number;
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const item =
|
||||
"flex items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50";
|
||||
return (
|
||||
<div className="grid h-full auto-cols-fr grid-flow-col divide-x divide-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenSingletonTab("host-manager")}
|
||||
className={item}
|
||||
>
|
||||
<Server className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xl font-bold leading-none tracking-tight">
|
||||
{hosts.length}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{t("dashboard.totalHosts")}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onOpenSingletonTab("host-manager", "host-manager:show-credentials")
|
||||
}
|
||||
className={item}
|
||||
>
|
||||
<KeyRound className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xl font-bold leading-none tracking-tight">
|
||||
{credentialCount}
|
||||
</span>
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</span>
|
||||
</button>
|
||||
<ComponentSlot slotId="dashboard.counters" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
hint,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
hint: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`group/btn flex h-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/50 ${className ?? ""}`}
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center border border-border bg-muted transition-colors group-hover/btn:border-accent-brand/40 group-hover/btn:bg-accent-brand/20">
|
||||
<Icon className="size-3 text-accent-brand" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-xs font-medium">{label}</span>
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{hint}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuickActions({
|
||||
onOpenSingletonTab,
|
||||
hosts,
|
||||
onOpenTab,
|
||||
isAdmin,
|
||||
}: {
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
hosts: Host[];
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pinnedHosts = hosts.filter((h) => h.pin).slice(0, 4);
|
||||
const endpoint = (host: Host) => {
|
||||
const protocol = host.enableSsh ? undefined : enabledHostProtocols(host)[0];
|
||||
const port = host.enableSsh
|
||||
? host.sshPort
|
||||
: protocol
|
||||
? protocolPort(host.pluginSettings, protocol)
|
||||
: host.port;
|
||||
return `${host.ip}:${port ?? host.port}`;
|
||||
};
|
||||
const actions = [
|
||||
{
|
||||
icon: Plus,
|
||||
label: t("dashboard.addHost"),
|
||||
hint: t("dashboardTab.registerNewServer"),
|
||||
onClick: () =>
|
||||
onOpenSingletonTab("host-manager", "host-manager:add-host"),
|
||||
},
|
||||
{
|
||||
icon: KeyRound,
|
||||
label: t("dashboard.addCredential"),
|
||||
hint: t("dashboardTab.storeSshKeysOrPasswords"),
|
||||
onClick: () =>
|
||||
onOpenSingletonTab("host-manager", "host-manager:add-credential"),
|
||||
},
|
||||
...(isAdmin
|
||||
? [
|
||||
{
|
||||
icon: Settings,
|
||||
label: t("dashboard.adminSettings"),
|
||||
hint: t("dashboardTab.manageUsersAndRoles"),
|
||||
onClick: () => onOpenSingletonTab("admin-settings"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: User,
|
||||
label: t("dashboard.userProfile"),
|
||||
hint: t("dashboardTab.manageYourAccount"),
|
||||
onClick: () => onOpenSingletonTab("user-profile"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col">
|
||||
<div className="grid flex-1 auto-rows-fr grid-cols-1 sm:grid-cols-2">
|
||||
{actions.map((action, i) => (
|
||||
<ActionButton
|
||||
key={action.label}
|
||||
{...action}
|
||||
className={quarterBorders(i, actions.length)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{pinnedHosts.length > 0 && (
|
||||
<div className="flex flex-col border-t border-border">
|
||||
{pinnedHosts.map((host) => {
|
||||
const Icon =
|
||||
defaultConnectAction(listHostActions(), host)?.icon ?? Server;
|
||||
return (
|
||||
<button
|
||||
key={host.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const type = getDefaultConnectionTab(host);
|
||||
if (type) onOpenTab(host, type);
|
||||
}}
|
||||
className="flex items-center gap-2 border-b border-border/60 px-3 py-1.5 text-left transition-colors last:border-b-0 hover:bg-muted/50"
|
||||
>
|
||||
<Icon className="size-3 shrink-0 text-accent-brand" />
|
||||
<span className="truncate text-xs font-medium">
|
||||
{host.name || host.ip}
|
||||
</span>
|
||||
<span className="ml-auto truncate font-mono text-[10px] text-muted-foreground">
|
||||
{endpoint(host)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostStatusList({
|
||||
hosts,
|
||||
onOpenTab,
|
||||
statusLoading,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
statusLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useStatusColorScheme();
|
||||
// A phone has no room for the metric bars next to the host name.
|
||||
const isMobile = useIsMobile();
|
||||
if (hosts.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
|
||||
{t("dashboardTab.noHostsConfigured")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{hosts.map((host) => {
|
||||
const availability = host.status ?? "offline";
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
// An overview action (a metrics view) wins over connecting.
|
||||
const actions = hostActionsFor(listHostActions(), host);
|
||||
const target =
|
||||
actions.find((action) => action.overview)?.tabType ??
|
||||
defaultConnectAction(actions, host)?.tabType;
|
||||
if (target) onOpenTab(host, target);
|
||||
}}
|
||||
className="group/row flex min-w-0 cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(availability, scheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<Facts className="min-w-0">
|
||||
<span
|
||||
className="truncate text-xs font-medium"
|
||||
title={host.name}
|
||||
>
|
||||
{host.name}
|
||||
</span>
|
||||
<span
|
||||
className="truncate font-mono text-[10px] text-muted-foreground"
|
||||
title={host.ip}
|
||||
>
|
||||
{host.ip}
|
||||
</span>
|
||||
</Facts>
|
||||
</span>
|
||||
{/* Plugins add live details to a host row here. */}
|
||||
{!isMobile && (
|
||||
<ComponentSlot
|
||||
slotId="dashboard.hostRow"
|
||||
props={{
|
||||
hostId: Number(host.id),
|
||||
host,
|
||||
online: availability === "online",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`shrink-0 border px-1.5 py-0.5 text-[10px] font-semibold ${getStatusClasses(availability, scheme, "badge", statusLoading)}`}
|
||||
>
|
||||
{statusLoading || availability === "unknown"
|
||||
? t("dashboardTab.checking")
|
||||
: availability === "online"
|
||||
? t("hosts.status.online")
|
||||
: t("hosts.status.offline")}
|
||||
</span>
|
||||
<ExternalLink className="size-2.5 shrink-0 text-muted-foreground/0 transition-colors group-hover/row:text-muted-foreground/60" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RecentActivityList({
|
||||
activity,
|
||||
hosts,
|
||||
onOpenTab,
|
||||
statusLoading,
|
||||
}: {
|
||||
activity: RecentActivityItem[];
|
||||
hosts: Host[];
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
statusLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scheme = useStatusColorScheme();
|
||||
const ago = (ts: string) => {
|
||||
const diff = Math.floor(
|
||||
(Date.now() - parseServerTime(ts).getTime()) / 1000,
|
||||
);
|
||||
if (diff < 60) return t("dashboard.justNow");
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
};
|
||||
if (activity.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center py-8 text-xs text-muted-foreground/60">
|
||||
{t("dashboard.noRecentActivity")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{activity.map((item) => {
|
||||
const host = hosts.find((h) => h.id === String(item.hostId));
|
||||
const target = activityTarget(item.type);
|
||||
const Icon = target?.icon ?? Server;
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (host && target) onOpenTab(host, target.tab);
|
||||
}}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${getStatusClasses(host?.status ?? false, scheme, "dot", statusLoading)}`}
|
||||
/>
|
||||
<Facts className="min-w-0">
|
||||
<span
|
||||
className="truncate text-xs font-medium"
|
||||
title={item.hostName}
|
||||
>
|
||||
{item.hostName}
|
||||
</span>
|
||||
<span className="flex min-w-0 items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Icon className="size-2.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{target?.labelKey
|
||||
? t(target.labelKey)
|
||||
: item.type.replace("_", " ")}
|
||||
</span>
|
||||
</span>
|
||||
</Facts>
|
||||
<span className="ml-auto shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{ago(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Lines only between cells, so the actions read as quarters: one column on
|
||||
* small screens, two above. An odd last cell spans the full row.
|
||||
*/
|
||||
export function quarterBorders(index: number, count: number): string {
|
||||
const classes = ["border-border"];
|
||||
const last = index === count - 1;
|
||||
if (!last) classes.push("border-b");
|
||||
if (Math.floor(index / 2) === Math.floor((count - 1) / 2)) {
|
||||
classes.push("sm:border-b-0");
|
||||
}
|
||||
if (index % 2 === 0 && !last) classes.push("sm:border-r");
|
||||
if (last && count % 2 === 1) classes.push("sm:col-span-2");
|
||||
return classes.join(" ");
|
||||
}
|
||||
@@ -136,288 +136,6 @@
|
||||
--terminal-bg: #0c0d0b;
|
||||
}
|
||||
|
||||
/* ── Dracula ────────────────────────────────────────────────── */
|
||||
.dracula {
|
||||
--background: #282a36;
|
||||
--foreground: #f8f8f2;
|
||||
--card: #2d303d;
|
||||
--card-foreground: #f8f8f2;
|
||||
--popover: #2d303d;
|
||||
--popover-foreground: #f8f8f2;
|
||||
--primary: #8be9fd;
|
||||
--primary-foreground: #282a36;
|
||||
--secondary: #50536b;
|
||||
--secondary-foreground: #f8f8f2;
|
||||
--surface: #2d303d;
|
||||
--surface-dim: #21222c;
|
||||
--muted: #50536b;
|
||||
--muted-foreground: #a8b3d9;
|
||||
--accent: #5a5e7a;
|
||||
--accent-foreground: #f8f8f2;
|
||||
--destructive: #ff5555;
|
||||
--border: rgba(168, 179, 217, 0.18);
|
||||
--input: rgba(168, 179, 217, 0.14);
|
||||
--ring: #8be9fd;
|
||||
--chart-1: #ff79c6;
|
||||
--chart-2: #8be9fd;
|
||||
--chart-3: #50fa7b;
|
||||
--chart-4: #ffb86c;
|
||||
--chart-5: #bd93f9;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #21222c;
|
||||
--sidebar-foreground: #f8f8f2;
|
||||
--sidebar-primary: #8be9fd;
|
||||
--sidebar-primary-foreground: #282a36;
|
||||
--sidebar-accent: #5a5e7a;
|
||||
--sidebar-accent-foreground: #f8f8f2;
|
||||
--sidebar-border: rgba(168, 179, 217, 0.18);
|
||||
--sidebar-ring: #8be9fd;
|
||||
--warning: #f1fa8c;
|
||||
--terminal-bg: #282a36;
|
||||
}
|
||||
|
||||
/* ── Catppuccin Mocha ───────────────────────────────────────── */
|
||||
.catppuccin {
|
||||
--background: #1e1e2e;
|
||||
--foreground: #cdd6f4;
|
||||
--card: #202235;
|
||||
--card-foreground: #cdd6f4;
|
||||
--popover: #202235;
|
||||
--popover-foreground: #cdd6f4;
|
||||
--primary: #89b4fa;
|
||||
--primary-foreground: #1e1e2e;
|
||||
--secondary: #3b3d52;
|
||||
--secondary-foreground: #cdd6f4;
|
||||
--surface: #202235;
|
||||
--surface-dim: #181825;
|
||||
--muted: #3b3d52;
|
||||
--muted-foreground: #b8c0e0;
|
||||
--accent: #45475a;
|
||||
--accent-foreground: #cdd6f4;
|
||||
--destructive: #f38ba8;
|
||||
--border: rgba(186, 194, 222, 0.18);
|
||||
--input: rgba(186, 194, 222, 0.14);
|
||||
--ring: #89b4fa;
|
||||
--chart-1: #f38ba8;
|
||||
--chart-2: #89dceb;
|
||||
--chart-3: #a6e3a1;
|
||||
--chart-4: #fab387;
|
||||
--chart-5: #cba6f7;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #181825;
|
||||
--sidebar-foreground: #cdd6f4;
|
||||
--sidebar-primary: #89b4fa;
|
||||
--sidebar-primary-foreground: #1e1e2e;
|
||||
--sidebar-accent: #45475a;
|
||||
--sidebar-accent-foreground: #cdd6f4;
|
||||
--sidebar-border: rgba(186, 194, 222, 0.18);
|
||||
--sidebar-ring: #89b4fa;
|
||||
--warning: #f9e2af;
|
||||
--terminal-bg: #1e1e2e;
|
||||
}
|
||||
|
||||
/* ── Nord ───────────────────────────────────────────────────── */
|
||||
.nord {
|
||||
--background: #2e3440;
|
||||
--foreground: #eceff4;
|
||||
--card: #353c4a;
|
||||
--card-foreground: #eceff4;
|
||||
--popover: #353c4a;
|
||||
--popover-foreground: #eceff4;
|
||||
--primary: #88c0d0;
|
||||
--primary-foreground: #2e3440;
|
||||
--secondary: #434c5e;
|
||||
--secondary-foreground: #eceff4;
|
||||
--surface: #353c4a;
|
||||
--surface-dim: #2a303b;
|
||||
--muted: #434c5e;
|
||||
--muted-foreground: #b4c0d4;
|
||||
--accent: #4c566a;
|
||||
--accent-foreground: #eceff4;
|
||||
--destructive: #bf616a;
|
||||
--border: rgba(180, 192, 212, 0.18);
|
||||
--input: rgba(180, 192, 212, 0.14);
|
||||
--ring: #88c0d0;
|
||||
--chart-1: #bf616a;
|
||||
--chart-2: #88c0d0;
|
||||
--chart-3: #a3be8c;
|
||||
--chart-4: #ebcb8b;
|
||||
--chart-5: #b48ead;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #2a303b;
|
||||
--sidebar-foreground: #eceff4;
|
||||
--sidebar-primary: #88c0d0;
|
||||
--sidebar-primary-foreground: #2e3440;
|
||||
--sidebar-accent: #4c566a;
|
||||
--sidebar-accent-foreground: #eceff4;
|
||||
--sidebar-border: rgba(180, 192, 212, 0.18);
|
||||
--sidebar-ring: #88c0d0;
|
||||
--warning: #ebcb8b;
|
||||
--terminal-bg: #2e3440;
|
||||
}
|
||||
|
||||
/* ── Solarized Dark ─────────────────────────────────────────── */
|
||||
.solarized {
|
||||
--background: #002b36;
|
||||
--foreground: #b7c4c4;
|
||||
--card: #073642;
|
||||
--card-foreground: #b7c4c4;
|
||||
--popover: #073642;
|
||||
--popover-foreground: #b7c4c4;
|
||||
--primary: #268bd2;
|
||||
--primary-foreground: #002b36;
|
||||
--secondary: #0b4654;
|
||||
--secondary-foreground: #b7c4c4;
|
||||
--surface: #073642;
|
||||
--surface-dim: #00212b;
|
||||
--muted: #0b4654;
|
||||
--muted-foreground: #9fb3b3;
|
||||
--accent: #105667;
|
||||
--accent-foreground: #d5dddd;
|
||||
--destructive: #dc322f;
|
||||
--border: rgba(159, 179, 179, 0.2);
|
||||
--input: rgba(159, 179, 179, 0.16);
|
||||
--ring: #268bd2;
|
||||
--chart-1: #dc322f;
|
||||
--chart-2: #2aa198;
|
||||
--chart-3: #859900;
|
||||
--chart-4: #b58900;
|
||||
--chart-5: #6c71c4;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #073642;
|
||||
--sidebar-foreground: #b7c4c4;
|
||||
--sidebar-primary: #268bd2;
|
||||
--sidebar-primary-foreground: #002b36;
|
||||
--sidebar-accent: #105667;
|
||||
--sidebar-accent-foreground: #d5dddd;
|
||||
--sidebar-border: rgba(159, 179, 179, 0.2);
|
||||
--sidebar-ring: #268bd2;
|
||||
--warning: #b58900;
|
||||
--terminal-bg: #002b36;
|
||||
}
|
||||
|
||||
/* ── Tokyo Night ────────────────────────────────────────────── */
|
||||
|
||||
.tokyo-night {
|
||||
--background: #1a1b26;
|
||||
--foreground: #c0caf5;
|
||||
--card: #1f2335;
|
||||
--card-foreground: #c0caf5;
|
||||
--popover: #1f2335;
|
||||
--popover-foreground: #c0caf5;
|
||||
--primary: #7aa2f7;
|
||||
--primary-foreground: #1a1b26;
|
||||
--secondary: #2a2f47;
|
||||
--secondary-foreground: #c0caf5;
|
||||
--surface: #1f2335;
|
||||
--surface-dim: #16161e;
|
||||
--muted: #2a2f47;
|
||||
--muted-foreground: #8b93bd;
|
||||
--accent: #2f3654;
|
||||
--accent-foreground: #c0caf5;
|
||||
--destructive: #f7768e;
|
||||
--border: rgba(139, 147, 189, 0.2);
|
||||
--input: rgba(139, 147, 189, 0.16);
|
||||
--ring: #7aa2f7;
|
||||
--chart-1: #f7768e;
|
||||
--chart-2: #7dcfff;
|
||||
--chart-3: #9ece6a;
|
||||
--chart-4: #e0af68;
|
||||
--chart-5: #bb9af7;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #16161e;
|
||||
--sidebar-foreground: #c0caf5;
|
||||
--sidebar-primary: #7aa2f7;
|
||||
--sidebar-primary-foreground: #1a1b26;
|
||||
--sidebar-accent: #2f3654;
|
||||
--sidebar-accent-foreground: #c0caf5;
|
||||
--sidebar-border: rgba(139, 147, 189, 0.2);
|
||||
--sidebar-ring: #7aa2f7;
|
||||
--warning: #e0af68;
|
||||
--terminal-bg: #1a1b26;
|
||||
}
|
||||
|
||||
/* ── One Dark ───────────────────────────────────────────────── */
|
||||
.one-dark {
|
||||
--background: #282c34;
|
||||
--foreground: #abb2bf;
|
||||
--card: #2f343d;
|
||||
--card-foreground: #abb2bf;
|
||||
--popover: #2f343d;
|
||||
--popover-foreground: #abb2bf;
|
||||
--primary: #61afef;
|
||||
--primary-foreground: #282c34;
|
||||
--secondary: #3a404b;
|
||||
--secondary-foreground: #abb2bf;
|
||||
--surface: #2f343d;
|
||||
--surface-dim: #21252b;
|
||||
--muted: #3a404b;
|
||||
--muted-foreground: #9aa3b2;
|
||||
--accent: #424957;
|
||||
--accent-foreground: #abb2bf;
|
||||
--destructive: #e06c75;
|
||||
--border: rgba(154, 163, 178, 0.18);
|
||||
--input: rgba(154, 163, 178, 0.14);
|
||||
--ring: #61afef;
|
||||
--chart-1: #e06c75;
|
||||
--chart-2: #56b6c2;
|
||||
--chart-3: #98c379;
|
||||
--chart-4: #e5c07b;
|
||||
--chart-5: #c678dd;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #21252b;
|
||||
--sidebar-foreground: #abb2bf;
|
||||
--sidebar-primary: #61afef;
|
||||
--sidebar-primary-foreground: #282c34;
|
||||
--sidebar-accent: #424957;
|
||||
--sidebar-accent-foreground: #abb2bf;
|
||||
--sidebar-border: rgba(154, 163, 178, 0.18);
|
||||
--sidebar-ring: #61afef;
|
||||
--warning: #e5c07b;
|
||||
--terminal-bg: #282c34;
|
||||
}
|
||||
|
||||
/* ── Gruvbox Dark ───────────────────────────────────────────── */
|
||||
|
||||
.gruvbox {
|
||||
--background: #282828;
|
||||
--foreground: #ebdbb2;
|
||||
--card: #32302f;
|
||||
--card-foreground: #ebdbb2;
|
||||
--popover: #32302f;
|
||||
--popover-foreground: #ebdbb2;
|
||||
--primary: #fabd2f;
|
||||
--primary-foreground: #282828;
|
||||
--secondary: #45403d;
|
||||
--secondary-foreground: #ebdbb2;
|
||||
--surface: #32302f;
|
||||
--surface-dim: #1d2021;
|
||||
--muted: #45403d;
|
||||
--muted-foreground: #c4b59d;
|
||||
--accent: #504945;
|
||||
--accent-foreground: #ebdbb2;
|
||||
--destructive: #fb4934;
|
||||
--border: rgba(196, 181, 157, 0.18);
|
||||
--input: rgba(196, 181, 157, 0.14);
|
||||
--ring: #fabd2f;
|
||||
--chart-1: #fb4934;
|
||||
--chart-2: #8ec07c;
|
||||
--chart-3: #b8bb26;
|
||||
--chart-4: #fabd2f;
|
||||
--chart-5: #d3869b;
|
||||
--radius: 0.625rem;
|
||||
--sidebar: #1d2021;
|
||||
--sidebar-foreground: #ebdbb2;
|
||||
--sidebar-primary: #fabd2f;
|
||||
--sidebar-primary-foreground: #282828;
|
||||
--sidebar-accent: #504945;
|
||||
--sidebar-accent-foreground: #ebdbb2;
|
||||
--sidebar-border: rgba(196, 181, 157, 0.18);
|
||||
--sidebar-ring: #fabd2f;
|
||||
--warning: #fabd2f;
|
||||
--terminal-bg: #282828;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
/* Tailwind v4 defaults border-color to currentColor, so any bare "border"
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/** The version of this build, empty in a dev build. */
|
||||
export const APP_VERSION: string | undefined =
|
||||
import.meta.env.VITE_APP_VERSION || undefined;
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { TFunction } from "i18next";
|
||||
/** The part of a translate function these helpers use. */
|
||||
export type Translator = (
|
||||
key: string,
|
||||
options?: Record<string, unknown>,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
@@ -28,7 +32,7 @@ export function pluginKey(
|
||||
* listed in a registry, shows the manifest name.
|
||||
*/
|
||||
export function pluginName(
|
||||
t: TFunction,
|
||||
t: Translator,
|
||||
plugin: { id: string; name: string },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.name"), { defaultValue: plugin.name });
|
||||
@@ -36,7 +40,7 @@ export function pluginName(
|
||||
|
||||
/** Same as pluginName, for "plugin.description". */
|
||||
export function pluginDescription(
|
||||
t: TFunction,
|
||||
t: Translator,
|
||||
plugin: { id: string; description?: string | null },
|
||||
): string {
|
||||
return t(pluginKey(plugin.id, "plugin.description"), {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { Translator } from "./plugin-i18n";
|
||||
|
||||
/** System roles store an i18n key as their display name. */
|
||||
export function roleLabel<T extends string | null | undefined>(
|
||||
t: TFunction,
|
||||
t: Translator,
|
||||
displayName: T,
|
||||
): T | string {
|
||||
return typeof displayName === "string" &&
|
||||
|
||||
+88
-857
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 330 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user