Compare commits

..
Author SHA1 Message Date
LukeGus af87d09273 fix: ship sharp binaries for Windows arm64 builds 2026-10-19 22:03:56 -05:00
LukeGus da3f0c7836 fix: keep text primary keys in the plugin data export 2026-10-19 22:03:25 -05:00
LukeGus 737e7cd7f7 fix: changelog for desktop link fix 2026-10-19 22:02:54 -05:00
SirHephaistos a699feb4e2 fix: detect the desktop link window by Electron's default user agent
Fixes #1675
2026-10-19 22:02:46 -05:00
LukeGus b22b3679e4 feat: native web view for plugins in the desktop app 2026-10-19 21:57:48 -05:00
LukeGus 895488d400 feat: mTLS client certificates for desktop server links 2026-10-19 21:54:19 -05:00
LukeGus 802b3003d5 feat: add companion panels beside host tabs 2026-10-19 21:46:02 -05:00
LukeGus 3b3fd7f135 feat: support FIDO2 security keys through ssh agent 2026-10-19 19:55:35 -05:00
LukeGus 901a235de4 feat: ssh transport providers, core ssh relay and plugin folders api 2026-10-19 19:20:32 -05:00
LukeGus 3caf6cca62 fix: show the linked account's name and admin flag in the app (#1580) 2026-10-19 17:20:32 -05:00
LukeGus e284c95891 fix: add a neutral linked desktop hook for plugins (#1580) 2026-10-19 17:07:07 -05:00
LukeGus b0f2213b15 feat: move the sync screens and wizard out of core into the plugin (#1580) 2026-10-19 16:42:29 -05:00
LukeGus ee0d1a5d8e chore: add sync to the plugin table 2026-10-19 16:07:43 -05:00
LukeGus 25816084aa feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) 2026-10-19 16:06:08 -05:00
LukeGus 87cc80b291 feat: api key socket tickets so a master can open remote sessions (#1654) 2026-10-19 16:06:04 -05:00
LukeGus 89222bed85 chore: add login security to the plugin table 2026-10-19 12:48:54 -05:00
LukeGus ad46f42ab1 feat: login policy hook for plugins and optional https redirect (#1584) 2026-10-19 12:47:47 -05:00
LukeGus 34a9c77114 chore: add hosts config to the plugin table 2026-10-19 12:07:55 -05:00
LukeGus 2c785fe35b feat: host lock api so a plugin can load hosts from a config file (#1581) 2026-10-19 12:02:05 -05:00
LukeGus 1cdbfc674c feat: move desktop, status, import, audit and branding features into plugins 2026-10-18 21:17:54 -05:00
LukeGus 9d93fb0893 feat: plugin filters by type, paged browse, hide plugins needing newer termix 2026-10-18 20:00:47 -05:00
LukeGus 556f0406aa chore: drop example plugin links from the plugin guide 2026-10-18 19:35:35 -05:00
LukeGus 0589465e4e chore: add classic layout and themes to the readme 2026-10-18 19:22:30 -05:00
LukeGus ec3cc9cf18 fix: home tab, layout switch and missing layout fallbacks for looks 2026-10-18 19:08:50 -05:00
LukeGus 4c235bb23f fix: plugin tests and type checks work with a linked sdk 2026-10-18 19:08:36 -05:00
LukeGus b414b85746 feat: move color themes into a bundled themes plugin 2026-10-18 17:56:34 -05:00
LukeGus d049588f7f feat: move the classic layout into its own bundled plugin 2026-10-18 17:25:59 -05:00
LukeGus 86161d66e2 feat: shell controller, TabStage, layout and theme plugins with repair view 2026-10-18 16:37:36 -05:00
168 changed files with 6894 additions and 26892 deletions

No files matched your search

+3 -15
View File
@@ -41,15 +41,9 @@ on:
default: ""
jobs:
# One image per plugin profile (docker/bundled-plugins.json). standard keeps
# the plain tags; minimal and full add a suffix.
build:
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
profile: [standard, minimal, full]
steps:
- name: Checkout repository
uses: actions/checkout@v7
@@ -86,17 +80,12 @@ jobs:
TAGS=()
ALL_TAGS=()
SUFFIX=""
if [ "${{ matrix.profile }}" != "standard" ]; then
SUFFIX="-${{ matrix.profile }}"
fi
if [ "$BUILD_TYPE" = "Production" ]; then
TAGS+=("$VERSION$SUFFIX" "latest$SUFFIX")
TAGS+=("$VERSION" "latest")
elif [ "$BUILD_TYPE" = "Beta" ]; then
TAGS+=("beta$SUFFIX" "$VERSION$SUFFIX")
TAGS+=("beta" "$VERSION")
else
TAGS+=("dev-$VERSION$SUFFIX")
TAGS+=("dev-$VERSION")
fi
for tag in "${TAGS[@]}"; do
@@ -135,7 +124,6 @@ jobs:
tags: ${{ env.ALL_TAGS }}
build-args: |
BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
PLUGIN_PROFILE=${{ matrix.profile }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ env.SOURCE_SHA }}
+7 -15
View File
@@ -161,10 +161,9 @@ jobs:
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
echo "prev_ref=$PREV" >> "$GITHUB_OUTPUT"
# Translates new or changed strings with the Termix LLM server and pins the
# bundled plugins to their newest registry releases, then commits both to the
# dev branch so the beta or stable build includes them. If the server is down
# it warns and the release goes on with what is there.
# Translates new or changed strings with the Termix LLM server and commits
# them to the dev branch, so the beta or stable build includes them. If the
# server is down it warns and the release goes on with what is there.
translate:
needs: [prep]
if: ${{ needs.prep.outputs.skip != 'true' && needs.prep.outputs.mode != 'Overwrite release' }}
@@ -192,26 +191,19 @@ jobs:
languages: src/ui/locales/languages.json
api-key: ${{ secrets.TERMIX_LLM_API_KEY }}
- name: Install dependencies
run: npm ci --ignore-scripts
# Newest stable plugin release whose engine.termix allows this version.
- name: Pin bundled plugins
run: node scripts/pin-bundled-plugins.cjs --all --core-version "${{ needs.prep.outputs.version }}"
- name: Commit translations and plugin pins
- name: Commit translations
id: commit
run: |
git add src/ui/locales docker/bundled-plugins.json
git add src/ui/locales
if git diff --cached --quiet; then
echo "No translation changes."
elif [ "${{ needs.prep.outputs.mode }}" = "Dry run" ]; then
echo "DRY RUN: would commit these translation and pin changes:"
echo "DRY RUN: would commit these translation changes:"
git --no-pager diff --cached --stat
else
git config user.name "LukeGus"
git config user.email "bugattiguy527@gmail.com"
git commit -m "chore: update translations and plugin pins"
git commit -m "chore: update translations"
git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
-35
View File
@@ -1,39 +1,5 @@
# Changelog
## 26.10.6
### Added
- The mobile app can sign in through your browser with any login method, including passkeys and security keys
- Plugins can say how they show in the mobile app
- The plugin list tells the mobile app which plugin API each plugin needs, so the app can say when it or a plugin needs an update
- Hosts can have a color and an icon, which sync to the desktop and mobile apps
## 26.10.5
### Added
- Safe mode: start the server with TERMIX_SAFE_MODE=1 to run only layout and theme plugins, so a broken plugin can be turned off
- TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS lets plugin registries live on a private address, like a registry in your LAN
- Plugin registry browsing and downloads use HTTP_PROXY and HTTPS_PROXY when they are set
- Docker image sizes: the default image now ships the most used plugins, the minimal tag ships only the look plugins and the full tag ships every official plugin
- Plugins an older image shipped and you had on are downloaded from the registry on the first start of a smaller image, so nothing goes missing
### Changed
- Card grids in the Plugins tab and in plugins can show Auto (as many as fit) or a set number of cards per row
### Fixed
- The macOS app asks for Local Network access, so it can reach servers on your LAN
- The desktop app can change the connection origin of a host shared with you, instead of failing to save
## 26.10.4
### Fixed
- Pin classic layout plugin (fixes proxmox install)
## 26.10.3
### Added
@@ -64,7 +30,6 @@
- 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
+2 -2
View File
@@ -1,6 +1,6 @@
cask "termix" do
version "26.10.5"
sha256 "808c467c5c34402193c7950ce61df0fa0445782f276348cf31ef235a6427d21c"
version "26.10.2"
sha256 "9df1850b5f75a3c0a1b3876847d46b7b4ff993aa1e50da55f4c88819308ba223"
url "https://github.com/Termix-SSH/Termix/releases/download/v#{version}/termix_macos_universal_dmg.dmg"
name "Termix"
-7
View File
@@ -66,9 +66,7 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [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. |
| [Cloud Providers](https://github.com/Termix-SSH/Plugin-Cloud-Providers) | Add hosts from AWS, Azure, Google Cloud, DigitalOcean, Hetzner and other clouds, and keep them in sync. |
| [Credential Tools](https://github.com/Termix-SSH/Plugin-Credential-Tools) | Generate SSH keys, check them and copy a public key to a host. |
| [Databases](https://github.com/Termix-SSH/Plugin-Databases) | Browse and edit MySQL, MariaDB, PostgreSQL and SQLite databases on your hosts from one tab. |
| [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. |
@@ -78,7 +76,6 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [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. |
| [Kubernetes](https://github.com/Termix-SSH/Plugin-Kubernetes) | Manage Kubernetes clusters from your hosts over SSH, with logs, metrics, YAML and a shell. |
| [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. |
@@ -86,14 +83,10 @@ Plugins add everything else. The official ones ship with Termix, and admins can
| [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. |
| [Reverse Proxy](https://github.com/Termix-SSH/Plugin-Reverse-Proxy) | Reverse proxy for TCP, UDP, HTTP and HTTPS with free certificates and Termix login, managed per host. |
| [Samba Shares](https://github.com/Termix-SSH/Plugin-Samba) | Create and manage Samba (SMB) file shares, users and permissions on your hosts from one tab. |
| [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. |
| [Services](https://github.com/Termix-SSH/Plugin-Service-Manager) | Manage services, timers and logs on your hosts over SSH. |
| [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. |
| [Simple Layout](https://github.com/Termix-SSH/Plugin-Simple-Layout) | A simple Termix layout for small setups with a top bar, a Home screen and only the basics. |
| [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. |
+4 -15
View File
@@ -34,20 +34,9 @@ WORKDIR /app
COPY . .
RUN npm run build:backend:core
# Stage 3b: Stage the bundled plugins for one profile (minimal, standard or
# full, see docker/bundled-plugins.json). Only this stage and the final one
# differ between profiles, so the other stages are built once.
FROM deps AS plugins
WORKDIR /app
ARG PLUGIN_PROFILE=standard
COPY scripts/build-plugins.cjs ./scripts/build-plugins.cjs
COPY scripts/lib/bundled-plugins.cjs ./scripts/lib/bundled-plugins.cjs
COPY docker/bundled-plugins.json ./docker/bundled-plugins.json
# Bundled plugin downloads show in the plugin store, so keep them between builds.
RUN --mount=type=cache,target=/app/node_modules/.cache/termix-bundled-plugins \
node scripts/build-plugins.cjs --profile "$PLUGIN_PROFILE"
npm run build:backend
# Stage 4: Download OPKSSH binary for the target platform so the image works offline
FROM node:24-slim AS opkssh-downloader
@@ -128,9 +117,9 @@ COPY --chown=node:node --from=frontend-builder /app/dist /app/html
COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
# Plugins of the chosen profile pinned in docker/bundled-plugins.json, resolved
# as dist/plugins by getBundledPluginsDir().
COPY --chown=node:node --from=plugins /app/dist/plugins ./dist/plugins
# Plugins pinned in docker/bundled-plugins.json, resolved as dist/plugins by
# getBundledPluginsDir().
COPY --chown=node:node --from=backend-builder /app/dist/plugins ./dist/plugins
# Target of node_modules/@termix-ssh/plugin-sdk, which core and the plugins import.
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/package.json ./packages/plugin-sdk/package.json
COPY --chown=node:node --from=backend-builder /app/packages/plugin-sdk/dist ./packages/plugin-sdk/dist
+57 -193
View File
@@ -3,85 +3,50 @@
{
"id": "acme-ssl",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-ACME-SSL/releases/download/v1.1.0/acme-ssl-1.1.0.tmxplug",
"sha256": "fb75541fae110007b45ed69d58aa4b9b6f6a79b6b1b762c6e54b635aa7cb6e4a",
"tier": "full"
"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.3.0/ai-1.3.0.tmxplug",
"sha256": "87ad17324cec6036dc224135fee342250c37413dccb2963f5f6b554d4ab8f5a6",
"tier": "full"
"url": "https://github.com/Termix-SSH/Plugin-AI/releases/download/v1.1.0/ai-1.1.0.tmxplug",
"sha256": "71dda6940d62c9eede7821bbac21e99443fbc08728a47759f215e2416f1d162f"
},
{
"id": "alerts",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Alerts/releases/download/v1.1.0/alerts-1.1.0.tmxplug",
"sha256": "a7ce9e20ce3c15f77c15479b4901563adf3c793e24a3aacd7226950491f0434c",
"tier": "standard",
"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",
"tier": "full"
},
{
"id": "automations",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.2.0/automations-1.2.0.tmxplug",
"sha256": "2ec9c718d73a094be3b59121aea049f0237e5783f53d193c4ea24a524584aeea",
"tier": "full"
"url": "https://github.com/Termix-SSH/Plugin-Automations/releases/download/v1.0.2/automations-1.0.2.tmxplug",
"sha256": "674eb04276b4a0c77c794b30647f8680218a78de8d52b73e99ec900cc1b3fdb3"
},
{
"id": "classic-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.2/classic-layout-1.0.2.tmxplug",
"sha256": "cf54f9b2e81c33b7e58cd3a21e203805f08ba37b3bc78412a08eb21b9fef8f14",
"tier": "minimal",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Layout/releases/download/v1.0.0/classic-layout-1.0.0.tmxplug",
"sha256": "3a51cc9a4c966478f2ebfd58dbbe025ff0ebe8bbdcd3ba73156151e555f62dec",
"defaultLook": true
},
{
"id": "classic-themes",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Classic-Themes/releases/download/v1.0.0/classic-themes-1.0.0.tmxplug",
"sha256": "919283f95701d41727c826637a7d349bd6a646abd61f9ac9017149bd0bee782c",
"tier": "minimal",
"sha256": "17561e569811476ea4e1f7cdf8d9afbe33edf061bd09f6b05699b44968da41ad",
"defaultLook": true
},
{
"id": "cloud-providers",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Cloud-Providers/releases/download/v1.1.0/cloud-providers-1.1.0.tmxplug",
"sha256": "7f61eafc3b99bdf1328fffa95791db1d27f8e1b683e5a5925603b5840057a9c7",
"tier": "full"
},
{
"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",
"tier": "standard"
},
{
"id": "databases",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Databases/releases/download/v1.1.0/databases-1.1.0.tmxplug",
"sha256": "4d9504cde705688a6f1f7706139d4e4c49b2276eccc67538fdee8251f29920b8",
"tier": "full"
},
{
"id": "docker",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.2.1/docker-1.2.1.tmxplug",
"sha256": "b2ca797465d88cfb549a04c3f0161708669def22a5ca73cb107879847c2db8ff",
"tier": "standard",
"url": "https://github.com/Termix-SSH/Plugin-Docker/releases/download/v1.0.2/docker-1.0.2.tmxplug",
"sha256": "0e1c722682faac6a027fd9b01e6910eb53ce5acb13332c6c8d8868554f3701ab",
"onboarding": {
"recommended": true,
"consent": false
@@ -90,9 +55,8 @@
{
"id": "file-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.1.1/file-manager-1.1.1.tmxplug",
"sha256": "ebba0b7d14facb3354f22a00e059e9f5a69e233458cb24985bf4ed5e5f8d2abd",
"tier": "standard",
"url": "https://github.com/Termix-SSH/Plugin-File-Manager/releases/download/v1.0.3/file-manager-1.0.3.tmxplug",
"sha256": "250e9779a6b1caf11721f1ee2baf76655ae6e52d963e894d8db84eb9677cf0b3",
"onboarding": {
"recommended": true,
"consent": false
@@ -102,167 +66,91 @@
"id": "fleets",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Fleets/releases/download/v1.0.2/fleets-1.0.2.tmxplug",
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59",
"tier": "full"
"sha256": "50dab38e1c6c95fdb4d0544ce50cdf84dae84789e64aa34ea9d04fdac3a38c59"
},
{
"id": "homepage",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.1.0/homepage-1.1.0.tmxplug",
"sha256": "5a9d5d2cc337fcee5590ea957e35ddeea5a71596aeb59410bf1420065b9b5b00",
"tier": "full"
"url": "https://github.com/Termix-SSH/Plugin-Homepage/releases/download/v1.0.2/homepage-1.0.2.tmxplug",
"sha256": "88cdea8ba8f458fcf124e7ab97e3d419896d8d22ffa9a372af34f1818872975b"
},
{
"id": "host-metrics",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.2.0/host-metrics-1.2.0.tmxplug",
"sha256": "8522cd23b558c3209821cee6d177daeb80f439b8ff652c577c5e4955315cc7a3",
"tier": "standard",
"url": "https://github.com/Termix-SSH/Plugin-Host-Metrics/releases/download/v1.0.3/host-metrics-1.0.3.tmxplug",
"sha256": "8f6022568349fc4318d509781bdb9d5b40973c010a9e27f720eed6baf68b881e",
"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",
"tier": "standard"
},
{
"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",
"tier": "full"
},
{
"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",
"tier": "standard"
},
{
"id": "kubernetes",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Kubernetes/releases/download/v1.0.1/kubernetes-1.0.1.tmxplug",
"sha256": "31d6b200579d326d07244712713ae291ff473270d44b478a884d07c863a394f0",
"tier": "full"
},
{
"id": "ldap",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-LDAP/releases/download/v1.0.2/ldap-1.0.2.tmxplug",
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03",
"tier": "full"
},
{
"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",
"tier": "full"
"sha256": "9ef269ad281f1c68cea53e0d43ccb7a5d4f949f11b30b22eac3d01047b20eb03"
},
{
"id": "network-topology",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Network-Topology/releases/download/v1.0.2/network-topology-1.0.2.tmxplug",
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56",
"tier": "full"
"sha256": "8d32340a0d0f9674b872da5d3d53e90f529b80dfe0d73ab294eed00e565fbb56"
},
{
"id": "opkssh",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-OPKSSH/releases/download/v1.0.2/opkssh-1.0.2.tmxplug",
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219",
"tier": "full"
"sha256": "4bb8f1ac5d39bd18ac3d0fe82ddbf2f26ab4c01bd75e6f7a073d65c2e9aef219"
},
{
"id": "proxmox",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Proxmox/releases/download/v1.1.0/proxmox-1.1.0.tmxplug",
"sha256": "8937d239ad40033d69fba59085fe8896ee25c61951c21f66ff98531fd4c63cbf",
"tier": "full"
"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.2.1/remote-desktop-1.2.1.tmxplug",
"sha256": "ad244f31016f0ea5df5c303bf6f48af35c6c8e52b12098d381c8296626b73de7",
"tier": "standard",
"url": "https://github.com/Termix-SSH/Plugin-Remote-Desktop/releases/download/v1.1.0/remote-desktop-1.1.0.tmxplug",
"sha256": "6f74f1d26f91a43b2d2203417d744211f366c335d5fff7bed34bfce33874dd01",
"onboarding": {
"recommended": true,
"consent": false
}
},
{
"id": "reverse-proxy",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Reverse-Proxy/releases/download/v1.1.0/reverse-proxy-1.1.0.tmxplug",
"sha256": "c8a9851da7ca6291c1f47cf6ab235da5057455a86b4ed0dc1c1a3026afb30aa7",
"tier": "full"
},
{
"id": "samba",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Samba/releases/download/v1.0.0/samba-1.0.0.tmxplug",
"sha256": "6e131ccdca998a9d0802becdd439a88305b64fcd7f07335bec9c7ed6791c0f53",
"tier": "full"
},
{
"id": "secret-sources",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Secret-Sources/releases/download/v1.0.2/secret-sources-1.0.2.tmxplug",
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134",
"tier": "full"
"sha256": "82e2b1e95b0837dd3e5fee277be4eea2d1ce68567b0e204941c48150f415d134"
},
{
"id": "serial",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Serial/releases/download/v1.0.2/serial-1.0.2.tmxplug",
"sha256": "6ede7e8726a7312429473f34a46f93ba6657c5b7ebc12bed18f80ec62c211715",
"tier": "standard",
"onboarding": {
"recommended": "desktop",
"consent": false
}
},
{
"id": "service-manager",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Service-Manager/releases/download/v1.1.0/service-manager-1.1.0.tmxplug",
"sha256": "82b1c70abd97ed535f217535c667fe6c48e5e01e2bf8037a89b58b80d35206bf",
"tier": "full"
},
{
"id": "session-recording",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Session-Recording/releases/download/v1.1.0/session-recording-1.1.0.tmxplug",
"sha256": "31cd78d67e061242f8776849a2fae5f84ab48c7c765d487b32c5e1693c54b46a",
"tier": "full"
"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.1.0/session-sharing-1.1.0.tmxplug",
"sha256": "2988b39221d93b3dbc5e2a81e104e5dc01ce264afb1e66df3407492658ab9fdc",
"tier": "full"
},
{
"id": "simple-layout",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Simple-Layout/releases/download/v1.0.1/simple-layout-1.0.1.tmxplug",
"sha256": "838f08050335b44b62295b87006b50d1db1029699bfe6a35fa75808d4aea2cde",
"tier": "full"
"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.1.0/snippets-1.1.0.tmxplug",
"sha256": "e2d7d1ebe5bdf2986ffb4961ac6f6d32b392b02c30a64a333a7eeb2ce6a46bb3",
"tier": "standard",
"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
@@ -271,9 +159,8 @@
{
"id": "ssh-terminal",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.1.1/ssh-terminal-1.1.1.tmxplug",
"sha256": "5cfb381a2de10b7a6fab108d79645bb1e6e7316fb5e4150c4529a3e76a2193d8",
"tier": "standard",
"url": "https://github.com/Termix-SSH/Plugin-SSH-Terminal/releases/download/v1.0.3/ssh-terminal-1.0.3.tmxplug",
"sha256": "bc288bd843e37b64bcda7462d175039147b75dc6de8866769e4a938036a9d023",
"onboarding": {
"recommended": true,
"consent": false
@@ -282,44 +169,31 @@
{
"id": "sso",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-SSO/releases/download/v1.0.4/sso-1.0.4.tmxplug",
"sha256": "2aad33177085fbc92fe06cd007facffbfc8f42f9c9fa688ad5f37d68f62093d9",
"tier": "standard",
"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,
"enabledByEnv": [
"OIDC_CLIENT_ID"
]
"enabledByEnv": ["OIDC_CLIENT_ID"]
}
},
{
"id": "step-ca",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Step-CA/releases/download/v1.0.2/step-ca-1.0.2.tmxplug",
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a",
"tier": "full"
},
{
"id": "sync",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Sync/releases/download/v1.0.1/sync-1.0.1.tmxplug",
"sha256": "66a301fd04fb8d2a3ece18dfb230069cf78e1138ee78941dc511dfbd8ee4f91e",
"tier": "standard"
"sha256": "685b644fe44c2d3fa007baee16e8ea8a7d5204f039ebc8aa65e8093ad93adf8a"
},
{
"id": "tailscale",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tailscale/releases/download/v1.1.0/tailscale-1.1.0.tmxplug",
"sha256": "8784a77a1fc1b0011d8243d9520574300df31bc40c31f86339868f4c9b76d1bf",
"tier": "full"
"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.2/telemetry-1.0.2.tmxplug",
"sha256": "5ffdc58b05cd8b37fd0a207f99d2d3a256164130c950032501c499f869edb9ec",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": true
@@ -329,22 +203,19 @@
"id": "termix-identity",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Termix-Identity/releases/download/v1.0.2/termix-identity-1.0.2.tmxplug",
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927",
"tier": "full"
"sha256": "ac6c90a48b4f1e9cff6773a5fd267b2bf7319d2fc7e2aa2cd4004cec82f0f927"
},
{
"id": "tmux-monitor",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.1.0/tmux-monitor-1.1.0.tmxplug",
"sha256": "3cdc54a34e1ca30c4e3c217bbc612e40ef3cd3e4d5de704ea92d914b32d98006",
"tier": "full"
"url": "https://github.com/Termix-SSH/Plugin-Tmux-Monitor/releases/download/v1.0.3/tmux-monitor-1.0.3.tmxplug",
"sha256": "f152d64f433d3f1de7c3fdf172e752ea7e333909f0fdda77336a6b8dddb9f242"
},
{
"id": "totp",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-TOTP/releases/download/v1.0.2/totp-1.0.2.tmxplug",
"sha256": "94a0772660fa8e4dfd9afc15b68d5bc9fe02fe28a1acfd35e65a57c304d460c0",
"tier": "standard",
"onboarding": {
"recommended": true,
"consent": false
@@ -353,9 +224,8 @@
{
"id": "tunnels",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.2.1/tunnels-1.2.1.tmxplug",
"sha256": "2871a8d31854e1a31416c4afe5839099a4c489e34d9d31573659f73b2515f159",
"tier": "standard",
"url": "https://github.com/Termix-SSH/Plugin-Tunnels/releases/download/v1.0.3/tunnels-1.0.3.tmxplug",
"sha256": "d0de9e59ccc9e1a0f2817dd1ec9951021f2d7e89304894bb1e8cefd65aebff0b",
"onboarding": {
"recommended": true,
"consent": false
@@ -365,36 +235,31 @@
"id": "vault",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Vault/releases/download/v1.0.2/vault-1.0.2.tmxplug",
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28",
"tier": "full"
"sha256": "48ef402d030912a836489edf0fdfcaf7929cbd67c21f16a2079cb4925134cc28"
},
{
"id": "wake-on-lan",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Wake-On-LAN/releases/download/v1.1.0/wake-on-lan-1.1.0.tmxplug",
"sha256": "1afb708c12631ededec40938aed3ab587bc9a519215fa5e93350b1f0a843d2a8",
"tier": "full"
"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.2/warpgate-1.0.2.tmxplug",
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47",
"tier": "full"
"sha256": "5c2c1c9aef40dbbf98424d02303f2ee77e6e869334247881e8aa0fcd8c415b47"
},
{
"id": "web-endpoint",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.1.0/web-endpoint-1.1.0.tmxplug",
"sha256": "79e691abc456b17c8ce651017b47097a7ff71e94267e668341548a05b911a92c",
"tier": "full"
"url": "https://github.com/Termix-SSH/Plugin-Web-Endpoint/releases/download/v1.0.2/web-endpoint-1.0.2.tmxplug",
"sha256": "0326edb1052d1debd083004c2dd1711d21a902cfacb55ce74d1a8856a5122477"
},
{
"id": "webauthn",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.1.0/webauthn-1.1.0.tmxplug",
"sha256": "83a61a530db0273f9fef2080d6aa027a82ef8374fb4e0f158a8559a2770299de",
"tier": "standard",
"url": "https://github.com/Termix-SSH/Plugin-WebAuthn/releases/download/v1.0.2/webauthn-1.0.2.tmxplug",
"sha256": "8269f6e407897332648c8526b65383c4db071ea782bf1e58aa7c7c00627f3f4b",
"onboarding": {
"recommended": true,
"consent": false
@@ -403,9 +268,8 @@
{
"id": "workspaces",
"source": "tmxplug",
"url": "https://github.com/Termix-SSH/Plugin-Workspaces/releases/download/v1.1.0/workspaces-1.1.0.tmxplug",
"sha256": "7189d767c3f7cddfb59366f6cceeca27a0b29e41da1dcc89d57a25317a0fe4bf",
"tier": "standard",
"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
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -533,13 +533,6 @@
"when": 1792430772041,
"tag": "0075_session_policy",
"breakpoints": true
},
{
"idx": 76,
"version": "5",
"when": 1792945361809,
"tag": "0076_host_color_icon",
"breakpoints": true
}
]
}
@@ -1,2 +0,0 @@
ALTER TABLE "ssh_data" ADD COLUMN "color" text;--> statement-breakpoint
ALTER TABLE "ssh_data" ADD COLUMN "icon" text;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -533,13 +533,6 @@
"when": 1792430770990,
"tag": "0075_session_policy",
"breakpoints": true
},
{
"idx": 76,
"version": "7",
"when": 1792945360507,
"tag": "0076_host_color_icon",
"breakpoints": true
}
]
}
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE `ssh_data` ADD `color` text;--> statement-breakpoint
ALTER TABLE `ssh_data` ADD `icon` text;
File diff suppressed because it is too large. Load diff
-7
View File
@@ -498,13 +498,6 @@
"when": 1792430769899,
"tag": "0070_session_policy",
"breakpoints": true
},
{
"idx": 71,
"version": "6",
"when": 1792945434651,
"tag": "0071_host_color_icon",
"breakpoints": true
}
]
}
-6
View File
@@ -128,10 +128,6 @@
"category": "public.app-category.developer-tools",
"hardenedRuntime": true,
"gatekeeperAssess": false,
"extendInfo": {
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"]
},
"entitlements": "packaging/build/entitlements.mac.plist",
"entitlementsInherit": "packaging/build/entitlements.mac.inherit.plist",
"type": "distribution",
@@ -157,8 +153,6 @@
"artifactName": "termix_macos_${arch}_mas.${ext}",
"extendInfo": {
"ITSAppUsesNonExemptEncryption": false,
"NSLocalNetworkUsageDescription": "Termix connects to servers and hosts on your local network.",
"NSBonjourServices": ["_http._tcp", "_https._tcp", "_ssh._tcp"],
"NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
}
},
-69
View File
@@ -31,10 +31,6 @@ const { quitApp } = require("./app-quit.cjs");
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
// Saved by the file manager after the user picks an editor in a file dialog.
const EXTERNAL_EDITOR_SETTING = "fileManager.externalEditorPath";
// The height of the title bar the app draws itself. The renderer uses the
// same number, so the bar and the window controls always line up.
const TITLE_BAR_HEIGHT = 40;
const { isExternalLoginUrl, isCallbackPort } = require("./external-login.cjs");
// The main process's Node.js networking (the global `fetch`) only
@@ -1331,27 +1327,12 @@ function createWindow() {
? "macOS"
: "Linux";
const isMac = process.platform === "darwin";
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
title: "Termix",
// The app draws its own title bar so it follows the active theme. macOS
// keeps the traffic lights on top of it, Windows and Linux get the
// native controls as an overlay whose colors the renderer keeps in sync.
titleBarStyle: isMac ? "hiddenInset" : "hidden",
...(isMac
? { trafficLightPosition: { x: 14, y: 16 } }
: {
titleBarOverlay: {
color: "#0f0f14",
symbolColor: "#e6e6ea",
height: TITLE_BAR_HEIGHT,
},
}),
icon: path.join(
appRoot,
"public",
@@ -1564,16 +1545,6 @@ function createWindow() {
}
});
const sendTitleBarVisible = () => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send(
"title-bar-visible-changed",
titleBarVisible(mainWindow),
);
};
mainWindow.on("enter-full-screen", sendTitleBarVisible);
mainWindow.on("leave-full-screen", sendTitleBarVisible);
mainWindow.on("closed", () => {
embeddedViews.destroyAll();
mainWindow = null;
@@ -1603,50 +1574,10 @@ ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
/** The window an IPC call came from, so the main window and the extra
* windows (isolated views, sign-in) can each answer for themselves. */
function windowFor(event) {
const win = BrowserWindow.fromWebContents(event.sender);
if (win && !win.isDestroyed()) return win;
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
return null;
}
ipcMain.handle("get-platform", () => {
return process.platform;
});
// Whether the app-drawn title bar shows in the window that asks. Only the
// main window hides the native bar, and in full screen macOS hides the
// traffic lights, so the bar goes away there too.
function titleBarVisible(win) {
return !!win && win === mainWindow && !win.isFullScreen();
}
ipcMain.handle("get-title-bar-visible", (event) => {
return titleBarVisible(windowFor(event));
});
// The renderer sends the colors of the active theme so the window controls
// the system draws (Windows and Linux) match the app.
ipcMain.handle("set-title-bar-overlay", (event, colors) => {
const win = windowFor(event);
if (!win || process.platform === "darwin") return;
const options = {};
if (typeof colors?.color === "string" && colors.color) {
options.color = colors.color;
}
if (typeof colors?.symbolColor === "string" && colors.symbolColor) {
options.symbolColor = colors.symbolColor;
}
if (Object.keys(options).length === 0) return;
try {
win.setTitleBarOverlay(options);
} catch (error) {
logToFile("setTitleBarOverlay failed:", error);
}
});
ipcMain.handle("get-embedded-server-status", () => {
return {
running:
-13
View File
@@ -32,19 +32,6 @@ 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"),
+1 -12
View File
@@ -298,7 +298,7 @@
{
"name": "HTTP_PROXY",
"group": "proxy",
"description": "Proxy for outbound HTTP requests, like plugin registry downloads and webhooks."
"description": "Proxy for outbound HTTP requests, like registry downloads and webhooks."
},
{
"name": "HTTPS_PROXY",
@@ -353,11 +353,6 @@
"group": "plugins",
"description": "Where plugin download counts are read from. Defaults to stats.json next to the registry index."
},
{
"name": "TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS",
"group": "plugins",
"description": "Comma-separated host names or IP addresses that plugin registry downloads may reach on a private address, like a registry in your LAN. List bare hosts: entries with a scheme, port or path are ignored. Matched by name only, on any port, so only list names your own DNS answers for. With HTTPS_PROXY set, add them to NO_PROXY too."
},
{
"name": "TERMIX_REQUIRE_SIGNED_PLUGINS",
"group": "plugins",
@@ -370,12 +365,6 @@
"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_SAFE_MODE",
"group": "plugins",
"default": "false",
"description": "Start only layout and theme plugins. Every other plugin stays off until a restart without it, so you can turn off a plugin that breaks the server or the app."
},
{
"name": "TERMIX_BUNDLED_PLUGINS_DIR",
"group": "plugins",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "termix",
"version": "26.10.6",
"version": "26.10.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "termix",
"version": "26.10.6",
"version": "26.10.3",
"hasInstallScript": true,
"workspaces": [
"packages/*"
+3 -4
View File
@@ -4,7 +4,7 @@
"workspaces": [
"packages/*"
],
"version": "26.10.6",
"version": "26.10.3",
"description": "Self-hosted, plugin-based server management.",
"author": "Karmaa",
"main": "electron/main.cjs",
@@ -18,7 +18,7 @@
"postinstall": "node scripts/patch-app-builder-lib.cjs && node scripts/patch-better-sqlite3.cjs && node scripts/patch-nan.cjs && node scripts/patch-node-pty.cjs && node scripts/patch-ssh2-agent.cjs && 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-button-colors.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": "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",
@@ -31,8 +31,7 @@
"dev:docker": "node scripts/dev.mjs docker",
"dev:frontend": "vite",
"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:core": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\"",
"build:backend": "npm run build:backend:core && 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",
"build:win-portable": "npm run build && npm run electron:rebuild && electron-builder --win --dir",
-48
View File
@@ -1,53 +1,5 @@
# Changelog
## 1.0.22
Plugin API 1.13.0.
### Added
- `contributes.mobile` in the manifest says how a plugin shows in the Termix mobile app: `webview`, `native` or `none`, with an optional mobile bundle, screen sizes, views and a note
- `termix-plugin build` bundles `src/mobile` into the `contributes.mobile.entry` file, and `validate` checks that it exists
- `@termix-ssh/plugin-sdk/frame-host` runs one plugin view outside the shell, with core's bridge, registries and CSS, so the mobile app can show `webview` plugins
### Fixed
- `@termix-ssh/plugin-sdk/ui` is built in production mode, so outside Termix it no longer sends requests to the Vite dev server path
## 1.0.21
Plugin API 1.12.0.
### Added
- `@termix-ssh/plugin-sdk/host-scripts`: Docker, Linux metrics, systemd, journal and OpenRC command builders and parsers, plus `scriptShell()` and `passwordSudoScript()` to send scripts to `bash -s` on stdin. It has no Node or ssh2 imports, so the Termix mobile app runs the same code
## 1.0.20
Plugin API 1.12.0.
### Added
- `columns` and `onColumns` on `ViewToggle` show a cards per row picker: Auto fits as many as the pane has room for, or pick 1 to 6
- `size` and `minCardWidth` on `DataView`, so a card grid follows that pick, and the `GridSize` type
- `usePluginPanelView` also keeps `columns`, the cards per row pick, with `setColumns`
## 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.
+3 -52
View File
@@ -1,6 +1,6 @@
# Termix plugin guide
SDK 1.0.22 | Plugin API 1.13.0
SDK 1.0.17 | Plugin API 1.9.0
This guide is for anyone, person or AI coding tool, building a Termix plugin. It ships inside
`@termix-ssh/plugin-sdk`, so the copy in your `node_modules` always matches the SDK you have installed.
@@ -175,49 +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`, `uses`, `layouts`, `themes` and `mobile`. Frontend `register*` calls need their ids declared here first.
### Mobile app
`contributes.mobile` says how the plugin shows in the Termix mobile app. Leave it out and the app treats the
plugin as having nothing to show (official plugins are mapped by the app itself).
```json
"mobile": { "mode": "webview", "views": ["hello"], "sizes": ["phone", "tablet"] }
```
| Field | Notes |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `mode` | `webview` (the app shows your UI from the server), `native` (the app has its own screens) or `none`. |
| `entry` | `webview` only. A bundle made for the app, like `dist/mobile.js`, next to `frontend.js`. Unset: your normal frontend runs in a phone sized frame. |
| `sizes` | `webview` only. `phone`, `tablet` or both (the default). |
| `views` | `webview` only. Ids from your `tabs`, `panels` or `dashboardCards` the app offers. Default: every tab. |
| `feature` | `native` only. The app feature that stands in for the plugin. Default: the plugin id. |
| `note` | A locale key or plain text the app shows next to the plugin. |
`build` makes `entry` from `src/mobile/index.tsx`, and it shares `frontend.css`.
How a `webview` plugin runs in the app:
- Only your plugin loads, with the same app object, SDK hooks and `/ui` kit as the web app. Each view opens
on its own screen: a tab with `host-context-menu` in `openFrom` opens from a host, the rest from the app's
plugin list.
- Every request and socket goes through the app, which adds the login, proxy headers and client
certificate. The app only lets through your own `/plugin-api`, `/plugin-ws` and `/plugin-assets`, your
settings and a short list of core reads (hosts, folders, permissions, current user, version, health,
activity). Other core routes answer 403, so call your own backend instead.
- Images load straight from the server, without proxy headers or a client certificate.
- Nothing in `localStorage` survives closing the view. Keep preferences in settings.
- Other plugins' views, extensions and components are not there, and neither is the shell (`useShell()`
is null). `/ui` names newer than the app's copy of the SDK make the view say it needs a newer app.
- The app checks your `engine.api` against the plugin API of its own copy of the SDK. When the app's API
is too old for the range, it asks the user to update the app instead of opening the view. When the range
is for an older API major, it asks to update the plugin. Ask only for the API you use.
- Turning a plugin off on the server hides it in the app the next time the app checks: when it comes back
to the front, when the list is pulled down, or when any request answers 503 with your `pluginId`.
Termix 26.10.5 and older refuse any manifest with `mobile`, so a plugin that declares it needs
`"engine": { "termix": ">=26.10.6", "api": "^1.13" }`.
`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.
@@ -377,9 +335,7 @@ try {
`ctx.ssh.withConnection(hostId, { pool, purpose }, fn)` borrows a pooled connection for frequent short
commands. `@termix-ssh/plugin-sdk/host-commands` has helpers to detect the OS and run package manager and
sudo commands. `@termix-ssh/plugin-sdk/host-scripts` builds Docker, metrics, systemd and journal
commands and parses their output; send a script on stdin to the command `scriptShell()` returns. Hosts from
`ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
sudo commands. Hosts from `ctx.hosts` never carry secrets. Pass the host or its id to `ctx.ssh`.
### The desktop app
@@ -540,7 +496,6 @@ 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) |
@@ -687,9 +642,6 @@ exports with props is `ui.api.txt` in the Termix repo
`PanePrompt` for small prompts.
- Every tab or panel sits in `PanelShell`. Lists use `PanelList` and `ListRow`, add with `AddButton`,
forms end with `FormFooter`, empty lists show `EmptyState`.
- Card grids use `DataView` with `ViewToggle`. Pass `columns` and `onColumns` to `ViewToggle` and the same
value as `size` to `DataView`, so users can pick Auto (as many as fit) or a fixed number of cards per
row. `usePluginPanelView` keeps the choice as `columns`. Older Termix versions ignore both props.
- Forms: `TextField`, `NumberField`, `SelectField`, `SwitchRow`, `SecretField`, `SettingRow` and friends.
- Host sessions use `ConnectionGate` for the whole connect flow (host keys, TOTP, passphrases, errors).
- Mobile: check `useIsMobile()` and give small screens a layout that works. The desktop app always uses the
@@ -949,7 +901,6 @@ To run your own registry instead, see https://docs.termix.site/develop/registrie
| `@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/host-scripts` | Docker, metrics, systemd and journal command builders and parsers, scripts sent to `bash -s` |
| `@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 |
+36 -55
View File
@@ -25,43 +25,6 @@ const FRONTEND_ENTRIES = [
"src/frontend/index.mjs",
"src/frontend/index.js",
];
const MOBILE_ENTRIES = [
"src/mobile/index.tsx",
"src/mobile/index.ts",
"src/mobile/index.mjs",
"src/mobile/index.js",
];
/** Builds a bundle that runs in a browser against core's shared modules. */
async function buildBrowserBundle(entry, outfile, outDir) {
await esbuild.build({
entryPoints: [entry],
outfile,
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
// Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
}
/**
* A bundled CommonJS package that calls require("react/jsx-runtime") (or any
@@ -229,12 +192,15 @@ export async function buildTailwind(cwd, outDir) {
// build() adds to what a compiler already emitted, so each half gets its own.
const compiler = () =>
compile(input, { base: path.dirname(THEME_CSS), onDependency: () => {} });
// The mobile bundle uses the same stylesheet.
const sources = ["frontend", "mobile"]
.map((dir) => path.join(cwd, "src", dir))
.filter((dir, index) => index === 0 || fs.existsSync(dir))
.map((base) => ({ base, pattern: "**/*", negated: false }));
const scanner = new Scanner({ sources });
const scanner = new Scanner({
sources: [
{
base: path.join(cwd, "src", "frontend"),
pattern: "**/*",
negated: false,
},
],
});
// Flattened and minified the way core's own CSS is, so it does not rely
// on native CSS nesting.
const emit = async (candidates, options) =>
@@ -331,18 +297,33 @@ export async function build({ cwd }) {
const frontendEntry = resolveEntry(cwd, FRONTEND_ENTRIES);
if (frontendEntry) {
await buildBrowserBundle(
frontendEntry,
path.join(outDir, "frontend.js"),
outDir,
);
}
// A bundle the mobile app loads in its WebView, next to the frontend one.
const mobileEntry = resolveEntry(cwd, MOBILE_ENTRIES);
const mobileOut = manifest.contributes?.mobile?.entry;
if (mobileEntry && typeof mobileOut === "string") {
await buildBrowserBundle(mobileEntry, path.join(cwd, mobileOut), outDir);
await esbuild.build({
entryPoints: [frontendEntry],
outfile: path.join(outDir, "frontend.js"),
bundle: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
sourcemap: true,
logLevel: "warning",
external: FRONTEND_EXTERNALS,
// Vite replaces these in the dev server; a plugin bundle has to have
// them too, or code that reads them throws when the plugin loads.
define: {
"process.env.NODE_ENV": '"production"',
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.SSR": "false",
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
plugins: [
externalRequireInterop(FRONTEND_EXTERNALS),
staticUrlImports({ outDir }),
],
});
}
if (frontendEntry) await buildTailwind(cwd, outDir);
@@ -28,10 +28,6 @@ export async function validate({ cwd }) {
// A theme only plugin has no frontend code.
...(isThemeOnly(raw) ? [] : [raw.frontend ?? "dist/frontend.js"]),
raw.locales ?? "locales",
// Built from src/mobile by termix-plugin build.
...(typeof raw.contributes?.mobile?.entry === "string"
? [raw.contributes.mobile.entry]
: []),
];
for (const rel of referenced) {
if (!fs.existsSync(path.join(cwd, rel))) {
+1 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@termix-ssh/plugin-sdk",
"version": "1.0.22",
"version": "1.0.17",
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
"license": "Apache-2.0",
"repository": {
@@ -81,10 +81,6 @@
"types": "./dist/host-commands.d.ts",
"default": "./dist/host-commands.js"
},
"./host-scripts": {
"types": "./dist/host-scripts/index.d.ts",
"default": "./dist/host-scripts/index.js"
},
"./ssh-certs": {
"types": "./dist/ssh-certs.d.ts",
"default": "./dist/ssh-certs.js"
@@ -102,10 +98,6 @@
"./testing-host": {
"default": "./dist/host/testing-host.js"
},
"./frame-host": {
"default": "./dist/host/frame-host.js"
},
"./frame-host.css": "./dist/host/frame-host.css",
"./theme.css": "./dist/host/theme.css"
},
"files": [
@@ -1239,48 +1239,6 @@
}
}
}
},
"mobile": {
"type": "object",
"description": "How the plugin shows in the Termix mobile app. Needs plugin API 1.13 (Termix 26.10.6).",
"additionalProperties": false,
"required": ["mode"],
"properties": {
"mode": {
"type": "string",
"enum": ["webview", "native", "none"],
"description": "webview: the app shows the plugin's UI from the server. native: the app has its own screens. none: nothing to show in the app."
},
"entry": {
"type": "string",
"pattern": "\\.js$",
"description": "webview only. A bundle built for the app, in the same folder as the frontend bundle. Unset means the normal frontend runs in a phone sized frame."
},
"sizes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "enum": ["phone", "tablet"] },
"description": "webview only. Screen sizes the UI works at. Defaults to both."
},
"views": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 },
"description": "webview only. Tab, panel or card ids the app offers. Defaults to every tab."
},
"feature": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]{1,39}$",
"description": "native only. The app feature that stands in for this plugin. Defaults to the plugin id."
},
"note": {
"type": "string",
"minLength": 1,
"description": "i18n key in the plugin's namespace, or plain text, shown in the app."
}
}
}
}
},
+1 -20
View File
@@ -308,15 +308,6 @@ export interface SyncBasicAuth {
password: string;
}
/** A client certificate for a server that asks for mutual TLS. */
export interface SyncClientCert {
/** Base64 of the PFX/P12 file. */
pfx: string;
passphrase: string;
/** The file name, to show which certificate is saved. */
name: string | null;
}
/** Extra per server connection options. Kept open so new ones need no migration. */
export type SyncLinkOptions = Record<string, unknown>;
@@ -332,7 +323,6 @@ export interface SyncLinkInfo {
customHeaders: SyncProxyHeader[];
basicAuth: SyncBasicAuth | null;
allowInvalidCertificate: boolean;
clientCert: SyncClientCert | null;
remoteUserId: string | null;
remoteUsername: string | null;
account: {
@@ -362,7 +352,6 @@ export interface SyncLinkCreate {
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: SyncClientCert | null;
remoteUserId?: string | null;
remoteUsername?: string | null;
}
@@ -376,7 +365,6 @@ export type SyncLinkPatch = Partial<
| "customHeaders"
| "basicAuth"
| "allowInvalidCertificate"
| "clientCert"
| "remoteUserId"
| "remoteUsername"
| "account"
@@ -391,13 +379,7 @@ export type SyncLinkPatch = Partial<
>;
export type SyncRemoteErrorKind =
| "offline"
| "tls"
| "client_cert"
| "proxy"
| "basic_auth"
| "signed_out"
| "server";
"offline" | "tls" | "proxy" | "basic_auth" | "signed_out" | "server";
export interface SyncRemoteError extends Error {
readonly kind: SyncRemoteErrorKind;
@@ -410,7 +392,6 @@ export interface SyncRemoteTarget {
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
allowInvalidCertificate?: boolean;
clientCert?: SyncClientCert | null;
}
export interface SyncRemoteRequest {
-11
View File
@@ -35,17 +35,6 @@ 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>;
+3 -61
View File
@@ -825,26 +825,6 @@ export interface CompanionDefaults {
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;
@@ -1138,14 +1118,6 @@ export interface TermixApp extends TermixAppInfo {
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: (
@@ -1375,7 +1347,6 @@ 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[];
@@ -1711,49 +1682,28 @@ export function usePluginUiPreferences<
return bridge.usePluginUiPreferences(bridge.usePluginId()) as never;
}
type PanelGridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
const PANEL_GRID_SIZES: readonly string[] = [
"auto",
"1",
"2",
"3",
"4",
"5",
"6",
];
/**
* Grid or list, row density and cards per row for one of this plugin's
* views, kept in its Appearance area as `<prefix>viewMode`, `<prefix>density`
* and `<prefix>columns`. Declare those keys in contributes.uiPresets so each
* preset level can pick a default. Hand columns to ViewToggle and DataView.
* Grid or list and row density for one of this plugin's views, kept in its
* Appearance area as `<prefix>viewMode` and `<prefix>density`. Declare those
* keys in contributes.uiPresets so each preset level can pick a default.
*/
export function usePluginPanelView(prefix = ""): {
view: "grid" | "list";
density: "comfortable" | "compact";
compact: boolean;
columns: PanelGridSize;
setView: (view: "grid" | "list") => void;
setDensity: (density: "comfortable" | "compact") => void;
setColumns: (columns: PanelGridSize) => void;
} {
const { values, set } = usePluginUiPreferences();
const view = values[`${prefix}viewMode`] === "list" ? "list" : "grid";
const density =
values[`${prefix}density`] === "compact" ? "compact" : "comfortable";
const saved = values[`${prefix}columns`];
const columns =
typeof saved === "string" && PANEL_GRID_SIZES.includes(saved)
? (saved as PanelGridSize)
: "auto";
return {
view,
density,
compact: density === "compact",
columns,
setView: (next) => set(`${prefix}viewMode`, next),
setDensity: (next) => set(`${prefix}density`, next),
setColumns: (next) => set(`${prefix}columns`, next),
};
}
@@ -1806,14 +1756,6 @@ 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
+8 -3
View File
@@ -11,9 +11,6 @@
export type { Client, ClientChannel } from "ssh2";
import type { Client, ClientChannel } from "ssh2";
// Homebrew and OrbStack paths a non-interactive shell misses.
import { EXTRA_PATH_DIRS } from "./host-scripts/docker.js";
export { EXTRA_PATH_DIRS };
export interface ExecResult {
stdout: string;
@@ -117,6 +114,14 @@ export interface PlatformInfo {
osPrettyName: string | null;
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
* Extend PATH with their common install locations before probing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
const DOCKER_PROBE =
`command -v docker >/dev/null 2>&1` +
` || [ -S /var/run/docker.sock ]` +
@@ -1,468 +0,0 @@
/**
* Docker and Podman command builders and output parsers.
*
* Container ids and names are checked with CONTAINER_ID_RE before they go on
* a command line, so they need no quoting. Anything free form is quoted.
*/
import { HostScriptError } from "./text.js";
export type ContainerRuntime = "docker" | "podman";
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
return value === "podman" ? "podman" : "docker";
}
export function getRuntimeLabel(runtime: ContainerRuntime): string {
return runtime === "podman" ? "Podman" : "Docker";
}
/**
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing.
*/
export const EXTRA_PATH_DIRS =
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
export function containerCommand(
runtime: ContainerRuntime | undefined,
args: string,
isWindows = false,
): string {
const cli = `${normalizeContainerRuntime(runtime)} ${args}`;
// cmd and PowerShell don't understand the POSIX env prefix.
return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`;
}
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
export function isContainerId(value: unknown): value is string {
return (
typeof value === "string" &&
value.length <= 256 &&
CONTAINER_ID_RE.test(value)
);
}
function requireContainer(id: string): string {
if (!isContainerId(id))
throw new HostScriptError("That is not a valid container id");
return id;
}
export const CONTAINER_ACTIONS = [
"start",
"stop",
"restart",
"pause",
"unpause",
] as const;
export type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
export function listFormat(isWindows: boolean): string {
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
}
export function statsFormat(isWindows: boolean): string {
return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
}
/** One row of `ps --format listFormat()`. */
export interface ContainerRow {
id: string;
name: string;
image: string;
status: string;
state: string;
ports: string;
created: string;
}
/** CLI arguments, without the runtime, for each container command. */
export const dockerArgs = {
version: () => "--version",
ping: () => "ps",
list: (all: boolean, isWindows = false) =>
`ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`,
inspect: (id: string) => `inspect ${requireContainer(id)}`,
stats: (id: string, isWindows = false) =>
`stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`,
action: (action: ContainerAction, id: string) => {
if (!CONTAINER_ACTIONS.includes(action))
throw new HostScriptError("Unknown action");
return `${action} ${requireContainer(id)}`;
},
remove: (id: string, force: boolean) =>
`rm ${force ? "-f " : ""}${requireContainer(id)}`,
logs: (
id: string,
options: {
tail?: number;
timestamps?: boolean;
since?: string;
until?: string;
} = {},
) => {
let args = `logs ${requireContainer(id)}`;
if (options.tail && options.tail > 0)
args += ` --tail ${Math.floor(options.tail)}`;
if (options.timestamps) args += " --timestamps";
if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) {
args += ` --since ${options.since}`;
}
if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) {
args += ` --until ${options.until}`;
}
return `${args} 2>&1`;
},
states: () => `ps -a --format ${PS_STATE_FORMAT}`,
composeProjects: () => "compose ls -a --format json",
};
/** The version from `docker --version` or `podman --version`. */
export function parseRuntimeVersion(output: string): string {
return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown";
}
export type RuntimeProblem =
"NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR";
/** Sorts a failed `docker ps` into a reason the UI can explain. */
export function classifyRuntimeError(message: string): RuntimeProblem {
if (
/command not found|not found: (docker|podman)|No such file or directory/i.test(
message,
)
) {
return "NOT_INSTALLED";
}
if (message.includes("Cannot connect to the Docker daemon"))
return "DAEMON_NOT_RUNNING";
if (/permission denied/i.test(message)) return "PERMISSION_DENIED";
return "DOCKER_ERROR";
}
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
export function parseContainerList(output: string): Record<string, unknown>[] {
return output
.split("\n")
.filter((line) => line.trim())
.flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
export interface ContainerStats {
cpu: string;
memoryUsed: string;
memoryLimit: string;
memoryPercent: string;
netInput: string;
netOutput: string;
blockRead: string;
blockWrite: string;
pids: string;
}
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
export function parseContainerStats(output: string): ContainerStats {
const raw = JSON.parse(output.trim()) as Record<string, string>;
const split = (value: string | undefined) => {
const [first, second] = (value ?? "").split(" / ");
return [first?.trim() || "0B", second?.trim() || "0B"];
};
const [memoryUsed, memoryLimit] = split(raw.memory);
const [netInput, netOutput] = split(raw.netIO);
const [blockRead, blockWrite] = split(raw.blockIO);
return {
cpu: raw.cpu,
memoryUsed,
memoryLimit,
memoryPercent: raw.memoryPercent,
netInput,
netOutput,
blockRead,
blockWrite,
pids: raw.pids,
};
}
export interface ContainerState {
/** Docker's own state word: running, exited, restarting, ... */
state: string;
/** Health from the status text, when the image declares a healthcheck. */
unhealthy: boolean;
}
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
export function parseContainerStates(
output: string,
): Map<string, ContainerState> {
const states = new Map<string, ContainerState>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed = JSON.parse(trimmed) as {
name?: string;
state?: string;
status?: string;
};
if (!parsed.name) continue;
states.set(parsed.name, {
state: (parsed.state ?? "").toLowerCase(),
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
});
} catch {
// A partial line is not worth failing the whole poll over.
}
}
return states;
}
/**
* The events a pair of snapshots implies. A container missing from the
* previous snapshot counts as newly seen rather than started, so the first
* poll after a restart does not replay every running container.
*/
export function diffContainerStates(
previous: Map<string, ContainerState>,
current: Map<string, ContainerState>,
): Array<{ container: string; event: DockerEventName }> {
const events: Array<{ container: string; event: DockerEventName }> = [];
for (const [name, now] of current) {
const before = previous.get(name);
if (!before) continue;
if (before.state !== now.state) {
if (now.state === "exited")
events.push({ container: name, event: "exited" });
else if (now.state === "running")
events.push({ container: name, event: "started" });
else if (now.state === "restarting") {
events.push({ container: name, event: "restarting" });
}
}
// A container can go unhealthy while it stays up.
if (!before.unhealthy && now.unhealthy) {
events.push({ container: name, event: "unhealthy" });
}
}
return events;
}
export interface ContainerCounts {
running: number;
stopped: number;
unhealthy: number;
total: number;
}
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
export function countContainers(output: string): ContainerCounts {
const states = [...parseContainerStates(output).values()];
const running = states.filter((s) => s.state === "running").length;
return {
running,
stopped: states.length - running,
unhealthy: states.filter((s) => s.unhealthy).length,
total: states.length,
};
}
// Compose stacks
export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/;
export const COMPOSE_FILES = [
"compose.yaml",
"compose.yml",
"docker-compose.yaml",
"docker-compose.yml",
];
export const STACK_ACTIONS = [
"deploy",
"redeploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
"down",
"destroy",
] as const;
export type StackAction = (typeof STACK_ACTIONS)[number];
/** Actions that can target one service of a stack. */
export const STACK_SERVICE_ACTIONS: StackAction[] = [
"deploy",
"pull",
"start",
"stop",
"restart",
"pause",
"unpause",
];
export type StackState =
"running" | "partial" | "stopped" | "paused" | "not-deployed";
export interface StackSummary {
name: string;
managed: boolean;
state: StackState;
running: number;
total: number;
composeFile: string | null;
}
export interface StackService {
service: string;
name: string;
id: string;
image: string;
state: string;
status: string;
health: string;
ports: string;
}
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
export function parseJsonLines(output: string): Record<string, unknown>[] {
const text = output.trim();
if (!text) return [];
if (text.startsWith("[")) {
try {
return JSON.parse(text) as Record<string, unknown>[];
} catch {
return [];
}
}
return text.split("\n").flatMap((line) => {
try {
return [JSON.parse(line) as Record<string, unknown>];
} catch {
return [];
}
});
}
const str = (value: unknown): string =>
typeof value === "string" ? value : "";
export function parseServices(output: string): StackService[] {
return parseJsonLines(output).map((row) => {
const publishers = Array.isArray(row.Publishers)
? (row.Publishers as Record<string, unknown>[])
: [];
const ports = publishers
.filter((p) => Number(p.PublishedPort) > 0)
.map((p) => `${p.PublishedPort}:${p.TargetPort}`)
.filter((value, index, all) => all.indexOf(value) === index)
.join(", ");
return {
service: str(row.Service),
name: str(row.Name),
id: str(row.ID),
image: str(row.Image),
state: str(row.State),
status: str(row.Status),
health: str(row.Health),
ports,
};
});
}
function stateOf(running: number, paused: number, total: number): StackState {
if (total === 0) return "not-deployed";
if (paused === total) return "paused";
if (running === total) return "running";
if (running > 0) return "partial";
return "stopped";
}
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
export function parseStackStatus(status: string): {
state: StackState;
running: number;
total: number;
} {
let running = 0;
let paused = 0;
let total = 0;
for (const part of status.split(",")) {
const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i);
if (!match) continue;
const count = Number(match[2]);
total += count;
if (match[1] === "running") running += count;
if (match[1] === "paused") paused += count;
}
return { state: stateOf(running, paused, total), running, total };
}
export function stackStateFromServices(services: StackService[]) {
const total = services.length;
const running = services.filter((s) => s.state === "running").length;
const paused = services.filter((s) => s.state === "paused").length;
return { state: stateOf(running, paused, total), running, total };
}
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
export function parseComposeProjects(output: string): StackSummary[] {
return parseJsonLines(output)
.flatMap((project) => {
const name = str(project.Name);
if (!STACK_NAME_RE.test(name)) return [];
return [
{
name,
managed: false,
...parseStackStatus(str(project.Status)),
composeFile: str(project.ConfigFiles).split(",")[0] || null,
},
];
})
.sort((a, b) => a.name.localeCompare(b.name));
}
/** The compose commands one action runs, in order. */
export function composeArgsFor(
action: StackAction,
service: string | undefined,
options: { removeVolumes?: boolean },
): string[] {
const target = service ? ` ${service}` : "";
switch (action) {
case "deploy":
return [`up -d --remove-orphans${target}`];
case "redeploy":
return [`pull${target}`, `up -d --remove-orphans${target}`];
case "pull":
return [`pull${target}`];
case "start":
case "stop":
case "restart":
case "pause":
case "unpause":
return [`${action}${target}`];
case "down":
return ["down --remove-orphans"];
case "destroy":
return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`];
}
}
@@ -1,15 +0,0 @@
/**
* Host scripts: command builders and output parsers for Docker, Linux
* metrics, systemd, the journal and OpenRC.
*
* Pure TypeScript with no Node or ssh2 imports, so the same code runs in a
* plugin backend and in the mobile app. Run a script by sending it on stdin
* to the command from `scriptShell()`.
*/
export * from "./text.js";
export * from "./shell.js";
export * from "./docker.js";
export * from "./metrics.js";
export * from "./systemd.js";
export * from "./journal.js";
export * from "./openrc.js";
@@ -1,242 +0,0 @@
/** journalctl scripts and parsers. */
import { isUnit } from "./systemd.js";
import { HostScriptError, shellQuote as q, utf8Decode } from "./text.js";
export const MAX_JOURNAL_LINES = 5000;
const FIELDS = "MESSAGE,PRIORITY,_SYSTEMD_UNIT,SYSLOG_IDENTIFIER,_PID,_COMM";
export interface JournalQuery {
units: string[];
priority?: number;
since?: string;
until?: string;
boot?: string;
grep?: string;
lines: number;
kernel: boolean;
}
export interface JournalEntry {
/** Unix ms. */
time: number;
priority: number;
unit: string;
ident: string;
pid: string;
message: string;
}
export interface BootView {
offset: number;
id: string;
first: string;
last: string;
}
const RELATIVE_RE = /^-\d{1,5}(s|m|min|h|d|w)$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?$/;
const WORDS = ["today", "yesterday", "now"];
export function isJournalTime(value: string): boolean {
return (
RELATIVE_RE.test(value) || DATE_RE.test(value) || WORDS.includes(value)
);
}
function str(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
/** Reads journal filters from a query string. Anything odd throws a HostScriptError. */
export function parseJournalQuery(
input: Record<string, unknown>,
): JournalQuery {
const rawUnits = Array.isArray(input.unit) ? input.unit : [input.unit];
const units = rawUnits.map(str).filter(Boolean);
for (const unit of units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
}
if (units.length > 20) throw new HostScriptError("Too many units");
const query: JournalQuery = {
units,
lines: 300,
kernel: input.kernel === "true" || input.kernel === true,
};
const priority = str(input.priority);
if (priority) {
const value = Number(priority);
if (!Number.isInteger(value) || value < 0 || value > 7) {
throw new HostScriptError("priority must be 0 to 7");
}
query.priority = value;
}
for (const key of ["since", "until"] as const) {
const value = str(input[key]);
if (!value) continue;
if (!isJournalTime(value))
throw new HostScriptError(`${key} is not a valid time`);
query[key] = value;
}
const boot = str(input.boot);
if (boot) {
if (!/^(-?\d{1,4}|[0-9a-f]{32})$/.test(boot))
throw new HostScriptError("boot is not valid");
query.boot = boot;
}
const grep = typeof input.grep === "string" ? input.grep : "";
if (grep) {
if (grep.length > 200 || /[\r\n\0]/.test(grep)) {
throw new HostScriptError("The search is too long");
}
query.grep = grep;
}
const lines = Number(str(input.lines) || 300);
if (Number.isFinite(lines) && lines > 0) {
query.lines = Math.min(MAX_JOURNAL_LINES, Math.round(lines));
}
return query;
}
/** journalctl arguments for a query, each one quoted. */
export function journalArgs(
query: Omit<JournalQuery, "lines"> & { lines?: number },
options: { follow?: boolean; useGrep?: boolean } = {},
): string {
const args = ["journalctl", "--no-pager", "-q", "-o", "json"];
args.push(`--output-fields=${FIELDS}`);
if (options.follow) args.push("-f");
if (query.lines) args.push("-n", String(Math.floor(query.lines)));
for (const unit of query.units) {
if (!isUnit(unit))
throw new HostScriptError("That is not a valid unit name");
args.push("-u", q(unit));
}
if (query.kernel) args.push("-k");
if (query.priority !== undefined)
args.push("-p", String(Math.floor(query.priority)));
if (query.since) args.push("--since", q(query.since));
if (query.until) args.push("--until", q(query.until));
if (query.boot) args.push("-b", q(query.boot));
if (query.grep && options.useGrep !== false) {
args.push("--case-sensitive=false", "-g", q(query.grep));
}
return args.join(" ");
}
export function journalScript(query: JournalQuery): string {
// Older journalctl builds have no -g. Then the search runs on our side instead.
const noGrep = journalArgs(
{ ...query, lines: query.grep ? MAX_JOURNAL_LINES : query.lines },
{ useGrep: false },
);
return `
command -v journalctl >/dev/null 2>&1 || { echo "SCRIPT_ERR:This host has no journal"; exit 1; }
${journalArgs(query)} 2>/tmp/termix-journal.$$
RC=$?
if [ $RC -ne 0 ] && grep -qi "pattern" /tmp/termix-journal.$$; then
rm -f /tmp/termix-journal.$$
echo "@@TERMIX_NOGREP@@"
${noGrep}
exit 0
fi
cat /tmp/termix-journal.$$ >&2; rm -f /tmp/termix-journal.$$
exit $RC
`;
}
function messageOf(value: unknown): string {
if (typeof value === "string") return value;
if (Array.isArray(value)) {
return utf8Decode(value.filter((n): n is number => typeof n === "number"));
}
return value == null ? "" : String(value);
}
/** One `journalctl -o json` line, or null when it is not an entry. */
export function parseEntry(line: string): JournalEntry | null {
const text = line.trim();
if (!text.startsWith("{")) return null;
let raw: Record<string, unknown>;
try {
raw = JSON.parse(text) as Record<string, unknown>;
} catch {
return null;
}
const micros = Number(raw.__REALTIME_TIMESTAMP);
const priority = Number(raw.PRIORITY);
return {
time: Number.isFinite(micros) ? Math.floor(micros / 1000) : 0,
priority: Number.isInteger(priority) ? priority : 6,
unit: messageOf(raw._SYSTEMD_UNIT),
ident: messageOf(raw.SYSLOG_IDENTIFIER ?? raw._COMM),
pid: messageOf(raw._PID),
message: messageOf(raw.MESSAGE),
};
}
export function parseJournal(
output: string,
query: JournalQuery,
): JournalEntry[] {
const fallback = output.includes("@@TERMIX_NOGREP@@");
const body = fallback ? output.split("@@TERMIX_NOGREP@@")[1] : output;
let entries = body
.split("\n")
.map(parseEntry)
.filter((entry): entry is JournalEntry => entry !== null);
if (fallback && query.grep) {
const needle = query.grep.toLowerCase();
entries = entries
.filter((entry) => entry.message.toLowerCase().includes(needle))
.slice(-query.lines);
}
return entries;
}
export const BOOTS_SCRIPT = `journalctl --list-boots --no-pager 2>/dev/null | tail -n 50; exit 0\n`;
export function parseBoots(output: string): BootView[] {
const boots: BootView[] = [];
for (const line of output.split("\n")) {
const match =
/^\s*(-?\d+)\s+([0-9a-f]{32})\s+(\w{3} \S+ \S+ \S+)\s*.?\s*(\w{3} \S+ \S+ \S+)?/.exec(
line,
);
if (!match) continue;
boots.push({
offset: Number(match[1]),
id: match[2],
first: match[3],
last: match[4] ?? "",
});
}
return boots.reverse();
}
export const USAGE_SCRIPT = `journalctl --disk-usage 2>&1; exit 0\n`;
export function parseUsage(output: string): string {
return /take up (\S+)/.exec(output)?.[1] ?? "";
}
const SIZE_RE = /^\d{1,6}(K|M|G)$/;
const AGE_RE = /^\d{1,4}(d|w|month|months|year|years)$/;
export function vacuumScript(body: Record<string, unknown>): string {
const size = str(body.size);
const time = str(body.time);
if (!size && !time) throw new HostScriptError("Give a size or an age");
if (size && !SIZE_RE.test(size))
throw new HostScriptError("Size looks like 500M or 2G");
if (time && !AGE_RE.test(time))
throw new HostScriptError("Age looks like 7d or 4w");
const flags = [
size ? `--vacuum-size=${size}` : "",
time ? `--vacuum-time=${time}` : "",
]
.filter(Boolean)
.join(" ");
return `journalctl ${flags} 2>&1 || { echo "SCRIPT_ERR:journalctl vacuum failed"; exit 1; }\n`;
}
@@ -1,433 +0,0 @@
/**
* Linux metrics: one awk sample of /proc for CPU, memory, load, network and
* processes, plus df parsing for disks.
*/
// One awk reads every /proc file itself, so a sample is one exec of awk instead
// of a shell plus six tools. Output sections are split by @@; each process line
// is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm.
const AWK = String.raw`BEGIN{f="/proc/stat";while((getline l<f)>0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l<f)>0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l<f;print l;close(f);f="/proc/uptime";getline l<f;print l;close(f);print "@@";f="/proc/net/dev";while((getline l<f)>0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i<ARGC;i++){f=ARGV[i];if((getline s<f)>0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`;
/** The live sample command. The page size never changes on a host, so pass it back once known. */
export const liveCmd = (pageBytes?: number) =>
"awk -v pg=" +
(pageBytes ?? '"$(getconf PAGESIZE)"') +
" '" +
AWK +
"' /proc/[0-9]*/stat";
export interface LiveSample {
/** The admin minimum between samples; the client never polls faster. */
minIntervalS?: number;
cpu: number | null;
cpuMix?: { user: number; system: number; iowait: number; steal: number };
cores: Array<number | null>;
coreIds: string[];
memTotalKb: number;
memCachedKb: number;
memFreeKb: number;
memUsedKb: number;
swapTotalKb: number;
swapUsedKb: number;
load: [number, number, number];
net: Array<{ name: string; rxBps: number | null; txBps: number | null }>;
count: number;
running: number;
procs: Array<{
pid: number;
name: string;
cpu: number;
memKb: number;
state: string;
ppid: number;
}>;
/** Seconds since boot. */
uptime?: number;
/** Total bytes received and sent on every interface but lo, since boot. */
netTotals?: { rx: number; tx: number };
}
/** What the next call needs from this one. */
export interface LivePrev {
at: number;
uptime: number;
pageBytes: number;
cpu: Map<string, { total: number; idle: number }>;
/** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */
cpuRaw?: number[];
/** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */
ticks: Map<string, number>;
net: Map<string, { rx: number; tx: number }>;
}
/** Total and idle ticks from a /proc/stat `cpu` line. */
export function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
const TOP_N = 60;
/** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
export function parseLive(
output: string,
prev: LivePrev | undefined,
at = Date.now(),
): { sample: LiveSample; next: LivePrev } {
const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] =
output.split("@@\n");
// Only the first 8 fields: guest and guest_nice are already inside user and nice.
const cpu = new Map<string, { total: number; idle: number }>();
let cpuRaw: number[] | undefined;
for (const l of cpuS.split("\n")) {
const name = l.split(/\s+/)[0];
if (!name.startsWith("cpu")) continue;
if (name === "cpu") cpuRaw = l.split(/\s+/).slice(1, 9).map(Number);
const c = parseCpuLine(
l
.replace(/^cpu\d*/, "cpu")
.split(/\s+/)
.slice(0, 9)
.join(" "),
);
if (c) cpu.set(name, c);
}
// Matched by name, so an offline core does not shift the ones after it.
const pct = (name: string) => {
const p = prev?.cpu.get(name);
const c = cpu.get(name);
if (!p || !c || c.total <= p.total) return null;
const used = c.total - p.total - (c.idle - p.idle);
return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100));
};
// Where the busy time went since the last sample, as percent of all ticks.
let cpuMix: LiveSample["cpuMix"];
if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) {
const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0));
const total = d.reduce((a, b) => a + b, 0);
if (total > 0 && d.every((v) => v >= 0)) {
const p = (n: number) => (n / total) * 100;
cpuMix = {
user: p(d[0] + d[1]),
system: p(d[2] + d[5] + d[6]),
iowait: p(d[4]),
steal: p(d[7]),
};
}
}
const mem: Record<string, number> = {};
for (const l of memS.split("\n")) {
const m = l.match(/^(\w+):\s+(\d+)/);
if (m) mem[m[1]] = Number(m[2]);
}
const [la = "", up = ""] = loadS.trim().split("\n");
const laParts = la.split(/\s+/).map(Number);
const uptime = Number(up.split(/\s+/)[0]) || 0;
const nets = new Map<string, { rx: number; tx: number }>();
for (const l of netS.split("\n")) {
const [name, rest] = l.split(":");
if (!rest || name.trim() === "lo") continue;
const f = rest.trim().split(/\s+/);
nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 });
}
// A reboot makes uptime go backwards, so rates start again.
const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0;
const rate = (now: number, before: number | undefined) =>
dt > 0 && before !== undefined && now >= before
? (now - before) / dt
: null;
const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096;
const pageKb = pageBytes / 1024;
const aggDelta =
prev && cpu.has("cpu") && prev.cpu.has("cpu")
? cpu.get("cpu")!.total - prev.cpu.get("cpu")!.total
: 0;
const cores = [...cpu.keys()].filter((k) => k !== "cpu");
const ncpu = Math.max(1, cores.length);
const ticks = new Map<string, number>();
const procs: LiveSample["procs"] = [];
for (const l of procS.split("\n")) {
const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t");
const name = rest.join("\t");
if (!name) continue;
const key = pid + ":" + start;
const tk = Number(t) || 0;
ticks.set(key, tk);
const before = prev?.ticks.get(key);
procs.push({
pid: Number(pid),
name,
cpu:
before !== undefined && aggDelta > 0 && tk >= before
? ((tk - before) / aggDelta) * ncpu * 100
: 0,
memKb: (Number(rss) || 0) * pageKb,
state: state || "?",
ppid: Number(ppid) || 0,
});
}
procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb);
// Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample.
if (!cpu.has("cpu") || !mem.MemTotal || !uptime) {
throw new Error("live sample unavailable: /proc data missing");
}
const memTotalKb = mem.MemTotal;
const swapTotalKb = mem.SwapTotal ?? 0;
let rxTotal = 0;
let txTotal = 0;
for (const c of nets.values()) {
rxTotal += c.rx;
txTotal += c.tx;
}
return {
sample: {
cpu: pct("cpu"),
cores: cores.map(pct),
coreIds: cores.map((k) => k.slice(3)),
cpuMix,
memTotalKb,
memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0),
memFreeKb: mem.MemFree ?? 0,
memUsedKb: memTotalKb - (mem.MemAvailable ?? 0),
swapTotalKb,
swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0),
load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0],
net: [...nets].map(([name, c]) => ({
name,
rxBps: rate(c.rx, prev?.net.get(name)?.rx),
txBps: rate(c.tx, prev?.net.get(name)?.tx),
})),
count: procs.length,
running: procs.filter((p) => p.state === "R").length,
procs: procs.slice(0, TOP_N),
uptime,
netTotals: { rx: rxTotal, tx: txTotal },
},
next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets },
};
}
// Disks
const DARWIN_DATA_MOUNT = "/System/Volumes/Data";
const PSEUDO_FS_RE =
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
/** GNU df sizes in KiB, without the header. */
export const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
/** GNU df sizes as short human text, without the header. */
export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
export interface DfRow {
filesystem: string;
type: string;
mount: string;
parts: string[];
}
export interface DiskFilesystem {
filesystem: string;
type: string;
mount: string;
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
usedBytes: number | null;
totalBytes: number | null;
availableBytes: number | null;
label?: string;
}
export interface MonitoredMount {
path: string;
label?: string;
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
/**
* Parses `df -T -P` style output: Filesystem, Type, then the size columns,
* with Mounted-on last. Pseudo filesystems are left out.
*/
export function parseDfLines(output: string): DfRow[] {
return output
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.map((line) => {
const parts = line.split(/\s+/);
return {
filesystem: parts[0] || "",
type: parts[1] || "",
mount: parts[6] || "",
parts,
};
})
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
}
/** The most used real filesystem in a `df -TkP` row set. */
export function findWorstMountIndex(bytesRows: DfRow[]): {
index: number;
usedBytes: number;
totalBytes: number;
} {
let worstIndex = -1;
let worstUsedBytes = -1;
let worstTotalBytes = 0;
bytesRows.forEach((row, index) => {
const totalBytes = Number(row.parts[2]);
const usedBytes = Number(row.parts[3]);
if (
!Number.isFinite(totalBytes) ||
!Number.isFinite(usedBytes) ||
totalBytes <= 0
) {
return;
}
const usedRatio = usedBytes / totalBytes;
const worstRatio =
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
if (usedRatio > worstRatio) {
worstIndex = index;
worstUsedBytes = usedBytes;
worstTotalBytes = totalBytes;
}
});
return {
index: worstIndex,
usedBytes: worstUsedBytes,
totalBytes: worstTotalBytes,
};
}
/**
* Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
* `df -hT -P` human rows into one list. Human rows only supply the display
* text, matched by mount point so a mismatched row count can't shift them.
*/
export function buildFilesystemList(
bytesRows: DfRow[],
humanRows: DfRow[],
blockSizeBytes = 1,
): DiskFilesystem[] {
const aligned = humanRows.length === bytesRows.length;
return bytesRows
.map((row, index) => {
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
const humanRow = aligned
? humanRows[index]
: humanRows.find((h) => h.mount === row.mount);
const percent = Number.isFinite(usedBytes)
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
: null;
return {
filesystem: row.filesystem,
type: row.type,
mount: row.mount,
percent: toFixedNum(percent, 0),
usedHuman: humanRow?.parts[3] || null,
totalHuman: humanRow?.parts[2] || null,
availableHuman: humanRow?.parts[4] || null,
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
totalBytes,
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
};
})
.filter((fs): fs is DiskFilesystem => fs !== null);
}
/**
* The headline disk is the root filesystem. On macOS "/" is the small sealed
* system volume, so the data volume wins there. With no root mount the most
* used one is shown.
*/
export function selectPrimaryFilesystem(
filesystems: DiskFilesystem[],
): DiskFilesystem | null {
if (filesystems.length === 0) return null;
const root =
filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ??
filesystems.find((fs) => fs.mount === "/");
if (root) return root;
let best = filesystems[0];
for (const fs of filesystems) {
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
if (ratio > bestRatio) best = fs;
}
return best;
}
/**
* Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
* ("nfs" matches nfs and nfs4), so network shares can be hidden.
*/
export function filterExcludedFilesystems(
filesystems: DiskFilesystem[],
excludedMounts?: string[] | null,
): DiskFilesystem[] {
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
const normalized = excludedMounts
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean);
if (normalized.length === 0) return filesystems;
return filesystems.filter((fs) => {
const mount = fs.mount.toLowerCase();
const type = fs.type.toLowerCase();
return !normalized.some(
(entry) => mount === entry || (type && type.includes(entry)),
);
});
}
export function mergeMonitoredFilesystems(
filesystems: DiskFilesystem[],
monitored: MonitoredMount[],
customFilesystems: Array<DiskFilesystem | null>,
): DiskFilesystem[] {
const result = [...filesystems];
monitored.forEach((entry, index) => {
const path = entry.path.trim();
const custom = customFilesystems[index];
if (!path || !custom) return;
const existing = result.find((fs) => fs.mount === path);
if (existing) {
existing.label = entry.label?.trim() || undefined;
return;
}
result.push({
...custom,
mount: path,
label: entry.label?.trim() || undefined,
});
});
return result;
}
/** Host name, OS, kernel and arch in one exec, as key=value lines. */
export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`;
@@ -1,94 +0,0 @@
/** OpenRC scripts, with states mapped onto the words systemd uses. */
import { isOpenRcName, type UnitAction, type UnitSummary } from "./systemd.js";
import { HostScriptError, shellQuote as q } from "./text.js";
export const OPENRC_LIST_SCRIPT = `
rc-status --servicelist 2>/dev/null
echo "@@TERMIX_RUNLEVELS@@"
rc-update show -v 2>/dev/null
exit 0
`;
function activeOf(state: string): { active: string; sub: string } {
switch (state) {
case "started":
return { active: "active", sub: "running" };
case "crashed":
return { active: "failed", sub: "crashed" };
case "starting":
return { active: "activating", sub: "starting" };
case "stopping":
return { active: "deactivating", sub: "stopping" };
case "inactive":
case "hotplugged":
return { active: "inactive", sub: state };
default:
return { active: "inactive", sub: state || "stopped" };
}
}
export function parseOpenRcList(output: string): UnitSummary[] {
const [list = "", levels = ""] = output.split("@@TERMIX_RUNLEVELS@@");
const units = new Map<string, UnitSummary>();
for (const line of list.split("\n")) {
const match = /^\s*(\S+)\s+\[\s*([a-z ]+?)\s*\]/.exec(line);
if (!match) continue;
const { active, sub } = activeOf(match[2]);
units.set(match[1], {
name: match[1],
type: "service",
load: "loaded",
active,
sub,
description: "",
fileState: "disabled",
preset: "",
});
}
for (const line of levels.split("\n")) {
const match = /^\s*(\S+)\s*\|\s*(.*)$/.exec(line);
if (!match) continue;
const runlevels = match[2].trim();
const known = units.get(match[1]) ?? {
name: match[1],
type: "service",
load: "loaded",
active: "inactive",
sub: "stopped",
description: "",
fileState: "disabled",
preset: "",
};
known.fileState = runlevels ? "enabled" : "disabled";
known.description = runlevels ? `Runlevels: ${runlevels}` : "";
units.set(match[1], known);
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
function requireName(name: string): string {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
export function openRcActionScript(name: string, action: UnitAction): string {
const s = q(requireName(name));
switch (action) {
case "start":
case "stop":
case "restart":
return `rc-service ${s} ${action} 2>&1 || { echo "SCRIPT_ERR:rc-service ${action} failed"; exit 1; }\n`;
case "enable":
return `rc-update add ${s} default 2>&1 || { echo "SCRIPT_ERR:rc-update add failed"; exit 1; }\n`;
case "disable":
return `rc-update del ${s} 2>&1 || { echo "SCRIPT_ERR:rc-update del failed"; exit 1; }\n`;
default:
throw new HostScriptError("OpenRC can't do that", 400);
}
}
export function openRcStatusScript(name: string): string {
const s = q(requireName(name));
return `rc-service ${s} status 2>&1; rc-update show -v 2>/dev/null | grep -E '^ *'${s}' *\\|'; exit 0\n`;
}
@@ -1,117 +0,0 @@
/**
* How a script reaches the host: the command line runs a shell that reads
* the script from stdin (`bash -s`), so names and values in a script are
* never parsed out of argv.
*/
import { shellQuote, toBase64 } from "./text.js";
// systemctl and friends often live in sbin, which a normal login skips.
// TZ=UTC so tools print every time in UTC, which parses the same anywhere.
const ENV = "SYSTEMD_COLORS=0 SYSTEMD_PAGER=cat LC_ALL=C TZ=UTC";
/** Puts sbin on PATH and sets a plain, UTC environment. */
export const SCRIPT_PRELUDE = `export PATH="$PATH:/usr/sbin:/sbin:/usr/local/sbin"; export ${ENV}; `;
const SUDO = `sudo -n env PATH="$PATH" ${ENV}`;
/**
* - `plain`: the login user.
* - `auto`: root when it can be had without a password, else the login user.
* - `root`: root or passwordless sudo, fails on anything else.
* - `root-only`: root logins only, for hosts where sudo is turned off.
*/
export type ScriptShellMode = "plain" | "auto" | "root" | "root-only";
const ERR = "SCRIPT_ERR:";
/** The command line that runs a script sent on stdin. */
export function scriptShell(mode: ScriptShellMode): string {
switch (mode) {
case "plain":
return `${SCRIPT_PRELUDE}exec bash -s`;
case "auto":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -s; else exec bash -s; fi`;
case "root":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else exec ${SUDO} bash -s; fi`;
case "root-only":
return `${SCRIPT_PRELUDE}if [ "$(id -u)" = "0" ]; then exec bash -s; else echo "${ERR}This needs root, or turn on Use sudo for this host"; exit 1; fi`;
}
}
/**
* Runs a script as root with a sudo password. The command is `plain`; all of
* the returned stdin goes to the host. The password is read by the first
* line into a shell variable and piped to `sudo -S`, so it is never in argv,
* never in the environment and never run as a command. Root logins and
* passwordless sudo skip the password. The script itself travels as base64.
*/
export function passwordSudoScript(
script: string,
password: string,
): { command: string; stdin: string } {
if (/[\r\n\0]/.test(password)) {
throw new Error("A sudo password can't contain a line break");
}
const run = `bash -c "$TERMIX_S"`;
const stdin = `IFS= read -r TERMIX_PW
${password}
TERMIX_S=$(printf '%s' ${shellQuote(toBase64(script))} | base64 -d)
if [ "$(id -u)" = "0" ]; then unset TERMIX_PW; exec ${run}
elif sudo -n true 2>/dev/null; then unset TERMIX_PW; exec ${SUDO} ${run}
else P=$TERMIX_PW; unset TERMIX_PW; printf '%s\\n' "$P" | sudo -k -S -p '' env PATH="$PATH" ${ENV} ${run}; exit $?
fi
`;
return { command: scriptShell("plain"), stdin };
}
/** sudo asked for a password or a terminal it did not get. */
export const SUDO_REFUSED_RE =
/password is required|a terminal is required|no tty present/i;
/** sudo got a password and it was wrong. */
export const SUDO_WRONG_RE =
/incorrect password|sorry, try again|no password was provided/i;
/** The user may not use sudo at all. */
export const NOT_SUDOER_RE =
/is not in the sudoers file|not allowed to run sudo/i;
/**
* Scripts print `SCRIPT_ERR:` lines (or the older `SERVICES_ERR:`) before a
* non zero exit to explain a failure.
*/
export function scriptError(stdout: string): string {
return stdout
.split("\n")
.map((line) => {
if (line.startsWith(ERR)) return line.slice(ERR.length);
if (line.startsWith("SERVICES_ERR:")) return line.slice(13);
return null;
})
.filter((line): line is string => line !== null)
.join("\n")
.trim();
}
/** A line a script prints to explain why it stops. */
export function errLine(message: string): string {
return `echo ${shellQuote(ERR + message)}`;
}
/**
* Runs a long command in the background and stops it once stdin closes,
* which happens when the SSH channel ends. A pty can't be trusted for this:
* sudo keeps its own and the command would outlive the viewer.
*/
export function watchdog(command: string): string {
return `${command} &
P=$!
trap 'kill $P 2>/dev/null' EXIT
cat >/dev/null
`;
}
/** The wrapper a streamed command runs in, as one shell command line. */
export function streamCommand(script: string, sudo: boolean): string {
const quoted = shellQuote(`${SCRIPT_PRELUDE}${script}`);
return sudo
? `if [ "$(id -u)" = "0" ]; then exec bash -c ${quoted}; elif sudo -n true 2>/dev/null; then exec ${SUDO} bash -c ${quoted}; else exec bash -c ${quoted}; fi`
: `exec bash -c ${quoted}`;
}
@@ -1,389 +0,0 @@
/**
* systemd unit scripts and parsers, plus init system detection.
* Every script is meant for `scriptShell()`, sent on stdin.
*/
import { HostScriptError, parseKeys, shellQuote as q } from "./text.js";
export const UNIT_TYPES = [
"service",
"socket",
"timer",
"target",
"mount",
"automount",
"path",
"swap",
"slice",
"scope",
"device",
] as const;
export type UnitType = (typeof UNIT_TYPES)[number];
// Backslash is allowed for escaped names like dev-disk-by\x2duuid.
const UNIT_RE = new RegExp(
`^[A-Za-z0-9_][A-Za-z0-9:_.@\\\\-]{0,250}\\.(${UNIT_TYPES.join("|")})$`,
);
const OPENRC_RE = /^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,127}$/;
export function isUnit(name: unknown): name is string {
return typeof name === "string" && UNIT_RE.test(name);
}
export function unitType(name: string): UnitType {
return name.slice(name.lastIndexOf(".") + 1) as UnitType;
}
/** Units a file may be written for. Templates and device style names are out. */
export function isWritableUnitName(name: string): boolean {
return (
isUnit(name) &&
!name.includes("\\") &&
["service", "socket", "timer", "target", "path", "mount"].includes(
unitType(name),
)
);
}
export function isOpenRcName(name: unknown): name is string {
return typeof name === "string" && OPENRC_RE.test(name);
}
export function requireUnit(name: unknown): string {
if (!isUnit(name)) throw new HostScriptError("That is not a valid unit name");
return name;
}
export type InitSystem = "systemd" | "openrc" | "none";
/** A unit name or, on OpenRC, a service name. */
export function requireService(name: unknown, init: string): string {
if (init === "openrc") {
if (!isOpenRcName(name))
throw new HostScriptError("That is not a valid service name");
return name;
}
return requireUnit(name);
}
export interface UnitSummary {
name: string;
type: string;
load: string;
active: string;
sub: string;
description: string;
/** enabled, disabled, static, masked and so on. Empty when unknown. */
fileState: string;
preset: string;
}
export interface UnitDetail {
name: string;
props: Record<string, string>;
status: string;
}
export interface TimerView {
name: string;
description: string;
active: string;
fileState: string;
/** Unix ms, or null when not set. */
next: number | null;
last: number | null;
activates: string;
schedule: string[];
}
export const UNIT_ACTIONS = [
"start",
"stop",
"restart",
"reload",
"enable",
"disable",
"enable-now",
"disable-now",
"mask",
"unmask",
"reset-failed",
"revert",
] as const;
export type UnitAction = (typeof UNIT_ACTIONS)[number];
/** Actions OpenRC can do. */
export const OPENRC_ACTIONS: readonly UnitAction[] = [
"start",
"stop",
"restart",
"enable",
"disable",
];
export const DETECT_SCRIPT = `
INIT=none
if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then INIT=systemd
elif command -v rc-service >/dev/null 2>&1; then INIT=openrc; fi
echo "init=$INIT"
if [ "$INIT" = systemd ]; then echo "version=$(systemctl --version 2>/dev/null | head -n1 | awk '{print $2}')"; fi
if [ "$INIT" = openrc ]; then echo "version=$(openrc --version 2>/dev/null | head -n1 | awk '{print $NF}')"; fi
echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"
echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"
echo "uid=$(id -u)"
if command -v journalctl >/dev/null 2>&1; then echo "journal=1"; fi
if [ "$INIT" = systemd ]; then echo "failed=$(systemctl list-units --state=failed --plain --no-legend --no-pager 2>/dev/null | wc -l)"; fi
if [ "$INIT" = openrc ]; then echo "failed=$(rc-status --crashed 2>/dev/null | wc -l)"; fi
exit 0
`;
export interface Detected {
init: InitSystem;
version: string;
os: string;
hostname: string;
root: boolean;
journal: boolean;
failed: number;
}
export function parseDetect(output: string): Detected {
const keys = parseKeys(output);
const init =
keys.init === "systemd" || keys.init === "openrc" ? keys.init : "none";
return {
init,
version: keys.version ?? "",
os: keys.os ?? "",
hostname: keys.hostname ?? "",
root: keys.uid === "0",
journal: keys.journal === "1",
failed: Number(keys.failed) || 0,
};
}
/** Types the list can show. */
export const LIST_TYPES = [
"service",
"socket",
"timer",
"target",
"path",
"mount",
] as const;
export type ListType = (typeof LIST_TYPES)[number] | "all";
export function listUnitsScript(type: ListType): string {
const types = type === "all" ? LIST_TYPES.join(",") : type;
if (type !== "all" && !LIST_TYPES.includes(type))
throw new HostScriptError("Unknown unit type");
return `
systemctl list-units --all --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
echo "@@TERMIX_FILES@@"
systemctl list-unit-files --plain --no-legend --no-pager --full --type=${types} 2>/dev/null
exit 0
`;
}
/** Merges list-units and list-unit-files. Template units are left out. */
export function parseUnitList(output: string): UnitSummary[] {
const [loaded = "", files = ""] = output.split("@@TERMIX_FILES@@");
const units = new Map<string, UnitSummary>();
for (const raw of loaded.split("\n")) {
const line = raw.replace(/^[●*\s]+/, "").trimEnd();
if (!line) continue;
const match = /^(\S+)\s+(\S+)\s+(\S+)\s+(\S+)\s*(.*)$/.exec(line);
if (!match) continue;
const [, name, load, active, sub, description] = match;
if (!name.includes(".") || name.includes("@.")) continue;
units.set(name, {
name,
type: unitType(name),
load,
active,
sub,
description: description.trim(),
fileState: "",
preset: "",
});
}
for (const raw of files.split("\n")) {
const line = raw.trim();
if (!line) continue;
const [name, state = "", preset = ""] = line.split(/\s+/);
if (!name || !name.includes(".") || name.includes("@.")) continue;
const known = units.get(name);
const cleanPreset = preset === "-" ? "" : preset;
if (known) {
known.fileState = state;
known.preset = cleanPreset;
} else {
units.set(name, {
name,
type: unitType(name),
load: state === "masked" ? "masked" : "unloaded",
active: "inactive",
sub: "dead",
description: "",
fileState: state,
preset: cleanPreset,
});
}
}
return [...units.values()].sort((a, b) => a.name.localeCompare(b.name));
}
export const DETAIL_PROPS = [
"Id",
"Description",
"LoadState",
"ActiveState",
"SubState",
"UnitFileState",
"UnitFilePreset",
"Result",
"MainPID",
"ExecMainStartTimestamp",
"ActiveEnterTimestamp",
"InactiveEnterTimestamp",
"MemoryCurrent",
"MemoryPeak",
"CPUUsageNSec",
"TasksCurrent",
"NRestarts",
"FragmentPath",
"DropInPaths",
"Requires",
"Wants",
"After",
"WantedBy",
"TriggeredBy",
"Triggers",
"User",
"Group",
"Restart",
"Type",
"ExecStart",
"CanStart",
"CanStop",
"CanReload",
] as const;
export function detailScript(unit: string): string {
const props = DETAIL_PROPS.map((p) => `-p ${p}`).join(" ");
return `
systemctl show --no-pager ${props} -- ${q(requireUnit(unit))}
echo "@@TERMIX_STATUS@@"
systemctl status --no-pager --full -n 0 -- ${q(unit)} 2>&1
exit 0
`;
}
export function parseDetail(unit: string, output: string): UnitDetail {
const [props = "", status = ""] = output.split("@@TERMIX_STATUS@@");
return { name: unit, props: parseKeys(props), status: status.trim() };
}
const VERBS: Record<UnitAction, string> = {
start: "start",
stop: "stop",
restart: "restart",
reload: "reload",
enable: "enable",
disable: "disable",
"enable-now": "enable --now",
"disable-now": "disable --now",
mask: "mask",
unmask: "unmask",
"reset-failed": "reset-failed",
revert: "revert",
};
/** The systemctl call for an action. Prints `state=` after it. */
export function actionScript(unit: string, action: UnitAction): string {
const verb = VERBS[action];
if (!verb) throw new HostScriptError("Unknown action");
const u = q(requireUnit(unit));
return `
systemctl ${verb} -- ${u} 2>&1 || { echo "SCRIPT_ERR:systemctl ${verb} failed"; exit 1; }
echo "state=$(systemctl is-active -- ${u} 2>/dev/null)"
`;
}
/** Times systemctl prints under TZ=UTC, like "Thu 2026-10-22 02:03:51 UTC". */
export function parseSystemdTime(value: string | undefined): number | null {
if (!value) return null;
const match = /(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})/.exec(value);
if (!match) return null;
const [, y, mo, d, h, mi, s] = match.map(Number);
return Date.UTC(y, mo - 1, d, h, mi, s);
}
export const TIMERS_SCRIPT = `
UNITS=$( { systemctl list-units --all --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; systemctl list-unit-files --type=timer --plain --no-legend --no-pager --full 2>/dev/null | awk '{print $1}'; } | grep '\\.timer$' | grep -v '@\\.timer$' | sort -u)
[ -n "$UNITS" ] || exit 0
systemctl show --no-pager -p Id -p Description -p ActiveState -p UnitFileState -p NextElapseUSecRealtime -p LastTriggerUSec -p Triggers -p TimersCalendar -p TimersMonotonic -- $UNITS
exit 0
`;
/** Reads systemctl show output for many units, split by blank lines. */
export function parseShowBlocks(output: string): Array<Record<string, string>> {
const blocks: Array<Record<string, string>> = [];
for (const block of output.split(/\n\s*\n/)) {
const lines = block.split("\n").filter((line) => line.includes("="));
if (!lines.length) continue;
// A unit can have several TimersCalendar lines; keep all of them.
const keys: Record<string, string> = {};
for (const line of lines) {
const eq = line.indexOf("=");
const key = line.slice(0, eq).trim();
const value = line.slice(eq + 1).trim();
keys[key] = key in keys ? `${keys[key]}\n${value}` : value;
}
blocks.push(keys);
}
return blocks;
}
function schedules(value: string | undefined): string[] {
if (!value) return [];
const out: string[] = [];
for (const line of value.split("\n")) {
const match = /\{\s*(\w+)=(.+?)\s*;/.exec(line);
if (match) out.push(`${match[1]}=${match[2]}`);
}
return out;
}
export function parseTimers(output: string): TimerView[] {
return parseShowBlocks(output)
.filter((keys) => keys.Id?.endsWith(".timer"))
.map((keys) => ({
name: keys.Id,
description: keys.Description ?? "",
active: keys.ActiveState ?? "",
fileState: keys.UnitFileState ?? "",
next: parseSystemdTime(keys.NextElapseUSecRealtime),
last: parseSystemdTime(keys.LastTriggerUSec),
activates: (keys.Triggers ?? "").split(/\s+/)[0] ?? "",
schedule: [
...schedules(keys.TimersCalendar),
...schedules(keys.TimersMonotonic),
],
}))
.sort((a, b) => (a.next ?? Infinity) - (b.next ?? Infinity));
}
/** One line per unit from systemctl is-active, in the order asked. */
export function isActiveScript(units: string[]): string {
return `systemctl is-active -- ${units.map((u) => q(requireUnit(u))).join(" ")} 2>/dev/null; exit 0\n`;
}
export function parseIsActive(
units: string[],
output: string,
): Map<string, string> {
const lines = output.split("\n").map((line) => line.trim());
const out = new Map<string, string>();
units.forEach((unit, i) => out.set(unit, lines[i] || "unknown"));
return out;
}
@@ -1,118 +0,0 @@
/**
* Quoting, encoding and the error type the host scripts share.
*
* No Node APIs here: this entry also runs in the mobile app.
*/
/** A bad input to a host script builder. `status` is the HTTP status to answer with. */
export class HostScriptError extends Error {
constructor(
message: string,
readonly status = 400,
readonly extra: Record<string, unknown> = {},
) {
super(message);
this.name = "HostScriptError";
}
}
/** Wraps a value in single quotes for a POSIX shell. */
export function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
// Written by hand because some JS engines (Hermes) have no TextDecoder.
/** UTF-8 bytes of a string. */
export function utf8Encode(text: string): Uint8Array {
const out: number[] = [];
for (const char of text) {
const cp = char.codePointAt(0) ?? 0xfffd;
if (cp < 0x80) out.push(cp);
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 63));
else if (cp < 0x10000) {
out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 63), 0x80 | (cp & 63));
} else {
out.push(
0xf0 | (cp >> 18),
0x80 | ((cp >> 12) & 63),
0x80 | ((cp >> 6) & 63),
0x80 | (cp & 63),
);
}
}
return Uint8Array.from(out);
}
/** A string from UTF-8 bytes. Bad sequences become U+FFFD. */
export function utf8Decode(bytes: ArrayLike<number>): string {
let out = "";
let i = 0;
while (i < bytes.length) {
const b = bytes[i];
let need = 0;
let cp = 0;
if (b < 0x80) cp = b;
else if (b >= 0xc2 && b < 0xe0) [need, cp] = [1, b & 31];
else if (b >= 0xe0 && b < 0xf0) [need, cp] = [2, b & 15];
else if (b >= 0xf0 && b < 0xf5) [need, cp] = [3, b & 7];
else {
out += "\ufffd";
i++;
continue;
}
let j = 1;
for (; j <= need; j++) {
const next = bytes[i + j];
if (next === undefined || (next & 0xc0) !== 0x80) break;
cp = (cp << 6) | (next & 63);
}
const tooSmall = [0, 0x80, 0x800, 0x10000][need];
if (
j <= need ||
cp < tooSmall ||
cp > 0x10ffff ||
(cp >= 0xd800 && cp < 0xe000)
) {
out += "\ufffd";
i += Math.max(1, j);
continue;
}
out += String.fromCodePoint(cp);
i += need + 1;
}
return out;
}
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/** Base64 of the UTF-8 bytes of a string, for `base64 -d` on the host. */
export function toBase64(text: string): string {
const bytes = utf8Encode(text);
let out = "";
for (let i = 0; i < bytes.length; i += 3) {
const a = bytes[i];
const b = i + 1 < bytes.length ? bytes[i + 1] : 0;
const c = i + 2 < bytes.length ? bytes[i + 2] : 0;
const n = (a << 16) | (b << 8) | c;
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63];
out += i + 1 < bytes.length ? B64[(n >> 6) & 63] : "=";
out += i + 2 < bytes.length ? B64[n & 63] : "=";
}
return out;
}
/** Reads key=value lines. Only the first = splits a line. */
export function parseKeys(output: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of output.split("\n")) {
const eq = line.indexOf("=");
if (eq > 0) out[line.slice(0, eq).trim()] = line.slice(eq + 1).trim();
}
return out;
}
/** Trims output and keeps it short enough to show. */
export function scrub(text: string, max = 600): string {
return text.trim().slice(0, max);
}
+1 -157
View File
@@ -37,7 +37,7 @@ export {
* "^1.2" needs 1.2 or later). engine.termix is checked against the core
* version by the server.
*/
export const PLUGIN_API_VERSION = "1.13.0";
export const PLUGIN_API_VERSION = "1.9.0";
/** The API major, the default engine.api for a new plugin. */
export const SUPPORTED_PLUGIN_API_VERSION = String(
@@ -526,36 +526,6 @@ export interface PluginContributions {
layouts?: PluginLayoutContribution[];
/** Themes. Needs the ui:theme capability. */
themes?: PluginThemeContribution[];
/** How the plugin shows in the Termix mobile app. Plugin API 1.13. */
mobile?: PluginMobileContribution;
}
/**
* - "webview": the app shows the plugin's UI from the server in a WebView.
* - "native": the app has its own screens for this plugin.
* - "none": nothing to show in the app.
*/
export const MOBILE_MODES = ["webview", "native", "none"] as const;
export type PluginMobileMode = (typeof MOBILE_MODES)[number];
export const MOBILE_SIZES = ["phone", "tablet"] as const;
export type PluginMobileSize = (typeof MOBILE_SIZES)[number];
export interface PluginMobileContribution {
mode: PluginMobileMode;
/**
* webview: a bundle built for the app, in the same folder as the frontend
* bundle. Unset means the normal frontend runs in a phone sized frame.
*/
entry?: string;
/** webview: screen sizes the UI works at. Defaults to both. */
sizes?: PluginMobileSize[];
/** webview: tab, panel or card ids the app offers. Defaults to every tab. */
views?: string[];
/** native: the app feature that stands in for it. Defaults to the plugin id. */
feature?: string;
/** i18n key in the plugin's namespace, or plain text, shown in the app. */
note?: string;
}
/** A field a protocol login carries besides username and password. */
@@ -795,7 +765,6 @@ const ALLOWED_CONTRIBUTES = new Set([
"protocols",
"layouts",
"themes",
"mobile",
]);
const ALLOWED_SETTINGS_FIELD = [
@@ -977,135 +946,10 @@ export function validateManifest(manifest: unknown): string[] {
typeof m.id === "string" ? m.id : undefined,
errors,
);
if (isPlainObject(m.contributes)) {
validateMobile(
m.contributes,
typeof m.frontend === "string" ? m.frontend : DEFAULT_FRONTEND_ENTRY,
errors,
);
}
return errors;
}
function folderOf(file: string): string {
const parts = file.split("/").filter((part) => part !== "" && part !== ".");
return parts.slice(0, -1).join("/");
}
function validateMobile(
contributes: Record<string, unknown>,
frontend: string,
errors: string[],
): void {
const mobile = contributes.mobile;
if (mobile === undefined) return;
const where = "contributes.mobile";
if (!isPlainObject(mobile)) {
errors.push(`Field "${where}" must be an object`);
return;
}
rejectUnknown(
mobile,
["mode", "entry", "sizes", "views", "feature", "note"],
`"${where}"`,
errors,
);
const mode = mobile.mode;
if (!(MOBILE_MODES as readonly unknown[]).includes(mode)) {
errors.push(`${where}.mode must be one of: ${MOBILE_MODES.join(", ")}`);
return;
}
for (const key of ["entry", "sizes", "views"]) {
if (mobile[key] !== undefined && mode !== "webview") {
errors.push(`${where}.${key} is only for mode "webview"`);
}
}
if (mobile.feature !== undefined && mode !== "native") {
errors.push(`${where}.feature is only for mode "native"`);
}
if (
mobile.entry !== undefined &&
requireString(mobile.entry, `${where}.entry`, errors)
) {
const before = errors.length;
requireRelativePath(mobile.entry, `${where}.entry`, errors);
if (!mobile.entry.endsWith(".js")) {
errors.push(`${where}.entry must be a .js file`);
} else if (
errors.length === before &&
folderOf(mobile.entry) !== folderOf(frontend)
) {
// Core only serves the folder the frontend bundle sits in.
errors.push(
`${where}.entry must sit in the same folder as the frontend bundle (${frontend})`,
);
}
}
if (mobile.sizes !== undefined) {
const sizes = mobile.sizes;
if (!Array.isArray(sizes) || sizes.length === 0) {
errors.push(`${where}.sizes must be a non-empty array`);
} else {
const seen = new Set<unknown>();
for (const size of sizes) {
if (!(MOBILE_SIZES as readonly unknown[]).includes(size)) {
errors.push(
`${where}.sizes entries must be one of: ${MOBILE_SIZES.join(", ")}`,
);
} else if (seen.has(size)) {
errors.push(`${where}.sizes duplicates "${String(size)}"`);
}
seen.add(size);
}
}
}
if (mobile.views !== undefined) {
const views = mobile.views;
if (!Array.isArray(views) || views.length === 0) {
errors.push(`${where}.views must be a non-empty array`);
} else {
const declared = new Set<string>();
for (const field of ["tabs", "panels", "dashboardCards"]) {
const list = contributes[field];
if (!Array.isArray(list)) continue;
for (const view of list) {
if (isPlainObject(view) && typeof view.id === "string") {
declared.add(view.id);
}
}
}
const seen = new Set<unknown>();
for (const view of views) {
if (typeof view !== "string" || !declared.has(view)) {
errors.push(
`${where}.views entries must be ids from contributes.tabs, panels or dashboardCards, got: ${JSON.stringify(view)}`,
);
} else if (seen.has(view)) {
errors.push(`${where}.views duplicates "${view}"`);
}
seen.add(view);
}
}
}
if (
mobile.feature !== undefined &&
(typeof mobile.feature !== "string" ||
!LOOK_ID_PATTERN.test(mobile.feature))
) {
errors.push(
`${where}.feature must match ${LOOK_ID_PATTERN} (lowercase letters, digits, dashes)`,
);
}
if (mobile.note !== undefined) {
requireString(mobile.note, `${where}.note`, errors);
}
}
function validateFeatures(features: unknown, errors: string[]): void {
if (features === undefined) return;
if (!Array.isArray(features)) {
-1
View File
@@ -139,7 +139,6 @@ const offline = () =>
}),
hostProtocols: () => ["ssh"],
useHostActions: () => [],
useCompanions: () => [],
useActivityTypes: () => [],
activityTarget: () => undefined,
useExtensions: () => [],
+2 -3
View File
@@ -80,7 +80,7 @@ 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, size, minCardWidth, listColumns, onRowClick, isRowActive, empty, className, }: { items: T[]; view: PanelViewMode; density?: PanelDensity; getKey: (item: T) => string; renderCard: (item: T) => ReactNode; columns: GridColumns; size?: GridSize; minCardWidth?: number; listColumns: DataColumn<T>[]; onRowClick?: (item: T) => void; isRowActive?: (item: T) => boolean; empty: ReactNode; className?: string; }) => Element
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
@@ -131,7 +131,6 @@ GridCardCatalogEntry (type): GridCardCatalogEntry
GridColSpan (type): GridColSpan
GridColumns (type): GridColumns
GridLayout (type): GridLayout
GridSize (type): GridSize
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
@@ -322,7 +321,7 @@ UserRole (type): UserRole
UserSecondFactorSummary (type): UserSecondFactorSummary
VersionBadge (value): ({ status, releaseUrl, className, }: VersionBadgeProps) => Element
VersionInfo (type): VersionInfo
ViewToggle (value): ({ view, onView, density, onDensity, columns, onColumns, }: { view: PanelViewMode; onView: (next: PanelViewMode) => void; density?: PanelDensity; onDensity?: (next: PanelDensity) => void; columns?: GridSize; onColumns?: (next: GridSize) => void; }) => Element
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
+2 -7
View File
@@ -3,8 +3,6 @@
*
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
* --profile minimal|standard|full (or TERMIX_PLUGIN_PROFILE, default standard)
* picks which tiers of the file are staged.
* Each .tmxplug is downloaded (or read from a path), checked against its
* pinned sha256 and unpacked.
*
@@ -27,16 +25,13 @@ const {
localSdkBuildCommand,
copyLocalBuild,
buildBundledIndex,
resolveProfile,
pluginsForProfile,
} = require("./lib/bundled-plugins.cjs");
const root = path.resolve(__dirname, "..");
const destination = path.join(root, "dist", "plugins");
async function main() {
const profile = resolveProfile();
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
const plugins = loadBundledPlugins(root);
const flag = process.argv.indexOf("--local");
const localArg =
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
@@ -79,7 +74,7 @@ async function main() {
);
console.log(
`Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`,
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
);
}
+6 -14
View File
@@ -1,11 +1,9 @@
/**
* Builds the pieces of the plugin SDK that come from core:
* Builds the two pieces of the plugin SDK that come from core:
*
* dist/host/ui.js @termix-ssh/plugin-sdk/ui, the shell's UI kit
* dist/host/testing-host.js what renderWithApp() renders plugins into
* dist/host/core-classes.json the classes core's CSS has rules for
* dist/host/frame-host.js runs one plugin outside the shell (mobile app)
* dist/host/frame-host.css core's compiled CSS for that frame
*
* Inside Termix the page's import map points @termix-ssh/plugin-sdk/ui at the
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
@@ -48,7 +46,6 @@ await esbuild.build({
entryPoints: {
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
"frame-host": path.join(root, "src/ui/plugin-host/frame-host.tsx"),
},
outdir: outDir,
bundle: true,
@@ -75,9 +72,6 @@ await esbuild.build({
// so the translated locale files it would list are simply none.
banner: { js: "const __termixNoGlob = () => ({});" },
define: {
// esbuild picks "development" for an unminified build, which sends core's
// requests to the Vite dev proxy on any server without a port.
"process.env.NODE_ENV": '"production"',
"import.meta.glob": "__termixNoGlob",
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
@@ -163,16 +157,14 @@ const rootSource =
const scanner = new Scanner({
sources: [...rootSource, ...compiler.sources],
});
const coreCss = optimize(compiler.build(scanner.scan()), {
minify: true,
}).code;
const coreClasses = [...classNamesInCss(coreCss)].sort();
const coreClasses = [
...classNamesInCss(
optimize(compiler.build(scanner.scan()), { minify: true }).code,
),
].sort();
fs.writeFileSync(
path.join(outDir, "core-classes.json"),
`${JSON.stringify(coreClasses)}\n`,
);
// The frame host has no Vite build to load core's CSS, so it gets it here.
fs.writeFileSync(path.join(outDir, "frame-host.css"), coreCss);
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
-102
View File
@@ -16,8 +16,6 @@ import {
localSdkBuildCommand,
rebuildLocalPlugins,
copyLocalBuild,
resolveProfile,
pluginsForProfile,
} from "./lib/bundled-plugins.cjs";
const cleanups: Array<() => void> = [];
@@ -265,52 +263,10 @@ describe("pinsFromIndex", () => {
source: "tmxplug",
url: "https://example.test/beta-1.1.0.tmxplug",
sha256: "b".repeat(64),
tier: "full",
});
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
});
it("keeps the tier of an existing pin", () => {
const pins = pinsFromIndex(index, null, [
{ id: "alpha", tier: "standard" },
]);
expect(pins[0].tier).toBe("standard");
expect(pins[1].tier).toBe("full");
});
it("skips releases whose engine.termix excludes the core version", () => {
const withEngine = {
plugins: [
{
id: "gamma",
versions: [
{
version: "2.0.0",
url: "https://example.test/gamma-2.0.0.tmxplug",
sha256: "d".repeat(64),
engine: { api: "^1", termix: ">=26.10.6" },
},
{
version: "1.0.0",
url: "https://example.test/gamma-1.0.0.tmxplug",
sha256: "e".repeat(64),
engine: { api: "^1", termix: ">=26.10.0" },
},
],
},
],
};
expect(pinsFromIndex(withEngine, null, [], "26.10.5")[0].url).toBe(
"https://example.test/gamma-1.0.0.tmxplug",
);
expect(pinsFromIndex(withEngine, null, [], "26.10.6")[0].url).toBe(
"https://example.test/gamma-2.0.0.tmxplug",
);
expect(pinsFromIndex(withEngine, null)[0].url).toBe(
"https://example.test/gamma-2.0.0.tmxplug",
);
});
it("keeps the onboarding defaults of an existing pin", () => {
const current = [
{ id: "beta", onboarding: { recommended: true, consent: false } },
@@ -555,61 +511,3 @@ describe("default look", () => {
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
});
});
describe("plugin profiles", () => {
const entry = (id: string, tier?: string) => ({
id,
source: "tmxplug",
url: `https://x/${id}.tmxplug`,
sha256: SHA,
...(tier ? { tier } : {}),
});
const { plugins } = parseBundledPlugins({
plugins: [
entry("look", "minimal"),
entry("term", "standard"),
entry("extra", "full"),
entry("untiered"),
],
});
const ids = (profile: string) =>
pluginsForProfile(plugins, profile).map((p: { id: string }) => p.id);
it("ships each tier and every smaller one", () => {
expect(ids("minimal")).toEqual(["look"]);
expect(ids("standard")).toEqual(["look", "term"]);
expect(ids("full")).toEqual(["look", "term", "extra", "untiered"]);
});
it("rejects an unknown tier", () => {
const { problems } = parseBundledPlugins({ plugins: [entry("x", "huge")] });
expect(problems[0]).toMatch(/tier must be one of/);
});
it("reads the profile from the flag, then the environment", () => {
expect(resolveProfile(["node", "x"], {})).toBe("standard");
expect(
resolveProfile(["node", "x"], { TERMIX_PLUGIN_PROFILE: "full" }),
).toBe("full");
expect(
resolveProfile(["node", "x", "--profile", "minimal"], {
TERMIX_PLUGIN_PROFILE: "full",
}),
).toBe("minimal");
expect(() =>
resolveProfile(["node", "x", "--profile", "tiny"], {}),
).toThrow(/unknown plugin profile/);
});
it("keeps the real file valid with a default look in the minimal tier", () => {
const real = loadBundledPlugins(path.resolve(__dirname, ".."));
const minimal = pluginsForProfile(real, "minimal");
expect(minimal.length).toBeGreaterThan(0);
expect(minimal.every((p: { defaultLook: boolean }) => p.defaultLook)).toBe(
true,
);
expect(pluginsForProfile(real, "standard").length).toBeLessThan(
real.length,
);
});
});
-111
View File
@@ -1,111 +0,0 @@
#!/usr/bin/env node
/**
* Buttons are never white.
*
* Every Button variant is the brand orange with a different background
* opacity (or red for destructive, or transparent for ghost). Fails when a
* variant in src/ui/components/button.tsx uses a neutral fill, or when a
* <Button> anywhere in core or the local plugin checkouts sets a neutral fill
* in its className.
*/
const fs = require("node:fs");
const path = require("node:path");
const NEUTRAL =
/(?:^|[\s"'`])(?:dark:)?(?:bg-(?:primary|white|foreground|background|secondary|card|popover|muted|accent|input|neutral-\d+|zinc-\d+|gray-\d+|slate-\d+|stone-\d+)(?![\w-])|text-primary-foreground)/;
/** The opening tag text of each <Button ...> in a source file. */
function buttonTags(source) {
const tags = [];
const re = /<Button(?=[\s/>])/g;
let match;
while ((match = re.exec(source))) {
let depth = 0;
let quote = "";
let i = match.index + match[0].length;
for (; i < source.length; i++) {
const c = source[i];
if (quote) {
if (c === "\\") i++;
else if (c === quote) quote = "";
} else if (c === '"' || c === "'" || c === "`") quote = c;
else if (c === "{") depth++;
else if (c === "}") depth--;
else if (c === ">" && depth === 0) break;
}
tags.push(source.slice(match.index, i + 1));
}
return tags;
}
/** Variant strings from the cva block of button.tsx. */
function variantLines(source) {
const start = source.indexOf("variant: {");
const end = source.indexOf("size: {");
if (start < 0 || end < 0) return [];
return source
.slice(start, end)
.split("\n")
.filter((line) => /["`]/.test(line) && !line.includes("variant: {"));
}
function walk(dir, out) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (["node_modules", "dist", "tests", "__fixtures__"].includes(entry.name))
continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, out);
else if (entry.name.endsWith(".tsx")) out.push(full);
}
}
/** Problems found under the given roots. */
function scan(roots, buttonFile) {
const problems = [];
if (buttonFile && fs.existsSync(buttonFile)) {
for (const line of variantLines(fs.readFileSync(buttonFile, "utf8"))) {
if (NEUTRAL.test(line)) {
problems.push(
`${buttonFile}: neutral fill in a variant: ${line.trim()}`,
);
}
}
}
const files = [];
for (const root of roots) walk(root, files);
for (const file of files) {
for (const tag of buttonTags(fs.readFileSync(file, "utf8"))) {
if (NEUTRAL.test(tag)) {
problems.push(
`${file}: <Button> with a neutral fill: ${tag.slice(0, 120)}`,
);
}
}
}
return problems;
}
module.exports = { scan, buttonTags, variantLines };
if (require.main === module) {
const core = path.resolve(__dirname, "..");
const plugins = path.resolve(core, "..", "Plugins");
const roots = [path.join(core, "src", "ui")];
if (fs.existsSync(plugins)) {
for (const entry of fs.readdirSync(plugins)) {
roots.push(path.join(plugins, entry, "src"));
}
}
const problems = scan(
roots,
path.join(core, "src", "ui", "components", "button.tsx"),
);
if (problems.length) {
console.error("Buttons must be the brand orange, never white or neutral:");
for (const p of problems) console.error(" " + p);
process.exit(1);
}
console.log("ok button colors");
}
-80
View File
@@ -1,80 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { scan, buttonTags } = require("./check-button-colors.cjs") as {
scan: (roots: string[], buttonFile?: string) => string[];
buttonTags: (source: string) => string[];
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function fixture(files: Record<string, string>): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "button-colors-"));
for (const [file, content] of Object.entries(files)) {
const full = path.join(root, file);
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, content);
}
return root;
}
describe("buttonTags", () => {
it("reads a tag with arrow functions inside", () => {
const tags = buttonTags(
`<Button onClick={() => go(a > b)} className="x">Go</Button>`,
);
expect(tags).toHaveLength(1);
expect(tags[0]).toContain('className="x"');
});
});
describe("check-button-colors", () => {
it("accepts orange buttons and hover fills", () => {
const dir = fixture({
"a.tsx": `<Button className="bg-accent-brand/15 text-accent-brand hover:bg-muted" />`,
});
expect(scan([dir])).toEqual([]);
});
it("rejects a white or neutral fill on a Button", () => {
for (const cls of [
"bg-primary",
"bg-white",
"bg-foreground text-background",
"dark:bg-secondary",
"bg-muted",
"text-primary-foreground",
]) {
const dir = fixture({ "a.tsx": `<Button className="${cls}">x</Button>` });
expect(scan([dir]), cls).toHaveLength(1);
}
});
it("rejects a neutral fill in a variant", () => {
const dir = fixture({
"button.tsx": `variant: {\n default: "bg-primary text-primary-foreground",\n ghost: "hover:bg-muted",\n},\nsize: {`,
});
expect(scan([], path.join(dir, "button.tsx"))).toHaveLength(1);
});
it("passes on the real button", () => {
const button = path.resolve(
__dirname,
"..",
"src",
"ui",
"components",
"button.tsx",
);
expect(scan([], button)).toEqual([]);
});
});
-2
View File
@@ -16,8 +16,6 @@ const SDK = path.join(ROOT, "packages", "plugin-sdk");
const INTERNAL_EXPORTS = new Set([
"./testing/setup",
"./testing-host",
"./frame-host",
"./frame-host.css",
"./tsconfig.plugin.json",
"./schema/manifest.schema.json",
"./theme.css",
-1
View File
@@ -151,7 +151,6 @@ const ALLOWED = [
"GridCardCatalogEntry",
"GridColSpan",
"GridColumns",
"GridSize",
"GridLayout",
"GridSlot",
"GroupHeading",
-2
View File
@@ -21,8 +21,6 @@ const packages = [
"@img/sharp-win32-x64",
].map((name) => `${name}@${sharpPackage.optionalDependencies[name]}`);
// A .cmd file only starts through a shell on current Node.
execFileSync("npm.cmd", ["install", "--force", "--no-save", ...packages], {
stdio: "inherit",
shell: true,
});
+2 -49
View File
@@ -16,11 +16,6 @@
* environment variables is set, for a server configured before first boot.
* Plugins without it start unchecked.
*
* "tier" says which image profile ships the plugin: "minimal" (only what the
* app cannot start without, the default look), "standard" (the default image)
* or "full" (everything else). A profile includes its own tier and every
* smaller one. A plugin with no tier counts as "full".
*
* "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
@@ -36,8 +31,6 @@ const ID = /^[a-z0-9][a-z0-9-]*$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
const SHA256 = /^[0-9a-f]{64}$/;
const PROFILES = ["minimal", "standard", "full"];
const DEFAULT_PROFILE = "standard";
function parseOnboarding(value, id, problems) {
if (value === undefined) return null;
@@ -129,9 +122,6 @@ function parseBundledPlugins(raw) {
) {
problems.push(`${entry.id} defaultLook must be a boolean`);
}
if (entry.tier !== undefined && !PROFILES.includes(entry.tier)) {
problems.push(`${entry.id} tier must be one of ${PROFILES.join(", ")}`);
}
if (problems.length > before) continue;
plugins.push({
id: entry.id,
@@ -141,7 +131,6 @@ function parseBundledPlugins(raw) {
sha256: entry.sha256,
onboarding,
defaultLook: entry.defaultLook === true,
tier: entry.tier ?? "full",
});
} else {
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
@@ -252,35 +241,12 @@ function buildBundledIndex(plugins, stagedIds) {
return { version: 1, plugins: out };
}
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
function resolveProfile(argv = process.argv, env = process.env) {
const flag = argv.indexOf("--profile");
const value =
(flag !== -1 ? argv[flag + 1] : env.TERMIX_PLUGIN_PROFILE) ||
DEFAULT_PROFILE;
if (!PROFILES.includes(value)) {
throw new Error(
`unknown plugin profile "${value}", use ${PROFILES.join(", ")}`,
);
}
return value;
}
/** The plugins a profile ships: its own tier and every smaller one. */
function pluginsForProfile(plugins, profile) {
const rank = PROFILES.indexOf(profile);
return plugins.filter((plugin) => PROFILES.indexOf(plugin.tier) <= rank);
}
/**
* Pins the newest registry version of each wanted plugin. `ids` is the list
* to pin, or null for every plugin in the index. `current` is the existing
* pin list, so fields the registry does not know (onboarding, tier) carry over;
* a plugin new to the list starts in the full tier. coreVersion, when given,
* skips releases whose engine.termix range does not include it, so the image
* never ships a plugin its own core cannot run.
* pin list, so fields the registry does not know (onboarding) carry over.
*/
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
function pinsFromIndex(index, ids, current = []) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry]));
@@ -291,25 +257,15 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
.filter((entry) => semver.valid(entry.version))
.filter((entry) => !semver.prerelease(entry.version))
.filter(
(entry) =>
!coreVersion ||
!entry.engine?.termix ||
semver.satisfies(coreVersion, entry.engine.termix, {
includePrerelease: true,
}),
)
.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;
const tier = currentById.get(id)?.tier ?? "full";
return {
id,
source: "tmxplug",
url: latest.url,
sha256: latest.sha256,
tier,
...(onboarding ? { onboarding } : {}),
...(defaultLook ? { defaultLook } : {}),
};
@@ -453,9 +409,6 @@ function copyLocalBuild(repo, destination) {
}
module.exports = {
PROFILES,
resolveProfile,
pluginsForProfile,
findLocalPluginBuilds,
isLocalBuildStale,
localSdkBuildCommand,
+2 -7
View File
@@ -4,10 +4,7 @@
* re-released.
*
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full)
* --core-version X skip releases whose engine.termix excludes X
*
* The release workflow runs it with --all and the version being released.
* node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
*/
const fs = require("node:fs");
@@ -25,8 +22,6 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
async function main() {
const all = process.argv.includes("--all");
const flag = process.argv.indexOf("--core-version");
const coreVersion = flag !== -1 ? process.argv[flag + 1] : null;
const response = await fetch(INDEX_URL);
if (!response.ok) {
throw new Error(`registry index download failed with ${response.status}`);
@@ -35,7 +30,7 @@ async function main() {
const current = loadBundledPlugins(root);
const ids = all ? null : current.map((plugin) => plugin.id);
const plugins = pinsFromIndex(index, ids, current, coreVersion);
const plugins = pinsFromIndex(index, ids, current);
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
console.log(
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,
-29
View File
@@ -144,32 +144,3 @@ describe("termix-plugin validate layouts and capabilities", () => {
expect(output).not.toContain("is declared but");
});
});
describe("termix-plugin validate mobile entry", () => {
function mobile(built: boolean) {
const root = plugin([]);
fs.writeFileSync(
path.join(root, "manifest.json"),
JSON.stringify({
id: "fixture",
version: "1.0.0",
contributes: { mobile: { mode: "webview", entry: "dist/mobile.js" } },
}),
);
if (built) {
fs.mkdirSync(path.join(root, "dist"), { recursive: true });
fs.writeFileSync(path.join(root, "dist", "mobile.js"), "");
}
return root;
}
it("reports a mobile entry that was not built", async () => {
expect(await problems(mobile(false))).toContain(
"manifest references dist/mobile.js, which does not exist",
);
});
it("finds a built mobile entry", async () => {
expect(await problems(mobile(true))).not.toContain("dist/mobile.js");
});
});
+2 -12
View File
@@ -137,19 +137,9 @@ function verifyArtifact(artifact) {
console.log(`Verified macOS sharp packaging: ${path.basename(artifact)}`);
} finally {
if (mountedAt) {
for (let attempt = 0; attempt < 5; attempt++) {
const args =
attempt < 3 ? ["detach", mountedAt] : ["detach", "-force", mountedAt];
if (spawnSync("hdiutil", args, { stdio: "ignore" }).status === 0) break;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 2000);
}
spawnSync("hdiutil", ["detach", mountedAt], { stdio: "ignore" });
}
fs.rmSync(temporaryRoot, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 1000,
});
fs.rmSync(temporaryRoot, { recursive: true, force: true });
}
}
-2
View File
@@ -1316,8 +1316,6 @@ const migrateSchema = () => {
{ column: "ssh_port", sql: "ALTER TABLE ssh_data ADD COLUMN ssh_port INTEGER DEFAULT 22" },
{ column: "connection_origin", sql: "ALTER TABLE ssh_data ADD COLUMN connection_origin TEXT" },
{ column: "parent_host_id", sql: "ALTER TABLE ssh_data ADD COLUMN parent_host_id INTEGER REFERENCES ssh_data(id) ON DELETE SET NULL" },
{ column: "color", sql: "ALTER TABLE ssh_data ADD COLUMN color TEXT" },
{ column: "icon", sql: "ALTER TABLE ssh_data ADD COLUMN icon TEXT" },
];
for (const migration of sshDataMigrations) {
-3
View File
@@ -193,9 +193,6 @@ export const hosts = mysqlTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: int("ssh_port").default(22),
-3
View File
@@ -193,9 +193,6 @@ export const hosts = pgTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: boolean("enable_ssh").notNull().default(true),
sshPort: integer("ssh_port").default(22),
-3
View File
@@ -183,9 +183,6 @@ export const hosts = sqliteTable(
// legacy algorithms, agent, environment). JSON.
sshOptions: text("ssh_options"),
notes: text("notes"),
// Hex color and Lucide icon name a layout may show next to the host.
color: text("color"),
icon: text("icon"),
enableSsh: integer("enable_ssh", { mode: "boolean" }).notNull().default(true),
sshPort: integer("ssh_port").default(22),
@@ -30,33 +30,6 @@ export function applyHostKeyTypeUpdate(
}
}
const HOST_COLOR = /^#[0-9a-fA-F]{6}$/;
const HOST_ICON = /^[a-z0-9-]{1,64}$/;
/**
* Color and icon from a host write. A field left out stays as stored, so
* editors that don't know about them never clear them. Bad values store null.
*/
export function normalizeHostLookFields(values: Record<string, unknown>): {
color?: string | null;
icon?: string | null;
} {
const result: { color?: string | null; icon?: string | null } = {};
if (values.color !== undefined) {
result.color =
typeof values.color === "string" && HOST_COLOR.test(values.color)
? values.color.toLowerCase()
: null;
}
if (values.icon !== undefined) {
result.icon =
typeof values.icon === "string" && HOST_ICON.test(values.icon)
? values.icon
: null;
}
return result;
}
const PROTOCOL_ENABLE_FIELDS = ["enableSsh"] as const;
export function normalizeProtocolEnableFields(
-3
View File
@@ -33,7 +33,6 @@ import {
isOptionalAuthType,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeProtocolEnableFields,
OWNER_PRIVATE_AUTH_FIELDS,
OWNER_PRIVATE_SSH_OPTION_FIELDS,
@@ -336,7 +335,6 @@ router.post(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
@@ -914,7 +912,6 @@ router.put(
? JSON.stringify(portKnockSequence)
: null,
...normalizeProtocolEnableFields(hostData),
...normalizeHostLookFields(hostData),
sshPort: sshPort || port || 22,
};
+1 -5
View File
@@ -18,7 +18,6 @@ import {
safeLayoutForced,
unavailableLooks,
} from "../../plugins/looks.js";
import { safeModeOn } from "../../plugins/safe-mode.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -37,7 +36,7 @@ async function adminDefaults() {
* /user-preferences/look:
* get:
* summary: Get the layout and theme to show the current user
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, whether TERMIX_SAFE_MODE runs only layout and theme plugins, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* description: The user's own choice, the admin defaults (a layout no installed plugin brings reads as null), the default look of the bundled plugins, whether TERMIX_SAFE_LAYOUT forces the repair view, and installed layout or theme plugins that could not load. The browser resolves the final look from these.
* tags:
* - User Preferences
* responses:
@@ -76,8 +75,6 @@ async function adminDefaults() {
* nullable: true
* safeLayout:
* type: boolean
* safeMode:
* type: boolean
* unavailable:
* type: array
* items:
@@ -115,7 +112,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
defaults: { ...defaults, layout: known(defaults.layout) },
defaultLook: resolveDefaultLook(loader),
safeLayout: safeLayoutForced(),
safeMode: safeModeOn(),
unavailable: unavailableLooks(loader),
});
} catch (error) {
+3 -32
View File
@@ -16,7 +16,6 @@ import { getPluginRuntime } from "../../plugins/index.js";
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
import { describePluginFrontend } from "../../plugins/assets.js";
import { isDefaultLookPlugin } from "../../plugins/bundled-index.js";
import { mayRunInSafeMode } from "../../plugins/safe-mode.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
@@ -244,17 +243,12 @@ export async function listPreLoginPlugins(): Promise<
* `assetVersion` is its cache key, `css` says a stylesheet sits beside
* it, and `locales` lists the languages it ships ("en" and xx_YY).
* `dependencies` and `optionalDependencies` let the loader activate
* frontends in the same order the server does. `engine` holds the
* manifest's `api` and `termix` ranges, so other clients (the mobile
* app) can tell whether they understand the plugin.
* frontends in the same order the server does.
*
* On a desktop linked to a server, `runsOnServer` marks plugins that
* only run on that server and stay off here on purpose, and, for
* admins, `managedByServer` marks plugins whose on/off state the server
* decides.
*
* While TERMIX_SAFE_MODE is on, an enabled plugin that safe mode keeps
* off reports `state: "safe-mode"`.
* tags:
* - Plugins
* responses:
@@ -305,7 +299,6 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
let features: string[] = [];
let docs: string | undefined;
let env: PluginEnvVar[] = [];
const engine: { api?: string; termix?: string } = {};
let desktopMode: unknown = loaded
? resolvePluginDesktopMode(loaded.manifest)
: undefined;
@@ -325,14 +318,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
optionalDependencies?: Record<string, string>;
desktop?: unknown;
runtime?: unknown;
engine?: { api?: unknown; termix?: unknown };
};
if (typeof manifest?.engine?.api === "string") {
engine.api = manifest.engine.api;
}
if (typeof manifest?.engine?.termix === "string") {
engine.termix = manifest.engine.termix;
}
desktopMode = loaded
? resolvePluginDesktopMode(loaded.manifest)
: (manifest?.desktop ??
@@ -368,14 +354,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
name: record.name,
version: record.version,
enabled: record.state === "enabled",
state:
record.state === "enabled" &&
loaded?.state !== "active" &&
!mayRunInSafeMode(record.id, loader.list())
? "safe-mode"
: (loaded?.state ?? record.state),
state: loaded?.state ?? record.state,
runsOnServer: linked && desktopMode === "server",
engine,
contributes,
icon,
dependencies,
@@ -956,7 +936,7 @@ router.delete(
* 404:
* description: No such plugin.
* 409:
* description: This desktop is linked to a server, which decides whether the plugin runs, or safe mode is on and the plugin is not a layout or theme.
* description: This desktop is linked to a server, which decides whether the plugin runs.
*/
router.patch(
"/:id/state",
@@ -1070,8 +1050,6 @@ router.patch(
* description: The caller lacks admin.plugins.manage.
* 404:
* description: No such plugin, or it is not on disk.
* 409:
* description: Safe mode is on and the plugin is not a layout or theme.
* 500:
* description: The plugin failed to start again.
*/
@@ -1095,13 +1073,6 @@ router.post(
res.status(404).json({ error: "Plugin is not present on disk" });
return;
}
if (!mayRunInSafeMode(pluginId, loader.list())) {
res.status(409).json({
error: "Safe mode is on, so only layout and theme plugins can start",
code: "SAFE_MODE",
});
return;
}
await repository.update(pluginId, {
state: "enabled",
-2
View File
@@ -73,8 +73,6 @@ async function exportRow(
: resolvedHost.tags || [],
pin: !!resolvedHost.pin,
notes: resolvedHost.notes || null,
color: resolvedHost.color || null,
icon: resolvedHost.icon || null,
// Every plugin's host settings, secrets redacted, for import to hand back.
pluginSettings: pluginSettings.get(host.id as number) ?? {},
// Each plugin protocol's login, secrets included like the SSH ones.
-2
View File
@@ -28,7 +28,6 @@ import { applyPluginHostImportSettings } from "../../database/routes/host-plugin
import {
isNonEmptyString,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
} from "../../database/routes/host-normalizers.js";
import { hasHostLocks, hostLock, manualCreateBlockedBy } from "../locks.js";
@@ -572,7 +571,6 @@ export async function runBulkImport(
? "true"
: "false",
notes: hostData.notes || null,
...normalizeHostLookFields(hostData as Record<string, unknown>),
useSocks5: hostData.useSocks5 ? 1 : 0,
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
-21
View File
@@ -14,24 +14,3 @@ export function startablePluginIds(
.map((record) => record.id),
);
}
/**
* Plugins that shipped in an earlier image and were left on, but whose code is
* not in this one (a smaller image tag). They get fetched from the registry.
*/
export function missingShippedPluginIds(
records: { id: string; tier: string; state: string }[],
presentIds: ReadonlySet<string>,
ignored: ReadonlySet<string>,
): string[] {
return records
.filter(
(record) =>
record.tier === "bundled" &&
record.state !== "disabled" &&
!presentIds.has(record.id) &&
!ignored.has(record.id),
)
.map((record) => record.id)
.sort();
}
+3 -6
View File
@@ -56,10 +56,7 @@ import type { PluginTableDefinition } from "@termix-ssh/plugin-sdk/db";
import * as syncRegistry from "./sync-registry.js";
import { provideSync } from "../sync/provider.js";
import { createSyncHost } from "../sync/host.js";
import type {
PluginSync,
PluginSyncHost,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginSyncHost } from "@termix-ssh/plugin-sdk/backend";
import {
needsExplicitPersist,
resolveDatabaseDialect,
@@ -196,7 +193,7 @@ export function createPluginContext(
): PluginContext {
const pluginId = manifest.id;
let syncHost: PluginSyncHost | undefined;
const syncApi: PluginSync = {
const syncApi: PluginContext["sync"] = {
registerEntity: (entity) => {
const dispose = syncRegistry.registerEntity(pluginId, entity);
handle.bag.add(dispose, `sync entity ${entity.type}`);
@@ -212,7 +209,7 @@ export function createPluginContext(
remove();
};
},
} as PluginSync;
} as PluginContext["sync"];
// Not enumerable, so tools that walk the context do not trip the check.
Object.defineProperty(syncApi, "host", {
enumerable: false,
-20
View File
@@ -23,11 +23,6 @@ import { setHostProtocolSource } from "../hosts/protocol-auth/registry.js";
import { setSecretResolverOwnerSource } from "../hosts/connect/secret-resolver-registry.js";
import { recordConflict } from "./conflicts.js";
import { startablePluginIds } from "./boot-state.js";
import {
mayRunInSafeMode,
safeModeOn,
safeModePluginIds,
} from "./safe-mode.js";
import { setPluginImpersonationCheck } from "../utils/auth-manager.js";
import { setKeybindingActionSource } from "../database/routes/keybinding-validation.js";
@@ -242,10 +237,6 @@ export async function initializePlugins(
if (loaded.length === 0) return [];
const fresh = await seedPlugins(loaded, options.freshInstall === true);
if (!fresh) {
const { restoreMissingPlugins } = await import("./manage.js");
loaded.push(...(await restoreMissingPlugins()));
}
await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) {
const { offerPluginSetup } = await import("./onboarding.js");
@@ -257,17 +248,8 @@ export async function initializePlugins(
const records = await createCurrentPluginRepository().listAll();
const enabled = startablePluginIds(records);
const safe = safeModeOn() ? safeModePluginIds(loaded) : null;
if (safe) {
pluginLogger.warn(
"Safe mode is on (TERMIX_SAFE_MODE). Only layout and theme plugins start",
{ operation: "plugin_init" },
);
}
const candidates = loaded
.filter((plugin) => enabled.has(plugin.id))
.filter((plugin) => !safe || safe.has(plugin.id))
.map((plugin) => plugin.id);
for (const pluginId of candidates) {
@@ -557,8 +539,6 @@ export async function activatePlugin(pluginId: string): Promise<void> {
// Before activate: a plugin's own activate() may already want to check one
// of its permissions.
const plugin = pluginLoader.get(pluginId);
// Stays enabled and starts on the next boot without safe mode.
if (!mayRunInSafeMode(pluginId, pluginLoader.list())) return;
if (plugin) await registerPluginPermissions(plugin);
await pluginLoader.activate(pluginId);
+1 -71
View File
@@ -18,7 +18,6 @@ import path from "node:path";
import semver from "semver";
import { pluginLogger } from "../utils/logger.js";
import { isDefaultLookPlugin } from "./bundled-index.js";
import { mayRunInSafeMode } from "./safe-mode.js";
import {
activatePlugin,
deactivatePlugin,
@@ -26,7 +25,6 @@ import {
registerLoadedPlugins,
unloadPlugin,
} from "./index.js";
import { missingShippedPluginIds } from "./boot-state.js";
import type { LoadedPlugin, PluginLoader } from "./loader.js";
import {
getBundledPluginsDir,
@@ -765,13 +763,6 @@ export async function setPluginStateUnlocked(
if (!loader.get(pluginId)) {
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
if (enabled && !mayRunInSafeMode(pluginId, loader.list())) {
throw new PluginManageError(
"Safe mode is on, so only layout and theme plugins can start",
409,
"SAFE_MODE",
);
}
const plan = await planStateChange(pluginId, enabled);
if (enabled) {
@@ -968,11 +959,7 @@ export function installPlugin(
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
// A record with no code is a plugin an older image shipped: install fills it in.
if (
(await repository.findById(pluginId)) &&
getPluginRuntime().loader.get(pluginId)
) {
if (await repository.findById(pluginId)) {
throw new PluginManageError(
"Plugin is already installed",
409,
@@ -1031,63 +1018,6 @@ export function installPlugin(
});
}
/**
* Called at boot before plugins start. A plugin the database has on but the
* image no longer ships (an update to a smaller image tag) is fetched from the
* official registry, so nothing an install used goes missing. One that cannot
* be fetched stays as it was and is tried again on the next start.
*/
export function restoreMissingPlugins(): Promise<LoadedPlugin[]> {
return serialized(async () => {
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const { loader } = getPluginRuntime();
const ids = missingShippedPluginIds(
await repository.listAll(),
new Set(loader.list().map((plugin) => plugin.id)),
await readUninstalled(),
);
const restored: LoadedPlugin[] = [];
for (const id of ids) {
try {
const record = await repository.findById(id);
const { source, release } = await requireRelease(
id,
record?.pinnedVersion ?? undefined,
channelOf(record),
OFFICIAL_REGISTRY_ID,
);
const plugin = await swapIn(id, { release, source });
await repository.update(id, {
registryId: source.id,
tier: tierFor(plugin, source),
});
await grantDeclared(plugin, null);
restored.push(plugin);
pluginLogger.info(
`Restored ${id}@${release.version} from the registry`,
{
operation: "plugin_restore_missing",
},
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
pluginLogger.warn(`Could not restore ${id}: ${message}`, {
operation: "plugin_restore_missing",
});
// The registry is down or offline, so the rest would fail the same way.
if (
error instanceof PluginManageError &&
error.code === "REGISTRY_UNREACHABLE"
) {
break;
}
}
}
return restored;
});
}
export function updatePlugin(
pluginId: string,
options: InstallOptions,
+3 -65
View File
@@ -12,7 +12,6 @@
import crypto from "node:crypto";
import fs from "node:fs";
import { isIP } from "node:net";
import path from "node:path";
import semver from "semver";
import {
@@ -24,11 +23,7 @@ import {
youtubeVideoId,
} from "@termix-ssh/plugin-sdk/manifest";
import { getLocalVersion } from "../utils/app-version.js";
import {
isBlockedAddress,
safeOutboundFetch,
} from "../utils/safe-outbound-fetch.js";
import { fetchWithProxy, getProxyAgent } from "../utils/proxy-agent.js";
import { safeOutboundFetch } from "../utils/safe-outbound-fetch.js";
import { pluginLogger } from "../utils/logger.js";
import {
COMMUNITY_PLUGIN_KEYS,
@@ -283,72 +278,15 @@ export function canRunRelease(
);
}
/**
* Hosts the admin lets registry fetches reach on a private address, like a
* registry in the LAN. Matched by host name on any port, also when another
* index or a redirect points there; every other host stays blocked.
*/
export function getRegistryPrivateHosts(): string[] {
return (process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean)
.map(normalizePrivateHost)
.filter((host): host is string => host !== undefined);
}
/**
* The host in the form safeOutboundFetch compares against, or undefined when
* the entry carries more than a host, like a scheme, port, path or user.
*/
function normalizePrivateHost(entry: string): string | undefined {
const candidate = isIP(entry) === 6 ? `[${entry}]` : entry;
if (/[/?#@\\]|:\d*$/.test(candidate)) return undefined;
try {
return new URL(`http://${candidate}`).hostname.replace(/^\[|\]$/g, "");
} catch {
return undefined;
}
}
/**
* With HTTP_PROXY or HTTPS_PROXY set, registry traffic goes through the proxy,
* which does its own name lookup. Only the URL itself is checked here: web
* pages only, no logins in it, and no private address typed in as a literal.
*/
function fetchThroughProxy(
url: string,
options: RequestInit,
privateHosts: readonly string[],
): Promise<Response> {
const parsed = new URL(url);
const host = parsed.hostname.replace(/^\[|\]$/g, "");
if (
!["http:", "https:"].includes(parsed.protocol) ||
parsed.username ||
parsed.password
) {
throw new Error("Invalid outbound URL");
}
if (isIP(host) && isBlockedAddress(host) && !privateHosts.includes(host)) {
throw new Error("Private destinations are not allowed");
}
return fetchWithProxy(url, options);
}
/** Follows redirects by hand so each hop still gets the outbound checks. */
async function fetchFollowing(url: string): Promise<Response> {
const privateHosts = getRegistryPrivateHosts();
let current = url;
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
const options: RequestInit = {
const response = await safeOutboundFetch(current, {
method: "GET",
redirect: "manual",
headers: { "User-Agent": "Termix" },
};
const response = getProxyAgent(current)
? await fetchThroughProxy(current, options, privateHosts)
: await safeOutboundFetch(current, options, privateHosts);
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) throw new Error(`redirect without a location: ${current}`);
-42
View File
@@ -1,42 +0,0 @@
import type { PluginManifest } from "./manifest.js";
/** TERMIX_SAFE_MODE=1 starts only the plugins that draw the app. */
export function safeModeOn(): boolean {
const value = process.env.TERMIX_SAFE_MODE?.trim().toLowerCase();
return value === "1" || value === "true";
}
function isLookPlugin(manifest: PluginManifest): boolean {
return (
(manifest.contributes?.layouts?.length ?? 0) > 0 ||
(manifest.contributes?.themes?.length ?? 0) > 0
);
}
/** Layout and theme plugins, plus the plugins they need to start. */
export function safeModePluginIds(
plugins: { id: string; manifest: PluginManifest }[],
): Set<string> {
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const allowed = new Set<string>();
const add = (id: string) => {
const plugin = byId.get(id);
if (!plugin || allowed.has(id)) return;
allowed.add(id);
for (const dependency of Object.keys(plugin.manifest.dependencies ?? {})) {
add(dependency);
}
};
for (const plugin of plugins) {
if (isLookPlugin(plugin.manifest)) add(plugin.id);
}
return allowed;
}
/** Whether a plugin may run now. Always true outside safe mode. */
export function mayRunInSafeMode(
pluginId: string,
plugins: { id: string; manifest: PluginManifest }[],
): boolean {
return !safeModeOn() || safeModePluginIds(plugins).has(pluginId);
}
-15
View File
@@ -7,8 +7,6 @@ import { createCurrentRepositoryContext } from "../database/repositories/factory
* On a linked desktop, hosts and credentials someone shared with the account
* are read-only copies kept in step by sync. Editing or deleting one here
* would be undone by the next sync, so it is refused with a clear reason.
* A host's connection origin only means something on this device, so that one
* field can still be changed.
*/
export function rejectSharedCopyWrites(
kind: "host" | "credential",
@@ -27,19 +25,6 @@ export function rejectSharedCopyWrites(
.where(eq(table.id, Number(match[1])))
.limit(1);
if (row?.sharedSource) {
const origin = req.body?.connectionOrigin;
if (
kind === "host" &&
req.method === "PUT" &&
(origin === "local" || origin === "remote")
) {
await createCurrentRepositoryContext()
.drizzle.update(hosts)
.set({ connectionOrigin: origin })
.where(eq(hosts.id, Number(match[1])));
res.json({ id: Number(match[1]), connectionOrigin: origin });
return;
}
res.status(403).json({
error: "This was shared with you and is managed on the server",
code: "SHARED_COPY",
@@ -214,43 +214,3 @@ describe("host.created", () => {
}
});
});
describe("host color and icon", () => {
async function stored(id: number) {
const [row] = await ctx.drizzle
.select({ color: hosts.color, icon: hosts.icon })
.from(hosts)
.where(eq(hosts.id, id));
return row;
}
it("saves them on create and keeps them when an update leaves them out", async () => {
const created = await request(app)
.post("/host/db/host")
.send({
...base,
authType: "password",
password: "pw",
color: "#22C55E",
icon: "database",
});
expect(created.status).toBe(200);
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, name: "renamed" });
expect(await stored(created.body.id)).toEqual({
color: "#22c55e",
icon: "database",
});
await request(app)
.put(`/host/db/host/${created.body.id}`)
.send({ ...base, color: null, icon: null });
expect(await stored(created.body.id)).toEqual({ color: null, icon: null });
});
});
@@ -8,7 +8,6 @@ import {
keepsStoredSshAuth,
isOptionalBoolean,
isValidPort,
normalizeHostLookFields,
normalizeImportedHost,
normalizeProtocolEnableFields,
renameFolderPath,
@@ -530,25 +529,3 @@ describe("impliedSshAuthType", () => {
expect(impliedSshAuthType({})).toBeUndefined();
});
});
describe("normalizeHostLookFields", () => {
it("leaves both fields out when the write does not send them", () => {
expect(normalizeHostLookFields({ name: "web" })).toEqual({});
});
it("keeps a hex color and a Lucide name", () => {
expect(
normalizeHostLookFields({ color: "#3B82F6", icon: "server-cog" }),
).toEqual({ color: "#3b82f6", icon: "server-cog" });
});
it("stores null for cleared or bad values", () => {
expect(normalizeHostLookFields({ color: "red", icon: "<script>" })).toEqual(
{ color: null, icon: null },
);
expect(normalizeHostLookFields({ color: null, icon: "" })).toEqual({
color: null,
icon: null,
});
});
});
@@ -102,7 +102,6 @@ beforeEach(async () => {
state.defaultLookIds = [];
state.installed = [];
delete process.env.TERMIX_SAFE_LAYOUT;
delete process.env.TERMIX_SAFE_MODE;
const app = express();
app.use(express.json());
@@ -114,7 +113,6 @@ beforeEach(async () => {
afterEach(async () => {
delete process.env.TERMIX_SAFE_LAYOUT;
delete process.env.TERMIX_SAFE_MODE;
await new Promise<void>((resolve) => server.close(() => resolve()));
});
@@ -140,7 +138,6 @@ describe("GET /user-preferences/look", () => {
defaults: { layout: null, theme: null },
defaultLook: { layout: null, theme: null },
safeLayout: false,
safeMode: false,
unavailable: [],
});
});
@@ -162,7 +159,6 @@ describe("GET /user-preferences/look", () => {
{ id: "other", version: null, layouts: [], themes: [], error: "y" },
];
process.env.TERMIX_SAFE_LAYOUT = "1";
process.env.TERMIX_SAFE_MODE = "1";
const { body } = await getLook();
expect(body).toMatchObject({
@@ -171,7 +167,6 @@ describe("GET /user-preferences/look", () => {
defaults: { layout: "admin-layout", theme: "gruvbox" },
defaultLook: { layout: "shipped", theme: "ink" },
safeLayout: true,
safeMode: true,
});
expect(body.unavailable.map((plugin: { id: string }) => plugin.id)).toEqual(
["broken"],
@@ -488,55 +488,6 @@ describe("plugins route", () => {
}
});
it("lists a plugin's engine ranges to any user", async () => {
state.plugins.set(
"sample-plugin",
makePlugin({
manifestJson: JSON.stringify({
engine: { api: "^1.13", termix: ">=26.10.6", extra: 1 },
}),
}),
);
state.plugins.set(
"bad-engine",
makePlugin({
id: "bad-engine",
manifestJson: JSON.stringify({ engine: { api: 2 } }),
}),
);
const res = await fetch(`${baseUrl}/plugins`, {
headers: { "x-test-user-id": "regular-user" },
});
const body = (await res.json()) as Array<{ id: string; engine: unknown }>;
expect(body.find((p) => p.id === "sample-plugin")?.engine).toEqual({
api: "^1.13",
termix: ">=26.10.6",
});
expect(body.find((p) => p.id === "bad-engine")?.engine).toEqual({});
});
it("reports an enabled plugin that safe mode keeps off", async () => {
state.plugins.set("sample-plugin", makePlugin({ state: "enabled" }));
const read = async () =>
(
(await (await fetch(`${baseUrl}/plugins`)).json()) as Array<{
state: string;
enabled: boolean;
}>
)[0];
expect((await read()).state).not.toBe("safe-mode");
process.env.TERMIX_SAFE_MODE = "1";
try {
expect(await read()).toMatchObject({ state: "safe-mode", enabled: true });
} finally {
delete process.env.TERMIX_SAFE_MODE;
}
});
it("grants a declared capability", async () => {
state.plugins.set("sample-plugin", makePlugin());
@@ -672,7 +623,6 @@ describe("plugins route", () => {
enabled: true,
state: "enabled",
runsOnServer: false,
engine: {},
contributes: null,
dependencies: {},
optionalDependencies: {},
+1 -26
View File
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest";
import {
missingShippedPluginIds,
startablePluginIds,
} from "../../plugins/boot-state.js";
import { startablePluginIds } from "../../plugins/boot-state.js";
describe("startablePluginIds", () => {
it("starts enabled plugins and retries ones that failed or were blocked", () => {
@@ -16,25 +13,3 @@ describe("startablePluginIds", () => {
expect([...ids].sort()).toEqual(["a", "b", "c"]);
});
});
describe("missingShippedPluginIds", () => {
const records = [
{ id: "gone", tier: "bundled", state: "enabled" },
{ id: "here", tier: "bundled", state: "enabled" },
{ id: "off", tier: "bundled", state: "disabled" },
{ id: "failed", tier: "bundled", state: "failed" },
{ id: "community", tier: "community", state: "enabled" },
{ id: "removed", tier: "bundled", state: "enabled" },
];
it("lists shipped plugins that are on but not in the image", () => {
expect(
missingShippedPluginIds(records, new Set(["here"]), new Set(["removed"])),
).toEqual(["failed", "gone"]);
});
it("finds nothing when every plugin is present", () => {
const present = new Set(records.map((record) => record.id));
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
});
});
@@ -1,211 +0,0 @@
import { describe, expect, it } from "vitest";
import {
classifyRuntimeError,
composeArgsFor,
containerCommand,
countContainers,
diffContainerStates,
dockerArgs,
parseComposeProjects,
parseContainerList,
parseContainerStates,
parseContainerStats,
parseRuntimeVersion,
parseServices,
parseStackStatus,
stackStateFromServices,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("containerCommand", () => {
it("extends PATH on POSIX hosts", () => {
const command = containerCommand("podman", "ps");
expect(command.startsWith('PATH="')).toBe(true);
expect(command.endsWith(" podman ps")).toBe(true);
});
it("runs the plain CLI on Windows hosts", () => {
expect(containerCommand("docker", "ps -a", true)).toBe("docker ps -a");
});
it("falls back to docker for an unknown runtime", () => {
expect(containerCommand(undefined, "ps", true)).toBe("docker ps");
});
});
describe("dockerArgs", () => {
it("builds the container commands", () => {
expect(dockerArgs.inspect("web")).toBe("inspect web");
expect(dockerArgs.action("restart", "web")).toBe("restart web");
expect(dockerArgs.remove("web", true)).toBe("rm -f web");
expect(dockerArgs.list(true)).toContain("ps -a --format '{");
expect(dockerArgs.stats("web")).toContain("stats web --no-stream");
});
it("refuses container ids that could break the command line", () => {
expect(() => dockerArgs.inspect("web; rm -rf /")).toThrow();
expect(() => dockerArgs.logs("$(id)")).toThrow();
expect(() => dockerArgs.action("restart", "-rf")).toThrow();
expect(() => dockerArgs.action("kill" as never, "web")).toThrow();
});
it("only passes sane log filters", () => {
expect(
dockerArgs.logs("web", {
tail: 50.7,
timestamps: true,
since: "2026-10-01T00:00:00Z",
}),
).toBe("logs web --tail 50 --timestamps --since 2026-10-01T00:00:00Z 2>&1");
expect(dockerArgs.logs("web", { since: "1h; id" })).toBe("logs web 2>&1");
});
});
describe("docker parsing", () => {
it("reads the version and sorts errors", () => {
expect(parseRuntimeVersion("Docker version 27.3.1, build ce12230")).toBe(
"27.3.1",
);
expect(parseRuntimeVersion("podman version 5.2.0")).toBe("5.2.0");
expect(parseRuntimeVersion("nope")).toBe("unknown");
expect(classifyRuntimeError("bash: docker: command not found")).toBe(
"NOT_INSTALLED",
);
expect(
classifyRuntimeError(
"Cannot connect to the Docker daemon at unix:///var/run/docker.sock",
),
).toBe("DAEMON_NOT_RUNNING");
expect(
classifyRuntimeError("permission denied while trying to connect"),
).toBe("PERMISSION_DENIED");
expect(classifyRuntimeError("something else")).toBe("DOCKER_ERROR");
});
it("skips lines that are not JSON in the list", () => {
const rows = parseContainerList(
'{"id":"a","name":"web"}\nWARNING: x\n{"id":"b","name":"db"}\n',
);
expect(rows.map((r) => r.name)).toEqual(["web", "db"]);
});
it("splits the stats columns", () => {
const stats = parseContainerStats(
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
);
expect(stats).toMatchObject({
cpu: "1.5%",
memoryUsed: "10MiB",
memoryLimit: "1GiB",
netInput: "1kB",
netOutput: "2kB",
pids: "3",
});
});
const ps = [
`{"name":"web","state":"running","status":"Up 2 hours"}`,
`{"name":"db","state":"running","status":"Up 2 hours (unhealthy)"}`,
`{"name":"old","state":"exited","status":"Exited (0)"}`,
].join("\n");
it("counts running, stopped and unhealthy containers", () => {
expect(countContainers(ps)).toEqual({
running: 2,
stopped: 1,
unhealthy: 1,
total: 3,
});
});
it("turns two snapshots into events", () => {
const before = parseContainerStates(ps);
const after = parseContainerStates(
[
`{"name":"web","state":"exited","status":"Exited (1)"}`,
`{"name":"db","state":"running","status":"Up (unhealthy)"}`,
`{"name":"old","state":"running","status":"Up 1 second"}`,
`{"name":"new","state":"running","status":"Up"}`,
].join("\n"),
);
expect(diffContainerStates(before, after)).toEqual([
{ container: "web", event: "exited" },
{ container: "old", event: "started" },
]);
});
});
describe("compose", () => {
it("reads stack status text", () => {
expect(parseStackStatus("running(2), exited(1)")).toEqual({
state: "partial",
running: 2,
total: 3,
});
expect(parseStackStatus("paused(2)")).toMatchObject({ state: "paused" });
expect(parseStackStatus("")).toMatchObject({ state: "not-deployed" });
});
it("reads projects from compose ls", () => {
const stacks = parseComposeProjects(
JSON.stringify([
{
Name: "web",
Status: "running(2)",
ConfigFiles: "/opt/stacks/web/compose.yaml",
},
{ Name: "Bad Name", Status: "running(1)", ConfigFiles: "" },
]),
);
expect(stacks).toEqual([
{
name: "web",
managed: false,
state: "running",
running: 2,
total: 2,
composeFile: "/opt/stacks/web/compose.yaml",
},
]);
});
it("reads services from compose ps, one line or one array", () => {
const row = {
Service: "app",
Name: "web-app-1",
ID: "abc",
Image: "nginx",
State: "running",
Status: "Up",
Health: "",
Publishers: [
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 8080, TargetPort: 80 },
{ PublishedPort: 0, TargetPort: 443 },
],
};
const lines = parseServices(JSON.stringify(row));
expect(lines[0]).toMatchObject({ service: "app", ports: "8080:80" });
expect(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
).toHaveLength(2);
expect(
stackStateFromServices(
parseServices(JSON.stringify([row, { ...row, State: "exited" }])),
),
).toEqual({
state: "partial",
running: 1,
total: 2,
});
});
it("maps actions to compose commands", () => {
expect(composeArgsFor("redeploy", "app", {})).toEqual([
"pull app",
"up -d --remove-orphans app",
]);
expect(
composeArgsFor("destroy", undefined, { removeVolumes: true }),
).toEqual(["down --remove-orphans -v"]);
});
});
@@ -1,400 +0,0 @@
import { describe, it, expect } from "vitest";
import {
buildFilesystemList,
filterExcludedFilesystems,
findWorstMountIndex,
liveCmd,
mergeMonitoredFilesystems,
parseCpuLine,
parseDfLines,
parseLive,
selectPrimaryFilesystem,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("parseCpuLine", () => {
it("parses a standard /proc/stat cpu line", () => {
// user nice system idle iowait irq softirq
const result = parseCpuLine("cpu 100 0 50 800 30 0 20");
expect(result).toBeDefined();
// idle = idle(800) + iowait(30)
expect(result?.idle).toBe(830);
// total = sum of all fields
expect(result?.total).toBe(100 + 0 + 50 + 800 + 30 + 0 + 20);
});
it("tolerates leading/trailing whitespace", () => {
const result = parseCpuLine(" cpu 1 2 3 4 ");
expect(result?.total).toBe(10);
expect(result?.idle).toBe(4);
});
it("returns undefined for non-cpu lines", () => {
expect(parseCpuLine("cpu0 1 2 3 4")).toBeUndefined();
expect(parseCpuLine("intr 12345")).toBeUndefined();
});
it("returns undefined when there are fewer than 4 numeric fields", () => {
expect(parseCpuLine("cpu 1 2 3")).toBeUndefined();
});
});
describe("parseDfLines", () => {
it("parses df -T -P output into rows", () => {
const output =
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(2);
expect(rows[0].mount).toBe("/");
expect(rows[0].type).toBe("ext4");
expect(rows[1].mount).toBe("/data");
});
it("filters out pseudo filesystems", () => {
const output =
"tmpfs tmpfs 8000 0 8000 0% /dev/shm\n" +
"overlay overlay 100 50 50 50% /\n" +
"/dev/sda1 ext4 100 50 50 50% /mnt/data\n";
const rows = parseDfLines(output);
expect(rows).toHaveLength(1);
expect(rows[0].mount).toBe("/mnt/data");
});
it("captures the filesystem type for network shares", () => {
const output =
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n";
const rows = parseDfLines(output);
expect(rows[0].type).toBe("nfs4");
expect(rows[1].type).toBe("cifs");
});
it("keeps full image and firmware mounts out of disk usage", () => {
const rows = parseDfLines(
"/dev/loop0 squashfs 100 100 0 100% /snap/core/1\n" +
"efivarfs efivarfs 100 89 11 89% /sys/firmware/efi/efivars\n" +
"/dev/sda1 ext4 1000 400 600 40% /\n",
);
expect(rows.map((row) => row.mount)).toEqual(["/"]);
expect(findWorstMountIndex(rows)).toEqual({
index: 0,
usedBytes: 400,
totalBytes: 1000,
});
});
it("retains real storage without requiring a /dev source", () => {
const rows = parseDfLines(
"/dev/sda1 btrfs 1000 400 600 40% /\n" +
"tank/data zfs 2000 1000 1000 50% /data\n" +
"nas:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
);
expect(rows.map((row) => row.type)).toEqual([
"btrfs",
"zfs",
"nfs4",
"cifs",
]);
});
});
describe("findWorstMountIndex", () => {
it("picks the most-utilized mount, not just the first row", () => {
const rows = parseDfLines(
"/dev/nvme0n1p2 ext4 3848290697216 1046898851840 2606516101120 29% /\n" +
"/dev/nvme1n1p1 ext4 15393162788864 15239230844928 153931922841 99% /data\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
expect(worst.totalBytes).toBe(15393162788864);
expect(worst.usedBytes).toBe(15239230844928);
});
it("falls back to the only mount available", () => {
const rows = parseDfLines("/dev/sda1 ext4 100 30 70 30% /\n");
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(0);
});
it("skips rows with invalid or zero totals", () => {
const rows = parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n" +
"/dev/sda2 ext4 100 40 60 40% /ok\n",
);
const worst = findWorstMountIndex(rows);
expect(worst.index).toBe(1);
});
it("returns index -1 when there are no usable rows", () => {
const worst = findWorstMountIndex([]);
expect(worst.index).toBe(-1);
expect(worst.totalBytes).toBe(0);
});
});
const BYTES_OUTPUT =
"/dev/nvme0n1p2 ext4 1000 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2000 1900 100 95% /data\n";
const HUMAN_OUTPUT =
"/dev/nvme0n1p2 ext4 1.0K 400 600 40% /\n" +
"/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n";
describe("buildFilesystemList", () => {
it("returns every real filesystem with byte maths and human strings", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(list).toHaveLength(2);
expect(list[0]).toMatchObject({
mount: "/",
type: "ext4",
percent: 40,
usedHuman: "400",
totalHuman: "1.0K",
availableHuman: "600",
usedBytes: 400,
totalBytes: 1000,
});
expect(list[1]).toMatchObject({ mount: "/data", percent: 95 });
});
it("matches human rows by mount when the row counts differ", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines("/dev/nvme1n1p1 ext4 2.0K 1.9K 100 95% /data\n"),
);
expect(list[0].totalHuman).toBeNull();
expect(list[1].totalHuman).toBe("2.0K");
});
it("drops filesystems with a zero or invalid total", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 0 0 0 0% /broken\n/dev/sda2 ext4 100 40 60 40% /ok\n",
),
[],
);
expect(list).toHaveLength(1);
expect(list[0].mount).toBe("/ok");
});
});
describe("selectPrimaryFilesystem", () => {
it("prefers root over a fuller secondary mount", () => {
const list = buildFilesystemList(
parseDfLines(BYTES_OUTPUT),
parseDfLines(HUMAN_OUTPUT),
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/");
});
it("falls back to the most-utilized mount when there is no root", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 100 900 10% /mnt/a\n" +
"/dev/sda2 ext4 1000 800 200 80% /mnt/b\n",
),
[],
);
expect(selectPrimaryFilesystem(list)?.mount).toBe("/mnt/b");
});
it("returns null for an empty list", () => {
expect(selectPrimaryFilesystem([])).toBeNull();
});
});
describe("filterExcludedFilesystems", () => {
const list = buildFilesystemList(
parseDfLines(
"/dev/sda1 ext4 1000 400 600 40% /\n" +
"nas.local:/export nfs4 2000 1900 100 95% /mnt/nas\n" +
"//server/share cifs 2000 1000 1000 50% /mnt/smb\n",
),
[],
);
it("returns the same list when no mounts are excluded", () => {
expect(filterExcludedFilesystems(list)).toHaveLength(3);
expect(filterExcludedFilesystems(list, [])).toHaveLength(3);
});
it("excludes an exact mount path match", () => {
const filtered = filterExcludedFilesystems(list, ["/mnt/nas"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("excludes by filesystem type substring, case-insensitively", () => {
const filtered = filterExcludedFilesystems(list, ["NFS"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/", "/mnt/smb"]);
});
it("supports excluding multiple network filesystem types at once", () => {
const filtered = filterExcludedFilesystems(list, ["nfs", "cifs"]);
expect(filtered.map((fs) => fs.mount)).toEqual(["/"]);
});
it("ignores blank/whitespace-only entries", () => {
const filtered = filterExcludedFilesystems(list, [" ", ""]);
expect(filtered).toHaveLength(3);
});
});
describe("mergeMonitoredFilesystems", () => {
it("adds an arbitrary path with a user label", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const custom = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/config", label: "Home Assistant" }],
custom,
);
expect(result).toHaveLength(2);
expect(result[1]).toMatchObject({
mount: "/config",
label: "Home Assistant",
totalBytes: 1000,
});
});
it("labels a path that is already a detected mount", () => {
const detected = buildFilesystemList(
parseDfLines("/dev/sda1 ext4 1000 400 600 40% /data\n"),
[],
);
const result = mergeMonitoredFilesystems(
detected,
[{ path: "/data", label: "Media" }],
detected,
);
expect(result).toHaveLength(1);
expect(result[0].label).toBe("Media");
});
});
const out = (
agg: [number, number],
core: [number, number],
rx: number,
up: number,
procs: string,
page = "4096",
) =>
[
`cpu ${agg[0]} 0 0 ${agg[1]} 0 0 0 0 0 0\ncpu0 ${core[0]} 0 0 ${core[1]} 0 0 0 0 0 0`,
"MemTotal: 1000 kB\nMemAvailable: 400 kB\nSwapTotal: 0 kB\nSwapFree: 0 kB",
`0.1 0.2 0.3 1/2 3\n${up} 5`,
`Inter-|\n eth0: ${rx} 0 0 0 0 0 0 0 ${rx * 2} 0\n lo: 9 0 0 0 0 0 0 0 9 0`,
page,
procs,
].join("\n@@\n");
const proc = (pid: number, ticks: number, start: number, name = "java") =>
`${pid}\t${ticks}\t10\t${start}\tR\t1\t${name}\n`;
describe("parseLive extras", () => {
it("reports state, parent, CPU mix, cache and the running count", () => {
const mk = (user: number, sys: number, wait: number, idle: number) =>
out([user, idle], [user, idle], 1, 10, procs).replace(
/cpu {2}\d+ 0 0 \d+ 0 0 0 0/,
`cpu ${user} 0 ${sys} ${idle} ${wait} 0 0 0`,
);
const procs = "5\t1\t10\t7\tR\t2\tjava\n6\t1\t10\t8\tS\t5\tsh\n";
const a = parseLive(mk(0, 0, 0, 0), undefined, 0);
const b = parseLive(mk(30, 10, 10, 50), a.next, 1000);
expect(b.sample.cpuMix).toMatchObject({ user: 30, system: 10, iowait: 10 });
expect(b.sample.running).toBe(1);
expect(b.sample.procs.find((p) => p.pid === 6)).toMatchObject({
state: "S",
ppid: 5,
});
expect(b.sample.memCachedKb).toBe(0);
});
});
describe("liveCmd", () => {
it("asks for the page size once, then passes the cached number", () => {
expect(liveCmd()).toContain('pg="$(getconf PAGESIZE)"');
expect(liveCmd(16384)).toContain("pg=16384 ");
expect(liveCmd(16384)).not.toContain("getconf");
});
});
describe("parseLive", () => {
it("has no rates on the first sample", () => {
const { sample } = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
expect(sample.memUsedKb).toBe(600);
});
it("derives cpu, per-core, net and process rates from the delta", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 0, 1)),
undefined,
);
// aggregate: +50 busy, +50 idle; core 0: +25 busy, +75 idle
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 50, 1)),
a.next,
);
expect(sample.cpu).toBeCloseTo(50, 0);
expect(sample.cores[0]).toBeCloseTo(25, 0);
expect(sample.net).toEqual([{ name: "eth0", rxBps: 2000, txBps: 4000 }]);
expect(sample.procs[0]).toMatchObject({ pid: 7, name: "java", memKb: 40 });
expect(sample.procs[0].cpu).toBeCloseTo(50, 0);
});
it("does not charge a reused pid with the old process's ticks", () => {
const a = parseLive(
out([100, 100], [100, 100], 1000, 10, proc(7, 5, 1)),
undefined,
);
// same pid, new start time, lower ticks must not look like a huge delta
const { sample } = parseLive(
out([150, 150], [125, 175], 3000, 11, proc(7, 9000, 2)),
a.next,
);
expect(sample.procs[0].cpu).toBe(0);
});
it("keeps the cached page size when a run omits it, and keeps names with tabs", () => {
const a = parseLive(
out([1, 1], [1, 1], 1, 10, proc(1, 0, 1), "16384"),
undefined,
);
expect(a.next.pageBytes).toBe(16384);
const b = parseLive(
out([2, 2], [2, 2], 2, 11, proc(1, 0, 1, "a b"), ""),
a.next,
);
expect(b.next.pageBytes).toBe(16384);
expect(b.sample.procs[0]).toMatchObject({ name: "a\tb", memKb: 160 });
});
it("starts rates again after a reboot", () => {
const a = parseLive(
out([100, 100], [100, 100], 5000, 900, proc(7, 0, 1)),
undefined,
);
const { sample } = parseLive(
out([10, 10], [10, 10], 20, 5, proc(7, 0, 1)),
a.next,
);
expect(sample.cpu).toBeNull();
expect(sample.net[0].rxBps).toBeNull();
});
});
@@ -1,244 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isUnit,
journalArgs,
parseBoots,
parseDetect,
parseEntry,
parseIsActive,
parseJournal,
parseJournalQuery,
parseOpenRcList,
parseSystemdTime,
parseTimers,
parseUnitList,
parseUsage,
shellQuote as q,
vacuumScript,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("names", () => {
it("quotes for bash", () => {
expect(q("a'b")).toBe(`'a'\\''b'`);
});
it("takes unit names and refuses anything else", () => {
expect(isUnit("nginx.service")).toBe(true);
expect(isUnit("getty@tty1.service")).toBe(true);
expect(isUnit("dev-disk-by\\x2duuid-1.device")).toBe(true);
expect(isUnit("-rf.service")).toBe(false);
expect(isUnit("a;b.service")).toBe(false);
expect(isUnit("a b.service")).toBe(false);
expect(isUnit("nginx")).toBe(false);
expect(isUnit("$(id).service")).toBe(false);
});
});
describe("systemd parsing", () => {
const list = [
"cron.service loaded active running Regular background program",
"● bad.service loaded failed failed Broken thing",
"getty@tty1.service loaded active running Getty on tty1",
"@@TERMIX_FILES@@",
"cron.service enabled enabled",
"bad.service disabled enabled",
"getty@.service enabled enabled",
"old.service masked -",
"",
].join("\n");
it("merges loaded units with unit files and skips templates", () => {
const units = parseUnitList(list);
expect(units.map((u) => u.name)).toEqual([
"bad.service",
"cron.service",
"getty@tty1.service",
"old.service",
]);
const cron = units.find((u) => u.name === "cron.service")!;
expect(cron).toMatchObject({
active: "active",
sub: "running",
description: "Regular background program",
fileState: "enabled",
preset: "enabled",
});
expect(units.find((u) => u.name === "bad.service")!.active).toBe("failed");
expect(units.find((u) => u.name === "old.service")).toMatchObject({
load: "masked",
active: "inactive",
preset: "",
});
});
it("reads UTC times", () => {
expect(parseSystemdTime("Thu 2026-10-22 02:03:51 UTC")).toBe(
Date.UTC(2026, 9, 22, 2, 3, 51),
);
expect(parseSystemdTime("")).toBeNull();
expect(parseSystemdTime("n/a")).toBeNull();
});
it("reads timers from systemctl show blocks", () => {
const out = [
"TimersCalendar={ OnCalendar=*-*-* 06,18:00:00 ; next_elapse=@1792605600 }",
"NextElapseUSecRealtime=Thu 2026-10-22 02:03:51 UTC",
"LastTriggerUSec=Wed 2026-10-21 17:02:58 UTC",
"Id=apt-daily.timer",
"Triggers=apt-daily.service",
"ActiveState=active",
"",
"TimersMonotonic={ OnUnitActiveUSec=1d ; next_elapse=1d }",
"TimersMonotonic={ OnBootUSec=15min ; next_elapse=15min }",
"NextElapseUSecRealtime=",
"LastTriggerUSec=",
"Id=clean.timer",
"Triggers=clean.service",
].join("\n");
const timers = parseTimers(out);
expect(timers[0]).toMatchObject({
name: "apt-daily.timer",
activates: "apt-daily.service",
next: Date.UTC(2026, 9, 22, 2, 3, 51),
schedule: ["OnCalendar=*-*-* 06,18:00:00"],
});
expect(timers[1]).toMatchObject({
name: "clean.timer",
next: null,
last: null,
schedule: ["OnUnitActiveUSec=1d", "OnBootUSec=15min"],
});
});
it("maps is-active lines to units", () => {
const states = parseIsActive(
["a.service", "b.service"],
"active\nfailed\n",
);
expect(states.get("a.service")).toBe("active");
expect(states.get("b.service")).toBe("failed");
});
it("reads what detect prints", () => {
expect(
parseDetect(
"init=systemd\nversion=257\nos=Debian GNU/Linux 13 (trixie)\nuid=0\njournal=1\nfailed=2\n",
),
).toMatchObject({
init: "systemd",
version: "257",
root: true,
journal: true,
failed: 2,
});
expect(parseDetect("init=weird\nuid=1000\n")).toMatchObject({
init: "none",
root: false,
});
});
});
describe("journal", () => {
it("checks filters", () => {
const query = parseJournalQuery({
unit: ["nginx.service", "ssh.service"],
priority: "3",
since: "-1h",
boot: "-1",
grep: "fail'ed",
lines: "99999",
});
expect(query.lines).toBe(5000);
const args = journalArgs(query);
expect(args).toContain("-u 'nginx.service' -u 'ssh.service'");
expect(args).toContain("-p 3");
expect(args).toContain("--since '-1h'");
expect(args).toContain("-b '-1'");
expect(args).toContain(`-g 'fail'\\''ed'`);
expect(() => parseJournalQuery({ unit: "x;rm" })).toThrow();
expect(() => parseJournalQuery({ priority: "9" })).toThrow();
expect(() => parseJournalQuery({ since: "$(id)" })).toThrow();
expect(() => parseJournalQuery({ boot: "abc" })).toThrow();
});
it("parses entries, binary messages included", () => {
const entry = parseEntry(
JSON.stringify({
__REALTIME_TIMESTAMP: "1792612429684000",
PRIORITY: "3",
_SYSTEMD_UNIT: "x.service",
SYSLOG_IDENTIFIER: "x",
_PID: "12",
MESSAGE: [104, 105],
}),
);
expect(entry).toEqual({
time: 1792612429684,
priority: 3,
unit: "x.service",
ident: "x",
pid: "12",
message: "hi",
});
expect(parseEntry("not json")).toBeNull();
});
it("filters here when journalctl has no -g", () => {
const line = (message: string) =>
JSON.stringify({ __REALTIME_TIMESTAMP: "1000", MESSAGE: message });
const out = `@@TERMIX_NOGREP@@\n${line("Disk Error")}\n${line("ok")}\n`;
const query = parseJournalQuery({ grep: "error", lines: "10" });
expect(parseJournal(out, query).map((e) => e.message)).toEqual([
"Disk Error",
]);
});
it("reads boots and disk use", () => {
const boots = parseBoots(
[
"IDX BOOT ID FIRST ENTRY LAST ENTRY",
"-1 d2785a657f4c4561a1f436db27555b50 Tue 2026-10-20 13:56:45 UTC Wed 2026-10-21 10:37:00 UTC",
" 0 61b15588a75b4c0cb8dea321fcc438ab Wed 2026-10-21 10:38:10 UTC Wed 2026-10-21 20:00:00 UTC",
].join("\n"),
);
expect(boots.map((b) => b.offset)).toEqual([0, -1]);
expect(boots[0].first).toBe("Wed 2026-10-21 10:38:10 UTC");
expect(
parseUsage(
"Archived and active journals take up 378.6M in the file system.",
),
).toBe("378.6M");
});
it("only takes sane vacuum values", () => {
expect(vacuumScript({ size: "500M" })).toContain("--vacuum-size=500M");
expect(vacuumScript({ time: "4w" })).toContain("--vacuum-time=4w");
expect(() => vacuumScript({ size: "1; rm -rf /" })).toThrow();
expect(() => vacuumScript({})).toThrow();
});
});
describe("openrc", () => {
it("maps services and runlevels", () => {
const units = parseOpenRcList(
[
" sshd [ started ]",
" crond [ stopped ]",
" nginx [ crashed ]",
"@@TERMIX_RUNLEVELS@@",
" sshd | default",
" nginx | ",
"",
].join("\n"),
);
expect(units.find((u) => u.name === "sshd")).toMatchObject({
active: "active",
fileState: "enabled",
});
expect(units.find((u) => u.name === "nginx")).toMatchObject({
active: "failed",
fileState: "disabled",
});
expect(units.find((u) => u.name === "crond")!.active).toBe("inactive");
});
});
@@ -1,130 +0,0 @@
import { execFileSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import {
actionScript,
errLine,
isActiveScript,
listUnitsScript,
openRcActionScript,
parseKeys,
passwordSudoScript,
scriptError,
scriptShell,
SUDO_REFUSED_RE,
SUDO_WRONG_RE,
toBase64,
utf8Decode,
utf8Encode,
watchdog,
} from "@termix-ssh/plugin-sdk/host-scripts";
describe("text helpers", () => {
it("encodes base64 like Buffer does", () => {
for (const text of [
"",
"a",
"ab",
"abc",
"héllo wörld",
"emoji 🙂 here",
"line\nbreak",
]) {
expect(toBase64(text)).toBe(Buffer.from(text, "utf8").toString("base64"));
}
});
it("round trips UTF-8 and replaces bad bytes", () => {
const text = "plain, ünïcode, 日本, 🙂";
expect(utf8Decode(utf8Encode(text))).toBe(text);
expect(Array.from(utf8Encode(text))).toEqual(
Array.from(Buffer.from(text, "utf8")),
);
expect(utf8Decode([0x61, 0xff, 0x62])).toBe("a\ufffdb");
expect(utf8Decode([0xe6, 0x97])).toBe("\ufffd");
});
it("reads key=value lines", () => {
expect(parseKeys("a=1\nb = two=2\njunk\n")).toEqual({ a: "1", b: "two=2" });
});
});
describe("script shells", () => {
it("always reads the script from stdin", () => {
for (const mode of ["plain", "auto", "root"] as const) {
expect(scriptShell(mode)).toContain("bash -s");
}
expect(scriptShell("auto")).toContain("sudo -n true");
expect(scriptShell("root-only")).toContain("SCRIPT_ERR:");
});
it("collects error lines, old and new markers", () => {
expect(scriptError("x\nSCRIPT_ERR:one\nSERVICES_ERR:two\n")).toBe(
"one\ntwo",
);
expect(errLine("it's broken")).toBe(`echo 'SCRIPT_ERR:it'\\''s broken'`);
});
it("matches sudo refusals", () => {
expect(SUDO_REFUSED_RE.test("sudo: a password is required")).toBe(true);
expect(SUDO_WRONG_RE.test("sudo: 1 incorrect password attempt")).toBe(true);
});
it("keeps the sudo password out of the command line", () => {
const { command, stdin } = passwordSudoScript(
"systemctl restart 'a.service'\n",
"s3cr'et $(id)",
);
expect(command).not.toContain("s3cr");
expect(stdin.split("\n")[0]).toBe("IFS= read -r TERMIX_PW");
expect(stdin.split("\n")[1]).toBe("s3cr'et $(id)");
expect(stdin).toContain("sudo -k -S -p ''");
expect(stdin).not.toContain("systemctl restart");
expect(() => passwordSudoScript("x", "a\nb")).toThrow();
});
it.runIf(process.platform !== "win32")(
"runs the script and never runs the password",
() => {
const { stdin } = passwordSudoScript(
'echo "ran:$1${TERMIX_PW:-none}"\n',
"touch /tmp/should-not-exist",
);
// No sudo here: swap it for a fake that drops its own stdin line.
const fake = stdin.replace(
/printf '%s\\n' "\$P" \| sudo -k -S -p '' env PATH="\$PATH" [^\n]* bash -c "\$TERMIX_S"/,
'printf "%s\\n" "$P" | { IFS= read -r _; bash -c "$TERMIX_S"; }',
);
expect(fake).not.toBe(stdin);
const out = execFileSync(
"bash",
["-c", "sudo() { return 1; }; export -f sudo; exec bash -s"],
{
input: fake,
encoding: "utf8",
},
);
expect(out.trim()).toBe("ran:none");
},
);
});
describe("unit scripts", () => {
it("quotes units and refuses bad names", () => {
expect(actionScript("a.service", "enable-now")).toContain(
"systemctl enable --now -- 'a.service'",
);
expect(() => actionScript("a;b.service", "start")).toThrow();
expect(isActiveScript(["a.service", "b.timer"])).toContain(
"-- 'a.service' 'b.timer'",
);
expect(() => listUnitsScript("bogus" as never)).toThrow();
expect(openRcActionScript("sshd", "enable")).toContain(
"rc-update add 'sshd' default",
);
expect(() => openRcActionScript("sshd", "mask")).toThrow();
});
it("stops a watched command once stdin closes", () => {
expect(watchdog("journalctl -f")).toContain("cat >/dev/null");
});
});
-20
View File
@@ -621,26 +621,6 @@ describe("dependencies", () => {
});
});
describe("safe mode", () => {
afterEach(() => {
delete process.env.TERMIX_SAFE_MODE;
});
it("refuses to start a plugin that is not a layout or theme, but still stops one", async () => {
createFixturePlugin({ id: "base", root: bundled });
const loader = await boot();
await manage.setPluginState("base", false);
process.env.TERMIX_SAFE_MODE = "1";
await expect(manage.setPluginState("base", true)).rejects.toMatchObject({
status: 409,
code: "SAFE_MODE",
});
expect(loader.get("base")?.state).toBe("stopped");
expect(db.rows.get("base")?.state).toBe("disabled");
});
});
describe("reloading a bundled plugin for the dev runner", () => {
it("loads the rebuilt copy and restarts what depends on it", async () => {
createFixturePlugin({ id: "base", root: bundled });
-104
View File
@@ -650,110 +650,6 @@ describe("panels, dashboard cards and guest pages", () => {
});
});
describe("contributes.mobile", () => {
const tabs = [
{ id: "sample", titleKey: "nav.sample", icon: "Box", openFrom: ["rail"] },
];
it("accepts each mode with its own fields", () => {
for (const mobile of [
{
mode: "webview",
entry: "dist/mobile.js",
sizes: ["phone", "tablet"],
views: ["sample"],
note: "mobile.note",
},
{ mode: "webview" },
{ mode: "native", feature: "file-manager" },
{ mode: "none", note: "Not on phones" },
]) {
expect(validateManifest(base({ contributes: { tabs, mobile } }))).toEqual(
[],
);
}
});
it("needs a known mode and rejects unknown fields", () => {
const errors = validateManifest(
base({ contributes: { mobile: { mode: "app", width: 3 } } }),
).join();
expect(errors).toMatch(/Unknown field "width" in "contributes\.mobile"/);
expect(errors).toMatch(/contributes\.mobile\.mode must be one of/);
expect(
validateManifest(base({ contributes: { mobile: "webview" } })).join(),
).toMatch(/"contributes\.mobile" must be an object/);
});
it("keeps webview and native fields to their own mode", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
mobile: {
mode: "native",
entry: "dist/mobile.js",
views: ["sample"],
},
},
}),
).join();
expect(errors).toMatch(/mobile\.entry is only for mode "webview"/);
expect(errors).toMatch(/mobile\.views is only for mode "webview"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", feature: "x1" } } }),
).join(),
).toMatch(/mobile\.feature is only for mode "native"/);
});
it("puts the entry beside the frontend bundle", () => {
const check = (entry: string, frontend?: string) =>
validateManifest(
base({
...(frontend ? { frontend } : {}),
contributes: { mobile: { mode: "webview", entry } },
}),
).join();
expect(check("dist/mobile.js")).toBe("");
expect(check("./dist/mobile.js")).toBe("");
expect(check("build/mobile.js", "build/frontend.js")).toBe("");
expect(check("mobile.js")).toMatch(/same folder as the frontend bundle/);
expect(check("dist/mobile.css")).toMatch(/entry must be a \.js file/);
expect(check("../dist/mobile.js")).toMatch(/relative path inside/);
});
it("checks sizes, views and the feature id", () => {
const errors = validateManifest(
base({
contributes: {
tabs,
panels: [{ id: "side", titleKey: "nav.side" }],
mobile: {
mode: "webview",
sizes: ["phone", "phone", "watch"],
views: ["sample", "side", "missing"],
},
},
}),
).join();
expect(errors).toMatch(/sizes duplicates "phone"/);
expect(errors).toMatch(/sizes entries must be one of: phone, tablet/);
expect(errors).toMatch(/views entries must be ids .*"missing"/);
expect(errors).not.toMatch(/"side"/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "webview", sizes: [] } } }),
).join(),
).toMatch(/sizes must be a non-empty array/);
expect(
validateManifest(
base({ contributes: { mobile: { mode: "native", feature: "Files" } } }),
).join(),
).toMatch(/mobile\.feature must match/);
});
});
describe("named service providers", () => {
const provides = (names: unknown) => [
{
@@ -8,23 +8,10 @@ import * as tar from "tar";
const fetchMock = vi.hoisted(() => vi.fn());
const testKey = vi.hoisted(() => ({ value: null as null | object }));
vi.mock("../../utils/safe-outbound-fetch.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../utils/safe-outbound-fetch.js")
>()),
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
safeOutboundFetch: fetchMock,
}));
const proxyMock = vi.hoisted(() => ({
on: false,
fetch: vi.fn(),
}));
vi.mock("../../utils/proxy-agent.js", () => ({
getProxyAgent: () => (proxyMock.on ? {} : undefined),
fetchWithProxy: proxyMock.fetch,
}));
vi.mock("../../utils/app-version.js", () => ({
getLocalVersion: () => "26.10.3",
}));
@@ -56,11 +43,9 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
import {
communitySource,
downloadRelease,
fetchIndexAt,
fetchRegistryIndex,
fetchRegistryStats,
findRelease,
getRegistryPrivateHosts,
latestOnChannel,
getStatsUrl,
canRunRelease,
@@ -424,91 +409,6 @@ describe("fetchRegistryIndex", () => {
});
});
describe("registry fetches with a proxy", () => {
afterEach(() => {
proxyMock.on = false;
proxyMock.fetch.mockReset();
});
it("goes through the proxy when one is configured", async () => {
proxyMock.on = true;
proxyMock.fetch.mockResolvedValueOnce(
response(JSON.stringify({ plugins: [] })),
);
await fetchIndexAt("https://registry.example/index.json");
expect(proxyMock.fetch).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
});
it("still refuses a private address typed in as a literal", async () => {
proxyMock.on = true;
await expect(fetchIndexAt("https://127.0.0.1/index.json")).rejects.toThrow(
"Private destinations are not allowed",
);
expect(proxyMock.fetch).not.toHaveBeenCalled();
});
});
describe("private registry hosts", () => {
afterEach(() => {
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
});
it("reads a comma separated list of host names", () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
" GitLab.lan ,, 192.168.1.5 ";
expect(getRegistryPrivateHosts()).toEqual(["gitlab.lan", "192.168.1.5"]);
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
expect(getRegistryPrivateHosts()).toEqual([]);
});
it("brings each entry into the form the fetch compares against", () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
"GitLab.LAN,[FD00::0001],fd00::2,bücher.lan";
expect(getRegistryPrivateHosts()).toEqual([
"gitlab.lan",
"fd00::1",
"fd00::2",
"xn--bcher-kva.lan",
]);
});
it("drops entries with more than a host", () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
"gitlab.lan:8443,https://git.lan,[fd00::1]:80,nas.lan";
expect(getRegistryPrivateHosts()).toEqual(["nas.lan"]);
});
it("allows the listed hosts on every hop", async () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
fetchMock
.mockResolvedValueOnce(
response("", 302, { location: "https://gitlab.lan/raw/index.json" }),
)
.mockResolvedValueOnce(response(JSON.stringify({ plugins: [] })));
await fetchIndexAt("https://gitlab.lan/index.json");
expect(fetchMock.mock.calls.map((call) => call[2])).toEqual([
["gitlab.lan"],
["gitlab.lan"],
]);
});
it("still blocks a redirect to a private host that is not listed", async () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
const actual = await vi.importActual<
typeof import("../../utils/safe-outbound-fetch.js")
>("../../utils/safe-outbound-fetch.js");
fetchMock
.mockResolvedValueOnce(
response("", 302, { location: "https://127.0.0.1/index.json" }),
)
.mockImplementationOnce(actual.safeOutboundFetch);
await expect(fetchIndexAt("https://gitlab.lan/index.json")).rejects.toThrow(
"Private destinations are not allowed",
);
});
});
/** A real .tmxplug holding only a manifest, enough for the checks. */
function pack(manifest: Record<string, unknown>): Buffer {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
@@ -1,62 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import {
mayRunInSafeMode,
safeModeOn,
safeModePluginIds,
} from "../../plugins/safe-mode.js";
import type { PluginManifest } from "../../plugins/manifest.js";
function plugin(id: string, manifest: Partial<PluginManifest> = {}) {
return { id, manifest: { id, ...manifest } as PluginManifest };
}
const plugins = [
plugin("layout", {
contributes: { layouts: [{ id: "classic" }] },
dependencies: { helper: "^1.0.0" },
} as Partial<PluginManifest>),
plugin("themes", {
contributes: { themes: [{ id: "dark" }] },
} as Partial<PluginManifest>),
plugin("helper"),
plugin("docker"),
];
afterEach(() => {
delete process.env.TERMIX_SAFE_MODE;
});
describe("safeModeOn", () => {
it("reads 1 and true, anything else is off", () => {
expect(safeModeOn()).toBe(false);
process.env.TERMIX_SAFE_MODE = "1";
expect(safeModeOn()).toBe(true);
process.env.TERMIX_SAFE_MODE = " TRUE ";
expect(safeModeOn()).toBe(true);
process.env.TERMIX_SAFE_MODE = "0";
expect(safeModeOn()).toBe(false);
});
});
describe("safeModePluginIds", () => {
it("keeps layout and theme plugins and what they depend on", () => {
expect([...safeModePluginIds(plugins)].sort()).toEqual([
"helper",
"layout",
"themes",
]);
});
});
describe("mayRunInSafeMode", () => {
it("lets every plugin run when safe mode is off", () => {
expect(mayRunInSafeMode("docker", plugins)).toBe(true);
});
it("only lets look plugins run when safe mode is on", () => {
process.env.TERMIX_SAFE_MODE = "1";
expect(mayRunInSafeMode("docker", plugins)).toBe(false);
expect(mayRunInSafeMode("layout", plugins)).toBe(true);
expect(mayRunInSafeMode("helper", plugins)).toBe(true);
});
});
@@ -1,77 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
row: { sharedSource: '{"owner":"x"}' } as { sharedSource: string | null },
updates: [] as unknown[],
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentRepositoryContext: () => ({
drizzle: {
select: () => ({
from: () => ({ where: () => ({ limit: async () => [h.row] }) }),
}),
update: () => ({
set: (values: unknown) => ({
where: async () => {
h.updates.push(values);
},
}),
}),
},
}),
}));
const { rejectSharedCopyWrites } =
await import("../../sync/shared-copy-guard.js");
function run(body: Record<string, unknown>) {
const guard = rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/);
const res = {
code: 0,
payload: undefined as unknown,
status(code: number) {
this.code = code;
return this;
},
json(payload: unknown) {
this.payload = payload;
return this;
},
};
const next = vi.fn();
return guard(
{ method: "PUT", path: "/db/host/5", body } as never,
res as never,
next,
).then(() => ({ res, next }));
}
beforeEach(() => {
process.env.ELECTRON_EMBEDDED = "true";
h.row = { sharedSource: '{"owner":"x"}' };
h.updates = [];
});
describe("shared copy guard", () => {
it("refuses edits to a shared copy", async () => {
const { res, next } = await run({ name: "new" });
expect(res.code).toBe(403);
expect(next).not.toHaveBeenCalled();
expect(h.updates).toEqual([]);
});
it("still lets the connection origin change on this device", async () => {
const { res, next } = await run({ name: "new", connectionOrigin: "local" });
expect(res.code).toBe(0);
expect(res.payload).toMatchObject({ connectionOrigin: "local" });
expect(next).not.toHaveBeenCalled();
expect(h.updates).toEqual([{ connectionOrigin: "local" }]);
});
it("passes hosts that are not shared copies", async () => {
h.row = { sharedSource: null };
const { next } = await run({ name: "new" });
expect(next).toHaveBeenCalled();
});
});
+3 -28
View File
@@ -1,10 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { prepareClientCacheVersion } from "@/lib/client-cache-version";
import {
TitleBar,
TITLE_BAR_OFFSET,
useTitleBarOverlayColors,
} from "@/shell/TitleBar";
import { StrictMode, Suspense, lazy, useState, useRef, useEffect } from "react";
import { createRoot } from "react-dom/client";
import "./ui/index.css";
@@ -12,7 +7,6 @@ import { ThemeProvider } from "@/components/theme-provider";
import "./ui/i18n/i18n";
import { isElectron } from "@/lib/electron";
import { isEmbeddedAuthFrame } from "@/lib/embedded-frame";
import { captureAppReturn } from "@/lib/app-return";
import { rememberViewLink, resumeViewLink } from "@/lib/view-link-resume";
import { getEmbeddedServerFailure } from "@/lib/embedded-server-status";
import { Toaster } from "@/components/sonner";
@@ -166,14 +160,9 @@ function FullscreenAppGate() {
}
function App() {
// Keeps the window controls the system draws in the colors of the theme.
useTitleBarOverlayColors();
const stored = getStoredAuth();
// A sign in for the mobile app always shows the login form, so the app gets
// its own fresh session.
const [appReturn] = useState(captureAppReturn);
const [phase, setPhase] = useState<Phase>(
stored?.loggedIn && !appReturn ? "verifying" : "idle-auth",
stored?.loggedIn ? "verifying" : "idle-auth",
);
const [authUsername, setAuthUsername] = useState(stored?.username ?? "");
const [verifyRetryCount, setVerifyRetryCount] = useState(0);
@@ -381,17 +370,10 @@ function App() {
const { t } = useTranslation();
const isTransitioning = phase === "fading-in" || phase === "fading-out";
// The desktop app draws a title bar on top of the window, so everything
// below it starts lower. The bar sets the offset only while it shows.
const titleBarOffset = TITLE_BAR_OFFSET;
if (phase === "verifying" || phase === "loading-app") {
return (
<div
className="fixed inset-0 flex items-center justify-center bg-background"
style={{ paddingTop: titleBarOffset }}
>
<TitleBar />
<div className="fixed inset-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted-foreground">{t("common.loading")}</p>
@@ -402,13 +384,8 @@ function App() {
return (
<>
<TitleBar />
{isTransitioning && (
<div
className="fixed inset-0 z-0 flex items-center justify-center bg-background"
style={{ paddingTop: titleBarOffset }}
>
<div className="fixed inset-0 z-0 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-muted-foreground">
@@ -439,7 +416,6 @@ function App() {
style={{
opacity: appOpacity,
pointerEvents: phase === "idle-app" ? "auto" : "none",
paddingTop: titleBarOffset,
}}
>
<Suspense fallback={null}>
@@ -456,7 +432,6 @@ function App() {
style={{
opacity: authOpacity,
pointerEvents: phase === "idle-auth" ? "auto" : "none",
paddingTop: titleBarOffset,
}}
>
<Auth onLogin={handleLogin} />
-8
View File
@@ -72,10 +72,6 @@ export type Host = {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;
@@ -186,10 +182,6 @@ export interface HostData {
terminalConfig?: HostTerminalConfig;
sshOptions?: HostSshOptions;
notes?: string;
/** Hex color, like #3b82f6. */
color?: string | null;
/** Lucide icon name. */
icon?: string | null;
useSocks5?: boolean;
socks5Host?: string;
-2
View File
@@ -19,8 +19,6 @@ export interface LookResponse {
defaultLook: { layout: string | null; theme: string | null };
/** TERMIX_SAFE_LAYOUT is set on the server. */
safeLayout: boolean;
/** TERMIX_SAFE_MODE is set: only layout and theme plugins run. */
safeMode?: boolean;
unavailable: UnavailableLook[];
}
+1 -1
View File
@@ -237,7 +237,7 @@ export interface PluginSummary {
name: string;
version: string;
enabled: boolean;
/** enabled | disabled | blocked | failed | safe-mode, or the loader's live state. */
/** enabled | disabled | blocked | failed, or the loader's live state. */
state: string;
/** A linked desktop leaves this plugin to its server. */
runsOnServer?: boolean;
+19 -33
View File
@@ -1,11 +1,9 @@
/* eslint-disable react-refresh/only-export-components */
import {
isEmbeddedAuthFrame,
isInElectronWebView,
isInMobileWebView,
type EmbeddedFrameWindow,
} from "@/lib/embedded-frame";
import { handOffToApp, pendingAppReturn } from "@/lib/app-return";
import { useState, useEffect, useCallback, useRef } from "react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -147,13 +145,6 @@ export function storeAuth(username: string) {
);
}
/** A browser login the mobile app asked for goes back to the app. */
async function handOffLogin(token?: string | null): Promise<boolean> {
if (!pendingAppReturn() || isEmbeddedAuthFrame()) return false;
const value = token || (await getCurrentToken());
return handOffToApp(value ? { token: value } : { error: "no_token" });
}
type AuthView = "login" | "register" | "reset" | "second-factor" | "external";
type ResetStep = "email" | "code" | "newpass";
@@ -340,9 +331,8 @@ export function Auth({ onLogin }: AuthProps) {
if (proxySigninHandledRef.current || isElectron()) return;
proxySigninHandledRef.current = true;
requestTrustedProxyLogin()
.then(async (result) => {
.then((result) => {
if (!result.enabled || !result.success) return;
if (await handOffLogin()) return;
storeAuth(result.username || "");
onLogin(
result.username || "",
@@ -635,27 +625,24 @@ export function Auth({ onLogin }: AuthProps) {
}
return;
}
void handOffLogin(urlParams.get("token")).then((handedOff) => {
if (handedOff) return;
getUserInfo()
.then((meRes) => {
storeAuth(meRes.username || "");
clearDesktopManualLogout();
onLogin(
meRes.username || "",
meRes.userId || undefined,
!!meRes.is_admin,
);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.catch(() => {
toast.error(t("errors.failedUserInfo"));
});
});
getUserInfo()
.then((meRes) => {
storeAuth(meRes.username || "");
clearDesktopManualLogout();
onLogin(
meRes.username || "",
meRes.userId || undefined,
!!meRes.is_admin,
);
window.history.replaceState(
{},
document.title,
window.location.pathname,
);
})
.catch(() => {
toast.error(t("errors.failedUserInfo"));
});
}
}, [onLogin, t]);
@@ -759,7 +746,6 @@ export function Auth({ onLogin }: AuthProps) {
setWebviewAuthSuccess(true);
return;
}
if (await handOffLogin(res?.token)) return;
const meRes = await getUserInfo();
const name =
meRes.username || res.username || options.fallbackUsername || "";
+4 -5
View File
@@ -10,17 +10,16 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
"border-accent-brand/40 bg-accent-brand/15 text-accent-brand hover:bg-accent-brand/25 aria-expanded:bg-accent-brand/25",
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-accent-brand/40 bg-transparent text-accent-brand hover:bg-accent-brand/10 aria-expanded:bg-accent-brand/10",
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"border-accent-brand/20 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20 aria-expanded:bg-accent-brand/20",
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-accent-brand underline-offset-4 hover:underline",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
+2 -21
View File
@@ -2,8 +2,6 @@ import type React from "react";
import { cn } from "@/lib/utils";
import {
PANEL,
gridSizeStyle,
type GridSize,
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
@@ -69,8 +67,6 @@ export function DataView<T>({
getKey,
renderCard,
columns,
size,
minCardWidth,
listColumns,
onRowClick,
isRowActive,
@@ -82,12 +78,7 @@ export function DataView<T>({
density?: PanelDensity;
getKey: (item: T) => string;
renderCard: (item: T) => React.ReactNode;
/** Breakpoint columns, used when no size is given. */
columns: GridColumns;
/** Columns the user picked with ViewToggle. Wins over columns. */
size?: GridSize;
/** Narrowest card in auto mode, in px. */
minCardWidth?: number;
listColumns: DataColumn<T>[];
onRowClick?: (item: T) => void;
isRowActive?: (item: T) => boolean;
@@ -102,19 +93,9 @@ export function DataView<T>({
if (view === "grid") {
return (
<div
className={cn(
"grid",
PANEL.gap,
!size && gridClass(columns),
className,
)}
style={size ? gridSizeStyle(size, minCardWidth) : undefined}
>
<div className={cn("grid", PANEL.gap, gridClass(columns), className)}>
{items.map((item) => (
<div key={getKey(item)} className="flex flex-col *:flex-1">
{renderCard(item)}
</div>
<div key={getKey(item)}>{renderCard(item)}</div>
))}
</div>
);
+3 -82
View File
@@ -1,26 +1,11 @@
/* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, isValidElement, useRef } from "react";
import type React from "react";
import {
ArrowLeft,
Columns3,
Grid3X3,
List,
Rows3,
Search,
X,
} from "lucide-react";
import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
/** One spacing rhythm for every panel and tab. */
export const PANEL = {
@@ -34,37 +19,6 @@ export const PANEL = {
export type PanelViewMode = "grid" | "list";
export type PanelDensity = "comfortable" | "compact";
/** Cards per row. "auto" fits as many as the pane has room for. */
export type GridSize = "auto" | "1" | "2" | "3" | "4" | "5" | "6";
export const GRID_SIZES: readonly GridSize[] = [
"auto",
"1",
"2",
"3",
"4",
"5",
"6",
];
// Sized off the pane's own width, not the window. Cards keep a min width,
// so narrow panes drop columns on their own.
export function gridSizeStyle(
size: GridSize,
minCardWidth = 320,
gap = "0.5rem",
): React.CSSProperties {
if (size === "auto") {
return {
gridTemplateColumns: `repeat(auto-fill, minmax(min(100%, ${minCardWidth}px), 1fr))`,
};
}
const n = Number(size);
return {
gridTemplateColumns: `repeat(auto-fill, minmax(max(min(100%, 220px), calc((100% - ${n - 1} * ${gap}) / ${n} - 0.5px)), 1fr))`,
};
}
/** The header, toolbar band and body every tab and panel draws. */
export function PanelShell({
chrome = true,
@@ -354,25 +308,20 @@ export function Segmented<T extends string>({
);
}
/** Grid or list, plus the compact rows toggle and the cards per row picker. */
/** Grid or list, plus the compact rows toggle. */
export function ViewToggle({
view,
onView,
density,
onDensity,
columns,
onColumns,
}: {
view: PanelViewMode;
onView: (next: PanelViewMode) => void;
density?: PanelDensity;
onDensity?: (next: PanelDensity) => void;
/** Cards per row in grid view. Pass with onColumns to show the picker. */
columns?: GridSize;
onColumns?: (next: GridSize) => void;
}) {
const { t } = useTranslation();
const toggle = (
return (
<div className="flex items-center border border-border">
<ToggleButton
active={view === "grid"}
@@ -407,34 +356,6 @@ export function ViewToggle({
)}
</div>
);
if (!columns || !onColumns || view !== "grid") return toggle;
return (
<div className="flex items-center gap-2">
<Select value={columns} onValueChange={(v) => onColumns(v as GridSize)}>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
title={t("panel.columns.title")}
aria-label={t("panel.columns.title")}
>
<Columns3 className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{GRID_SIZES.map((size) => (
<SelectItem key={size} value={size} className="text-xs">
{size === "auto"
? t("panel.columns.auto")
: t("panel.columns.count", { count: Number(size) })}
</SelectItem>
))}
</SelectContent>
</Select>
{toggle}
</div>
);
}
function ToggleButton({
-25
View File
@@ -555,28 +555,3 @@ html.fs-xl {
background-color: color-mix(in oklab, var(--accent-brand) 35%, transparent);
color: inherit;
}
/*
* The title bar the desktop app draws on top of its window. The window has
* no native frame, so this strip is what moves the window around: the whole
* bar drags, everything inside it that takes clicks does not.
*/
.termix-titlebar {
-webkit-app-region: drag;
user-select: none;
}
.termix-titlebar button,
.termix-titlebar a,
.termix-titlebar input,
.termix-titlebar [role="button"] {
-webkit-app-region: no-drag;
}
/*
* Layouts size themselves to the viewport with h-dvh. Under the app-drawn
* title bar the usable height is shorter, so trim it while the bar shows.
*/
.h-dvh {
height: calc(100dvh - var(--termix-titlebar-height, 0px));
}
-61
View File
@@ -1,61 +0,0 @@
/**
* Sign in for the Termix mobile app in a real browser. The app opens the
* login page with ?return_to=termix-mobile://..., and after any login method
* the page sends the token back to that address. Only the app's own scheme
* is accepted, so the value can never send a token to a website.
*/
const STORAGE_KEY = "termix-app-return";
const PARAM = "return_to";
const APP_RETURN = /^termix-mobile:\/\/[A-Za-z0-9._~/-]*$/;
export function isAppReturn(value: unknown): value is string {
return typeof value === "string" && APP_RETURN.test(value);
}
/** Reads return_to from the address bar once and keeps it for this tab. */
export function captureAppReturn(): string | null {
try {
const url = new URL(window.location.href);
const value = url.searchParams.get(PARAM);
if (value !== null) {
url.searchParams.delete(PARAM);
window.history.replaceState({}, document.title, url.toString());
if (isAppReturn(value)) sessionStorage.setItem(STORAGE_KEY, value);
else sessionStorage.removeItem(STORAGE_KEY);
}
return pendingAppReturn();
} catch {
return null;
}
}
export function pendingAppReturn(): string | null {
try {
const value = sessionStorage.getItem(STORAGE_KEY);
return isAppReturn(value) ? value : null;
} catch {
return null;
}
}
export function appReturnUrl(
base: string,
params: Record<string, string>,
): string {
const query = new URLSearchParams(params).toString();
return query ? `${base}?${query}` : base;
}
/** Sends the result to the app. False when no app asked for it. */
export function handOffToApp(params: Record<string, string>): boolean {
const base = pendingAppReturn();
if (!base) return false;
try {
sessionStorage.removeItem(STORAGE_KEY);
} catch {
// The redirect still goes out.
}
window.location.replace(appReturnUrl(base, params));
return true;
}
+7 -14
View File
@@ -235,10 +235,6 @@
"ready": "Termix {{version}} is ready to install",
"readyHint": "It installs the next time Termix restarts."
},
"safeMode": {
"title": "Safe mode",
"description": "Only layout and theme plugins are running. Restart the server without TERMIX_SAFE_MODE to turn the rest back on."
},
"companion": {
"close": "Close panel",
"moveSide": "Move panel",
@@ -1102,8 +1098,6 @@
"resetEveryone": "Reset the default for everyone",
"update": "Update {{name}} to {{version}}",
"disable": "Turn off {{name}}",
"enable": "Turn on {{name}}",
"startAgain": "Start {{name}} again",
"install": "Find layouts and themes",
"installTitle": "Layouts and themes",
"installButton": "Install",
@@ -1315,8 +1309,7 @@
"disabled": "Disabled",
"failed": "Stopped unexpectedly",
"blocked": "Waiting on another plugin",
"server": "Runs on your server",
"safeMode": "Off in safe mode"
"server": "Runs on your server"
},
"groups": {
"running": "Running",
@@ -1330,6 +1323,11 @@
"updated": "Recently updated",
"category": "Category"
},
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
},
"table": {
"name": "Name",
"description": "Description",
@@ -1654,12 +1652,7 @@
"gridView": "Grid",
"listView": "List",
"compactRows": "Compact rows",
"comfortableRows": "Comfortable rows",
"columns": {
"title": "Columns",
"auto": "Auto",
"count": "{{count}} wide"
}
"comfortableRows": "Comfortable rows"
},
"fields": {
"secretSaved": "Saved. Type to replace it."
+151 -154
View File
@@ -213,8 +213,20 @@
"pluginsSomeFailed": "Sommige plugins kon nie verander word nie. Jy kan hulle later regstel vanaf die Plugins-oortjie. Druk Volgende om voort te gaan.",
"pluginsApplyFailed": "Jou keuses kon nie gestoor word nie. Probeer weer, of gaan voort en verander hulle later vanaf die Plugins-oortjie.",
"pluginsLoadFailed": "Die pluginlys kon nie gelaai word nie. Jy kan plugins later kies vanaf die Plugins-oortjie.",
"presetTitle": "Hoeveel wil jy op die skerm hê?",
"presetIntro": "Kies hoeveel op die skerm wys.",
"presetChangeLater": "Jy kan later voorinstellings wissel of enige enkele instelling verander in Instellings, onder Koppelvlak.",
"appearanceTitle": "Maak dit joune",
"appearanceIntro": "Kies 'n tema, 'n aksentkleur, 'n lettertipe en jou taal. Veranderinge wys dadelik, en jy kan hulle later in Instellings verander.",
"securityTitle": "Beveilig jou rekening",
"securityIntro": "Voeg 'n passkey of 'n autentikator-toepassing by jou rekening. Jy kan dit ook later doen in Instellings, onder Beveiliging.",
"desktopTitle": "Hoe wil jy Termix gebruik?",
"desktopBody": "Jy kan dit op enige tyd verander vanaf Sinkroniseer in die sybalk.",
"desktopLocalTitle": "Slegs op hierdie toestel",
"desktopLocalBody": "Alles word hier gestoor. Niks om op te stel nie.",
"desktopLinkTitle": "Met my Termix-bediener",
"desktopLinkBody": "Teken in op jou bediener om dieselfde gasheer en instellings op elke toestel te gebruik.",
"desktopLinked": "Gekoppel aan {{server}}",
"pluginsDocsLink": "Lees oor elkeen"
},
"desktopUpdates": {
@@ -235,15 +247,6 @@
"ready": "Termix {{version}} is gereed om te installeer",
"readyHint": "Dit word die volgende keer wat Termix herbegin geïnstalleer."
},
"safeMode": {
"title": "Veilige modus",
"description": "Slegs uitleg- en tema-plug-ins word uitgevoer. Herbegin die bediener sonder TERMIX_SAFE_MODE om die res weer aan te skakel."
},
"companion": {
"close": "Maak paneel toe",
"moveSide": "Skuif paneel",
"resize": "Verander grootte van paneel"
},
"nav": {
"home": "Tuis",
"terminal": "Terminaal",
@@ -254,6 +257,7 @@
"hosts": "Gasheer",
"credentials": "Aanmeldingsgegewens",
"connections": "Verbindings",
"sync": "Sinkroniseer",
"reconnectDisconnectedTerminals": "Herstel al die ontkoppelde terminale",
"reconnectingTerminals_one": "Herstel tans {{count}} terminaal",
"reconnectingTerminals_other": "Herstel tans {{count}} terminale",
@@ -275,7 +279,10 @@
"retry": "Probeer weer",
"refresh": "Verfris",
"optional": "Opsioneel",
"downloadSample": "Laai Voorbeeld Af",
"failedToDeleteHost": "Kon nie {{name}} verwyder nie",
"importSkipExisting": "Voer in (slaas bestaande oor)",
"importSSHConfig": "Voer in vanaf SSH-konfigurasie",
"connectionDetails": "Verbindingsbesonderhede",
"ssh": "SSH",
"telnet": "Telnet",
@@ -494,7 +501,10 @@
"noFolder": "(Geen gids nie)",
"deleteSelected": "Vee uit",
"exitSelection": "Verlaat seleksie",
"importSkip": "Voer in (slaag bestaande oor)",
"importOverwrite": "Voer in (skryf oor)",
"collapseBtn": "Vou in",
"importExportBtn": "Voer in / Voer uit",
"hostStatusesRefreshed": "Gasheer-statusse opgedateer",
"failedToRefreshHosts": "Kon nie gasheer opdateer nie",
"movedHostTo": "Het {{host}} na \"{{folder}}\" geskuif",
@@ -549,9 +559,6 @@
"deleteFolderConfirm_one": "Vee \"{{name}}\" en sy {{count}} gasheer uit? Dit kan nie ongedaan gemaak word nie.",
"deleteFolderConfirm_other": "Vee \"{{name}}\" en sy {{count}} gasheer uit? Dit kan nie ongedaan gemaak word nie.",
"failedToMoveHosts": "Kon nie gasheerbedieners skuif nie",
"managedBanner": "Hierdie gasheer word bestuur deur {{plugin}}. Wysig dit daar. Jy kan dit steeds vassteek en van volgorde verander.",
"managedTooltip": "Bestuur deur {{plugin}}",
"addBlocked": "Gasheer word bestuur deur {{plugin}}, so nuwe gasheer kan nie hier bygevoeg word nie.",
"failedToReorder": "Kon nie herorden nie",
"expandAll": "Vou alle gidse uit",
"collapseAll": "Vou alle gidse in",
@@ -662,6 +669,35 @@
"addCredentialBtn2": "Voeg Geloofsbriew By",
"checkingHostStatuses": "Hoststatusse word nagegaan...",
"pinnedSection": "Vastegemaak",
"hostsExported": "Hosts is suksesvol geëksporteer",
"export": {
"menuItem": "Eksporteer...",
"title": "Eksporteer hosts",
"scope": "Reikwydte",
"scopeAll": "Alles",
"scopeSelected": "Geselekteer",
"searchHosts": "Soek hosts...",
"include": "Sluit in",
"groupConnection": "Verbinding",
"groupCredentials": "Aanmeldingsbesonderhede",
"groupNotes": "Notas",
"groupTags": "Etikette & vasmaak",
"groupProxy": "Proxy",
"groupJumpHosts": "Oorsprongshosts",
"groupFeatureFlags": "Plugin-instellings",
"groupAdvanced": "Gevorderde konfigurasie",
"preview": "Voorskou",
"moreHosts_one": "... {{count}} meer gasheer",
"moreHosts_other": "... {{count}} meer gasheer",
"summary": "{{selected}} van {{total}} hosts",
"credentialsIncluded": "aanmeldingsbesonderhede ingesluit",
"credentialsExcluded": "aanmeldingsbesonderhede uitgesluit",
"noneSelected": "Geen hosts geselekteer nie",
"cancel": "Kanselleer",
"confirm": "Eksporteer",
"fetchFailed": "Kon nie hosts laai vir eksport nie",
"bulkButton": "Eksporteer"
},
"sampleDownloaded": "Voorbeeldlêer is afgelaai",
"failedToDeleteCredential2": "Kon nie aanmeldingsbesonderhede verwyder nie",
"noFolderOption": "(Geen gids nie)",
@@ -824,7 +860,11 @@
"editorUpdate": "Wysig Gasheer",
"editorAdd": "Voeg Gasheer by",
"speedSearch": "Soek gasheer. Pyltjies om te kies, Enter om te verbind, Esc om te sluit",
"speedSearchList": "Gasheerlys. Tik of druk / om te soek."
"speedSearchList": "Gasheerlys. Tik of druk / om te soek.",
"importNoHosts": "Geen gasheer in die lêer gevind nie",
"importTooMany": "Jy kan tot {{max}} gasheer gelyktydig invoer",
"importSummary": "Invoer gedoen: {{imported}} ingevoer, {{updated}} opgedateer, {{failed}} misluk",
"importHostsFailed": "Kon nie gasheer invoer nie"
},
"splitScreen": {
"title": "Gesplete skerm",
@@ -1075,59 +1115,17 @@
"colorPickerTooltip": "Maak kleurkiezer oop",
"themeSystem": "Stelsel",
"themeLight": "Lig",
"themeDark": "Donker"
"themeDark": "Donker",
"themeDracula": "Dracula",
"themeCatppuccin": "Catppuccin",
"themeNord": "Nord",
"themeSolarized": "Solarized",
"themeTokyoNight": "Tokyo Night",
"themeOneDark": "One Dark",
"themeGruvbox": "Gruvbox"
}
}
},
"looks": {
"layoutHint": "Hoe die hele app gelê is. Temas en lêe kom van plugins.",
"defaultsLabel": "Verstek vir almal",
"defaultsHint": "Wat mense sien totdat hulle hul eie lê of tema kies.",
"followDefault": "Gebruik die verstek",
"defaultSaved": "Verstek voorkoms gestoor",
"saveFailed": "Kon nie die voorkoms stoor nie",
"repair": {
"title": "Hierdie lê kon nie gewys word nie",
"safeTitle": "Veilige modus",
"layout": "Lê",
"plugin": "Plugin",
"reason": "Rede",
"error": "Fout",
"noError": "Geen foutboodskap nie.",
"copy": "Kopieer",
"copied": "Gekopieer",
"copyFailed": "Kon nie die besonderhede kopieer nie",
"retry": "Probeer weer",
"reset": "Stel terug na die verstek voorkoms",
"resetEveryone": "Stel die verstek vir almal terug",
"update": "Dateer {{name}} op na {{version}}",
"disable": "Skakel {{name}} af",
"enable": "Skakel {{name}} aan",
"startAgain": "Begin {{name}} weer",
"install": "Vind lêe en temas",
"installTitle": "Lêe en temas",
"installButton": "Installeer",
"nothingToInstall": "Daar is tans geen ander lêe of temas beskikbaar nie.",
"registryUnreachable": "Die plugin-register kon nie bereik word nie.",
"askAdmin": "Vra 'n admin om hierdie lê op te dateer of te herstel. Jy kan hieronder na 'n ander lê of tema skakel.",
"switchLayout": "Wissel lê",
"switchTheme": "Wissel tema",
"saveFailed": "Kon nie jou keuse stoor nie",
"reasons": {
"safe-url": "Die adres het om veilige modus gevra. Kies 'n lê hieronder, of verwyder ?safe-layout=1 uit die adres.",
"safe-env": "Die bediener is gestart met TERMIX_SAFE_LAYOUT, so almal sien hierdie skerm totdat dit verwyder word.",
"missing": "Geen geïnstalleerde plugin verskaf hierdie lê nie.",
"disabled": "Die plugin wat hierdie lê verskaf, is afgeskakel.",
"blocked": "Die plugin wat hierdie lê verskaf, wag op iets wat dit nodig het.",
"incompatible": "Die plugin wat hierdie lê verskaf, werk nie met hierdie weergawe van Termix nie.",
"failed": "Die lê het misluk om te begin.",
"crashed": "Die lê het twee keer gekraak terwyl die app geteken is."
},
"noLayout": "Geen lê is gestel nie",
"restore": "Herstel die ingeboude {{name}}",
"restoreFailed": "Kon nie die ingeboude kopie herstel nie"
}
},
"plugins": {
"capabilities": {
"credentials:read": {
@@ -1150,18 +1148,6 @@
"title": "Hanteer aanmelding",
"consequence": "Dit kan 'n aanmeldmetode, 'n tweede faktor of 'n SSH-aanmeldtipe byvoeg."
},
"auth:policy": {
"title": "Stel aanmeldreëls",
"consequence": "Dit kan mense uitteken wanneer hulle inaktief is, sessieduur beperk, \"onthou my\" afskakel en 'n tweede faktor vereis."
},
"ssh:transport": {
"title": "Kies hoe gasheer bereik word",
"consequence": "Dit kan die SSH-verbinding na 'n gasheer oor sy eie roete dra, so dit sien alles wat na en van daardie gasheer gestuur word, insluitende wagwoorde."
},
"sync:provide": {
"title": "Voer sinkronisering uit",
"consequence": "Dit kan al die sinkronisversoek beantwoord, so dit sien die data wat toestelle en bedieners sinkroniseer."
},
"system:tls": {
"title": "Bestuur sertifikate",
"consequence": "Dit kan die sertifikaat vervang wat Termix oor HTTPS verskaf en domeinkontroles van sertifikaatautoriteite beantwoord."
@@ -1170,10 +1156,6 @@
"title": "Toegang tot reeks- en USB-toestelle",
"consequence": "Dit kan 'n fisiese reekspoort op die masjien wat Termix uitvoer, oopmaak."
},
"hosts:export": {
"title": "Voer gasheer uit met hul geheime",
"consequence": "Kan jou gasheer in sy geheel lees, insluitende gestoorde wagwoorde en sleutels, en dit uitdeel. Elke uitvoer word in die ouditering gelog."
},
"hosts:write": {
"title": "Skep en verander bedieners",
"consequence": "Dit kan bedieners byvoeg en die een wat jy reeds het, redigeer."
@@ -1218,30 +1200,10 @@
"title": "Lees die ouditlogboek",
"consequence": "Dit kan sien wie wat gedoen het en wanneer."
},
"network:probe": {
"title": "Kyk of poorte antwoord",
"consequence": "Kan 'n plat verbinding na enige adres vanaf die bediener oopmaak, om te sien of dit bereikbaar is."
},
"hosts:status-provide": {
"title": "Verskaf gasheerstatus",
"consequence": "Word die bron van die aanlyn- en aflyn-punte vir elke gasheer."
},
"hosts:activity-provide": {
"title": "Bewaar onlangse aktiwiteit",
"consequence": "Word die bergplek vir die onlangse aktiwiteitslys van elke gebruiker."
},
"hosts:lock": {
"title": "Bestuur gasheer as slegs-lees",
"consequence": "Gasheer wat dit bestuur, kan nie deur iemand anders redigeer, verwyder of hergedeel word nie."
},
"desktop:window": {
"title": "Maak burebladvensters oop",
"consequence": "Slegs in die Termix-burebladtoepassing."
},
"desktop:local": {
"title": "Gebruik hierdie rekenaar",
"consequence": "Kan lêers lees en verander, 'n skul uitvoer en plaaslike poorte op hierdie rekenaar oopmaak. Slegs in die Termix-bureaubladprogram."
},
"users:impersonate": {
"title": "Tree op as ander gebruikers",
"consequence": "Voer agtergrondwerk uit met 'n ander gebruiker se toegang, soos geskeduleerde take. Elke gebruik word in die ouditlogboek geskryf."
@@ -1281,14 +1243,6 @@
"ui:surface": {
"title": "Voeg sy eie skerm by",
"consequence": "Oortjies, panele en instellings wat jy later kan verberg."
},
"ui:layout": {
"title": "Vervang die hele programlêer",
"consequence": "Wanneer gekies, teken sy kode elke skerm, insluitend jou terminale en instellings."
},
"ui:theme": {
"title": "Voeg kleurtemas by",
"consequence": "Slegs kleure en styl. Geen kode word vir 'n tema uitgevoer nie."
}
},
"runtime": {
@@ -1315,8 +1269,7 @@
"disabled": "Afgeskakel",
"failed": "Onverwags gestop",
"blocked": "Wag op 'n ander plugin",
"server": "Loop op jou bediener",
"safeMode": "Af in veilige modus"
"server": "Loop op jou bediener"
},
"groups": {
"running": "Aktief",
@@ -1330,6 +1283,11 @@
"updated": "Onlangs opgedateer",
"category": "Kategorie"
},
"columns": {
"title": "Kolomme",
"auto": "Outo",
"count": "{{count}} breed"
},
"table": {
"name": "Naam",
"description": "Beskrywing",
@@ -1414,7 +1372,6 @@
"options": "Kon nie opsies vir {{name}} stoor nie: {{error}}",
"deleteData": "Kon nie data vir {{name}} verwyder nie: {{error}}",
"managedByServer": "Hierdie werkskerm is aan 'n bediener gekoppel, wat sy plugins bepaal.",
"defaultLook": "{{name}} is die standaard voorkoms, so dit kan afgeskakel of vervang word, maar nie onteinstalleer nie.",
"hasDependents": "{{name}} word deur {{list}} benodig. Skakel daardie eers af of deïnstalleer hulle.",
"missingDependencies": "{{name}} het {{list}} nodig, wat nie geïnstalleer is nie.",
"consentChanged": "Wat hierdie plugin vra, het verander sedert jy gekyk het. Kyk weer na die lys.",
@@ -1484,6 +1441,7 @@
"noServerAccess": "Geen toegang tot jou bedieners nie",
"searchInstalled": "Soek geïnstalleerdes",
"searchPlugins": "Soek plugins",
"allCategories": "Almal",
"nothingMatches": "Niets pas by nie.",
"noneInstalled": "Nog geen plugins geïnstalleer nie. Blaai om sommige by te voeg.",
"nothingHere": "Niets hier nie.",
@@ -1573,44 +1531,7 @@
"documentation": "Dokumentasie",
"envVars": "Omgewingsveranderlikes",
"envRequired": "Verpligtend",
"envDefault": "Verstek: {{value}}",
"kinds": {
"layouts": "Indeling",
"themes": "Tema's",
"tabs": "Oortjies",
"panels": "Sypaneelpanele",
"dashboardCards": "Kontrolepaneelkaarte",
"protocols": "Verbindingstipes",
"auth": "Aanmeldmetodes",
"actions": "Aksies",
"hostCapability": "Gasheerinstellings"
},
"filters": {
"type": "Tipe",
"allTypes": "Alle tipes",
"category": "Kategorie",
"allCategories": "Alle kategorieë",
"results_one": "{{count}} invoegprogram",
"results_other": "{{count}} invoegprogramme",
"remove": "Verwyder filter {{name}}",
"clear": "Vee filters uit",
"button": "Filters",
"buttonCount": "Filters ({{count}})"
},
"pager": {
"range": "{{from}}-{{to}} van {{total}}",
"page": "Bladsy {{page}} van {{pages}}",
"previous": "Vorige bladsy",
"next": "Volgende bladsy",
"perPage": "{{count}} per bladsy",
"perPageLabel": "Invoegprogramme per bladsy"
},
"showUnsupportedHint": "Hierdie word gelys, maar geen vrylasing loop nog op hierdie weergawe nie. Werk Termix op om dit te installeer.",
"unsupported": "Benodig 'n nuwer Termix",
"unsupportedHint": "Geen vrylasing van hierdie invoegprogram loop op hierdie weergawe van Termix nie. Werk Termix op om dit te installeer.",
"needsTermix": "Benodig Termix {{range}}",
"showUnsupported": "Wys nie-ondersteunde ({{count}})",
"showMore": "Wys {{count}} meer ({{total}} oor)"
"envDefault": "Verstek: {{value}}"
}
},
"settings": {
@@ -1626,6 +1547,87 @@
"noData": "Geen data nie",
"filter": "Filter..."
},
"sync": {
"minutesAgo_one": "{{count}} minuut gelede",
"minutesAgo_other": "{{count}} minute gelede",
"hoursAgo_one": "{{count}} uur gelede",
"hoursAgo_other": "{{count}} uur gelede",
"pending_one": "{{count}} verandering wag om te sinkroniseer",
"pending_other": "{{count}} veranderinge wag om te sinkroniseer",
"proxy": {
"headersTitle": "Bykomende koptekste",
"headersHint": "Word saam met elke versoek na die bediener gestuur, byvoorbeeld 'n Cloudflare Access-diens token.",
"headerName": "Koptekstnaam",
"headerValue": "Waarde",
"savedValue": "Opgeslaan, tik om te verander",
"addHeader": "Voeg koptekst by",
"removeHeader": "Verwyder koptekst",
"basicAuthTitle": "Gebruikersnaam en wagwoord",
"basicAuthHint": "Vir 'n proxy wat om 'n gebruikersnaam en wagwoord vra voordat Termix laai.",
"username": "Gebruikersnaam",
"password": "Wagwoord",
"allowInvalidCertificate": "Vertrou 'n self-ondertekende sertifikaat",
"allowInvalidCertificateHint": "Slegs vir jou eie bediener, wanneer sy sertifikaat nie van 'n bekende owerheid kom nie."
},
"problems": {
"invalidUrl": "Dit lyk nie soos 'n webadres nie.",
"unreachable": "Kon nie die bediener bereik nie. Kontroleer die adres en dat die bediener loop.",
"unreachableWithReason": "Kon nie die bediener bereik nie: {{reason}}",
"tlsUntrusted": "Die bediener se sertifikaat word nie vertrou nie. As dit jou eie bediener is met 'n self-ondertekende sertifikaat, skakel die opsie hieronder aan.",
"basicAuthRequired": "Iets voor die bediener vra vir 'n gebruikersnaam en wagwoord. Voer hulle hieronder in.",
"proxyLogin": "'n Aanmeldbladsy of proxy het geantwoord in plaas van Termix. Voeg die koppe wat dit benodig hieronder by, of kontroleer die adres.",
"notTermix": "Daar is geen Termix-bedienaar op daardie adres nie. As dit onder 'n pad loop, sluit dit in, soos https://voorbeeld.com/termix.",
"versionMismatch": "Daardie bediener loop 'n ouer Termix. Werk dit op na 2.9 of nuwer om te koppel.",
"differentAccount": "Jy het aangemeld by 'n ander rekening. Meld aan by die rekening waaraan hierdie toestel gekoppel is, of ontkoppel eers."
},
"wizard": {
"title": "Koppel aan 'n Termix-bedienaar",
"reloginTitle": "Meld weer aan",
"savedServers": "Onlangse bedieners",
"removeServer": "Verwyder van die lys",
"serverDescription": "Voer die adres in wat jy gebruik om Termix in jou blaaier oop te maak.",
"signinDescription": "Meld aan by {{url}}",
"chooseDescription": "Hierdie toestel het reeds data. Wat moet daarmee gebeur?",
"finishingDescription": "Koppel hierdie toestel.",
"serverUrl": "Bedienaaradres",
"serverUrlHint": "Byvoorbeeld https://termix.voorbeeld.com",
"showProxySettings": "Omgekeerde proxy-instellings",
"hideProxySettings": "Versteek omgekeerde proxy-instellings",
"continue": "Gaan voort",
"back": "Terug",
"mergeTitle": "Voeg dit by my rekening",
"mergeBody_one": "Die item op hierdie toestel word by jou rekening gevoeg. Items wat jou rekening reeds het, bly soos hulle op die bediener is.",
"mergeBody_other": "Die {{count}} items op hierdie toestel word by jou rekening gevoeg. Items wat jou rekening reeds het, bly soos hulle op die bediener is.",
"replaceTitle": "Vervang dit met my rekening",
"replaceBody_one": "Die item op hierdie toestel word verwyder, en hierdie toestel neem alles van jou rekening. Hosts wat slegs op hierdie toestel gehou word, bly.",
"replaceBody_other": "Die {{count}} items op hierdie toestel word verwyder, en hierdie toestel neem alles van jou rekening. Hosts wat slegs op hierdie toestel gehou word, bly.",
"link": "Koppel",
"linking": "Koppel en voer die eerste sinkronisering uit...",
"linked": "Gekoppel. Jou data word nou gesinkroniseer.",
"signedInAgain": "Aangemeld. Sinkronisering loop weer."
},
"entities": {
"hosts": "Bedieners",
"sshCredentials": "Inlogbesonderhede",
"sshFolders": "Vouers",
"userPreferences": "Voorkeure",
"pluginUserSettings": "Plugin-instellings",
"sharedHosts": "Bedieners wat met jou gedeel is",
"sharedCredentials": "Inlogbesonderhede wat met jou gedeel is",
"vaultProfiles": "Vault-profile",
"snippetFolders": "Snippet-vouers",
"snippets": "Snippets",
"dashboardServiceLinks": "Diensskakels",
"homepageItems": "Tuisblad-items",
"networkTopology": "Netwerkkaart",
"workspaces": "Werkruimtes",
"hostDefaults": "Bediener-standaarde",
"hostDefaultsAdmin": "Bediener se bediener-standaarde",
"aiProviders": "AI-lewerders",
"homepageLayouts": "Tuisblad-lêers",
"accountProfile": "Rekeningprofiel"
}
},
"hostDefaults": {
"source": {
"folder": "Map {{name}}",
@@ -1654,12 +1656,7 @@
"gridView": "Rooster",
"listView": "Lys",
"compactRows": "Kompakte rye",
"comfortableRows": "Gerieflike rye",
"columns": {
"title": "Kolomme",
"auto": "Outo",
"count": "{{count}} breed"
}
"comfortableRows": "Gerieflike rye"
},
"fields": {
"secretSaved": "Opgeslaan. Tik om dit te vervang."
+199 -158
View File
@@ -213,8 +213,20 @@
"pluginsSomeFailed": "تعذر تغيير بعض الإضافات. يمكنك إصلاحها لاحقاً من علامة التبويب الإضافات. اضغط التالي للمتابعة.",
"pluginsApplyFailed": "تعذر حفظ اختياراتك. حاول مرة أخرى، أو تابع وقم بتغييرها لاحقاً من علامة التبويب الإضافات.",
"pluginsLoadFailed": "تعذر تحميل قائمة الإضافات. يمكنك اختيار الإضافات لاحقاً من علامة التبويب الإضافات.",
"presetTitle": "كم تريد أن يظهر على الشاشة؟",
"presetIntro": "اختر مقدار ما يظهر على الشاشة.",
"presetChangeLater": "يمكنك التبديل بين الإعدادات الجاهزة أو تغيير أي إعداد فردي لاحقاً في الإعدادات، تحت واجهة المستخدم.",
"appearanceTitle": "اجعله خاصاً بك",
"appearanceIntro": "اختر سمة، ولوناً مميزاً، وخطاً، ولغتك. تظهر التغييرات فوراً، ويمكنك تغييرها لاحقاً في الإعدادات.",
"securityTitle": "أمن حسابك",
"securityIntro": "أضف مفتاح مرور أو تطبيق مصادقة إلى حسابك. يمكنك أيضاً القيام بذلك لاحقاً في الإعدادات، تحت الأمان.",
"desktopTitle": "كيف تريد استخدام Termix؟",
"desktopBody": "يمكنك تغيير ذلك في أي وقت من المزامنة في الشريط الجانبي.",
"desktopLocalTitle": "على هذا الجهاز فقط",
"desktopLocalBody": "يتم تخزين كل شيء هنا. لا حاجة للإعداد.",
"desktopLinkTitle": "مع خادم Termix الخاص بي",
"desktopLinkBody": "سجّل الدخول إلى خادمك لاستخدام نفس المضيفين والإعدادات على كل جهاز.",
"desktopLinked": "مرتبطة بـ {{server}}",
"pluginsDocsLink": "اقرأ عن كل منها"
},
"desktopUpdates": {
@@ -235,15 +247,6 @@
"ready": "Termix {{version}} جاهز للتثبيت",
"readyHint": "سيتم تثبيته في المرة القادمة التي يعاد فيها تشغيل Termix."
},
"safeMode": {
"title": "الوضع الآمن",
"description": "لا تعمل سوى إضافات التخطيط والمظهر. أعد تشغيل الخادم بدون TERMIX_SAFE_MODE لإعادة تشغيل الباقي."
},
"companion": {
"close": "إغلاق اللوحة",
"moveSide": "نقل اللوحة",
"resize": "تغيير حجم اللوحة"
},
"nav": {
"home": "الرئيسية",
"terminal": "الطرفية",
@@ -254,6 +257,7 @@
"hosts": "المضيفين",
"credentials": "بيانات الاعتماد",
"connections": "الاتصالات",
"sync": "مزامنة",
"reconnectDisconnectedTerminals": "إعادة توصيل جميع المحطات المنفصلة",
"reconnectingTerminals_zero": "جاري إعادة توصيل {{count}} محطة",
"reconnectingTerminals_one": "جاري إعادة توصيل {{count}} محطة",
@@ -287,7 +291,10 @@
"retry": "إعادة المحاولة",
"refresh": "تحديث",
"optional": "اختياري",
"downloadSample": "تحميل نموذج",
"failedToDeleteHost": "فشل في حذف {{name}}",
"importSkipExisting": "استيراد (تخطي الموجود)",
"importSSHConfig": "استيراد من إعدادات SSH",
"connectionDetails": "تفاصيل الاتصال",
"ssh": "SSH",
"telnet": "Telnet",
@@ -510,7 +517,10 @@
"noFolder": "(بدون مجلد)",
"deleteSelected": "حذف",
"exitSelection": "خروج من التحديد",
"importSkip": "استيراد (تخطي الموجود)",
"importOverwrite": "استيراد (استبدال)",
"collapseBtn": "طي",
"importExportBtn": "استيراد / تصدير",
"hostStatusesRefreshed": "تم تحديث حالات المضيفين",
"failedToRefreshHosts": "فشل تحديث المضيفين",
"movedHostTo": "تم نقل {{host}} إلى \"{{folder}}\"",
@@ -573,9 +583,6 @@
"deleteFolderConfirm_many": "هل تريد حذف \"{{name}}\" و{{count}} مضيفًا؟ لا يمكن التراجع عن هذا الإجراء.",
"deleteFolderConfirm_other": "هل تريد حذف \"{{name}}\" و{{count}} مضيف؟ لا يمكن التراجع عن هذا الإجراء.",
"failedToMoveHosts": "فشل نقل الأجهزة",
"managedBanner": "هذا المضيف مُدار بواسطة {{plugin}}. قم بتغييره هناك. يمكنك لا يزال تثبيته وإعادة ترتيبه.",
"managedTooltip": "مُدار بواسطة {{plugin}}",
"addBlocked": "المضيفون مُدارون بواسطة {{plugin}}، لذا لا يمكن إضافة مضيفين جدد هنا.",
"failedToReorder": "فشل إعادة الترتيب",
"expandAll": "توسيع جميع المجلدات",
"collapseAll": "طي جميع المجلدات",
@@ -690,6 +697,39 @@
"addCredentialBtn2": "إضافة بيانات اعتماد",
"checkingHostStatuses": "جاري التحقق من حالات المضيفين...",
"pinnedSection": "مثبّتة",
"hostsExported": "تم تصدير المضيفين بنجاح",
"export": {
"menuItem": "تصدير...",
"title": "تصدير المضيفين",
"scope": "النطاق",
"scopeAll": "الكل",
"scopeSelected": "المحددة",
"searchHosts": "البحث عن المضيفين...",
"include": "تضمين",
"groupConnection": "الاتصال",
"groupCredentials": "بيانات الاعتماد",
"groupNotes": "ملاحظات",
"groupTags": "الوسوم والتثبيت",
"groupProxy": "الوكيل",
"groupJumpHosts": "مضيفي القفز",
"groupFeatureFlags": "إعدادات الإضافات",
"groupAdvanced": "إعدادات متقدمة",
"preview": "معاينة",
"moreHosts_zero": "... و{{count}} مضيف آخر",
"moreHosts_one": "... ومضيف واحد آخر",
"moreHosts_two": "... و{{count}} مضيفين آخرين",
"moreHosts_few": "... و{{count}} مضيفين آخرين",
"moreHosts_many": "... و{{count}} مضيفًا آخر",
"moreHosts_other": "... و{{count}} مضيف آخر",
"summary": "{{selected}} من أصل {{total}} مضيف",
"credentialsIncluded": "تم تضمين بيانات الاعتماد",
"credentialsExcluded": "تم استبعاد بيانات الاعتماد",
"noneSelected": "لم يتم تحديد أي مضيفين",
"cancel": "إلغاء",
"confirm": "تصدير",
"fetchFailed": "فشل في تحميل المضيفين للتصدير",
"bulkButton": "تصدير"
},
"sampleDownloaded": "تم تنزيل الملف النموذجي",
"failedToDeleteCredential2": "فشل في حذف بيانات الاعتماد",
"noFolderOption": "(بدون مجلد)",
@@ -872,7 +912,11 @@
"editorUpdate": "تحديث المضيف",
"editorAdd": "إضافة مضيف",
"speedSearch": "البحث عن المضيفين. مفاتيح الأسهم للاختيار، Enter للاتصال، Esc للإغلاق",
"speedSearchList": "قائمة المضيفين. اكتب أو اضغط / للبحث."
"speedSearchList": "قائمة المضيفين. اكتب أو اضغط / للبحث.",
"importNoHosts": "لم يتم العثور على مضيفين في الملف",
"importTooMany": "يمكنك استيراد ما يصل إلى {{max}} مضيفين في وقت واحد",
"importSummary": "اكتمل الاستيراد: {{imported}} تم استيرادها، {{updated}} تم تحديثها، {{failed}} فشلت",
"importHostsFailed": "فشل في استيراد المضيفين"
},
"splitScreen": {
"title": "شاشة مقسمة",
@@ -1082,10 +1126,26 @@
"hostCount_other": "{{count}} مضيف"
},
"admin": {
"usersCount_zero": "{{count}} مستخدم",
"usersCount_one": "مستخدم واحد",
"usersCount_two": "{{count}} مستخدمين",
"usersCount_few": "{{count}} مستخدمين",
"usersCount_many": "{{count}} مستخدمًا",
"rolesCount_zero": "{{count}} دور",
"rolesCount_one": "دور واحد",
"rolesCount_two": "{{count}} دورين",
"rolesCount_few": "{{count}} أدوار",
"rolesCount_many": "{{count}} دورًا",
"apiKeysCount_zero": "{{count}} مفتاح",
"apiKeysCount_one": "مفتاح واحد",
"apiKeysCount_two": "{{count}} مفتاحين",
"apiKeysCount_few": "{{count}} مفاتيح",
"apiKeysCount_many": "{{count}} مفتاحًا",
"sessionsActive_zero": "0 نشط",
"sessionsActive_one": "1 نشط",
"sessionsActive_two": "2 نشط",
"sessionsActive_few": "{{count}} نشطة",
"sessionsActive_many": "{{count}} نشطة",
"hostsCount_zero": "{{count}} مضيف",
"hostsCount_one": "مضيف واحد",
"hostsCount_two": "{{count}} مضيفين",
@@ -1099,7 +1159,11 @@
"credentialsCount_many": "{{count}} بيانات اعتماد",
"credentialsCount_other": "{{count}} بيانات اعتماد",
"rolePermissions": {
"count_zero": "{{count}} إذن",
"count_one": "إذن واحد",
"count_two": "{{count}} إذنين",
"count_few": "{{count}} أذونات",
"count_many": "{{count}} إذنًا",
"groups": {
"hosts": "المضيفين",
"credentials": "بيانات الاعتماد",
@@ -1139,63 +1203,25 @@
"interfaceFontLabel": "خط الواجهة",
"interfaceFontDescription": "يغيّر خط واجهة التطبيق. تحتفظ ملفات تعريف المحطة الطرفية بإعدادات الخط الخاصة بها.",
"accentColorLabel": "لون التمييز",
"apiKeyCount_zero": "{{count}} مفتاح",
"apiKeyCount_one": "مفتاح واحد",
"apiKeyCount_two": "{{count}} مفتاحين",
"apiKeyCount_few": "{{count}} مفاتيح",
"apiKeyCount_many": "{{count}} مفتاحًا",
"colorPickerTooltip": "فتح منتقي الألوان",
"themeSystem": "النظام",
"themeLight": "فاتح",
"themeDark": "داكن"
"themeDark": "داكن",
"themeDracula": "Dracula",
"themeCatppuccin": "Catppuccin",
"themeNord": "Nord",
"themeSolarized": "Solarized",
"themeTokyoNight": "Tokyo Night",
"themeOneDark": "One Dark",
"themeGruvbox": "Gruvbox"
}
}
},
"looks": {
"layoutHint": "كيف يتم تخطيط التطبيق بأكمله. تأتي السمات والتخطيطات من الإضافات.",
"defaultsLabel": "الافتراضي للجميع",
"defaultsHint": "ما يراه الناس حتى يختاروا تخطيطهم أو سماتهم الخاصة.",
"followDefault": "استخدام الافتراضي",
"defaultSaved": "تم حفظ المظهر الافتراضي",
"saveFailed": "تعذر حفظ المظهر",
"repair": {
"title": "تعذر عرض هذا التخطيط",
"safeTitle": "الوضع الآمن",
"layout": "التخطيط",
"plugin": "الإضافة",
"reason": "السبب",
"error": "خطأ",
"noError": "لا توجد رسالة خطأ.",
"copy": "نسخ",
"copied": "تم النسخ",
"copyFailed": "تعذر نسخ التفاصيل",
"retry": "حاول مرة أخرى",
"reset": "إعادة تعيين إلى المظهر الافتراضي",
"resetEveryone": "إعادة تعيين الافتراضي للجميع",
"update": "تحديث {{name}} إلى {{version}}",
"disable": "إيقاف {{name}}",
"enable": "تفعيل {{name}}",
"startAgain": "إعادة تشغيل {{name}}",
"install": "البحث عن التخطيطات والسمات",
"installTitle": "التخطيطات والسمات",
"installButton": "تثبيت",
"nothingToInstall": "لا توجد تخطيطات أو سمات أخرى متاحة حالياً.",
"registryUnreachable": "تعذر الوصول إلى سجل الإضافات.",
"askAdmin": "اطلب من المسؤول تحديث أو إصلاح هذا التخطيط. يمكنك التبديل إلى تخطيط أو سمة أخرى لنفسك أدناه.",
"switchLayout": "تبديل التخطيط",
"switchTheme": "تبديل السمة",
"saveFailed": "تعذر حفظ اختيارك",
"reasons": {
"safe-url": "العنوان طلب الوضع الآمن. اختر تخطيطاً أدناه، أو احذف ?safe-layout=1 من العنوان.",
"safe-env": "تم تشغيل الخادم مع TERMIX_SAFE_LAYOUT، لذا يرى الجميع هذه الشاشة حتى يتم إزالته.",
"missing": "لا توجد إضافة مثبتة توفر هذا التخطيط.",
"disabled": "الإضافة التي توفر هذا التخطيط معطلة.",
"blocked": "الإضافة التي توفر هذا التخطيط تنتظر شيئاً تحتاجه.",
"incompatible": "الإضافة التي توفر هذا التخطيط لا تعمل مع هذه النسخة من Termix.",
"failed": "فشل التخطيط في البدء.",
"crashed": "تعطل التخطيط مرتين أثناء رسم التطبيق."
},
"noLayout": "لم يتم تعيين أي تخطيط",
"restore": "استعادة {{name}} المرفقة",
"restoreFailed": "تعذر استعادة النسخة المرفقة"
}
},
"plugins": {
"capabilities": {
"credentials:read": {
@@ -1218,18 +1244,6 @@
"title": "إدارة تسجيل الدخول",
"consequence": "يمكنه إضافة طريقة تسجيل دخول، أو عامل تحقق ثانٍ، أو نوع تسجيل دخول عبر SSH."
},
"auth:policy": {
"title": "تعيين قواعد تسجيل الدخول",
"consequence": "يمكنه تسجيل خروج الأشخاص عند الخمول، وتقييد مدة الجلسة، وإيقاف تذكرني، ومتطلب عامل ثانٍ."
},
"ssh:transport": {
"title": "اختر كيفية الوصول إلى المضيفين",
"consequence": "يمكنه نقل اتصال SSH إلى مضيف عبر مساره الخاص، لذا يرى كل ما يُرسل إلى ومن تلك المضيفين، بما في ذلك كلمات المرور."
},
"sync:provide": {
"title": "تشغيل المزامنة",
"consequence": "يمكنه الإجابة على جميع طلبات المزامنة، لذا يرى البيانات التي تزامن الأجهزة والخوادم."
},
"system:tls": {
"title": "إدارة الشهادات",
"consequence": "يمكنه استبدال الشهادة التي يقدمها Termix عبر HTTPS والرد على فحوصات النطاق من جهات إصدار الشهادات."
@@ -1238,10 +1252,6 @@
"title": "الوصول إلى الأجهزة التسلسلية و USB",
"consequence": "يمكنه فتح منفذ تسلسلي مادي على الجهاز الذي يعمل عليه Termix."
},
"hosts:export": {
"title": "تصدير المضيفين مع أسرارهم",
"consequence": "يمكنه قراءة مضيفيك بالكامل، بما في ذلك كلمات المرور والمفاتيح المحفوظة، وتسليمها. يتم كتابة كل تصدير في سجل التدقيق."
},
"hosts:write": {
"title": "إنشاء وتعديل الخوادم",
"consequence": "يمكنه إضافة خوادم وتعديل تلك التي لديك بالفعل."
@@ -1286,30 +1296,10 @@
"title": "قراءة سجل التدقيق",
"consequence": "يمكنه رؤية من فعل ماذا ومتى."
},
"network:probe": {
"title": "التحقق مما إذا كانت المنافذ تجيب",
"consequence": "يمكنه فتح اتصال عادي إلى أي عنوان من الخادم، لمعرفة ما إذا كان يمكن الوصول إليه."
},
"hosts:status-provide": {
"title": "توفير حالة المضيف",
"consequence": "يصبح مصدر النقاط عبر الإنترنت وغير المتصلة لكل مضيف."
},
"hosts:activity-provide": {
"title": "تخزين النشاط الأخير",
"consequence": "يُصبح مخزناً لقائمة النشاطات الأخيرة لكل مستخدم."
},
"hosts:lock": {
"title": "إدارة المضيفين كقراءة فقط",
"consequence": "لا يمكن لأي شخص آخر تعديل أو حذف أو إعادة مشاركة المضيفين التي يديرها."
},
"desktop:window": {
"title": "فتح نوافذ سطح المكتب",
"consequence": "فقط في تطبيق سطح المكتب الخاص بـ Termix."
},
"desktop:local": {
"title": "استخدام هذا الكمبيوتر",
"consequence": "يمكنه قراءة وتغيير الملفات، وتشغيل shell، وفتح المنافذ المحلية على هذا الكمبيوتر. فقط في تطبيق Termix المكتبي."
},
"users:impersonate": {
"title": "التصرف كمستخدمين آخرين",
"consequence": "يؤدي العمل في الخلفية باستخدام صلاحيات مستخدم آخر، مثل المهام المجدولة. يتم تسجيل كل استخدام في سجل التدقيق."
@@ -1349,14 +1339,6 @@
"ui:surface": {
"title": "إضافة شاشات خاصة بها",
"consequence": "علامات تبويب، ولوحات، وإعدادات يمكنك إخفاؤها لاحقاً."
},
"ui:layout": {
"title": "استبدال تخطيط التطبيق بالكامل",
"consequence": "عند اختياره، يقوم كوده برسم كل شاشة، بما في ذلك طرفياتك وإعداداتك."
},
"ui:theme": {
"title": "إضافة سمات ألوان",
"consequence": "ألوان وأنماط فقط. لا يتم تشغيل أي كود لسمات."
}
},
"runtime": {
@@ -1383,8 +1365,7 @@
"disabled": "معطلة",
"failed": "توقفت بشكل غير متوقع",
"blocked": "في انتظار إضافة أخرى",
"server": "يعمل على خادمك",
"safeMode": "معطل في الوضع الآمن"
"server": "يعمل على خادمك"
},
"groups": {
"running": "تعمل",
@@ -1398,6 +1379,11 @@
"updated": "تم تحديثها مؤخراً",
"category": "الفئة"
},
"columns": {
"title": "الأعمدة",
"auto": "تلقائي",
"count": "عرض {{count}}"
},
"table": {
"name": "الاسم",
"description": "الوصف",
@@ -1530,7 +1516,6 @@
"options": "تعذر حفظ خيارات {{name}}: {{error}}",
"deleteData": "تعذر حذف بيانات {{name}}: {{error}}",
"managedByServer": "هذا سطح المكتب مرتبط بخادم، وهو الذي يحدد إضافاته.",
"defaultLook": "{{name}} هو المظهر الافتراضي، لذا يمكن إيقافه أو استبداله ولكن لا يمكن إلغاء تثبيته.",
"hasDependents": "{{name}} مطلوب من قبل {{list}}. قم بتعطيلها أو إلغاء تثبيتها أولاً.",
"missingDependencies": "{{name}} يحتاج إلى {{list}}، وهو غير مثبت.",
"consentChanged": "تغير ما تطلبه هذه الإضافة منذ أن نظرته. تحقق من القائمة مرة أخرى.",
@@ -1616,6 +1601,7 @@
"noServerAccess": "لا يوجد وصول إلى خوادمك",
"searchInstalled": "البحث في المثبتة",
"searchPlugins": "البحث في الإضافات",
"allCategories": "الكل",
"nothingMatches": "لا شيء يطابق ذلك.",
"noneInstalled": "لم يتم تثبيت أي إضافات بعد. تصفح لإضافة بعض.",
"nothingHere": "لا شيء هنا.",
@@ -1721,48 +1707,7 @@
"documentation": "التوثيق",
"envVars": "متغيرات البيئة",
"envRequired": "مطلوب",
"envDefault": "الافتراضي: {{value}}",
"kinds": {
"layouts": "التخطيطات",
"themes": "السمات",
"tabs": "العلامات",
"panels": "لوحات الشريط الجانبي",
"dashboardCards": "بطاقات لوحة التحكم",
"protocols": "أنواع الاتصال",
"auth": "طرق تسجيل الدخول",
"actions": "الإجراءات",
"hostCapability": "إعدادات المضيف"
},
"filters": {
"type": "النوع",
"allTypes": "جميع الأنواع",
"category": "الفئة",
"allCategories": "جميع الفئات",
"results_zero": "لا توجد إضافات",
"results_one": "إضافة واحدة",
"results_two": "إضافتان",
"results_few": "{{count}} إضافات",
"results_many": "{{count}} إضافة",
"results_other": "{{count}} إضافة",
"remove": "إزالة الفلتر {{name}}",
"clear": "مسح الفلاتر",
"button": "الفلاتر",
"buttonCount": "الفلاتر ({{count}})"
},
"pager": {
"range": "{{from}}-{{to}} من {{total}}",
"page": "الصفحة {{page}} من {{pages}}",
"previous": "الصفحة السابقة",
"next": "الصفحة التالية",
"perPage": "{{count}} في كل صفحة",
"perPageLabel": "الإضافات في كل صفحة"
},
"showUnsupportedHint": "هذه مدرجة ولكن لا يوجد إصدار يعمل على هذا الإصدار بعد. قم بتحديث Termix لتثبيتها.",
"unsupported": "يتطلب إصدار أحدث من Termix",
"unsupportedHint": "لا يوجد إصدار من هذه الإضافة يعمل على هذا الإصدار من Termix. قم بتحديث Termix لتثبيتها.",
"needsTermix": "يتطلب Termix {{range}}",
"showUnsupported": "إظهار غير المدعوم ({{count}})",
"showMore": "إظهار {{count}} أخرى ({{total}} متبقية)"
"envDefault": "الافتراضي: {{value}}"
}
},
"settings": {
@@ -1778,6 +1723,107 @@
"noData": "لا توجد بيانات",
"filter": "تصفية..."
},
"sync": {
"minutesAgo_zero": "منذ {{count}} دقائق",
"minutesAgo_one": "منذ دقيقة واحدة",
"minutesAgo_two": "منذ دقيقتين",
"minutesAgo_few": "منذ {{count}} دقائق",
"minutesAgo_many": "منذ {{count}} دقيقة",
"minutesAgo_other": "منذ {{count}} دقيقة",
"hoursAgo_zero": "منذ {{count}} ساعات",
"hoursAgo_one": "منذ ساعة واحدة",
"hoursAgo_two": "منذ ساعتين",
"hoursAgo_few": "منذ {{count}} ساعات",
"hoursAgo_many": "منذ {{count}} ساعة",
"hoursAgo_other": "منذ {{count}} ساعة",
"pending_zero": "{{count}} تغييرات في انتظار المزامنة",
"pending_one": "تغيير واحد في انتظار المزامنة",
"pending_two": "تغييران في انتظار المزامنة",
"pending_few": "{{count}} تغييرات في انتظار المزامنة",
"pending_many": "{{count}} تغييراً في انتظار المزامنة",
"pending_other": "{{count}} تغيير في انتظار المزامنة",
"proxy": {
"headersTitle": "رؤوس إضافية",
"headersHint": "تُرسَل مع كل طلب إلى الخادم، على سبيل المثال رمز خدمة Cloudflare Access.",
"headerName": "اسم الرأس",
"headerValue": "القيمة",
"savedValue": "تم الحفظ، اكتب لتغيير",
"addHeader": "إضافة رأس",
"removeHeader": "إزالة الرأس",
"basicAuthTitle": "اسم المستخدم وكلمة المرور",
"basicAuthHint": "لوكيل يطلب اسم مستخدم وكلمة مرور قبل Termix.",
"username": "اسم المستخدم",
"password": "كلمة المرور",
"allowInvalidCertificate": "الثقة بشهادة موقعة ذاتياً",
"allowInvalidCertificateHint": "فقط لخادمك الخاص، عندما لا تكون شهادته من جهة معروفة."
},
"problems": {
"invalidUrl": "هذا لا يبدو كعنوان ويب.",
"unreachable": "تعذر الوصول إلى الخادم. تحقق من العنوان وأن الخادم قيد التشغيل.",
"unreachableWithReason": "تعذر الوصول إلى الخادم: {{reason}}",
"tlsUntrusted": "شهادة الخادم غير موثوقة. إذا كان هذا خادمك الخاص بشهادة موقعة ذاتيًا، فعّل الخيار أدناه.",
"basicAuthRequired": "شيء ما أمام الخادم يطلب اسم مستخدم وكلمة مرور. أدخلهما أدناه.",
"proxyLogin": "صفحة تسجيل دخول أو وكيل (Proxy) أجابت بدلاً من Termix. أضف الرؤوس (Headers) التي يحتاجها أدناه، أو تحقق من العنوان.",
"notTermix": "لا يوجد خادم Termix عند هذا العنوان. إذا كان يعمل تحت مسار، فقم بتضمينه، مثل https://example.com/termix.",
"versionMismatch": "يعمل ذلك الخادم على إصدار أقدم من Termix. قم بتحديثه إلى 2.9 أو أحدث للربط.",
"differentAccount": "لقد قمت بتسجيل الدخول إلى حساب مختلف. سجّل الدخول إلى الحساب المرتبط بهذا الجهاز، أو قم بإلغاء الربط أولاً."
},
"wizard": {
"title": "الربط بخادم Termix",
"reloginTitle": "تسجيل الدخول مرة أخرى",
"savedServers": "الخوادم الأخيرة",
"removeServer": "إزالة من القائمة",
"serverDescription": "أدخل العنوان الذي تستخدمه لفتح Termix في متصفحك.",
"signinDescription": "تسجيل الدخول إلى {{url}}",
"chooseDescription": "هذا الجهاز يحتوي بالفعل على بيانات. ماذا يجب أن يحدث لها؟",
"finishingDescription": "جارٍ ربط هذا الجهاز.",
"serverUrl": "عنوان الخادم",
"serverUrlHint": "على سبيل المثال https://termix.example.com",
"showProxySettings": "إعدادات الوكيل العكسي",
"hideProxySettings": "إخفاء إعدادات الوكيل العكسي",
"continue": "متابعة",
"back": "رجوع",
"mergeTitle": "إضافته إلى حسابي",
"mergeBody_zero": "تمت إضافة العناصر الموجودة على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
"mergeBody_one": "تمت إضافة العنصر الموجود على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
"mergeBody_two": "تمت إضافة العنصرين الموجودين على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
"mergeBody_few": "تمت إضافة العناصر الموجودة على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
"mergeBody_many": "تمت إضافة العناصر الموجودة على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
"mergeBody_other": "تمت إضافة {{count}} عنصرًا موجودًا على هذا الجهاز إلى حسابك. العناصر التي يمتلكها حسابك بالفعل تبقى كما هي على الخادم.",
"replaceTitle": "استبداله بحسابي",
"replaceBody_zero": "تمت إزالة العناصر الموجودة على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
"replaceBody_one": "تمت إزالة العنصر الموجود على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
"replaceBody_two": "تمت إزالة العنصرين الموجودين على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
"replaceBody_few": "تمت إزالة العناصر الموجودة على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
"replaceBody_many": "تمت إزالة العناصر الموجودة على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
"replaceBody_other": "تمت إزالة {{count}} عنصرًا موجودًا على هذا الجهاز، وسيأخذ هذا الجهاز كل شيء من حسابك. ستبقى المضيفات المحفوظة على هذا الجهاز فقط.",
"link": "ربط",
"linking": "جارٍ الربط وتشغيل المزامنة الأولى...",
"linked": "تم الربط. بياناتك تتم مزامنتها الآن.",
"signedInAgain": "تم تسجيل الدخول. المزامنة تعمل مرة أخرى."
},
"entities": {
"hosts": "المضيفين",
"sshCredentials": "بيانات الاعتماد",
"sshFolders": "المجلدات",
"userPreferences": "التفضيلات",
"pluginUserSettings": "إعدادات الإضافات",
"sharedHosts": "المضيفين المشتركين معك",
"sharedCredentials": "بيانات الاعتماد المشتركة معك",
"vaultProfiles": "ملفات تعريف Vault",
"snippetFolders": "مجلدات المقاطع",
"snippets": "المقاطع",
"dashboardServiceLinks": "روابط الخدمات",
"homepageItems": "عناصر الصفحة الرئيسية",
"networkTopology": "خريطة الشبكة",
"workspaces": "مساحات العمل",
"hostDefaults": "الإعدادات الافتراضية للمضيفين",
"hostDefaultsAdmin": "الإعدادات الافتراضية لمضيفي الخادم",
"aiProviders": "مزودي الذكاء الاصطناعي",
"homepageLayouts": "تخطيطات الصفحة الرئيسية",
"accountProfile": "ملف تعريف الحساب"
}
},
"hostDefaults": {
"source": {
"folder": "مجلد {{name}}",
@@ -1822,12 +1868,7 @@
"gridView": "شبكة",
"listView": "قائمة",
"compactRows": "صفوف مدمجة",
"comfortableRows": "صفوف مريحة",
"columns": {
"title": "الأعمدة",
"auto": "تلقائي",
"count": "عرض {{count}}"
}
"comfortableRows": "صفوف مريحة"
},
"fields": {
"secretSaved": "تم الحفظ. اكتب لاستبداله."
+151 -154
View File
@@ -213,8 +213,20 @@
"pluginsSomeFailed": "Някои плъгини не могат да бъдат променени. Можете да ги поправите по-късно от раздела Плъгини. Натиснете Напред, за да продължите.",
"pluginsApplyFailed": "Вашите избори не могат да бъдат запазени. Опитайте отново или продължете и ги променете по-късно от раздела Плъгини.",
"pluginsLoadFailed": "Списъкът с плъгини не може да бъде зареден. Можете да изберете плъгини по-късно от раздела Плъгини.",
"presetTitle": "Колко искате да виждате на екрана?",
"presetIntro": "Изберете колко се показва на екрана.",
"presetChangeLater": "Можете да смените предварителните настройки или да промените всяка отделна настройка по-късно в Настройки, под Интерфейс.",
"appearanceTitle": "Направете го ваше",
"appearanceIntro": "Изберете тема, акцентен цвят, шрифт и език. Промените се виждат веднага и можете да ги промените по-късно в Настройки.",
"securityTitle": "Сигурност на акаунта",
"securityIntro": "Добавете passkey или приложение за автентикация към вашия акаунт. Можете да направите това и по-късно в Настройки, под Сигурност.",
"desktopTitle": "Как искате да използвате Termix?",
"desktopBody": "Можете да промените това по всяко време от Синхронизация в страничната лента.",
"desktopLocalTitle": "Само на това устройство",
"desktopLocalBody": "Всичко се съхранява тук. Няма нужда от настройка.",
"desktopLinkTitle": "С моя Termix сървър",
"desktopLinkBody": "Влезте в сървъра си, за да използвате същите хостове и настройки на всяко устройство.",
"desktopLinked": "Връзка с {{server}}",
"pluginsDocsLink": "Прочетете за всеки от тях"
},
"desktopUpdates": {
@@ -235,15 +247,6 @@
"ready": "Termix {{version}} е готов за инсталация",
"readyHint": "Ще се инсталира следващия път, когато Termix се рестартира."
},
"safeMode": {
"title": "Режим на безопасност",
"description": "Само плъгините за подредба и тема са активни. Рестартирайте сървъра без променливата TERMIX_SAFE_MODE, за да активирате останалите."
},
"companion": {
"close": "Затвори панела",
"moveSide": "Премести панела",
"resize": "Промени размера на панела"
},
"nav": {
"home": "Начало",
"terminal": "Терминал",
@@ -254,6 +257,7 @@
"hosts": "Хостове",
"credentials": "Удостоверения",
"connections": "Връзки",
"sync": "Синхронизиране",
"reconnectDisconnectedTerminals": "Повторно свързване на всички прекъснати терминали",
"reconnectingTerminals_one": "Повторно свързване на {{count}} терминал",
"reconnectingTerminals_other": "Повторно свързване на {{count}} терминала",
@@ -275,7 +279,10 @@
"retry": "Опитай отново",
"refresh": "Опресни",
"optional": "По избор",
"downloadSample": "Изтегли пример",
"failedToDeleteHost": "Неуспешно изтриване на {{name}}",
"importSkipExisting": "Импортиране (пропусни съществуващите)",
"importSSHConfig": "Импортиране от SSH конфигурация",
"connectionDetails": "Детайли за връзката",
"ssh": "SSH",
"telnet": "Telnet",
@@ -494,7 +501,10 @@
"noFolder": "(Без папка)",
"deleteSelected": "Изтриване",
"exitSelection": "Изход от избора",
"importSkip": "Импортиране (пропускане на съществуващи)",
"importOverwrite": "Импортиране (записване върху съществуващи)",
"collapseBtn": "Свиване",
"importExportBtn": "Импортиране / Експортиране",
"hostStatusesRefreshed": "Статусите на хостовете са обновени",
"failedToRefreshHosts": "Грешка при обновяването на хостовете",
"movedHostTo": "Преместихте {{host}} в \"{{folder}}\"",
@@ -549,9 +559,6 @@
"deleteFolderConfirm_one": "Изтриване на \"{{name}}\" и неговия {{count}} хост? Това действие е необратимо.",
"deleteFolderConfirm_other": "Изтриване на \"{{name}}\" и неговите {{count}} хоста? Това действие е необратимо.",
"failedToMoveHosts": "Грешка при преместване на хостовете",
"managedBanner": "Този хост се управлява от {{plugin}}. Направете промени там. Все още можете да го закрепите и да промените реда му.",
"managedTooltip": "Управлява се от {{plugin}}",
"addBlocked": "Хостовете се управляват от {{plugin}}, така че тук не могат да се добавят нови хостове.",
"failedToReorder": "Грешка при преподреждане",
"expandAll": "Разгъване на всички папки",
"collapseAll": "Свиване на всички папки",
@@ -662,6 +669,35 @@
"addCredentialBtn2": "Добавяне на идентификационни данни",
"checkingHostStatuses": "Проверяване на състоянието на хостовете...",
"pinnedSection": "Закачени",
"hostsExported": "Хостовете бяха експортирани успешно",
"export": {
"menuItem": "Експортиране...",
"title": "Експортиране на хостове",
"scope": "Обхват",
"scopeAll": "Всички",
"scopeSelected": "Избрани",
"searchHosts": "Търсене на хостове...",
"include": "Включване",
"groupConnection": "Връзка",
"groupCredentials": "Удостоверения",
"groupNotes": "Бележки",
"groupTags": "Етикети и закачане",
"groupProxy": "Прокси",
"groupJumpHosts": "Промежътъчни хостове",
"groupFeatureFlags": "Настройки на плъгините",
"groupAdvanced": "Разширена конфигурация",
"preview": "Преглед",
"moreHosts_one": "... още {{count}} хост",
"moreHosts_other": "... още {{count}} хоста",
"summary": "{{selected}} от {{total}} хоста",
"credentialsIncluded": "включени удостоверения",
"credentialsExcluded": "изключени удостоверения",
"noneSelected": "Няма избрани хостове",
"cancel": "Отказ",
"confirm": "Експортиране",
"fetchFailed": "Грешка при зареждане на хостовете за експортиране",
"bulkButton": "Експортиране"
},
"sampleDownloaded": "Примерният файл беше изтеглен",
"failedToDeleteCredential2": "Грешка при изтриване на удостоверение",
"noFolderOption": "(Без папка)",
@@ -824,7 +860,11 @@
"editorUpdate": "Актуализирай хост",
"editorAdd": "Добави хост",
"speedSearch": "Търсене на хостове. Стрелки за избор, Enter за връзка, Esc за затваряне",
"speedSearchList": "Списък с хостове. Напишете или натиснете / за търсене."
"speedSearchList": "Списък с хостове. Напишете или натиснете / за търсене.",
"importNoHosts": "Не са намерени хостове във файла",
"importTooMany": "Можете да импортирате до {{max}} хоста наведнъж",
"importSummary": "Импортирането е завършено: {{imported}} импортирани, {{updated}} актуализирани, {{failed}} неуспешни",
"importHostsFailed": "Неуспешно импортиране на хостове"
},
"splitScreen": {
"title": "Разделен екран",
@@ -1075,59 +1115,17 @@
"colorPickerTooltip": "Отваряне на изборника на цветове",
"themeSystem": "Системна",
"themeLight": "Светла",
"themeDark": "Тъмна"
"themeDark": "Тъмна",
"themeDracula": "Dracula",
"themeCatppuccin": "Catppuccin",
"themeNord": "Nord",
"themeSolarized": "Solarized",
"themeTokyoNight": "Tokyo Night",
"themeOneDark": "One Dark",
"themeGruvbox": "Gruvbox"
}
}
},
"looks": {
"layoutHint": "Как е подредено цялото приложение. Темите и подредбите идват от плъгини.",
"defaultsLabel": "По подразбиране за всички",
"defaultsHint": "Какво виждат потребителите, докато не изберат своя собствена подредба или тема.",
"followDefault": "Използвай по подразбиране",
"defaultSaved": "Външният вид по подразбиране е запазен",
"saveFailed": "Външният вид не може да бъде запазен",
"repair": {
"title": "Тази подредба не може да бъде показана",
"safeTitle": "Сигурен режим",
"layout": "Подредба",
"plugin": "Плъгин",
"reason": "Причина",
"error": "Грешка",
"noError": "Няма съобщение за грешка.",
"copy": "Копирай",
"copied": "Копирано",
"copyFailed": "Детайлите не могат да бъдат копирани",
"retry": "Опитай отново",
"reset": "Върни външния вид по подразбиране",
"resetEveryone": "Върни настройките по подразбиране за всички",
"update": "Актуализирай {{name}} до {{version}}",
"disable": "Изключи {{name}}",
"enable": "Активиране на {{name}}",
"startAgain": "Стартиране на {{name}} отново",
"install": "Намери подредби и теми",
"installTitle": "Подредби и теми",
"installButton": "Инсталирай",
"nothingToInstall": "В момента няма други налични подредби или теми.",
"registryUnreachable": "Регистърът на плъгините не може да бъде достигнат.",
"askAdmin": "Попитайте администратор да актуализира или поправи тази подредба. По-долу можете да превключите на друга подредба или тема само за себе си.",
"switchLayout": "Превключи подредба",
"switchTheme": "Превключи тема",
"saveFailed": "Вашият избор не може да бъде запазен",
"reasons": {
"safe-url": "Адресът изисква сигурен режим. Изберете подредба по-долу или премахнете ?safe-layout=1 от адреса.",
"safe-env": "Сървърът е стартиран с TERMIX_SAFE_LAYOUT, така че всички виждат този екран, докато той не бъде премахнат.",
"missing": "Няма инсталиран плъгин, който да предоставя тази подредба.",
"disabled": "Плъгинът, който предоставя тази подредба, е изключен.",
"blocked": "Плъгинът, който предоставя тази подредба, чака нещо, от което се нуждае.",
"incompatible": "Плъгинът, който предоставя тази подредба, не работи с тази версия на Termix.",
"failed": "Подредбата не успя да се стартира.",
"crashed": "Подредбата се срина два пъти, докато зареждаше приложението."
},
"noLayout": "Няма зададена подредба",
"restore": "Възстанови вградената {{name}}",
"restoreFailed": "Вградената копия не може да бъде възстановена"
}
},
"plugins": {
"capabilities": {
"credentials:read": {
@@ -1150,18 +1148,6 @@
"title": "Управление на влизането",
"consequence": "Може да добавя метод за влизане, втори фактор или тип SSH влизане."
},
"auth:policy": {
"title": "Задайте правила за влизане",
"consequence": "Може да излиза потребителите при бездействие, да ограничава продължителността на сесията, да изключва опцията „Запомни ме“ и да изисква втор фактор."
},
"ssh:transport": {
"title": "Изберете как се достъпват хостовете",
"consequence": "Може да пренасочва SSH връзката към хост през собствен си маршрут, така че вижда всичко, изпратено до и от тези хостове, включително пароли."
},
"sync:provide": {
"title": "Изпълни синхронизация",
"consequence": "Може да отговаря на всички заявки за синхронизация, така че вижда данните, които устройствата и сървърите синхронизират."
},
"system:tls": {
"title": "Управление на сертификати",
"consequence": "Може да замени сертификата, който Termix предоставя чрез HTTPS, и да отговаря на проверки от домейни от органи за издаване на сертификати."
@@ -1170,10 +1156,6 @@
"title": "Достъп до серийни и USB устройства",
"consequence": "Може да отвори физически серийен порт на устройството, което изпълнява Termix."
},
"hosts:export": {
"title": "Експортирай хостове с техните тайни",
"consequence": "Може да чете хостовете ви напълно, включително запазените пароли и ключове, и да ги предоставя. Всеки експорт се записва в журнала за одит."
},
"hosts:write": {
"title": "Създаване и промяна на хостове",
"consequence": "Може да добавя хостове и да редактира тези, които вече имате."
@@ -1218,30 +1200,10 @@
"title": "Четене на одитния журнал",
"consequence": "Може да вижда кой какво е направил и кога."
},
"network:probe": {
"title": "Провери дали портовете отговарят",
"consequence": "Може да отваря обикновена връзка към всеки адрес от сървъра, за да види дали е достъпен."
},
"hosts:status-provide": {
"title": "Предоставяне на статус на хоста",
"consequence": "Става източник на точките за онлайн и офлайн статус за всеки хост."
},
"hosts:activity-provide": {
"title": "Съхраняване на последната активност",
"consequence": "Станва хранилище за списъка с последната активност на всеки потребител."
},
"hosts:lock": {
"title": "Управление на хостове само за четене",
"consequence": "Хостовете, които управлява, не могат да бъдат редактирани, изтривани или споделяни отново от никого друг."
},
"desktop:window": {
"title": "Отваряне на прозорци на работния плот",
"consequence": "Само в десктоп приложението на Termix."
},
"desktop:local": {
"title": "Използване на този компютър",
"consequence": "Може да чете и променя файлове, да стартира shell и да отваря локални портове на този компютър. Само в десктоп приложението на Termix."
},
"users:impersonate": {
"title": "Действане от името на други потребители",
"consequence": "Изпълнява фоновата работа с достъпа на друг потребител, като например планирани задачи. Всяко използване се записва в одитния журнал."
@@ -1281,14 +1243,6 @@
"ui:surface": {
"title": "Добавя свои собствени екрани",
"consequence": "Раздели, панели и настройки, които можете да скриете по-късно."
},
"ui:layout": {
"title": "Замяна на цялата структура на приложението",
"consequence": "Когато е избран, неговият код изчертава всеки екран, включително терминалите и настройките ви."
},
"ui:theme": {
"title": "Добавяне на цветови теми",
"consequence": "Само цветове и стилове. За тема не се изпълнява код."
}
},
"runtime": {
@@ -1315,8 +1269,7 @@
"disabled": "Изключена",
"failed": "Спряна неочаквано",
"blocked": "Чака друга добавка",
"server": "Работи на вашия сървър",
"safeMode": "Изключен в режим на безопасност"
"server": "Работи на вашия сървър"
},
"groups": {
"running": "Работещи",
@@ -1330,6 +1283,11 @@
"updated": "Наскоро актуализирани",
"category": "Категория"
},
"columns": {
"title": "Колони",
"auto": "Автоматично",
"count": "{{count}} колони"
},
"table": {
"name": "Име",
"description": "Описание",
@@ -1414,7 +1372,6 @@
"options": "Не можа да се запазят опциите за {{name}}: {{error}}",
"deleteData": "Не можа да се изтрият данните за {{name}}: {{error}}",
"managedByServer": "Този работен плот е свързан със сървър, който определя плъгините му.",
"defaultLook": "{{name}} е стандартният външен вид, така че може да бъде изключен или заменен, но не и деинсталиран.",
"hasDependents": "{{name}} е необходим за {{list}}. Изключете ги или ги деинсталирайте първо.",
"missingDependencies": "{{name}} се нуждае от {{list}}, който не е инсталиран.",
"consentChanged": "Какво изисква този плъгин се е променило от последния път, когато го проверихте. Проверете списъка отново.",
@@ -1484,6 +1441,7 @@
"noServerAccess": "Няма достъп до сървърите ви",
"searchInstalled": "Търси сред инсталираните",
"searchPlugins": "Търси плъгини",
"allCategories": "Всички",
"nothingMatches": "Няма съвпадения.",
"noneInstalled": "Все още няма инсталирани плъгини. Разгледайте каталога, за да добавите някой.",
"nothingHere": "Тук няма нищо.",
@@ -1573,44 +1531,7 @@
"documentation": "Документация",
"envVars": "Променливи на средата",
"envRequired": "Задължителни",
"envDefault": "По подразбиране: {{value}}",
"kinds": {
"layouts": "Структури",
"themes": "Теми",
"tabs": "Раздели",
"panels": "Панели в страничната лента",
"dashboardCards": "Карти на таблото",
"protocols": "Видове връзки",
"auth": "Методи за вписване",
"actions": "Действия",
"hostCapability": "Настройки на хоста"
},
"filters": {
"type": "Вид",
"allTypes": "Всички видове",
"category": "Категория",
"allCategories": "Всички категории",
"results_one": "{{count}} плъгин",
"results_other": "{{count}} плъгина",
"remove": "Премахване на филтъра {{name}}",
"clear": "Изчистване на филтрите",
"button": "Филтри",
"buttonCount": "Филтри ({{count}})"
},
"pager": {
"range": "{{from}}-{{to}} от {{total}}",
"page": "Страница {{page}} от {{pages}}",
"previous": "Предишна страница",
"next": "Следваща страница",
"perPage": "{{count}} на страница",
"perPageLabel": "Плъгини на страница"
},
"showUnsupportedHint": "Тези са изброени, но все още няма версия, която да работи с тази версия. Актуализирайте Termix, за да ги инсталирате.",
"unsupported": "Изисква по-нова версия на Termix",
"unsupportedHint": "Няма версия на този плъгин, която да работи с тази версия на Termix. Актуализирайте Termix, за да го инсталирате.",
"needsTermix": "Изисква Termix {{range}}",
"showUnsupported": "Показване на неподдържани ({{count}})",
"showMore": "Показване на още {{count}} (остава {{total}})"
"envDefault": "По подразбиране: {{value}}"
}
},
"settings": {
@@ -1626,6 +1547,87 @@
"noData": "Няма данни",
"filter": "Филтър..."
},
"sync": {
"minutesAgo_one": "преди {{count}} минута",
"minutesAgo_other": "преди {{count}} минути",
"hoursAgo_one": "преди {{count}} час",
"hoursAgo_other": "преди {{count}} часа",
"pending_one": "{{count}} промяна чака синхронизиране",
"pending_other": "{{count}} промени чакат синхронизиране",
"proxy": {
"headersTitle": "Допълнителни хедъри",
"headersHint": "Изпращат се с всяка заявка към сървъра, например токен за услуга Cloudflare Access.",
"headerName": "Име на хедъра",
"headerValue": "Стойност",
"savedValue": "Запазено, въведете за промяна",
"addHeader": "Добави хедър",
"removeHeader": "Премахни хедър",
"basicAuthTitle": "Потребителско име и парола",
"basicAuthHint": "За прокси, който изисква потребителско име и парола преди Termix.",
"username": "Потребителско име",
"password": "Парола",
"allowInvalidCertificate": "Да доверя самоподписан сертификат",
"allowInvalidCertificateHint": "Само за вашия собствен сървър, когато сертификатът му не е от известен орган."
},
"problems": {
"invalidUrl": "Това не изглежда като уеб адрес.",
"unreachable": "Не може да се свърже със сървъра. Проверете адреса и дали сървърът работи.",
"unreachableWithReason": "Не може да се свърже със сървъра: {{reason}}",
"tlsUntrusted": "Сертификатът на сървъра не е доверен. Ако това е ваш собствен сървър със самоподписан сертификат, включете опцията по-долу.",
"basicAuthRequired": "Някакво устройство пред сървъра изисква потребителско име и парола. Въведете ги по-долу.",
"proxyLogin": "Страница за вход или прокси отговори вместо Termix. Добавете необходимите хедъри по-долу или проверете адреса.",
"notTermix": "На този адрес няма Termix сървър. Ако работи под път, включете го, например https://example.com/termix.",
"versionMismatch": "Този сървър използва по-стара версия на Termix. Актуализирайте го до 2.9 или по-нова, за да го свържете.",
"differentAccount": "Влязохте в различен акаунт. Влезте в акаунта, към който е свързано това устройство, или първо го разкачете."
},
"wizard": {
"title": "Свързване с Termix сървър",
"reloginTitle": "Влезте отново",
"savedServers": "Последни сървъри",
"removeServer": "Премахване от списъка",
"serverDescription": "Въведете адреса, който използвате за отваряне на Termix в браузъра си.",
"signinDescription": "Влезте в {{url}}",
"chooseDescription": "Това устройство вече има данни. Какво да се случи с тях?",
"finishingDescription": "Свързване на това устройство.",
"serverUrl": "Адрес на сървъра",
"serverUrlHint": "Например https://termix.example.com",
"showProxySettings": "Настройки на обратния прокси",
"hideProxySettings": "Скриване на настройките на обратния прокси",
"continue": "Продължи",
"back": "Назад",
"mergeTitle": "Добави ги към моя акаунт",
"mergeBody_one": "Елементът на това устройство се добавя към вашия акаунт. Елементите, които вече има акаунтът ви, остават непроменени на сървъра.",
"mergeBody_other": "{{count}} елемента от това устройство се добавят към вашия акаунт. Елементите, които вече има акаунтът ви, остават непроменени на сървъра.",
"replaceTitle": "Замени ги с моя акаунт",
"replaceBody_one": "Елементът на това устройство се премахва, а устройството приема всичко от вашия акаунт. Хостове, запазени само на това устройство, остават.",
"replaceBody_other": "{{count}} елемента от това устройство се премахват, а устройството приема всичко от вашия акаунт. Хостове, запазени само на това устройство, остават.",
"link": "Свържи",
"linking": "Свързване и стартиране на първата синхронизация...",
"linked": "Свързано. Данните ви се синхронизират.",
"signedInAgain": "Влезте. Синхронизацията работи отново."
},
"entities": {
"hosts": "Хостове",
"sshCredentials": "Идентификационни данни",
"sshFolders": "Папки",
"userPreferences": "Предпочитания",
"pluginUserSettings": "Настройки на плъгините",
"sharedHosts": "Хостове, споделени с вас",
"sharedCredentials": "Идентификационни данни, споделени с вас",
"vaultProfiles": "Профили на Vault",
"snippetFolders": "Папки със снипети",
"snippets": "Снипети",
"dashboardServiceLinks": "Връзки към услуги",
"homepageItems": "Елементи на началната страница",
"networkTopology": "Мрежова карта",
"workspaces": "Работни пространства",
"hostDefaults": "Стандартни настройки на хостовете",
"hostDefaultsAdmin": "Стандартни настройки на хостовете на сървъра",
"aiProviders": "AI доставчици",
"homepageLayouts": "Разположения на началната страница",
"accountProfile": "Профил на акаунта"
}
},
"hostDefaults": {
"source": {
"folder": "Папка {{name}}",
@@ -1654,12 +1656,7 @@
"gridView": "Мрежа",
"listView": "Списък",
"compactRows": "Компактни редове",
"comfortableRows": "Комфортни редове",
"columns": {
"title": "Колони",
"auto": "Автоматично",
"count": "ширина {{count}}"
}
"comfortableRows": "Комфортни редове"
},
"fields": {
"secretSaved": "Запазено. Въведете ново, за да го замените."
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more