From 791d8656a141d0e65ea2061b23ec1dba5498a38b Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 24 Sep 2026 01:26:40 +0000 Subject: [PATCH] feat: convert serial console into the serial plugin --- docker/nginx-https.conf | 21 -- docker/nginx.conf | 21 -- package-lock.json | 15 +- package.json | 1 - packages/plugin-sdk/ARCHITECTURE.md | 92 ++++++++- packages/plugin-sdk/FINISH-LIST.md | 24 +++ packages/plugin-sdk/cli/commands/build.mjs | 7 +- packages/plugin-sdk/cli/commands/validate.mjs | 28 +++ .../plugin-sdk/schema/manifest.schema.json | 10 + packages/plugin-sdk/src/backend.ts | 17 ++ packages/plugin-sdk/src/capabilities.ts | 1 + packages/plugin-sdk/src/frontend.ts | 6 + packages/plugin-sdk/src/manifest.ts | 26 +++ packages/plugin-sdk/src/testing.ts | 14 ++ plugins/serial/CHANGELOG.md | 8 + plugins/serial/README.md | 32 +++ plugins/serial/locales/en.json | 29 +++ plugins/serial/locales/translated/af_ZA.json | 29 +++ plugins/serial/locales/translated/ar_SA.json | 29 +++ plugins/serial/locales/translated/bg_BG.json | 29 +++ plugins/serial/locales/translated/bn_BD.json | 29 +++ plugins/serial/locales/translated/ca_ES.json | 29 +++ plugins/serial/locales/translated/cs_CZ.json | 29 +++ plugins/serial/locales/translated/da_DK.json | 29 +++ plugins/serial/locales/translated/de_DE.json | 29 +++ plugins/serial/locales/translated/el_GR.json | 29 +++ plugins/serial/locales/translated/es_ES.json | 29 +++ plugins/serial/locales/translated/fi_FI.json | 29 +++ plugins/serial/locales/translated/fr_FR.json | 29 +++ plugins/serial/locales/translated/he_IL.json | 29 +++ plugins/serial/locales/translated/hi_IN.json | 29 +++ plugins/serial/locales/translated/hu_HU.json | 29 +++ plugins/serial/locales/translated/id_ID.json | 29 +++ plugins/serial/locales/translated/it_IT.json | 29 +++ plugins/serial/locales/translated/ja_JP.json | 29 +++ plugins/serial/locales/translated/ko_KR.json | 29 +++ plugins/serial/locales/translated/nl_NL.json | 29 +++ plugins/serial/locales/translated/no_NO.json | 29 +++ plugins/serial/locales/translated/pl_PL.json | 29 +++ plugins/serial/locales/translated/pt_BR.json | 29 +++ plugins/serial/locales/translated/pt_PT.json | 29 +++ plugins/serial/locales/translated/ro_RO.json | 29 +++ plugins/serial/locales/translated/ru_RU.json | 29 +++ plugins/serial/locales/translated/sr_SP.json | 29 +++ plugins/serial/locales/translated/sv_SE.json | 29 +++ plugins/serial/locales/translated/th_TH.json | 29 +++ plugins/serial/locales/translated/tr_TR.json | 29 +++ plugins/serial/locales/translated/uk_UA.json | 29 +++ plugins/serial/locales/translated/vi_VN.json | 29 +++ plugins/serial/locales/translated/zh_CN.json | 29 +++ plugins/serial/locales/translated/zh_TW.json | 29 +++ plugins/serial/manifest.json | 38 ++++ plugins/serial/package.json | 18 ++ plugins/serial/src/backend/index.ts | 12 ++ plugins/serial/src/backend/session.ts | 169 +++++++++++++++ plugins/serial/src/backend/ws-message.ts | 38 ++++ .../serial/src/frontend}/Serial.tsx | 93 +++------ .../serial/src/frontend}/SerialPanel.tsx | 36 ++-- plugins/serial/src/frontend/electron.ts | 16 ++ plugins/serial/src/frontend/index.tsx | 85 ++++++++ plugins/serial/src/frontend/transport.ts | 23 +++ plugins/serial/src/frontend/types.ts | 15 ++ plugins/serial/tests/backend/activate.test.ts | 37 ++++ plugins/serial/tests/backend/session.test.ts | 187 +++++++++++++++++ .../serial/tests/frontend/activate.test.tsx | 59 ++++++ plugins/serial/tsconfig.json | 4 + plugins/serial/vitest.config.ts | 3 + src/backend/hosts/serial.ts | 195 ------------------ src/backend/plugins/ctx.ts | 28 ++- src/backend/starter.ts | 12 +- .../tests/plugins/ctx-capabilities.test.ts | 106 ++++++++++ src/backend/utils/swagger.ts | 4 - src/types/ui-types.ts | 13 +- src/ui/AppShell.tsx | 70 ------- src/ui/features/serial/serial-types.ts | 6 - src/ui/lib/connection-origin.ts | 17 +- src/ui/locales/en.json | 29 +-- src/ui/locales/translated/af_ZA.json | 25 --- src/ui/locales/translated/ar_SA.json | 25 --- src/ui/locales/translated/bg_BG.json | 25 --- src/ui/locales/translated/bn_BD.json | 25 --- src/ui/locales/translated/ca_ES.json | 25 --- src/ui/locales/translated/cs_CZ.json | 25 --- src/ui/locales/translated/da_DK.json | 25 --- src/ui/locales/translated/de_DE.json | 25 --- src/ui/locales/translated/el_GR.json | 25 --- src/ui/locales/translated/es_ES.json | 25 --- src/ui/locales/translated/fi_FI.json | 25 --- src/ui/locales/translated/fr_FR.json | 25 --- src/ui/locales/translated/he_IL.json | 25 --- src/ui/locales/translated/hi_IN.json | 25 --- src/ui/locales/translated/hu_HU.json | 25 --- src/ui/locales/translated/id_ID.json | 25 --- src/ui/locales/translated/it_IT.json | 25 --- src/ui/locales/translated/ja_JP.json | 25 --- src/ui/locales/translated/ko_KR.json | 25 --- src/ui/locales/translated/nl_NL.json | 25 --- src/ui/locales/translated/no_NO.json | 25 --- src/ui/locales/translated/pl_PL.json | 25 --- src/ui/locales/translated/pt_BR.json | 25 --- src/ui/locales/translated/pt_PT.json | 25 --- src/ui/locales/translated/ro_RO.json | 25 --- src/ui/locales/translated/ru_RU.json | 25 --- src/ui/locales/translated/sr_SP.json | 25 --- src/ui/locales/translated/sv_SE.json | 25 --- src/ui/locales/translated/th_TH.json | 25 --- src/ui/locales/translated/tr_TR.json | 25 --- src/ui/locales/translated/uk_UA.json | 25 --- src/ui/locales/translated/vi_VN.json | 25 --- src/ui/locales/translated/zh_CN.json | 25 --- src/ui/locales/translated/zh_TW.json | 25 --- src/ui/shell/TabBar.tsx | 15 +- src/ui/shell/shell-layout.ts | 14 +- src/ui/shell/tab-registry.ts | 9 +- src/ui/shell/tabUtils.tsx | 21 -- src/ui/sidebar/AppRail.tsx | 1 - src/ui/sidebar/rail-items.ts | 3 - src/ui/tests/lib/connection-origin.test.ts | 10 - src/ui/tests/shell/shell-layout.test.ts | 59 +----- src/ui/tests/sidebar/rail-items.test.ts | 6 +- vite.config.ts | 2 +- 121 files changed, 2216 insertions(+), 1425 deletions(-) create mode 100644 plugins/serial/CHANGELOG.md create mode 100644 plugins/serial/README.md create mode 100644 plugins/serial/locales/en.json create mode 100644 plugins/serial/locales/translated/af_ZA.json create mode 100644 plugins/serial/locales/translated/ar_SA.json create mode 100644 plugins/serial/locales/translated/bg_BG.json create mode 100644 plugins/serial/locales/translated/bn_BD.json create mode 100644 plugins/serial/locales/translated/ca_ES.json create mode 100644 plugins/serial/locales/translated/cs_CZ.json create mode 100644 plugins/serial/locales/translated/da_DK.json create mode 100644 plugins/serial/locales/translated/de_DE.json create mode 100644 plugins/serial/locales/translated/el_GR.json create mode 100644 plugins/serial/locales/translated/es_ES.json create mode 100644 plugins/serial/locales/translated/fi_FI.json create mode 100644 plugins/serial/locales/translated/fr_FR.json create mode 100644 plugins/serial/locales/translated/he_IL.json create mode 100644 plugins/serial/locales/translated/hi_IN.json create mode 100644 plugins/serial/locales/translated/hu_HU.json create mode 100644 plugins/serial/locales/translated/id_ID.json create mode 100644 plugins/serial/locales/translated/it_IT.json create mode 100644 plugins/serial/locales/translated/ja_JP.json create mode 100644 plugins/serial/locales/translated/ko_KR.json create mode 100644 plugins/serial/locales/translated/nl_NL.json create mode 100644 plugins/serial/locales/translated/no_NO.json create mode 100644 plugins/serial/locales/translated/pl_PL.json create mode 100644 plugins/serial/locales/translated/pt_BR.json create mode 100644 plugins/serial/locales/translated/pt_PT.json create mode 100644 plugins/serial/locales/translated/ro_RO.json create mode 100644 plugins/serial/locales/translated/ru_RU.json create mode 100644 plugins/serial/locales/translated/sr_SP.json create mode 100644 plugins/serial/locales/translated/sv_SE.json create mode 100644 plugins/serial/locales/translated/th_TH.json create mode 100644 plugins/serial/locales/translated/tr_TR.json create mode 100644 plugins/serial/locales/translated/uk_UA.json create mode 100644 plugins/serial/locales/translated/vi_VN.json create mode 100644 plugins/serial/locales/translated/zh_CN.json create mode 100644 plugins/serial/locales/translated/zh_TW.json create mode 100644 plugins/serial/manifest.json create mode 100644 plugins/serial/package.json create mode 100644 plugins/serial/src/backend/index.ts create mode 100644 plugins/serial/src/backend/session.ts create mode 100644 plugins/serial/src/backend/ws-message.ts rename {src/ui/features/serial => plugins/serial/src/frontend}/Serial.tsx (77%) rename {src/ui/sidebar => plugins/serial/src/frontend}/SerialPanel.tsx (93%) create mode 100644 plugins/serial/src/frontend/electron.ts create mode 100644 plugins/serial/src/frontend/index.tsx create mode 100644 plugins/serial/src/frontend/transport.ts create mode 100644 plugins/serial/src/frontend/types.ts create mode 100644 plugins/serial/tests/backend/activate.test.ts create mode 100644 plugins/serial/tests/backend/session.test.ts create mode 100644 plugins/serial/tests/frontend/activate.test.tsx create mode 100644 plugins/serial/tsconfig.json create mode 100644 plugins/serial/vitest.config.ts delete mode 100644 src/backend/hosts/serial.ts create mode 100644 src/backend/tests/plugins/ctx-capabilities.test.ts delete mode 100644 src/ui/features/serial/serial-types.ts diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 845c9287d..b2e2e367f 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -585,27 +585,6 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - location ^~ /serial/websocket/ { - proxy_pass http://127.0.0.1:30011/; - proxy_http_version 1.1; - - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $http_host; - proxy_cache_bypass $http_upgrade; - - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - - proxy_read_timeout 86400s; - proxy_send_timeout 86400s; - proxy_connect_timeout 10s; - - proxy_buffering off; - proxy_request_buffering off; - } - error_page 500 502 503 504 /50x.html; location = /50x.html { root /app/html; diff --git a/docker/nginx.conf b/docker/nginx.conf index 4513cc352..30ab59a93 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -563,27 +563,6 @@ http { proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; } - location ^~ /serial/websocket/ { - proxy_pass http://127.0.0.1:30011/; - proxy_http_version 1.1; - - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - proxy_set_header Host $http_host; - proxy_cache_bypass $http_upgrade; - - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto; - - proxy_read_timeout 86400s; - proxy_send_timeout 86400s; - proxy_connect_timeout 10s; - - proxy_buffering off; - proxy_request_buffering off; - } - error_page 500 502 503 504 /50x.html; location = /50x.html { root /app/html; diff --git a/package-lock.json b/package-lock.json index c0791ecaf..074b456bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,6 @@ "qrcode": "^1.5.4", "redis": "^6.2.1", "semver": "^7.8.5", - "serialport": "^13.0.0", "sharp": "^0.35.4", "socks": "^2.8.10", "speakeasy": "^2.0.0", @@ -7013,6 +7012,10 @@ "resolved": "plugins/remote-desktop", "link": true }, + "node_modules/@termix-plugin/serial": { + "resolved": "plugins/serial", + "link": true + }, "node_modules/@termix-plugin/snippets": { "resolved": "plugins/snippets", "link": true @@ -18596,6 +18599,16 @@ "@termix/plugin-sdk": "*" } }, + "plugins/serial": { + "name": "@termix-plugin/serial", + "version": "1.0.0", + "dependencies": { + "serialport": "^13.0.0" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } + }, "plugins/snippets": { "name": "@termix-plugin/snippets", "version": "1.0.0", diff --git a/package.json b/package.json index 349717f4f..aa0cb4e77 100644 --- a/package.json +++ b/package.json @@ -88,7 +88,6 @@ "qrcode": "^1.5.4", "redis": "^6.2.1", "semver": "^7.8.5", - "serialport": "^13.0.0", "sharp": "^0.35.4", "socks": "^2.8.10", "speakeasy": "^2.0.0", diff --git a/packages/plugin-sdk/ARCHITECTURE.md b/packages/plugin-sdk/ARCHITECTURE.md index 980589cf9..33391833e 100644 --- a/packages/plugin-sdk/ARCHITECTURE.md +++ b/packages/plugin-sdk/ARCHITECTURE.md @@ -1031,6 +1031,7 @@ not do. "frontend": "dist/frontend.js", // default "locales": "locales", // default "platforms": ["linux", "win32", "darwin"], // optional + "nativeDependencies": ["serialport"], // optional, see "Native dependencies" } ``` @@ -1104,6 +1105,7 @@ deciding, not the mechanism. | `users:write` | high | Create and change user accounts | | `auth:provide` | high | Add a login method, second factor or SSH auth type | | `system:tls` | high | Request and replace the server certificate | +| `device:serial` | high | Open a physical serial or USB device on the Termix server | | `hosts:write` | medium | Create and change hosts | | `credentials:use` | medium | Connect using a host's stored credentials, without seeing them | | `network:outbound` | medium | Make outbound requests | @@ -1156,6 +1158,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. | `ctx.sync.registerEntity` | none | **A3** | | `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** | | `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** | +| `ctx.capabilities.has` / `.require` | the capability itself | **B11** | | `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** | | `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** | | `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** | @@ -1165,6 +1168,20 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`. | `ctx.audit.record` | none, the actor is the runtime's | **B9** | | `ctx.fetch` | `network:outbound` | B | +**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a +generic check for a capability no other ctx member wraps. Unlike every other +row above, the capability it checks is not fixed at the call site: the +plugin names it. That is right for exactly one kind of caller - privileged +code core cannot mediate through its own primitives, because it is not core's +to mediate. The serial plugin bundling `serialport` and opening an OS device +file is the first: there is no `ctx.serial.open()` for core to gate, because +the whole point is that the plugin talks to the hardware directly. `has` +answers without an audit line, for deciding whether to offer something; +`require` throws `PluginCapabilityError` and audits like a guarded member. A +plugin reaching for this first checks whether its need is actually one of the +rows above - `device:serial` is not a general "trust me" capability, it is +specifically for code a dedicated ctx member cannot cover. + `ctx.ssh` was pulled forward from B so plugin transports go through core's connect pipeline instead of importing ssh2 helpers from core: @@ -1429,6 +1446,70 @@ it. The lists live in `packages/plugin-sdk/cli/lib/externals.mjs`. +### Native dependencies + +A package with a compiled `.node` binding (`serialport`, `better-sqlite3`, +`node-pty`) cannot be bundled the way an ordinary npm dependency is. esbuild +would inline its JS, but the binding is loaded through a path computed +relative to the original package's own directory, which breaks once that JS +moves into `dist/backend.js` somewhere else entirely. It also cannot go on +the host-provided list above: that list is for packages every plugin shares +one instance of, and a serial port binding is one plugin's business, not +core's. + +**B11** (the serial plugin, the first bundled plugin with a native +dependency) resolved this by declaring the package in the manifest's +`nativeDependencies` array (`["serialport"]`) as well as a real dependency in +the plugin's own `package.json`. `termix-plugin build` adds every entry to +esbuild's `external` list for that plugin alone, so the compiled bundle keeps +a plain `import { SerialPort } from "serialport"` instead of inlining it, and +`termix-plugin validate` checks the name is actually declared in the +plugin's own `dependencies`. + +At runtime that import is a bare specifier Node resolves by walking up from +wherever `dist/backend.js` is loaded, through `dist/plugins/serial/`, +`dist/plugins/`, `dist/`, to the repo (or image) root. **The three +deployments this repo builds all end up with the package there because of +how npm workspaces already install it**, not because of anything specific to +native code: + +- **Dev and CI.** `npm install` at the repo root installs every workspace's + own dependencies, including a plugin's, and hoists them into the root + `node_modules` unless a version conflict forces one to stay nested. Nothing + about `serialport` is special here; it lands exactly where any of this + plugin's other dependencies would. +- **Docker.** The image's `npm ci` runs once at the repo root over the whole + workspace and produces one `node_modules`, which is copied into the final + image wholesale (`docker/Dockerfile`'s `production-deps` stage). Its native + binding compiles against the image's own `node:26-slim` toolchain + (`python3 make g++`, already installed for `better-sqlite3`), once per + target architecture under `docker buildx`, so linux x64 and arm64 each get + a binary actually built for that machine rather than a cross-compiled one. +- **Electron.** `electron-builder.json`'s `asarUnpack` already unpacks all of + `node_modules` (not a curated subset), and `npm run electron:rebuild` runs + `electron-rebuild` over the same hoisted `node_modules`, rebuilding + `@serialport/bindings-cpp` against Electron's ABI by name, exactly as it + already did for `better-sqlite3` and `node-pty`. Nothing in that script + changed: a package's native binding gets rebuilt because the script names + it, not because of which `package.json` declared the package as a + dependency. + +None of this required a new packaging mechanism, which is the honest reason +it was chosen over shipping the plugin its own `node_modules`: this repo's +build already treats the whole workspace as one `node_modules`, so a +native dependency declared in a plugin's `package.json` rides along for +free. A **community plugin installed from a tarball has none of this**: there +is no repo-root `npm install` to hoist its dependency into, no shared +`node_modules` its bundle can walk up to. Shipping a native dependency in a +plugin nobody's build system already vendors is an open problem this step +does not solve - see `packages/plugin-sdk/FINISH-LIST.md`. A community plugin +author who hits this today has three honest options, worst to best: ask the +person installing it to `npm install` the native package into the server's +own `node_modules` by hand (fragile, easy to get wrong on upgrade); avoid a +native dependency and reach for a WASM build of the same functionality if one +exists; or wait for prebuild-per-platform packaging support, which is not +built yet. + --- ## Converting a feature into a plugin @@ -1797,11 +1878,12 @@ Known specifics: an `optionalDependency` (added in A2; it previously declared nothing, and the loader had no reason to order the two). - Core feature servers that are not plugins yet still own ports: dashboard - 30006, tmux 30010, serial 30011 and homepage 30012. Each keeps its nginx - block until its own Phase B step. No plugin owns a port any more (A4). - **B6** moved file-manager off port 30004 onto `/plugin-api/file-manager/`, - and **B7** moved tunnels off port 30003 onto `/plugin-api/tunnels/` and - `/plugin-ws/tunnels/c2s/stream`. + 30006 and homepage 30012. Each keeps its nginx block until its own Phase B + step. No plugin owns a port any more (A4). **B6** moved file-manager off + port 30004 onto `/plugin-api/file-manager/`, **B7** moved tunnels off port + 30003 onto `/plugin-api/tunnels/` and `/plugin-ws/tunnels/c2s/stream`, + **B10** moved tmux monitoring off port 30010, and **B11** moved serial off + port 30011 onto `/plugin-ws/serial/console`. - **B9** moved the terminal, its session manager, the local terminal and the command history panel into ssh-terminal, which imports nothing from core. The file manager's terminal window renders the `terminal.view` component diff --git a/packages/plugin-sdk/FINISH-LIST.md b/packages/plugin-sdk/FINISH-LIST.md index 75adaa992..6998c4aff 100644 --- a/packages/plugin-sdk/FINISH-LIST.md +++ b/packages/plugin-sdk/FINISH-LIST.md @@ -247,6 +247,28 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite `app.registerAction` opening the singleton tab) but is a shared gap across four plugins, not specific to this step. Owner: D1, or whoever revisits the toolbar's quick-link row. +- **B11 (serial):** `plugins/serial/src/frontend/Serial.tsx` uses a small + hardcoded light/dark xterm palette and a generic monospace font stack + instead of the app's real terminal theme system + (`resolveTermixThemeColors`, `DEFAULT_TERMINAL_CONFIG`, `TERMINAL_FONTS`, + font loading), which is still core-only (`src/ui/features/terminal/`, + `src/ui/lib/terminal-themes.ts`) and not yet exposed through the SDK. The + serial console therefore does not match a user's chosen terminal theme or + font. Promoting that system into `@termix/plugin-sdk/ui` is really + ssh-terminal's own Phase B step's call to make (it is the plugin that + currently owns the terminal component itself, per the "Known specifics" + note above), so this line just points at the gap rather than building a + premature shared surface for one caller. Owner: ssh-terminal's own Phase B + step, or D1. +- **B11 (serial):** shipping a plugin's own native dependency (`serialport`) + works for this repo's three bundled deployments only because they all + install the whole npm workspace at once and copy/rebuild one shared + `node_modules` - see "Native dependencies" in ARCHITECTURE.md for the full + reasoning. A community plugin installed later from a tarball has no such + shared `node_modules` to land in, so it cannot ship a native dependency the + same way. No fix attempted here; the honest workarounds are listed in that + section. Owner: whoever designs the 3.0.0 install flow (prebuild-per-platform + packaging, or a documented "avoid native code" rule for community plugins). ## Manual checks after 2.9.0 @@ -254,3 +276,5 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite prompt), TOTP keyboard-interactive, a jump host, Warpgate, split view, snippets sent to a terminal, reconnect after a network drop and after a page reload (session reattach), and the local terminal in the desktop app. +- Serial: the Electron backend path against a real device path/COM port, and + the Web Serial browser picker in Chrome or Edge. diff --git a/packages/plugin-sdk/cli/commands/build.mjs b/packages/plugin-sdk/cli/commands/build.mjs index 85ceae4b8..d714c3a10 100644 --- a/packages/plugin-sdk/cli/commands/build.mjs +++ b/packages/plugin-sdk/cli/commands/build.mjs @@ -52,7 +52,12 @@ export async function build({ cwd }) { target: "node22", sourcemap: true, logLevel: "warning", - external: BACKEND_EXTERNALS, + // A native dependency's compiled .node binary is resolved by the + // package's own relative paths, which break once esbuild inlines its JS + // elsewhere. Declaring it in nativeDependencies keeps it a real + // node_modules import instead, resolved at runtime like a host-provided + // package. + external: [...BACKEND_EXTERNALS, ...(manifest.nativeDependencies ?? [])], plugins: [legacyCoreImports({ pluginId, platform: "node" })], }); diff --git a/packages/plugin-sdk/cli/commands/validate.mjs b/packages/plugin-sdk/cli/commands/validate.mjs index 0137546df..04e6eb687 100644 --- a/packages/plugin-sdk/cli/commands/validate.mjs +++ b/packages/plugin-sdk/cli/commands/validate.mjs @@ -37,6 +37,7 @@ export async function validate({ cwd }) { } problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd))); + problems.push(...validateNativeDependencies(cwd, raw)); if (problems.length > 0) { for (const problem of problems) console.error(` ${problem}`); @@ -46,6 +47,33 @@ export async function validate({ cwd }) { console.log(`ok ${raw.id ?? path.basename(cwd)}`); } +/** + * A native dependency has to actually be a real dependency of the plugin, or + * the build's external declaration points at nothing node_modules can + * resolve at runtime. + */ +function validateNativeDependencies(cwd, raw) { + const problems = []; + const nativeDependencies = raw.nativeDependencies ?? []; + if (nativeDependencies.length === 0) return problems; + + const pkgPath = path.join(cwd, "package.json"); + const pkg = fs.existsSync(pkgPath) + ? JSON.parse(fs.readFileSync(pkgPath, "utf8")) + : {}; + const deps = { ...pkg.dependencies }; + + for (const name of nativeDependencies) { + if (!(name in deps)) { + problems.push( + `nativeDependencies names "${name}", which is not in this plugin's own package.json dependencies`, + ); + } + } + + return problems; +} + const DIALECTS = ["sqlite", "postgres", "mysql"]; const CREATE_OR_ALTER = diff --git a/packages/plugin-sdk/schema/manifest.schema.json b/packages/plugin-sdk/schema/manifest.schema.json index 12a35588e..47fb2ade7 100644 --- a/packages/plugin-sdk/schema/manifest.schema.json +++ b/packages/plugin-sdk/schema/manifest.schema.json @@ -103,6 +103,7 @@ "users:write", "auth:provide", "system:tls", + "device:serial", "hosts:write", "credentials:use", "network:outbound", @@ -742,6 +743,15 @@ "type": "string", "enum": ["linux", "win32", "darwin"] } + }, + "nativeDependencies": { + "type": "array", + "minItems": 1, + "description": "Bare npm package names this plugin depends on that carry a native (.node) binding. The build never bundles them: they stay a real dependency in the plugin's own package.json and are resolved from node_modules at runtime, the same as a host-provided package, so the compiled native binary is never copied into the JS bundle.", + "items": { + "type": "string", + "minLength": 1 + } } }, "$defs": { diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index 4b74434dd..a89eb7941 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -1111,6 +1111,21 @@ export interface PluginAudit { }) => Promise; } +/** + * A generic capability check, for a privileged action that has no dedicated + * ctx method to wrap it: a plugin bundling its own native dependency to touch + * hardware (a serial port, a USB device) is the first caller. Core cannot + * mediate that access the way it mediates ctx.ssh or ctx.db, so the + * capability here is a declared, reviewable, audited statement of intent + * rather than a technical gate, matching credentials:read and process:spawn. + */ +export interface PluginCapabilities { + /** Whether this plugin currently holds `capability`. Not audited: for deciding whether to offer something, not for gating an action. */ + has: (capability: string) => Promise; + /** Throws PluginCapabilityError if this plugin does not hold `capability`. Audited like every guarded ctx method. */ + require: (capability: string) => Promise; +} + export interface PluginContext { readonly pluginId: string; readonly manifest: PluginManifest; @@ -1127,6 +1142,8 @@ export interface PluginContext { readonly rbac: PluginRbac; readonly settings: PluginSettings; readonly disposables: PluginDisposables; + /** A generic capability check, for privileged code no ctx method wraps. */ + readonly capabilities: PluginCapabilities; /** Hosts the actor can see, and host-sharing operations. Needs hosts:read / hosts:write. */ readonly hosts: PluginHosts; /** SSH through core's connect pipeline. Needs ssh:connect and credentials:use. */ diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index 31fcdec94..0cafcde52 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -45,6 +45,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("users:write", "high"), entry("auth:provide", "high"), entry("system:tls", "high"), + entry("device:serial", "high"), entry("hosts:write", "medium"), entry("credentials:use", "medium"), diff --git a/packages/plugin-sdk/src/frontend.ts b/packages/plugin-sdk/src/frontend.ts index 875326d05..e2df21b17 100644 --- a/packages/plugin-sdk/src/frontend.ts +++ b/packages/plugin-sdk/src/frontend.ts @@ -232,6 +232,12 @@ export interface TabOptions { singleton?: boolean; /** A live session: asks before closing and can be refreshed. */ session?: boolean; + /** + * Whether a session tab offers "Share session". Default true. A plugin + * whose ref does not implement session sharing (a local device, not a + * remote connection another user could join) sets this false. + */ + shareable?: boolean; /** Restorable without a host. */ hostless?: boolean; /** Decides whether a saved tab for this host may be restored. */ diff --git a/packages/plugin-sdk/src/manifest.ts b/packages/plugin-sdk/src/manifest.ts index 984dd1273..de7e18332 100644 --- a/packages/plugin-sdk/src/manifest.ts +++ b/packages/plugin-sdk/src/manifest.ts @@ -298,6 +298,15 @@ export interface PluginManifest { /** Locales directory, relative to the plugin root. */ locales?: string; platforms?: string[]; + /** + * Bare npm package names carrying a native (.node) binding. The CLI build + * never bundles them: they stay a real dependency in the plugin's own + * package.json and are resolved from node_modules at runtime instead, + * because esbuild bundling a native addon breaks the relative path it uses + * to locate its compiled binary. See ARCHITECTURE.md under "native + * dependencies". + */ + nativeDependencies?: string[]; } export const DEFAULT_BACKEND_ENTRY = "dist/backend.js"; @@ -331,6 +340,7 @@ const ALLOWED_TOP_LEVEL = new Set([ "frontend", "locales", "platforms", + "nativeDependencies", ]); const ALLOWED_CONTRIBUTES = new Set([ @@ -441,6 +451,7 @@ export function validateManifest(manifest: unknown): string[] { validateCategory(m.category, errors); validateCapabilities(m.capabilities, errors); validatePlatforms(m.platforms, errors); + validateNativeDependencies(m.nativeDependencies, errors); validateDependencyMap(m.dependencies, "dependencies", errors); validateDependencyMap(m.optionalDependencies, "optionalDependencies", errors); validateProvides(m.provides, errors); @@ -535,6 +546,21 @@ function validatePlatforms(platforms: unknown, errors: string[]): void { }); } +function validateNativeDependencies(value: unknown, errors: string[]): void { + if (value === undefined) return; + if (!Array.isArray(value) || value.length === 0) { + errors.push( + 'Field "nativeDependencies" must be a non-empty array when present', + ); + return; + } + value.forEach((entry, index) => { + if (typeof entry !== "string" || entry.length === 0) { + errors.push(`nativeDependencies[${index}] must be a non-empty string`); + } + }); +} + function validateDependencyMap( value: unknown, field: string, diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 5eaa2751a..265625d50 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -406,6 +406,13 @@ export function createFakeContext( }) as PluginMiddleware, }, + // Nothing here is capability-checked, per the module doc; createMockCtx + // gates this against options.capabilities. + capabilities: { + has: async () => true, + require: async () => {}, + }, + settings: { get: async (key) => settings.get(settingsKey("admin", undefined, key)) as never, @@ -936,6 +943,13 @@ export function createMockCtx( return ctx.desktop.openIsolatedWindow(request); }, }, + + capabilities: { + has: async (capability) => granted.has(capability), + require: async (capability) => { + require(capability); + }, + }, }; return { ...base, ctx: gatedCtx, checked }; diff --git a/plugins/serial/CHANGELOG.md b/plugins/serial/CHANGELOG.md new file mode 100644 index 000000000..7cd11af6a --- /dev/null +++ b/plugins/serial/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## 1.0.0 + +Converted from core's `src/backend/hosts/serial.ts` and +`src/ui/features/serial/` in Termix 2.9.0. Its own port (30011) and nginx +blocks are gone; it now serves `/plugin-ws/serial/console` like every other +plugin. diff --git a/plugins/serial/README.md b/plugins/serial/README.md new file mode 100644 index 000000000..ce7ac8e93 --- /dev/null +++ b/plugins/serial/README.md @@ -0,0 +1,32 @@ +# Serial + +Serial console access over a device attached to the machine running Termix: +the desktop app talks to it through the backend's `serialport` binding, and a +browser without the desktop app falls back to the Web Serial API. + +Bundled with Termix and enabled by default. + +## Layout + + manifest.json id, capabilities and what this plugin contributes + src/backend/ activate(ctx) / deactivate(), the /console WS route + src/frontend/ the rail panel, the tab and their shared transport + locales/en.json its strings (English only; the rest are translated) + tests/backend/ vitest, node + tests/frontend/ vitest, jsdom + +## Native dependency + +`serialport` (and its native `@serialport/bindings-cpp` binding) is this +plugin's own npm dependency rather than a host-provided one, but the build +never bundles it: see "native dependencies" in +packages/plugin-sdk/ARCHITECTURE.md for why, and how Docker, Electron and dev +each end up with a working binary anyway. + +## Commands + + npm run build bundle into dist/ + npm run test run this plugin's tests + npm run typecheck + +See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows. diff --git a/plugins/serial/locales/en.json b/plugins/serial/locales/en.json new file mode 100644 index 000000000..bf4b7de0c --- /dev/null +++ b/plugins/serial/locales/en.json @@ -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." + } +} diff --git a/plugins/serial/locales/translated/af_ZA.json b/plugins/serial/locales/translated/af_ZA.json new file mode 100644 index 000000000..e495fd809 --- /dev/null +++ b/plugins/serial/locales/translated/af_ZA.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Reeks" + } +} diff --git a/plugins/serial/locales/translated/ar_SA.json b/plugins/serial/locales/translated/ar_SA.json new file mode 100644 index 000000000..742638dec --- /dev/null +++ b/plugins/serial/locales/translated/ar_SA.json @@ -0,0 +1,29 @@ +{ + "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 وسيفتح متصفحك نافذة اختيار المنفذ لتحديد الجهاز." + }, + "nav": { + "serial": "تسلسلي" + } +} diff --git a/plugins/serial/locales/translated/bg_BG.json b/plugins/serial/locales/translated/bg_BG.json new file mode 100644 index 000000000..417519176 --- /dev/null +++ b/plugins/serial/locales/translated/bg_BG.json @@ -0,0 +1,29 @@ +{ + "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": "Щракнете върху „Свързване“ и браузърът ви ще отвори инструмент за избор на порт, за да изберете устройството." + }, + "nav": { + "serial": "Сериен" + } +} diff --git a/plugins/serial/locales/translated/bn_BD.json b/plugins/serial/locales/translated/bn_BD.json new file mode 100644 index 000000000..d4a598f43 --- /dev/null +++ b/plugins/serial/locales/translated/bn_BD.json @@ -0,0 +1,29 @@ +{ + "serial": { + "title": "সিরিয়াল", + "portLabel": "বন্দর", + "portPlaceholder": "/dev/ttyUSB0 অথবা COM3", + "baudRateLabel": "বড রেট", + "dataBitsLabel": "ডেটা", + "stopBitsLabel": "থামুন", + "parityLabel": "সমতা", + "parityNone": "কোনোটিই না", + "parityEven": "এমনকি", + "parityOdd": "অদ্ভুত", + "connect": "সিরিয়ালের সাথে সংযোগ করুন", + "disconnect": "সংযোগ বিচ্ছিন্ন করুন", + "refreshPorts": "পোর্টগুলি রিফ্রেশ করুন", + "connected": "{{path}} এর সাথে {{baud}} বাউডে সংযুক্ত।", + "disconnected": "সিরিয়াল পোর্ট সংযোগ বিচ্ছিন্ন", + "connectionError": "সিরিয়াল পোর্ট খুলতে ব্যর্থ হয়েছে", + "wsError": "ওয়েব সকেট ত্রুটি", + "errorNoServerUrl": "কোন সার্ভার URL কনফিগার করা নেই", + "notSupportedTitle": "সিরিয়াল সমর্থিত নয়", + "notSupported": "সিরিয়াল সংযোগের জন্য ওয়েব সিরিয়াল এপিআই সমর্থনসহ একটি ব্রাউজার (ক্রোম, এজ, বা ফায়ারফক্স ১৫১+), অথবা টারমিক্স ডেস্কটপ অ্যাপ প্রয়োজন।", + "hideHint": "আপনি ইউজার প্রোফাইল > অ্যাপিয়ারেন্স > সাইডবার > নেভিগেশন-এর অধীনে সিরিয়াল ট্যাবটি লুকাতে পারেন।", + "browserPickerHint": "কানেক্ট-এ ক্লিক করলে আপনার ব্রাউজারে ডিভাইসটি বেছে নেওয়ার জন্য একটি পোর্ট পিকার খুলবে।" + }, + "nav": { + "serial": "সিরিয়াল" + } +} diff --git a/plugins/serial/locales/translated/ca_ES.json b/plugins/serial/locales/translated/ca_ES.json new file mode 100644 index 000000000..ed7952f69 --- /dev/null +++ b/plugins/serial/locales/translated/ca_ES.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Sèrie" + } +} diff --git a/plugins/serial/locales/translated/cs_CZ.json b/plugins/serial/locales/translated/cs_CZ.json new file mode 100644 index 000000000..bba5e3cbc --- /dev/null +++ b/plugins/serial/locales/translated/cs_CZ.json @@ -0,0 +1,29 @@ +{ + "serial": { + "title": "Seriál", + "portLabel": "Přístav", + "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í." + }, + "nav": { + "serial": "Seriál" + } +} diff --git a/plugins/serial/locales/translated/da_DK.json b/plugins/serial/locales/translated/da_DK.json new file mode 100644 index 000000000..40cfce3a3 --- /dev/null +++ b/plugins/serial/locales/translated/da_DK.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Seriel" + } +} diff --git a/plugins/serial/locales/translated/de_DE.json b/plugins/serial/locales/translated/de_DE.json new file mode 100644 index 000000000..0d67e67d0 --- /dev/null +++ b/plugins/serial/locales/translated/de_DE.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Seriell" + } +} diff --git a/plugins/serial/locales/translated/el_GR.json b/plugins/serial/locales/translated/el_GR.json new file mode 100644 index 000000000..d422fb177 --- /dev/null +++ b/plugins/serial/locales/translated/el_GR.json @@ -0,0 +1,29 @@ +{ + "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": "Κάντε κλικ στην επιλογή Σύνδεση και το πρόγραμμα περιήγησής σας θα ανοίξει ένα πρόγραμμα επιλογής θυρών για να επιλέξετε τη συσκευή." + }, + "nav": { + "serial": "Σειράς" + } +} diff --git a/plugins/serial/locales/translated/es_ES.json b/plugins/serial/locales/translated/es_ES.json new file mode 100644 index 000000000..5e945adbb --- /dev/null +++ b/plugins/serial/locales/translated/es_ES.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Serial" + } +} diff --git a/plugins/serial/locales/translated/fi_FI.json b/plugins/serial/locales/translated/fi_FI.json new file mode 100644 index 000000000..bc1884b0e --- /dev/null +++ b/plugins/serial/locales/translated/fi_FI.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Sarjanumero" + } +} diff --git a/plugins/serial/locales/translated/fr_FR.json b/plugins/serial/locales/translated/fr_FR.json new file mode 100644 index 000000000..64ea17736 --- /dev/null +++ b/plugins/serial/locales/translated/fr_FR.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Série" + } +} diff --git a/plugins/serial/locales/translated/he_IL.json b/plugins/serial/locales/translated/he_IL.json new file mode 100644 index 000000000..f8cbab632 --- /dev/null +++ b/plugins/serial/locales/translated/he_IL.json @@ -0,0 +1,29 @@ +{ + "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": "לחץ על התחבר והדפדפן שלך יפתח בורר פורטים לבחירת המכשיר." + }, + "nav": { + "serial": "סִדרָתִי" + } +} diff --git a/plugins/serial/locales/translated/hi_IN.json b/plugins/serial/locales/translated/hi_IN.json new file mode 100644 index 000000000..aad31f879 --- /dev/null +++ b/plugins/serial/locales/translated/hi_IN.json @@ -0,0 +1,29 @@ +{ + "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 पर क्लिक करें और आपका ब्राउज़र डिवाइस चुनने के लिए एक पोर्ट पिकर खोलेगा।" + }, + "nav": { + "serial": "सीरियल" + } +} diff --git a/plugins/serial/locales/translated/hu_HU.json b/plugins/serial/locales/translated/hu_HU.json new file mode 100644 index 000000000..acf049aed --- /dev/null +++ b/plugins/serial/locales/translated/hu_HU.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Sorozatszám" + } +} diff --git a/plugins/serial/locales/translated/id_ID.json b/plugins/serial/locales/translated/id_ID.json new file mode 100644 index 000000000..869e4b3a4 --- /dev/null +++ b/plugins/serial/locales/translated/id_ID.json @@ -0,0 +1,29 @@ +{ + "serial": { + "title": "Serial", + "portLabel": "Pelabuhan", + "portPlaceholder": "/dev/ttyUSB0 atau COM3", + "baudRateLabel": "Kecepatan Baud", + "dataBitsLabel": "Data", + "stopBitsLabel": "Berhenti", + "parityLabel": "Keseimbangan", + "parityNone": "Tidak ada", + "parityEven": "Bahkan", + "parityOdd": "Aneh", + "connect": "Hubungkan ke Serial", + "disconnect": "Memutuskan", + "refreshPorts": "Penyegaran port", + "connected": "Terhubung ke {{path}} pada kecepatan {{baud}} baud", + "disconnected": "Port serial terputus", + "connectionError": "Gagal membuka port serial", + "wsError": "Kesalahan WebSocket", + "errorNoServerUrl": "Tidak ada URL server yang dikonfigurasi.", + "notSupportedTitle": "Nomor Seri Tidak Didukung", + "notSupported": "Koneksi serial memerlukan browser dengan dukungan Web Serial API (Chrome, Edge, atau Firefox 151+), atau aplikasi desktop Termix.", + "hideHint": "Anda dapat menyembunyikan tab Serial di bawah Profil Pengguna > Tampilan > Bilah Samping > Navigasi.", + "browserPickerHint": "Klik Sambungkan dan browser Anda akan membuka pemilih port untuk memilih perangkat." + }, + "nav": { + "serial": "Serial" + } +} diff --git a/plugins/serial/locales/translated/it_IT.json b/plugins/serial/locales/translated/it_IT.json new file mode 100644 index 000000000..e3418d11d --- /dev/null +++ b/plugins/serial/locales/translated/it_IT.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Seriale" + } +} diff --git a/plugins/serial/locales/translated/ja_JP.json b/plugins/serial/locales/translated/ja_JP.json new file mode 100644 index 000000000..79bfa2ed6 --- /dev/null +++ b/plugins/serial/locales/translated/ja_JP.json @@ -0,0 +1,29 @@ +{ + "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": "接続をクリックすると、ブラウザがポートピッカーを開き、デバイスを選択できます。" + }, + "nav": { + "serial": "シリアル" + } +} diff --git a/plugins/serial/locales/translated/ko_KR.json b/plugins/serial/locales/translated/ko_KR.json new file mode 100644 index 000000000..9ba2d1436 --- /dev/null +++ b/plugins/serial/locales/translated/ko_KR.json @@ -0,0 +1,29 @@ +{ + "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": "연결 버튼을 클릭하면 브라우저에서 장치 선택을 위한 포트 선택기가 열립니다." + }, + "nav": { + "serial": "시리얼" + } +} diff --git a/plugins/serial/locales/translated/nl_NL.json b/plugins/serial/locales/translated/nl_NL.json new file mode 100644 index 000000000..c419affda --- /dev/null +++ b/plugins/serial/locales/translated/nl_NL.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Serieel" + } +} diff --git a/plugins/serial/locales/translated/no_NO.json b/plugins/serial/locales/translated/no_NO.json new file mode 100644 index 000000000..1af7da345 --- /dev/null +++ b/plugins/serial/locales/translated/no_NO.json @@ -0,0 +1,29 @@ +{ + "serial": { + "title": "Seriell", + "portLabel": "Havn", + "portPlaceholder": "/dev/ttyUSB0 eller COM3", + "baudRateLabel": "Baudhastighet", + "dataBitsLabel": "Data", + "stopBitsLabel": "Stoppe", + "parityLabel": "Paritet", + "parityNone": "Ingen", + "parityEven": "Til og med", + "parityOdd": "Merkelig", + "connect": "Koble til seriell", + "disconnect": "Frakople", + "refreshPorts": "Oppdater porter", + "connected": "Koblet til {{path}} ved {{baud}} baud", + "disconnected": "Seriell port frakoblet", + "connectionError": "Klarte ikke å åpne serieporten", + "wsError": "WebSocket-feil", + "errorNoServerUrl": "Ingen server-URL konfigurert", + "notSupportedTitle": "Serienummer støttes ikke", + "notSupported": "Serielle tilkoblinger krever en nettleser med støtte for Web Serial API (Chrome, Edge eller Firefox 151+), eller Termix-skrivebordsappen.", + "hideHint": "Du kan skjule Seriell-fanen under Brukerprofil > Utseende > Sidefelt > Navigasjon.", + "browserPickerHint": "Klikk på Koble til, så åpner nettleseren din en portvelger der du kan velge enheten." + }, + "nav": { + "serial": "Seriell" + } +} diff --git a/plugins/serial/locales/translated/pl_PL.json b/plugins/serial/locales/translated/pl_PL.json new file mode 100644 index 000000000..609dde221 --- /dev/null +++ b/plugins/serial/locales/translated/pl_PL.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Port szeregowy" + } +} diff --git a/plugins/serial/locales/translated/pt_BR.json b/plugins/serial/locales/translated/pt_BR.json new file mode 100644 index 000000000..a51751c9b --- /dev/null +++ b/plugins/serial/locales/translated/pt_BR.json @@ -0,0 +1,29 @@ +{ + "serial": { + "title": "Serial", + "portLabel": "Porta", + "portPlaceholder": "/dev/ttyUSB0 ou COM3", + "baudRateLabel": "Taxa de transmissão (Baud rate)", + "dataBitsLabel": "Dados", + "stopBitsLabel": "Parar", + "parityLabel": "Paridade", + "parityNone": "Nenhum", + "parityEven": "Até", + "parityOdd": "Chance", + "connect": "Conectar à porta serial", + "disconnect": "Desconectar", + "refreshPorts": "Atualizar portas", + "connected": "Conectado a {{path}} na taxa de transmissão de {{baud}}", + "disconnected": "Porta serial desconectada", + "connectionError": "Falha ao abrir a porta serial", + "wsError": "Erro no WebSocket", + "errorNoServerUrl": "Nenhuma URL de servidor configurada", + "notSupportedTitle": "Serial não suportado", + "notSupported": "As conexões seriais exigem um navegador com suporte à API Web Serial (Chrome, Edge ou Firefox 151+) ou o aplicativo Termix para desktop.", + "hideHint": "Você pode ocultar a guia Serial em Perfil do Usuário > Aparência > Barra lateral > Navegação.", + "browserPickerHint": "Clique em Conectar e seu navegador abrirá um seletor de portas para que você escolha o dispositivo." + }, + "nav": { + "serial": "Serial" + } +} diff --git a/plugins/serial/locales/translated/pt_PT.json b/plugins/serial/locales/translated/pt_PT.json new file mode 100644 index 000000000..037980312 --- /dev/null +++ b/plugins/serial/locales/translated/pt_PT.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Serial" + } +} diff --git a/plugins/serial/locales/translated/ro_RO.json b/plugins/serial/locales/translated/ro_RO.json new file mode 100644 index 000000000..2cdf507af --- /dev/null +++ b/plugins/serial/locales/translated/ro_RO.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Serial" + } +} diff --git a/plugins/serial/locales/translated/ru_RU.json b/plugins/serial/locales/translated/ru_RU.json new file mode 100644 index 000000000..e667219f4 --- /dev/null +++ b/plugins/serial/locales/translated/ru_RU.json @@ -0,0 +1,29 @@ +{ + "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": "Нажмите «Подключиться», и браузер откроет окно выбора порта для выбора устройства." + }, + "nav": { + "serial": "Последовательный порт" + } +} diff --git a/plugins/serial/locales/translated/sr_SP.json b/plugins/serial/locales/translated/sr_SP.json new file mode 100644 index 000000000..7c7c3117b --- /dev/null +++ b/plugins/serial/locales/translated/sr_SP.json @@ -0,0 +1,29 @@ +{ + "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": "Кликните на Повежи се и ваш прегледач ће отворити бирач портова за избор уређаја." + }, + "nav": { + "serial": "Серијски" + } +} diff --git a/plugins/serial/locales/translated/sv_SE.json b/plugins/serial/locales/translated/sv_SE.json new file mode 100644 index 000000000..e9fb4b635 --- /dev/null +++ b/plugins/serial/locales/translated/sv_SE.json @@ -0,0 +1,29 @@ +{ + "serial": { + "title": "Serie", + "portLabel": "Hamn", + "portPlaceholder": "/dev/ttyUSB0 eller COM3", + "baudRateLabel": "Baudhastighet", + "dataBitsLabel": "Data", + "stopBitsLabel": "Stopp", + "parityLabel": "Paritet", + "parityNone": "Ingen", + "parityEven": "Även", + "parityOdd": "Udda", + "connect": "Anslut till seriellt", + "disconnect": "Koppla från", + "refreshPorts": "Uppdatera portar", + "connected": "Ansluten till {{path}} vid {{baud}} baud", + "disconnected": "Seriell port frånkopplad", + "connectionError": "Misslyckades med att öppna serieporten", + "wsError": "WebSocket-fel", + "errorNoServerUrl": "Ingen server-URL konfigurerad", + "notSupportedTitle": "Seriellt stöds inte", + "notSupported": "Seriella anslutningar kräver en webbläsare med stöd för Web Serial API (Chrome, Edge eller Firefox 151+), eller Termix-skrivbordsappen.", + "hideHint": "Du kan dölja fliken Seriell under Användarprofil > Utseende > Sidofält > Navigering.", + "browserPickerHint": "Klicka på Anslut så öppnar din webbläsare en portväljare där du kan välja enheten." + }, + "nav": { + "serial": "Serie" + } +} diff --git a/plugins/serial/locales/translated/th_TH.json b/plugins/serial/locales/translated/th_TH.json new file mode 100644 index 000000000..949789113 --- /dev/null +++ b/plugins/serial/locales/translated/th_TH.json @@ -0,0 +1,29 @@ +{ + "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": "คุณสามารถซ่อนแท็บ Serial ได้ที่ โปรไฟล์ผู้ใช้ > ลักษณะที่ปรากฏ > แถบด้านข้าง > การนำทาง", + "browserPickerHint": "คลิก \"เชื่อมต่อ\" แล้วเบราว์เซอร์ของคุณจะเปิดตัวเลือกพอร์ตเพื่อเลือกอุปกรณ์" + }, + "nav": { + "serial": "ซีเรียล" + } +} diff --git a/plugins/serial/locales/translated/tr_TR.json b/plugins/serial/locales/translated/tr_TR.json new file mode 100644 index 000000000..26f1b3b94 --- /dev/null +++ b/plugins/serial/locales/translated/tr_TR.json @@ -0,0 +1,29 @@ +{ + "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." + }, + "nav": { + "serial": "Seri" + } +} diff --git a/plugins/serial/locales/translated/uk_UA.json b/plugins/serial/locales/translated/uk_UA.json new file mode 100644 index 000000000..e3f926e84 --- /dev/null +++ b/plugins/serial/locales/translated/uk_UA.json @@ -0,0 +1,29 @@ +{ + "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": "Натисніть «Підключитися», і ваш браузер відкриє вибір порту для вибору пристрою." + }, + "nav": { + "serial": "Серійний" + } +} diff --git a/plugins/serial/locales/translated/vi_VN.json b/plugins/serial/locales/translated/vi_VN.json new file mode 100644 index 000000000..2364aedff --- /dev/null +++ b/plugins/serial/locales/translated/vi_VN.json @@ -0,0 +1,29 @@ +{ + "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ị." + }, + "nav": { + "serial": "Nối tiếp" + } +} diff --git a/plugins/serial/locales/translated/zh_CN.json b/plugins/serial/locales/translated/zh_CN.json new file mode 100644 index 000000000..741dc7743 --- /dev/null +++ b/plugins/serial/locales/translated/zh_CN.json @@ -0,0 +1,29 @@ +{ + "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": "点击连接,您的浏览器将打开端口选择器以选择设备。" + }, + "nav": { + "serial": "串口" + } +} diff --git a/plugins/serial/locales/translated/zh_TW.json b/plugins/serial/locales/translated/zh_TW.json new file mode 100644 index 000000000..98145c4a5 --- /dev/null +++ b/plugins/serial/locales/translated/zh_TW.json @@ -0,0 +1,29 @@ +{ + "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": "按一下連線,瀏覽器會開啟連接埠選擇器讓您選取裝置。" + }, + "nav": { + "serial": "序列" + } +} diff --git a/plugins/serial/manifest.json b/plugins/serial/manifest.json new file mode 100644 index 000000000..eee47562c --- /dev/null +++ b/plugins/serial/manifest.json @@ -0,0 +1,38 @@ +{ + "id": "serial", + "name": "Serial", + "version": "1.0.0", + "description": "Serial console access over a locally attached device.", + "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" + } + ] + } +} diff --git a/plugins/serial/package.json b/plugins/serial/package.json new file mode 100644 index 000000000..410da081c --- /dev/null +++ b/plugins/serial/package.json @@ -0,0 +1,18 @@ +{ + "name": "@termix-plugin/serial", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p tsconfig.json", + "validate": "termix-plugin validate" + }, + "dependencies": { + "serialport": "^13.0.0" + }, + "devDependencies": { + "@termix/plugin-sdk": "*" + } +} diff --git a/plugins/serial/src/backend/index.ts b/plugins/serial/src/backend/index.ts new file mode 100644 index 000000000..17f46a0a9 --- /dev/null +++ b/plugins/serial/src/backend/index.ts @@ -0,0 +1,12 @@ +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { createSerialSession } from "./session.js"; + +export async function activate(ctx: PluginContext): Promise { + ctx.ws.route("/console", createSerialSession(ctx)); + ctx.log.info("Serial console mounted at /plugin-ws/serial/console"); +} + +export async function deactivate(): Promise { + // The WS route and every open socket are torn down by core: see + // src/backend/plugins/ws.ts. Nothing here outlives activate(). +} diff --git a/plugins/serial/src/backend/session.ts b/plugins/serial/src/backend/session.ts new file mode 100644 index 000000000..b869f7ac0 --- /dev/null +++ b/plugins/serial/src/backend/session.ts @@ -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 => { + 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; +} diff --git a/plugins/serial/src/backend/ws-message.ts b/plugins/serial/src/backend/ws-message.ts new file mode 100644 index 000000000..ad8907d01 --- /dev/null +++ b/plugins/serial/src/backend/ws-message.ts @@ -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 }; +} diff --git a/src/ui/features/serial/Serial.tsx b/plugins/serial/src/frontend/Serial.tsx similarity index 77% rename from src/ui/features/serial/Serial.tsx rename to plugins/serial/src/frontend/Serial.tsx index e10ba236c..14a61165a 100644 --- a/src/ui/features/serial/Serial.tsx +++ b/plugins/serial/src/frontend/Serial.tsx @@ -7,16 +7,24 @@ import { } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; -import { useTranslation } from "react-i18next"; import { TriangleAlert } from "lucide-react"; -import { isElectron } from "@/lib/electron"; -import { websocketAuthProtocols } from "@/lib/ws-auth"; -import { useTheme } from "@/components/theme-provider"; -import { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme"; -import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes"; -import { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles"; -import type { SerialConfig } from "@/types/ui-types"; -import type { SerialHandle } from "./serial-types"; +import { useTranslation, useTheme } from "@termix/plugin-sdk/frontend"; +import type { SerialConfig, SerialHandle } from "./types.js"; +import { isElectron } from "./electron.js"; +import { resolveSerialWsUrl } from "./transport.js"; + +const XTERM_THEME: Record<"light" | "dark", Record> = { + dark: { + background: "#0b0e14", + foreground: "#d4d7de", + cursor: "#d4d7de", + }, + light: { + background: "#ffffff", + foreground: "#1a1a1a", + cursor: "#1a1a1a", + }, +}; type WebSerialPort = { open(options: { @@ -60,52 +68,16 @@ export const Serial = forwardRef(function Serial( [terminal], ); - // ── Theme sync ───────────────────────────────────────────────────────── - useEffect(() => { if (!terminal) return; - const themeColors = resolveTermixThemeColors("termix", appTheme); - const fontConfig = TERMINAL_FONTS.find( - (f) => f.value === DEFAULT_TERMINAL_CONFIG.fontFamily, - ); - ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value); - terminal.options.theme = { - background: themeColors.background, - foreground: themeColors.foreground, - cursor: themeColors.cursor, - cursorAccent: themeColors.cursorAccent, - selectionBackground: themeColors.selectionBackground, - selectionForeground: themeColors.selectionForeground, - black: themeColors.black, - red: themeColors.red, - green: themeColors.green, - yellow: themeColors.yellow, - blue: themeColors.blue, - magenta: themeColors.magenta, - cyan: themeColors.cyan, - white: themeColors.white, - brightBlack: themeColors.brightBlack, - brightRed: themeColors.brightRed, - brightGreen: themeColors.brightGreen, - brightYellow: themeColors.brightYellow, - brightBlue: themeColors.brightBlue, - brightMagenta: themeColors.brightMagenta, - brightCyan: themeColors.brightCyan, - brightWhite: themeColors.brightWhite, - }; + terminal.options.theme = XTERM_THEME[appTheme]; terminal.options.fontFamily = - fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback; - terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize; + '"SF Mono", Consolas, "Liberation Mono", monospace'; + terminal.options.fontSize = 14; }, [terminal, appTheme]); // ── WebSocket (Electron) path ────────────────────────────────────────── - const buildWsUrl = useCallback(() => { - // Serial is always local -- the device is physically attached to this - // desktop machine, so it never routes through a remote server. - return "ws://127.0.0.1:30011"; - }, []); - const disconnectWs = useCallback(() => { if (wsRef.current) { wsRef.current.onclose = null; @@ -115,18 +87,15 @@ export const Serial = forwardRef(function Serial( connectedRef.current = false; }, []); - const connectWs = useCallback(() => { + const connectWs = useCallback(async () => { disconnectWs(); - const url = buildWsUrl(); - if (!url) { + const target = await resolveSerialWsUrl(); + if (!target) { write(`\r\n\x1b[31m${t("serial.errorNoServerUrl")}\x1b[0m\r\n`); return; } - const ws = new WebSocket( - url, - websocketAuthProtocols(localStorage.getItem("jwt")), - ); + const ws = new WebSocket(target.url, target.protocols); wsRef.current = ws; ws.onopen = () => { @@ -171,7 +140,7 @@ export const Serial = forwardRef(function Serial( ws.onerror = () => { write(`\r\n\x1b[31m${t("serial.wsError")}\x1b[0m\r\n`); }; - }, [buildWsUrl, config, disconnectWs, t, write]); + }, [config, disconnectWs, t, write]); // ── Web Serial API path ──────────────────────────────────────────────── @@ -252,7 +221,7 @@ export const Serial = forwardRef(function Serial( if (useWebSerial) { connectWebSerial(); } else { - connectWs(); + void connectWs(); } }, [useWebSerial, connectWebSerial, connectWs]); @@ -267,9 +236,15 @@ export const Serial = forwardRef(function Serial( } }, [useWebSerial, disconnectWebSerial, disconnectWs]); + const reconnect = useCallback(() => { + disconnect(); + connect(); + }, [disconnect, connect]); + useImperativeHandle(ref, () => ({ connect, disconnect, + reconnect, isConnected: () => connectedRef.current, sendInput: (data: string) => { if (useWebSerial) { @@ -289,8 +264,8 @@ export const Serial = forwardRef(function Serial( const fitAddon = new FitAddon(); fitAddonRef.current = fitAddon; terminal.loadAddon(fitAddon); - terminal.options.cursorBlink = DEFAULT_TERMINAL_CONFIG.cursorBlink; - terminal.options.scrollback = DEFAULT_TERMINAL_CONFIG.scrollback; + terminal.options.cursorBlink = true; + terminal.options.scrollback = 10000; terminal.onData((data) => { if (!connectedRef.current) return; diff --git a/src/ui/sidebar/SerialPanel.tsx b/plugins/serial/src/frontend/SerialPanel.tsx similarity index 93% rename from src/ui/sidebar/SerialPanel.tsx rename to plugins/serial/src/frontend/SerialPanel.tsx index f4a66cb96..41317b1c3 100644 --- a/src/ui/sidebar/SerialPanel.tsx +++ b/plugins/serial/src/frontend/SerialPanel.tsx @@ -1,11 +1,10 @@ import { useState, useEffect, useCallback } from "react"; -import { useTranslation } from "react-i18next"; import { RefreshCw, Usb, TriangleAlert } from "lucide-react"; -import { Input } from "@/components/input"; -import { isElectron } from "@/lib/electron"; -import { websocketAuthProtocols } from "@/lib/ws-auth"; -import type { SerialConfig } from "@/types/ui-types"; -import { Select2 } from "@/components/select2"; +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, @@ -33,25 +32,16 @@ export function SerialPanel({ onConnect }: SerialPanelProps) { const [availablePorts, setAvailablePorts] = useState([]); const [loadingPorts, setLoadingPorts] = useState(false); - const buildWsUrl = () => { - // Serial is always local -- the device is physically attached to this - // desktop machine, so it never routes through a remote server. - return "ws://127.0.0.1:30011"; - }; - - const refreshPorts = useCallback(() => { + const refreshPorts = useCallback(async () => { if (!isElectron()) return; setLoadingPorts(true); - const url = buildWsUrl(); - if (!url) { + const target = await resolveSerialWsUrl(); + if (!target) { setLoadingPorts(false); return; } - const ws = new WebSocket( - url, - websocketAuthProtocols(localStorage.getItem("jwt")), - ); + const ws = new WebSocket(target.url, target.protocols); ws.onopen = () => ws.send(JSON.stringify({ type: "list_ports" })); ws.onmessage = (ev) => { try { @@ -62,7 +52,7 @@ export function SerialPanel({ onConnect }: SerialPanelProps) { if (msg.type === "ports_list" && Array.isArray(msg.data)) { const paths = msg.data.map((p) => p.path); setAvailablePorts(paths); - if (!path && paths.length > 0) setPath(paths[0]); + setPath((current) => current || (paths[0] ?? current)); } } catch { // ignore @@ -72,10 +62,10 @@ export function SerialPanel({ onConnect }: SerialPanelProps) { } }; ws.onerror = () => setLoadingPorts(false); - }, [path]); + }, []); useEffect(() => { - if (isElectron()) refreshPorts(); + if (isElectron()) void refreshPorts(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -218,7 +208,7 @@ export function SerialPanel({ onConnect }: SerialPanelProps) { {t("serial.portLabel")} )} - {isConnectionTab(tab.type) && onOpenShare && ( + {isShareableConnectionTab(tab.type) && onOpenShare && (