release-2.9.0 (#1532)

* fix(macos): skip single-instance lock in Mac App Store builds (#1454)

fix(macos): skip single-instance lock in Mac App Store builds

* Fix/backend optional sharp (#1455)

Fix/backend optional sharp

* feat: issue #1218 (#1218)

https://github.com/Termix-SSH/Support/issues/1218

* feat: Add option to use saved global custom theme under Connection Defaults (#1209)

https://github.com/Termix-SSH/Support/issues/1209

* feat: Add Fleet Sharing functionality to the UI (#1228)

https://github.com/Termix-SSH/Support/issues/1228

* feat: Add a list view to the Docker management page (#1237)

https://github.com/Termix-SSH/Support/issues/1237

* feat: issue #1264 (#1264)

https://github.com/Termix-SSH/Support/issues/1264

* feat: 2FA With FortiToken (#1288)

https://github.com/Termix-SSH/Support/issues/1288

* feat: Allow to disable showing paths to folders (#1274)

https://github.com/Termix-SSH/Support/issues/1274

* feat: Sync Network Graph between Desktop and Remote Server (#1245)

https://github.com/Termix-SSH/Support/issues/1245

* feat: Version Number on offline servers (#1291)

https://github.com/Termix-SSH/Support/issues/1291

* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)

https://github.com/Termix-SSH/Support/issues/1302

* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)

https://github.com/Termix-SSH/Support/issues/1289

* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)

https://github.com/Termix-SSH/Support/issues/1303

* fix: I can't send a file on its own. (#1304)

https://github.com/Termix-SSH/Support/issues/1304

* fix: sudo password not autofilling (#1248)

https://github.com/Termix-SSH/Support/issues/1248

* fix: node-MainThread (#1300)

https://github.com/Termix-SSH/Support/issues/1300

* fix: Tunnel authentication with shared credentials (#1295)

https://github.com/Termix-SSH/Support/issues/1295

* fix: host key updates not syncing, metrics 404 race on first connect

Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.

* chore: increment ver

* feat: add category and icon fields to plugin manifest schema

* ci(deps): bump the github-actions group with 2 updates (#1458)

Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).


Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)

Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)

---
updated-dependencies:
- dependency-name: crowdin/github-action
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)

Bumps the dev-patch-updates group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |


Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)

Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)

Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)

Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)

Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)

Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)

Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)

Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)

Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)

Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)

Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)

Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)

---
updated-dependencies:
- dependency-name: "@codemirror/commands"
  dependency-version: 6.11.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
  dependency-version: 6.7.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
  dependency-version: 10.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
  dependency-version: 16.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
  dependency-version: 1.15.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: cytoscape
  dependency-version: 3.34.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
  dependency-version: 0.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: i18next
  dependency-version: 26.4.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: prettier
  dependency-version: 3.9.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: react-i18next
  dependency-version: 17.0.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)

Bumps the prod-patch-updates group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |


Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)

Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)

Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)

Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)

Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)

Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: compression
  dependency-version: 1.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jose
  dependency-version: 6.2.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: jszip
  dependency-version: 3.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: socks
  dependency-version: 2.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: undici
  dependency-version: 8.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: add plugin worker protocol, bootstrap and plugin_storage table

Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.

* feat: add allowlisted host view for plugins

An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.

* feat: add plugin loader with manifest validation and crash restart

Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.

The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.

loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.

* feat: route internal events through a shared plugin event bus

Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.

Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.

* feat: add plugin ctx broker with permission gate and audit

Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.

Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.

fix: stop plugin workers seeing plaintext credentials in error messages

A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:

  Authentication failed for root using password <SECRET>

where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.

Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.

Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.

* feat: dispatch plugin HTTP routes and wire the runtime into startup

ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.

Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.

fix: correct the nginx plugin-api location regex

The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.

* fix: update stale SFTP transfer test for single file destination paths

The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.

* feat: ship the ssh terminal as a first-party in-process plugin

Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.

* feat: ship docker as a first-party in-process plugin

Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.

* feat: ship host metrics as a first-party in-process plugin

* fix: harden plugin auth and add per-request identity for ctx calls

Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.

* feat: ship the AI assistant as a first-party in-process plugin

Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.

* fix: remove compiled plugin JS left behind in plugins/ after build

copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.

* feat: add a permission-gated backend service registry for plugins

* feat: add a permission-gated UI action registry and action slots for plugins

Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.

* feat: add cross-plugin secret references gated by the provider's RBAC permission

* feat: move proxmox discovery/import/sync into a first-party plugin

* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin

Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.

* feat: wire remote-desktop plugin into core and rename i18n keys

Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.

* feat: move fleets into a first-party plugin

* feat: move automations into a first-party plugin

* feat: remove legacy alert rules, superseded by automations

Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.

* feat: move network topology into a first-party plugin

* feat: move tailscale device discovery and settings into a plugin

Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.

* feat: move workspaces into a first-party plugin

* feat: move web endpoint into a first-party plugin

* feat: collapse plugin runtime to one tier behind a plugin SDK

* feat: make each plugin an npm workspace built by a termix-plugin CLI

* feat: give plugins their own tables, migrations and sync entities

* feat: serve plugin http and websockets from one mount instead of ports

* feat: namespace plugin permissions and keep them valid when a plugin is off

* feat: give plugins their own settings and one settings screen

* feat: load plugin frontends at runtime and remove plugin ids from the shell

* fix: mock the ai status fetch in its activate test

* feat: add auth extension points and one ssh connect pipeline

* feat: rebuild workspaces as the reference plugin

* fix: gate second factors after SSO/LDAP behind an admin setting

* feat: convert network-topology to a full plugin (B1)

* feat: move snippets backend and tables into a plugin (B2)

* feat: point core frontend snippets pickers at the plugin (B2)

* feat: add the snippets plugin (B2)

* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)

* feat: finish the tailscale plugin, including SSH auth (B3)

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* chore: update proxmox and host-metrics locale files for B5

* chore: regenerate drizzle migration journals for B5

* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox

* feat: move proxmox node stats and collectors into the proxmox plugin

* feat: drop proxmox host columns, use plugin settings instead

* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

* feat: convert file manager into a plugin

* feat: convert ssh tunnels into a plugin

* feat: finish the web-endpoint plugin, add ctx.desktop (B8)

* feat: move the terminal fully into the ssh-terminal plugin (B9)

* feat: convert tmux monitoring into a plugin (B10)

* feat: convert serial console into the serial plugin

* feat: move session sharing and collab rooms into a plugin (B12)

* feat: turn session recording into a plugin (B13)

* feat: finish the remote-desktop plugin (B14)

* feat: finish the host-metrics plugin, move host status into core (B16)

* feat: finish the docker plugin (B15)

* feat: finish the automations plugin (B17)

* feat: finish the ai plugin (B18)

* feat: finish the homepage plugin, move service links and canvas out of core (B19)

* feat: convert wake-on-lan into a plugin (B20)

* feat: convert secret-sources into a plugin (B20)

* feat: move totp and passkeys into plugins (C1)

* feat: move sso and ldap logins into plugins (C2)

* feat: move opkssh and warpgate into plugins (C3)

* feat: move step-ca into a plugin (C4)

* feat: move vault ssh signing into a plugin (C5)

* feat: move termix identity into a plugin (C6)

* feat: move acme certificate automation into a plugin (C7)

* fix: finish 2.9.0 plugin moves and get every check green

* chore: lock the plugin boundary and remove 2.9.0 leftovers

* fix: harden the plugin runtime after the 2.9.0 security review

* feat: add plugin packing, signing, pinned trust and tmxplug bundling

* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught

* fix: give bundled plugin backends a real require for CJS deps

* feat: improve UI loading time and jitter

* feat: show credential sharing inline in the sidebar like host sharing

* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning

* feat: unify alerts into one plugin, channels, rules and inbox

* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling

* fix: set blacksmith builder cache-key to stop broken gha cache fallback

* fix: link plugin-sdk workspace before npm ci in docker deps stage

* fix: link every plugin workspace before npm ci in docker deps stage

* fix(host-metrics): exclude image and firmware filesystems (#1469)

* fix(host-metrics): exclude image and firmware filesystems

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(hosts): normalize imported jump host references (#1470)

* fix(hosts): normalize imported jump host references

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent (#1476)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): keep inline rename consistent

* fix(dashboard): reuse translated host status labels (#1475)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(dashboard): reuse translated host status labels

* docs(api): clarify local and remote version fields (#1474)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* docs(api): clarify local and remote version fields

* fix(file-manager): preserve selected backgrounds on hover (#1473)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): preserve selection feedback on hover

* test(ui): await dialog focus cleanup before teardown

* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)

* fix(ci): build plugin SDK before dependent checks

* fix(mysql): keep plugin settings scope indexes within limits

* test(mysql): refresh reviewed plugin upgrade SQL

* fix(file-manager): focus file search with Ctrl or Cmd F

* fix: keep rounded-full circular in production builds

* fix(file-manager): cancel uploads and stop remote writes (#1477)

* fix(file-manager): cancel uploads and stop remote writes

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): apply compact density to directory tree (#1478)

* fix(file-manager): apply compact density to directory tree

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): preserve font sizes in compact mode (#1480)

* fix(file-manager): preserve font sizes in compact mode

* test(css): exercise the configured PostCSS plugin chain

* fix(file-manager): prioritize file names in narrow lists (#1483)

* fix(file-manager): prioritize file names in narrow lists

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(remote-desktop): probe the saved host guacd endpoint (#1481)

* fix(remote-desktop): probe the saved host guacd endpoint

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* fix(homepage): check allowlisted private service reachability (#1479)

* fix(homepage): check allowlisted private service reachability

* test(css): exercise the configured PostCSS plugin chain

* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)

* fix(remote-desktop): read clipboard only on explicit paste

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* chore: simplify plugin readmes, changelogs and manifest descriptions

* fix: keep plugins loaded after signing in from the login screen

* fix(file-manager): accept OS file drops over existing rows (#1484)

* fix(file-manager): accept OS file drops over existing rows

* test(file-manager): target the file label in drop tests

* test(css): exercise the configured PostCSS plugin chain

* test(identity): preserve full Ed25519 public keys in fixtures

* test(remote-desktop): always mutate ciphertext in tamper test

* fix(file-manager): pipeline SFTP download reads (#1485)

* feat: add plugin sdk build to beta release

* fix: make link-store secret check immune to random base64 matches

* fix(ldap): preserve the directory-provided bind DN (#1486)

* fix: compute beta changes across dev branches and move the beta tag

* chore: untrack plugin sdk ARCHITECTURE.md

* chore: remove references to plugin sdk ARCHITECTURE.md

* feat: merge host editor terminal tabs into one owned by ssh-terminal

* feat: add descriptions to plugin host enable toggles

* fix(file-manager): show download progress in file viewers (#1487)

* fix(electron): signal backend readiness over IPC (#1488)

* fix(split-screen): allow layout creation without randomUUID (#1490)

* fix(split-screen): allow layout creation without randomUUID

* test(credentials): avoid flaky Ed25519 key generation

* fix(sidebar): remeasure visible rows after density changes (#1489)

* fix(sidebar): remeasure visible rows after density changes

* test(credentials): avoid flaky Ed25519 key generation

* feat(split-screen): redesign split screen with free-form panes and pane picker

* feat(sidebar): add host click behavior setting, default to always opening a new tab

* feat(snippets): redesign panel to match the host list

* fix(session-recording): stop session logs panel from refetching in a loop

* feat(snippets): bring back sharing, target hosts and drag reorder

* feat(ssh-terminal): add toolbar position, start state, display and fade host settings

* feat(ssh-terminal): add host setting to turn off password prompt auto-fill

* fix(ai): stop streams getting cut off and keep tool steps across messages

* feat: improve quick connect system using plugin api

* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport

* feat: move terminal settings, look and ssh tools into ssh-terminal plugin

* feat: move snippets, keybinding actions and macros out of core into plugins

* feat: store rdp, vnc and telnet logins in a generic per-protocol table

* feat: type the plugin host record, move plugin strings out of core and add lint checks

* feat: replace core homepage widget registry with generic extension points

* feat: plugins only see their own host settings in the browser

* feat: harden plugin api for separate repos, signed updates and generic external login

* fix: keep plugin css below core utilities and version plugin assets by their css

* fix: load and save plugin host settings in the editor and tighten settings row spacing

* feat: redesign user host defaults system

* fix(remote-desktop): release held keys on focus loss (#1498)

* fix(session-sharing): replace local presentation after takeover (#1500)

* fix(file-manager): refresh disk usage with directory listing (#1503)

* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)

* feat(remote-desktop): expose display zoom controls for RDP (#1514)

* fix(file-manager): restore text selection and copying in Markdown previews (#1518)

* fix(sidebar): keep compact host addresses visible (#1524)

* fix(hosts): preserve plugin settings when reopening the editor (#1499)

* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)

* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)

* fix(session-sharing): show participant presence in member terminals (#1504)

* fix(ai): sync provider configuration and keys across linked devices (#1506)

* feat(ssh-terminal): add a duplicate tab action (#1510)

* feat(file-manager): allow disabling move-to-trash confirmation (#1511)

* feat(file-manager): default to the remote login directory (#1512)

* feat(homepage): synchronize canvas layouts across devices (#1515)

* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)

* feat(tmux-monitor): add a unified multi-host view (#1520)

* fix(electron): allow explicit quit with active connections (#1522)

* feat: reconnect all disconnected terminal tabs (#1523)

* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)

* fix(database): preserve RDP domain during startup backfill (#1497)

* feat(sidebar): add keyboard type-ahead search for hosts (#1516)

* feat(automations): add per-host maintenance mode and recurring schedules (#1521)

* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)

* fix(session-sharing): use the linked server for desktop meetings and links (#1507)

* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)

* fix: type errors and lint failures from merged plugin prs

* feat(installer): make the Windows desktop shortcut optional (#1517)

* fix: ship the plugin sdk and serialport in desktop builds

* fix: prefer ipv4 when resolving ssh host names (#1365)

* fix: readable terminal selection with the light theme (#1319)

* fix: build desktop session share links on the linked server (#1350)

* fix: show meeting guests to members and hide the roster from guests (#1353)

* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)

* fix: show disk usage for the mount being browsed (#1347)

* fix: load the linux tray icon reliably and log tray failures (#1332)

* fix: cover the gnome close path in the desktop quit test (#1332)

* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)

* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

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

* chore: skip crowdin pre-translate in release for now

* chore: sync Crowdin translations for 2.9.0

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 16:20:16 -05:00
1 parent 49fa488093
commit 3643e7af97
4405 files changed
+1341026 -289722

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
# Changelog
## 1.0.0
First release, bundled with Termix 2.9.0.
Opens a serial console to a device plugged into your computer. The desktop app talks to the device directly, and the browser uses Web Serial.
+21
View File
@@ -0,0 +1,21 @@
# Serial
Open a serial console to a device plugged into your computer.
## Features
- Open a serial console to a device plugged into your computer.
- The desktop app talks to the device directly.
- In a browser, the plugin uses Web Serial.
## Setup
The desktop app uses the `serialport` npm package, which has a native binary. The build does not bundle it.
## Development
```bash
npm run build # build into dist/
npm run test # run this plugin's tests
npm run typecheck # type-check this plugin
```
+29
View File
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Parity",
"parityNone": "None",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Disconnect",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Reeks"
},
"serial": {
"title": "Reeks",
"portLabel": "Hawe",
"portPlaceholder": "/dev/ttyUSB0 of COM3",
"baudRateLabel": "Baudtempo",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Pariteit",
"parityNone": "Geen",
"parityEven": "Selfs",
"parityOdd": "Vreemd",
"connect": "Koppel aan serieel",
"disconnect": "Ontkoppel",
"refreshPorts": "Verfris poorte",
"connected": "Verbind aan {{path}} teen {{baud}} baud",
"disconnected": "Seriepoort ontkoppel",
"connectionError": "Kon nie die seriepoort oopmaak nie",
"wsError": "WebSocket-fout",
"errorNoServerUrl": "Geen bediener-URL gekonfigureer nie",
"notSupportedTitle": "Serienommer word nie ondersteun nie",
"notSupported": "Seriële verbindings vereis 'n blaaier met Web Serial API-ondersteuning (Chrome, Edge of Firefox 151+), of die Termix-lessenaarprogram.",
"hideHint": "Jy kan die Seriële oortjie versteek onder Gebruikersprofiel > Voorkoms > Sybalk > Navigasie.",
"browserPickerHint": "Klik op Verbind en jou blaaier sal 'n poortkieser oopmaak om die toestel te kies."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "تسلسلي"
},
"serial": {
"title": "منفذ تسلسلي",
"portLabel": "المنفذ",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "معدل الباود",
"dataBitsLabel": "البيانات",
"stopBitsLabel": "التوقف",
"parityLabel": "التكافؤ",
"parityNone": "بلا",
"parityEven": "زوجي",
"parityOdd": "فردي",
"connect": "اتصال",
"disconnect": "قطع الاتصال",
"refreshPorts": "تحديث المنافذ",
"connected": "متصل بـ {{path}} بسرعة {{baud}} باود",
"disconnected": "تم قطع الاتصال بالمنفذ التسلسلي",
"connectionError": "فشل في فتح المنفذ التسلسلي",
"wsError": "خطأ في WebSocket",
"errorNoServerUrl": "لم يتم تكوين عنوان URL للخادم",
"notSupportedTitle": "المنفذ التسلسلي غير مدعوم",
"notSupported": "تتطلب الاتصالات التسلسلية متصفحًا يدعم Web Serial API (Chrome، أو Edge، أو Firefox 151+)، أو تطبيق Termix لسطح المكتب.",
"hideHint": "يمكنك إخفاء تبويب المنفذ التسلسلي من الملف الشخصي > المظهر > الشريط الجانبي > التنقل.",
"browserPickerHint": "انقر على Connect وسيفتح متصفحك نافذة اختيار المنفذ لتحديد الجهاز."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Сериен"
},
"serial": {
"title": "Сериен",
"portLabel": "Порт",
"portPlaceholder": "/dev/ttyUSB0 или COM3",
"baudRateLabel": "Скорост на бодове",
"dataBitsLabel": "Данни",
"stopBitsLabel": "Спри",
"parityLabel": "Паритет",
"parityNone": "Няма",
"parityEven": "Дори",
"parityOdd": "Странно",
"connect": "Свързване към сериен",
"disconnect": "Изключване",
"refreshPorts": "Обновяване на портове",
"connected": "Свързан към {{path}} при {{baud}} бод",
"disconnected": "Серийният порт е прекъснат",
"connectionError": "Неуспешно отваряне на серийния порт",
"wsError": "Грешка в WebSocket",
"errorNoServerUrl": "Няма конфигуриран URL адрес на сървъра",
"notSupportedTitle": "Серийният номер не се поддържа",
"notSupported": "Серийните връзки изискват браузър с поддръжка на Web Serial API (Chrome, Edge или Firefox 151+) или настолното приложение Termix.",
"hideHint": "Можете да скриете раздела „Сериен номер“ в Потребителски профил > Външен вид > Странична лента > Навигация.",
"browserPickerHint": "Щракнете върху „Свързване“ и браузърът ви ще отвори инструмент за избор на порт, за да изберете устройството."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Parity",
"parityNone": "None",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Disconnect",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Sèrie"
},
"serial": {
"title": "Sèrie",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 o COM3",
"baudRateLabel": "velocitat de bauds",
"dataBitsLabel": "Dades",
"stopBitsLabel": "Atura",
"parityLabel": "Paritat",
"parityNone": "Cap",
"parityEven": "Fins i tot",
"parityOdd": "Estrany",
"connect": "Connecta't al serial",
"disconnect": "Desconnecta",
"refreshPorts": "Actualitza els ports",
"connected": "Connectat a {{path}} a {{baud}} baud",
"disconnected": "Port sèrie desconnectat",
"connectionError": "No s'ha pogut obrir el port sèrie",
"wsError": "Error de WebSocket",
"errorNoServerUrl": "No s'ha configurat cap URL del servidor",
"notSupportedTitle": "Número de sèrie no compatible",
"notSupported": "Les connexions en sèrie requereixen un navegador compatible amb l'API Web Serial (Chrome, Edge o Firefox 151+) o l'aplicació d'escriptori Termix.",
"hideHint": "Podeu amagar la pestanya Sèrie a Perfil d'usuari > Aparença > Barra lateral > Navegació.",
"browserPickerHint": "Feu clic a Connecta i el navegador obrirà un selector de ports per triar el dispositiu."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Seriál"
},
"serial": {
"title": "Seriál",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 nebo COM3",
"baudRateLabel": "Přenosová rychlost",
"dataBitsLabel": "Data",
"stopBitsLabel": "Zastávka",
"parityLabel": "Parita",
"parityNone": "Žádný",
"parityEven": "Dokonce",
"parityOdd": "Zvláštní",
"connect": "Připojení k sériovému",
"disconnect": "Odpojit",
"refreshPorts": "Obnovit porty",
"connected": "Připojeno k {{path}} rychlostí {{baud}} baudu",
"disconnected": "Sériový port odpojen",
"connectionError": "Nepodařilo se otevřít sériový port",
"wsError": "Chyba WebSocketu",
"errorNoServerUrl": "Není nakonfigurována žádná adresa URL serveru",
"notSupportedTitle": "Sériové číslo není podporováno",
"notSupported": "Sériová připojení vyžadují prohlížeč s podporou webového sériového API (Chrome, Edge nebo Firefox 151+) nebo desktopovou aplikaci Termix.",
"hideHint": "Kartu Sériové číslo můžete skrýt v části Profil uživatele > Vzhled > Postranní panel > Navigace.",
"browserPickerHint": "Klikněte na tlačítko Připojit a prohlížeč otevře výběr portu pro výběr zařízení."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Seriel"
},
"serial": {
"title": "Seriel",
"portLabel": "Havn",
"portPlaceholder": "/dev/ttyUSB0 eller COM3",
"baudRateLabel": "Baudhastighed",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Paritet",
"parityNone": "Ingen",
"parityEven": "Endog",
"parityOdd": "Ulige",
"connect": "Tilslut til seriel",
"disconnect": "Afbryde",
"refreshPorts": "Opdater porte",
"connected": "Forbundet til {{path}} ved {{baud}} baud",
"disconnected": "Seriel port afbrudt",
"connectionError": "Kunne ikke åbne seriel port",
"wsError": "WebSocket-fejl",
"errorNoServerUrl": "Ingen server-URL konfigureret",
"notSupportedTitle": "Seriel understøttes ikke",
"notSupported": "Serielle forbindelser kræver en browser med Web Serial API-understøttelse (Chrome, Edge eller Firefox 151+) eller Termix-desktopappen.",
"hideHint": "Du kan skjule fanen Seriel under Brugerprofil > Udseende > Sidebjælke > Navigation.",
"browserPickerHint": "Klik på Opret forbindelse, og din browser åbner en portvælger, hvor du kan vælge enheden."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Seriell"
},
"serial": {
"title": "Seriell",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 oder COM3",
"baudRateLabel": "Baudrate",
"dataBitsLabel": "Daten",
"stopBitsLabel": "Stop",
"parityLabel": "Parität",
"parityNone": "Keine",
"parityEven": "Gerade",
"parityOdd": "Ungerade",
"connect": "Mit Seriell verbinden",
"disconnect": "Trennen",
"refreshPorts": "Ports aktualisieren",
"connected": "Verbunden mit {{path}} bei {{baud}} Baud",
"disconnected": "Serielle Schnittstelle getrennt",
"connectionError": "Fehler beim Öffnen der seriellen Schnittstelle",
"wsError": "WebSocket-Fehler",
"errorNoServerUrl": "Keine Server-URL konfiguriert",
"notSupportedTitle": "Seriell nicht unterstützt",
"notSupported": "Serielle Verbindungen erfordern einen Browser mit Web Serial API-Unterstützung (Chrome, Edge oder Firefox 151+) oder die Termix-Desktop-App.",
"hideHint": "Sie können den Seriell-Tab unter Benutzerprofil > Erscheinungsbild > Seitenleiste > Navigation ausblenden.",
"browserPickerHint": "Klicken Sie auf Verbinden, und Ihr Browser öffnet eine Port-Auswahl, um das Gerät auszuwählen."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Σειράς"
},
"serial": {
"title": "Σειράς",
"portLabel": "Λιμάνι",
"portPlaceholder": "/dev/ttyUSB0 ή COM3",
"baudRateLabel": "Ρυθμός Baud",
"dataBitsLabel": "Δεδομένα",
"stopBitsLabel": "Στάση",
"parityLabel": "Ισοτιμία",
"parityNone": "Κανένας",
"parityEven": "Ακόμη και",
"parityOdd": "Περιττός",
"connect": "Σύνδεση σε σειριακή σύνδεση",
"disconnect": "Αποσυνδέω",
"refreshPorts": "Ανανέωση θυρών",
"connected": "Συνδεδεμένο με {{path}} σε {{baud}} baud",
"disconnected": "Η σειριακή θύρα αποσυνδέθηκε",
"connectionError": "Αποτυχία ανοίγματος σειριακής θύρας",
"wsError": "Σφάλμα WebSocket",
"errorNoServerUrl": "Δεν έχει ρυθμιστεί διεύθυνση URL διακομιστή",
"notSupportedTitle": "Σειριακή Δεν Υποστηρίζεται",
"notSupported": "Οι σειριακές συνδέσεις απαιτούν ένα πρόγραμμα περιήγησης με υποστήριξη Web Serial API (Chrome, Edge ή Firefox 151+) ή την εφαρμογή Termix για υπολογιστές.",
"hideHint": "Μπορείτε να αποκρύψετε την καρτέλα Σειριακός αριθμός στην περιοχή Προφίλ χρήστη > Εμφάνιση > Πλαϊνή μπάρα > Πλοήγηση.",
"browserPickerHint": "Κάντε κλικ στην επιλογή Σύνδεση και το πρόγραμμα περιήγησής σας θα ανοίξει ένα πρόγραμμα επιλογής θυρών για να επιλέξετε τη συσκευή."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Puerto",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baudios",
"dataBitsLabel": "Datos",
"stopBitsLabel": "Parada",
"parityLabel": "Paridad",
"parityNone": "Ninguna",
"parityEven": "Par",
"parityOdd": "Impar",
"connect": "Conectar a Serial",
"disconnect": "Desconectar",
"refreshPorts": "Actualizar puertos",
"connected": "Conectado a {{path}} a {{baud}} baudios",
"disconnected": "Puerto serie desconectado",
"connectionError": "Error al abrir el puerto serie",
"wsError": "Error WebSocket",
"errorNoServerUrl": "No se ha configurado URL de servidor",
"notSupportedTitle": "Serial no compatible",
"notSupported": "Las conexiones serie requieren un navegador compatible con Web Serial API (Chrome, Edge o Firefox 151+), o la aplicación de escritorio Termix.",
"hideHint": "Puedes ocultar la pestaña Serial en Perfil de usuario > Apariencia > Barra lateral > Navegación.",
"browserPickerHint": "Haz clic en Conectar y el navegador abrirá un selector de puertos para elegir el dispositivo."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Sarjanumero"
},
"serial": {
"title": "Sarjanumero",
"portLabel": "Portti",
"portPlaceholder": "/dev/ttyUSB0 tai COM3",
"baudRateLabel": "Baud-nopeus",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Pariteetti",
"parityNone": "Ei mitään",
"parityEven": "Jopa",
"parityOdd": "Pariton",
"connect": "Yhdistä sarjaporttiin",
"disconnect": "Katkaise yhteys",
"refreshPorts": "Päivitä portit",
"connected": "Yhdistetty {{path}} -laitteeseen {{baud}} baudilla",
"disconnected": "Sarjaportti irrotettu",
"connectionError": "Sarjaportin avaaminen epäonnistui",
"wsError": "WebSocket-virhe",
"errorNoServerUrl": "Palvelimen URL-osoitetta ei ole määritetty",
"notSupportedTitle": "Sarjanumeroa ei tueta",
"notSupported": "Sarjaliikenne vaatii selaimen, jossa on Web Serial API -tuki (Chrome, Edge tai Firefox 151+), tai Termix-työpöytäsovelluksen.",
"hideHint": "Voit piilottaa Sarja-välilehden kohdassa Käyttäjäprofiili > Ulkoasu > Sivupalkki > Navigointi.",
"browserPickerHint": "Napsauta Yhdistä, niin selaimesi avaa porttivalitsimen laitteen valitsemiseksi."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Série"
},
"serial": {
"title": "Série",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Débit en bauds",
"dataBitsLabel": "Données",
"stopBitsLabel": "Stop",
"parityLabel": "Parité",
"parityNone": "Aucune",
"parityEven": "Paire",
"parityOdd": "Impaire",
"connect": "Connecter",
"disconnect": "Déconnecter",
"refreshPorts": "Actualiser les ports",
"connected": "Connecté à {{path}} à {{baud}} bauds",
"disconnected": "Port série déconnecté",
"connectionError": "Échec de l'ouverture du port série",
"wsError": "Erreur WebSocket",
"errorNoServerUrl": "Aucune URL de serveur configurée",
"notSupportedTitle": "Port série non pris en charge",
"notSupported": "Les connexions série nécessitent un navigateur prenant en charge l'API Web Serial (Chrome, Edge, ou Firefox 151+), ou l'application de bureau Termix.",
"hideHint": "Vous pouvez masquer l'onglet Série sous Profil utilisateur > Apparence > Barre latérale > Navigation.",
"browserPickerHint": "Cliquez sur Connexion et votre navigateur ouvrira un sélecteur de port pour choisir le périphérique."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "סִדרָתִי"
},
"serial": {
"title": "סִדרָתִי",
"portLabel": "נָמָל",
"portPlaceholder": "/dev/ttyUSB0 או COM3",
"baudRateLabel": "קצב באוד",
"dataBitsLabel": "נְתוּנִים",
"stopBitsLabel": "לְהַפְסִיק",
"parityLabel": "שִׁוּוּי",
"parityNone": "אַף לֹא אֶחָד",
"parityEven": "אֲפִילוּ",
"parityOdd": "מְשׁוּנֶה",
"connect": "חיבור סריאלי",
"disconnect": "לְנַתֵק",
"refreshPorts": "רענון פורטים",
"connected": "מחובר ל- {{path}} ב- {{baud}} באוד",
"disconnected": "יציאה טורית מנותקת",
"connectionError": "נכשלה פתיחת הפורט הטורי",
"wsError": "שגיאת WebSocket",
"errorNoServerUrl": "לא הוגדרה כתובת URL של שרת",
"notSupportedTitle": "סריאלי לא נתמך",
"notSupported": "חיבורים טוריים דורשים דפדפן עם תמיכה ב-Web Serial API (Chrome, Edge או Firefox 151+), או את אפליקציית שולחן העבודה Termix.",
"hideHint": "ניתן להסתיר את הכרטיסייה 'סדרתי' תחת פרופיל משתמש > מראה > סרגל צד > ניווט.",
"browserPickerHint": "לחץ על התחבר והדפדפן שלך יפתח בורר פורטים לבחירת המכשיר."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "सीरियल"
},
"serial": {
"title": "सीरियल",
"portLabel": "पोर्ट",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "बॉड दर",
"dataBitsLabel": "डेटा",
"stopBitsLabel": "स्टॉप",
"parityLabel": "पैरिटी",
"parityNone": "कोई नहीं",
"parityEven": "सम",
"parityOdd": "विषम",
"connect": "सीरियल कनेक्ट करें",
"disconnect": "डिस्कनेक्ट करें",
"refreshPorts": "पोर्ट रीफ़्रेश करें",
"connected": "{{path}} से {{baud}} बॉड पर कनेक्टेड",
"disconnected": "सीरियल पोर्ट डिस्कनेक्ट हो गया",
"connectionError": "सीरियल पोर्ट खोलने में विफल",
"wsError": "WebSocket त्रुटि",
"errorNoServerUrl": "कोई सर्वर URL कॉन्फ़िगर नहीं है",
"notSupportedTitle": "सीरियल समर्थित नहीं",
"notSupported": "सीरियल कनेक्शन के लिए Web Serial API समर्थन वाले ब्राउज़र (Chrome, Edge, या Firefox 151+) या Termix डेस्कटॉप ऐप की आवश्यकता है।",
"hideHint": "आप सीरियल टैब को User Profile > Appearance > Sidebar > Navigation में छिपा सकते हैं।",
"browserPickerHint": "Connect पर क्लिक करें और आपका ब्राउज़र डिवाइस चुनने के लिए एक पोर्ट पिकर खोलेगा।"
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Sorozatszám"
},
"serial": {
"title": "Sorozatszám",
"portLabel": "Kikötő",
"portPlaceholder": "/dev/ttyUSB0 vagy COM3",
"baudRateLabel": "Baud sebesség",
"dataBitsLabel": "Adat",
"stopBitsLabel": "Stop",
"parityLabel": "Paritás",
"parityNone": "Egyik sem",
"parityEven": "Még",
"parityOdd": "Páratlan",
"connect": "Csatlakozás soros porthoz",
"disconnect": "Leválasztás",
"refreshPorts": "Frissítse a portokat",
"connected": "{{path}} -hoz csatlakoztatva {{baud}} baud sebességgel",
"disconnected": "Soros port leválasztva",
"connectionError": "Nem sikerült megnyitni a soros portot",
"wsError": "WebSocket hiba",
"errorNoServerUrl": "Nincs konfigurálva szerver URL",
"notSupportedTitle": "Sorozatszám Nem támogatott",
"notSupported": "A soros kapcsolatokhoz Web Serial API-t támogató böngésző (Chrome, Edge vagy Firefox 151+), vagy a Termix asztali alkalmazás szükséges.",
"hideHint": "A Sorozat fület elrejtheted a Felhasználói profil > Megjelenés > Oldalsáv > Navigáció alatt.",
"browserPickerHint": "Kattintson a Csatlakozás gombra, és a böngészője megnyit egy portválasztót az eszköz kiválasztásához."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Parity",
"parityNone": "None",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Disconnect",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Seriale"
},
"serial": {
"title": "Seriale",
"portLabel": "Porta",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud rate",
"dataBitsLabel": "Dati",
"stopBitsLabel": "Stop",
"parityLabel": "Parità",
"parityNone": "Nessuna",
"parityEven": "Pari",
"parityOdd": "Dispari",
"connect": "Connetti alla Seriale",
"disconnect": "Disconnetti",
"refreshPorts": "Aggiorna porte",
"connected": "Connesso a {{path}} a {{baud}} baud",
"disconnected": "Porta seriale disconnessa",
"connectionError": "Impossibile aprire la porta seriale",
"wsError": "Errore WebSocket",
"errorNoServerUrl": "Nessun URL del server configurato",
"notSupportedTitle": "Seriale non supportato",
"notSupported": "Le connessioni seriali richiedono un browser con supporto Web Serial API (Chrome, Edge o Firefox 151+), oppure l'app desktop Termix.",
"hideHint": "Puoi nascondere la scheda Seriale in Profilo Utente > Aspetto > Barra laterale > Navigazione.",
"browserPickerHint": "Fai clic su Connetti e il browser aprirà un selettore di porte per scegliere il dispositivo."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "シリアル"
},
"serial": {
"title": "シリアル",
"portLabel": "ポート",
"portPlaceholder": "/dev/ttyUSB0 または COM3",
"baudRateLabel": "ボーレート",
"dataBitsLabel": "データ",
"stopBitsLabel": "ストップ",
"parityLabel": "パリティ",
"parityNone": "なし",
"parityEven": "偶数",
"parityOdd": "奇数",
"connect": "シリアルに接続",
"disconnect": "切断",
"refreshPorts": "ポートを更新",
"connected": "{{path}} に {{baud}} ボーで接続しました",
"disconnected": "シリアルポートが切断されました",
"connectionError": "シリアルポートを開けませんでした",
"wsError": "WebSocket エラー",
"errorNoServerUrl": "サーバー URL が設定されていません",
"notSupportedTitle": "シリアル非対応",
"notSupported": "シリアル接続には Web Serial API をサポートするブラウザ (Chrome, Edge, Firefox 151+) または Termix デスクトップアプリが必要です。",
"hideHint": "シリアルタブは「ユーザープロファイル > 外観 > サイドバー > ナビゲーション」で非表示にできます。",
"browserPickerHint": "接続をクリックすると、ブラウザがポートピッカーを開き、デバイスを選択できます。"
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "시리얼"
},
"serial": {
"title": "시리얼",
"portLabel": "포트",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "보레이트",
"dataBitsLabel": "데이터",
"stopBitsLabel": "스톱",
"parityLabel": "패리티",
"parityNone": "없음",
"parityEven": "짝수",
"parityOdd": "홀수",
"connect": "시리얼 연결",
"disconnect": "연결 해제",
"refreshPorts": "포트 새로 고침",
"connected": "{{path}}에 {{baud}} 보로 연결됨",
"disconnected": "시리얼 포트 연결 해제됨",
"connectionError": "시리얼 포트를 열지 못했습니다.",
"wsError": "WebSocket 오류",
"errorNoServerUrl": "서버 URL이 구성되지 않았습니다.",
"notSupportedTitle": "시리얼 미지원",
"notSupported": "시리얼 연결에는 Web Serial API를 지원하는 브라우저(Chrome, Edge, Firefox 151+) 또는 Termix 데스크톱 앱이 필요합니다.",
"hideHint": "Serial 탭은 사용자 프로필 > 모양 > 사이드바 > 탐색에서 숨길 수 있습니다.",
"browserPickerHint": "연결 버튼을 클릭하면 브라우저에서 장치 선택을 위한 포트 선택기가 열립니다."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serieel"
},
"serial": {
"title": "Serieel",
"portLabel": "Haven",
"portPlaceholder": "/dev/ttyUSB0 of COM3",
"baudRateLabel": "Baudrate",
"dataBitsLabel": "Gegevens",
"stopBitsLabel": "Stop",
"parityLabel": "Pariteit",
"parityNone": "Geen",
"parityEven": "Zelfs",
"parityOdd": "Vreemd",
"connect": "Verbinden via serieel",
"disconnect": "Verbinding verbreken",
"refreshPorts": "Vernieuw poorten",
"connected": "Verbonden met {{path}} met {{baud}} baud",
"disconnected": "Seriële poort losgekoppeld",
"connectionError": "Het openen van de seriële poort is mislukt.",
"wsError": "WebSocket-fout",
"errorNoServerUrl": "Geen server-URL geconfigureerd",
"notSupportedTitle": "Serienummer niet ondersteund",
"notSupported": "Voor seriële verbindingen is een browser met Web Serial API-ondersteuning vereist (Chrome, Edge of Firefox 151+), of de Termix-desktopapp.",
"hideHint": "Je kunt het tabblad 'Serienummer' verbergen via Gebruikersprofiel > Uiterlijk > Zijbalk > Navigatie.",
"browserPickerHint": "Klik op Verbinden en uw browser opent een poortkiezer waarmee u het apparaat kunt selecteren."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stopp",
"parityLabel": "Parity",
"parityNone": "Ingen",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Koble fra",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Port szeregowy"
},
"serial": {
"title": "Port szeregowy",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 lub COM3",
"baudRateLabel": "Szybkość transmisji",
"dataBitsLabel": "Dane",
"stopBitsLabel": "Stop",
"parityLabel": "Parzystość",
"parityNone": "Brak",
"parityEven": "Parzysta",
"parityOdd": "Nieparzysta",
"connect": "Połącz z portem szeregowym",
"disconnect": "Odłącz",
"refreshPorts": "Odśwież porty",
"connected": "Połączono z {{path}} z szybkością {{baud}} bodów",
"disconnected": "Port szeregowy rozłączony",
"connectionError": "Nie udało się otworzyć portu szeregowego",
"wsError": "Błąd WebSocket",
"errorNoServerUrl": "Brak skonfigurowanego adresu URL serwera",
"notSupportedTitle": "Port szeregowy nieobsługiwany",
"notSupported": "Połączenia szeregowe wymagają przeglądarki obsługującej Web Serial API (Chrome, Edge lub Firefox 151+) albo aplikacji desktopowej Termix.",
"hideHint": "Kartę Port szeregowy można ukryć w: Profil użytkownika > Wygląd > Pasek boczny > Nawigacja.",
"browserPickerHint": "Kliknij Połącz, a przeglądarka otworzy okno wyboru portu, w którym wskażesz urządzenie."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Porta",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Parity",
"parityNone": "Nenhum",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Desconectar",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Porta",
"portPlaceholder": "/dev/ttyUSB0 ou COM3",
"baudRateLabel": "Taxa Baud",
"dataBitsLabel": "Dados",
"stopBitsLabel": "Stop",
"parityLabel": "Paridade",
"parityNone": "Nenhuma",
"parityEven": "Par",
"parityOdd": "Ímpar",
"connect": "Ligar à Serial",
"disconnect": "Desligar",
"refreshPorts": "Atualizar portas",
"connected": "Ligado a {{path}} a {{baud}} baud",
"disconnected": "Porta série desligada",
"connectionError": "Falha ao abrir a porta série",
"wsError": "Erro de WebSocket",
"errorNoServerUrl": "Nenhum URL de servidor configurado",
"notSupportedTitle": "Série não suportada",
"notSupported": "As ligações série requerem um navegador com suporte à Web Serial API (Chrome, Edge ou Firefox 151+) ou a aplicação de ambiente de trabalho Termix.",
"hideHint": "Pode ocultar o separador Série em Perfil do Utilizador > Aparência > Barra Lateral > Navegação.",
"browserPickerHint": "Clique em Ligar e o seu navegador abrirá um seletor de porta para escolher o dispositivo."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 sau COM3",
"baudRateLabel": "Rată de baud",
"dataBitsLabel": "Date",
"stopBitsLabel": "Stop",
"parityLabel": "Paritate",
"parityNone": "Nici unul",
"parityEven": "Chiar",
"parityOdd": "Ciudat",
"connect": "Conectare la serial",
"disconnect": "Deconecta",
"refreshPorts": "Reîmprospătați porturile",
"connected": "Conectat la {{path}} la {{baud}} baud",
"disconnected": "Portul serial deconectat",
"connectionError": "Nu s-a putut deschide portul serial",
"wsError": "Eroare WebSocket",
"errorNoServerUrl": "Nu este configurată nicio adresă URL a serverului",
"notSupportedTitle": "Număr de serie neacceptat",
"notSupported": "Conexiunile seriale necesită un browser cu suport Web Serial API (Chrome, Edge sau Firefox 151+) sau aplicația desktop Termix.",
"hideHint": "Puteți ascunde fila Serial din Profil utilizator > Aspect > Bara laterală > Navigare.",
"browserPickerHint": "Faceți clic pe Conectare și browserul dvs. va deschide un selector de porturi pentru a alege dispozitivul."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Последовательный порт"
},
"serial": {
"title": "Последовательный порт",
"portLabel": "Порт",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Скорость передачи",
"dataBitsLabel": "Биты данных",
"stopBitsLabel": "Стоп-биты",
"parityLabel": "Чётность",
"parityNone": "Нет",
"parityEven": "Чётный",
"parityOdd": "Нечётный",
"connect": "Подключиться",
"disconnect": "Отключиться",
"refreshPorts": "Обновить порты",
"connected": "Подключено к {{path}} на скорости {{baud}} бод",
"disconnected": "Последовательный порт отключен",
"connectionError": "Ошибка открытия последовательного порта",
"wsError": "Ошибка WebSocket",
"errorNoServerUrl": "Не настроен URL сервера",
"notSupportedTitle": "Последовательный порт не поддерживается",
"notSupported": "Последовательные подключения требуют браузер с поддержкой Web Serial API (Chrome, Edge или Firefox 151+) или десктопное приложение Termix.",
"hideHint": "Вы можете скрыть вкладку «Serial» в разделе «Профиль пользователя» > «Оформление» > «Боковая панель» > «Навигация».",
"browserPickerHint": "Нажмите «Подключиться», и браузер откроет окно выбора порта для выбора устройства."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Parity",
"parityNone": "None",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Disconnect",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Parity",
"parityNone": "None",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Disconnect",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Parity",
"parityNone": "None",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Disconnect",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Seri"
},
"serial": {
"title": "Seri",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 veya COM3",
"baudRateLabel": "Baud Hızı",
"dataBitsLabel": "Veri",
"stopBitsLabel": "Durdurma",
"parityLabel": "Eşlik",
"parityNone": "Yok",
"parityEven": "Çift",
"parityOdd": "Tek",
"connect": "Seri Bağlantıya Bağlan",
"disconnect": "Bağlantıyı Kes",
"refreshPorts": "Portları yenile",
"connected": "{{path}} ile {{baud}} baud hızında bağlandı.",
"disconnected": "Seri port bağlantısı kesildi.",
"connectionError": "Seri bağlantı noktası açılamadı",
"wsError": "WebSocket hatası",
"errorNoServerUrl": "Sunucu URL'si yapılandırılmamış",
"notSupportedTitle": "Seri Bağlantı Desteklenmiyor",
"notSupported": "Seri bağlantılar, Web Serial API desteği olan bir tarayıcı (Chrome, Edge veya Firefox 151+) ya da Termix masaüstü uygulaması gerektirir.",
"hideHint": "Seri sekmesini Kullanıcı Profili > Görünüm > Kenar Çubuğu > Gezinme altından gizleyebilirsiniz.",
"browserPickerHint": "Bağlan'a tıklayın, tarayıcınız cihazı seçmek için bir bağlantı noktası seçici açacaktır."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Parity",
"parityNone": "None",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Disconnect",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Nối tiếp"
},
"serial": {
"title": "Cổng nối tiếp",
"portLabel": "Cổng",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Tốc độ Baud",
"dataBitsLabel": "Dữ liệu",
"stopBitsLabel": "Bit dừng",
"parityLabel": "Kiểm tra chẵn lẻ",
"parityNone": "Không",
"parityEven": "Chẵn",
"parityOdd": "Lẻ",
"connect": "Kết nối cổng nối tiếp",
"disconnect": "Ngắt kết nối",
"refreshPorts": "Làm mới cổng",
"connected": "Đã kết nối tới {{path}} với tốc độ {{baud}} baud",
"disconnected": "Đã ngắt kết nối cổng nối tiếp",
"connectionError": "Không thể mở cổng nối tiếp",
"wsError": "Lỗi WebSocket",
"errorNoServerUrl": "Chưa cấu hình URL máy chủ",
"notSupportedTitle": "Không hỗ trợ Serial",
"notSupported": "Kết nối nối tiếp yêu cầu trình duyệt hỗ trợ Web Serial API (Chrome, Edge, hoặc Firefox 151+), hoặc ứng dụng Termix desktop.",
"hideHint": "Bạn có thể ẩn tab Serial trong Hồ sơ người dùng > Giao diện > Thanh bên > Điều hướng.",
"browserPickerHint": "Nhấn Kết nối và trình duyệt sẽ mở bộ chọn cổng để chọn thiết bị."
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "串口"
},
"serial": {
"title": "串口",
"portLabel": "端口",
"portPlaceholder": "/dev/ttyUSB0 或 COM3",
"baudRateLabel": "波特率",
"dataBitsLabel": "数据",
"stopBitsLabel": "停止位",
"parityLabel": "奇偶校验",
"parityNone": "无",
"parityEven": "偶校验",
"parityOdd": "奇校验",
"connect": "连接串口",
"disconnect": "断开",
"refreshPorts": "刷新端口",
"connected": "已连接到 {{path}},波特率 {{baud}}",
"disconnected": "串口已断开",
"connectionError": "打开串口失败",
"wsError": "WebSocket 错误",
"errorNoServerUrl": "未配置服务器 URL",
"notSupportedTitle": "不支持串口",
"notSupported": "串口连接需要支持 Web Serial API 的浏览器(Chrome、Edge 或 Firefox 151+),或者 Termix 桌面应用。",
"hideHint": "您可以在用户资料 > 外观 > 侧边栏 > 导航中隐藏串口标签页。",
"browserPickerHint": "点击连接,您的浏览器将打开端口选择器以选择设备。"
}
}
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "序列"
},
"serial": {
"title": "序列",
"portLabel": "連接埠",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "鮑率",
"dataBitsLabel": "資料位元",
"stopBitsLabel": "停止位元",
"parityLabel": "同位檢查",
"parityNone": "無",
"parityEven": "偶",
"parityOdd": "奇",
"connect": "連線至序列埠",
"disconnect": "中斷連線",
"refreshPorts": "重新整理連接埠",
"connected": "已連線至 {{path}},鮑率 {{baud}}",
"disconnected": "序列埠已中斷連線",
"connectionError": "開啟序列埠失敗",
"wsError": "WebSocket 錯誤",
"errorNoServerUrl": "未設定伺服器網址",
"notSupportedTitle": "不支援序列埠",
"notSupported": "序列連線需要支援 Web Serial API 的瀏覽器 (Chrome、Edge 或 Firefox 151+) 或 Termix 桌面應用程式。",
"hideHint": "您可以在「使用者設定檔 > 外觀 > 側邊欄 > 導覽」中隱藏序列索引標籤。",
"browserPickerHint": "按一下連線,瀏覽器會開啟連接埠選擇器讓您選取裝置。"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "serial",
"name": "Serial",
"version": "1.0.0",
"description": "Open a serial console to a device plugged into your computer.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Terminal",
"icon": "Usb",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": ["network:serve", "device:serial", "ui:surface"],
"nativeDependencies": ["serialport"],
"locales": "locales",
"platforms": ["linux", "win32", "darwin"],
"contributes": {
"tabs": [
{
"id": "serial",
"titleKey": "nav.serial",
"icon": "Usb",
"openFrom": ["rail"]
}
],
"panels": [
{
"id": "serial",
"titleKey": "nav.serial",
"icon": "Usb"
}
],
"uses": ["terminal.resolveTheme"]
},
"desktop": "local"
}
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@termix-plugin/serial",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.33.0",
"react-xtermjs": "^1.0.10",
"serialport": "^13.0.0"
},
"devDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/react": "^16.3.3",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"react": "^19.2.8",
"ws": "^8.21.3"
},
"termix": {
"patches": [
"xterm-android-ime"
]
},
"overrides": {
"react-xtermjs": {
"@xterm/xterm": "$@xterm/xterm"
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createSerialSession } from "./session.js";
export async function activate(ctx: PluginContext): Promise<void> {
ctx.ws.route("/console", createSerialSession(ctx));
ctx.log.info("Serial console mounted at /plugin-ws/serial/console");
}
export async function deactivate(): Promise<void> {
// The WS route and every open socket are torn down by core: see
// src/backend/plugins/ws.ts. Nothing here outlives activate().
}
+169
View File
@@ -0,0 +1,169 @@
import type { WebSocket } from "ws";
import { SerialPort } from "serialport";
import type {
PluginContext,
PluginWebSocketConnection,
} from "@termix/plugin-sdk/backend";
import { parseWsMessage } from "./ws-message.js";
interface SerialConnectData {
path: string;
baudRate: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 2;
parity?: "none" | "even" | "odd";
}
/**
* Handles one /plugin-ws/serial/console connection. Auth already ran (core's
* ctx.ws.route authenticates before this is called), so every message here
* only has to decide whether to touch the actual hardware.
*/
export function createSerialSession(ctx: PluginContext) {
return async (connection: PluginWebSocketConnection): Promise<void> => {
const ws = connection.socket as WebSocket;
let port: SerialPort | null = null;
const send = (msg: object) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(msg));
}
};
const cleanup = () => {
if (port?.isOpen) {
port.close();
}
port = null;
};
if (!connection.isDataUnlocked()) {
send({ type: "error", data: "Data locked" });
ws.close(1008, "Data access required");
return;
}
ws.on("message", async (raw) => {
let type: string;
let data: unknown;
try {
({ type, data } = parseWsMessage(raw));
} catch {
return;
}
if (!connection.isDataUnlocked()) {
send({ type: "error", data: "Data access expired" });
ws.close(1008, "Data access expired");
return;
}
try {
switch (type) {
case "list_ports": {
await ctx.capabilities.require("device:serial");
try {
const ports = await SerialPort.list();
send({ type: "ports_list", data: ports });
} catch (err) {
send({
type: "error",
data: errorMessage(err, "Failed to list ports"),
});
}
break;
}
case "connect": {
await ctx.capabilities.require("device:serial");
if (port?.isOpen) {
port.close();
port = null;
}
const cfg = data as SerialConnectData;
if (!cfg?.path || !cfg?.baudRate) {
send({ type: "error", data: "Missing port path or baud rate" });
break;
}
try {
const opened = new SerialPort({
path: cfg.path,
baudRate: cfg.baudRate,
dataBits: cfg.dataBits ?? 8,
stopBits: cfg.stopBits ?? 1,
parity: cfg.parity ?? "none",
autoOpen: false,
});
port = opened;
opened.open((err) => {
if (err) {
ctx.log.error(`Serial port open failed for ${cfg.path}`, err);
send({ type: "error", data: err.message });
port = null;
return;
}
ctx.log.info(
`Serial port opened: ${cfg.path} at ${cfg.baudRate} baud`,
);
send({ type: "connected" });
});
opened.on("data", (chunk: Buffer) => {
send({ type: "data", data: chunk.toString("binary") });
});
opened.on("error", (err) => {
send({ type: "error", data: err.message });
});
opened.on("close", () => {
send({ type: "disconnected" });
port = null;
});
} catch (err) {
send({
type: "error",
data: errorMessage(err, "Failed to open serial port"),
});
}
break;
}
case "input": {
if (!port?.isOpen) break;
const input = typeof data === "string" ? data : "";
if (!input) break;
port.write(Buffer.from(input, "binary"), (err) => {
if (err) send({ type: "error", data: err.message });
});
break;
}
case "disconnect": {
cleanup();
send({ type: "disconnected" });
break;
}
}
} catch (err) {
ctx.log.error("Error handling serial WebSocket message", err);
send({
type: "error",
data:
err instanceof Error ? err.message : "Failed to process message",
});
}
});
ws.on("close", cleanup);
ws.on("error", cleanup);
};
}
function errorMessage(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback;
}
+38
View File
@@ -0,0 +1,38 @@
import type { RawData } from "ws";
// Control messages here are tiny (a path, a baud rate) or raw terminal input,
// bounded by what a user can type or paste, so anything larger is abuse.
const MAX_MESSAGE_BYTES = 1024 * 1024;
function rawByteLength(raw: RawData): number {
if (Buffer.isBuffer(raw)) return raw.length;
if (Array.isArray(raw))
return raw.reduce((sum, part) => sum + part.length, 0);
if (raw instanceof ArrayBuffer) return raw.byteLength;
return 0;
}
/** Parses a WebSocket frame into `{ type, data }`, throwing on anything malformed. */
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
if (rawByteLength(raw) > MAX_MESSAGE_BYTES) {
throw new Error("Message too large");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
throw new Error("Invalid JSON");
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Message must be a JSON object");
}
const { type, data } = parsed as { type?: unknown; data?: unknown };
if (typeof type !== "string") {
throw new Error("Message type must be a string");
}
return { type, data };
}
+358
View File
@@ -0,0 +1,358 @@
import {
useEffect,
useRef,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { TriangleAlert } from "lucide-react";
import {
invokeAction,
useTranslation,
useTheme,
} from "@termix/plugin-sdk/frontend";
import type { SerialConfig, SerialHandle } from "./types.js";
import { isElectron } from "./electron.js";
import { resolveSerialWsUrl } from "./transport.js";
interface SerialLook {
colors: Record<string, string | undefined>;
fontFamily: string;
fontSize: number;
}
const FALLBACK_COLORS = {
background: "#0c0d0b",
foreground: "#fafafa",
cursor: "#fafafa",
cursorAccent: "#0c0d0b",
};
const FALLBACK_FONT = '"SF Mono", Consolas, "Liberation Mono", monospace';
type WebSerialPort = {
open(options: {
baudRate: number;
dataBits?: number;
stopBits?: number;
parity?: string;
}): Promise<void>;
close(): Promise<void>;
readable: ReadableStream<Uint8Array> | null;
writable: WritableStream<Uint8Array> | null;
};
interface SerialProps {
config: SerialConfig;
isVisible: boolean;
instanceId: string;
}
export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
{ config, isVisible, instanceId },
ref,
) {
const { t } = useTranslation();
const { theme: appTheme } = useTheme();
const { instance: terminal, ref: xtermRef } = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const connectedRef = useRef(false);
const webSerialReaderRef = useRef<ReadableStreamDefaultReader | null>(null);
const webSerialWriterRef = useRef<WritableStreamDefaultWriter | null>(null);
const webSerialPortRef = useRef<WebSerialPort | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const useWebSerial = !isElectron();
const write = useCallback(
(text: string) => {
terminal?.write(text);
},
[terminal],
);
useEffect(() => {
if (!terminal) return;
let active = true;
terminal.options.theme = { ...FALLBACK_COLORS };
terminal.options.fontFamily = FALLBACK_FONT;
terminal.options.fontSize = 14;
// The user's terminal look from the SSH terminal, when it is running.
void invokeAction("terminal.resolveTheme", { appTheme })
.then((look) => {
const resolved = look as SerialLook | undefined;
if (!active || !resolved) return;
terminal.options.theme = { ...resolved.colors };
terminal.options.fontFamily = resolved.fontFamily;
terminal.options.fontSize = resolved.fontSize;
fitAddonRef.current?.fit();
})
.catch(() => {});
return () => {
active = false;
};
}, [terminal, appTheme]);
// ── WebSocket (Electron) path ──────────────────────────────────────────
const disconnectWs = useCallback(() => {
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
connectedRef.current = false;
}, []);
const connectWs = useCallback(async () => {
disconnectWs();
const target = await resolveSerialWsUrl();
if (!target) {
write(`\r\n\x1b[31m${t("serial.errorNoServerUrl")}\x1b[0m\r\n`);
return;
}
const ws = new WebSocket(target.url, target.protocols);
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: "connect", data: config }));
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data as string) as {
type: string;
data?: unknown;
};
switch (msg.type) {
case "connected":
connectedRef.current = true;
write(
`\x1b[32m${t("serial.connected", { path: config.path, baud: config.baudRate })}\x1b[0m\r\n`,
);
break;
case "data":
if (typeof msg.data === "string") write(msg.data);
break;
case "disconnected":
connectedRef.current = false;
write(`\r\n\x1b[33m${t("serial.disconnected")}\x1b[0m\r\n`);
break;
case "error":
write(
`\r\n\x1b[31m${t("serial.connectionError")}: ${msg.data as string}\x1b[0m\r\n`,
);
break;
}
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
connectedRef.current = false;
};
ws.onerror = () => {
write(`\r\n\x1b[31m${t("serial.wsError")}\x1b[0m\r\n`);
};
}, [config, disconnectWs, t, write]);
// ── Web Serial API path ────────────────────────────────────────────────
const disconnectWebSerial = useCallback(async () => {
try {
webSerialReaderRef.current?.cancel();
webSerialWriterRef.current?.releaseLock();
await webSerialPortRef.current?.close();
} catch {
// best-effort
}
webSerialReaderRef.current = null;
webSerialWriterRef.current = null;
webSerialPortRef.current = null;
connectedRef.current = false;
}, []);
const connectWebSerial = useCallback(async () => {
await disconnectWebSerial();
if (!("serial" in navigator)) {
write(`\r\n\x1b[31m${t("serial.notSupported")}\x1b[0m\r\n`);
return;
}
try {
const serial = navigator.serial as {
requestPort(): Promise<WebSerialPort>;
};
const port = await serial.requestPort();
await port.open({
baudRate: config.baudRate,
dataBits: config.dataBits,
stopBits: config.stopBits,
parity: config.parity === "none" ? "none" : config.parity,
});
webSerialPortRef.current = port;
connectedRef.current = true;
write(
`\x1b[32m${t("serial.connected", { path: config.path || "serial", baud: config.baudRate })}\x1b[0m\r\n`,
);
const reader = port.readable!.getReader();
webSerialReaderRef.current = reader;
const decoder = new TextDecoder();
(async () => {
try {
for (;;) {
const { value, done } = await reader.read();
if (done) break;
write(decoder.decode(value));
}
} catch {
// port closed
} finally {
reader.releaseLock();
connectedRef.current = false;
write(`\r\n\x1b[33m${t("serial.disconnected")}\x1b[0m\r\n`);
}
})();
const writer = port.writable!.getWriter();
webSerialWriterRef.current = writer;
} catch (err) {
if (err instanceof Error && err.name !== "NotFoundError") {
write(
`\r\n\x1b[31m${t("serial.connectionError")}: ${err.message}\x1b[0m\r\n`,
);
}
}
}, [config, disconnectWebSerial, t, write]);
// ── Unified connect/disconnect ─────────────────────────────────────────
const connect = useCallback(() => {
if (useWebSerial) {
connectWebSerial();
} else {
void connectWs();
}
}, [useWebSerial, connectWebSerial, connectWs]);
const disconnect = useCallback(() => {
if (useWebSerial) {
disconnectWebSerial();
} else {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
}
disconnectWs();
}
}, [useWebSerial, disconnectWebSerial, disconnectWs]);
const reconnect = useCallback(() => {
disconnect();
connect();
}, [disconnect, connect]);
useImperativeHandle(ref, () => ({
connect,
disconnect,
reconnect,
isConnected: () => connectedRef.current,
sendInput: (data: string) => {
if (useWebSerial) {
const encoder = new TextEncoder();
webSerialWriterRef.current?.write(encoder.encode(data)).catch(() => {});
} else if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "input", data }));
}
},
}));
// ── Terminal setup ─────────────────────────────────────────────────────
useEffect(() => {
if (!terminal) return;
const fitAddon = new FitAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.options.cursorBlink = true;
terminal.options.scrollback = 10000;
terminal.onData((data) => {
if (!connectedRef.current) return;
if (useWebSerial) {
const encoder = new TextEncoder();
webSerialWriterRef.current?.write(encoder.encode(data)).catch(() => {});
} else if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: "input", data }));
}
});
connect();
return () => {
disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminal, instanceId]);
// ── Fit on visibility change ───────────────────────────────────────────
useEffect(() => {
if (!isVisible || !fitAddonRef.current) return;
try {
fitAddonRef.current.fit();
} catch {
// ignore
}
}, [isVisible]);
// ── ResizeObserver ─────────────────────────────────────────────────────
useEffect(() => {
if (!containerRef.current || !terminal) return;
const observer = new ResizeObserver(() => {
try {
fitAddonRef.current?.fit();
} catch {
// ignore
}
});
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [terminal]);
// ── Not supported (Firefox etc.) ──────────────────────────────────────
if (useWebSerial && !("serial" in navigator)) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<TriangleAlert className="size-5 text-muted-foreground/50" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-foreground">
{t("serial.notSupportedTitle")}
</span>
<span className="text-xs text-muted-foreground max-w-xs">
{t("serial.notSupported")}
</span>
</div>
</div>
);
}
return (
<div ref={containerRef} className="flex h-full w-full">
<div ref={xtermRef} className="flex-1 min-h-0" />
</div>
);
});
+325
View File
@@ -0,0 +1,325 @@
import { useState, useEffect, useCallback } from "react";
import { RefreshCw, Usb, TriangleAlert } from "lucide-react";
import { Input, Select2 } from "@termix/plugin-sdk/ui";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { SerialConfig } from "./types.js";
import { isElectron } from "./electron.js";
import { resolveSerialWsUrl } from "./transport.js";
const BAUD_RATES = [
300, 1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800,
921600,
];
interface SerialPanelProps {
onConnect: (config: SerialConfig) => void;
}
// In Chrome/Edge the Web Serial API shows its own native port picker on requestPort(),
// so getPorts() only returns previously-granted ports. We never need the user to type
// a path — the browser handles selection at connect time.
const useWebSerial = !isElectron() && "serial" in navigator;
const serialUnsupported = !isElectron() && !("serial" in navigator);
export function SerialPanel({ onConnect }: SerialPanelProps) {
const { t } = useTranslation();
const [path, setPath] = useState("");
const [baudRate, setBaudRate] = useState(115200);
const [dataBits, setDataBits] = useState<5 | 6 | 7 | 8>(8);
const [stopBits, setStopBits] = useState<1 | 2>(1);
const [parity, setParity] = useState<"none" | "even" | "odd">("none");
// Electron only: ports discovered from the backend
const [availablePorts, setAvailablePorts] = useState<string[]>([]);
const [loadingPorts, setLoadingPorts] = useState(false);
const refreshPorts = useCallback(async () => {
if (!isElectron()) return;
setLoadingPorts(true);
const target = await resolveSerialWsUrl();
if (!target) {
setLoadingPorts(false);
return;
}
const ws = new WebSocket(target.url, target.protocols);
ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" }));
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data as string) as {
type: string;
data?: { path: string }[];
};
if (msg.type === "ports_list" && Array.isArray(msg.data)) {
const paths = msg.data.map((p) => p.path);
setAvailablePorts(paths);
setPath((current) => current || (paths[0] ?? current));
}
} catch {
// ignore
} finally {
ws.close();
setLoadingPorts(false);
}
};
ws.onerror = () => setLoadingPorts(false);
}, []);
useEffect(() => {
if (isElectron()) void refreshPorts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const connect = () => {
// For Web Serial, path is irrelevant — the browser picker handles it.
// We pass an empty string; Serial.tsx calls requestPort() itself.
if (useWebSerial) {
onConnect({ path: "", baudRate, dataBits, stopBits, parity });
return;
}
const finalPath = path.trim();
if (!finalPath) return;
onConnect({ path: finalPath, baudRate, dataBits, stopBits, parity });
};
// ── Not supported (Firefox, Safari, etc.) ─────────────────────────────
if (serialUnsupported) {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-2 rounded border border-destructive/30 bg-destructive/5 p-3">
<div className="flex items-center gap-2">
<TriangleAlert className="size-3.5 text-destructive shrink-0" />
<span className="text-xs font-semibold text-destructive">
{t("serial.notSupportedTitle")}
</span>
</div>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("serial.notSupported")}
</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("serial.hideHint")}
</p>
</div>
</div>
</div>
);
}
// ── Web Serial (Chrome / Edge) ─────────────────────────────────────────
if (useWebSerial) {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<p className="text-[11px] text-muted-foreground leading-relaxed">
{t("serial.browserPickerHint")}
</p>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.baudRateLabel")}
</label>
<Select2
value={baudRate}
onChange={(e) => setBaudRate(Number(e.target.value))}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{BAUD_RATES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select2>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.dataBitsLabel")}
</label>
<Select2
value={dataBits}
onChange={(e) =>
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{([5, 6, 7, 8] as const).map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.stopBitsLabel")}
</label>
<Select2
value={stopBits}
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value={1}>1</option>
<option value={2}>2</option>
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.parityLabel")}
</label>
<Select2
value={parity}
onChange={(e) =>
setParity(e.target.value as "none" | "even" | "odd")
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="none">{t("serial.parityNone")}</option>
<option value="even">{t("serial.parityEven")}</option>
<option value="odd">{t("serial.parityOdd")}</option>
</Select2>
</div>
</div>
<button
onClick={connect}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors mt-1"
>
<Usb className="size-3.5" />
{t("serial.connect")}
</button>
</div>
</div>
);
}
// ── Electron (backend serialport) ─────────────────────────────────────
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.portLabel")}
</label>
<button
onClick={() => void refreshPorts()}
className="text-muted-foreground hover:text-foreground transition-colors"
title={t("serial.refreshPorts")}
>
<RefreshCw
className={`size-3 ${loadingPorts ? "animate-spin" : ""}`}
/>
</button>
</div>
{availablePorts.length > 0 && (
<Select2
value={path}
onChange={(e) => setPath(e.target.value)}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("serial.portPlaceholder")}</option>
{availablePorts.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</Select2>
)}
<Input
placeholder={t("serial.portPlaceholder")}
value={path}
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect();
}}
className="h-7 text-xs font-mono"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.baudRateLabel")}
</label>
<Select2
value={baudRate}
onChange={(e) => setBaudRate(Number(e.target.value))}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{BAUD_RATES.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</Select2>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.dataBitsLabel")}
</label>
<Select2
value={dataBits}
onChange={(e) =>
setDataBits(Number(e.target.value) as 5 | 6 | 7 | 8)
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{([5, 6, 7, 8] as const).map((b) => (
<option key={b} value={b}>
{b}
</option>
))}
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.stopBitsLabel")}
</label>
<Select2
value={stopBits}
onChange={(e) => setStopBits(Number(e.target.value) as 1 | 2)}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value={1}>1</option>
<option value={2}>2</option>
</Select2>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{t("serial.parityLabel")}
</label>
<Select2
value={parity}
onChange={(e) =>
setParity(e.target.value as "none" | "even" | "odd")
}
className="flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="none">{t("serial.parityNone")}</option>
<option value="even">{t("serial.parityEven")}</option>
<option value="odd">{t("serial.parityOdd")}</option>
</Select2>
</div>
</div>
<button
onClick={connect}
disabled={!path.trim()}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed mt-1"
>
<Usb className="size-3.5" />
{t("serial.connect")}
</button>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
type ElectronWindow = Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: { isElectron?: boolean };
};
/** Serial is always local: the device is physically attached to this desktop machine. */
export function isElectron(): boolean {
if (typeof window === "undefined") return false;
const win = window as ElectronWindow;
return (
win.IS_ELECTRON === true ||
!!win.electronAPI ||
win.electronAPI?.isElectron === true
);
}
+84
View File
@@ -0,0 +1,84 @@
// The terminal surfaces need xterm's own stylesheet.
import "@xterm/xterm/css/xterm.css";
import type { Ref } from "react";
import { Usb } from "lucide-react";
import type {
PanelProps,
PluginHostRecord,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { Serial } from "./Serial.js";
import { SerialPanel } from "./SerialPanel.js";
import { setSerialWsUrl } from "./transport.js";
import type { SerialConfig, SerialHandle } from "./types.js";
/**
* A per-connection synthetic host, never a real DB host. The shell only
* opens a genuinely hostless (host: null) tab as a singleton, and a serial
* console is not one: a user can have several open at once, each with its
* own device and settings. A distinct id per connection is what keeps them
* separate tabs.
*/
function buildPseudoHost(config: SerialConfig): PluginHostRecord {
return {
id: `serial-${Date.now()}`,
name: config.path
? `${config.path} (${config.baudRate})`
: `Serial (${config.baudRate})`,
ip: "",
port: 0,
};
}
function SerialPanelView({ shell }: PanelProps) {
return (
<SerialPanel
onConnect={(config) => {
shell.openTab(buildPseudoHost(config), "serial", {
forceNewTab: true,
data: { serialConfig: config },
});
}}
/>
);
}
function SerialTabView({ tab, isVisible, handleRef }: TabProps) {
const config = tab.data?.serialConfig as SerialConfig | undefined;
if (!config) return null;
return (
<Serial
ref={handleRef as Ref<SerialHandle>}
config={config}
isVisible={isVisible}
instanceId={tab.id}
/>
);
}
export function activate(app: TermixApp): void {
setSerialWsUrl(app.wsUrl);
app.onDispose(() => setSerialWsUrl(null));
app.registerRailItem({
id: "serial",
icon: Usb,
titleKey: "nav.serial",
after: "quick-connect",
separatorAfter: true,
});
app.registerPanel("serial", SerialPanelView);
app.registerTab("serial", SerialTabView, {
icon: Usb,
titleKey: "nav.serial",
session: true,
// Reconnecting to a COM port automatically after a restart is not a
// workflow anyone asked for, and there's no host to restore against
// anyway, so a serial tab is a local, ephemeral session like the
// desktop app's local terminal rather than something workspaces saves.
inLayouts: false,
});
}
+23
View File
@@ -0,0 +1,23 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
/**
* The live app.wsUrl, for components that are defined at module scope rather
* than inside activate(). Set on activate, cleared on dispose, the same
* pattern the tunnels plugin uses for app.api.
*/
let wsUrl: TermixApp["wsUrl"] | null = null;
export function setSerialWsUrl(next: TermixApp["wsUrl"] | null): void {
wsUrl = next;
}
/**
* The console's WebSocket URL and auth subprotocols. No origin option: a
* serial device is physically attached to this desktop machine, so it
* always dials the embedded local backend rather than a connected remote
* sync server (app.wsUrl's default when no origin is given).
*/
export async function resolveSerialWsUrl() {
if (!wsUrl) return null;
return wsUrl("/console");
}
+15
View File
@@ -0,0 +1,15 @@
export type SerialConfig = {
path: string;
baudRate: number;
dataBits: 5 | 6 | 7 | 8;
stopBits: 1 | 2;
parity: "none" | "even" | "odd";
};
export interface SerialHandle {
connect: () => void;
disconnect: () => void;
reconnect: () => void;
isConnected: () => boolean;
sendInput: (data: string) => void;
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activate } from "../../src/backend/index.js";
const manifest = manifestJson as unknown as PluginManifest;
describe("serial activate", () => {
it("mounts the console WS route at /console with every declared capability", async () => {
const mock: MockPluginContext = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
});
await activate(mock.ctx);
expect(mock.wsRoutes).toMatchObject([{ path: "/console", raw: false }]);
});
it("refuses to mount without network:serve", async () => {
const mock: MockPluginContext = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== "network:serve"),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
});
@@ -0,0 +1,187 @@
import { EventEmitter } from "node:events";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginWebSocketConnection } from "@termix/plugin-sdk/backend";
import { createSerialSession } from "../../src/backend/session.js";
// vi.mock's factory is hoisted above every import, including node:events, so
// FakeSerialPort gets its own minimal emitter rather than extending it.
const { listMock, openMock, FakeSerialPort } = vi.hoisted(() => {
const listMock = vi.fn(async () => [{ path: "/dev/ttyUSB0" }]);
const openMock = vi.fn();
class FakeSerialPort {
isOpen = false;
private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
constructor(public options: Record<string, unknown>) {}
on(event: string, listener: (...args: unknown[]) => void) {
const set = this.listeners.get(event) ?? new Set();
set.add(listener);
this.listeners.set(event, set);
return this;
}
private emit(event: string, ...args: unknown[]) {
for (const listener of this.listeners.get(event) ?? []) listener(...args);
}
open(cb: (err?: Error) => void) {
openMock(this.options);
this.isOpen = true;
cb();
}
write(_data: Buffer, cb: (err?: Error) => void) {
cb();
}
close() {
this.isOpen = false;
this.emit("close");
}
static list = listMock;
}
return { listMock, openMock, FakeSerialPort };
});
vi.mock("serialport", () => ({ SerialPort: FakeSerialPort }));
class FakeSocket extends EventEmitter {
readyState = 1;
OPEN = 1;
sent: unknown[] = [];
send(msg: string) {
this.sent.push(JSON.parse(msg));
}
close(_code?: number, _reason?: string) {
this.readyState = 3;
}
}
function fakeConnection(
socket: FakeSocket,
isDataUnlocked: () => boolean = () => true,
): PluginWebSocketConnection {
return {
userId: "user-1",
request: {},
socket,
isDataUnlocked,
} as unknown as PluginWebSocketConnection;
}
async function deliver(socket: FakeSocket, message: object) {
socket.emit("message", Buffer.from(JSON.stringify(message)));
// Let the async message handler's microtasks settle.
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("serial session", () => {
let mock: MockPluginContext;
beforeEach(() => {
listMock.mockClear();
openMock.mockClear();
});
it("refuses list_ports and connect without device:serial", async () => {
mock = createMockCtx({ pluginId: "serial", capabilities: [] });
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket));
await deliver(socket, { type: "list_ports" });
expect(listMock).not.toHaveBeenCalled();
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
socket.sent.length = 0;
await deliver(socket, {
type: "connect",
data: { path: "/dev/ttyUSB0", baudRate: 9600 },
});
expect(openMock).not.toHaveBeenCalled();
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
});
it("lists ports once granted device:serial", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket));
await deliver(socket, { type: "list_ports" });
expect(listMock).toHaveBeenCalledTimes(1);
expect(socket.sent).toEqual([
{ type: "ports_list", data: [{ path: "/dev/ttyUSB0" }] },
]);
});
it("opens, writes to and closes a port through connect/input/disconnect", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket));
await deliver(socket, {
type: "connect",
data: { path: "/dev/ttyUSB0", baudRate: 115200 },
});
expect(openMock).toHaveBeenCalledWith(
expect.objectContaining({ path: "/dev/ttyUSB0", baudRate: 115200 }),
);
expect(socket.sent).toContainEqual({ type: "connected" });
socket.sent.length = 0;
await deliver(socket, { type: "input", data: "AT\r\n" });
// write() succeeds silently; nothing is echoed back until the port emits data.
socket.sent.length = 0;
await deliver(socket, { type: "disconnect" });
expect(socket.sent).toContainEqual({ type: "disconnected" });
});
it("rejects a connect with no path or baud rate without touching the port", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket));
await deliver(socket, { type: "connect", data: { path: "" } });
expect(openMock).not.toHaveBeenCalled();
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
});
it("closes the socket at connect time when data is locked", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket, () => false));
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
});
it("refuses a message once data locks mid-session", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
let unlocked = true;
await createSerialSession(mock.ctx)(fakeConnection(socket, () => unlocked));
unlocked = false;
await deliver(socket, { type: "list_ports" });
expect(listMock).not.toHaveBeenCalled();
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
});
});
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("activates and registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
const panels = [...declared(contributes.panels), ...tabs];
expect(rendered.registered.tabs()).toEqual(["serial"]);
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
"serial",
);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
});
it("renders the connect form in the rail panel", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
rendered.renderPanel("serial", { active: true });
// jsdom has no Web Serial API and isn't Electron, so the panel falls
// back to its "not supported" message rather than a live form.
expect(
await screen.findByText(locales.serial.notSupportedTitle),
).toBeTruthy();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);