Compare commits

...
Author SHA1 Message Date
a89ccc6b07 v26.10.3 (#1692)
* 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>
2026-10-20 16:03:29 -05:00
LukeGus aff29a9581 chore: bump Homebrew cask to 26.10.2 2026-10-18 06:25:23 +00:00
66e8ce6df7 v26.10.2 (#1672)
* fix: keep concurrent ssh pool connects from overwriting each other (#1619)

* fix: stop auto-accepting changed jump host keys (#1620)

A jump hop whose key no longer matched the pinned fingerprint was
silently accepted and the pin overwritten. Background status probes
reconnect to jump hosts every cycle, so a MITM on that hop got the
credentials with no user interaction. Changed jump host keys now go
through the same path as any other host: prompt when there is a
terminal socket, otherwise refuse.

* fix(electron): validate external login URL scheme and callback port (#1621)

* fix(plugins): read artifact manifest with the same tar parser as the unpack (#1622)

The consent check read the first manifest.json with a hand-written tar
reader, while the unpack keeps the last one and also follows pax and
long-name headers. An archive with two manifests could pass the check
with one capability list and get granted another. Read the manifest
with node-tar, count only plain files, and refuse archives that hold
more than one manifest.json.

* fix: only trust the server's origin in the desktop login frame (#1624)

The desktop sign-in iframe accepted AUTH_SUCCESS and system browser requests from whatever page it had navigated to, and posted replies (including 2FA temp tokens) to any origin. Messages must now come from the server's origin and replies are posted to it.

The web UI also treated any framing page as the desktop app and posted the JWT to it. It now also requires the Termix-Desktop user agent.

* fix: check trusted proxy login against nginx's peer, not nginx (#1625)

The backend only sees the bundled nginx on loopback, so the allowlist
could only work by trusting 127.0.0.1, which let any client that reached
nginx log in as anyone by sending the identity headers. nginx now passes
its own TCP peer (before real_ip rewriting) in X-Termix-Proxy-Peer, and
loopback connections are judged by that address instead.

* fix: keep foreign keys on during user data import (#1626)

Import credentials before hosts and remap the file's credential ids to
the ones created here, so hosts and protocol logins no longer point at
another server's ids. With rows written in dependency order, the import
no longer turns off SQLite foreign keys on the shared connection. The
uploaded file is now removed on every exit path.

* fix: enforce unique usernames (#1628)

* fix: enforce unique usernames

Registration checked for a taken name, hashed the password, then
inserted, with nothing in the schema enforcing uniqueness. Concurrent
sign-ups could create two accounts with one name, after which login,
password reset, proxy login and admin lookups by username picked
between them arbitrarily.

Add a unique index on users.username for every dialect. Existing
duplicates are renamed (later rows get a -<id> suffix, the oldest keeps
the name) rather than deleted. Registration and admin user creation now
return the same 409 when the insert loses the race.

* fix: avoid username collisions during migration

* fix: don't auto-enable synced plugins signed only by a server-sent key (#1623)

* fix: don't auto-enable synced plugins signed only by a server-sent key

Registry keys a linked server sends still let its plugins load on the desktop, but no longer count as a signer for switching them on or copying their grants. Keys the desktop added itself keep their trust.

* fix: enforce local plugin trust across remote source labels

* fix: require positive local trust for synced plugin keys

* fix(desktop): harden external editor and session cookie IPC (#1627)

* fix(desktop): stop the renderer choosing the external editor binary or reading the session cookie

open-external-editor now launches only the editor saved from a file dialog
and refuses to hand executable file types to the OS opener. The unused
get-session-cookie and wait-session-cookie channels, which returned the
httpOnly jwt, are removed.

* fix: validate setting keys before editor authorization

* fix(sidebar): sort hosts by IP address numerically (#1629)

* fix(desktop): find spawn-helper in universal macOS builds (#1630)

The universal build ships app-arm64.asar and app-x64.asar instead of
app.asar, so node-pty's helper path rewrite never pointed at the
unpacked copy and every local terminal failed with posix_spawnp failed.

* fix(sync): send shared hosts with text columns in their stored form (#1631)

resolveHostById turns forceKeyboardInteractive into a boolean, and the
shared-host export passed it on as is. A desktop could not bind the boolean
into the text column, so every shared host failed to apply.

* fix: drop ssh_data.vault_profile_id so removing the vault plugin keeps hosts writable (#1633)

The vault plugin renames vault_profiles to p_vault_profiles, and SQLite
rewrites ssh_data's foreign key to follow it. Uninstalling the plugin then
dropped p_vault_profiles and left ssh_data pointing at a missing table, so
creating, cloning and deleting hosts failed with "no such table:
main.p_vault_profiles". Postgres and MySQL refused the uninstall instead.

The column was already copied into the vault plugin's host settings by 2.9,
so retire it on every engine. The SQLite boot drop also repairs databases
already in the broken state; MySQL drops the column's foreign key first.

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own (#1632)

* fix(sync): let a linked desktop enable mirrored plugins it does not trust on its own

* fix(sync): switch off an untrusted mirrored plugin when the server updates it

* fix: require local consent for mirrored plugin activation

* docs: describe consent fields on the plugin state endpoint

* fix(i18n): keep Indonesian technical terms consistent (#1636)

Use the English terms Indonesian users know for tunnel, snippet, deploy,
file manager, remote desktop, jump host, passphrase, sidebar, toolbar,
endpoint, browser and SSH agent, instead of mixing literal translations
(terowongan, cuplikan, terapkan, bilah sisi, titik akhir, ...) with the
English words. Match the split shortcuts to the split screen menu.

Also fix the user data export description, which said secrets are
encrypted while the English says they are decrypted.

* fix(settings): leave user manage view when switching admin sections (#1649)

The admin panel stays mounted across Settings pages, so an open user
manage view kept replacing every other admin section until Back to users
was clicked. Reset it whenever the selected section changes.

* fix(sync): don't delete a local row when the server can't send its copy (#1650)

* fix(sync): don't delete a local row when the server can't send its copy

A push conflict on a live record whose server row failed to serialize
came back as row: null, deleted: false, and the desktop treated that as
a delete. The server now rejects that op as unavailable, the desktop
never deletes on a conflict without a row, and serialization failures
are logged.

* fix(sync): retry temporarily unavailable records

* fix: smooth host editor jumps to lower sections

* chore: add changelog entries for 26.10.2 fixes

* fix: stop building ssh2 native crypto in the docker image (#1644)

* chore: test that an update keeps a changed auth type (#1601)

* feat: option to hide the sidebar after opening a host (#1652)

* feat: sort the dashboard host status card (Plugin-Host-Metrics#6)

* fix: let the hover side menu overlay content instead of resizing it (Plugin-Remote-Desktop#2)

* fix: say the server is down and keep retrying instead of a database error (#1637)

* fix: show why a client tunnel failed to start (Plugin-Tunnels#2)

* fix(sdk): skip comment-only chunks when splitting migrations

* feat: setting to let the hover side menu push content again

* fix: use the vite proxy for plugin sockets on any dev port

* feat(sdk): let a plugin raise its public route rate limit

* chore: update translations

* chore: add the overview video to the readme

* chore: update the overview video and add a play button to its thumbnail

* feat: plugin builds bundle worker entries and import wasm and fonts as bytes

* feat: plugin packs include LICENSE and NOTICE

* chore: drop guacd from compose files and dev runner

* chore: update remote desktop origin comment for the built-in engine

* fix: show plugin names and descriptions in the UI language (#1658)

* fix: show plugin names and descriptions in the UI language

Read plugin.name and plugin.description from each plugin's own locale
namespace, falling back to the manifest, everywhere core shows a plugin's
name: the Plugins manager, onboarding picker, host editor tabs and
sections, host filters and bulk actions, beta updates, interface presets
and plugin crash/loading notices.

* docs(sdk): document plugin.name and plugin.description locale keys

---------

Co-authored-by: ZacharyZcR <zkl205305@gmail.com>

* fix(hosts): reject or infer a missing authType on host create (#1664)

Creating an SSH host through the API without authType hit the NOT NULL
auth_type column and came back as a 500. The create route now infers the
type from the credential, key or password in the body, falls back to the
host defaults, and otherwise answers 400. A non-string authType is a 400
on create and update.

* chore: changelog for merged plugin name and host auth type PRs

* fix: move macOS split pane navigation to Cmd+Option+Arrow

* fix: pick a free port for the desktop backend when 30001 is taken (#1663)

* feat: build the Windows desktop app for ARM64 (#1615)

* fix(sync): keep the changes cursor within the 32-bit seq column (#1665)

The desktop learned the server's entity types by asking for changes after
Number.MAX_SAFE_INTEGER. sync_records.seq is a 32-bit integer on Postgres,
which rejects that bound value, so every pass needing the probe failed with
22003 out of range.

The server now clamps the cursor to the largest seq the column holds and
answers 400 for a cursor or limit that is not a non-negative integer. The
desktop asks for a one-row page from its saved cursor instead.

* fix(audit): coalesce identical plugin routine audits so they cannot flush the log (#1667)

Successful plugin_as_user and plugin_service_* entries written by a plugin
with the same user, action, resource and details are recorded once per
10 minutes. Failures and every other action are still written each time.

* feat(plugins): publish host.created when a host is added (#1668)

Plugins that keep an inventory of hosts had no way to hear about a new
one short of polling. Core now publishes host.created with the same
{ hostId, userId } payload as host.updated wherever a host row is
created: the host create route, ctx.hosts.create, JSON and SSH config
bulk import, database file import, and sync writing a host it did not
have yet.

* fix: show the login for signed-out full-screen view links and return to them (#1669)

A ?view= link opened without a session spun forever: the gate rendered
the bare view when the session check failed, so the plugin runtime never
started and the view never registered. A regular browser now keeps the
link in sessionStorage, shows the normal login and opens the link again
after any login, including redirect logins. Only same-origin paths with
a view are restored. The desktop app, embedded sign-in frames and guest
views keep their current behavior.

* fix(desktop): apply linked server proxy headers and certificate exception to WebSockets (#1670)

* fix(desktop): send linked server proxy headers on WebSocket upgrades

isLinkedOrigin compared the request origin with the link's http(s)
origin, so the terminal WebSocket (wss://host/...) never matched and its
upgrade went out without the proxy headers and basic auth. Behind a
forward-auth proxy that upgrade was redirected to the login and the
terminal closed with "connection closed unexpectedly". ws and wss now
count as http and https for the comparison, the same mapping the
WebSocket handshake itself uses, with host and port still matched
exactly.

* fix(desktop): allow a linked server's self-signed certificate for its WebSockets

isInvalidCertificateAllowedForUrl compared the URL with a local
getOrigin copy, so wss://host never matched the link's https://host
origin and an allowed self-signed certificate did not cover the
terminal and tunnel sockets. It now uses linkedServer.isLinkedOrigin,
which maps ws and wss the same way as the proxy headers, and the local
copy is removed.

* feat(sdk): let plugins mint a short-lived user session and read a user's locale (#1666)

ctx.auth.issueUserToken(userId, { purpose, ttlSeconds }) gives background
work a session for calling Termix's own API as an existing user. It needs
users:impersonate, defaults to 300s and is capped at 900s, is stored as a
normal session row marked as plugin-issued, and every issue is audited.
Such sessions are refused on routes that change the user's password,
second factors, API keys or linked logins.

ctx.users.getLocale(userId) reads the user's UI language under users:read.

SDK 1.0.11, plugin API 1.3.0.

* chore: changelog for merged sync, audit, event and desktop PRs

* feat: add shell.rightEdge slot so plugin panels can push the page over

* chore: pin bundled plugins to the latest releases

* chore: lint, format, and bump version to 26.10.2

---------

Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Ferdina Kusumah <19812460+FerdinaKusumah@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zkl205305@gmail.com>
Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
2026-10-18 00:34:50 -05:00
LukeGus 3517b2f0ed chore: update the overview video and add a play button to its thumbnail 2026-10-16 18:20:46 -05:00
LukeGus 220f5c8676 chore: add the overview video to the readme 2026-10-16 16:23:59 -05:00
LukeGus 4dfad3efb3 chore: match banner logo to app icon 2026-10-14 22:04:11 -05:00
e152bacc7c fix(sw): stamp __TERMIX_SW_BASE_PATH__ and cache version into sw.js at build time (#1618)
* fix(sw): stamp __TERMIX_SW_BASE_PATH__ and version into sw.js at build time

vite copies public/ verbatim, so the placeholder in public/sw.js reached
dist/sw.js untouched. The service worker then requested
__TERMIX_SW_BASE_PATH__/favicon.ico and friends, its install promise
rejected, and it never activated - while a worker from an older release
kept serving its cached frontend against a newer backend.

scripts/stamp-sw-build-info.cjs now runs right after vite build and
replaces both placeholders in dist/sw.js:

- __TERMIX_SW_BASE_PATH__ -> the base path the build used (VITE_BASE_PATH,
  or root-relative by default, matching vite.config.ts)
- __TERMIX_CACHE_VERSION__ -> package.json version, so CACHE_NAME changes
  per release and the activate handler evicts the previous build's cache

Closes #1617

* chore: format stamp-sw-build-info.cjs to prettier style

* fix: preserve runtime service worker base paths

---------

Co-authored-by: inontz <inontz@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
2026-10-14 08:17:40 +08:00
ShadyNabil8andClaude Opus 5.5 8d9c83b0f6 fix: skip existing hosts on JSON and SSH config import (#1607)
* fix: skip existing hosts on JSON and SSH config import

Import (skip existing) only loaded existing hosts when overwriting, so
every host was created again. Always load them, and skip a match unless
overwrite is set. A skipped host still resolves as a jump host for the
hosts imported after it.

Fixes #1606

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: use .js extension for test import

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 08:13:20 +08:00
664 changed files with 95467 additions and 101609 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+36 -5
View File
@@ -115,7 +115,9 @@ jobs:
Write-Host "Spectre libs present: $($found.FullName)"
- name: Install dependencies
run: npm ci
run: |
npm ci
node scripts/install-windows-sharp.cjs
# Bundled plugins are counted as downloads in the plugin store, so a
# build only fetches them when the pins change.
@@ -143,12 +145,12 @@ jobs:
- name: Build Windows (All Architectures)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npm run build && npx electron-builder --win --x64 --ia32 --publish never
run: npm run build && npx electron-builder --win --x64 --ia32 --arm64 --publish never
# The updater only installs the x64 NSIS build; 32-bit installs get a link.
# The combined x64+ia32 installer is never uploaded.
# The updater only installs the x64 NSIS build; 32-bit and ARM installs get a link.
# The combined multi-arch installer is never uploaded.
- name: Fix Windows update files
run: node scripts/fix-update-info.cjs --drop ia32 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
run: node scripts/fix-update-info.cjs --drop ia32 --drop arm64 --drop termix_windows_nsis.exe release/latest.yml release/beta.yml
- name: Upload Windows x64 NSIS Installer
uses: actions/upload-artifact@v7
@@ -166,6 +168,14 @@ jobs:
path: release/termix_windows_ia32_nsis.exe
retention-days: 30
- name: Upload Windows arm64 NSIS Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_nsis.exe') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_nsis
path: release/termix_windows_arm64_nsis.exe
retention-days: 30
- name: Upload Windows x64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
@@ -182,6 +192,14 @@ jobs:
path: release/termix_windows_ia32_msi.msi
retention-days: 30
- name: Upload Windows arm64 MSI Installer
uses: actions/upload-artifact@v7
if: hashFiles('release/termix_windows_arm64_msi.msi') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_msi
path: release/termix_windows_arm64_msi.msi
retention-days: 30
- name: Create Windows x64 Portable zip
if: hashFiles('release/win-unpacked/*') != ''
run: |
@@ -192,6 +210,11 @@ jobs:
run: |
Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"
- name: Create Windows arm64 Portable zip
if: hashFiles('release/win-arm64-unpacked/*') != ''
run: |
Compress-Archive -Path "release\win-arm64-unpacked\*" -DestinationPath "termix_windows_arm64_portable.zip"
- name: Upload Windows update files
uses: actions/upload-artifact@v7
if: hashFiles('release/*.yml') != '' && inputs.artifact_destination != 'none'
@@ -219,6 +242,14 @@ jobs:
path: termix_windows_ia32_portable.zip
retention-days: 30
- name: Upload Windows arm64 Portable
uses: actions/upload-artifact@v7
if: hashFiles('termix_windows_arm64_portable.zip') != '' && inputs.artifact_destination != 'none'
with:
name: termix_windows_arm64_portable
path: termix_windows_arm64_portable.zip
retention-days: 30
build-linux:
runs-on: blacksmith-8vcpu-ubuntu-2404
if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
-1
View File
@@ -11,4 +11,3 @@ db
*.min.css
openapi.json
drizzle/
src/ui/settings/settings-search-index.ts
+108
View File
@@ -1,5 +1,113 @@
# Changelog
## 26.10.3
### Added
- Choose the layout of the whole app in Settings, Appearance. Admins can also set the default layout and theme for everyone
- Plugins can add layouts and themes
- A repair screen shows when the chosen layout cannot load, with ways to update, switch, reset or turn it off. Open it any time with ?safe-layout=1, or for everyone with TERMIX_SAFE_LAYOUT=1
- Hovering a theme in Settings previews it
- Restore the bundled copy of a plugin from the repair screen, which also pins the shipped version
- Switching layouts closes the tabs only the old layout could show, like its Settings tab
- A chosen layout whose plugin was uninstalled falls back to the default instead of the repair screen
- Filter plugins by type, like layouts, themes or tabs, from a filter list beside the results
- Browse shows plugins a page at a time
- Plugins that need a newer Termix are hidden in Browse, with a toggle to show them
- Hosts can be managed from a config file through a plugin. Managed hosts are read only for everyone, but pins and order still work
- A plugin can block adding hosts by hand, so every host comes from one place
- Plugins can set login rules: sign out after a time without activity, limit how long a session lasts, turn off remember me and require a second factor
- A user who must have a second factor but has none can sign in only to set one up
- Admins can redirect HTTP to HTTPS and send HSTS while HTTPS is on. Off by default so reverse proxies keep working
- A plugin can answer all sync requests and use the replica store, change feed and linked server list, so sync no longer lives in the core app
- The desktop app can hold several accounts, each with its own copy of a server's data. The app signs in as the active one and its auto session follows a switch
- The linked server list holds one link per account, with room for more connection options per server
- A plugin can carry SSH connections over its own stream, and core relays a host's connection over a socket for another Termix
- Plugins can create a folder with a color and icon and remove it when it is empty
- Another Termix server can manage this one with an API key, which can now ask for a one minute ticket to open plugin sockets as its user
- FIDO2 security keys (ed25519-sk and ecdsa-sk) work through SSH Agent authentication when Termix runs in the desktop app. The key asks for a touch or PIN on your own machine
- Pasting a security key file now explains how to use it instead of failing to decode it
- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it
- The desktop app can link to a server that requires a client certificate (mutual TLS). It uses a PFX or P12 file for sync and the certificate in your system store for the app window
- Plugins can show a web page in a native view in the desktop app, for sites that do not load in an iframe
- The desktop app draws its own title bar in the colors of your theme instead of the native white one
### Changed
- Server and desktop sync moved out of the core app into a plugin, including the Sync screen, the link wizard and the first run step. Existing links and offline copies keep working
- The icon rail, sidebar, tab bar, dock, Dashboard, Manage, Settings and command palette now come from a bundled layout. It can be swapped for another layout but not removed, and a fix to it no longer needs a Termix release
- Your theme is saved to your account so your other devices use it too, unless you keep settings on this device only
- The themes besides light and dark now come from a bundled themes plugin. It can be swapped but not removed, and Termix keeps a basic light and dark theme of its own
- A theme that no longer exists shows as System instead of an unstyled page
- Installs and updates from a registry check the Termix version a plugin needs, not only its plugin API
- Host online and offline dots now come from the Host Status plugin. The check interval setting moved to its settings and keeps its value. Without the plugin hosts show no dot
- Audit log forwarding and the CSV and NDJSON export now come from the Audit Tools plugin. The collector URL and token move to its settings on upgrade. The audit log viewer, retention and the audit log itself stay in Termix
- Client tunnels on your own computer and opening a host in Windows Remote Desktop moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build
- The local terminal, the local side of the file manager with its transfers, and opening a file in an external editor moved out of the desktop app into plugins, so fixes to them no longer need a new desktop build
- The recent activity list and the first run steps for the preset and for theme, accent, font and language now come from bundled plugins. Existing entries and choices are kept, and people who went through those steps are not asked again
- Branding (app name, tagline and logo) is now saved by the bundled layout plugin. An existing setup keeps its branding, and the sign in screen still reads it from Termix
- Without the plugin that stores recent activity, the list stays empty and nothing is recorded
- Importing hosts from a JSON or SSH config file and exporting hosts now come from the Import and Export plugin. Exports are now written to the audit log. Without the plugin there is no import or export
- Generating and checking SSH keys in the credential editor and deploying a key to a host now come from the Credential Tools plugin. Deploys are now written to the audit log. Without the plugin those buttons are hidden
### Fixed
- Linking the desktop app to a server no longer stalls on the server dashboard after sign in
- Exporting your data no longer fails when a plugin table has a text key, like saved passkeys
- The Windows on ARM desktop app starts again instead of failing with a local server error
### Removed
- The desktop app no longer has its own client tunnel and native Remote Desktop code
## 26.10.2
### Added
- Hide sidebar after opening, in Customize Sidebar, closes the host list once you open a host
- Sort the dashboard's Host Status card by name, IP address or status
- Windows on ARM builds of the desktop app (installer, MSI and portable)
- Plugins get an event when a host is added
- Plugins can start a short-lived session as a user and read the user's language, for tasks that run without a browser
- Plugins can pin a panel to the right edge of the window that pushes the page over
### Changed
- The Docker Compose files no longer start a guacd container. npm run dev -- --guacd also points remote desktop at the guacd it starts
- The side menu now opens over the page when it expands on hover, so open sessions like remote desktop are not resized each time. Turn on Push content when it expands in Settings to get the old behavior back
- On macOS, moving between split panes is now Cmd+Option+Arrow, so Option+Arrow can move the cursor by word in a terminal
### Fixed
- Picking a section lower down in the host editor, like Warpgate, stuttered on the way and sometimes stopped before it got there
- Sorting hosts by IP address put 10.0.0.210 before 10.0.0.3
- The local terminal failed to start in the universal macOS build
- Hosts shared with you never showed up on a linked desktop
- A linked desktop could never turn on an unsigned plugin the server had enabled
- Creating, cloning or deleting hosts failed with "no such table: main.p_vault_profiles" after the Vault plugin was removed
- Opening a user in Settings, Users blocked the other admin sections until you went back to the user list
- A linked desktop could keep showing the same sync conflicts, or delete a local host, when the server could not send its copy
- Usernames are now unique
- Importing user data keeps foreign keys on
- Trusted proxy login checks the address nginx saw, not nginx itself
- The desktop login frame only trusts the server's own origin
- External login URLs and callback ports are checked on the desktop app
- A changed jump host key is no longer accepted without asking
- Synced plugins signed only by a key the server sent are no longer turned on by themselves
- Two connects to the same host at once could overwrite each other's connection
- Indonesian uses the same technical terms everywhere
- When the server was down or still starting, the login page said it could not connect to the database and stopped trying. It now says the server is not responding and continues on its own once the server is back
- nginx answered 404 instead of 502 while the backend was down
- A client tunnel that could not start always said "Endpoint SSH connection closed" on the desktop app instead of the real reason, like the target refusing the connection
- The Docker server crashed and restarted every few hours in ssh2's native crypto on Node 24
- Plugin names and descriptions showed in English in the plugin manager, setup picker and host editor no matter the UI language
- Creating a host through the API without an auth type failed with a server error. It now picks the type from the credential, key or password, or says auth type is required
- The desktop app would not start its local server when another program used port 30001. It now picks a free port on its own
- A linked desktop could never finish syncing with a server on Postgres
- Routine plugin activity filled the audit log and pushed out real security entries
- Opening a full-screen view link while signed out showed a spinner forever. It now shows the login and opens the link after
- A linked desktop behind a proxy that needs extra headers, or with an allowed self-signed certificate, could not open remote terminals or tunnels
## 26.10.1
### Added
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "26.10.1"
sha256 "08a8fdc6ce3f29bd9031b050a4f6364f3ca446f17cb272392f1b3b488cea5d30"
version "26.10.2"
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
+48 -58
View File
@@ -18,6 +18,8 @@
<a href="https://donate.termix.site/"><img alt="Donations this month" src="https://img.shields.io/badge/dynamic/json?style=for-the-badge&label=Donations%20this%20month&query=%24.fiatTotal&prefix=%24&url=https%3A%2F%2Ftermix.site%2Fdonation-snapshot.json&color=F39044&labelColor=1a1a1a" /></a>
</p>
<a href="https://www.youtube.com/watch?v=vhtkP-UqV5U"><img src="./.github/readme/overview.jpg" width="720" alt="Watch the Termix overview on YouTube" /></a>
</div>
<br />
@@ -55,42 +57,51 @@ What every install has, with or without plugins:
Plugins add everything else. The official ones ship with Termix, and admins can install, update or remove plugins from the Plugins tab. Each plugin has its own repo.
| Plugin | What it does |
| --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
| Plugin | What it does |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| [ACME Certificates](https://github.com/Termix-SSH/Plugin-ACME-SSL) | Gets the Termix HTTPS certificate from Let's Encrypt or any ACME provider and renews it for you. |
| [AI Assistant](https://github.com/Termix-SSH/Plugin-AI) | An AI assistant that reads your setup and proposes changes for you to approve. |
| [Alerts](https://github.com/Termix-SSH/Plugin-Alerts) | One inbox for every alert in Termix, with delivery to webhooks, ntfy, Discord and email. |
| [Audit Tools](https://github.com/Termix-SSH/Plugin-Audit-Tools) | Send the audit log to your SIEM and export it as CSV or NDJSON. |
| [Automations](https://github.com/Termix-SSH/Plugin-Automations) | Run steps on your hosts on a schedule or when something happens. |
| [Classic Layout](https://github.com/Termix-SSH/Plugin-Classic-Layout) | The classic Termix layout with an icon rail, sidebar, tab bar and dock. |
| [Classic Themes](https://github.com/Termix-SSH/Plugin-Classic-Themes) | Light, dark and seven more color themes for Termix. |
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
| [Docker](https://github.com/Termix-SSH/Plugin-Docker) | Manage Docker and Podman containers on your hosts over SSH, with logs, stats and a console. |
| [File Manager](https://github.com/Termix-SSH/Plugin-File-Manager) | Browse, edit and move files over SFTP, including straight from one server to another. |
| [Fleets](https://github.com/Termix-SSH/Plugin-Fleets) | Group hosts into fleets and run commands, package actions and file transfers on all of them at once. |
| [HashiCorp Vault](https://github.com/Termix-SSH/Plugin-Vault) | Connect to hosts with short-lived SSH certificates signed by HashiCorp Vault. |
| [Homepage](https://github.com/Termix-SSH/Plugin-Homepage) | A drag and drop widget page for your servers and services, plus service links for the dashboard. |
| [Host Metrics](https://github.com/Termix-SSH/Plugin-Host-Metrics) | Live CPU, memory, disk, network and GPU stats, plus tools to manage your hosts. |
| [Host Status](https://github.com/Termix-SSH/Plugin-Host-Status) | Shows if each host is online with a dot in the host list, checked by connecting to its port. |
| [Hosts Config](https://github.com/Termix-SSH/Plugin-Hosts-Config) | Keep hosts, folders and sharing in a YAML or JSON file. Termix loads them as read only hosts and reloads when the file changes. |
| [Import and Export](https://github.com/Termix-SSH/Plugin-Import-Export) | Import hosts from a JSON file or an SSH config file and export them to share or back up. |
| [LDAP](https://github.com/Termix-SSH/Plugin-LDAP) | Sign in with your LDAP or Active Directory username and password. |
| [Login Security](https://github.com/Termix-SSH/Plugin-Login-Security) | Sign people out when idle, limit session length, turn off remember me and require a second factor at login. |
| [Network Topology](https://github.com/Termix-SSH/Plugin-Network-Topology) | Draws your hosts and the links between them as a live, interactive graph. |
| [OPKSSH](https://github.com/Termix-SSH/Plugin-OPKSSH) | Connect to hosts with short-lived OpenPubkey SSH certificates after signing in with your identity provider. |
| [Passkeys](https://github.com/Termix-SSH/Plugin-WebAuthn) | Sign in with a passkey or security key instead of a password. |
| [Proxmox](https://github.com/Termix-SSH/Plugin-Proxmox) | Import Proxmox VE guests as hosts, keep them in sync and watch node stats. |
| [Remote Desktop](https://github.com/Termix-SSH/Plugin-Remote-Desktop) | RDP, VNC and Telnet sessions in your browser, with clipboard, file transfer, sharing and recording. |
| [Secret Sources](https://github.com/Termix-SSH/Plugin-Secret-Sources) | Pull host passwords and keys from 1Password when you connect instead of storing them in Termix. |
| [Serial](https://github.com/Termix-SSH/Plugin-Serial) | Open a serial console to a router, switch or other device plugged into your computer. |
| [Session Recording](https://github.com/Termix-SSH/Plugin-Session-Recording) | Record terminal and remote desktop sessions and play them back or download them later. |
| [Session Sharing](https://github.com/Termix-SSH/Plugin-Session-Sharing) | Share live sessions by link or with another user, and present sessions to a group in meeting rooms. |
| [Single sign-on](https://github.com/Termix-SSH/Plugin-SSO) | Sign in with any OpenID Connect provider, GitHub or Google. |
| [Snippets](https://github.com/Termix-SSH/Plugin-Snippets) | Save the commands you run often and run them in a terminal or on many hosts in one click. |
| [SSH Terminal](https://github.com/Termix-SSH/Plugin-SSH-Terminal) | SSH terminal sessions with tabs, split screen, history, macros and a local terminal in the desktop app. |
| [Step CA](https://github.com/Termix-SSH/Plugin-Step-CA) | Connect to hosts with short-lived SSH certificates from a smallstep step-ca server. |
| [Sync](https://github.com/Termix-SSH/Plugin-Sync) | Sync the desktop app with one or more servers, and manage other Termix servers as remote sites from a master. |
| [Tailscale](https://github.com/Termix-SSH/Plugin-Tailscale) | Add tailnet devices as hosts and connect with Tailscale SSH. |
| [Termix Identity](https://github.com/Termix-SSH/Plugin-Termix-Identity) | Publish your SSH public keys under a public handle and run your own SSH certificate authority. |
| [Tmux Monitor](https://github.com/Termix-SSH/Plugin-Tmux-Monitor) | Browse and control tmux sessions, windows and panes across your hosts. |
| [TOTP](https://github.com/Termix-SSH/Plugin-TOTP) | Two-factor sign in with a code from an authenticator app, with backup codes. |
| [Tunnels](https://github.com/Termix-SSH/Plugin-Tunnels) | SSH port forwarding that reconnects on its own, plus client tunnels from the desktop app. |
| [Usage Statistics](https://github.com/Termix-SSH/Plugin-Telemetry) | Sends a small anonymous daily report so we can see how Termix is used. |
| [Wake-on-LAN](https://github.com/Termix-SSH/Plugin-Wake-On-LAN) | Wake sleeping machines by sending a Wake-on-LAN packet to their MAC address. |
| [Warpgate](https://github.com/Termix-SSH/Plugin-Warpgate) | Connect to hosts through a Warpgate SSH bastion. |
| [Web Endpoint](https://github.com/Termix-SSH/Plugin-Web-Endpoint) | Open a host's web UI inside Termix, directly or over an SSH tunnel. |
| [Workspaces](https://github.com/Termix-SSH/Plugin-Workspaces) | Save your open tabs and split layouts and reopen them in one click. |
Want to build your own? Start from the [plugin template](https://github.com/Termix-SSH/Termix-Plugin-Template). Plugins you can install are listed in the [registry](https://github.com/Termix-SSH/Termix-Registry). Turn on the community registry in Plugins, Registries to install plugins made by other people, or add your own registry. See [submitting a plugin](https://docs.termix.site/develop/community-registry) and [hosting your own registry](https://docs.termix.site/develop/registries#host-your-own-registry).
@@ -135,7 +146,7 @@ Want to build your own? Start from the [plugin template](https://github.com/Term
See the [docs](https://docs.termix.site/install) for install steps on every platform.
Sample Docker Compose file (you can leave out `guacd` and the network if you don't plan to use the Remote Desktop plugin):
Sample Docker Compose file. Remote desktop (RDP, VNC and Telnet) is built in, so nothing else needs to run next to it:
```yaml
services:
@@ -149,31 +160,10 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
volumes:
termix-data:
driver: local
networks:
termix-net:
driver: bridge
```
### Cloud Hosting
+5 -2
View File
@@ -26,7 +26,7 @@ COPY . .
RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete
RUN npm cache clean --force && \
NODE_OPTIONS="--max-old-space-size=3072" npm run build
TERMIX_RUNTIME_BASE_PATH=true NODE_OPTIONS="--max-old-space-size=3072" npm run build
# Stage 3: Build backend
FROM deps AS backend-builder
@@ -74,12 +74,15 @@ COPY vendor ./vendor
COPY scripts/patch-ssh2-agent.cjs ./scripts/
# ssh2's native crypto crashes Node 24 when a cipher is freed, so ssh2 uses
# OpenSSL instead (chacha20-poly1305 is then left out).
RUN npm ci --omit=dev --ignore-scripts && \
node scripts/patch-ssh2-agent.cjs && \
rm -rf node_modules/better-sqlite3/prebuilds && \
npm run build-release --prefix node_modules/better-sqlite3 && \
test -f node_modules/better-sqlite3/build/Release/better_sqlite3.node && \
npm rebuild bcryptjs ssh2 && \
npm rebuild bcryptjs && \
rm -rf node_modules/ssh2/lib/protocol/crypto/build && \
npm cache clean --force
# Stage 6: Final optimized image
+124 -68
View File
@@ -3,36 +3,62 @@
{
"id": "acme-ssl",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.1/acme-ssl-1.0.1.tmxplug",
"sha256": "cb5b7c56a281272cfff229d01a2729a4fa3936de04c0ac487d65bcf8dbd2f38e"
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.0.2/acme-ssl-1.0.2.tmxplug",
"sha256": "157d0f96dc42136ff64e618b6904db7862079cd31c1f1cf5f19846b1d127b00f"
},
{
"id": "ai",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.0.1/ai-1.0.1.tmxplug",
"sha256": "2558c91e3e6823b3e18873d7b074762c4d76a1a0b96ff38d2dad13e0404b74a7"
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.2.0/ai-1.2.0.tmxplug",
"sha256": "3b486133af78ae5168e6f5b8a4c71f3db3403cfdae537ed1ca8d6cc16035d8dd"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.1/alerts-1.0.1.tmxplug",
"sha256": "f5f233c0ffdeee956e1839a17a323c18ce8eb389a08569c3c6e5e24b698dd72a",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.0.2/alerts-1.0.2.tmxplug",
"sha256": "50768e77019276343a5d162c85d081659fb89d1cc3733ac45cd0db4436918faa",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "audit-tools",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Audit-Tools/releases/download/v1.0.0/audit-tools-1.0.0.tmxplug",
"sha256": "131479c294d692b1c0ed4d6cec273730a3a67a28ecd44825ffd36bade9aa863b"
},
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.1/automations-1.0.1.tmxplug",
"sha256": "d2bb5298f8afac3f479ded81d5b1a799d9844b3f4990c8aaf2dd8fed7222428d"
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.3/automations-1.0.3.tmxplug",
"sha256": "b5e5b39a053010b72ad1ba03ea6bb05afbbb2b789a86e0e98634d9ada543d02d"
},
{
"id": "classic-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
"sha256": "aff01b91a28a17013cd4cf64b47dcc02cb85a35710e3382aa9adfe7d93e0fb3a",
"defaultLook": true
},
{
"id": "classic-themes",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
"defaultLook": true
},
{
"id": "credential-tools",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Credential-Tools/releases/download/v1.0.0/credential-tools-1.0.0.tmxplug",
"sha256": "413eff710b5c832a2575a890bbab1c90d28ea55f10cd7a7b94b851d51dd77dce"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.1/docker-1.0.1.tmxplug",
"sha256": "00e170e61ea9b7952449ea9ce30b09b983433f8d18d29f523cf3c6f98e7286e3",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.1.0/docker-1.1.0.tmxplug",
"sha256": "da32dfbe9d4cffec5d80c4a1037a79e7591a3d16c50f882db6a146322a50c2b7",
"onboarding": {
"recommended": true,
"consent": false
@@ -41,8 +67,8 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.2/file-manager-1.0.2.tmxplug",
"sha256": "4c735f5b795a8c676590a0dc25db1bcc6036ac250b9817a61867cebdc17931c6",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.0/file-manager-1.1.0.tmxplug",
"sha256": "807e9ffa80d6054a5911522721a555e0daa3e50f6edffdebe97efe7c91aea7a2",
"onboarding": {
"recommended": true,
"consent": false
@@ -51,54 +77,78 @@
{
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.1/fleets-1.0.1.tmxplug",
"sha256": "82ac71e69022c4991570e6fed109a9eec79421f081af0a1dcb6d8b8e4251ee3b"
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.1/homepage-1.0.1.tmxplug",
"sha256": "573375726f334e37bbf504d9411c4ad2d4bfc4363219ff10dcea6cb2cb00ed32"
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug",
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00"
},
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.2/host-metrics-1.0.2.tmxplug",
"sha256": "433d5297e2fc263f2262b7ea33d7a795c9a970089aea328077861c3f032a23f5",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.1.0/host-metrics-1.1.0.tmxplug",
"sha256": "4f1d422ec1d0565c79f94fa814ee0864d556d7367793b11035e68239e49e297f",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "host-status",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Status/releases/download/v1.0.0/host-status-1.0.0.tmxplug",
"sha256": "1ba62577dc7373a2a2787bab7efdb89b544a30a8003b1de91ce51f372fc4f170"
},
{
"id": "hosts-config",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Hosts-Config/releases/download/v1.0.0/hosts-config-1.0.0.tmxplug",
"sha256": "60a2b2a6d9ba13bad12a511c93af70a30a10fbc560066b6419acf533d806a3ad"
},
{
"id": "import-export",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Import-Export/releases/download/v1.0.0/import-export-1.0.0.tmxplug",
"sha256": "6670b6c147e81eed0ddfd249f9a7c9564f1ab50e45f2771161d2e30c47f6fd58"
},
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.1/ldap-1.0.1.tmxplug",
"sha256": "f5ba13c682d11060f373f85a9c7aaa131e587ec59c0498c0dabb013c2e474f5c"
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
},
{
"id": "login-security",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Login-Security/releases/download/v1.0.0/login-security-1.0.0.tmxplug",
"sha256": "b21dff5722e72a02022d563ef27130e8b56a53e273f1668fade4ec7d030374aa"
},
{
"id": "network-topology",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.1/network-topology-1.0.1.tmxplug",
"sha256": "4c8ec1ae0c39b8b72a89e0dce4b20a36df4adc826abe215b8ee9b69b44fe75ca"
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.1/opkssh-1.0.1.tmxplug",
"sha256": "c19eadfe763e4dbecbd9a5e9e753576c577c75296ddc8e0af4cce98c2376b26f"
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.1/proxmox-1.0.1.tmxplug",
"sha256": "4e0989856e061b9ab1ec25cc727051e03ea5e3dc1603e08895bd54e7ce132db7"
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.0.2/proxmox-1.0.2.tmxplug",
"sha256": "b92be2644b0c8121732103b922069a6cb3cf00caad4e6549f7a8cb471bda5e4e"
},
{
"id": "remote-desktop",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.0.2/remote-desktop-1.0.2.tmxplug",
"sha256": "70823a3f5161f6722fee22a9d5fe274dd47623960bc2188429c39a1c7e3e2b39",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.2.0/remote-desktop-1.2.0.tmxplug",
"sha256": "0de40896bfeb4b1a702c3ce815355b7b7b0bb4bfe424f33e905bb5a0209d02f6",
"onboarding": {
"recommended": true,
"consent": false
@@ -107,14 +157,14 @@
{
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.1/secret-sources-1.0.1.tmxplug",
"sha256": "9f5246325d3128c6376962f4cdbed33e78c487a4837f69235dfdcafeb293229a"
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.1/serial-1.0.1.tmxplug",
"sha256": "9908467fc184e40f108ff750bf2fb8044e000a436f84d0a7121109f4fcd5d121",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"onboarding": {
"recommended": "desktop",
"consent": false
@@ -123,20 +173,20 @@
{
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.1/session-recording-1.0.1.tmxplug",
"sha256": "45a08f950244c862a3774f9a653b3e3645285edb507a49b5c8da123832faf1e7"
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.0.2/session-recording-1.0.2.tmxplug",
"sha256": "55ace6897710ce9a63f24e3d736a9e533cac557be0f3d98974956c94cf417d1c"
},
{
"id": "session-sharing",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.1/session-sharing-1.0.1.tmxplug",
"sha256": "753aa9c0998f194ccf5fc49fd39c133a87f21f21e803014c4bfac6ccc7f9acb1"
"url": "https://github.com/Termix-SSH/Plugin-Session-Sharing/releases/download/v1.0.2/session-sharing-1.0.2.tmxplug",
"sha256": "6408e7949a9549ac5ec94b35205729099138e730df1598d50f4bb618d8d90804"
},
{
"id": "snippets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.1/snippets-1.0.1.tmxplug",
"sha256": "bf643828beba86677220d802c0df74b441d7954538637f37413f78d59c08585f",
"url": "https://github.com/Termix-SSH/Plugin-Snippets/releases/download/v1.0.2/snippets-1.0.2.tmxplug",
"sha256": "cc1f5d74cf97f9a9c8f800fccf481c8a000e6fa516bc260ce24a7249d4f4c8e5",
"onboarding": {
"recommended": true,
"consent": false
@@ -145,8 +195,8 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.2/ssh-terminal-1.0.2.tmxplug",
"sha256": "e65102a8cd6afdaa890095fa698f3f455a1035729b9fdca800a31cc6e5ed760f",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.0/ssh-terminal-1.1.0.tmxplug",
"sha256": "dacdbadb3c6a0d7014b1de4f62b499f80abf358c7799a6e6326674660b055e4a",
"onboarding": {
"recommended": true,
"consent": false
@@ -155,8 +205,8 @@
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.2/sso-1.0.2.tmxplug",
"sha256": "29ab0e4748ee71cb11f9b8dacd630430d284b6ce24561cffae9b4d7fe64e9c3b",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.3/sso-1.0.3.tmxplug",
"sha256": "87b03c0789d981dbc806162d73dfcc2c624150375277efd2ae40ab9d7657d48e",
"onboarding": {
"recommended": false,
"consent": false,
@@ -166,20 +216,26 @@
{
"id": "step-ca",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.1/step-ca-1.0.1.tmxplug",
"sha256": "ff4df5bae8c48ff61fe659b6ed69a40ac070862bc423972711a194d558d77cc8"
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
},
{
"id": "sync",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.0/sync-1.0.0.tmxplug",
"sha256": "12ced05523f7c1a9151b5e680726bd31f53888e8bb0047d7bd12a2570ea168bf"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.1/tailscale-1.0.1.tmxplug",
"sha256": "0efe1cbf0f431763611926b6a9e92630ca8c1a89ea57bb796c5d7bd3ce0ab55f"
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.0.2/tailscale-1.0.2.tmxplug",
"sha256": "5c7b00d6b6b0186d833302e39a9d31b92ea92d4542e9e2dab550b95837a59d97"
},
{
"id": "telemetry",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.1/telemetry-1.0.1.tmxplug",
"sha256": "c7f70e149c67a570f2b004c2cceeba27ffe8cbd95cd4df4bbffd880cc3451469",
"url": "https://github.com/Termix-SSH/Plugin-Telemetry/releases/download/v1.0.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"onboarding": {
"recommended": true,
"consent": true
@@ -188,20 +244,20 @@
{
"id": "termix-identity",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.1/termix-identity-1.0.1.tmxplug",
"sha256": "dc423ec53f012fdcc2cdccd872ac01353025e4da2ee9fea0d2a2d5988518a5a0"
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
},
{
"id": "tmux-monitor",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.2/tmux-monitor-1.0.2.tmxplug",
"sha256": "ea41f1f90bdeab6164cfa2a594f359492afa123ed06990afa338be69a9d974c6"
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.4/tmux-monitor-1.0.4.tmxplug",
"sha256": "42b8a2284e68a321c1736984f5c641b1857c87c6a65ce8cf694c6bc946de2444"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.1/totp-1.0.1.tmxplug",
"sha256": "4a083871663c2f6034904bd49bb02c2d4dd4ed4d50fbce1522172dae84991474",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"onboarding": {
"recommended": true,
"consent": false
@@ -210,8 +266,8 @@
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.2/tunnels-1.0.2.tmxplug",
"sha256": "44799c0bdbcc576fb0e1c1ecce96f283d2cc32b44ac5aeeb7253f8ca0b650860",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.1.0/tunnels-1.1.0.tmxplug",
"sha256": "b1e4f1b6dd2bbd15a9a240ae1c1d381111c3609258c8946d19493a83f861ea1e",
"onboarding": {
"recommended": true,
"consent": false
@@ -220,32 +276,32 @@
{
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.1/vault-1.0.1.tmxplug",
"sha256": "c828eeebe37bf576020908e16a28e73073fc77178062b323058624f557a8c72a"
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
},
{
"id": "wake-on-lan",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.1/wake-on-lan-1.0.1.tmxplug",
"sha256": "5301d26138cb37812e8ef1fc5d3df688415517d545667b365fca2f41166a8402"
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.0.2/wake-on-lan-1.0.2.tmxplug",
"sha256": "3e9cf9a133b4dd96ec5f2b9fbf2d6a3ff7e08f8dfbf1e86b8d45de0c2f024f42"
},
{
"id": "warpgate",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.1/warpgate-1.0.1.tmxplug",
"sha256": "2f3b3169101ff04d0ea6229b31270ba0b5b8ff2c3d16e3f21a9e6c569d463ea8"
"url": "https://github.com/Termix-SSH/Plugin-Warpgate/releases/download/v1.0.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
},
{
"id": "web-endpoint",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.1/web-endpoint-1.0.1.tmxplug",
"sha256": "88477231ab3c93206c99393c6cc8628c1ff8159af6978d0d09db969c28e1d637"
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.3/web-endpoint-1.0.3.tmxplug",
"sha256": "014cbf23c82cab20e485bc4f4c9b8baa779aee9bb3ed644b41f22a9b1be95687"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.1/webauthn-1.0.1.tmxplug",
"sha256": "f9ccd5d6fe110b5c7ad8b90fd95e0a201e03d440462c2c3f6559406222364464",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
"onboarding": {
"recommended": true,
"consent": false
@@ -254,8 +310,8 @@
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.1/workspaces-1.0.1.tmxplug",
"sha256": "17f53044318d6f53ef1e332d204f6469c7043fd9985c4d1eae16b6a1c3248153",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.0.2/workspaces-1.0.2.tmxplug",
"sha256": "97519a01fbb2eb2eecf21d7b9ab9028c740457bcff9fc6b075fb6facbc5ccb6b",
"onboarding": {
"recommended": true,
"consent": false
-15
View File
@@ -12,24 +12,9 @@ services:
environment:
PORT: "8080"
NODE_ENV: development
GUACD_HOST: "guacd-dev"
GUACD_TUNNEL_HOST: "termix-dev"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# REDIS_URL: "redis://redis:6379"
# TERMIX_REDIS_PREFIX: "termix:collab"
# TERMIX_STEP_CA_REDIS_PREFIX: "termix:step-ca"
depends_on:
- guacd-dev
networks:
- termix-dev-net
guacd-dev:
image: guacamole/guacd:1.6.0
container_name: guacd-dev
restart: unless-stopped
volumes:
- termix-dev-data:/termix-data
networks:
- termix-dev-net
+5 -18
View File
@@ -9,12 +9,6 @@ services:
- termix-data:/app/data
environment:
PORT: "8080"
GUACD_HOST: "guacd"
GUACD_TUNNEL_HOST: "termix"
GUACD_RECORDING_PATH: "/termix-data/session_recordings/guacamole"
# Where guacd stores files for RDP drive redirection (one folder per
# user is created underneath). Must be writable by guacd's user.
GUACD_DRIVE_PATH: "/termix-data/rdp-drive"
# Multi-instance deployments should point every backend at the same
# Redis. Without it, collaboration rooms keep their single-instance
# in-memory fallback.
@@ -28,21 +22,14 @@ services:
# Trusted reverse-proxy authentication is disabled by default. When
# enabled, do not expose this container directly to untrusted clients.
# TRUSTED_PROXY_AUTH_ENABLED: "true"
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.16.0.0/12"
# List the address your authenticating proxy connects from, as this
# container sees it (for a proxy container on termix-net, its IP there).
# Keep it narrow: published ports can reach the container from the
# Docker gateway, so do not trust whole private ranges.
# TRUSTED_PROXY_AUTH_TRUSTED_PROXIES: "172.20.0.10"
# TRUSTED_PROXY_AUTH_ROLE_MAP: '{"operators":["user"]}'
# TRUSTED_PROXY_AUTH_USERNAME_HEADER: "x-forwarded-username"
# TRUSTED_PROXY_AUTH_ROLE_HEADER: "x-forwarded-role"
depends_on:
- guacd
networks:
- termix-net
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
volumes:
- termix-data:/termix-data
networks:
- termix-net
+7 -1
View File
@@ -200,6 +200,9 @@ http {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
# The TCP peer before set_real_ip_from rewrites $remote_addr from
# X-Forwarded-For; trusted proxy login checks this address.
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
@@ -473,9 +476,12 @@ http {
}
error_page 500 502 503 504 /50x.html;
# Only nginx's own errors land here (the backend is down or too slow).
# There is no 50x page, so answer directly instead of a 404.
location = /50x.html {
root /app/html;
internal;
default_type application/json;
return 502 '{"error":"The Termix server is not responding"}';
}
}
}
+7 -1
View File
@@ -169,6 +169,9 @@ http {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
# The TCP peer before set_real_ip_from rewrites $remote_addr from
# X-Forwarded-For; trusted proxy login checks this address.
proxy_set_header X-Termix-Proxy-Peer $realip_remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
@@ -442,9 +445,12 @@ http {
}
error_page 500 502 503 504 /50x.html;
# Only nginx's own errors land here (the backend is down or too slow).
# There is no 50x page, so answer directly instead of a 404.
location = /50x.html {
root /app/html;
internal;
default_type application/json;
return 502 '{"error":"The Termix server is not responding"}';
}
}
}
+19
View File
@@ -0,0 +1,19 @@
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
WITH ranked AS (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE CONCAT(CONCAT('termix-recovered-', candidate, '-'), '%')
)
)
UPDATE users JOIN ranked ON ranked.id = users.id CROSS JOIN available
SET users.username = CONCAT('termix-recovered-', available.candidate, '-', ranked.sequence)
WHERE ranked.occurrence > 1;--> statement-breakpoint
ALTER TABLE `users` MODIFY COLUMN `username` varchar(255) NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD CONSTRAINT `idx_users_username` UNIQUE(`username`);
@@ -0,0 +1,2 @@
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
ALTER TABLE `user_preferences` ADD `theme_id` text;
+4
View File
@@ -0,0 +1,4 @@
-- recent_activity moved to the homepage plugin, whose migration renames it to
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
SELECT 1;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `sessions` ADD `restriction` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `idle_timeout` int;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+28
View File
@@ -505,6 +505,34 @@
"when": 1791755801066,
"tag": "0071_drop_install_count_source",
"breakpoints": true
},
{
"idx": 72,
"version": "5",
"when": 1791935283211,
"tag": "0072_unique_username",
"breakpoints": true
},
{
"idx": 73,
"version": "5",
"when": 1792357129912,
"tag": "0073_user_look_preferences",
"breakpoints": true
},
{
"idx": 74,
"version": "5",
"when": 1792374489419,
"tag": "0074_mature_puff_adder",
"breakpoints": true
},
{
"idx": 75,
"version": "5",
"when": 1792430772041,
"tag": "0075_session_policy",
"breakpoints": true
}
]
}
+18
View File
@@ -0,0 +1,18 @@
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
WITH ranked AS MATERIALIZED (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
)
)
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "username" SET DATA TYPE varchar(255);--> statement-breakpoint
CREATE UNIQUE INDEX "idx_users_username" ON "users" USING btree ("username");
@@ -0,0 +1,2 @@
ALTER TABLE "user_preferences" ADD COLUMN "layout_id" text;--> statement-breakpoint
ALTER TABLE "user_preferences" ADD COLUMN "theme_id" text;
+4
View File
@@ -0,0 +1,4 @@
-- recent_activity moved to the homepage plugin, whose migration renames it to
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
SELECT 1;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "sessions" ADD COLUMN "restriction" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD COLUMN "idle_timeout" integer;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+28
View File
@@ -505,6 +505,34 @@
"when": 1791755800419,
"tag": "0071_drop_install_count_source",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1791935282340,
"tag": "0072_unique_username",
"breakpoints": true
},
{
"idx": 73,
"version": "7",
"when": 1792357128746,
"tag": "0073_user_look_preferences",
"breakpoints": true
},
{
"idx": 74,
"version": "7",
"when": 1792374488837,
"tag": "0074_bouncy_sersi",
"breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1792430770990,
"tag": "0075_session_policy",
"breakpoints": true
}
]
}
+17
View File
@@ -0,0 +1,17 @@
-- Choose an unused prefix from N + 1 disjoint prefixes for N existing users.
WITH ranked AS MATERIALIZED (
SELECT id, username,
ROW_NUMBER() OVER (ORDER BY registered_at, id) AS sequence,
ROW_NUMBER() OVER (PARTITION BY username ORDER BY registered_at, id) AS occurrence
FROM users
), candidates AS (
SELECT 0 AS candidate UNION ALL SELECT sequence FROM ranked
), available AS (
SELECT MIN(candidate) AS candidate FROM candidates
WHERE NOT EXISTS (
SELECT 1 FROM ranked WHERE username LIKE 'termix-recovered-' || candidate || '-' || '%'
)
)
UPDATE users SET username = 'termix-recovered-' || (SELECT candidate FROM available) || '-' || ranked.sequence
FROM ranked WHERE ranked.id = users.id AND ranked.occurrence > 1;--> statement-breakpoint
CREATE UNIQUE INDEX `idx_users_username` ON `users` (`username`);
@@ -0,0 +1,2 @@
ALTER TABLE `user_preferences` ADD `layout_id` text;--> statement-breakpoint
ALTER TABLE `user_preferences` ADD `theme_id` text;
@@ -0,0 +1,4 @@
-- recent_activity moved to the homepage plugin, whose migration renames it to
-- p_homepage_recent_activity. Dropping it here would lose every entry, so this
-- only stops drizzle tracking it. SELECT 1 because MySQL rejects an empty query.
SELECT 1;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE `sessions` ADD `restriction` text(32);--> statement-breakpoint
ALTER TABLE `sessions` ADD `idle_timeout` integer;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+28
View File
@@ -470,6 +470,34 @@
"when": 1791755799726,
"tag": "0066_drop_install_count_source",
"breakpoints": true
},
{
"idx": 67,
"version": "6",
"when": 1791935281343,
"tag": "0067_unique_username",
"breakpoints": true
},
{
"idx": 68,
"version": "6",
"when": 1792357127581,
"tag": "0068_user_look_preferences",
"breakpoints": true
},
{
"idx": 69,
"version": "6",
"when": 1792374488196,
"tag": "0069_normal_ozymandias",
"breakpoints": true
},
{
"idx": 70,
"version": "6",
"when": 1792430769899,
"tag": "0070_session_policy",
"breakpoints": true
}
]
}
+2 -2
View File
@@ -46,11 +46,11 @@
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
"arch": ["x64", "ia32", "arm64"]
},
{
"target": "msi",
"arch": ["x64", "ia32"]
"arch": ["x64", "ia32", "arm64"]
}
],
"icon": "public/icon.ico",
-78
View File
@@ -1,78 +0,0 @@
const { randomUUID } = require("node:crypto");
function createC2SAuthBridge(ipcMain, getWindow) {
const pending = new Map();
ipcMain.handle("answer-c2s-auth", (event, id, answer) => {
const item = pending.get(id);
if (!item || item.sender !== event.sender) return false;
if (answer !== null && (typeof answer !== "string" || answer.length > 8192))
return false;
item.finish(answer);
return true;
});
return function attach(ws, tunnelName) {
const active = new Set();
const close = () => {
for (const id of active) pending.get(id)?.finish(null);
ws.off("message", onMessage);
ws.off("close", close);
ws.off("error", close);
};
const onMessage = (raw, binary) => {
if (binary) return;
let message;
try {
message = JSON.parse(raw.toString());
} catch {
return;
}
if (message.type !== "auth-prompt") return;
const window = getWindow();
if (
!window ||
window.isDestroyed() ||
active.size ||
typeof message.requestId !== "string" ||
!["totp", "input", "browser"].includes(message.request?.kind) // plugin-id-ok: SSH prompt kind, not the plugin
) {
ws.close();
return;
}
const sender = window.webContents;
const id = randomUUID();
const finish = (answer) => {
if (!pending.delete(id)) return;
active.delete(id);
clearTimeout(timer);
sender.off("destroyed", cancelled);
sender.off("did-start-loading", cancelled);
if (!sender.isDestroyed())
sender.send("c2s-auth-prompt", { id, closed: true });
if (ws.readyState === 1)
ws.send(
JSON.stringify({
type: "auth-response",
requestId: message.requestId,
answer,
}),
);
if (answer === null) ws.close();
};
const cancelled = () => finish(null);
const timer = setTimeout(cancelled, 60_000);
active.add(id);
pending.set(id, { sender, finish });
sender.once("destroyed", cancelled);
sender.once("did-start-loading", cancelled);
sender.send("c2s-auth-prompt", {
id,
tunnelName,
request: message.request,
});
};
ws.on("message", onMessage);
ws.once("close", close);
ws.once("error", close);
};
}
module.exports = { createC2SAuthBridge };
+165
View File
@@ -0,0 +1,165 @@
const { isolatedUrl } = require("./isolated-window.cjs");
function cleanBounds(bounds) {
const value = (n) => Math.max(0, Math.round(Number(n) || 0));
return {
x: value(bounds?.x),
y: value(bounds?.y),
width: value(bounds?.width),
height: value(bounds?.height),
};
}
/**
* Native web views drawn inside the main window, for pages that do not work
* in a sandboxed iframe. Each one is a WebContentsView in its own
* non-persistent session, with the same limits as an isolated window: no
* permissions, no downloads, http(s) and ws(s) only. The renderer owns the
* placement and sends the bounds of the element the view should cover.
*/
function createEmbeddedViews({ WebContentsView, session, getMainWindow }) {
const views = new Map();
function destroy(id) {
const entry = views.get(id);
if (!entry) return;
views.delete(id);
const main = getMainWindow();
if (main && !main.isDestroyed()) {
try {
main.contentView.removeChildView(entry.view);
} catch {
/* already detached */
}
}
if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
void Promise.allSettled([
entry.session.clearStorageData(),
entry.session.clearCache(),
entry.session.closeAllConnections(),
]);
}
function destroyAll() {
for (const id of [...views.keys()]) destroy(id);
}
function open(options = {}) {
const main = getMainWindow();
if (!main) throw new Error("No window to attach a view to");
const id = String(options.id || "");
if (!id) throw new Error("A view needs an id");
const url = isolatedUrl(options.url);
const existing = views.get(id);
if (existing) {
if (
existing.url === url.href &&
existing.ignoreCert === !!options.ignoreCert
) {
setBounds(id, options.bounds);
setVisible(id, options.visible !== false);
return { success: true };
}
destroy(id);
}
const isolated = session.fromPartition(
`embedded-view-${id}-${Date.now()}`,
{
cache: false,
},
);
isolated.setPermissionRequestHandler((_c, _p, callback) => callback(false));
isolated.setPermissionCheckHandler(() => false);
isolated.on("will-download", (event) => event.preventDefault());
isolated.webRequest.onBeforeRequest((details, callback) => {
const protocol = new URL(details.url).protocol;
callback({
cancel: !["http:", "https:", "ws:", "wss:"].includes(protocol),
});
});
const view = new WebContentsView({
webPreferences: {
session: isolated,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webviewTag: false,
webSecurity: true,
},
});
const canNavigate = (target) => {
try {
return target === "about:blank" || !!isolatedUrl(target);
} catch {
return false;
}
};
for (const name of ["will-navigate", "will-redirect"]) {
view.webContents.on(name, (event, target) => {
if (!canNavigate(target)) event.preventDefault();
});
}
view.webContents.setWindowOpenHandler(({ url: target }) =>
canNavigate(target)
? {
action: "allow",
overrideBrowserWindowOptions: {
webPreferences: { session: isolated, sandbox: true },
},
}
: { action: "deny" },
);
views.set(id, {
view,
session: isolated,
url: url.href,
origin: url.origin,
ignoreCert: !!options.ignoreCert,
});
main.contentView.addChildView(view);
setBounds(id, options.bounds);
setVisible(id, options.visible !== false);
view.webContents.loadURL(url.href).catch(() => {});
return { success: true };
}
function setBounds(id, bounds) {
const entry = views.get(id);
if (entry) entry.view.setBounds(cleanBounds(bounds));
}
function setVisible(id, visible) {
const entry = views.get(id);
if (entry) entry.view.setVisible(!!visible);
}
function handleCertificateError(event, contents, url, callback) {
for (const entry of views.values()) {
if (entry.view.webContents !== contents) continue;
event.preventDefault();
let allowed = false;
try {
allowed = entry.ignoreCert && new URL(url).origin === entry.origin;
} catch {
/* malformed URL is refused */
}
callback(allowed);
return true;
}
return false;
}
return {
open,
setBounds,
setVisible,
destroy,
destroyAll,
handleCertificateError,
};
}
module.exports = { createEmbeddedViews, cleanBounds };
+20
View File
@@ -0,0 +1,20 @@
/**
* The login URL a server hands the desktop app goes to shell.openExternal,
* so only web pages pass: any other scheme (file:, smb:, ms-msdt:, ...)
* would launch whatever OS handler is registered for it.
*/
function isExternalLoginUrl(value) {
try {
const { protocol } = new URL(value);
return protocol === "https:" || protocol === "http:";
} catch {
return false;
}
}
/** A non-privileged port for the loopback callback listener. */
function isCallbackPort(value) {
return Number.isInteger(value) && value >= 1024 && value <= 65535;
}
module.exports = { isExternalLoginUrl, isCallbackPort };
+21 -1
View File
@@ -6,9 +6,16 @@
let linked = null;
const WEBSOCKET_TO_HTTP = { "ws:": "http:", "wss:": "https:" };
// A WebSocket to the linked server (wss://host/...) counts as the link's
// https://host origin, so its upgrade carries the proxy headers too.
function getOrigin(url) {
try {
return new URL(url).origin;
const parsed = new URL(url);
const httpProtocol = WEBSOCKET_TO_HTTP[parsed.protocol];
if (httpProtocol) parsed.protocol = httpProtocol;
return parsed.origin;
} catch {
return null;
}
@@ -27,6 +34,8 @@ function setLinkedServer(config) {
basicAuth:
config.basicAuth && config.basicAuth.username ? config.basicAuth : null,
allowInvalidCertificate: !!config.allowInvalidCertificate,
clientCert:
config.clientCert && config.clientCert.pfx ? config.clientCert : null,
};
}
@@ -38,6 +47,16 @@ function isLinkedOrigin(url) {
return !!linked && getOrigin(url) === linked.origin;
}
// The PFX of the link's client certificate, for TLS this process makes itself
// to the linked origin. Null for any other origin.
function clientCertOptionsFor(url) {
if (!isLinkedOrigin(url) || !linked.clientCert) return {};
return {
pfx: Buffer.from(linked.clientCert.pfx, "base64"),
passphrase: linked.clientCert.passphrase || "",
};
}
function basicAuthValue(basicAuth) {
return `Basic ${Buffer.from(
`${basicAuth.username}:${basicAuth.password || ""}`,
@@ -92,6 +111,7 @@ module.exports = {
setLinkedServer,
getLinkedServer,
isLinkedOrigin,
clientCertOptionsFor,
applyLinkHeaders,
linkedRequestHeaders,
answerLogin,
File diff suppressed because it is too large. Load diff
-67
View File
@@ -1,67 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
function isUsableShell(file, exists) {
return !!file && (!path.isAbsolute(file) || exists(file));
}
function resolveLocalShell(
platform,
requestedShell,
env = process.env,
exists = fs.existsSync,
) {
if (platform === "win32") {
if (requestedShell === "wsl") {
return { file: "wsl.exe", args: [] };
}
return {
file: env.TERMIX_LOCAL_SHELL || "powershell.exe",
args: ["-NoLogo"],
};
}
// A SHELL left pointing at an uninstalled shell makes the spawn fail.
const fallbacks =
platform === "darwin"
? ["/bin/zsh", "/bin/bash", "/bin/sh"]
: ["/bin/bash", "/bin/sh"];
const file =
[env.TERMIX_LOCAL_SHELL, env.SHELL, ...fallbacks].find((candidate) =>
isUsableShell(candidate, exists),
) || fallbacks[0];
return { file, args: ["-l"] };
}
/**
* node-pty starts every shell through its spawn-helper binary, and a packaged
* copy that lost its execute bit fails with "posix_spawnp failed". Returns
* how many helpers were fixed.
*/
function repairSpawnHelpers(ptyDir, fsImpl = fs) {
const dir = ptyDir.replace(/app\.asar(?!\.unpacked)/, "app.asar.unpacked");
const candidates = [
path.join(dir, "build", "Release", "spawn-helper"),
path.join(
dir,
"prebuilds",
`${process.platform}-${process.arch}`,
"spawn-helper",
),
];
let fixed = 0;
for (const helper of candidates) {
try {
const mode = fsImpl.statSync(helper).mode;
if ((mode & 0o111) === 0) {
fsImpl.chmodSync(helper, mode | 0o755);
fixed += 1;
}
} catch {
// Missing or read-only; nothing to fix here.
}
}
return fixed;
}
module.exports = { resolveLocalShell, repairSpawnHelpers };
+226 -2040
View File
File diff suppressed because it is too large. Load diff
-85
View File
@@ -1,85 +0,0 @@
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawn } = require("child_process");
function singleLine(value, maxLength = 255) {
return String(value ?? "")
.replace(/[\r\n\0]/g, "")
.trim()
.slice(0, maxLength);
}
function validateNativeRdpOptions(options) {
const host = singleLine(options?.host);
const port = Number(options?.port ?? 3389);
if (!host || host.length > 253 || /[\\/]/.test(host)) {
throw new Error("Invalid RDP host");
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error("Invalid RDP port");
}
return {
host,
port,
username: singleLine(options?.username),
domain: singleLine(options?.domain),
};
}
function buildRdpFile(options) {
const { host, port, username, domain } = validateNativeRdpOptions(options);
const address = host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
const qualifiedUsername = username
? domain
? `${domain}\\${username}`
: username
: "";
return [
`full address:s:${address}`,
"prompt for credentials:i:1",
"administrative session:i:0",
...(qualifiedUsername ? [`username:s:${qualifiedUsername}`] : []),
"",
].join("\r\n");
}
async function launchNativeRdp(options, platform = process.platform) {
if (platform !== "win32") {
return {
success: false,
error: "Windows Remote Desktop is only available on Windows",
};
}
const rdpContent = buildRdpFile(options);
const tempDir = await fs.promises.mkdtemp(
path.join(os.tmpdir(), "termix-rdp-"),
);
const rdpPath = path.join(tempDir, "connection.rdp");
await fs.promises.writeFile(rdpPath, rdpContent, {
encoding: "utf8",
mode: 0o600,
});
return new Promise((resolve) => {
const child = spawn("mstsc.exe", [rdpPath], {
detached: true,
windowsHide: true,
stdio: "ignore",
});
const cleanup = () =>
fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
child.once("error", (error) => {
cleanup();
resolve({ success: false, error: error.message });
});
child.once("spawn", () => {
child.unref();
setTimeout(cleanup, 30_000).unref();
resolve({ success: true });
});
});
}
module.exports = { buildRdpFile, launchNativeRdp, validateNativeRdpOptions };
+30 -119
View File
@@ -14,7 +14,16 @@ function invokeAllowed(channel, ...args) {
return ipcRenderer.invoke(channel, ...args);
}
// The embedded backend's port, picked by the main process at startup.
const backendPortArg = process.argv.find((arg) =>
arg.startsWith("--termix-backend-port="),
);
const backendPort = backendPortArg
? Number(backendPortArg.split("=")[1])
: undefined;
contextBridge.exposeInMainWorld("electronAPI", {
backendPort,
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
// Kept so the interface size can reset a zoom an earlier build applied.
setZoomFactor: (factor) => {
@@ -23,6 +32,19 @@ contextBridge.exposeInMainWorld("electronAPI", {
webFrame.setZoomFactor(Math.min(2, Math.max(0.5, value)));
},
getPlatform: () => ipcRenderer.invoke("get-platform"),
// Whether the app-drawn title bar shows in this window, and when it changes
// (full screen hides it).
getTitleBarVisible: () => ipcRenderer.invoke("get-title-bar-visible"),
onTitleBarVisibleChange: (callback) => {
const listener = (_event, visible) => callback(visible);
ipcRenderer.on("title-bar-visible-changed", listener);
return () =>
ipcRenderer.removeListener("title-bar-visible-changed", listener);
},
// Keeps the window controls the system draws in the colors of the theme.
setTitleBarOverlay: (colors) =>
ipcRenderer.invoke("set-title-bar-overlay", colors),
getEmbeddedServerStatus: () =>
ipcRenderer.invoke("get-embedded-server-status"),
@@ -49,36 +71,6 @@ contextBridge.exposeInMainWorld("electronAPI", {
getSetting: (key) => ipcRenderer.invoke("get-setting", key),
setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
answerC2SAuth: (id, answer) =>
ipcRenderer.invoke("answer-c2s-auth", id, answer),
onC2SAuthPrompt: (callback) => {
const listener = (_event, prompt) => callback(prompt);
ipcRenderer.on("c2s-auth-prompt", listener);
return () => ipcRenderer.removeListener("c2s-auth-prompt", listener);
},
getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"),
saveC2STunnelConfig: (config) =>
ipcRenderer.invoke("save-c2s-tunnel-config", config),
checkLocalPortAvailable: (host, port) =>
ipcRenderer.invoke("check-local-port-available", host, port),
getC2STunnelPresetDefaultName: () =>
ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"),
startC2STunnel: (tunnel, index, authToken) =>
ipcRenderer.invoke("start-c2s-tunnel", tunnel, index, authToken),
testC2STunnel: (tunnel, index, authToken) =>
ipcRenderer.invoke("test-c2s-tunnel", tunnel, index, authToken),
stopC2STunnel: (tunnelName) =>
ipcRenderer.invoke("stop-c2s-tunnel", tunnelName),
getC2STunnelStatuses: () => ipcRenderer.invoke("get-c2s-tunnel-statuses"),
onC2STunnelStatuses: (callback) => {
const listener = (_event, statuses) => callback(statuses);
ipcRenderer.on("c2s-tunnel-statuses", listener);
return () => ipcRenderer.removeListener("c2s-tunnel-statuses", listener);
},
startC2SAutoStartTunnels: () =>
ipcRenderer.invoke("start-c2s-autostart-tunnels"),
setC2SRelayPath: (relayPath) =>
ipcRenderer.invoke("set-c2s-relay-path", relayPath),
onCloseActiveTab: (callback) => {
const listener = () => callback();
@@ -87,46 +79,12 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
getSessionCookie: (name, targetUrl) =>
ipcRenderer.invoke("get-session-cookie", name, targetUrl),
waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
ipcRenderer.invoke(
"wait-session-cookie",
name,
targetUrl,
previousValue,
timeoutMs,
),
externalBrowserLogin: (authUrl, callbackPort) =>
ipcRenderer.invoke("external-browser-login", authUrl, callbackPort),
openExternalEditor: (fileData) =>
ipcRenderer.invoke("open-external-editor", fileData),
closeExternalEditor: (editId) =>
ipcRenderer.invoke("close-external-editor", editId),
onExternalEditorSaved: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("external-editor-saved", listener);
return () => ipcRenderer.removeListener("external-editor-saved", listener);
},
showSaveDialog: (options) => ipcRenderer.invoke("show-save-dialog", options),
showOpenDialog: (options) => ipcRenderer.invoke("show-open-dialog", options),
getLocalHomeDirectory: () => ipcRenderer.invoke("get-local-home-directory"),
listLocalDirectory: (dirPath) =>
ipcRenderer.invoke("list-local-directory", dirPath),
statLocalPaths: (paths) => ipcRenderer.invoke("stat-local-paths", paths),
collectLocalFiles: (paths) =>
ipcRenderer.invoke("collect-local-files", paths),
createLocalFolder: (parentPath, folderName) =>
ipcRenderer.invoke("create-local-folder", parentPath, folderName),
renameLocalPath: (entryPath, newName) =>
ipcRenderer.invoke("rename-local-path", entryPath, newName),
trashLocalPath: (entryPath) =>
ipcRenderer.invoke("trash-local-path", entryPath),
chmodLocalPath: (entryPath, permissions) =>
ipcRenderer.invoke("chmod-local-path", entryPath, permissions),
createTempFile: (fileData) =>
ipcRenderer.invoke("create-temp-file", fileData),
createTempFolder: (folderData) =>
@@ -135,61 +93,14 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("start-drag-to-desktop", dragData),
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
startLocalTerminal: (dimensions) =>
ipcRenderer.invoke("local-terminal-start", dimensions),
writeLocalTerminal: (sessionId, data) =>
ipcRenderer.invoke("local-terminal-write", sessionId, data),
readyLocalTerminal: (sessionId) =>
ipcRenderer.invoke("local-terminal-ready", sessionId),
resizeLocalTerminal: (sessionId, cols, rows) =>
ipcRenderer.invoke("local-terminal-resize", sessionId, cols, rows),
closeLocalTerminal: (sessionId) =>
ipcRenderer.invoke("local-terminal-close", sessionId),
onLocalTerminalData: (sessionId, callback) => {
const channel = `local-terminal:data:${sessionId}`;
const listener = (_event, data) => callback(data);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
onLocalTerminalExit: (sessionId, callback) => {
const channel = `local-terminal:exit:${sessionId}`;
const listener = (_event, exitCode) => callback(exitCode);
ipcRenderer.on(channel, listener);
return () => ipcRenderer.removeListener(channel, listener);
},
// Dual-pane file manager: local disk browsing and streamed transfers.
localFs: {
home: () => ipcRenderer.invoke("local-fs:home"),
list: (dirPath) => ipcRenderer.invoke("local-fs:list", dirPath),
mkdir: (parentPath, name) =>
ipcRenderer.invoke("local-fs:mkdir", parentPath, name),
createFile: (parentPath, name) =>
ipcRenderer.invoke("local-fs:create-file", parentPath, name),
rename: (oldPath, newName) =>
ipcRenderer.invoke("local-fs:rename", oldPath, newName),
trash: (paths) => ipcRenderer.invoke("local-fs:trash", paths),
ensureDir: (dirPath, rootPath) =>
ipcRenderer.invoke("local-fs:ensure-dir", dirPath, rootPath),
exists: (paths) => ipcRenderer.invoke("local-fs:exists", paths),
walk: (paths) => ipcRenderer.invoke("local-fs:walk", paths),
reveal: (targetPath) => ipcRenderer.invoke("local-fs:reveal", targetPath),
open: (targetPath) => ipcRenderer.invoke("local-fs:open", targetPath),
},
localTransfer: {
setApiPath: (apiPath) =>
ipcRenderer.invoke("local-transfer:set-api", apiPath),
upload: (options) => ipcRenderer.invoke("local-transfer:upload", options),
download: (options) =>
ipcRenderer.invoke("local-transfer:download", options),
cancel: (transferId) =>
ipcRenderer.invoke("local-transfer:cancel", transferId),
onProgress: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("local-transfer:progress", listener);
return () =>
ipcRenderer.removeListener("local-transfer:progress", listener);
},
// Native web views placed over an element of the page (WebContentsView).
embeddedView: {
open: (options) => ipcRenderer.invoke("embedded-view-open", options),
setBounds: (id, bounds) =>
ipcRenderer.invoke("embedded-view-bounds", id, bounds),
setVisible: (id, visible) =>
ipcRenderer.invoke("embedded-view-visible", id, visible),
close: (id) => ipcRenderer.invoke("embedded-view-close", id),
},
invoke: invokeAllowed,
-22
View File
@@ -1,22 +0,0 @@
const fs = require("node:fs");
function renameLocalPath(source, target) {
if (source === target) return;
try {
fs.lstatSync(target);
const error = new Error("A file or folder with that name already exists");
error.code = "EEXIST";
throw error;
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
if (fs.lstatSync(source).isDirectory()) {
fs.renameSync(source, target);
return;
}
// link is exclusive: a destination created concurrently is never replaced.
fs.linkSync(source, target);
fs.unlinkSync(source);
}
module.exports = { renameLocalPath };
+18 -21
View File
@@ -71,7 +71,6 @@
"group": "server",
"description": "SSH agent socket used for hosts set to agent auth when they do not name their own socket."
},
{
"name": "ENABLE_SSL",
"group": "https",
@@ -108,7 +107,6 @@
"internal": true,
"description": "Set by the image when nginx serves HTTPS in front of the backend."
},
{
"name": "DATABASE_DIALECT",
"group": "database",
@@ -161,7 +159,6 @@
"internal": true,
"description": "Where database migrations are read from. Set by the image."
},
{
"name": "JWT_SECRET",
"group": "secrets",
@@ -204,7 +201,6 @@
"secret": true,
"description": "Only read to move data from installs older than 2.5. Never change it on an install that has it."
},
{
"name": "ALLOW_PASSWORD_LOGIN",
"group": "auth",
@@ -283,7 +279,6 @@
"group": "auth",
"description": "JSON map from proxy roles to Termix roles, like {\"admins\":[\"admin\"],\"staff\":[\"user\"]}. Required when trusted proxy login is on."
},
{
"name": "TRUSTED_PROXIES",
"group": "proxy",
@@ -315,7 +310,6 @@
"group": "proxy",
"description": "Comma separated hosts that skip the proxy."
},
{
"name": "LOG_LEVEL",
"group": "logging",
@@ -338,24 +332,12 @@
"default": "10000",
"description": "Most audit entries kept. The oldest go first."
},
{
"name": "AUDIT_LOG_FORWARD_URL",
"group": "logging",
"description": "Also POST every audit entry as JSON to this URL. Overrides the admin setting."
},
{
"name": "AUDIT_LOG_FORWARD_TOKEN",
"group": "logging",
"secret": true,
"description": "Bearer token sent with forwarded audit entries."
},
{
"name": "SESSION_RECORDING_RETENTION_DAYS",
"group": "logging",
"deprecated": "the session-recording plugin setting",
"description": "Only read once to carry a 2.8 value into the session-recording plugin."
},
{
"name": "TERMIX_PLUGIN_REGISTRY_URL",
"group": "plugins",
@@ -377,6 +359,12 @@
"default": "false",
"description": "Only load plugins signed by a trusted key. Blocks uploads of unsigned files even in developer mode."
},
{
"name": "TERMIX_SAFE_LAYOUT",
"group": "plugins",
"default": "false",
"description": "Start everyone in the layout repair view instead of the chosen layout. Use it when a layout plugin breaks the app."
},
{
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
"group": "plugins",
@@ -406,7 +394,6 @@
"internal": true,
"description": "Set by npm run dev."
},
{
"name": "ENABLE_INSECURE_MODE",
"group": "desktop",
@@ -435,6 +422,18 @@
"internal": true,
"description": "Set by the desktop app when it runs its own backend."
},
{
"name": "ELECTRON_USER_DATA",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app to its user data folder."
},
{
"name": "EMBEDDED_BACKEND_PORT",
"group": "desktop",
"internal": true,
"description": "Set by the desktop app to the port its own backend listens on. 30001 unless another program holds it."
},
{
"name": "TERMIX_DATA_DIR",
"group": "desktop",
@@ -459,14 +458,12 @@
"internal": true,
"description": "Set by Flatpak. The desktop app links new versions instead of installing them."
},
{
"name": "DESKTOP_SESSION",
"group": "desktop",
"internal": true,
"description": "Read on Linux to pick the password store."
},
{
"name": "VITE_BASE_PATH",
"group": "internal",
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.1",
"version": "26.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.1",
"version": "26.10.3",
"hasInstallScript": true,
"workspaces": [
"packages/*"
@@ -15058,7 +15058,7 @@
},
"packages/plugin-sdk": {
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.6",
"version": "1.0.13",
"license": "Apache-2.0",
"dependencies": {
"@deadendjs/swagger-jsdoc": "^8.1.2",
+4 -4
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.1",
"version": "26.10.3",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -15,10 +15,10 @@
"scripts": {
"format": "prettier --write .",
"format:check": "prettier --check .",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs",
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && node scripts/patch-ssh2-sk.cjs",
"prepare": "husky || true",
"prebuild": "node scripts/write-electron-build-info.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/generate-settings-search-index.cjs --check && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint": "node scripts/generate-dialect-schema.cjs --check && eslint . && node scripts/check-importmap-csp.cjs && node scripts/check-core-plugin-ids.cjs && node scripts/check-sdk-ui-exports.cjs && node scripts/check-sdk-guide.cjs && node scripts/check-test-locations.cjs && node scripts/check-changelog.cjs && node scripts/check-env-catalog.cjs",
"lint:fix": "eslint --fix .",
"type-check": "npm run build:sdk && tsc -b --force",
"test": "vitest run",
@@ -30,7 +30,7 @@
"dev:electron": "node scripts/dev.mjs electron",
"dev:docker": "node scripts/dev.mjs docker",
"dev:frontend": "vite",
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build": "npm run build:sdk && vite build && node scripts/stamp-sw-build-info.cjs && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
"generate:openapi": "npm run build:sdk && node scripts/generate-openapi.mjs",
"electron:rebuild": "electron-rebuild -f -o better-sqlite3,@serialport/bindings-cpp,node-pty",
+140
View File
@@ -1,5 +1,145 @@
# 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.
### Added
- `ctx.auth.issueUserToken()` mints a short-lived session for a user, for calling Termix's own API as them from background work. Needs `users:impersonate` and `engine.api` 1.3
- `ctx.users.getLocale()` reads a user's UI language. Needs `users:read` and `engine.api` 1.3
## 1.0.10
### Changed
- `termix-plugin pack` includes the plugin's `LICENSE` and `NOTICE` files, so third-party notices ship with it
## 1.0.9
### Added
- Worker entries in `package.json` `termix.workers`, each bundled on its own into `dist/workers/<name>.js`
- A `.wasm`, `.ttf` or `.otf` import gives the file's bytes, in `termix-plugin build` and in plugin tests
### Fixed
- The plugin guide listed jszip, guacamole-lite and @anthropic-ai/sdk as shared packages, but they are bundled
## 1.0.8
### Added
- `publicRateLimit` on `ctx.http.router()`, for a plugin whose public routes serve a proxied page
## 1.0.7
### Fixed
- A migration file, or a part of one, holding only comments made core fail to start the plugin
## 1.0.6
### Added
+305 -102
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.6 | Plugin API 1.2.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.
@@ -90,7 +90,7 @@ npm test # termix-plugin test: Vitest
npm run typecheck # tsc
npm run validate # termix-plugin validate: manifest, files, migrations, changelog, docs
npm run migrations # termix-plugin migrations: write migrations after changing tables.ts
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug
npm run pack # termix-plugin pack: write <id>-<version>.tmxplug (with LICENSE and NOTICE)
npm run format # Prettier
npm run format:check
```
@@ -105,7 +105,7 @@ Before you call a change finished:
- [ ] `npm run build` passes
- [ ] `npm run format:check` passes
- [ ] `npx termix-plugin migrations --check` passes (you ran `npm run migrations` after changing tables)
- [ ] New strings are in `locales/en.json`
- [ ] New strings are in `locales/en.json`, and `plugin.name` / `plugin.description` match the manifest
- [ ] New routes have an `@openapi` block and a test, including without the permission
- [ ] New env vars are listed in `env` in the manifest
- [ ] `docs/` and the "About this plugin" section of `AGENTS.md` still match what the plugin does
@@ -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,34 +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 |
| `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
@@ -237,6 +243,13 @@ In a route the actor is the signed-in user, set by Termix. `ctx.currentActor()`
work with no request (a timer, an event) uses `ctx.asUser(userId, fn)`, which is audited and still applies
that user's permissions.
Background work that must call Termix's own HTTP API (the Termix CLI, say) asks for a session with
`ctx.auth.issueUserToken(userId, { purpose, ttlSeconds })` and sends it as `Authorization: Bearer <token>`.
It needs `users:impersonate` and `engine.api` `^1.3`, lasts 300 seconds by default and 900 at most, and is
audited as `plugin_user_token_issued`. The user's permissions apply, and it is refused on routes that change
their password, second factors, API keys or linked logins. `ctx.users.getLocale(userId)` gives the user's
UI language, like `zh_CN`, or null, for writing to them in it.
### Routes
Routes live at `/plugin-api/<id>/`, sockets at `/plugin-ws/<id>/`, on the main Termix port. You never need
@@ -283,13 +296,22 @@ router.post("/notes", async (req, res) => {
- The `@openapi` path is the full path, starting with `/plugin-api/<id>/`. `build` turns these into
`dist/openapi.json` and the docs site renders them.
- `ctx.http.router({ public: ["/webhook/:token"], bodyLimit: "20mb", rawBody: false })`. Public routes
have no actor: check their token yourself and rate limit them.
have no actor: check their token yourself. Core allows 120 public requests a minute per address;
`publicRateLimit` raises that for a proxied page that loads its own assets.
- `ctx.http.baseUrl(req)` is the public address, for callback URLs.
- `ctx.ws.route("/live", (socket, conn) => ...)`. Long lived sockets should check
`conn.isDataUnlocked()` per message.
- `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
@@ -315,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
@@ -333,8 +373,27 @@ ctx.schedule.every(60_000, () => refresh(), { jitterMs: 5_000 });
### Shared packages
These come from Termix and are never bundled: `@termix-ssh/plugin-sdk`, `express`, `ssh2`, `ws`, `multer`,
`cookie-parser`, `axios`, `jszip`, `guacamole-lite`, `@anthropic-ai/sdk`, `drizzle-orm`, `sharp` and every
Node builtin. List the ones you use in `peerDependencies`. Any other npm package is bundled.
`cookie-parser`, `axios`, `drizzle-orm`, `sharp` and every Node builtin. List the ones you use in
`peerDependencies`. Any other npm package is bundled.
### Workers and WebAssembly
Heavy work (decoding, encoding, parsing) belongs in a worker thread so it never blocks the server. Name
each worker entry in `package.json`:
```json
"termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
```
`build` bundles it alone into `dist/workers/engine.js`; start it with
`new Worker(new URL("./workers/engine.js", import.meta.url))`. A worker bundles everything it imports and
can't use the shared packages above, so it must not import `@termix-ssh/plugin-sdk`. Talk to it with
`postMessage`.
Importing a `.wasm`, `.ttf` or `.otf` file gives its bytes as a `Uint8Array`, in `build` and in tests, so a module ships
inside the bundle with no file to find. Compile it once on the main thread with `new WebAssembly.Module`
and post that to each worker. Add `declare module "*.wasm" { const bytes: Uint8Array; export default bytes; }`
to a `.d.ts` in the plugin.
## Database
@@ -436,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
@@ -464,15 +538,103 @@ your own host settings, under `host.pluginSettings[<id>]`.
Slots you can fill include `terminal.toolbar`, `terminal.toolbarStatus`, `terminal.sidePanel`,
`terminal.overlay`, `dashboard.counters`, `dashboard.hostMetrics`, `admin.userTabs`, `hosts.importMenu`,
`hosts.panel`, `credentials.badges`, `shell.overlay` and `tab.menu`. See
`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.
@@ -518,6 +680,20 @@ Plurals use `_one` and `_other` with `t("hosts", { count })`. Other languages go
`locales/translated/<code>.json` (like `de_DE.json`) with the same keys. Keep `{{placeholders}}` as they are.
Untranslated strings show in English.
Termix shows your plugin's name and description in the plugin manager, the setup picker and settings.
Put them in `locales/en.json` as `plugin.name` and `plugin.description`, with the same text as `name` and
`description` in the manifest, so they get translated too. Without them, the English manifest text shows in
every language.
```json
{
"plugin": {
"name": "Host Metrics",
"description": "Live CPU, memory, disk, network and GPU stats."
}
}
```
## Docs
`docs/index.md` is the first page: what the plugin does in a sentence or two, how to turn it on, how to use
@@ -535,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
@@ -682,75 +873,87 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
## termix-plugin CLI
| Command | What it does |
| ------------------------------ | ---------------------------------------------------------------------------------------------- |
| `build` | Bundle `dist/backend.js` and `dist/frontend.js`, CSS, locales, migrations, `dist/openapi.json` |
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
| `test` | Apply patches, run Vitest |
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
| `keygen` | Write a new signing key pair. `--out <dir>` |
| `changelog` | Print one version's notes. `--version x.y.z` |
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
| Command | What it does |
| ------------------------------ | ----------------------------------------------------------------------------------------------- |
| `build` | Bundle `dist/backend.js`, `dist/frontend.js`, workers, CSS, locales, migrations, `openapi.json` |
| `validate` | Check the manifest, its files, migrations for every database, the changelog and `package.json` |
| `test` | Apply patches, run Vitest |
| `migrations` | Write a migration per database from `tables.ts`. `--check` fails when one is missing |
| `openapi` | Write `dist/openapi.json` from `@openapi` blocks |
| `pack` | Validate, then write `<id>-<version>.tmxplug`. `--out <dir>` |
| `sign <file>` | Sign a `.tmxplug` with `TERMIX_PLUGIN_SIGNING_KEY` |
| `verify <file> --key <base64>` | Check a `.tmxplug` against its `.sig` |
| `keygen` | Write a new signing key pair. `--out <dir>` |
| `changelog` | Print one version's notes. `--version x.y.z` |
| `patch` | Apply the plugin's dependency patches (`build` and `test` do this too) |
| `dev` | Build, pack and install on a server, again on every change. `--server`, `--key`, `--once` |
## 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 | Run background work as another user, 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 and roles |
| `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.
+88 -23
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";
@@ -52,6 +57,43 @@ export function externalRequireInterop(externals) {
};
}
// A .wasm or font import becomes its bytes (a Uint8Array) inside the bundle,
// so it ships in backend.js or a worker with no separate file to find.
export const BINARY_EXTENSIONS = [".wasm", ".ttf", ".otf"];
const BINARY_LOADERS = Object.fromEntries(
BINARY_EXTENSIONS.map((ext) => [ext, "binary"]),
);
const REQUIRE_BANNER =
"import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);";
/**
* package.json "termix": { "workers": { "engine": "src/backend/engine/worker.ts" } }
* bundles each entry on its own into dist/workers/<name>.js, for
* new Worker(new URL("./workers/<name>.js", import.meta.url)). Nothing is left
* external: core's import fallback only runs on the main thread, so a worker
* can't rely on host packages and must not import the SDK.
*/
export function readWorkerEntries(cwd, pluginId) {
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const workers = pkg.termix?.workers ?? {};
return Object.entries(workers).map(([name, entry]) => {
if (!/^[a-z0-9-]+$/.test(name)) {
throw new Error(`${pluginId}: worker name "${name}" must be lowercase`);
}
const file = path.resolve(cwd, String(entry));
if (!fs.existsSync(file)) {
throw new Error(
`${pluginId}: worker "${name}" entry ${entry} is missing`,
);
}
return { name, file };
});
}
const SDK_PATCHES = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
@@ -195,33 +237,56 @@ 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 ?? [])],
// 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: "import { createRequire as __termixCreateRequire } from 'node:module'; const require = __termixCreateRequire(import.meta.url);",
},
});
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({
entryPoints: [worker.file],
outfile: path.join(outDir, "workers", `${worker.name}.js`),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
logLevel: "warning",
loader: BINARY_LOADERS,
banner: { js: REQUIRE_BANNER },
});
}
// Without this Node finds the host's typeless package.json, tries backend.js
// as CommonJS first and warns before reparsing it as ESM.
+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);
+12
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",
@@ -11,6 +20,9 @@ export const TMXPLUG_CONTENTS = [
"README.md",
"CHANGELOG.md",
"icon.svg",
// Licence texts, so third-party notices travel with the plugin.
"LICENSE",
"NOTICE",
];
const BLOCK = 512;
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.6",
"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",
+12 -2
View File
@@ -309,17 +309,27 @@ export function splitStatements(source: string): string[] {
continue;
}
if (char === ";") {
if (current.trim()) statements.push(current.trim());
if (hasSql(current)) statements.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) statements.push(current.trim());
if (hasSql(current)) statements.push(current.trim());
return statements;
}
/** False for a chunk that is only comments, which a driver refuses to run. */
function hasSql(chunk: string): boolean {
return (
chunk
.replace(/--[^\n]*/g, "")
.replace(/\/\*[\s\S]*?\*\//g, "")
.trim() !== ""
);
}
/** DROP statement for a definition, for removePluginData. */
export function dropTableSql(
dialect: SqlDialect,
+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.2.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),
};
}
}
+430 -3
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,8 +198,12 @@ 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. */
locales?: Record<string, string>;
}
export interface FakeProcessOptions {
@@ -247,6 +277,10 @@ export interface FakeAuthRegistrations {
}>;
/** Failures recorded through ctx.auth.loginRateLimit, by "<ip>|<key>". */
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 {
@@ -259,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. */
@@ -291,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. */
@@ -334,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. */
@@ -460,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 };
@@ -487,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[] = [];
@@ -504,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"] =
[];
@@ -527,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"] = [];
@@ -574,6 +630,8 @@ export function createFakeContext(
completedLogins: [],
revokedSessions: [],
loginFailures: new Map(),
userTokens: [],
policies: [],
};
const sshClient = options.sshClient ?? {};
let actor = options.actor;
@@ -660,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
@@ -700,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: {
@@ -897,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> => {
@@ -951,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) => {
@@ -966,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) => {
@@ -991,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 {
@@ -1008,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);
@@ -1140,6 +1308,34 @@ export function createFakeContext(
},
countLinkedUsers: async (provider) =>
options.linkedUsers?.[provider] ?? 0,
issueUserToken: async (userId, tokenOptions) => {
auth.userTokens.push({ userId, ...tokenOptions });
const ttl = Math.min(tokenOptions.ttlSeconds ?? 300, 900);
return {
token: `fake-token-${auth.userTokens.length}`,
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: {
getLocale: async (userId) => options.locales?.[userId] ?? null,
},
desktop: {
@@ -1152,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) => ({
@@ -1178,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(() => {
@@ -1328,6 +1533,9 @@ export function createFakeContext(
return errors;
},
disposals,
hostStatusProviders,
activityProviders,
hostLockProviders,
emitted,
kv,
tables,
@@ -1338,6 +1546,7 @@ export function createFakeContext(
coreSettings,
sshConnections,
hostShares,
hostShareSets,
auth,
desktopWindows,
externalClientLaunches,
@@ -1351,6 +1560,9 @@ export function createFakeContext(
tls,
secretStore,
audits,
emitAudit: (entry) => {
for (const handler of auditSubscribers) handler(entry);
},
activities,
trackedSessions,
interactions,
@@ -1383,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[];
@@ -1392,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. */
@@ -1415,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[];
/**
@@ -1442,6 +1670,10 @@ export interface MockContextOptions {
linkedUsers?: Record<string, number>;
/** See FakeContextOptions. */
loginAttemptLimit?: number;
/** See FakeContextOptions. */
secondFactors?: FakeContextOptions["secondFactors"];
/** See FakeContextOptions. */
locales?: Record<string, string>;
}
export interface MockPluginContext extends FakePluginContext {
@@ -1458,9 +1690,67 @@ export interface MockPluginContext extends FakePluginContext {
* topic outside the plugin's own namespace, settings:read-core on
* ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh,
* notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately
* ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, auth:provide on ctx.auth
* (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 {
@@ -1469,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,
@@ -1483,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,
@@ -1495,6 +1793,8 @@ export function createMockCtx(
baseUrl: options.baseUrl,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
secondFactors: options.secondFactors,
locales: options.locales,
manifest: {
capabilities: options.capabilities ?? [],
...options.manifest,
@@ -1629,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();
@@ -1661,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();
@@ -1677,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);
@@ -1699,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");
@@ -1786,6 +2157,40 @@ export function createMockCtx(
require("auth:provide");
return ctx.auth.countLinkedUsers(provider);
},
issueUserToken: async (userId, tokenOptions) => {
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: {
getLocale: async (userId) => {
require("users:read");
return ctx.users.getLocale(userId);
},
},
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: {
@@ -1798,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();
@@ -1819,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);
@@ -1845,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)];
}
+62 -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";
@@ -30,6 +31,25 @@ function findRepoRoot(startDir: string): string | null {
}
}
/**
* termix-plugin build turns a .wasm or font import into its bytes. Tests get the same,
* so code that imports a module compiles it the same way under vitest.
*/
export function wasmBinaryPlugin() {
return {
name: "termix-wasm-binary",
enforce: "pre" as const,
load(id: string) {
const file = id.split("?")[0];
if (![".wasm", ".ttf", ".otf"].some((ext) => file.endsWith(ext))) {
return null;
}
const base64 = fs.readFileSync(file).toString("base64");
return `export default new Uint8Array(Buffer.from(${JSON.stringify(base64)}, "base64"));`;
},
};
}
export interface PluginVitestOptions {
/** Extra aliases merged over the defaults. */
alias?: Record<string, string>;
@@ -96,15 +116,52 @@ export function pluginVitestConfig(
// unexpectedly". Threads don't.
const pool = "threads" as const;
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 {
resolve: { alias },
plugins,
server,
resolve,
test: {
globals: true,
setupFiles: [setupFile],
pool,
projects: [
{
resolve: { alias },
plugins,
server,
resolve,
test: {
name: `${pluginId}:backend`,
root: pluginDir,
@@ -116,7 +173,9 @@ export function pluginVitestConfig(
},
},
{
resolve: { alias },
plugins,
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>>
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

+6 -1
View File
@@ -1,4 +1,9 @@
const CACHE_NAME = "termix-static-v2";
// CACHE_NAME carries the package version so a new release always gets a fresh
// cache: the activate handler below deletes every cache whose name differs
// from this one, which is what evicts a previous build's entries. Both
// placeholders on these lines are stamped by scripts/stamp-sw-build-info.cjs
// during `npm run build` - vite copies public/ verbatim and never transforms it.
const CACHE_NAME = "termix-static-" + "__TERMIX_CACHE_VERSION__";
const BASE_PATH = "__TERMIX_SW_BASE_PATH__";
const STATIC_ASSETS = [
`${BASE_PATH}/favicon.ico`,
+95
View File
@@ -276,6 +276,33 @@ describe("pinsFromIndex", () => {
expect(pins[0]).not.toHaveProperty("onboarding");
});
it("pins a release kept in gated", () => {
const pins = pinsFromIndex(
{
plugins: [
{
id: "look",
versions: [],
gated: [
{
version: "1.1.0-beta.1",
url: "https://example.test/look-1.1.0-beta.1.tmxplug",
sha256: "d".repeat(64),
},
{
version: "1.0.0",
url: "https://example.test/look-1.0.0.tmxplug",
sha256: "e".repeat(64),
},
],
},
],
},
null,
);
expect(pins[0].url).toBe("https://example.test/look-1.0.0.tmxplug");
});
it("pins only the ids asked for", () => {
expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([
"beta",
@@ -416,3 +443,71 @@ describe("rebuilding local plugins", () => {
expect(run.sort()).toEqual(["a", "b"]);
});
});
describe("default look", () => {
const entry = { source: "tmxplug", path: "a", sha256: SHA };
it("reads the defaultLook flag and refuses anything but a boolean", () => {
const { plugins, problems } = parseBundledPlugins({
plugins: [
{ ...entry, id: "look", defaultLook: true },
{ ...entry, id: "plain" },
{ ...entry, id: "odd", defaultLook: "yes" },
],
});
expect(problems).toEqual(["odd defaultLook must be a boolean"]);
expect(
plugins.map((p: { id: string; defaultLook: boolean }) => [
p.id,
p.defaultLook,
]),
).toEqual([
["look", true],
["plain", false],
]);
});
it("carries it into the bundled index and through a re-pin", () => {
expect(
buildBundledIndex(
[
{ id: "look", onboarding: null, defaultLook: true },
{ id: "plain", onboarding: null, defaultLook: false },
],
["look", "plain"],
),
).toEqual({ version: 1, plugins: { look: { defaultLook: true } } });
const index = {
plugins: [
{
id: "look",
versions: [
{ version: "1.0.0", url: "https://x/l.tmxplug", sha256: SHA },
],
},
],
};
expect(
pinsFromIndex(index, null, [{ id: "look", defaultLook: true }])[0],
).toMatchObject({ id: "look", defaultLook: true });
});
it("copies a local build's theme files", () => {
const repo = tempDir();
fs.mkdirSync(path.join(repo, "dist"), { recursive: true });
fs.mkdirSync(path.join(repo, "themes"), { recursive: true });
fs.writeFileSync(path.join(repo, "dist", "backend.js"), "export {}");
fs.writeFileSync(path.join(repo, "themes", "paper.css"), ".paper{}");
fs.writeFileSync(
path.join(repo, "manifest.json"),
JSON.stringify({
id: "inks",
contributes: { themes: [{ id: "paper", css: "themes/paper.css" }] },
}),
);
const out = path.join(tempDir(), "inks");
copyLocalBuild(repo, out);
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
});
});
+6
View File
@@ -32,12 +32,18 @@ const OFFICIAL_IDS = [
"acme-ssl",
"ai",
"alerts",
"audit-tools",
"automations",
"classic-layout",
"classic-themes",
"credential-tools",
"docker",
"file-manager",
"fleets",
"homepage",
"host-metrics",
"host-status",
"import-export",
"ldap",
"network-topology",
"opkssh",
+390 -1
View File
@@ -19,8 +19,13 @@ const SDK_UI = path.join(ROOT, "src", "ui", "plugin-host", "sdk-ui.ts");
const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
const ALLOWED = [
"ACCENT_PRESET_COLORS",
"APP_VERSION",
"AccessRecord",
"ActionSlot",
"ActiveSessionInfo",
"AddButton",
"AdminSection",
"Alert",
"AlertDescription",
"AlertDialog",
@@ -35,14 +40,23 @@ const ALLOWED = [
"AlertDialogTitle",
"AlertDialogTrigger",
"AlertTitle",
"ApiKey",
"AuditLog",
"AuditLogFilters",
"AuthEnrollmentSections",
"AuthOverrideProtocol",
"BackButton",
"Badge",
"BarSeries",
"BarSeriesItem",
"BarSeriesProps",
"BrandingSettings",
"BrowserSignInDialog",
"Button",
"ButtonProps",
"CORE_HOST_DEFAULTS",
"CORE_NAMESPACE",
"CREDENTIAL_ROW_FIELD_KEYS",
"Card",
"CardAction",
"CardContent",
@@ -54,6 +68,12 @@ const ALLOWED = [
"CardTitle",
"Checkbox",
"ColumnCountStepper",
"Command",
"CommandEmpty",
"CommandGroup",
"CommandItem",
"CommandList",
"CommandPrimitive",
"ComponentSlot",
"ConfirmOptions",
"ConnectionGate",
@@ -66,9 +86,26 @@ const ALLOWED = [
"ConnectionStage",
"ConnectionStatus",
"ConnectionUnavailable",
"CreatedApiKey",
"Credential",
"CredentialDensity",
"CredentialPermissionLevel",
"CredentialRowFields",
"CredentialSidebarFilterState",
"CredentialSidebarPreferences",
"CredentialSortKey",
"CredentialTrayTrigger",
"CustomKeybinding",
"DASHBOARD_CARDS",
"DEFAULT_FOLDER_COLOR",
"DEFAULT_FOLDER_ICON",
"DashboardCardId",
"DataColumn",
"DataView",
"DefaultOverrides",
"DefaultsOnly",
"DesktopUpdateMode",
"DesktopUpdateState",
"Dialog",
"DialogClose",
"DialogContent",
@@ -81,6 +118,7 @@ const ALLOWED = [
"DialogTrigger",
"DocsLink",
"DocsLinkProps",
"DocsPage",
"DropdownMenu",
"DropdownMenuCheckboxItem",
"DropdownMenuContent",
@@ -99,10 +137,14 @@ const ALLOWED = [
"EditingWidth",
"EmptyState",
"FOLDER_COLORS",
"FONT_SIZES",
"Facts",
"FakeSwitch",
"Field",
"FieldPair",
"FolderAccessRule",
"FolderIconEl",
"FontSizeId",
"FormFooter",
"FullScreenAppPhase",
"FullScreenAppWrapper",
@@ -112,11 +154,36 @@ const ALLOWED = [
"GridLayout",
"GridSlot",
"GroupHeading",
"HexColorPicker",
"Host",
"HostActionDef",
"HostClickBehavior",
"HostData",
"HostDefaultBadge",
"HostDefaultField",
"HostDefaultsChange",
"HostDefaultsContext",
"HostDefaultsContextValue",
"HostDefaultsLevel",
"HostDefaultsTarget",
"HostDensity",
"HostFeatureFields",
"HostLock",
"HostPolicy",
"HttpsSettings",
"HostFeatureFieldsProps",
"HostFolder",
"HostGroupKey",
"HostKeyVerificationDialog",
"HostOnly",
"HostProtocolAuthInput",
"HostProtocolDef",
"HostProtocols",
"HostRowFields",
"HostSidebarFilterState",
"HostSidebarPreferences",
"HostTrayTrigger",
"IconPicker",
"InlineView",
"Input",
"Kbd",
@@ -124,10 +191,13 @@ const ALLOWED = [
"KbdSeparator",
"KeyCombo",
"KeybindingAction",
"KeybindingActionDef",
"KeybindingActionType",
"LANGUAGES",
"Label",
"LineChart",
"LineChartSeries",
"LinkedAccountInfo",
"ListBadge",
"ListRow",
"ListRowAction",
@@ -135,19 +205,29 @@ const ALLOWED = [
"ListRowTone",
"LogContext",
"LogEntry",
"LookSettings",
"MANAGE_REQUEST_EVENT",
"MAX_PANES",
"MFAPromptMode",
"ManageMode",
"ManageRequest",
"ManagerCardError",
"ManagerCardShell",
"ManagerSearch",
"Meter",
"MetricCard",
"MiniStat",
"NativeView",
"NativeViewProps",
"NumberField",
"OPEN_PLUGINS_EVENT",
"OpenTabRecord",
"PANEL",
"PanePrompt",
"PROMPT_BUTTON",
"PROMPT_DESTRUCTIVE_BUTTON",
"PROMPT_PRIMARY_BUTTON",
"PaletteItemDef",
"PanePrompt",
"PanelDensity",
"PanelList",
"PanelSearch",
@@ -155,18 +235,35 @@ const ALLOWED = [
"PanelViewMode",
"PassphraseDialog",
"PasswordInput",
"PermissionCatalogEntry",
"PluginComponent",
"PRESETS",
"PluginIcon",
"PluginSettingsErrors",
"PluginSettingsField",
"PluginSettingsValidationError",
"PluginSummary",
"PluginViewPlaceholder",
"Popover",
"PopoverAnchor",
"PopoverContent",
"PopoverTrigger",
"ProxyNode",
"RadialGauge",
"RadialGaugeProps",
"RailGroup",
"RailItemDef",
"RecentActivityItem",
"ReleaseInfo",
"Repeater",
"ResolvedHostDefault",
"RobustClipboardProvider",
"Role",
"SSHAuthDialog",
"SSHHost",
"SSHHostData",
"SSHHostWithStatus",
"SSH_AUTH_PROTOCOL",
"ScrollArea",
"ScrollBar",
"SecretField",
@@ -186,6 +283,9 @@ const ALLOWED = [
"SelectValue",
"Separator",
"SettingRow",
"SettingsFieldRow",
"SharePermissionLevel",
"ShareTarget",
"Sheet",
"SheetClose",
"SheetContent",
@@ -194,74 +294,363 @@ const ALLOWED = [
"SheetHeader",
"SheetTitle",
"SheetTrigger",
"ShellConfirmCapture",
"Skeleton",
"Slider",
"SortKey",
"Sparkline",
"SparklineProps",
"SplitLayoutMenu",
"SplitOpenTarget",
"SplitSummary",
"StatRow",
"StatusValue",
"SurfaceKind",
"SurfaceScope",
"Switch",
"SwitchRow",
"TOTPDialog",
"Tab",
"TabShellCallbacks",
"TabSplitAction",
"TabStage",
"TabStrip",
"TabStripItem",
"TabType",
"TagInput",
"TextAreaField",
"TextField",
"Textarea",
"TextareaProps",
"ThemeId",
"TlsStatus",
"ToolsTab",
"Tooltip",
"TooltipContent",
"TooltipProvider",
"TooltipTrigger",
"UI_AREA_KEYS",
"UI_FONTS",
"UiAreaKey",
"UiFontId",
"UiPreset",
"UpdateChannelState",
"UptimeInfo",
"UsagePair",
"UserPreferences",
"UserRole",
"UserSecondFactorSummary",
"VersionBadge",
"VersionInfo",
"ViewToggle",
"WebSocketConnectionTarget",
"WidgetTitle",
"actionOrder",
"activityTarget",
"adminCreateUser",
"adminCreateUserCredential",
"adminCreateUserHost",
"adminDeleteUserCredential",
"adminDeleteUserHost",
"adminExportUserData",
"adminGetHostPassword",
"adminGetUserCredentials",
"adminGetUserHosts",
"adminResetUserPassword",
"adminUpdateUserCredential",
"adminUpdateUserHost",
"applyAccentColor",
"applyFontSize",
"applyPresetSideEffects",
"applyUiFont",
"assignRoleToUser",
"beginSplitDrag",
"buildOriginWsUrl",
"bulkUpdateSSHHosts",
"canConfigureUpdates",
"canJoinSplit",
"changeAppLanguage",
"changePassword",
"clearLocalAdaptivePreferences",
"cn",
"copyToClipboard",
"createApiKey",
"createCredential",
"createFrontendLogger",
"createQuickConnectHost",
"createRole",
"createSSHHost",
"defaultConnectAction",
"defaultCredentialRowFields",
"defaultCredentialSidebarPreferences",
"defaultHostRowFields",
"defaultHostSidebarPreferences",
"defaultValuesEqual",
"deleteAccount",
"deleteAllHostsInFolder",
"deleteApiKey",
"deleteCredential",
"deleteOpenTab",
"deleteRole",
"deleteSSHHost",
"deleteUser",
"desktopUpdates",
"docsUrl",
"duplicateCredential",
"enabledHostProtocols",
"endSplitDrag",
"filterPaletteItems",
"findCoreHostDefault",
"findMatchingKeybinding",
"getActiveSessions",
"getAdaptiveResourceBudget",
"getApiKeys",
"getAuditLogActions",
"getAuditLogs",
"getBasePath",
"getBranding",
"getChannelReleases",
"getConnectedRemoteApi",
"getCredentialAccess",
"getCredentialDetails",
"getCredentialSidebarPreferences",
"getCredentials",
"getDatabaseHealth",
"getDatabaseTransferUrl",
"getDefaultConnectionTab",
"getDeveloperMode",
"getDeviceId",
"getErrorMessage",
"getExternalAutoProvision",
"getFolderAccess",
"getFolderDefaultsId",
"getHostAccess",
"getHostAuthOverride",
"getHostDefaultsLevel",
"getHostEditorSection",
"getHostPassword",
"getHostPolicy",
"getHostSidebarPreferences",
"getHostTags",
"getLiveHostStatus",
"getHttpsSettings",
"getLogLevel",
"getNotificationPrivateEndpoints",
"getPanel",
"getPasswordLoginAllowed",
"getPasswordResetAllowed",
"getPermissionsCatalog",
"getPluginAdminSettings",
"getPluginHostSettings",
"getPluginUserSettings",
"getPlugins",
"getPollingEnvironmentMultiplier",
"getPreferredHostAction",
"getRecentActivity",
"getRegisteredDashboardCard",
"getRegistrationAllowed",
"getRoles",
"getSSHFolders",
"getSSHHosts",
"getSecondFactorAfterExternalLogin",
"getSessionTimeout",
"getSessionTimeoutMinutes",
"getSessions",
"getStatusCheckSettings",
"getStatusClasses",
"getTabType",
"getTlsStatus",
"getUpdateChannel",
"getUptime",
"getUserInfo",
"getUserList",
"getUserPreferences",
"getUserRoles",
"getUserSecondFactors",
"getVersionInfo",
"globalShortcutHandler",
"groupRailItems",
"hasVisibleFields",
"hostActionsFor",
"hostBadgesFor",
"hostEditorToolbarItemsFor",
"hostMenuItemsFor",
"hostProtocolFlags",
"hostStatusIndicatorsFor",
"hydrateLocalSharedHostAuth",
"invalidateHostsAndStatusCaches",
"invalidateServerStatusCache",
"isCoreRailView",
"isElectron",
"isFieldShown",
"isMacPlatform",
"isPersistentTabType",
"isPrerelease",
"isSessionTabType",
"isSplitDragging",
"isSplitTab",
"isTabJumpHotkey",
"linkExternalToPasswordAccount",
"linkedServerUrl",
"listHostActions",
"listHostProtocols",
"loadPaletteGroup",
"logoutUser",
"makeHostSshSubTabs",
"makeHostTabs",
"makeUserAdmin",
"mapCredentials",
"markAdaptiveResourceUsed",
"markTabSurfaceUsed",
"moveRailItem",
"moveSplitDrag",
"normalizeLanguageCode",
"notifySyncChanged",
"openInSplit",
"paletteEntriesFor",
"parseCustomKeybindings",
"parseServerTime",
"pluginKey",
"pluginName",
"pluginWsUrl",
"preloadTabSurface",
"previewHostDefaultsLevel",
"promotableIds",
"protocolEnabled",
"protocolPort",
"quickConnectTargets",
"railItemLabel",
"readFromClipboard",
"recordHostActionPreference",
"registerLayout",
"registerTabCloseGuard",
"releaseUrlFrom",
"rem",
"removeAdminStatus",
"removeRoleFromUser",
"renameCredentialFolder",
"renameFolder",
"reorderCredentials",
"reorderFolders",
"reorderSSHHosts",
"reportBetaFeedbackUrl",
"reportCoreIssueUrl",
"requestFromLegacyEvent",
"requestManage",
"resetHostToDefaults",
"resetRecentActivity",
"resetUserSecondFactors",
"resolveConnectionOrigin",
"resolveHostDefaults",
"resolveHostTabType",
"resolveRemoteHostId",
"revokeAllUserSessions",
"revokeCredentialAccess",
"revokeFolderAccess",
"revokeHostAccess",
"revokeSession",
"rightDockableIds",
"roleLabel",
"runAdaptiveBackgroundTask",
"runAdaptivePolling",
"runHostAction",
"runVisibleInterval",
"sanitizeCredentialSidebarPreferences",
"sanitizeHostSidebarPreferences",
"saveCredentialSidebarPreferences",
"saveHostDefaultsLevel",
"saveHostSidebarPreferences",
"saveHostTags",
"saveUserPreferences",
"setAllPluginChannels",
"setDeveloperMode",
"setHostAuthOverride",
"setNotificationPrivateEndpoints",
"setPluginOptions",
"setUpdateChannel",
"shareCredential",
"shareFolder",
"shareHost",
"shell",
"splitDefaultKey",
"sshHostToHost",
"tabIcon",
"tabJumpHotkeyKeys",
"takePendingManageRequest",
"unlinkExternalFromPasswordAccount",
"updateCredential",
"updateExternalAutoProvision",
"updateFolderMetadata",
"updateHostAccess",
"updateLogLevel",
"updatePasswordLoginAllowed",
"updatePasswordResetAllowed",
"updatePluginAdminSettings",
"updatePluginHostSettings",
"updatePluginUserSettings",
"updateRegistrationAllowed",
"updateRole",
"updateSSHHost",
"updateSecondFactorAfterExternalLogin",
"updateSessionTimeout",
"updateHttpsSettings",
"updateStatusCheckSettings",
"uploadTlsCertificate",
"usageBarColor",
"usageColor",
"useActionSlot",
"useAdaptivePolling",
"useAppTheme",
"useAreaPreferences",
"useBranding",
"useChooseTheme",
"useConfirm",
"useConnectionGate",
"useConnectionLog",
"useDesktopLinked",
"useDesktopUpdate",
"useEditorDefaultKeys",
"useHostActions",
"useHostBadges",
"useHostContextMenuItems",
"useHostEditorToolbarItems",
"useHostStatusIndicators",
"useHostEditorSections",
"useHostProtocols",
"useHostStatus",
"useInstalledPlugins",
"useIsDefaultsEditor",
"useIsMobile",
"useKeybindingActions",
"useKeybindingDefaults",
"useOptionalConnectionLog",
"usePageVisibleInterval",
"usePaletteEntries",
"usePaletteGroups",
"usePanels",
"usePermissions",
"usePluginDocsUrl",
"usePluginHostSections",
"usePluginStore",
"useRailItems",
"useRegisteredDashboardCards",
"useServerStatus",
"useServerStatusMeta",
"useShellController",
"useSplitTargets",
"useSshAuthEditors",
"useSshAuthProviders",
"useStatusColorScheme",
"useSurfaceKind",
"useTabHandlesVersion",
"useTabs",
"useTabsSafe",
"useTheme",
"useThemeOptions",
"useUiPreference",
"useUiPreferencesContext",
"waitForDefaultsJob",
"withProtocolSettings",
];
/** Every name a module exports, resolved the way the compiler sees it. */
+2
View File
@@ -42,6 +42,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "postgres://termix:termix@127.0.0.1:55432/termix",
GUACD_HOST: "127.0.0.1",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
@@ -53,6 +54,7 @@ describe("serviceEnv", () => {
DATABASE_URL: "mysql://root:termix@termix-dev-mysql:3306/termix",
GUACD_HOST: "termix-dev-guacd",
GUACD_PORT: "4822",
REMOTE_DESKTOP_ENGINE: "guacd",
});
});
});
+2 -1
View File
@@ -14,7 +14,8 @@
* mounted. A plugin change rebuilds it and restarts the container. Core
* changes wait for "r", since an image build is slow.
*
* --db postgres|mysql and --guacd run those in Docker next to it.
* --db postgres|mysql and --guacd run those in Docker next to it; --guacd also
* switches remote desktop from its built-in engine to that guacd.
*/
import fs from "node:fs";
+6 -1
View File
@@ -48,11 +48,16 @@ function start() {
writeFileSync: vi.fn(),
},
fork: () => child,
app: { isPackaged: true, getVersion: () => "2.9.0" },
app: {
isPackaged: true,
getVersion: () => "2.9.0",
getPath: () => "/user-data",
},
process: { env: { LOG_LEVEL: "error" } },
isDev: false,
appRoot: "/app",
BACKEND_STDERR_TAIL_LIMIT: 4096,
backendPort: 30001,
handleBackendRequest,
setTimeout,
clearTimeout,
+41
View File
@@ -0,0 +1,41 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
const require = createRequire(import.meta.url);
const { isExternalLoginUrl, isCallbackPort } =
require("../electron/external-login.cjs") as {
isExternalLoginUrl: (value: unknown) => boolean;
isCallbackPort: (value: unknown) => boolean;
};
describe("Electron external login", () => {
it.each([
"https://idp.example.com/authorize?client_id=termix",
"http://keycloak.lan:8080/realms/home/protocol/openid-connect/auth",
])("opens web login URLs: %s", (url) => {
expect(isExternalLoginUrl(url)).toBe(true);
});
it.each([
"file:///C:/Windows/System32/calc.exe",
"smb://attacker/share",
"ms-msdt:/id PCWDiagnostic",
"javascript:alert(1)",
"not a url",
undefined,
])("refuses other schemes: %s", (url) => {
expect(isExternalLoginUrl(url)).toBe(false);
});
it("accepts the ports the login screen picks", () => {
expect(isCallbackPort(17832)).toBe(true);
expect(isCallbackPort(17931)).toBe(true);
});
it.each([0, 80, 65536, 17832.5, "17832", NaN, undefined])(
"refuses callback port %s",
(port) => {
expect(isCallbackPort(port)).toBe(false);
},
);
});
@@ -32,9 +32,74 @@ describe("Electron security boundary", () => {
}
});
it("never hands the session cookie to the renderer over IPC", () => {
for (const channel of ["get-session-cookie", "wait-session-cookie"]) {
expect(main).not.toContain(`"${channel}"`);
expect(preload).not.toContain(`"${channel}"`);
}
});
it("saves an external editor only if it was chosen in a file dialog", () => {
expect(main).toContain("dialogChosenPaths.has(value)");
});
it("keeps local files, the local terminal and the external editor out of Electron", () => {
for (const channel of [
"open-external-editor",
"close-external-editor",
"local-terminal-start",
"local-fs:",
"local-transfer:",
"list-local-directory",
]) {
expect(main).not.toContain(channel);
expect(preload).not.toContain(channel);
}
});
it("scopes proxy headers and basic auth to the linked origin", () => {
expect(main).toContain('"sync-proxy-config"');
expect(main).toContain("linkedServer.applyLinkHeaders(");
expect(main).toContain("linkedServer.answerLogin(");
});
});
it("rejects setting keys that coerce to the protected editor key", async () => {
const { runInNewContext } = await import("node:vm");
const { vi } = await import("vitest");
const writeFileSync = vi.fn();
let handler: (event: unknown, key: unknown, value: unknown) => unknown;
runInNewContext(
main.slice(
main.indexOf('ipcMain.handle("set-setting"'),
main.indexOf('ipcMain.handle("clear-session-cookies"'),
),
{
ipcMain: {
handle: (_: string, callback: typeof handler) => {
handler = callback;
},
},
EXTERNAL_EDITOR_SETTING: "fileManager.externalEditorPath",
dialogChosenPaths: new Set(["/chosen/editor"]),
app: { getPath: () => "/settings" },
path: { join: (...parts: string[]) => parts.join("/") },
fs: { existsSync: () => false, mkdirSync: vi.fn(), writeFileSync },
console,
},
);
for (const key of [["fileManager.externalEditorPath"], null, {}, 42]) {
expect(handler!({}, key, "/unapproved/editor")).toEqual({
success: false,
error: "Invalid setting key",
});
}
expect(writeFileSync).not.toHaveBeenCalled();
expect(
handler!({}, "fileManager.externalEditorPath", "/unapproved/editor"),
).toEqual({ success: false, error: "Choose the editor in the file dialog" });
expect(
handler!({}, "fileManager.externalEditorPath", "/chosen/editor"),
).toEqual({ success: true });
expect(writeFileSync).toHaveBeenCalledOnce();
});
+1 -1
View File
@@ -38,7 +38,7 @@ function buildTable(version, mobileVersion, mobileTag) {
`| Architecture | Windows | Linux | Mac | Android | iOS |`,
`|------------------|------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------|---------------------------------------------|-----------------------------------|`,
`| **x86-64 (64-bit)** | [EXE](${win("termix_windows_x64_nsis.exe")}) · [MSI](${win("termix_windows_x64_msi.msi")}) · [Portable](${win("termix_windows_x64_portable.zip")}) | [AppImage](${linux("termix_linux_x64_appimage.AppImage")}) · [DEB](${linux("termix_linux_x64_deb.deb")}) · [Portable](${linux("termix_linux_x64_portable.tar.gz")}) | [DMG](${mac("termix_macos_x64_dmg.dmg")}) | - | - |`,
`| **AArch64 (ARM64)** | - | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **AArch64 (ARM64)** | [EXE](${win("termix_windows_arm64_nsis.exe")}) · [MSI](${win("termix_windows_arm64_msi.msi")}) · [Portable](${win("termix_windows_arm64_portable.zip")}) | [AppImage](${linux("termix_linux_arm64_appimage.AppImage")}) · [DEB](${linux("termix_linux_arm64_deb.deb")}) · [Portable](${linux("termix_linux_arm64_portable.tar.gz")}) | [DMG](${mac("termix_macos_arm64_dmg.dmg")}) | [APK (${mobileVersion})](${mobileBase}/termix_android.apk) | [IPA (${mobileVersion})](${mobileBase}/termix_ios.ipa) |`,
`| **ARMv7 (32-bit)** | - | [AppImage](${linux("termix_linux_armv7l_appimage.AppImage")}) · [DEB](${linux("termix_linux_armv7l_deb.deb")}) · [Portable](${linux("termix_linux_armv7l_portable.tar.gz")}) | - | - | - |`,
`| **x86-32 (32-bit)** | [EXE](${win("termix_windows_ia32_nsis.exe")}) · [MSI](${win("termix_windows_ia32_msi.msi")}) · [Portable](${win("termix_windows_ia32_portable.zip")}) | - | - | - | - |`,
`| **Universal** | [Chocolatey](https://docs.termix.site/install/apps/windows) | [Flatpak](https://docs.termix.site/install/apps/linux) | [DMG](${mac("termix_macos_universal_dmg.dmg")}) · [App Store](https://apps.apple.com/us/app/termix-ssh-companion/id6752672071) · [Homebrew](https://docs.termix.site/install/apps/macos) | - | - |`,
-176
View File
@@ -1,176 +0,0 @@
#!/usr/bin/env node
/**
* Builds src/ui/settings/settings-search-index.ts: the i18n keys each core
* settings section shows, so the Settings search can match on any label in
* a section without mounting it. Run it after changing a settings section.
*
* --check fails when the committed file is out of date.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SIDEBAR = path.join(ROOT, "src", "ui", "sidebar");
const OUT = path.join(
ROOT,
"src",
"ui",
"settings",
"settings-search-index.ts",
);
function flatKeys(obj, prefix = "") {
return Object.entries(obj).flatMap(([key, value]) =>
typeof value === "object"
? flatKeys(value, `${prefix}${key}.`)
: [`${prefix}${key}`],
);
}
const EN_KEYS = new Set(
flatKeys(require(path.join(ROOT, "src", "ui", "locales", "en.json"))),
);
// t("x", { count }) reads x_one or x_other, so index the plural key.
function realKey(key) {
return EN_KEYS.has(key) || !EN_KEYS.has(`${key}_other`)
? key
: `${key}_other`;
}
function keysIn(text) {
return [
...new Set(
[...text.matchAll(/\bt\(\s*"([\w.-]+)"/g)].map((m) => realKey(m[1])),
),
];
}
function read(file) {
return fs.readFileSync(path.join(SIDEBAR, file), "utf8");
}
/** The body of one exported or local function, by name. */
function functionBody(source, name) {
const start = source.search(new RegExp(`function ${name}\\b`));
if (start < 0) throw new Error(`No function ${name}`);
const next = source.slice(start + 1).search(/\n(export )?function \w+/);
return next < 0 ? source.slice(start) : source.slice(start, start + 1 + next);
}
/** Profile sections, split on their AccordionSection ids. */
function profileSections() {
const source = read("UserProfilePanel.tsx");
const lines = source.split("\n");
const starts = [];
lines.forEach((line, i) => {
if (/^\s{6}<AccordionSection\b/.test(line)) starts.push(i);
});
const out = {};
starts.forEach((start, k) => {
const end = k + 1 < starts.length ? starts[k + 1] : lines.length;
const chunk = lines.slice(start, end).join("\n");
const id = (chunk.match(/\bid="([^"]+)"/) || [])[1];
if (id) out[id] = keysIn(chunk);
});
out.interface = [
...new Set([
...(out.interface ?? []),
...keysIn(read("InterfacePresetSettings.tsx")),
]),
];
out.security = [
...new Set([
...(out.security ?? []),
...keysIn(read("AuthEnrollmentSections.tsx")),
...keysIn(functionBody(source, "PasswordChangeSection")),
]),
];
out["api-keys"] = [
...new Set([
...(out["api-keys"] ?? []),
...keysIn(functionBody(source, "NewApiKeyDialog")),
]),
];
out.keybindings = keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", "KeybindingsSettings.tsx"),
"utf8",
),
);
return out;
}
function adminSections() {
const settings = read("AdminSettingsSections.tsx");
const management = read("AdminManagementSections.tsx");
return {
"admin-general": [
...new Set([
...keysIn(functionBody(settings, "AdminGeneralSettingsSection")),
...keysIn(read("AdminPluginDeveloperMode.tsx")),
]),
],
"admin-users": [
...new Set([
...keysIn(functionBody(management, "AdminUsersSection")),
...keysIn(read("AdminUserDialogs.tsx")),
]),
],
"admin-sessions": keysIn(functionBody(management, "AdminSessionsSection")),
"admin-roles": keysIn(functionBody(management, "AdminRolesSection")),
"admin-host-defaults": [
...new Set([
...keysIn(functionBody(settings, "AdminHostDefaultsSection")),
...keysIn(read("AdminHostTags.tsx")),
]),
],
"admin-branding": keysIn(read("AdminBrandingSection.tsx")),
"admin-database": keysIn(functionBody(settings, "AdminDatabaseSection")),
"admin-ssl": keysIn(functionBody(settings, "AdminSSLSection")),
"admin-api-keys": keysIn(read("AdminApiKeysSection.tsx")),
"admin-audit-log": keysIn(read("AdminAuditLogSection.tsx")),
updates: [
...new Set(
["UpdatesSettings.tsx", "DesktopUpdatesCard.tsx"].flatMap((file) =>
keysIn(
fs.readFileSync(
path.join(ROOT, "src", "ui", "settings", file),
"utf8",
),
),
),
),
],
};
}
function render() {
const index = { ...profileSections(), ...adminSections() };
const body = Object.entries(index)
.sort(([a], [b]) => a.localeCompare(b))
.map(
([id, keys]) =>
` ${JSON.stringify(id)}: [\n${keys
.sort()
.map((key) => ` ${JSON.stringify(key)},`)
.join("\n")}\n ],`,
)
.join("\n");
return `// Generated by scripts/generate-settings-search-index.cjs. Do not edit.\n\n/** The i18n keys each core settings section shows, for search. */\nexport const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {\n${body}\n};\n`;
}
const next = render();
if (process.argv.includes("--check")) {
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, "utf8") : "";
if (current !== next) {
console.error(
"src/ui/settings/settings-search-index.ts is out of date. Run: node scripts/generate-settings-search-index.cjs",
);
process.exit(1);
}
} else {
fs.writeFileSync(OUT, next);
console.log(`Wrote ${path.relative(ROOT, OUT)}`);
}
+26
View File
@@ -0,0 +1,26 @@
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
function findPackage(start) {
let directory = path.dirname(start);
while (directory !== path.dirname(directory)) {
const manifest = path.join(directory, "package.json");
if (fs.existsSync(manifest)) return JSON.parse(fs.readFileSync(manifest));
directory = path.dirname(directory);
}
throw new Error("Could not locate the installed sharp package manifest");
}
// The build runs on x64 but ships arm64 and ia32 too, so npm ci alone
// only installs the x64 binary.
const sharpPackage = findPackage(require.resolve("sharp"));
const packages = [
"@img/sharp-win32-arm64",
"@img/sharp-win32-ia32",
"@img/sharp-win32-x64",
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
execFileSync("npm.cmd", ["install", "--force", "--no-save", ...packages], {
stdio: "inherit",
});
+45 -4
View File
@@ -15,11 +15,17 @@
* enabledByEnv counts the plugin as recommended when any of those
* environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked.
*
* "defaultLook": true marks the plugin whose layout (and that layout's
* theme) a fresh install shows. Core finds the default look this way, so it
* never names a plugin. Such a plugin always starts enabled and cannot be
* uninstalled, only swapped for another look.
*/
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const semver = require("semver");
const ID = /^[a-z0-9][a-z0-9-]*$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
@@ -110,6 +116,12 @@ function parseBundledPlugins(raw) {
}
const before = problems.length;
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
if (
entry.defaultLook !== undefined &&
typeof entry.defaultLook !== "boolean"
) {
problems.push(`${entry.id} defaultLook must be a boolean`);
}
if (problems.length > before) continue;
plugins.push({
id: entry.id,
@@ -118,6 +130,7 @@ function parseBundledPlugins(raw) {
path: hasPath ? entry.path : null,
sha256: entry.sha256,
onboarding,
defaultLook: entry.defaultLook === true,
});
} else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
@@ -218,8 +231,12 @@ function buildBundledIndex(plugins, stagedIds) {
const staged = new Set(stagedIds);
const out = {};
for (const plugin of plugins) {
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
out[plugin.id] = { onboarding: plugin.onboarding };
if (!staged.has(plugin.id)) continue;
if (!plugin.onboarding && !plugin.defaultLook) continue;
out[plugin.id] = {
...(plugin.onboarding ? { onboarding: plugin.onboarding } : {}),
...(plugin.defaultLook ? { defaultLook: true } : {}),
};
}
return { version: 1, plugins: out };
}
@@ -235,15 +252,22 @@ function pinsFromIndex(index, ids, current = []) {
const currentById = new Map(current.map((entry) => [entry.id, entry]));
const wanted = ids ?? plugins.map((plugin) => plugin.id);
return [...wanted].sort().map((id) => {
const latest = byId.get(id)?.versions?.[0];
const listed = byId.get(id);
// gated holds releases that need a newer Termix than old builds check for.
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
.filter((entry) => semver.valid(entry.version))
.filter((entry) => !semver.prerelease(entry.version))
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
if (!latest) throw new Error(`${id} is not in the registry index`);
const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true;
return {
id,
source: "tmxplug",
url: latest.url,
sha256: latest.sha256,
...(onboarding ? { onboarding } : {}),
...(defaultLook ? { defaultLook } : {}),
};
});
}
@@ -357,9 +381,26 @@ function rebuildLocalPlugins(dir, ids, run) {
}
/** Copies a local plugin build into the staging folder. */
/** Theme CSS files the repo's manifest declares, which ship with it too. */
function localThemeFiles(repo) {
try {
const manifest = JSON.parse(
fs.readFileSync(path.join(repo, "manifest.json"), "utf8"),
);
const themes = manifest?.contributes?.themes;
return Array.isArray(themes)
? themes
.map((theme) => theme?.css)
.filter((css) => typeof css === "string")
: [];
} catch {
return [];
}
}
function copyLocalBuild(repo, destination) {
fs.mkdirSync(destination, { recursive: true });
for (const entry of LOCAL_FILES) {
for (const entry of new Set([...LOCAL_FILES, ...localThemeFiles(repo)])) {
const from = path.join(repo, entry);
if (fs.existsSync(from)) {
fs.cpSync(from, path.join(destination, entry), { recursive: true });
+2
View File
@@ -85,6 +85,8 @@ export function serviceEnv(options, inDocker) {
const guacd = SERVICES.guacd;
env.GUACD_HOST = inDocker ? guacd.container : "127.0.0.1";
env.GUACD_PORT = String(inDocker ? guacd.port : guacd.hostPort);
// Remote desktop runs built in unless told to use guacd.
env.REMOTE_DESKTOP_ENGINE = "guacd";
}
return env;
}
+1 -1
View File
@@ -125,7 +125,7 @@ Options
--plugins <dir> Plugin repos to build and watch (default ../Plugins)
--no-local Use only the plugins pinned in docker/bundled-plugins.json
--db <engine> sqlite (default), postgres or mysql. Postgres and MySQL run in Docker
--guacd Run guacd in Docker for remote desktop
--guacd Run guacd in Docker and use it instead of the built-in remote desktop
--fresh Start from empty data (first run, onboarding)
--data <dir> Data folder for the backend (default db/data, db/data-desktop for
electron, plus -<engine> for postgres or mysql)
+48 -34
View File
@@ -3,43 +3,45 @@ const path = require("node:path");
const nodePtyDir = path.join(__dirname, "..", "node_modules", "node-pty");
if (!fs.existsSync(nodePtyDir)) {
console.log("[patch-node-pty] node-pty not found, skipping");
process.exit(0);
}
const unixTerminalPath = path.join(nodePtyDir, "lib", "unixTerminal.js");
const originalHelperRewrite = [
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
].join("\n");
// Older installs may still carry the first version of this patch.
const knownHelperRewrites = [
[
"helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
].join("\n"),
[
"if (!helperPath.includes('app.asar.unpacked')) {",
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"}",
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
"}",
].join("\n"),
];
const patchedHelperRewrite = [
"if (!helperPath.includes('app.asar.unpacked')) {",
" helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');",
"}",
"if (!helperPath.includes('node_modules.asar.unpacked')) {",
" helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');",
"}",
].join("\n");
// A universal macOS build ships app-arm64.asar and app-x64.asar, not app.asar.
const patchedHelperRewrite =
"helperPath = helperPath.replace(/\\b(app(?:-[a-z0-9]+)?|node_modules)\\.asar(?!\\.unpacked)/g, '$1.asar.unpacked');";
function patchUnixTerminalHelperRewrite() {
if (!fs.existsSync(unixTerminalPath)) {
function patchUnixTerminalHelperRewrite(file = unixTerminalPath) {
if (!fs.existsSync(file)) {
return false;
}
const source = fs.readFileSync(unixTerminalPath, "utf8");
const source = fs.readFileSync(file, "utf8");
if (source.includes(patchedHelperRewrite)) {
return false;
}
if (!source.includes(originalHelperRewrite)) {
const known = knownHelperRewrites.find((rewrite) => source.includes(rewrite));
if (!known) {
return false;
}
fs.writeFileSync(
unixTerminalPath,
source.replace(originalHelperRewrite, patchedHelperRewrite),
file,
source.replace(known, () => patchedHelperRewrite),
);
return true;
}
@@ -67,19 +69,31 @@ function chmodSpawnHelpers(rootDir) {
return fixed;
}
const helperRewritePatched = patchUnixTerminalHelperRewrite();
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
// Tests and afterPack require this file; only a direct run patches node_modules.
function main() {
if (!fs.existsSync(nodePtyDir)) {
console.log("[patch-node-pty] node-pty not found, skipping");
return;
}
if (helperRewritePatched) {
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
const helperRewritePatched = patchUnixTerminalHelperRewrite();
const helpersChmodded = chmodSpawnHelpers(nodePtyDir);
if (helperRewritePatched) {
console.log("[patch-node-pty] Patched unixTerminal helper path rewrite");
}
if (helpersChmodded > 0) {
console.log(
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
);
}
if (!helperRewritePatched && helpersChmodded === 0) {
console.log("[patch-node-pty] Already patched or target files not found");
}
}
if (helpersChmodded > 0) {
console.log(
`[patch-node-pty] Restored execute bit on ${helpersChmodded} spawn-helper binary(ies)`,
);
}
if (!helperRewritePatched && helpersChmodded === 0) {
console.log("[patch-node-pty] Already patched or target files not found");
if (require.main === module) {
main();
}
module.exports = { chmodSpawnHelpers, patchUnixTerminalHelperRewrite };
+149
View File
@@ -0,0 +1,149 @@
const fs = require("node:fs");
const SK_ED = "sk-ssh-ed25519@openssh.com";
const SK_EC = "sk-ecdsa-sha2-nistp256@openssh.com";
function replaceOnce(source, from, to, label) {
const first = source.indexOf(from);
if (first === -1 || source.indexOf(from, first + 1) !== -1) {
throw new Error(`ssh2 changed (${label}); review the security key patch`);
}
return source.replace(from, () => to);
}
function patchKeyParserSource(source) {
if (source.includes("// termix-sk")) return source;
let out = replaceOnce(
source,
` case 'ecdsa-sha2-nistp521':
return true;
case 'ssh-ed25519':
if (eddsaSupported)
return true;`,
` case 'ecdsa-sha2-nistp521':
return true;
case '${SK_EC}': // termix-sk
return true;
case '${SK_ED}':
case 'ssh-ed25519':
if (eddsaSupported)
return true;`,
"key types",
);
out = replaceOnce(
out,
` default:
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
}
return new OpenSSH_Public(`,
` case '${SK_ED}':
case '${SK_EC}': {
// Public blob only; the security key signs, so there is no private half.
const rest = data.subarray(data._pos || 0);
const typeLen = Buffer.byteLength(baseType);
pubSSH = Buffer.allocUnsafe(4 + typeLen + rest.length);
writeUInt32BE(pubSSH, typeLen, 0);
pubSSH.utf8Write(baseType, 4, typeLen);
pubSSH.set(rest, 4 + typeLen);
algo = baseType === '${SK_ED}' ? null : 'sha256';
break;
}
default:
return new Error(\`Unsupported OpenSSH public key type: \${baseType}\`);
}
return new OpenSSH_Public(`,
"parseDER",
);
return out;
}
function patchAgentSource(source) {
if (source.includes("skTrailer")) return source;
return replaceOnce(
source,
` binaryParser.init(signature, 0);
binaryParser.readString(true);
signature = binaryParser.readString();
binaryParser.clear();`,
` binaryParser.init(signature, 0);
binaryParser.readString(true);
signature = binaryParser.readString();
// Security key signatures end with flags and a counter.
const skTrailer = signature === undefined
? undefined
: binaryParser.readRaw();
binaryParser.clear();
if (skTrailer && skTrailer.length)
signature.skTrailer = Buffer.from(skTrailer);`,
"agent sign response",
);
}
function patchProtocolSource(source) {
if (source.includes("skTrailer")) return source;
const start = source.indexOf(" authPK(username, pubKey, keyAlgo, cbSign) {");
const end = source.indexOf(" authHostbased(", start);
if (start === -1 || end === -1) {
throw new Error("ssh2 changed (authPK); review the security key patch");
}
let region = source.slice(start, end);
region = replaceOnce(
region,
` signature = convertSignature(signature, keyType);`,
` const skTrailer = signature && signature.skTrailer;
signature = convertSignature(signature, keyType);`,
"authPK sign",
);
region = replaceOnce(
region,
` + 4 + algoLen + 4 + sigLen
);`,
` + 4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0)
);`,
"authPK alloc",
);
region = replaceOnce(
region,
` writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);`,
` writeUInt32BE(
packet,
4 + algoLen + 4 + sigLen + (skTrailer ? skTrailer.length : 0),
p += pubKeyLen
);`,
"authPK length",
);
region = replaceOnce(
region,
` packet.set(signature, p += 4);`,
` packet.set(signature, p += 4);
if (skTrailer)
packet.set(skTrailer, p + sigLen);`,
"authPK copy",
);
return source.slice(0, start) + region + source.slice(end);
}
if (require.main === module) {
const targets = [
["ssh2/lib/protocol/keyParser.js", patchKeyParserSource],
["ssh2/lib/agent.js", patchAgentSource],
["ssh2/lib/protocol/Protocol.js", patchProtocolSource],
];
for (const [id, patch] of targets) {
const target = require.resolve(id);
const source = fs.readFileSync(target, "utf8");
const result = patch(source);
if (result !== source) fs.writeFileSync(target, result);
}
console.log(
"[patch-ssh2-sk] Security key public keys and signatures enabled",
);
}
module.exports = {
patchKeyParserSource,
patchAgentSource,
patchProtocolSource,
};
+104
View File
@@ -35,6 +35,42 @@ function fixturePlugin(): string {
}
describe("termix-plugin build", () => {
it("builds a theme only plugin with an empty backend and no source", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
fs.writeFileSync(
path.join(dir, "manifest.json"),
JSON.stringify({
id: "theme-fixture",
capabilities: ["ui:theme"],
contributes: { themes: [{ id: "paper" }] },
}),
);
await build({ cwd: dir });
expect(
fs.readFileSync(path.join(dir, "dist", "backend.js"), "utf8"),
).toContain("export function activate()");
expect(fs.existsSync(path.join(dir, "dist", "frontend.js"))).toBe(false);
});
it("still needs a backend entry once a plugin holds more than themes", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-build-"));
cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true }));
fs.writeFileSync(
path.join(dir, "manifest.json"),
JSON.stringify({
id: "mixed-fixture",
capabilities: ["ui:theme", "ui:layout"],
contributes: { themes: [{ id: "paper" }] },
}),
);
await expect(build({ cwd: dir })).rejects.toThrow("no backend entry");
});
it("marks dist as ESM so Node loads backend.js without reparsing", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
@@ -92,6 +128,74 @@ describe("termix-plugin build", () => {
});
});
describe("termix-plugin build workers and wasm", () => {
// The smallest valid module: magic and version, no sections.
const EMPTY_WASM = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0, 0, 0]);
it("inlines a .wasm import as its bytes", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
fs.writeFileSync(path.join(dir, "src", "backend", "mod.wasm"), EMPTY_WASM);
fs.writeFileSync(
path.join(dir, "src", "backend", "index.ts"),
'import bytes from "./mod.wasm";\nexport const valid = WebAssembly.validate(bytes);\nexport function activate() {}\n',
);
await build({ cwd: dir });
const mod = await import(
pathToFileURL(path.join(dir, "dist", "backend.js")).href
);
expect(mod.valid).toBe(true);
});
it("bundles each worker entry on its own into dist/workers", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ termix: { workers: { engine: "src/worker.ts" } } }),
);
const dep = path.join(dir, "node_modules", "worker-dep");
fs.mkdirSync(dep, { recursive: true });
fs.writeFileSync(
path.join(dep, "package.json"),
JSON.stringify({ name: "worker-dep", main: "index.js" }),
);
fs.writeFileSync(path.join(dep, "index.js"), "exports.answer = 42;\n");
fs.writeFileSync(
path.join(dir, "src", "worker.ts"),
'import { parentPort } from "node:worker_threads";\nimport { answer } from "worker-dep";\nparentPort?.postMessage(answer);\n',
);
await build({ cwd: dir });
const file = path.join(dir, "dist", "workers", "engine.js");
expect(fs.readFileSync(file, "utf8")).not.toContain('from "worker-dep"');
const { Worker } = await import("node:worker_threads");
const answer = await new Promise((resolve, reject) => {
const worker = new Worker(file);
worker.once("message", (value) => {
void worker.terminate();
resolve(value);
});
worker.once("error", reject);
});
expect(answer).toBe(42);
});
it("refuses a worker whose entry is missing", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const dir = fixturePlugin();
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ termix: { workers: { engine: "src/nope.ts" } } }),
);
await expect(build({ cwd: dir })).rejects.toThrow(/entry src\/nope.ts/);
});
});
describe("plugin CSS layers", () => {
it("renames the Tailwind layers so core's utilities always win", () => {
expect(
+30
View File
@@ -46,6 +46,8 @@ function fixturePlugin(manifest: Record<string, unknown> = {}): string {
writeFile(dir, "migrations/mysql/0001_init.sql", "SELECT 1;\n");
writeFile(dir, "README.md", "# Fixture\n");
writeFile(dir, "icon.svg", "<svg/>\n");
writeFile(dir, "LICENSE", "Apache-2.0\n");
writeFile(dir, "NOTICE", "Third-party notices\n");
writeFile(dir, "src/backend/index.ts", "left out of the archive\n");
return dir;
}
@@ -91,6 +93,8 @@ describe("termix-plugin pack", () => {
const names = await entries(file);
expect(names).toEqual([
"LICENSE",
"NOTICE",
"README.md",
"dist/backend.js",
"dist/frontend.js",
@@ -138,3 +142,29 @@ describe("termix-plugin pack", () => {
expect(fs.readdirSync(out)).toEqual([]);
});
});
describe("termix-plugin pack themes", () => {
it("ships the CSS files the manifest's themes declare", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
const dir = fixturePlugin({
capabilities: ["ui:theme"],
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "styles/paper.css",
},
],
},
});
writeFile(dir, "styles/paper.css", ".paper { --background: #fff; }\n");
writeFile(dir, "styles/draft.css", ".draft {}\n");
const file = await pack({ cwd: dir, args: ["--out", tempDir("t-")] });
const names = await entries(file);
expect(names).toContain("styles/paper.css");
expect(names).not.toContain("styles/draft.css");
});
});
+95
View File
@@ -49,3 +49,98 @@ describe("termix-plugin validate migrations", () => {
expect(output).not.toMatch(/migrations\/\w+ is missing/);
});
});
describe("termix-plugin validate themes", () => {
function themed(css: string | null) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
id: "fixture",
version: "1.0.0",
capabilities: ["ui:theme"],
contributes: {
themes: [
{
id: "paper",
label: "Paper",
mode: "light",
css: "themes/paper.css",
},
],
},
}),
);
if (css !== null) {
fs.mkdirSync(path.join(root, "themes"));
fs.writeFileSync(path.join(root, "themes", "paper.css"), css);
}
return root;
}
it("reports a missing theme file", async () => {
expect(await problems(themed(null))).toContain(
"theme paper: themes/paper.css does not exist",
);
});
it("reports theme CSS that reaches outside or leaves its class", async () => {
const output = await problems(
themed('@import "x.css"; body { color: red }'),
);
expect(output).toContain("theme paper: @import is not allowed in a theme");
expect(output).toContain(
'theme paper: Selector "body" must start with .paper',
);
});
it("says nothing about CSS that is fine", async () => {
const output = await problems(themed(".paper { --background: #fff; }"));
expect(output).not.toContain("theme paper");
});
});
describe("termix-plugin validate layouts and capabilities", () => {
function withManifest(extra: Record<string, unknown>) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({ id: "fixture", version: "1.0.0", ...extra }),
);
return root;
}
it("flags ui:theme with no themes and ui:layout with no layouts", async () => {
const output = await problems(
withManifest({ capabilities: ["ui:theme", "ui:layout"] }),
);
expect(output).toContain(
'"ui:theme" is declared but contributes.themes is empty',
);
expect(output).toContain(
'"ui:layout" is declared but contributes.layouts is empty',
);
});
it("flags a layout plugin with no frontend source", async () => {
const output = await problems(
withManifest({
capabilities: ["ui:layout"],
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
}),
);
expect(output).toContain("contributes.layouts needs a frontend");
});
it("accepts a layout plugin that has a frontend entry", async () => {
const root = withManifest({
capabilities: ["ui:layout"],
contributes: { layouts: [{ id: "wide", label: "Wide" }] },
});
fs.mkdirSync(path.join(root, "src", "frontend"), { recursive: true });
fs.writeFileSync(path.join(root, "src", "frontend", "index.tsx"), "");
const output = await problems(root);
expect(output).not.toContain("contributes.layouts needs a frontend");
expect(output).not.toContain("is declared but");
});
});
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/** Stamp the cache version and static-build base path.
* Docker keeps the base-path placeholder for entrypoint.sh to fill at startup.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const SW_PATH = path.join(ROOT, "dist", "sw.js");
const VERSION_PLACEHOLDER = "__TERMIX_CACHE_VERSION__";
const BASE_PATH_PLACEHOLDER = "__TERMIX_SW_BASE_PATH__";
const pkg = JSON.parse(
fs.readFileSync(path.join(ROOT, "package.json"), "utf8"),
);
const cacheVersion = String(pkg.version || "0.0.0").replace(/\./g, "-");
const rawBasePath = process.env.VITE_BASE_PATH || "./";
// "./" is what vite emits for a root deployment; cache URLs stay root-relative.
const basePath =
rawBasePath === "./"
? ""
: `/${rawBasePath.replace(/^\/+/, "").replace(/\/+$/, "")}`;
if (!fs.existsSync(SW_PATH)) {
console.error(`[stamp-sw] dist/sw.js not found - run vite build first`);
process.exit(1);
}
let sw = fs.readFileSync(SW_PATH, "utf8");
const rel = path.relative(ROOT, SW_PATH);
const replacements = [
[VERSION_PLACEHOLDER, cacheVersion],
...(process.env.TERMIX_RUNTIME_BASE_PATH === "true"
? []
: [[BASE_PATH_PLACEHOLDER, basePath]]),
];
let changed = false;
for (const [placeholder, value] of replacements) {
if (sw.includes(placeholder)) {
sw = sw.split(placeholder).join(value);
changed = true;
console.log(
`[stamp-sw] ${rel}: ${placeholder} -> ${JSON.stringify(value)}`,
);
}
}
if (changed) {
fs.writeFileSync(SW_PATH, sw);
}
+59
View File
@@ -0,0 +1,59 @@
import { execFileSync } from "node:child_process";
import {
mkdtempSync,
mkdirSync,
readFileSync,
writeFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, it } from "vitest";
it.each([
[false, "", ""],
[false, "/termix/", "/termix"],
[true, "", "__TERMIX_SW_BASE_PATH__"],
])(
"stamps service worker paths (runtime=%s, base=%s)",
(runtime, base, expected) => {
const dir = mkdtempSync(path.join(tmpdir(), "termix-sw-"));
try {
mkdirSync(path.join(dir, "scripts"));
mkdirSync(path.join(dir, "dist"));
writeFileSync(
path.join(dir, "scripts/stamp-sw-build-info.cjs"),
readFileSync("scripts/stamp-sw-build-info.cjs"),
);
writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ version: "26.10.2" }),
);
writeFileSync(path.join(dir, "dist/sw.js"), readFileSync("public/sw.js"));
execFileSync(
process.execPath,
[path.join(dir, "scripts/stamp-sw-build-info.cjs")],
{
env: {
...process.env,
VITE_BASE_PATH: base,
TERMIX_RUNTIME_BASE_PATH: String(runtime),
},
},
);
const sw = readFileSync(path.join(dir, "dist/sw.js"), "utf8");
expect(sw).toContain('"26-10-2"');
expect(sw).toContain(`const BASE_PATH = "${expected}";`);
if (runtime) {
expect(sw.replaceAll("__TERMIX_SW_BASE_PATH__", "/termix")).toContain(
'const BASE_PATH = "/termix";',
);
expect(readFileSync("docker/Dockerfile", "utf8")).toContain(
"TERMIX_RUNTIME_BASE_PATH=true",
);
}
} finally {
rmSync(dir, { recursive: true, force: true });
}
},
);
+74 -17
View File
@@ -26,6 +26,12 @@ import {
isSecondFactorAfterExternalLoginEnabled,
} from "./core-auth.js";
import { findOrProvisionExternalUser } from "./provisioning.js";
import {
SECOND_FACTOR_SETUP,
evaluateAuthPolicy,
hasAuthPolicies,
rememberDeviceAllowed,
} from "./policy.js";
import {
getLoginMethod,
getSecondFactor,
@@ -122,12 +128,15 @@ export async function evaluateSecondFactors(
* governs external methods (SSO, LDAP) alone.
*/
function shouldRunSecondFactors(methodId: string): boolean {
ensureCoreLoginProviders();
const method = getLoginMethod(methodId);
if (!method?.external) return true;
if (!isExternalMethod(methodId)) return true;
return isSecondFactorAfterExternalLoginEnabled();
}
function isExternalMethod(methodId: string): boolean {
ensureCoreLoginProviders();
return !!getLoginMethod(methodId)?.external;
}
export const TRUST_DEVICE_COOKIE = "termix_trust_device";
const TRUST_TOKEN_PATTERN = /^[a-f0-9]{64}$/;
const TRUST_TOKEN_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -294,9 +303,40 @@ export async function runLogin(
`${context.methodId}_role_shared_credentials`,
);
const factors = shouldRunSecondFactors(context.methodId)
? await evaluateSecondFactors(user.id)
: { required: [], blocked: [] };
// A login policy may want a factor where core would skip it, so it always
// sees the full picture.
const runFactors = shouldRunSecondFactors(context.methodId);
const factors =
runFactors || hasAuthPolicies()
? await evaluateSecondFactors(user.id)
: { required: [], blocked: [] };
const trusted =
factors.required.length > 0 || hasAuthPolicies()
? await isTrustedDevice(req, user.id)
: false;
const policy = await evaluateAuthPolicy({
userId: user.id,
methodId: context.methodId,
external: isExternalMethod(context.methodId),
deviceType,
trustedDevice: trusted,
mfaSatisfied: !!identity.mfaSatisfied,
secondFactors: listSecondFactors().map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
enrolled: factors.required.some(
(required) =>
required.id === factor.id && required.pluginId === factor.pluginId,
),
})),
});
if (!runFactors && !policy.secondFactor) {
factors.required = [];
factors.blocked = [];
}
const rememberMe = context.rememberMe && policy.allowRememberDevice;
const skipByDevice =
trusted && policy.allowRememberDevice && policy.secondFactor !== "always";
if (factors.blocked.length > 0) {
authLogger.warn("Login refused: an enrolled second factor is unavailable", {
operation: "second_factor_unavailable",
@@ -324,11 +364,7 @@ export async function runLogin(
const ssoClaims = { externalSession: identity.externalSession ?? null };
if (
factors.required.length > 0 &&
!identity.mfaSatisfied &&
!(await isTrustedDevice(req, user.id))
) {
if (factors.required.length > 0 && !identity.mfaSatisfied && !skipByDevice) {
const tempToken = await AuthManager.getInstance().generateJWTToken(
user.id,
{ pendingTOTP: true, expiresIn: "10m" },
@@ -337,14 +373,17 @@ export async function runLogin(
pendingLogins.set(pendingKey(tempToken), {
userId: user.id,
methodId: context.methodId,
rememberMe: context.rememberMe,
rememberMe,
allowRememberDevice: policy.allowRememberDevice,
maxSessionSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
...ssoClaims,
createdAt: Date.now(),
});
return {
kind: "second-factor",
tempToken,
rememberMe: context.rememberMe,
rememberMe,
factors: factors.required.map((factor) => ({
id: factor.id,
pluginId: factor.pluginId,
@@ -353,11 +392,22 @@ export async function runLogin(
};
}
// Required but nothing set up yet: a session that can only set one up.
const needsSetup =
!!policy.secondFactor &&
factors.required.length === 0 &&
!identity.mfaSatisfied &&
!skipByDevice;
const session = await issueSession(req, user, {
methodId: context.methodId,
rememberMe: context.rememberMe,
rememberMe,
rateLimitUsername: context.rateLimitUsername ?? identity.rateLimitUsername,
longLivedForApps: identity.kind === "external",
longLivedForApps:
identity.kind === "external" && policy.allowRememberDevice,
maxAgeSeconds: policy.maxSessionSeconds,
idleTimeoutSeconds: policy.idleTimeoutSeconds,
restriction: needsSetup ? SECOND_FACTOR_SETUP : null,
...ssoClaims,
});
return { kind: "session", ...session };
@@ -510,10 +560,15 @@ export async function verifySecondFactorAndRespond(
}
loginRateLimiter.resetTOTPAttempts(user.id);
// A pending entry lost to a restart falls back to the current policy.
const allowRemember = lookup.pending
? lookup.pending.allowRememberDevice !== false
: rememberDeviceAllowed();
const rememberMe =
typeof req.body?.rememberMe === "boolean"
allowRemember &&
(typeof req.body?.rememberMe === "boolean"
? req.body.rememberMe
: (lookup.pending?.rememberMe ?? false);
: (lookup.pending?.rememberMe ?? false));
let trustToken: string | null = null;
if (rememberMe) {
@@ -545,6 +600,8 @@ export async function verifySecondFactorAndRespond(
methodId: pending?.methodId ?? "password",
rememberMe,
externalSession: pending?.externalSession ?? null,
maxAgeSeconds: pending?.maxSessionSeconds ?? null,
idleTimeoutSeconds: pending?.idleTimeoutSeconds ?? null,
});
if (trustToken && isNativeAppRequest(req)) {
Loaded 100 of 664 files, more files were not shown because too many files have changed in this diff. Show more