feat: convert serial console into the serial plugin

This commit is contained in:
LukeGus committed 2026-09-23 23:06:37 -05:00
1 parent 708eedb861
commit 791d8656a1
121 files changed
+2216 -1425

No files matched your search

-21
View File
@@ -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;
-21
View File
@@ -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;
+14 -1
View File
@@ -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",
-1
View File
@@ -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",
+87 -5
View File
@@ -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
+24
View File
@@ -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.
+6 -1
View File
@@ -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" })],
});
@@ -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 =
@@ -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": {
+17
View File
@@ -1111,6 +1111,21 @@ export interface PluginAudit {
}) => Promise<void>;
}
/**
* 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<boolean>;
/** Throws PluginCapabilityError if this plugin does not hold `capability`. Audited like every guarded ctx method. */
require: (capability: string) => Promise<void>;
}
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. */
+1
View File
@@ -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"),
+6
View File
@@ -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. */
+26
View File
@@ -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,
+14
View File
@@ -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 };
+8
View File
@@ -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.
+32
View File
@@ -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.
+29
View File
@@ -0,0 +1,29 @@
{
"nav": {
"serial": "Serial"
},
"serial": {
"title": "Serial",
"portLabel": "Port",
"portPlaceholder": "/dev/ttyUSB0 or COM3",
"baudRateLabel": "Baud Rate",
"dataBitsLabel": "Data",
"stopBitsLabel": "Stop",
"parityLabel": "Parity",
"parityNone": "None",
"parityEven": "Even",
"parityOdd": "Odd",
"connect": "Connect to Serial",
"disconnect": "Disconnect",
"refreshPorts": "Refresh ports",
"connected": "Connected to {{path}} at {{baud}} baud",
"disconnected": "Serial port disconnected",
"connectionError": "Failed to open serial port",
"wsError": "WebSocket error",
"errorNoServerUrl": "No server URL configured",
"notSupportedTitle": "Serial Not Supported",
"notSupported": "Serial connections require a browser with Web Serial API support (Chrome, Edge, or Firefox 151+), or the Termix desktop app.",
"hideHint": "You can hide the Serial tab under User Profile > Appearance > Sidebar > Navigation.",
"browserPickerHint": "Click Connect and your browser will open a port picker to choose the device."
}
}
@@ -0,0 +1,29 @@
{
"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"
}
}
@@ -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": "تسلسلي"
}
}
@@ -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": "Сериен"
}
}
@@ -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": "সিরিয়াল"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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": "Σειράς"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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": "סִדרָתִי"
}
}
@@ -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": "सीरियल"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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": "シリアル"
}
}
@@ -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": "시리얼"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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"
}
}
@@ -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": "Последовательный порт"
}
}
@@ -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": "Серијски"
}
}
@@ -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"
}
}
@@ -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": "ซีเรียล"
}
}
@@ -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"
}
}
@@ -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": "Серійний"
}
}
@@ -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"
}
}
@@ -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": "串口"
}
}
@@ -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": "序列"
}
}
+38
View File
@@ -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"
}
]
}
}
+18
View File
@@ -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": "*"
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { createSerialSession } from "./session.js";
export async function activate(ctx: PluginContext): Promise<void> {
ctx.ws.route("/console", createSerialSession(ctx));
ctx.log.info("Serial console mounted at /plugin-ws/serial/console");
}
export async function deactivate(): Promise<void> {
// The WS route and every open socket are torn down by core: see
// src/backend/plugins/ws.ts. Nothing here outlives activate().
}
+169
View File
@@ -0,0 +1,169 @@
import type { WebSocket } from "ws";
import { SerialPort } from "serialport";
import type {
PluginContext,
PluginWebSocketConnection,
} from "@termix/plugin-sdk/backend";
import { parseWsMessage } from "./ws-message.js";
interface SerialConnectData {
path: string;
baudRate: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 2;
parity?: "none" | "even" | "odd";
}
/**
* Handles one /plugin-ws/serial/console connection. Auth already ran (core's
* ctx.ws.route authenticates before this is called), so every message here
* only has to decide whether to touch the actual hardware.
*/
export function createSerialSession(ctx: PluginContext) {
return async (connection: PluginWebSocketConnection): Promise<void> => {
const ws = connection.socket as WebSocket;
let port: SerialPort | null = null;
const send = (msg: object) => {
if (ws.readyState === ws.OPEN) {
ws.send(JSON.stringify(msg));
}
};
const cleanup = () => {
if (port?.isOpen) {
port.close();
}
port = null;
};
if (!connection.isDataUnlocked()) {
send({ type: "error", data: "Data locked" });
ws.close(1008, "Data access required");
return;
}
ws.on("message", async (raw) => {
let type: string;
let data: unknown;
try {
({ type, data } = parseWsMessage(raw));
} catch {
return;
}
if (!connection.isDataUnlocked()) {
send({ type: "error", data: "Data access expired" });
ws.close(1008, "Data access expired");
return;
}
try {
switch (type) {
case "list_ports": {
await ctx.capabilities.require("device:serial");
try {
const ports = await SerialPort.list();
send({ type: "ports_list", data: ports });
} catch (err) {
send({
type: "error",
data: errorMessage(err, "Failed to list ports"),
});
}
break;
}
case "connect": {
await ctx.capabilities.require("device:serial");
if (port?.isOpen) {
port.close();
port = null;
}
const cfg = data as SerialConnectData;
if (!cfg?.path || !cfg?.baudRate) {
send({ type: "error", data: "Missing port path or baud rate" });
break;
}
try {
const opened = new SerialPort({
path: cfg.path,
baudRate: cfg.baudRate,
dataBits: cfg.dataBits ?? 8,
stopBits: cfg.stopBits ?? 1,
parity: cfg.parity ?? "none",
autoOpen: false,
});
port = opened;
opened.open((err) => {
if (err) {
ctx.log.error(`Serial port open failed for ${cfg.path}`, err);
send({ type: "error", data: err.message });
port = null;
return;
}
ctx.log.info(
`Serial port opened: ${cfg.path} at ${cfg.baudRate} baud`,
);
send({ type: "connected" });
});
opened.on("data", (chunk: Buffer) => {
send({ type: "data", data: chunk.toString("binary") });
});
opened.on("error", (err) => {
send({ type: "error", data: err.message });
});
opened.on("close", () => {
send({ type: "disconnected" });
port = null;
});
} catch (err) {
send({
type: "error",
data: errorMessage(err, "Failed to open serial port"),
});
}
break;
}
case "input": {
if (!port?.isOpen) break;
const input = typeof data === "string" ? data : "";
if (!input) break;
port.write(Buffer.from(input, "binary"), (err) => {
if (err) send({ type: "error", data: err.message });
});
break;
}
case "disconnect": {
cleanup();
send({ type: "disconnected" });
break;
}
}
} catch (err) {
ctx.log.error("Error handling serial WebSocket message", err);
send({
type: "error",
data:
err instanceof Error ? err.message : "Failed to process message",
});
}
});
ws.on("close", cleanup);
ws.on("error", cleanup);
};
}
function errorMessage(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback;
}
+38
View File
@@ -0,0 +1,38 @@
import type { RawData } from "ws";
// Control messages here are tiny (a path, a baud rate) or raw terminal input,
// bounded by what a user can type or paste, so anything larger is abuse.
const MAX_MESSAGE_BYTES = 1024 * 1024;
function rawByteLength(raw: RawData): number {
if (Buffer.isBuffer(raw)) return raw.length;
if (Array.isArray(raw))
return raw.reduce((sum, part) => sum + part.length, 0);
if (raw instanceof ArrayBuffer) return raw.byteLength;
return 0;
}
/** Parses a WebSocket frame into `{ type, data }`, throwing on anything malformed. */
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
if (rawByteLength(raw) > MAX_MESSAGE_BYTES) {
throw new Error("Message too large");
}
let parsed: unknown;
try {
parsed = JSON.parse(raw.toString());
} catch {
throw new Error("Invalid JSON");
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Message must be a JSON object");
}
const { type, data } = parsed as { type?: unknown; data?: unknown };
if (typeof type !== "string") {
throw new Error("Message type must be a string");
}
return { type, data };
}
@@ -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<string, string>> = {
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<SerialHandle, SerialProps>(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<SerialHandle, SerialProps>(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<SerialHandle, SerialProps>(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<SerialHandle, SerialProps>(function Serial(
if (useWebSerial) {
connectWebSerial();
} else {
connectWs();
void connectWs();
}
}, [useWebSerial, connectWebSerial, connectWs]);
@@ -267,9 +236,15 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(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<SerialHandle, SerialProps>(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;
@@ -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<string[]>([]);
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")}
</label>
<button
onClick={refreshPorts}
onClick={() => void refreshPorts()}
className="text-muted-foreground hover:text-foreground transition-colors"
title={t("serial.refreshPorts")}
>
+16
View File
@@ -0,0 +1,16 @@
type ElectronWindow = Window &
typeof globalThis & {
IS_ELECTRON?: boolean;
electronAPI?: { isElectron?: boolean };
};
/** Serial is always local: the device is physically attached to this desktop machine. */
export function isElectron(): boolean {
if (typeof window === "undefined") return false;
const win = window as ElectronWindow;
return (
win.IS_ELECTRON === true ||
!!win.electronAPI ||
win.electronAPI?.isElectron === true
);
}
+85
View File
@@ -0,0 +1,85 @@
import type { Ref } from "react";
import { Usb } from "lucide-react";
import type {
PanelProps,
PluginHostRecord,
TabProps,
TermixApp,
} from "@termix/plugin-sdk/frontend";
import { Serial } from "./Serial.js";
import { SerialPanel } from "./SerialPanel.js";
import { setSerialWsUrl } from "./transport.js";
import type { SerialConfig, SerialHandle } from "./types.js";
/**
* A per-connection synthetic host, never a real DB host. The shell only
* opens a genuinely hostless (host: null) tab as a singleton, and a serial
* console is not one: a user can have several open at once, each with its
* own device and settings. A distinct id per connection is what keeps them
* separate tabs.
*/
function buildPseudoHost(config: SerialConfig): PluginHostRecord {
return {
id: `serial-${Date.now()}`,
name: config.path
? `${config.path} (${config.baudRate})`
: `Serial (${config.baudRate})`,
ip: "",
port: 0,
};
}
function SerialPanelView({ shell }: PanelProps) {
return (
<SerialPanel
onConnect={(config) => {
shell.openTab(buildPseudoHost(config), "serial", {
forceNewTab: true,
data: { serialConfig: config },
});
}}
/>
);
}
function SerialTabView({ tab, isVisible, handleRef }: TabProps) {
const config = tab.data?.serialConfig as SerialConfig | undefined;
if (!config) return null;
return (
<Serial
ref={handleRef as Ref<SerialHandle>}
config={config}
isVisible={isVisible}
instanceId={tab.id}
/>
);
}
export function activate(app: TermixApp): void {
setSerialWsUrl(app.wsUrl);
app.onDispose(() => setSerialWsUrl(null));
app.registerRailItem({
id: "serial",
icon: Usb,
titleKey: "nav.serial",
after: "quick-connect",
separatorAfter: true,
});
app.registerPanel("serial", SerialPanelView);
app.registerTab("serial", SerialTabView, {
icon: Usb,
titleKey: "nav.serial",
session: true,
// A physical device attached to this machine, not a remote connection
// another user could join.
shareable: false,
// Reconnecting to a COM port automatically after a restart is not a
// workflow anyone asked for, and there's no host to restore against
// anyway, so a serial tab is a local, ephemeral session like the
// desktop app's local terminal rather than something workspaces saves.
inLayouts: false,
});
}
+23
View File
@@ -0,0 +1,23 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
/**
* The live app.wsUrl, for components that are defined at module scope rather
* than inside activate(). Set on activate, cleared on dispose, the same
* pattern the tunnels plugin uses for app.api.
*/
let wsUrl: TermixApp["wsUrl"] | null = null;
export function setSerialWsUrl(next: TermixApp["wsUrl"] | null): void {
wsUrl = next;
}
/**
* The console's WebSocket URL and auth subprotocols. No origin option: a
* serial device is physically attached to this desktop machine, so it
* always dials the embedded local backend rather than a connected remote
* sync server (app.wsUrl's default when no origin is given).
*/
export async function resolveSerialWsUrl() {
if (!wsUrl) return null;
return wsUrl("/console");
}
+15
View File
@@ -0,0 +1,15 @@
export type SerialConfig = {
path: string;
baudRate: number;
dataBits: 5 | 6 | 7 | 8;
stopBits: 1 | 2;
parity: "none" | "even" | "odd";
};
export interface SerialHandle {
connect: () => void;
disconnect: () => void;
reconnect: () => void;
isConnected: () => boolean;
sendInput: (data: string) => void;
}
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
import { activate } from "../../src/backend/index.js";
const manifest = manifestJson as unknown as PluginManifest;
describe("serial activate", () => {
it("mounts the console WS route at /console with every declared capability", async () => {
const mock: MockPluginContext = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities,
});
await activate(mock.ctx);
expect(mock.wsRoutes).toEqual([{ path: "/console", raw: false }]);
});
it("refuses to mount without network:serve", async () => {
const mock: MockPluginContext = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: manifest.capabilities.filter((c) => c !== "network:serve"),
});
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
PluginCapabilityError,
);
});
});
@@ -0,0 +1,187 @@
import { EventEmitter } from "node:events";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
createMockCtx,
type MockPluginContext,
} from "@termix/plugin-sdk/testing";
import type { PluginWebSocketConnection } from "@termix/plugin-sdk/backend";
import { createSerialSession } from "../../src/backend/session.js";
// vi.mock's factory is hoisted above every import, including node:events, so
// FakeSerialPort gets its own minimal emitter rather than extending it.
const { listMock, openMock, FakeSerialPort } = vi.hoisted(() => {
const listMock = vi.fn(async () => [{ path: "/dev/ttyUSB0" }]);
const openMock = vi.fn();
class FakeSerialPort {
isOpen = false;
private listeners = new Map<string, Set<(...args: unknown[]) => void>>();
constructor(public options: Record<string, unknown>) {}
on(event: string, listener: (...args: unknown[]) => void) {
const set = this.listeners.get(event) ?? new Set();
set.add(listener);
this.listeners.set(event, set);
return this;
}
private emit(event: string, ...args: unknown[]) {
for (const listener of this.listeners.get(event) ?? []) listener(...args);
}
open(cb: (err?: Error) => void) {
openMock(this.options);
this.isOpen = true;
cb();
}
write(_data: Buffer, cb: (err?: Error) => void) {
cb();
}
close() {
this.isOpen = false;
this.emit("close");
}
static list = listMock;
}
return { listMock, openMock, FakeSerialPort };
});
vi.mock("serialport", () => ({ SerialPort: FakeSerialPort }));
class FakeSocket extends EventEmitter {
readyState = 1;
OPEN = 1;
sent: unknown[] = [];
send(msg: string) {
this.sent.push(JSON.parse(msg));
}
close(_code?: number, _reason?: string) {
this.readyState = 3;
}
}
function fakeConnection(
socket: FakeSocket,
isDataUnlocked: () => boolean = () => true,
): PluginWebSocketConnection {
return {
userId: "user-1",
request: {},
socket,
isDataUnlocked,
} as unknown as PluginWebSocketConnection;
}
async function deliver(socket: FakeSocket, message: object) {
socket.emit("message", Buffer.from(JSON.stringify(message)));
// Let the async message handler's microtasks settle.
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("serial session", () => {
let mock: MockPluginContext;
beforeEach(() => {
listMock.mockClear();
openMock.mockClear();
});
it("refuses list_ports and connect without device:serial", async () => {
mock = createMockCtx({ pluginId: "serial", capabilities: [] });
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket));
await deliver(socket, { type: "list_ports" });
expect(listMock).not.toHaveBeenCalled();
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
socket.sent.length = 0;
await deliver(socket, {
type: "connect",
data: { path: "/dev/ttyUSB0", baudRate: 9600 },
});
expect(openMock).not.toHaveBeenCalled();
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
});
it("lists ports once granted device:serial", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket));
await deliver(socket, { type: "list_ports" });
expect(listMock).toHaveBeenCalledTimes(1);
expect(socket.sent).toEqual([
{ type: "ports_list", data: [{ path: "/dev/ttyUSB0" }] },
]);
});
it("opens, writes to and closes a port through connect/input/disconnect", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket));
await deliver(socket, {
type: "connect",
data: { path: "/dev/ttyUSB0", baudRate: 115200 },
});
expect(openMock).toHaveBeenCalledWith(
expect.objectContaining({ path: "/dev/ttyUSB0", baudRate: 115200 }),
);
expect(socket.sent).toContainEqual({ type: "connected" });
socket.sent.length = 0;
await deliver(socket, { type: "input", data: "AT\r\n" });
// write() succeeds silently; nothing is echoed back until the port emits data.
socket.sent.length = 0;
await deliver(socket, { type: "disconnect" });
expect(socket.sent).toContainEqual({ type: "disconnected" });
});
it("rejects a connect with no path or baud rate without touching the port", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket));
await deliver(socket, { type: "connect", data: { path: "" } });
expect(openMock).not.toHaveBeenCalled();
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
});
it("closes the socket at connect time when data is locked", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
await createSerialSession(mock.ctx)(fakeConnection(socket, () => false));
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
});
it("refuses a message once data locks mid-session", async () => {
mock = createMockCtx({
pluginId: "serial",
capabilities: ["network:serve", "device:serial"],
});
const socket = new FakeSocket();
let unlocked = true;
await createSerialSession(mock.ctx)(fakeConnection(socket, () => unlocked));
unlocked = false;
await deliver(socket, { type: "list_ports" });
expect(listMock).not.toHaveBeenCalled();
expect(socket.sent).toEqual([expect.objectContaining({ type: "error" })]);
});
});
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
const declared = (views?: Array<{ id: string }>) =>
(views ?? []).map((view) => view.id).sort();
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("activates and registers only views its manifest declares", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const contributes = manifest.contributes as unknown as Record<
string,
Array<{ id: string }> | undefined
>;
const tabs = declared(contributes.tabs);
const panels = [...declared(contributes.panels), ...tabs];
expect(rendered.registered.tabs()).toEqual(["serial"]);
for (const id of rendered.registered.tabs()) expect(tabs).toContain(id);
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
"serial",
);
});
it("removes everything it registered on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.tabs()).toEqual([]);
expect(app.registered.panels()).toEqual([]);
expect(app.registered.railItems()).toEqual([]);
});
it("renders the connect form in the rail panel", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
rendered.renderPanel("serial", { active: true });
// jsdom has no Web Serial API and isn't Electron, so the panel falls
// back to its "not supported" message rather than a live form.
expect(
await screen.findByText(locales.serial.notSupportedTitle),
).toBeTruthy();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);
-195
View File
@@ -1,195 +0,0 @@
import { getErrorMessage } from "../utils/error-message.js";
import { WebSocketServer, WebSocket, type RawData } from "ws";
import { SerialPort } from "serialport";
import { AuthManager } from "../utils/auth-manager.js";
import { DataCrypto } from "../utils/data-crypto.js";
import { sshLogger } from "../utils/logger.js";
import { parseWsMessage } from "../utils/ws-message.js";
import { extractWebSocketToken } from "../utils/ws-auth.js";
interface SerialConnectData {
path: string;
baudRate: number;
dataBits?: 5 | 6 | 7 | 8;
stopBits?: 1 | 2;
parity?: "none" | "even" | "odd";
}
const authManager = AuthManager.getInstance();
const wss = new WebSocketServer({ host: "127.0.0.1", port: 30011 });
wss.on("error", (error) => {
sshLogger.error("Serial WebSocket server error", error, {
operation: "wss_error",
});
});
wss.on("connection", async (ws: WebSocket, req) => {
let userId: string | undefined;
try {
const token = extractWebSocketToken(req);
if (!token) {
ws.close(1008, "Authentication required");
return;
}
const payload = await authManager.verifyJWTToken(token);
if (!payload?.userId || payload.pendingTOTP) {
ws.close(1008, "Authentication required");
return;
}
userId = payload.userId;
} catch {
ws.close(1008, "Authentication required");
return;
}
const dataKey = DataCrypto.getUserDataKey(userId);
if (!dataKey) {
ws.send(JSON.stringify({ type: "error", data: "Data locked" }));
ws.close(1008, "Data access required");
return;
}
let port: SerialPort | null = null;
const send = (msg: object) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
};
const cleanup = () => {
if (port?.isOpen) {
port.close();
}
port = null;
};
ws.on("message", async (raw: RawData) => {
let type: string;
let data: unknown;
try {
({ type, data } = parseWsMessage(raw));
} catch {
return;
}
try {
switch (type) {
case "list_ports": {
try {
const ports = await SerialPort.list();
send({ type: "ports_list", data: ports });
} catch (err) {
send({
type: "error",
data: getErrorMessage(err, "Failed to list ports"),
});
}
break;
}
case "connect": {
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 {
port = new SerialPort({
path: cfg.path,
baudRate: cfg.baudRate,
dataBits: cfg.dataBits ?? 8,
stopBits: cfg.stopBits ?? 1,
parity: cfg.parity ?? "none",
autoOpen: false,
});
port.open((err) => {
if (err) {
sshLogger.error("Serial port open failed", err, {
operation: "serial_open",
path: cfg.path,
userId,
});
send({ type: "error", data: err.message });
port = null;
return;
}
sshLogger.info("Serial port opened", {
operation: "serial_open",
path: cfg.path,
baudRate: cfg.baudRate,
userId,
});
send({ type: "connected" });
});
port.on("data", (chunk: Buffer) => {
send({ type: "data", data: chunk.toString("binary") });
});
port.on("error", (err) => {
send({ type: "error", data: err.message });
});
port.on("close", () => {
send({ type: "disconnected" });
port = null;
});
} catch (err) {
send({
type: "error",
data: getErrorMessage(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) {
sshLogger.error("Error handling serial WebSocket message", err, {
operation: "serial_message_handler_error",
userId,
messageType: type,
});
send({ type: "error", data: "Failed to process message" });
}
});
ws.on("close", () => {
cleanup();
});
ws.on("error", () => {
cleanup();
});
});
+27 -1
View File
@@ -26,7 +26,7 @@ import * as serviceRegistry from "./service-registry.js";
import type { ServiceRegistration } from "./service-registry.js";
import * as secretRegistry from "./secret-registry.js";
import type { SecretRegistration } from "./secret-registry.js";
import { assertCapability } from "./permissions.js";
import { assertCapability, hasCapability } from "./permissions.js";
import { getActor, runAsActor } from "./actor.js";
import { DisposableBag } from "./disposables.js";
import {
@@ -322,6 +322,27 @@ export function createPluginContext(
},
);
const capabilitiesRequire = async (capability: string) => {
try {
await assertCapability(pluginId, capability, declared);
} catch (error) {
await writeAudit(
manifest,
{ action: "capability_require", details: () => capability },
{
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
},
);
throw error;
}
await writeAudit(
manifest,
{ action: "capability_require", details: () => capability },
{ success: true },
);
};
return {
pluginId,
manifest,
@@ -626,6 +647,11 @@ export function createPluginContext(
require: (permission) => createRbacMiddleware(manifest, permission),
},
capabilities: {
has: (capability) => hasCapability(pluginId, capability, declared),
require: capabilitiesRequire,
},
disposables: {
add: (dispose) => {
handle.bag.add(dispose, "plugin resource");
+6 -6
View File
@@ -309,12 +309,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// plugins/ssh-terminal, plugins/docker, plugins/host-metrics,
// plugins/file-manager and plugins/tmux-monitor.
// Every other plugin (AI, Proxmox, Remote Desktop, Fleets, Automations,
// Network Topology, Workspaces, Web Endpoint, Tunnels) is absent for the
// same reason: each one serves its routes under /plugin-api/<id>/ through
// ctx.http on activate, so disabling it answers 503 instead of leaving a
// dead import here. Automations' scheduler and tunnel autostart also
// start from their own activate() rather than here.
await import("./hosts/serial.js");
// Network Topology, Workspaces, Web Endpoint, Tunnels, Serial) is absent
// for the same reason: each one serves its routes under /plugin-api/<id>/
// (or a WS route under /plugin-ws/<id>/) through ctx on activate, so
// disabling it answers 503 instead of leaving a dead import here.
// Automations' scheduler and tunnel autostart also start from their own
// activate() rather than here.
await import("./services/dashboard.js");
await import("./services/homepage.js");
@@ -0,0 +1,106 @@
/**
* ctx.capabilities: a generic check for privileged code no ctx method wraps,
* used first by the serial plugin to gate opening a physical device. Every
* `require` call is audited, allowed or refused, the same as a guarded ctx
* method.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
return {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
}
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = manifestFor(pluginId, capabilities);
const handle = createPluginHandle(pluginId, { activate: () => {} });
return { ctx: createPluginContext(manifest, handle), handle };
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
invalidatePluginPermissionCache();
});
describe("ctx.capabilities", () => {
it("has() is false when the manifest never declared the capability", async () => {
grants.set("demo", ["device:serial"]);
const { ctx } = contextFor("demo", []);
await expect(ctx.capabilities.has("device:serial")).resolves.toBe(false);
});
it("has() is false when declared but not granted", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", ["device:serial"]);
await expect(ctx.capabilities.has("device:serial")).resolves.toBe(false);
});
it("has() is true when declared and granted", async () => {
grants.set("demo", ["device:serial"]);
const { ctx } = contextFor("demo", ["device:serial"]);
await expect(ctx.capabilities.has("device:serial")).resolves.toBe(true);
});
it("require() throws and audits a refusal without the capability", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(ctx.capabilities.require("device:serial")).rejects.toThrow(
/device:serial/,
);
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_capability_require",
success: false,
});
});
it("require() resolves and audits success when granted", async () => {
grants.set("demo", ["device:serial"]);
const { ctx } = contextFor("demo", ["device:serial"]);
await expect(
ctx.capabilities.require("device:serial"),
).resolves.toBeUndefined();
expect(auditEntries.at(-1)).toMatchObject({
action: "plugin_capability_require",
success: true,
});
});
});
-4
View File
@@ -30,10 +30,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
url: "http://localhost:30006",
description: "Dashboard server",
},
{
url: "http://localhost:30011",
description: "Serial connection server",
},
],
components: {
securitySchemes: {
+1 -12
View File
@@ -292,14 +292,6 @@ export type KnownTabType =
*/
export type TabType = KnownTabType | (string & {});
export type SerialConfig = {
path: string;
baudRate: number;
dataBits: 5 | 6 | 7 | 8;
stopBits: 1 | 2;
parity: "none" | "even" | "odd";
};
export type Tab = {
id: string;
instanceId: string;
@@ -319,7 +311,6 @@ export type Tab = {
data?: Record<string, unknown>;
/** Which collab room a collab tab is showing. */
collabRoomId?: string;
serialConfig?: SerialConfig;
/** Present only on a split-screen container tab. Pane ids reference live child tabs. */
splitConfig?: SplitTabConfig;
/** Hides this session from the top-level tab bar while it belongs to a split tab. */
@@ -423,7 +414,7 @@ export type WorkspaceTabSnapshot = {
/** Stable key within the saved tab list, not the live Tab.id (which is regenerated on every open). */
slotId: string;
type: TabType;
/** Set for host-bound tab types, resolved by Host.syncId on apply. Never set for "serial". */
/** Set for host-bound tab types, resolved by Host.syncId on apply. */
hostSyncId?: string | null;
/** Denormalized snapshot for display and graceful-skip messaging if the host is later deleted. */
hostNameSnapshot?: string | null;
@@ -435,8 +426,6 @@ export type WorkspaceTabSnapshot = {
fleetId?: number;
/** The tab's plugin payload. */
data?: Record<string, unknown>;
/** Only present when type === "serial". Fully self-contained, no host resolution needed. */
serialConfig?: SerialConfig;
};
/** One dock's arrangement. `view` is a RailView, or null when the dock is closed. */
-70
View File
@@ -66,9 +66,6 @@ const QuickConnectPanel = lazy(() =>
default: m.QuickConnectPanel,
})),
);
const SerialPanel = lazy(() =>
import("@/sidebar/SerialPanel").then((m) => ({ default: m.SerialPanel })),
);
const SplitScreenPanel = lazy(() =>
import("@/sidebar/SplitScreenPanel").then((m) => ({
default: m.SplitScreenPanel,
@@ -146,7 +143,6 @@ import type {
HostFolder,
ThemeId,
FontSizeId,
SerialConfig,
WorkspacePayload,
} from "@/types/ui-types";
import { applyAccentColor, applyFontSize, PANE_COUNTS } from "@/lib/theme";
@@ -1263,12 +1259,6 @@ export function AppShell({
continue;
}
if (target.kind === "serial" && snapshot.serialConfig) {
const newTabId = openSerialTab(snapshot.serialConfig);
slotIdToNewTabId.set(snapshot.slotId, newTabId);
continue;
}
if (target.kind === "singleton") {
openSingletonTab(
snapshot.type,
@@ -1562,7 +1552,6 @@ export function AppShell({
savedLabel?: string;
initialFilePath?: string;
initialPath?: string;
serialConfig?: SerialConfig;
joinSharedSessionId?: string | null;
joinShareId?: string | null;
collabRoomId?: string;
@@ -1600,7 +1589,6 @@ export function AppShell({
const savedLabel = restore?.savedLabel;
const initialFilePath = restore?.initialFilePath;
const initialPath = restore?.initialPath;
const serialConfig = restore?.serialConfig;
const joinSharedSessionId = restore?.joinSharedSessionId ?? null;
const joinShareId = restore?.joinShareId ?? null;
// A saved label that doesn't match the bare host name or the auto-numbered pattern is a custom label
@@ -1628,7 +1616,6 @@ export function AppShell({
joinShareId,
initialFilePath,
initialPath,
serialConfig,
collabRoomId: restore?.collabRoomId,
},
];
@@ -1667,7 +1654,6 @@ export function AppShell({
joinShareId,
initialFilePath,
initialPath,
serialConfig,
collabRoomId: restore?.collabRoomId,
data: options?.data,
},
@@ -1717,53 +1703,6 @@ export function AppShell({
[loadHosts, t],
);
function openSerialTab(config: SerialConfig): string {
const pseudoHost: Host = {
id: `serial-${Date.now()}`,
name: config.path
? `${config.path} (${config.baudRate})`
: `Serial (${config.baudRate})`,
username: "",
ip: "",
port: 0,
folder: "",
online: false,
cpu: null,
ram: null,
lastAccess: new Date().toISOString(),
authType: "none",
enableTerminal: false,
enableCommandHistory: false,
enableTunnel: false,
enableFileManager: false,
enableDocker: false,
enableProxmox: false,
enableProxmoxStats: false,
enableTmuxMonitor: false,
enableTerminalToolbar: false,
enableAiAssistant: false,
enableSsh: false,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: 22,
rdpPort: 3389,
vncPort: 5900,
telnetPort: 23,
serverTunnels: [],
quickActions: [],
};
const instanceId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
return openTab(pseudoHost, "serial", {
instanceId,
restoredSessionId: null,
serialConfig: config,
});
}
/** A tab type that opens a fresh tab every time (a local shell). */
function openMultiInstanceTab(type: TabType): string {
const instanceId =
@@ -2500,15 +2439,6 @@ export function AppShell({
</div>
)}
{railView === "serial" && (
<SerialPanel
onConnect={(config) => {
openSerialTab(config);
if (isMobile) setSidebarOpen(false);
}}
/>
)}
{railView === "quick-connect" && (
<QuickConnectPanel
onConnect={(host, type) => {
-6
View File
@@ -1,6 +0,0 @@
export interface SerialHandle {
connect: () => void;
disconnect: () => void;
isConnected: () => boolean;
sendInput: (data: string) => void;
}
+8 -9
View File
@@ -11,13 +11,15 @@ interface OriginResolvableHost {
const GUACAMOLE_CONNECTION_TYPES = new Set(["rdp", "vnc", "telnet"]);
/**
* Resolves which backend a given host's interactive connection (SSH,
* Docker console, Serial, RDP/VNC/Telnet) should dial: the desktop app's
* embedded local backend, or a connected remote sync server.
* Resolves which backend a given host's interactive connection (SSH, Docker
* console, RDP/VNC/Telnet) should dial: the desktop app's embedded local
* backend, or a connected remote sync server. A plugin whose connection is
* always local regardless of this setting (serial: the hardware is
* physically attached to this desktop machine) skips this helper entirely
* and calls app.wsUrl() without an origin option, which defaults to "local".
*
* Serial always resolves to "local" -- the hardware is physically attached
* to this desktop machine. Everything else follows the host's own override
* if set, falling back to the desktop-wide default.
* Everything else follows the host's own override if set, falling back to
* the desktop-wide default.
*
* RDP/VNC/Telnet are the exception to that fallback: left on Default they
* resolve to "remote" rather than following the desktop-wide setting. They
@@ -30,9 +32,6 @@ const GUACAMOLE_CONNECTION_TYPES = new Set(["rdp", "vnc", "telnet"]);
export async function resolveConnectionOrigin(
host: OriginResolvableHost,
): Promise<ConnectionOrigin> {
if (host.connectionType === "serial") {
return "local";
}
if (!isElectron()) {
return "local";
}
+4 -25
View File
@@ -709,7 +709,6 @@
"hostManager": "Host Manager",
"credentials": "Credentials",
"connections": "Connections",
"serial": "Serial",
"keepSideMenuOpen": "Keep Menu Open",
"collapseSideMenu": "Collapse Menu",
"roleAdministrator": "Administrator",
@@ -3092,30 +3091,6 @@
"openProxmoxStats": "Open Proxmox Stats",
"openTmuxMonitor": "Open Tmux Monitor"
},
"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."
},
"alerts": {
"addChannel": "Add Channel",
"editChannel": "Edit Channel",
@@ -3256,6 +3231,10 @@
"title": "Manage certificates",
"consequence": "It can request and replace the server certificate."
},
"device:serial": {
"title": "Access serial and USB devices",
"consequence": "It can open a physical serial port on the machine running Termix."
},
"hosts:write": {
"title": "Create and change hosts",
"consequence": "It can add hosts and edit the ones you already have."
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Gasheerbestuurder",
"credentials": "Geloofsbriewe",
"connections": "Verbindings",
"serial": "Reeks",
"keepSideMenuOpen": "Hou Kieslys Oop",
"collapseSideMenu": "Vou Kieslys in",
"roleAdministrator": "Administrateur",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Maak Proxmox-statistieke oop",
"openTmuxMonitor": "Maak Tmux-monitor oop"
},
"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."
},
"alerts": {
"addChannel": "Voeg kanaal by",
"editChannel": "Wysig kanaal",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "مدير المضيفين",
"credentials": "بيانات الاعتماد",
"connections": "الاتصالات",
"serial": "تسلسلي",
"keepSideMenuOpen": "أبقِ قائمة الطعام مفتوحة",
"collapseSideMenu": "قائمة قابلة للطي",
"roleAdministrator": "مسؤول",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "افتح إحصائيات Proxmox",
"openTmuxMonitor": "افتح شاشة Tmux"
},
"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 وسيفتح متصفحك نافذة اختيار المنفذ لتحديد الجهاز."
},
"alerts": {
"addChannel": "إضافة قناة",
"editChannel": "تعديل القناة",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Мениджър на домакини",
"credentials": "Пълномощия",
"connections": "Връзки",
"serial": "Сериен",
"keepSideMenuOpen": "Запази менюто отворено",
"collapseSideMenu": "Свиване на менюто",
"roleAdministrator": "Администратор",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Отворете статистиката на Proxmox",
"openTmuxMonitor": "Отворете Tmux монитора"
},
"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": "Щракнете върху „Свързване“ и браузърът ви ще отвори инструмент за избор на порт, за да изберете устройството."
},
"alerts": {
"addChannel": "Добавяне на канал",
"editChannel": "Редактиране на канал",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "হোস্ট ম্যানেজার",
"credentials": "যোগ্যতা",
"connections": "সংযোগ",
"serial": "সিরিয়াল",
"keepSideMenuOpen": "মেনু খোলা রাখুন",
"collapseSideMenu": "মেনু সংকুচিত করুন",
"roleAdministrator": "প্রশাসক",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "ওপেন প্রক্সমক্স স্ট্যাটস",
"openTmuxMonitor": "ওপেন Tmux মনিটর"
},
"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": "কানেক্ট-এ ক্লিক করলে আপনার ব্রাউজারে ডিভাইসটি বেছে নেওয়ার জন্য একটি পোর্ট পিকার খুলবে।"
},
"alerts": {
"addChannel": "চ্যানেল যোগ করুন",
"editChannel": "চ্যানেল সম্পাদনা করুন",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Gestor d'amfitrions",
"credentials": "Credencials",
"connections": "Connexions",
"serial": "Sèrie",
"keepSideMenuOpen": "Mantenir el menú obert",
"collapseSideMenu": "Menú replegable",
"roleAdministrator": "Administrador/a",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Obre les estadístiques de Proxmox",
"openTmuxMonitor": "Obre el monitor Tmux"
},
"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."
},
"alerts": {
"addChannel": "Afegeix un canal",
"editChannel": "Edita el canal",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Hostitelský manažer",
"credentials": "Pověření",
"connections": "Připojení",
"serial": "Seriál",
"keepSideMenuOpen": "Ponechat nabídku otevřenou",
"collapseSideMenu": "Sbalit nabídku",
"roleAdministrator": "Správce",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Otevřít statistiky Proxmoxu",
"openTmuxMonitor": "Otevřít monitor Tmux"
},
"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í."
},
"alerts": {
"addChannel": "Přidat kanál",
"editChannel": "Upravit kanál",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Værtsadministrator",
"credentials": "Akkreditiver",
"connections": "Forbindelser",
"serial": "Seriel",
"keepSideMenuOpen": "Hold menuen åben",
"collapseSideMenu": "Skjul menu",
"roleAdministrator": "Administrator",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Åbn Proxmox-statistik",
"openTmuxMonitor": "Åbn Tmux-skærmen"
},
"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."
},
"alerts": {
"addChannel": "Tilføj kanal",
"editChannel": "Rediger kanal",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Host-Manager",
"credentials": "Zugangsdaten",
"connections": "Verbindungen",
"serial": "Seriell",
"keepSideMenuOpen": "Menü geöffnet lassen",
"collapseSideMenu": "Menü ausklappen",
"roleAdministrator": "Administrator",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Proxmox-Statistiken öffnen",
"openTmuxMonitor": "Tmux-Monitor öffnen"
},
"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."
},
"alerts": {
"addChannel": "Kanal hinzufügen",
"editChannel": "Kanal bearbeiten",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Διευθυντής Υποδοχής",
"credentials": "Διαπιστευτήρια",
"connections": "Συνδέσεις",
"serial": "Σειράς",
"keepSideMenuOpen": "Διατήρηση μενού ανοιχτού",
"collapseSideMenu": "Σύμπτυξη μενού",
"roleAdministrator": "Διαχειριστής",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Άνοιγμα στατιστικών Proxmox",
"openTmuxMonitor": "Άνοιγμα παρακολούθησης Tmux"
},
"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": "Κάντε κλικ στην επιλογή Σύνδεση και το πρόγραμμα περιήγησής σας θα ανοίξει ένα πρόγραμμα επιλογής θυρών για να επιλέξετε τη συσκευή."
},
"alerts": {
"addChannel": "Προσθήκη καναλιού",
"editChannel": "Επεξεργασία καναλιού",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Gestor de hosts",
"credentials": "Credenciales",
"connections": "Conexiones",
"serial": "Serial",
"keepSideMenuOpen": "Mantener el menú abierto",
"collapseSideMenu": "Contraer menú",
"roleAdministrator": "Administrador",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Abrir estadísticas de Proxmox",
"openTmuxMonitor": "Abrir el monitor de Tmux"
},
"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."
},
"alerts": {
"addChannel": "Añadir canal",
"editChannel": "Editar canal",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Isäntäpäällikkö",
"credentials": "Valtakirjat",
"connections": "Yhteydet",
"serial": "Sarjanumero",
"keepSideMenuOpen": "Pidä valikko auki",
"collapseSideMenu": "Pienennä valikko",
"roleAdministrator": "Ylläpitäjä",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Avaa Proxmox-tilastot",
"openTmuxMonitor": "Avaa Tmux-näyttö"
},
"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."
},
"alerts": {
"addChannel": "Lisää kanava",
"editChannel": "Muokkaa kanavaa",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Gestionnaire d'hôtes",
"credentials": "Identifiants",
"connections": "Connexions",
"serial": "Série",
"keepSideMenuOpen": "Garder le menu ouvert",
"collapseSideMenu": "Réduire le menu",
"roleAdministrator": "Administrateur",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Ouvrir les statistiques de Proxmox",
"openTmuxMonitor": "Moniteur Open Tmux"
},
"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."
},
"alerts": {
"addChannel": "Ajouter un canal",
"editChannel": "Modifier le canal",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "מנהל מארח",
"credentials": "אישורים",
"connections": "חיבורים",
"serial": "סִדרָתִי",
"keepSideMenuOpen": "שמור את התפריט פתוח",
"collapseSideMenu": "כווץ תפריט",
"roleAdministrator": "מְנַהֵל",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "פתח את סטטיסטיקות Proxmox",
"openTmuxMonitor": "פתח את צג Tmux"
},
"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": "לחץ על התחבר והדפדפן שלך יפתח בורר פורטים לבחירת המכשיר."
},
"alerts": {
"addChannel": "הוסף ערוץ",
"editChannel": "עריכת ערוץ",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "होस्ट प्रबंधक",
"credentials": "क्रेडेंशियल्स",
"connections": "कनेक्शन्स",
"serial": "सीरियल",
"keepSideMenuOpen": "मेनू खुला रखें",
"collapseSideMenu": "मेनू को छोटा करें",
"roleAdministrator": "प्रशासक",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "प्रोक्समॉक्स के आँकड़े खोलें",
"openTmuxMonitor": "ओपन टीएमएक्स मॉनिटर"
},
"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 पर क्लिक करें और आपका ब्राउज़र डिवाइस चुनने के लिए एक पोर्ट पिकर खोलेगा।"
},
"alerts": {
"addChannel": "चैनल जोड़ें",
"editChannel": "चैनल संपादित करें",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Házigazda-kezelő",
"credentials": "Hitelesítő adatok",
"connections": "Kapcsolatok",
"serial": "Sorozatszám",
"keepSideMenuOpen": "Menü nyitva tartása",
"collapseSideMenu": "Menü összecsukása",
"roleAdministrator": "Adminisztrátor",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Proxmox statisztikák megnyitása",
"openTmuxMonitor": "Tmux monitor megnyitása"
},
"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."
},
"alerts": {
"addChannel": "Csatorna hozzáadása",
"editChannel": "Csatorna szerkesztése",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Manajer Host",
"credentials": "Kredensial",
"connections": "Koneksi",
"serial": "Serial",
"keepSideMenuOpen": "Biarkan Menu Tetap Terbuka",
"collapseSideMenu": "Ciutkan Menu",
"roleAdministrator": "Administrator",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Buka Statistik Proxmox",
"openTmuxMonitor": "Buka Monitor Tmux"
},
"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."
},
"alerts": {
"addChannel": "Tambahkan Saluran",
"editChannel": "Edit Saluran",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Gestione host",
"credentials": "Credenziali",
"connections": "Connessioni",
"serial": "Seriale",
"keepSideMenuOpen": "Mantieni il menu aperto",
"collapseSideMenu": "Comprimi menu",
"roleAdministrator": "Amministratore",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Statistiche di Proxmox aperte",
"openTmuxMonitor": "Monitor Tmux aperto"
},
"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."
},
"alerts": {
"addChannel": "Aggiungi canale",
"editChannel": "Modifica canale",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "ホストマネージャー",
"credentials": "認証情報",
"connections": "接続",
"serial": "シリアル",
"keepSideMenuOpen": "メニューを開いたままにする",
"collapseSideMenu": "メニューを折りたたむ",
"roleAdministrator": "管理者",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Proxmox統計を開く",
"openTmuxMonitor": "Tmux Monitorを開く"
},
"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": "接続をクリックすると、ブラウザがポートピッカーを開き、デバイスを選択できます。"
},
"alerts": {
"addChannel": "チャンネルを追加",
"editChannel": "チャンネルを編集",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "호스트 관리자",
"credentials": "자격 증명",
"connections": "연결",
"serial": "시리얼",
"keepSideMenuOpen": "메뉴를 열어 두세요",
"collapseSideMenu": "메뉴 접기",
"roleAdministrator": "관리자",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Proxmox 통계 열기",
"openTmuxMonitor": "Tmux 모니터 열기"
},
"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": "연결 버튼을 클릭하면 브라우저에서 장치 선택을 위한 포트 선택기가 열립니다."
},
"alerts": {
"addChannel": "채널 추가",
"editChannel": "채널 편집",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Hostmanager",
"credentials": "Referenties",
"connections": "Verbindingen",
"serial": "Serieel",
"keepSideMenuOpen": "Menu open houden",
"collapseSideMenu": "Menu inklappen",
"roleAdministrator": "Beheerder",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Open Proxmox-statistieken",
"openTmuxMonitor": "Open Tmux Monitor"
},
"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."
},
"alerts": {
"addChannel": "Kanaal toevoegen",
"editChannel": "Bewerk kanaal",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Vertsadministrator",
"credentials": "Legitimasjon",
"connections": "Tilkoblinger",
"serial": "Seriell",
"keepSideMenuOpen": "Hold menyen åpen",
"collapseSideMenu": "Skjul menyen",
"roleAdministrator": "Administrator",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Åpne Proxmox-statistikk",
"openTmuxMonitor": "Åpne Tmux-skjermen"
},
"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."
},
"alerts": {
"addChannel": "Legg til kanal",
"editChannel": "Rediger kanal",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Menedżer hostów",
"credentials": "Poświadczenia",
"connections": "Połączenia",
"serial": "Port szeregowy",
"keepSideMenuOpen": "Zachowaj otwarte menu",
"collapseSideMenu": "Zwiń menu",
"roleAdministrator": "Administrator",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Otwórz statystyki Proxmox",
"openTmuxMonitor": "Otwórz monitor Tmux"
},
"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."
},
"alerts": {
"addChannel": "Dodaj kanał",
"editChannel": "Edytuj kanał",
-25
View File
@@ -713,7 +713,6 @@
"hostManager": "Gerente de Hospedagem",
"credentials": "Credenciais",
"connections": "Conexões",
"serial": "Serial",
"keepSideMenuOpen": "Mantenha o menu aberto",
"collapseSideMenu": "Recolher menu",
"roleAdministrator": "Administrador",
@@ -3055,30 +3054,6 @@
"openProxmoxStats": "Estatísticas do Open Proxmox",
"openTmuxMonitor": "Abra o monitor Tmux"
},
"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."
},
"alerts": {
"addChannel": "Adicionar canal",
"editChannel": "Editar canal",
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more