* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

+102
View File
@@ -1,5 +1,107 @@
# Changelog
## 1.0.19
Plugin API 1.11.0.
### Added
- `app.registerCompanionLayout` and `useCompanions`, so a plugin can let each host turn the panel bar on or off, move it and choose which panels show and in what order
## 1.0.18
Plugin API 1.10.0.
### Added
- `clientCert` on the sync link, link patch and remote target, and the `client_cert` remote error kind, so a sync plugin can link to a server that needs mutual TLS
## 1.0.17
Plugin API 1.9.0.
### Added
- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running
- `NativeView` in `@termix-ssh/plugin-sdk/ui`, which shows a web page in a native view in the desktop app, for sites that do not work in an iframe
## 1.0.16
Plugin API 1.8.0.
### Added
- `ctx.sync.provide` and the `sync:provide` capability, so a plugin can answer every /sync request
- `ctx.ssh.transports.provide` and the `ssh:transport` capability, so a plugin can carry SSH connections over its own stream
- `ctx.hosts.folders.ensure` and `removeEmpty`
### Removed
- The sync screens and calls in `@termix-ssh/plugin-sdk/ui`: `LinkServerDialog`, `ProxySettingsFields`, `useSyncStatus`, `refreshSyncStatus`, `setSyncStatus`, `getSyncConflicts`, `getSyncErrors`, `retrySyncErrors`, `settleSyncConflict`, `syncNow`, `unlinkServer`, `updateSyncSettings` and their types. They moved into the sync plugin
## 1.0.15
Plugin API 1.7.0.
### Added
- `ctx.auth.policy.provide` and the `auth:policy` capability, so a plugin can set an idle timeout, cap session length, turn off remember me and require a second factor
- `ctx.auth.listSecondFactors` lists the second factors from every running plugin
- `AuthEnrollmentSections` takes `factorsOnly`
- `getHttpsSettings` and `updateHttpsSettings` in `@termix-ssh/plugin-sdk/ui`
### Fixed
- Plugin frontend tests against a locally linked SDK failed with a second copy of React
## 1.0.14
Plugin API 1.6.0.
### Added
- `ctx.hosts.locks.provide` and the `hosts:lock` capability, so a plugin can manage hosts that stay read only for everyone else and block adding hosts by hand
- `ctx.hosts.setShares` to replace every grant on a host in one call
- `ctx.credentials.list` returns the acting user's credentials by name, without secrets
- `ctx.hosts.create` and `update` take `password`, `key`, `keyPassword`, `keyType`, `sudoPassword`, `protocolAuth` and `pluginEnable`
## 1.0.13
Plugin API 1.5.0.
### Added
- `runtime: "desktop"` in the manifest for a plugin that only loads on the desktop app
- `desktop:local` and `ctx.desktop.local`: file system, local shell and machine info on the desktop app's own backend
- `ctx.audit.subscribe`, `ctx.audit.list` and `ctx.audit.exportBatches` under `audit:read`
- `ctx.hosts.importMany`, `importSshConfig`, `exportMany` and `registerImportNormalizer`, with the `hosts:export` capability
- `ctx.credentials.publicKey` and `resolveKeyRefs`, `ctx.ssh.exec`, and `@termix-ssh/plugin-sdk/ssh-keys`
- `ctx.hosts.status.provide` and `ctx.hosts.activity.provide` so a plugin can own host status and recent activity, with `ctx.network.tcpProbe`, `ctx.ssh.probe`, `ctx.hosts.probeTargets` and `ctx.hosts.sessionActive`
- `registerHostStatusIndicator` and `registerHostEditorToolbarItem`, and `order` for host badges
- Onboarding steps can sit in a `start` section, take a `position` and `replaces` a core step
- A plugin can provide the app name, tagline and logo through the `<id>.brandingProvider` registry key
## 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.
+244 -84
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.11 | Plugin API 1.3.0
SDK 1.0.19 | Plugin API 1.11.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.
@@ -164,7 +164,7 @@ Unknown fields are refused at every level, so a typo fails validation. The schem
| `icon` | A [Lucide](https://lucide.dev/icons/) icon name, like `Hand`. |
| `features` | Up to 20 short lines shown on the plugin's page. |
| `docs` | An https link to your docs. |
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. |
| `engine.termix` | Termix versions you work with, like `>=26.10.0`. Older Termix builds don't list a release they can't run. |
| `engine.api` | Plugin API range, like `^1.2`. A major bump of the API is breaking. |
| `capabilities` | What your code may do. See the reference at the end. |
| `dependencies`, `optionalDependencies` | Other plugins, `{ "snippets": "^1.0.0" }`. You must keep working without optional ones. |
@@ -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.
@@ -202,36 +202,40 @@ Bundled as ESM for Node 22+ into `dist/backend.js`. Backend imports of your own
### What ctx has
| Member | Capability | What it does |
| ---------------------- | -------------------------------- | -------------------------------------------------------------- |
| `pluginId`, `manifest` | none | Your id and manifest |
| `log` | none | Log lines tagged with your plugin |
| `db` | `db:own` | Your own tables |
| `kv` | `kv:own` | Small key value state |
| `files.dataDir()` | `files:own` | A folder of your own |
| `settings` | none | Your settings |
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
| `http`, `ws` | `network:serve` | Routes and sockets |
| `rbac` | none | Check the actor's permissions |
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
| `credentials` | varies | The actor's saved keys, and protocol logins |
| `services` | per service | Call other plugins and offer your own |
| `events` | `events:core` for core topics | Emit and listen for events |
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
| `process` | `process:spawn` | Run programs on the server |
| `schedule` | none | Timers that stop on disable and never overlap |
| `audit.record` | none | Write your own audit line |
| `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed |
| `asUser(userId, fn)` | always audited | Run background work as a user |
| `currentActor()` | none | The user the current call runs for |
| `disposables.add(fn)` | none | Extra cleanup on disable |
| Member | Capability | What it does |
| ---------------------- | -------------------------------- | --------------------------------------------------------------- |
| `pluginId`, `manifest` | none | Your id and manifest |
| `log` | none | Log lines tagged with your plugin |
| `db` | `db:own` | Your own tables |
| `kv` | `kv:own` | Small key value state |
| `files.dataDir()` | `files:own` | A folder of your own |
| `settings` | none | Your settings |
| `secrets` | `secrets:own` | Encrypted values per user, `seal`/`unseal` for your own tables |
| `http`, `ws` | `network:serve` | Routes and sockets |
| `rbac` | none | Check the actor's permissions |
| `hosts` | `hosts:read`, `hosts:write` | List, read, create, update and share hosts as the actor |
| `ssh` | `ssh:connect`, `credentials:use` | Connect to hosts through Termix's SSH pipeline |
| `credentials` | varies | The actor's saved keys, and protocol logins |
| `services` | per service | Call other plugins and offer your own |
| `events` | `events:core` for core topics | Emit and listen for events |
| `notify` | `notify:send` | Send alerts to the Alerts inbox |
| `fetch` | `network:outbound` | Outbound HTTP with SSRF protection |
| `process` | `process:spawn` | Run programs on the server |
| `schedule` | none | Timers that stop on disable and never overlap |
| `audit.record` | none | Write your own audit line |
| `sync` | none | Sync your tables to a linked desktop |
| `auth` | `auth:provide` | Login methods, second factors, SSH auth types |
| `auth.issueUserToken` | `users:impersonate` | A short-lived session for calling Termix's API as a user |
| `auth.policy` | `auth:policy` | Login rules: idle timeout, session length, second factors |
| `sync.provide` | `sync:provide` | Answers all /sync requests: server sync, linked desktops |
| `ssh.transports` | `ssh:transport` | Carries a host's SSH connection over your own stream |
| `hosts.folders` | `hosts:write` | Create a folder row with a color and icon, remove it when empty |
| `users.getLocale` | `users:read` | A user's UI language |
| `system` | `system:tls` | The server's HTTPS certificate |
| `plugins.list()` | `plugins:read` | Which plugins are installed |
| `asUser(userId, fn)` | always audited | Run background work as a user |
| `currentActor()` | none | The user the current call runs for |
| `disposables.add(fn)` | none | Extra cleanup on disable |
### The actor
@@ -300,6 +304,14 @@ router.post("/notes", async (req, res) => {
- `ctx.rbac.require("manage")` on a single route, `await ctx.rbac.has("manage")` in code. A full id like
`hosts.edit` checks a core permission.
- Host access: `ctx.hosts.checkAccess(hostId, "connect" | "view" | "edit" | "manage")`.
- Transports: with `ssh:transport`, `ctx.ssh.transports.provide({ claims(hostId), open({ id, ip, port }) })`
returns a stream the SSH client uses instead of a direct, proxy or jump host connection. Core also serves
`/plugin-ws/core/ssh-relay?hostId=` for callers with connect access, so one Termix can relay to another.
- Managed hosts: with `hosts:lock`, `ctx.hosts.locks.provide({ isLocked, note, allowManualCreate })` makes
the hosts you manage read only for the UI, the API and other plugins (pins and order still work), and
`allowManualCreate: () => false` blocks adding hosts by hand. Your own `ctx.hosts` writes still go
through. `ctx.hosts.setShares(hostId, grants)` replaces a host's whole grant list, and
`ctx.credentials.list()` finds the acting user's credentials by name.
### Errors
@@ -325,6 +337,24 @@ try {
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
### The desktop app
Set `"runtime": "desktop"` in the manifest for a plugin that only makes sense on a desktop app. It never
loads on a server, ignores server sync and may declare `desktop:local`. Any plugin can declare
`desktop:local` and check `ctx.desktop.local.available()` at runtime.
`ctx.desktop.local` works on the desktop app's own backend only:
- `info()`: home folder, platform, user data folder, backend port and the linked server (url and token) or null
- `fs`: `list`, `mkdir`, `createFile`, `rename`, `ensureDir`, `exists`, `walk`, `trash`, `reveal`, `open`,
`openRead` and `openDownload`. Downloads stay inside the folder the user picked, links in it are refused and
an existing file is never replaced by accident
- `pty.spawn()`: a local shell, stopped when the plugin stops
- `pluginFetch(origin, subPath, init)`: calls this plugin's own routes on the desktop's backend ("local") or the linked server ("remote") as the acting user. Bodies can be streams, so a plugin can move big files without the renderer
Use `ctx.audit.subscribe`, `list` and `exportBatches` (`audit:read`) to read the audit trail. Subscribers
are called after a line is accepted and never delay the action that wrote it.
### Events, timers, alerts, services
```ts
@@ -465,27 +495,42 @@ your plugin's error boundary and Suspense.
| ---------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `registerRailItem` | A sidebar rail button |
| `registerTab`, `registerPanel` | A tab type or sidebar panel (id in `contributes.tabs` / `panels`) |
| `registerCompanion`, `toggleCompanion` | A panel beside any host tab, bound to its host and handle |
| `registerCompanionLayout` | Per host on/off, side, order and hidden panels for a tab type |
| `registerDashboardCard` | A dashboard card (`contributes.dashboardCards`) |
| `registerHostAction` | A host button, palette entry or default connect |
| `registerHostEditorSection` | Your own host editor section (host settings get one for free) |
| `registerHostProtocol` | A connection protocol next to SSH |
| `registerHostBadge`, `registerHostContextMenuItem` | Badges and menu items on host rows |
| `registerHostStatusIndicator` | The status dot or stripe on a host row (a layout draws it) |
| `registerHostEditorToolbarItem` | A control in the host editor toolbar (a layout draws it) |
| `registerPaletteEntry`, `registerPaletteGroup` | Command palette entries |
| `registerKeybindingAction`, `registerKeybindingDefault` | Bindable actions |
| `registerSettingsComponent` | A component for a `custom` settings field |
| `registerAction`, `invokeAction` | Actions across plugins |
| `registerSlotContribution`, `declareActionSlot` | Fill or own a UI slot |
| `registerComponent`, `registerExtension` | Offer a component by id, add to an extension point |
| `registerOnboardingStep` | A first run setup step |
| `registerOnboardingStep` | A first run setup step (`section`, `position`, `replaces`) |
| `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 |
| `onSettingsChanged`, `onDispose` | Hooks |
Badges, status indicators and toolbar items take `{ id, when(host), component, order? }`; lower `order`
comes first. An onboarding step sits in a section (`start`, `setup`, `explore`, `security`) or at an exact
`position` from 1 to 99. `replaces: ["preset"]` lists older step keys the step took over, so people who saw
those are not shown it again.
Branding: a plugin that owns the app name, tagline and logo provides
`ctx.registry.provide("<id>.brandingProvider", async () => ({ appName, tagline, logo }))` in its backend.
Core reads it for `GET /users/branding` (the login page, the browser tab and the sync server name) and falls
back to "Termix" with no logo when no plugin provides one. `logo` is a PNG data URL or null.
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,12 +541,100 @@ 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()`.
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.
## Looking native
Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the theme. The full list of
exports with props is `ui.api.txt` in the Termix repo
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
- Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the
page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders
`fallback` in a browser.
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
@@ -578,6 +711,21 @@ closely. Declare ids in `contributes.auth` and register them with `ctx.auth.regi
`registerSecondFactor` or `registerSshAuthProvider`. Connection protocols go in `contributes.protocols`
and `app.registerHostProtocol`. Read https://docs.termix.site/develop/auth before starting one.
Login rules need `auth:policy`. `ctx.auth.policy.provide({ evaluate, allowRememberDevice })` is asked at
every login with the user, the method, the device and every second factor (`ctx.auth.listSecondFactors()`
lists them too). `evaluate` returns `allowRememberDevice`, `maxSessionSeconds`, `idleTimeoutSeconds` and
`secondFactor` (`"always"` or `"new-device"`). The strictest answer from all plugins wins, and a throw
refuses the login. A user who must have a second factor but has none gets a session that can only set one
up, and `/users/me` reports `restriction: "second-factor-setup"`.
Sync is answered by one plugin. `sync:provide` is high risk: `ctx.sync.provide({ handle })` takes over every
request under `/sync` (an Express style handler, 404 when nothing provides it), and `ctx.sync.host` hands
over the neutral parts core keeps: the replica store (`replica`), the change feed and push applier
(`feed`), the linked server list (`links`, one per local user), desktop accounts (`accounts`), sessions
(`sessions`), host lookups, plugin mirroring and the proxy settings the desktop app needs. A test passes its
own `syncHost` to `createMockCtx`. An API key can ask `POST /users/ws-ticket/api-key` for a one minute
ticket that opens plugin sockets as its user, which is how one server opens sessions through another.
## Testing
```ts
@@ -742,58 +890,70 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## SDK entry points
| Import | What it is |
| -------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers |
| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these |
| Import | What it is |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `@termix-ssh/plugin-sdk/backend` | `PluginContext` and the backend types |
| `@termix-ssh/plugin-sdk/frontend` | `TermixApp`, `TabProps` and the hooks |
| `@termix-ssh/plugin-sdk/ui` | The UI kit, served by Termix at runtime |
| `@termix-ssh/plugin-sdk/db` | `defineTable` and column builders |
| `@termix-ssh/plugin-sdk/manifest` | `PluginManifest` type and manifest checks |
| `@termix-ssh/plugin-sdk/capabilities` | The capability catalog |
| `@termix-ssh/plugin-sdk/settings` | Settings field validation |
| `@termix-ssh/plugin-sdk/testing` | `createMockCtx`, `createTestDb`, `renderWithApp`, `createFakeContext` |
| `@termix-ssh/plugin-sdk/vitest-preset` | `pluginVitestConfig` |
| `@termix-ssh/plugin-sdk/host-commands` | OS detection, package manager and sudo helpers over ssh2 |
| `@termix-ssh/plugin-sdk/ssh-certs` | `applyCertificateAuth()` for certificate based SSH auth |
| `@termix-ssh/plugin-sdk/ssh-keys` | `generateSshKeyPair()`, `detectSshKeyType()`, `publicKeyFromPrivate()`, `validateSshKeyPair()` |
| `@termix-ssh/plugin-sdk/desktop` | The desktop app bridge, typed. Check it exists before use |
| `@termix-ssh/plugin-sdk/openapi` | `buildOpenApi()` |
| `@termix-ssh/plugin-sdk/changelog` | Changelog parsing and checks |
| `@termix-ssh/plugin-sdk/docs` | Docs link and env var helpers |
| `@termix-ssh/plugin-sdk/ddl`, `@termix-ssh/plugin-sdk/table-builder` | Table to SQL and Drizzle mapping. You rarely need these |
## Capabilities
| Capability | Risk | What it allows |
| -------------------- | -------- | ------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `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) |
| `hosts:write` | medium | Add and edit hosts |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `settings:read-core` | low | Read Termix server settings |
| `plugins:read` | low | See installed plugins |
| `ui:surface` | low | Add tabs, panels and settings |
| Capability | Risk | What it allows |
| ------------------------ | -------- | ------------------------------------------------------------- |
| `credentials:read` | critical | Read stored secrets for hosts the user can reach |
| `system:tls` | critical | Replace the HTTPS certificate, answer CA domain checks |
| `ssh:connect` | high | Run any command on hosts, with the user's access |
| `process:spawn` | high | Run programs on the Termix server |
| `users:write` | high | Add users and change their roles |
| `users:impersonate` | high | Act as another user, or get a session for them, audited |
| `db:core-refs` | high | Query core users, roles and hosts tables (read only) |
| `auth:provide` | high | Add login methods, second factors, SSH auth types |
| `auth:policy` | high | Set login rules: idle timeout, session length, 2FA |
| `sync:provide` | high | Run sync: answer every /sync request |
| `device:serial` | high | Open serial and USB ports on the server |
| `desktop:local` | high | Use the user's own computer, desktop app only |
| `hosts:export` | high | Export hosts with their saved logins |
| `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 |
| `hosts:status-provide` | medium | Be the source of host status dots |
| `hosts:activity-provide` | medium | Be the source of the recent activity list |
| `ssh:transport` | high | Carry SSH connections for hosts it claims over its own stream |
| `hosts:lock` | medium | Make hosts it manages read only for everyone else |
| `network:probe` | medium | Check that a host answers on a TCP port |
| `credentials:use` | medium | Connect with saved credentials without seeing them |
| `credentials:write` | medium | Save new credentials |
| `network:outbound` | medium | Outbound HTTP through `ctx.fetch` |
| `network:serve` | medium | HTTP routes and sockets |
| `network:broadcast` | medium | Local network broadcasts, like Wake-on-LAN |
| `users:read` | medium | Usernames, roles and UI language |
| `events:core` | medium | Listen to core events |
| `notify:send` | medium | Send alerts |
| `audit:read` | medium | Read the audit log |
| `desktop:window` | medium | Open windows in the desktop app |
| `hosts:read` | low | Read hosts the user can see |
| `db:own` | low | Your own tables |
| `kv:own` | low | Small key value state |
| `files:own` | low | Your own folder on disk |
| `secrets:own` | low | Your own encrypted secrets |
| `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.
+36 -22
View File
@@ -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({
+12 -2
View File
@@ -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)}`);
+63 -3
View File
@@ -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);
+9
View File
@@ -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",
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.11",
"version": "1.0.19",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -85,6 +85,10 @@
"types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js"
},
"./ssh-keys": {
"types": "./dist/ssh-keys.d.ts",
"default": "./dist/ssh-keys.js"
},
"./tsconfig.plugin.json": "./tsconfig.plugin.json",
"./schema/manifest.schema.json": "./schema/manifest.schema.json",
"./ui": {
+125 -8
View File
@@ -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"
}
}
}
},
@@ -134,9 +151,13 @@
"process:spawn",
"users:write",
"auth:provide",
"auth:policy",
"sync:provide",
"ssh:transport",
"system:tls",
"device:serial",
"hosts:write",
"hosts:export",
"credentials:use",
"credentials:write",
"network:outbound",
@@ -146,7 +167,12 @@
"events:core",
"notify:send",
"audit:read",
"network:probe",
"hosts:status-provide",
"hosts:activity-provide",
"hosts:lock",
"desktop:window",
"desktop:local",
"users:impersonate",
"db:core-refs",
"hosts:read",
@@ -155,7 +181,11 @@
"files:own",
"secrets:own",
"settings:read-core",
"ui:surface"
"ui:surface",
"notify:hub",
"plugins:read",
"ui:layout",
"ui:theme"
]
}
},
@@ -944,7 +974,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 +1159,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}$"
}
}
}
}
}
}
}
},
@@ -1158,6 +1270,11 @@
"enum": ["mirror", "local", "server"],
"description": "How a desktop linked to a server treats this plugin: mirror follows the server, local is decided on each desktop, server never runs on a linked desktop. Defaults to mirror."
},
"runtime": {
"type": "string",
"enum": ["desktop"],
"description": "desktop: the plugin only loads on a desktop app's own embedded backend, never on a server. It implies desktop local and may declare desktop:local."
},
"nativeDependencies": {
"type": "array",
"minItems": 1,
File diff suppressed because it is too large. Load diff
+11
View File
@@ -47,20 +47,30 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [
entry("users:impersonate", "high"),
entry("db:core-refs", "high"),
entry("auth:provide", "high"),
entry("auth:policy", "high"),
entry("sync:provide", "high"),
entry("ssh:transport", "high"),
entry("device:serial", "high"),
entry("notify:hub", "high"),
entry("ui:layout", "high"),
entry("hosts:write", "medium"),
entry("hosts:export", "high"),
entry("credentials:use", "medium"),
entry("credentials:write", "medium"),
entry("network:outbound", "medium"),
entry("network:serve", "medium"),
entry("network:broadcast", "medium"),
entry("network:probe", "medium"),
entry("hosts:status-provide", "medium"),
entry("hosts:activity-provide", "medium"),
entry("hosts:lock", "medium"),
entry("users:read", "medium"),
entry("events:core", "medium"),
entry("notify:send", "medium"),
entry("audit:read", "medium"),
entry("desktop:window", "medium"),
entry("desktop:local", "high"),
entry("hosts:read", "low"),
entry("db:own", "low"),
@@ -70,6 +80,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(
+1
View File
@@ -256,6 +256,7 @@ export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
homepage_items: "homepage",
homepage_layouts: "homepage",
dashboard_service_links: "homepage",
recent_activity: "homepage",
secret_sources: "secret-sources",
opkssh_tokens: "opkssh",
vault_profiles: "vault",
+11 -286
View File
@@ -3,8 +3,6 @@
* only inside the Termix desktop app; check isElectron before calling it.
*/
import type { PluginSshPromptRequest } from "./backend.js";
export interface ServerConfig {
serverUrl?: string;
allowInvalidCertificate?: boolean;
@@ -33,31 +31,21 @@ export interface DialogResult {
[key: string]: unknown;
}
export interface LocalDirectoryResult {
success: boolean;
path: string;
parent?: string;
entries: LocalFileEntry[];
error?: string;
}
export interface LocalCollectedFile {
path: string;
name: string;
relativePath: string;
size: number;
created?: string;
modified: string;
}
export type LocalPathMutationResult =
| ({ success: true } & Partial<LocalFileEntry>)
| { success: false; error?: string };
export interface ElectronAPI {
getAppVersion: () => Promise<string>;
setZoomFactor?: (factor: number) => void;
getPlatform: () => Promise<string>;
/** Whether the app-drawn title bar shows in this window. */
getTitleBarVisible?: () => Promise<boolean>;
/** Calls back when the title bar shows or hides; returns an unsubscribe. */
onTitleBarVisibleChange?: (
callback: (visible: boolean) => void,
) => () => void;
/** Keeps the window controls the system draws in the colors of the theme. */
setTitleBarOverlay?: (colors: {
color?: string;
symbolColor?: string;
}) => Promise<void>;
getSetting?: (key: string) => Promise<string | null | undefined>;
setSetting?: (key: string, value: string) => Promise<void>;
@@ -67,60 +55,6 @@ export interface ElectronAPI {
serverUrl: string,
allowInvalidCertificate?: boolean,
) => Promise<ConnectionTestResult>;
answerC2SAuth: (id: string, answer: string | null) => Promise<boolean>;
onC2SAuthPrompt: (
callback: (prompt: {
id: string;
closed?: boolean;
tunnelName?: string;
request?: PluginSshPromptRequest;
}) => void,
) => () => void;
getC2STunnelConfig: () => Promise<unknown[]>;
saveC2STunnelConfig: (
config: unknown[],
) => Promise<{ success: boolean; error?: string }>;
checkLocalPortAvailable: (
host: string,
port: number,
) => Promise<{ available: boolean; error?: string }>;
getC2STunnelPresetDefaultName: () => Promise<string>;
startC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
tunnelName?: string;
error?: string;
code?: string;
}>;
testC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
message?: string;
error?: string;
code?: string;
}>;
stopC2STunnel: (
tunnelName: string,
) => Promise<{ success: boolean; error?: string }>;
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
onC2STunnelStatuses?: (
callback: (statuses: Record<string, unknown>) => void,
) => () => void;
startC2SAutoStartTunnels: () => Promise<{
success: boolean;
started: number;
errors: string[];
}>;
/** Where the linked server relays client tunnel streams, a /plugin-ws/ path. */
setC2SRelayPath?: (
relayPath: string,
) => Promise<{ success: boolean; error?: string }>;
onCloseActiveTab?: (callback: () => void) => () => void;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (
@@ -136,62 +70,6 @@ export interface ElectronAPI {
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
getLocalHomeDirectory?: () => Promise<string>;
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
statLocalPaths?: (
paths: string[],
) => Promise<
Array<
| (LocalFileEntry & { success: true })
| { success: false; path: string; error?: string }
>
>;
collectLocalFiles?: (paths: string[]) => Promise<{
success: boolean;
files: LocalCollectedFile[];
truncated?: boolean;
error?: string;
}>;
createLocalFolder?: (
parentPath: string,
folderName: string,
) => Promise<LocalPathMutationResult>;
renameLocalPath?: (
path: string,
newName: string,
) => Promise<LocalPathMutationResult>;
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
chmodLocalPath?: (
path: string,
permissions: string,
) => Promise<LocalPathMutationResult>;
openExternalEditor: (fileData: {
fileName: string;
content: string;
encoding?: "utf8" | "base64";
editorPath?: string | null;
}) => Promise<{
success: boolean;
editId?: string;
path?: string;
error?: string;
}>;
closeExternalEditor: (editId: string) => Promise<{
success: boolean;
error?: string;
}>;
onExternalEditorSaved?: (
callback: (payload: {
editId: string;
content: string;
encoding: "utf8";
path: string;
}) => void,
) => () => void;
onUpdateAvailable: (callback: () => void) => void;
onUpdateDownloaded: (callback: () => void) => void;
@@ -238,159 +116,6 @@ export interface ElectronAPI {
success: boolean;
error?: string;
}>;
startLocalTerminal(dimensions: {
cols: number;
rows: number;
shell?: "default" | "wsl";
}): Promise<{ sessionId: string; shell: string }>;
readyLocalTerminal(sessionId: string): Promise<boolean>;
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
resizeLocalTerminal(
sessionId: string,
cols: number,
rows: number,
): Promise<boolean>;
closeLocalTerminal(sessionId: string): Promise<boolean>;
onLocalTerminalData(
sessionId: string,
callback: (data: string) => void,
): () => void;
onLocalTerminalExit(
sessionId: string,
callback: (exitCode: number) => void,
): () => void;
/** Local disk browsing for the dual-pane file manager (desktop only). */
localFs?: {
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
mkdir: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
createFile: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
rename: (
oldPath: string,
newName: string,
) => Promise<LocalFsResult<{ path: string }>>;
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
ensureDir: (
dirPath: string,
rootPath?: string,
) => Promise<LocalFsResult<{ path: string }>>;
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
};
/** Streamed local<->remote transfers driven by the main process. */
localTransfer?: {
/** The /plugin-api/<id> path that serves the streaming routes. */
setApiPath?: (apiPath: string) => Promise<LocalFsResult<object>>;
upload: (
options: LocalUploadRequest,
) => Promise<LocalFsResult<{ bytes: number }>>;
download: (
options: LocalDownloadRequest,
) => Promise<LocalFsResult<{ path: string }>>;
cancel: (
transferId: string,
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
onProgress: (
callback: (payload: LocalTransferProgress) => void,
) => () => void;
};
}
export type LocalFsResult<T> =
({ success: true } & T) | { success: false; error: string; code?: string };
export interface LocalFsHomeInfo {
home: string;
separator: string;
platform: string;
}
export interface LocalFileEntry {
name: string;
path: string;
type: "file" | "directory" | "link" | "other";
created?: string;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
size: number;
modifiedTimestamp?: number;
linkTarget?: string;
hidden: boolean;
}
export interface LocalDirectoryListing {
path: string;
parent: string | null;
entries: LocalFileEntry[];
}
export interface LocalTrashResult {
trashed: number;
failed: Array<{ path: string; error: string }>;
}
export interface LocalWalkFile {
localPath: string;
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
relativePath: string;
size: number;
}
export interface LocalWalkResult {
files: LocalWalkFile[];
emptyDirs: string[];
totalBytes: number;
}
/**
* Which Termix backend a transfer talks to. The main process resolves the
* actual URL and credentials for the origin; the renderer never supplies them.
*/
export type LocalTransferOrigin = "local" | "remote";
export interface LocalUploadRequest {
transferId: string;
origin: LocalTransferOrigin;
fields: Record<string, string>;
localPath: string;
fileName: string;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalDownloadRequest {
transferId: string;
origin: LocalTransferOrigin;
body: Record<string, unknown>;
destPath: string;
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
rootPath: string;
expectedSize?: number;
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
overwrite?: boolean;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalTransferProgress {
transferId: string;
transferred: number;
total?: number;
}
declare global {
+256 -2
View File
@@ -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
// ---------------------------------------------------------------------------
@@ -530,8 +653,16 @@ export interface HostBadgeContribution {
id: string;
when: (host: PluginHostRecord) => boolean;
component: ComponentType<{ host: PluginHostRecord }>;
/** Lower comes first. Ties sort by id. */
order?: number;
}
/** The dot or stripe that shows a host's status on its row. */
export type HostStatusIndicatorContribution = HostBadgeContribution;
/** A control in the host editor's toolbar. */
export type HostEditorToolbarItemContribution = HostBadgeContribution;
export interface HostContextMenuItemContribution {
id: string;
titleKey: string;
@@ -683,6 +814,66 @@ export interface SettingsComponentProps {
running: boolean;
}
/** What a companion starts with. Read from the plugin's own settings. */
export interface CompanionDefaults {
/** Open by itself the first time a matching tab shows. */
open?: boolean;
side?: "left" | "right" | "bottom";
/** Pixels of width, or height at the bottom. */
size?: number;
/** Show the edge button that opens and closes it. Default true. */
edgeButton?: boolean;
}
/** A panel a tab type can show beside it, for a picker. */
export interface CompanionInfo {
/** The id registerCompanionLayout's hidden and order lists use. */
id: string;
titleKey: string;
icon?: IconComponent;
}
/** How a host wants the panel bar beside a tab. */
export interface CompanionLayout {
/** False removes the bar and its panels. */
enabled?: boolean;
/** Where a panel opens until the user moves it. */
side?: "left" | "right" | "bottom";
/** Companion ids in bar order. */
order?: string[];
/** Companion ids left out. */
hidden?: string[];
}
export interface CompanionProps {
/** The tab it sits beside. */
tab: PluginTabRecord;
host?: PluginHostRecord;
/** The host in the shape the connection APIs take. */
sshHost?: Record<string, unknown>;
/** The tab's surface handle (see TabHandle), read at call time. */
getHandle: () => TabHandle | null;
side: "left" | "right" | "bottom";
/** False while the tab is in the background or the panel is closed. */
isVisible: boolean;
close: () => void;
setSide: (side: "left" | "right" | "bottom") => void;
}
export interface CompanionOptions {
icon?: IconComponent;
titleKey?: string;
order?: number;
/** Tab types it applies to. Any tab with a host when empty. */
tabTypes?: string[];
when?: (tab: PluginTabRecord, host?: PluginHostRecord) => boolean;
/**
* A hook returning the starting state. It runs in the plugin's scope, so it
* can read the plugin's settings.
*/
useDefaults?: () => CompanionDefaults;
}
export type ActionHandler = (...args: never[]) => unknown;
export interface ActionSlotDefinition {
@@ -715,10 +906,11 @@ export type OnboardingMode = "full" | "partial" | "rerun";
* Where a plugin step sits in onboarding. "setup" comes right after the
* appearance steps, for things that need doing before the feature works
* (like pointing at a guacd server). "explore" (the default) comes after
* that, "security" after the account security step. Onboarding is for
* that, "security" after the account security step. "start" comes before
* the appearance steps, for choices that shape how the rest looks. Onboarding is for
* choices, not for explaining features.
*/
export type OnboardingSection = "setup" | "explore" | "security";
export type OnboardingSection = "start" | "setup" | "explore" | "security";
export interface OnboardingStepProps {
/**
@@ -761,6 +953,16 @@ export interface OnboardingStepContribution {
permission?: string;
section?: OnboardingSection;
order?: number;
/**
* Exact place in the flow, 1 to 99, instead of a section. Core steps sit at
* 10 (plugins), 20, 25, 30 (preset), 40 (appearance) and 90 (security).
*/
position?: number;
/**
* Older step keys this step took over, like "preset". People who already
* saw one of them are not shown this step again.
*/
replaces?: string[];
when?: (context: {
isAdmin: boolean;
isDesktop: boolean;
@@ -926,12 +1128,40 @@ export interface TermixApp extends TermixAppInfo {
component: ComponentType<TabProps>,
options?: TabOptions,
) => Disposer;
/**
* A panel that sits beside any host tab the options match (a terminal, a
* desktop, ...), bound to that tab's host. The user opens, resizes and moves
* it; the tab keeps running underneath.
*/
registerCompanion: (
id: string,
component: ComponentType<CompanionProps>,
options?: CompanionOptions,
) => Disposer;
/**
* Lets a plugin decide, per host, how the panel bar looks on one tab type.
* Read from the host payload, so it must not call hooks.
*/
registerCompanionLayout: (
tabType: string,
resolve: (host: PluginHostRecord | undefined) => CompanionLayout,
) => Disposer;
/** Opens a companion on a tab, or closes it when it is showing. */
toggleCompanion: (tab: PluginTabRecord, id?: string) => void;
registerHostEditorSection: (
section: HostEditorSectionContribution,
) => Disposer;
registerHostAction: (action: HostActionContribution) => Disposer;
registerHostProtocol: (protocol: HostProtocolContribution) => Disposer;
registerHostBadge: (badge: HostBadgeContribution) => Disposer;
/** Draws a host row's status. A layout renders every registered one. */
registerHostStatusIndicator: (
indicator: HostStatusIndicatorContribution,
) => Disposer;
/** Adds a control to the host editor's toolbar. */
registerHostEditorToolbarItem: (
item: HostEditorToolbarItemContribution,
) => Disposer;
registerHostContextMenuItem: (
item: HostContextMenuItemContribution,
) => Disposer;
@@ -988,6 +1218,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 +1358,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;
@@ -1138,6 +1375,7 @@ export interface PluginHostBridge {
hostProtocols: (host: PluginHostRecord) => string[];
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
useHostActions: () => HostActionContribution[];
useCompanions: (tabType: string) => CompanionInfo[];
useActivityTypes: () => string[];
activityTarget: (type: string) => ActivityTargetInfo | undefined;
useExtensions: (pointId: string) => ExtensionContribution[];
@@ -1372,6 +1610,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;
}
@@ -1539,6 +1785,14 @@ export function useHostActions(): HostActionContribution[] {
return requireHost().useHostActions();
}
/**
* Every panel that can open beside a tab of this type, for a settings screen
* that lets the user hide or reorder them. Reactive.
*/
export function useCompanions(tabType: string): CompanionInfo[] {
return requireHost().useCompanions(tabType);
}
/**
* Recent-activity types every registered tab claims (registerTab's
* activityTypes), core's and plugins'. For a filter that lists what a user
+195 -1
View File
@@ -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.11.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. */
@@ -649,6 +688,21 @@ export interface PluginManifest {
nativeDependencies?: string[];
/** How a linked desktop treats this plugin. Defaults to "mirror". */
desktop?: PluginDesktopMode;
/**
* "desktop": the plugin only loads on a desktop app's own embedded backend,
* never on a server. It implies desktop "local" and may declare desktop:local.
*/
runtime?: PluginRuntime;
}
export type PluginRuntime = "desktop";
export const PLUGIN_RUNTIMES: readonly PluginRuntime[] = ["desktop"];
/** The desktop mode a manifest resolves to. */
export function resolvePluginDesktopMode(
m: Pick<PluginManifest, "desktop" | "runtime">,
): PluginDesktopMode {
return m.desktop ?? (m.runtime === "desktop" ? "local" : "mirror");
}
export const DEFAULT_BACKEND_ENTRY = "dist/backend.js";
@@ -688,6 +742,7 @@ const ALLOWED_TOP_LEVEL = new Set([
"platforms",
"nativeDependencies",
"desktop",
"runtime",
]);
const ALLOWED_CONTRIBUTES = new Set([
@@ -708,6 +763,8 @@ const ALLOWED_CONTRIBUTES = new Set([
"uses",
"keybindingActions",
"protocols",
"layouts",
"themes",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -863,6 +920,21 @@ export function validateManifest(manifest: unknown): string[] {
`Field "desktop" must be one of: ${PLUGIN_DESKTOP_MODES.join(", ")}`,
);
}
if (
m.runtime !== undefined &&
!PLUGIN_RUNTIMES.includes(m.runtime as PluginRuntime)
) {
errors.push(
`Field "runtime" must be one of: ${PLUGIN_RUNTIMES.join(", ")}`,
);
}
if (
m.runtime === "desktop" &&
m.desktop !== undefined &&
m.desktop !== "local"
) {
errors.push(`A "desktop" runtime plugin must use desktop "local"`);
}
validateDependencyMap(m.dependencies, "dependencies", errors);
validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors);
validateProvides(m.provides, errors);
@@ -1300,6 +1372,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 +2501,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(
+122
View File
@@ -0,0 +1,122 @@
/**
* SSH key helpers for plugins that make, check or deploy keys: generate a key
* pair, tell what a key is, derive a public key and check a pair belongs
* together. Pure functions over ssh2, which the host supplies. Core keeps its
* own copy for the connect pipeline.
*/
import ssh2 from "ssh2";
export type SshKeyKind = "ssh-ed25519" | "ssh-rsa" | "ecdsa-sha2-nistp256";
export interface GeneratedSshKeyPair {
privateKey: string;
publicKey: string;
keyType: SshKeyKind;
}
export interface GenerateSshKeyPairOptions {
/** RSA only. 2048 to 8192, default 4096. */
bits?: number;
passphrase?: string;
comment?: string;
}
/** Makes a new key pair; the public half is one authorized_keys line. */
export function generateSshKeyPair(
type: SshKeyKind,
options: GenerateSshKeyPairOptions = {},
): GeneratedSshKeyPair {
const algorithm =
type === "ssh-rsa" ? "rsa" : type === "ssh-ed25519" ? "ed25519" : "ecdsa";
if (
type !== "ssh-rsa" &&
type !== "ssh-ed25519" &&
type !== "ecdsa-sha2-nistp256"
) {
throw new Error(`Unsupported key type: ${String(type)}`);
}
const generateOptions: Record<string, unknown> = {
comment: options.comment ?? "",
};
if (options.passphrase) {
generateOptions.passphrase = options.passphrase;
generateOptions.cipher = "aes256-ctr";
}
if (type === "ssh-rsa") {
const bits = options.bits ?? 4096;
if (!Number.isInteger(bits) || bits < 2048 || bits > 8192) {
throw new Error("RSA keys need 2048 to 8192 bits");
}
generateOptions.bits = bits;
}
if (type === "ecdsa-sha2-nistp256") generateOptions.bits = 256;
const pair = ssh2.utils.generateKeyPairSync(
algorithm as "rsa",
generateOptions as never,
);
return {
privateKey: pair.private,
publicKey: pair.public.trim(),
keyType: type,
};
}
function parse(key: string, passphrase?: string) {
const parsed = ssh2.utils.parseKey(key.trim(), passphrase || undefined);
const first = Array.isArray(parsed) ? parsed[0] : parsed;
if (!first || first instanceof Error) {
throw first instanceof Error ? first : new Error("Not a readable SSH key");
}
return first;
}
/** The key's algorithm, like "ssh-ed25519", or null when it cannot be read. */
export function detectSshKeyType(
key: string,
passphrase?: string,
): string | null {
try {
return parse(key, passphrase).type;
} catch {
return null;
}
}
/** The authorized_keys line for a private key, or null. */
export function publicKeyFromPrivate(
privateKey: string,
passphrase?: string,
): string | null {
try {
const parsed = parse(privateKey, passphrase);
return `${parsed.type} ${parsed.getPublicSSH().toString("base64")}`;
} catch {
return null;
}
}
/** Whether a public key line belongs to the private key. */
export function validateSshKeyPair(
privateKey: string,
publicKey: string,
passphrase?: string,
): { isValid: boolean; error?: string } {
try {
const priv = parse(privateKey, passphrase);
const pub = parse(publicKey);
const same = priv.getPublicSSH().equals(pub.getPublicSSH());
return same
? { isValid: true }
: {
isValid: false,
error: "The public key does not match the private key",
};
} catch (error) {
return {
isValid: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
+397 -2
View File
@@ -14,7 +14,10 @@ import {
PluginCapabilityError,
} from "./backend.js";
import type {
PluginAuditEntry,
PluginAuditFilters,
PluginContext,
PluginDesktopLocal,
PluginDisposables,
PluginLogger,
} from "./backend.js";
@@ -65,12 +68,19 @@ import type {
PluginSshKeyCredential,
PluginSshKeyCredentialInput,
} from "./backend.js";
import type { SyncEntityRegistration } from "./backend.js";
import type { PluginSyncHost, SyncEntityRegistration } from "./backend.js";
import type { PluginDatabase } from "./backend.js";
import type {
PluginExternalClientRequest,
PluginProtocolTarget,
PluginHostProbeTarget,
PluginHostStatusEntry,
PluginActivityProvider,
PluginHostLockProvider,
PluginAuthPolicyProvider,
PluginCredentialSummary,
PluginHostShareGrant,
PluginHostStatusProvider,
PluginNotification,
PluginNotificationChannel,
PluginNotifyHub,
@@ -94,6 +104,8 @@ export interface FakeScheduledJob {
export interface FakeContextOptions {
pluginId?: string;
/** What ctx.sync.host answers, for a sync provider's tests. */
syncHost?: Partial<PluginSyncHost>;
manifest?: Partial<PluginManifest>;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
@@ -101,6 +113,8 @@ export interface FakeContextOptions {
settings?: Record<string, unknown>;
/** Seed for the core settings ctx.settings.readCore can reach. */
coreSettings?: Record<string, string>;
/** What ctx.files.dataDir() answers. Defaults to /tmp/<pluginId>. */
dataDir?: string;
/** What ctx.ssh.connect and withConnection hand back as the client. */
sshClient?: unknown;
/** A real database behind ctx.db, usually createTestDb().database. */
@@ -132,10 +146,22 @@ export interface FakeContextOptions {
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys answers. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.credentials.list answers. */
credentialSummaries?: PluginCredentialSummary[];
/** What ctx.desktop.available() answers. Defaults to false. */
desktopAvailable?: boolean;
/** What ctx.desktop.local is. Calls throw when this is not given. */
desktopLocal?: PluginDesktopLocal;
/** The stored audit lines ctx.audit.list and exportBatches serve. */
auditEntries?: PluginAuditEntry[];
/** What ctx.hosts.status.get and check answer, by host id. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** What ctx.hosts.probeTargets answers. */
probeTargets?: PluginHostProbeTarget[];
/** Host ids ctx.hosts.sessionActive answers true for. */
activeSessions?: number[];
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. Defaults to false. */
reachable?: (target: string | number, port?: number) => boolean;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
@@ -172,6 +198,8 @@ export interface FakeContextOptions {
baseUrl?: string;
/** What ctx.auth.countLinkedUsers answers, by provider. Defaults to 0. */
linkedUsers?: Record<string, number>;
/** Second factors from other plugins that ctx.auth.listSecondFactors adds. */
secondFactors?: { id: string; pluginId: string }[];
/** Failures before ctx.auth.loginRateLimit locks a key. Defaults to 5. */
loginAttemptLimit?: number;
/** What ctx.users.getLocale answers, by user id. A missing user answers null. */
@@ -251,6 +279,8 @@ export interface FakeAuthRegistrations {
loginFailures: Map<string, number>;
/** Every ctx.auth.issueUserToken call, in order. */
userTokens: Array<{ userId: string; purpose: string; ttlSeconds?: number }>;
/** Providers passed to ctx.auth.policy.provide that are still registered. */
policies: PluginAuthPolicyProvider[];
}
export interface FakePluginContext {
@@ -263,6 +293,12 @@ export interface FakePluginContext {
) => Promise<Record<string, string>>;
/** Everything the plugin registered through ctx.disposables. */
disposals: Array<() => void | Promise<void>>;
/** Providers registered through ctx.hosts.status.provide. */
hostStatusProviders: PluginHostStatusProvider[];
/** Every provider passed to ctx.hosts.activity.provide that is still registered. */
activityProviders: PluginActivityProvider[];
/** Every provider passed to ctx.hosts.locks.provide that is still registered. */
hostLockProviders: PluginHostLockProvider[];
/** Topics emitted through ctx.events.emit, in order. */
emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */
@@ -295,6 +331,8 @@ export interface FakePluginContext {
permissionLevel: string;
durationHours?: number;
}>;
/** Every ctx.hosts.setShares call, in order. */
hostShareSets: Array<{ hostId: number; grants: PluginHostShareGrant[] }>;
/** Everything registered through ctx.auth. */
auth: FakeAuthRegistrations;
/** Every ctx.desktop.openIsolatedWindow call, in order. */
@@ -338,6 +376,8 @@ export interface FakePluginContext {
challenges: Map<string, string>;
renewers: number;
};
/** Calls every ctx.audit.subscribe handler, like a line core just accepted. */
emitAudit: (entry: PluginAuditEntry) => void;
/** Every ctx.audit.record entry, in order. */
audits: Array<{ action: string; success: boolean; [key: string]: unknown }>;
/** Every ctx.hosts.recordActivity call, in order. */
@@ -464,7 +504,14 @@ function applyHostInput(
const {
tags,
pluginSettings: _settings,
pluginEnable: _pluginEnable,
forceKeyboardInteractive: _fki,
password: _password,
key: _key,
keyPassword: _keyPassword,
keyType: _keyType,
sudoPassword: _sudoPassword,
protocolAuth: _protocolAuth,
...rest
} = input;
const next: PluginHostRecord = { ...record };
@@ -491,6 +538,9 @@ export function createFakeContext(
const pluginId = options.pluginId ?? "test-plugin";
const disposals: Array<() => void | Promise<void>> = [];
const emitted: Array<{ topic: string; payload: unknown }> = [];
const hostStatusProviders: PluginHostStatusProvider[] = [];
const activityProviders: PluginActivityProvider[] = [];
const hostLockProviders: PluginHostLockProvider[] = [];
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
@@ -508,6 +558,7 @@ export function createFakeContext(
}>();
const sshConnections: FakePluginContext["sshConnections"] = [];
const hostShares: FakePluginContext["hostShares"] = [];
const hostShareSets: FakePluginContext["hostShareSets"] = [];
const desktopWindows: FakePluginContext["desktopWindows"] = [];
const externalClientLaunches: FakePluginContext["externalClientLaunches"] =
[];
@@ -531,6 +582,7 @@ export function createFakeContext(
};
const secretStore = new Map<string, string>();
const audits: FakePluginContext["audits"] = [];
const auditSubscribers = new Set<(entry: PluginAuditEntry) => void>();
const activities: FakePluginContext["activities"] = [];
const trackedSessions: number[] = [];
const interactions: FakePluginContext["interactions"] = [];
@@ -579,6 +631,7 @@ export function createFakeContext(
revokedSessions: [],
loginFailures: new Map(),
userTokens: [],
policies: [],
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -665,7 +718,7 @@ export function createFakeContext(
files: {
// A fake path; a test that needs a real folder uses the OS temp dir
// itself, not this double.
dataDir: async () => `/tmp/${pluginId}`,
dataDir: async () => options.dataDir ?? `/tmp/${pluginId}`,
},
db: options.db
@@ -705,6 +758,12 @@ export function createFakeContext(
registerEntity: (entity) => {
syncEntities.push(entity);
},
provide: () => () => {},
get host() {
if (!options.syncHost)
throw new Error("Pass syncHost to use ctx.sync.host");
return options.syncHost as PluginSyncHost;
},
},
registry: {
@@ -902,6 +961,27 @@ export function createFakeContext(
disposables,
hosts: {
importMany: async (rows) => {
const created = rows.map(() => nextHostId++);
return {
success: created.length,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: created,
};
},
importSshConfig: async () => ({
success: 0,
updated: 0,
skipped: 0,
failed: 0,
errors: [],
createdHostIds: [],
}),
exportMany: async () => ({ hosts: [] }),
registerImportNormalizer: () => () => {},
list: async () => [...hostsById.values()],
get: async (hostId) => hostsById.get(hostId) ?? null,
checkAccess: async (hostId): Promise<PluginHostAccess> => {
@@ -956,6 +1036,10 @@ export function createFakeContext(
hostShares.push({ hostId, targets, permissionLevel, durationHours });
return { hostId, shared: true };
},
setShares: async (hostId, grants) => {
hostShareSets.push({ hostId, grants: [...grants] });
return { hostId, shared: true };
},
listUsers: async () => options.shareableUsers ?? [],
listRoles: async () => options.shareableRoles ?? [],
trackSession: (hostId) => {
@@ -971,7 +1055,42 @@ export function createFakeContext(
recordActivity: async (hostId, type, hostName) => {
activities.push({ hostId, type, hostName });
},
probeTargets: async (filter) =>
(options.probeTargets ?? []).filter(
(target) => !filter?.hostIds || filter.hostIds.includes(target.id),
),
sessionActive: (hostId) =>
options.activeSessions?.includes(hostId) ?? false,
activity: {
provide: (provider) => {
activityProviders.push(provider);
return () => {
const at = activityProviders.indexOf(provider);
if (at >= 0) activityProviders.splice(at, 1);
};
},
},
folders: {
ensure: async () => {},
removeEmpty: async () => true,
},
locks: {
provide: (provider) => {
hostLockProviders.push(provider);
return () => {
const at = hostLockProviders.indexOf(provider);
if (at >= 0) hostLockProviders.splice(at, 1);
};
},
},
status: {
provide: (provider) => {
hostStatusProviders.push(provider);
return () => {
const at = hostStatusProviders.indexOf(provider);
if (at >= 0) hostStatusProviders.splice(at, 1);
};
},
get: async (hostId) => hostStatuses.get(hostId) ?? null,
check: async (hostId) => hostStatuses.get(hostId) ?? null,
reportLogin: (hostId, outcome) => {
@@ -996,12 +1115,46 @@ export function createFakeContext(
},
audit: {
subscribe: (handler) => {
auditSubscribers.add(handler);
return () => {
auditSubscribers.delete(handler);
};
},
list: async (listOptions) => {
const matching = filterAuditEntries(
options.auditEntries ?? [],
listOptions?.filters,
).reverse();
const offset = listOptions?.offset ?? 0;
return {
entries: matching.slice(offset, offset + (listOptions?.limit ?? 50)),
total: matching.length,
};
},
exportBatches: async function* (filters, batchSize) {
const matching = filterAuditEntries(
options.auditEntries ?? [],
filters,
);
const size = batchSize ?? 500;
for (let at = 0; at < matching.length; at += size) {
yield matching.slice(at, at + size);
}
},
record: async (entry) => {
audits.push({ ...entry });
},
},
network: {
tcpProbe: async (host, port) => options.reachable?.(host, port) ?? false,
},
ssh: {
transports: { provide: () => () => {} },
probe: async (hostId, probeOptions) =>
options.reachable?.(hostId, probeOptions?.port) ?? false,
connect: async (host, connectOptions) => {
sshConnections.push({ host, options: connectOptions });
return {
@@ -1013,6 +1166,16 @@ export function createFakeContext(
dispose: () => {},
};
},
exec: async (host) => {
sshConnections.push({ host });
return {
stdout: "",
stderr: "",
code: 0,
truncated: false,
timedOut: false,
};
},
withConnection: async (host, connectOptions, fn) => {
sshConnections.push({ host, pool: connectOptions.pool });
return fn(sshClient as never);
@@ -1153,6 +1316,22 @@ export function createFakeContext(
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
};
},
policy: {
provide: (provider) => {
auth.policies.push(provider);
return () => {
const at = auth.policies.indexOf(provider);
if (at >= 0) auth.policies.splice(at, 1);
};
},
},
listSecondFactors: () => [
...auth.secondFactors.map((factor) => ({
id: factor.id,
pluginId,
})),
...(options.secondFactors ?? []),
],
},
users: {
@@ -1169,9 +1348,11 @@ export function createFakeContext(
return { success: true };
},
available: () => options.desktopAvailable ?? false,
local: options.desktopLocal ?? unavailableDesktopLocal(),
},
credentials: {
list: async () => [...(options.credentialSummaries ?? [])],
listSshKeys: async () => [
...(options.sshKeyCredentials ?? []),
...createdSshKeys.map((key) => ({
@@ -1195,6 +1376,13 @@ export function createFakeContext(
credentialReads.push({ hostId, protocol });
return options.protocolTargets?.[`${hostId}:${protocol}`] ?? null;
},
publicKey: async (credentialId) =>
(options.sshKeyCredentials ?? []).find((k) => k.id === credentialId)
?.publicKey ?? null,
resolveKeyRefs: async (key, passphrase) => ({
key,
passphrase: passphrase ?? undefined,
}),
registerSecretResolver: (scheme, resolve) => {
auth.secretResolvers.set(scheme, resolve);
disposals.push(() => {
@@ -1345,6 +1533,9 @@ export function createFakeContext(
return errors;
},
disposals,
hostStatusProviders,
activityProviders,
hostLockProviders,
emitted,
kv,
tables,
@@ -1355,6 +1546,7 @@ export function createFakeContext(
coreSettings,
sshConnections,
hostShares,
hostShareSets,
auth,
desktopWindows,
externalClientLaunches,
@@ -1368,6 +1560,9 @@ export function createFakeContext(
tls,
secretStore,
audits,
emitAudit: (entry) => {
for (const handler of auditSubscribers) handler(entry);
},
activities,
trackedSessions,
interactions,
@@ -1400,6 +1595,8 @@ export function createFakeContext(
export interface MockContextOptions {
pluginId?: string;
/** What ctx.sync.host answers, for a sync provider's tests. */
syncHost?: Partial<PluginSyncHost>;
manifest?: Partial<PluginManifest>;
/** Capabilities the plugin is granted. Anything else throws. */
capabilities?: string[];
@@ -1409,6 +1606,8 @@ export interface MockContextOptions {
settings?: Record<string, unknown>;
/** Seed for the core settings readCore can reach. */
coreSettings?: Record<string, string>;
/** What ctx.files.dataDir() answers. */
dataDir?: string;
/** Acting user returned by currentActor and used by asUser. */
actor?: string;
/** What ctx.ssh.connect and withConnection hand back as the client. */
@@ -1432,10 +1631,22 @@ export interface MockContextOptions {
protocolTargets?: Record<string, PluginProtocolTarget>;
/** What ctx.credentials.listSshKeys serves. */
sshKeyCredentials?: PluginSshKeyCredential[];
/** What ctx.credentials.list serves. */
credentialSummaries?: PluginCredentialSummary[];
/** What ctx.desktop.available() answers. */
desktopAvailable?: boolean;
/** What ctx.desktop.local is. See FakeContextOptions. */
desktopLocal?: PluginDesktopLocal;
/** See FakeContextOptions. */
auditEntries?: PluginAuditEntry[];
/** What ctx.hosts.status answers. See FakeContextOptions. */
hostStatuses?: Record<number, PluginHostStatusEntry>;
/** What ctx.hosts.probeTargets answers. */
probeTargets?: PluginHostProbeTarget[];
/** Host ids ctx.hosts.sessionActive answers true for. */
activeSessions?: number[];
/** What ctx.network.tcpProbe and ctx.ssh.probe answer. */
reachable?: (target: string | number, port?: number) => boolean;
/** The acting user's channels, what ctx.notify.channels answers. */
notificationChannels?: PluginNotificationChannel[];
/**
@@ -1460,6 +1671,8 @@ export interface MockContextOptions {
/** See FakeContextOptions. */
loginAttemptLimit?: number;
/** See FakeContextOptions. */
secondFactors?: FakeContextOptions["secondFactors"];
/** See FakeContextOptions. */
locales?: Record<string, string>;
}
@@ -1481,6 +1694,63 @@ export interface MockPluginContext extends FakePluginContext {
* (users:impersonate on issueUserToken), and users:read on ctx.users. Reading a plugin's own settings is deliberately
* ungated. As the SDK grows a member, add its gate here in the same shape.
*/
function filterAuditEntries(
entries: PluginAuditEntry[],
filters: PluginAuditFilters | undefined,
): PluginAuditEntry[] {
return entries.filter(
(entry) =>
(filters?.userId === undefined || entry.userId === filters.userId) &&
(filters?.action === undefined || entry.action === filters.action) &&
(filters?.resourceType === undefined ||
entry.resourceType === filters.resourceType) &&
(filters?.success === undefined || entry.success === filters.success) &&
(filters?.startDate === undefined ||
entry.timestamp >= filters.startDate) &&
(filters?.endDate === undefined || entry.timestamp <= filters.endDate),
);
}
function gateDesktopLocal(
target: PluginDesktopLocal,
check: () => void,
): PluginDesktopLocal {
const wrap = (value: unknown): unknown => {
if (typeof value === "function") {
return (...args: unknown[]) => {
check();
return (value as (...a: unknown[]) => unknown)(...args);
};
}
if (value && typeof value === "object") {
return new Proxy(value as object, {
get: (obj, key) => wrap((obj as Record<string | symbol, unknown>)[key]),
});
}
return value;
};
return new Proxy(target, {
get: (obj, key) =>
key === "available"
? obj.available
: wrap((obj as unknown as Record<string | symbol, unknown>)[key]),
});
}
function unavailableDesktopLocal(): PluginDesktopLocal {
const fail = (): never => {
throw new Error("ctx.desktop.local needs the desktopLocal test option");
};
const deep = (): unknown =>
new Proxy(() => fail(), { get: () => deep(), apply: () => fail() });
return {
available: () => false,
info: fail,
fs: deep(),
pty: deep(),
} as unknown as PluginDesktopLocal;
}
export function createMockCtx(
options: MockContextOptions = {},
): MockPluginContext {
@@ -1489,9 +1759,11 @@ export function createMockCtx(
const base = createFakeContext({
pluginId: options.pluginId,
syncHost: options.syncHost,
actor: options.actor,
settings: options.settings,
coreSettings: options.coreSettings,
dataDir: options.dataDir,
sshClient: options.sshClient,
db: options.db,
router: options.router,
@@ -1503,8 +1775,14 @@ export function createMockCtx(
services: options.services,
protocolTargets: options.protocolTargets,
sshKeyCredentials: options.sshKeyCredentials,
credentialSummaries: options.credentialSummaries,
desktopAvailable: options.desktopAvailable,
desktopLocal: options.desktopLocal,
auditEntries: options.auditEntries,
hostStatuses: options.hostStatuses,
probeTargets: options.probeTargets,
activeSessions: options.activeSessions,
reachable: options.reachable,
notificationChannels: options.notificationChannels,
fetch: options.fetch,
process: options.process,
@@ -1515,6 +1793,7 @@ export function createMockCtx(
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
secondFactors: options.secondFactors,
locales: options.locales,
manifest: {
capabilities: options.capabilities ?? [],
@@ -1650,6 +1929,23 @@ export function createMockCtx(
},
hosts: {
importMany: async (rows, importOptions) => {
require("hosts:write");
if (importOptions?.credentials?.length) require("credentials:write");
return ctx.hosts.importMany(rows, importOptions);
},
importSshConfig: async (content, importOptions) => {
require("hosts:write");
return ctx.hosts.importSshConfig(content, importOptions);
},
exportMany: async (exportOptions) => {
require("hosts:export");
return ctx.hosts.exportMany(exportOptions);
},
registerImportNormalizer: (normalizer) => {
require("hosts:write");
return ctx.hosts.registerImportNormalizer(normalizer);
},
list: async () => {
require("hosts:read");
return ctx.hosts.list();
@@ -1682,6 +1978,10 @@ export function createMockCtx(
require("hosts:write");
return ctx.hosts.share(hostId, targets, permissionLevel, durationHours);
},
setShares: async (hostId, grants) => {
require("hosts:write");
return ctx.hosts.setShares(hostId, grants);
},
listUsers: async () => {
require("hosts:write");
return ctx.hosts.listUsers();
@@ -1698,7 +1998,42 @@ export function createMockCtx(
require("hosts:read");
return ctx.hosts.recordActivity(hostId, type, hostName);
},
probeTargets: async (filter) => {
require("hosts:read");
return ctx.hosts.probeTargets(filter);
},
sessionActive: (hostId) => {
require("hosts:read");
return ctx.hosts.sessionActive(hostId);
},
activity: {
provide: (provider) => {
require("hosts:activity-provide");
return ctx.hosts.activity.provide(provider);
},
},
folders: {
ensure: async (name, style) => {
require("hosts:write");
return ctx.hosts.folders.ensure(name, style);
},
removeEmpty: async (name) => {
require("hosts:write");
return ctx.hosts.folders.removeEmpty(name);
},
},
locks: {
provide: (provider) => {
require("hosts:lock");
return ctx.hosts.locks.provide(provider);
},
},
status: {
provide: (provider) => {
require("hosts:read");
require("hosts:status-provide");
return ctx.hosts.status.provide(provider);
},
get: async (hostId) => {
require("hosts:read");
return ctx.hosts.status.get(hostId);
@@ -1720,11 +2055,26 @@ export function createMockCtx(
ssh: {
...ctx.ssh,
transports: {
provide: (provider) => {
require("ssh:transport");
return ctx.ssh.transports.provide(provider);
},
},
probe: async (hostId, probeOptions) => {
require("ssh:connect");
return ctx.ssh.probe(hostId, probeOptions);
},
connect: async (host, connectOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.connect(host, connectOptions);
},
exec: async (host, command, execOptions) => {
require("ssh:connect");
require("credentials:use");
return ctx.ssh.exec(host, command, execOptions);
},
withConnection: async (host, connectOptions, fn) => {
require("ssh:connect");
require("credentials:use");
@@ -1811,6 +2161,13 @@ export function createMockCtx(
require("users:impersonate");
return ctx.auth.issueUserToken(userId, tokenOptions);
},
policy: {
provide: (provider) => {
require("auth:policy");
return ctx.auth.policy.provide(provider);
},
},
listSecondFactors: () => ctx.auth.listSecondFactors(),
},
users: {
@@ -1820,6 +2177,22 @@ export function createMockCtx(
},
},
audit: {
record: (entry) => ctx.audit.record(entry),
subscribe: (handler) => {
require("audit:read");
return ctx.audit.subscribe(handler);
},
list: async (listOptions) => {
require("audit:read");
return ctx.audit.list(listOptions);
},
exportBatches: (filters, batchSize) => {
require("audit:read");
return ctx.audit.exportBatches(filters, batchSize);
},
},
desktop: {
openIsolatedWindow: async (request) => {
require("desktop:window");
@@ -1830,9 +2203,16 @@ export function createMockCtx(
return ctx.desktop.launchExternalClient(request);
},
available: () => ctx.desktop.available(),
local: gateDesktopLocal(ctx.desktop.local, () =>
require("desktop:local"),
),
},
credentials: {
list: async () => {
require("credentials:use");
return ctx.credentials.list();
},
listSshKeys: async () => {
require("credentials:use");
return ctx.credentials.listSshKeys();
@@ -1851,6 +2231,14 @@ export function createMockCtx(
}
return ctx.credentials.resolveHostProtocol(hostId, protocol);
},
publicKey: async (credentialId) => {
require("credentials:use");
return ctx.credentials.publicKey(credentialId);
},
resolveKeyRefs: async (key, passphrase) => {
require("credentials:read");
return ctx.credentials.resolveKeyRefs(key, passphrase);
},
registerSecretResolver: (scheme, resolve) => {
require("auth:provide");
ctx.credentials.registerSecretResolver(scheme, resolve);
@@ -1877,6 +2265,13 @@ export function createMockCtx(
return ctx.fetch(url, init);
},
network: {
tcpProbe: async (host, port, probeOptions) => {
require("network:probe");
return ctx.network.tcpProbe(host, port, probeOptions);
},
},
process: {
run: async (file, args, runOptions) => {
require("process:spawn");
+2
View File
@@ -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,
@@ -138,6 +139,7 @@ const offline = () =>
}),
hostProtocols: () => ["ssh"],
useHostActions: () => [],
useCompanions: () => [],
useActivityTypes: () => [],
activityTarget: () => undefined,
useExtensions: () => [],
+150
View File
@@ -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)];
}
+38 -3
View File
@@ -9,6 +9,7 @@
* Two projects: tests/backend runs in node, tests/frontend in jsdom.
*/
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -117,9 +118,41 @@ 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] } };
// dedupe alone is not enough: react-dom or the testing library loaded from
// the linked SDK's own node_modules pull in a second React, and hooks then
// see no renderer.
if (!sdkDir.startsWith(pluginDir)) {
const pluginRequire = createRequire(path.join(pluginDir, "package.json"));
for (const name of [
"react",
"react-dom",
"@testing-library/react",
"react-i18next",
"i18next",
]) {
if (alias[name]) continue;
try {
alias[name] = path.dirname(
pluginRequire.resolve(`${name}/package.json`),
);
} catch {
// Not installed in the plugin: leave it to normal resolution.
}
}
}
const resolve = {
alias,
dedupe: ["react", "react-dom", "react-i18next", "i18next"],
};
return {
plugins,
resolve: { alias },
server,
resolve,
test: {
globals: true,
setupFiles: [setupFile],
@@ -127,7 +160,8 @@ export function pluginVitestConfig(
projects: [
{
plugins,
resolve: { alias },
server,
resolve,
test: {
name: `${pluginId}:backend`,
root: pluginDir,
@@ -140,7 +174,8 @@ export function pluginVitestConfig(
},
{
plugins,
resolve: { alias },
server,
resolve,
test: {
name: `${pluginId}:frontend`,
root: pluginDir,
+1
View File
@@ -12,6 +12,7 @@
"moduleDetection": "force",
"esModuleInterop": true,
"resolveJsonModule": true,
"preserveSymlinks": true,
"noEmit": true,
"strict": false,
"noUnusedLocals": false,
+389
View File
@@ -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): ({ factorsOnly, }?: { factorsOnly?: boolean; }) => 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
@@ -78,10 +116,14 @@ DropdownMenuTrigger (value): ({ ...props }: DropdownMenuTriggerProps & RefAttrib
EditingWidth (type): EditingWidth
EmptyState (value): ({ icon: Icon, title, hint, action, className, }: { icon?: LucideIcon; title: string; hint?: string; action?: ReactNode; className?: string; }) => Element
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
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 +133,36 @@ 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
HostLock (type): HostLock
HostOnly (value): ({ children }: { children: ReactNode; }) => Element
HostPolicy (type): HostPolicy
HostProtocolAuthInput (type): HostProtocolAuthInput
HostProtocolDef (type): HostProtocolDef
HostProtocols (type): HostProtocols
HostRowFields (type): HostRowFields
HostSidebarFilterState (type): HostSidebarFilterState
HostSidebarPreferences (type): HostSidebarPreferences
HostTrayTrigger (type): HostTrayTrigger
HttpsSettings (type): HttpsSettings
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 +170,13 @@ 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
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,18 +184,29 @@ 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
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
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
NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
NativeViewProps (type): NativeViewProps
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"; }
PRESETS (value): Record<"simple" | "balanced" | "advanced", UiAreaPreferences>
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 +215,34 @@ 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
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 +262,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 +273,360 @@ 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
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: "simple" | "balanced" | "advanced") => 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
buildOriginWsUrl (value): ({ origin, localPort, localPath, remotePath, includeJwt, }: { origin: ConnectionOrigin; localPort: number; localPath: string; remotePath: string; includeJwt?: boolean; }) => Promise<WebSocketConnectionTarget>
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>>
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
filterPaletteItems (value): (items: PaletteItemDef[], query: string, showWhenEmpty?: boolean) => PaletteItemDef[]
findCoreHostDefault (value): (key: string) => CoreHostDefault
findMatchingKeybinding (value): (e: KeyboardEvent, bindings: CustomKeybinding[]) => CustomKeybinding
getActiveSessions (value): () => Promise<ActiveSessionInfo[]>
getAdaptiveResourceBudget (value): (kind: AdaptiveTaskKind) => AdaptiveResourceBudget
getApiKeys (value): () => Promise<{ apiKeys: ApiKey[]; }>
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>
getHostPolicy (value): () => Promise<HostPolicy>
getHostSidebarPreferences (value): () => Promise<HostSidebarPreferences>
getHostTags (value): () => Promise<string[]>
getHttpsSettings (value): () => Promise<HttpsSettings>
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
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[]
hostEditorToolbarItemsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hostMenuItemsFor (value): (all: HostContextMenuItemDef[], host: Host) => HostContextMenuItemDef[]
hostProtocolFlags (value): (host: { enableSsh?: boolean; pluginSettings?: Record<string, Record<string, unknown>>; }, list?: HostProtocolDef[]) => HostProtocols
hostStatusIndicatorsFor (value): (all: HostBadgeDef[], host: Host) => HostBadgeDef[]
hydrateLocalSharedHostAuth (value): <T>(host: T) => Promise<T>
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
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
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
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>
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; }>
setUpdateChannel (value): (channel: UpdateChannel) => Promise<UpdateChannelState>
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
tabIcon (value): (type: TabType) => Element
tabJumpHotkeyKeys (value): () => string[]
takePendingManageRequest (value): () => ManageRequest
unlinkExternalFromPasswordAccount (value): (userId: string) => Promise<{ success: boolean; message: string; }>
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; }>
updateHttpsSettings (value): (forceHttps: boolean) => Promise<HttpsSettings>
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>
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
useDesktopLinked (value): () => boolean
useDesktopUpdate (value): () => DesktopUpdateState
useEditorDefaultKeys (value): () => Map<string, EditorDefaultKey>
useHostActions (value): () => HostActionDef[]
useHostBadges (value): () => HostBadgeDef[]
useHostContextMenuItems (value): () => HostContextMenuItemDef[]
useHostEditorSections (value): () => HostEditorSectionDef[]
useHostEditorToolbarItems (value): () => HostBadgeDef[]
useHostProtocols (value): () => HostProtocolDef[]
useHostStatus (value): (hostId: number, statusCheckEnabled?: boolean) => StatusValue
useHostStatusIndicators (value): () => HostBadgeDef[]
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
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>>