mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
release-2.9.0 (#1532)
* fix(macos): skip single-instance lock in Mac App Store builds (#1454)
fix(macos): skip single-instance lock in Mac App Store builds
* Fix/backend optional sharp (#1455)
Fix/backend optional sharp
* feat: issue #1218 (#1218)
https://github.com/Termix-SSH/Support/issues/1218
* feat: Add option to use saved global custom theme under Connection Defaults (#1209)
https://github.com/Termix-SSH/Support/issues/1209
* feat: Add Fleet Sharing functionality to the UI (#1228)
https://github.com/Termix-SSH/Support/issues/1228
* feat: Add a list view to the Docker management page (#1237)
https://github.com/Termix-SSH/Support/issues/1237
* feat: issue #1264 (#1264)
https://github.com/Termix-SSH/Support/issues/1264
* feat: 2FA With FortiToken (#1288)
https://github.com/Termix-SSH/Support/issues/1288
* feat: Allow to disable showing paths to folders (#1274)
https://github.com/Termix-SSH/Support/issues/1274
* feat: Sync Network Graph between Desktop and Remote Server (#1245)
https://github.com/Termix-SSH/Support/issues/1245
* feat: Version Number on offline servers (#1291)
https://github.com/Termix-SSH/Support/issues/1291
* feat: Support OrbStack Docker socket path on macOS / Fix "Docker is not installed" on mac... (#1302)
https://github.com/Termix-SSH/Support/issues/1302
* feat: Sidebar host click should focus existing tab instead of opening a new connection (#1289)
https://github.com/Termix-SSH/Support/issues/1289
* fix: Search in side bar shows overlapping hosts when grouped by tags (#1303)
https://github.com/Termix-SSH/Support/issues/1303
* fix: I can't send a file on its own. (#1304)
https://github.com/Termix-SSH/Support/issues/1304
* fix: sudo password not autofilling (#1248)
https://github.com/Termix-SSH/Support/issues/1248
* fix: node-MainThread (#1300)
https://github.com/Termix-SSH/Support/issues/1300
* fix: Tunnel authentication with shared credentials (#1295)
https://github.com/Termix-SSH/Support/issues/1295
* fix: host key updates not syncing, metrics 404 race on first connect
Host key writes never bumped updatedAt so sync never picked them up.
Also retry the first metrics fetch briefly instead of failing right away.
* chore: increment ver
* feat: add category and icon fields to plugin manifest schema
* ci(deps): bump the github-actions group with 2 updates (#1458)
Bumps the github-actions group with 2 updates: [crowdin/github-action](https://github.com/crowdin/github-action) and [actions/github-script](https://github.com/actions/github-script).
Updates `crowdin/github-action` from 2 to 3
- [Release notes](https://github.com/crowdin/github-action/releases)
- [Commits](https://github.com/crowdin/github-action/compare/v2...v3)
Updates `actions/github-script` from 7 to 9
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v9)
---
updated-dependencies:
- dependency-name: crowdin/github-action
dependency-version: '3'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
- dependency-name: actions/github-script
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: github-actions
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps-dev): bump the dev-patch-updates group with 13 updates (#1459)
Bumps the dev-patch-updates group with 13 updates:
| Package | From | To |
| --- | --- | --- |
| [@codemirror/commands](https://github.com/codemirror/commands) | `6.11.0` | `6.11.1` |
| [@codemirror/search](https://github.com/codemirror/search) | `6.7.1` | `6.7.2` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.9` | `6.43.12` |
| [@testing-library/dom](https://github.com/testing-library/dom-testing-library) | `10.4.1` | `10.4.2` |
| [@testing-library/react](https://github.com/testing-library/react-testing-library) | `16.3.2` | `16.3.3` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.5` | `14.6.7` |
| [@types/ssh2](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/ssh2) | `1.15.5` | `1.15.6` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.0` | `6.1.1` |
| [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.34.1` | `3.34.3` |
| [eslint-plugin-react-refresh](https://github.com/ArnaudBarre/eslint-plugin-react-refresh) | `0.5.4` | `0.5.7` |
| [i18next](https://github.com/i18next/i18next) | `26.4.0` | `26.4.2` |
| [prettier](https://github.com/prettier/prettier) | `3.9.6` | `3.9.8` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.12` | `17.0.14` |
Updates `@codemirror/commands` from 6.11.0 to 6.11.1
- [Changelog](https://github.com/codemirror/commands/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/commands/commits)
Updates `@codemirror/search` from 6.7.1 to 6.7.2
- [Changelog](https://github.com/codemirror/search/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/search/commits)
Updates `@codemirror/view` from 6.43.9 to 6.43.12
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)
Updates `@testing-library/dom` from 10.4.1 to 10.4.2
- [Release notes](https://github.com/testing-library/dom-testing-library/releases)
- [Changelog](https://github.com/testing-library/dom-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/dom-testing-library/compare/v10.4.1...v10.4.2)
Updates `@testing-library/react` from 16.3.2 to 16.3.3
- [Release notes](https://github.com/testing-library/react-testing-library/releases)
- [Changelog](https://github.com/testing-library/react-testing-library/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/react-testing-library/compare/v16.3.2...v16.3.3)
Updates `@testing-library/user-event` from 14.6.5 to 14.6.7
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.5...v14.6.7)
Updates `@types/ssh2` from 1.15.5 to 1.15.6
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/ssh2)
Updates `@vitejs/plugin-react` from 6.1.0 to 6.1.1
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.1/packages/plugin-react)
Updates `cytoscape` from 3.34.1 to 3.34.3
- [Release notes](https://github.com/cytoscape/cytoscape.js/releases)
- [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.34.1...v3.34.3)
Updates `eslint-plugin-react-refresh` from 0.5.4 to 0.5.7
- [Release notes](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/releases)
- [Changelog](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ArnaudBarre/eslint-plugin-react-refresh/compare/v0.5.4...v0.5.7)
Updates `i18next` from 26.4.0 to 26.4.2
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.4.0...v26.4.2)
Updates `prettier` from 3.9.6 to 3.9.8
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.9.6...3.9.8)
Updates `react-i18next` from 17.0.12 to 17.0.14
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v17.0.12...v17.0.14)
---
updated-dependencies:
- dependency-name: "@codemirror/commands"
dependency-version: 6.11.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/search"
dependency-version: 6.7.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@codemirror/view"
dependency-version: 6.43.12
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/dom"
dependency-version: 10.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/react"
dependency-version: 16.3.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@testing-library/user-event"
dependency-version: 14.6.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@types/ssh2"
dependency-version: 1.15.6
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: "@vitejs/plugin-react"
dependency-version: 6.1.1
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: cytoscape
dependency-version: 3.34.3
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: eslint-plugin-react-refresh
dependency-version: 0.5.7
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: i18next
dependency-version: 26.4.2
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: prettier
dependency-version: 3.9.8
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
- dependency-name: react-i18next
dependency-version: 17.0.14
dependency-type: direct:development
update-type: version-update:semver-patch
dependency-group: dev-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* chore(deps): bump the prod-patch-updates group with 6 updates (#1461)
Bumps the prod-patch-updates group with 6 updates:
| Package | From | To |
| --- | --- | --- |
| [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual) | `3.14.10` | `3.14.13` |
| [compression](https://github.com/expressjs/compression) | `1.8.1` | `1.8.2` |
| [jose](https://github.com/panva/jose) | `6.2.9` | `6.2.12` |
| [jszip](https://github.com/Stuk/jszip) | `3.10.1` | `3.10.2` |
| [socks](https://github.com/JoshGlazebrook/socks) | `2.8.9` | `2.8.10` |
| [undici](https://github.com/nodejs/undici) | `8.10.0` | `8.10.2` |
Updates `@tanstack/react-virtual` from 3.14.10 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/react-virtual@3.14.13/packages/react-virtual)
Updates `compression` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/expressjs/compression/releases)
- [Changelog](https://github.com/expressjs/compression/blob/master/HISTORY.md)
- [Commits](https://github.com/expressjs/compression/compare/v1.8.1...v1.8.2)
Updates `jose` from 6.2.9 to 6.2.12
- [Release notes](https://github.com/panva/jose/releases)
- [Changelog](https://github.com/panva/jose/blob/main/CHANGELOG.md)
- [Commits](https://github.com/panva/jose/compare/v6.2.9...v6.2.12)
Updates `jszip` from 3.10.1 to 3.10.2
- [Changelog](https://github.com/Stuk/jszip/blob/main/CHANGES.md)
- [Commits](https://github.com/Stuk/jszip/compare/v3.10.1...v3.10.2)
Updates `socks` from 2.8.9 to 2.8.10
- [Release notes](https://github.com/JoshGlazebrook/socks/releases)
- [Commits](https://github.com/JoshGlazebrook/socks/compare/2.8.9...2.8.10)
Updates `undici` from 8.10.0 to 8.10.2
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.10.0...v8.10.2)
---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
dependency-version: 3.14.13
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: compression
dependency-version: 1.8.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jose
dependency-version: 6.2.12
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: jszip
dependency-version: 3.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: socks
dependency-version: 2.8.10
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
- dependency-name: undici
dependency-version: 8.10.2
dependency-type: direct:production
update-type: version-update:semver-patch
dependency-group: prod-patch-updates
...
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
* feat: add plugin worker protocol, bootstrap and plugin_storage table
Worker-side ctx proxy plus the message envelope it talks over, and the
per-plugin key/value table behind ctx.storage with migrations for all
three dialects. Rows cascade with the plugin so uninstalling leaves
nothing behind.
* feat: add allowlisted host view for plugins
An allowlist rather than a list of fields to strip. A blocklist rots:
the moment a new secret-bearing column lands on SSHHost it starts
leaking silently. With an allowlist a new field stays invisible to
plugins until someone adds it here on purpose.
* feat: add plugin loader with manifest validation and crash restart
Validates an unpacked plugin, spawns a worker_threads worker and manages
activate/deactivate/crash-restart with backoff, disabling after three
tries.
The crash counter clears only after a plugin stays up past a stability
window, not on activation. Clearing it on activation let a plugin that
activates cleanly and then dies restart forever, because every attempt
looked like the first.
loader.ts documents what the worker boundary does and does not buy you.
Node's permission model is process-wide, not per-worker, so it cannot be
enabled for plugin workers alone and is not claimed here.
* feat: route internal events through a shared plugin event bus
Formalises three ad-hoc publish paths that already existed:
automation-events.ts, metrics/automation-bridge.ts and the
hostSessionStatus singleton, now the host.session.status topic.
Both invariants the old code relied on are preserved. Publishing stays
fire-and-forget, so a failing subscriber cannot disturb a metrics poll or
a host delete. The automations engine subscribes to the bus at scheduler
start instead of being imported ad hoc, which keeps the edge
one-directional: automations may import repositories, hosts modules must
not import automations.
* feat: add plugin ctx broker with permission gate and audit
Every ctx call a plugin makes is checked against its granted
capabilities, performed by the main thread, then audited. A capability
must be both declared in the manifest and granted in the database, so
widening a plugin's reach always needs a new manifest the user can see,
never just a database row.
Audit attribution is set by the broker, not the plugin. The plugin
supplies only the details, so it cannot forge the actor.
fix: stop plugin workers seeing plaintext credentials in error messages
A failing ctx.ssh.connect handed the worker the raw error from deep in
the SSH stack, which embeds the connect config. The worker received, in
full:
Authentication failed for root using password <SECRET>
where <SECRET> was the host's real plaintext password. Any plugin with
ssh.exec could read it by catching the error, defeating the whole point
of the handle-based ssh API.
Errors crossing the boundary are now sanitised. Only PluginFacingError
and PluginPermissionError, both authored here, reach a plugin verbatim;
anything else becomes a generic message and the real one goes to the
log. This uses a marker class rather than matching on message text,
because matching would start leaking again the moment an upstream error
happened to contain a familiar phrase.
Covered by credential-isolation.test.ts, which plants a sentinel in
every secret-bearing field and asserts it appears in no message the
worker ever receives.
* feat: dispatch plugin HTTP routes and wire the runtime into startup
ctx.http.route now registers a real router on the /plugin-api/:pluginId
dispatcher, replacing its stub 404. A plugin never touches the Express
req/res: it gets a plain summary and returns a plain object, so sockets
and session cookies stay on this side of the boundary. Authorization and
cookie headers are not forwarded.
Plugins load last in the start-up sequence, after the server is fully
wired, and are terminated before the database on shutdown so none can
outlive it. A plugin that fails to load cannot stop the backend.
fix: correct the nginx plugin-api location regex
The pattern was ^/plugin-api/(/.*)?$, which needs a double slash and so
never matched /plugin-api/<id>/<route>. Every other block in the file
uses ^/prefix(/.*)?$. Left alone, plugin routes would work in dev and
404 only behind Docker.
* fix: update stale SFTP transfer test for single file destination paths
The test still asserted transferToHost was called with the destination
directory "/srv". Commit 7727fa09 (#1304) deliberately changed a single
file transfer to send the full destination path including the filename,
so the correct expectation is "/srv/remote-1.txt". The code was right
and the test was left behind.
* feat: ship the ssh terminal as a first-party in-process plugin
Adds a hardcoded process:transport-owner tier so the terminal can own its
WebSocket server and ssh2 clients, which cannot cross the worker boundary.
* feat: ship docker as a first-party in-process plugin
Moves docker backend and frontend into plugins/docker/, alongside
ssh-terminal. Adds a host editor tab seam, a dashboard card registry,
and a build step to compile plugin backends into dist.
* feat: ship host metrics as a first-party in-process plugin
* fix: harden plugin auth and add per-request identity for ctx calls
Require auth on /plugin-api, gate plugin enable/grant/revoke routes
behind admin.plugins.manage, and thread the calling user through
worker ctx.hosts/ctx.ssh calls instead of always using the install
owner. Also adds the plugin permissions grant/revoke UI.
* feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a
small /ai dispatcher instead of its own port. Moves the ai-enabled and
ai-private-endpoints settings routes out of user-settings-routes.ts
into the plugin too, so it has no leftover core dependents.
* fix: remove compiled plugin JS left behind in plugins/ after build
copy-bundled-plugins.cjs compiled and rewrote plugin backend TS in
place, copied it to dist/plugins, but never deleted the .js it had
just emitted next to the .ts sources. Those files shadowed the real
source for vitest/tsx and had import paths rewritten for the dist
layout, so tests failed with "Cannot find module" once a local build
had run. Affects docker, host-metrics and ai, not just this one.
* feat: add a permission-gated backend service registry for plugins
* feat: add a permission-gated UI action registry and action slots for plugins
Moves the terminal toolbar's hardwired AI button into a slot the ssh-terminal
plugin offers and the ai plugin fills, gated on ai.services.use.
* feat: add cross-plugin secret references gated by the provider's RBAC permission
* feat: move proxmox discovery/import/sync into a first-party plugin
* feat: move remote desktop (RDP/VNC/Telnet) into a first-party plugin
Relocates guacamole backend/frontend source and tests into
plugins/remote-desktop, following the ssh-terminal/docker pattern.
* feat: wire remote-desktop plugin into core and rename i18n keys
Repoints core consumers (dispatcher, collab, session-sharing, tab
utils) at the plugin, adds isTabTypeAvailable gating for rdp/vnc/
telnet, widens hostCapability to accept an array, and renames the
guacamole i18n namespace to remoteDesktop.
* feat: move fleets into a first-party plugin
* feat: move automations into a first-party plugin
* feat: remove legacy alert rules, superseded by automations
Alert rules already auto-migrate into automations on boot and the old
engine stands down after. Deletes the rule/firing tables, routes, UI,
and AI tools; keeps notification channels since automations still use
them. Also fixes automations' notify step, which still called the
deleted repository.
* feat: move network topology into a first-party plugin
* feat: move tailscale device discovery and settings into a plugin
Adds plugins/tailscale/ with a devices sidebar tab, admin settings
section, and re-auth dialog. Host Metrics tailscale manager card and
SSH re-auth banner parsing stay in core.
* feat: move workspaces into a first-party plugin
* feat: move web endpoint into a first-party plugin
* feat: collapse plugin runtime to one tier behind a plugin SDK
* feat: make each plugin an npm workspace built by a termix-plugin CLI
* feat: give plugins their own tables, migrations and sync entities
* feat: serve plugin http and websockets from one mount instead of ports
* feat: namespace plugin permissions and keep them valid when a plugin is off
* feat: give plugins their own settings and one settings screen
* feat: load plugin frontends at runtime and remove plugin ids from the shell
* fix: mock the ai status fetch in its activate test
* feat: add auth extension points and one ssh connect pipeline
* feat: rebuild workspaces as the reference plugin
* fix: gate second factors after SSO/LDAP behind an admin setting
* feat: convert network-topology to a full plugin (B1)
* feat: move snippets backend and tables into a plugin (B2)
* feat: point core frontend snippets pickers at the plugin (B2)
* feat: add the snippets plugin (B2)
* feat: wire ai, automations, fleets and ssh-terminal to snippets (B2)
* feat: finish the tailscale plugin, including SSH auth (B3)
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* chore: update proxmox and host-metrics locale files for B5
* chore: regenerate drizzle migration journals for B5
* feat: add ctx.hosts create/update to the SDK, host-metrics drops proxmox
* feat: move proxmox node stats and collectors into the proxmox plugin
* feat: drop proxmox host columns, use plugin settings instead
* feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
* feat: convert file manager into a plugin
* feat: convert ssh tunnels into a plugin
* feat: finish the web-endpoint plugin, add ctx.desktop (B8)
* feat: move the terminal fully into the ssh-terminal plugin (B9)
* feat: convert tmux monitoring into a plugin (B10)
* feat: convert serial console into the serial plugin
* feat: move session sharing and collab rooms into a plugin (B12)
* feat: turn session recording into a plugin (B13)
* feat: finish the remote-desktop plugin (B14)
* feat: finish the host-metrics plugin, move host status into core (B16)
* feat: finish the docker plugin (B15)
* feat: finish the automations plugin (B17)
* feat: finish the ai plugin (B18)
* feat: finish the homepage plugin, move service links and canvas out of core (B19)
* feat: convert wake-on-lan into a plugin (B20)
* feat: convert secret-sources into a plugin (B20)
* feat: move totp and passkeys into plugins (C1)
* feat: move sso and ldap logins into plugins (C2)
* feat: move opkssh and warpgate into plugins (C3)
* feat: move step-ca into a plugin (C4)
* feat: move vault ssh signing into a plugin (C5)
* feat: move termix identity into a plugin (C6)
* feat: move acme certificate automation into a plugin (C7)
* fix: finish 2.9.0 plugin moves and get every check green
* chore: lock the plugin boundary and remove 2.9.0 leftovers
* fix: harden the plugin runtime after the 2.9.0 security review
* feat: add plugin packing, signing, pinned trust and tmxplug bundling
* test: prove 2.8 to 2.9.0 upgrade is lossless and fix what it caught
* fix: give bundled plugin backends a real require for CJS deps
* feat: improve UI loading time and jitter
* feat: show credential sharing inline in the sidebar like host sharing
* feat: tailscale add host and copy ip, fix outbound fetch hang and plugin ESM warning
* feat: unify alerts into one plugin, channels, rules and inbox
* feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
* fix: set blacksmith builder cache-key to stop broken gha cache fallback
* fix: link plugin-sdk workspace before npm ci in docker deps stage
* fix: link every plugin workspace before npm ci in docker deps stage
* fix(host-metrics): exclude image and firmware filesystems (#1469)
* fix(host-metrics): exclude image and firmware filesystems
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(hosts): normalize imported jump host references (#1470)
* fix(hosts): normalize imported jump host references
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(ci): build plugin SDK and repair MySQL settings migration (#1471)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent (#1476)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): keep inline rename consistent
* fix(dashboard): reuse translated host status labels (#1475)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(dashboard): reuse translated host status labels
* docs(api): clarify local and remote version fields (#1474)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* docs(api): clarify local and remote version fields
* fix(file-manager): preserve selected backgrounds on hover (#1473)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): preserve selection feedback on hover
* test(ui): await dialog focus cleanup before teardown
* fix(file-manager): restore Ctrl/Cmd+F file search (#1472)
* fix(ci): build plugin SDK before dependent checks
* fix(mysql): keep plugin settings scope indexes within limits
* test(mysql): refresh reviewed plugin upgrade SQL
* fix(file-manager): focus file search with Ctrl or Cmd F
* fix: keep rounded-full circular in production builds
* fix(file-manager): cancel uploads and stop remote writes (#1477)
* fix(file-manager): cancel uploads and stop remote writes
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): apply compact density to directory tree (#1478)
* fix(file-manager): apply compact density to directory tree
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): preserve font sizes in compact mode (#1480)
* fix(file-manager): preserve font sizes in compact mode
* test(css): exercise the configured PostCSS plugin chain
* fix(file-manager): prioritize file names in narrow lists (#1483)
* fix(file-manager): prioritize file names in narrow lists
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(remote-desktop): probe the saved host guacd endpoint (#1481)
* fix(remote-desktop): probe the saved host guacd endpoint
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* fix(homepage): check allowlisted private service reachability (#1479)
* fix(homepage): check allowlisted private service reachability
* test(css): exercise the configured PostCSS plugin chain
* fix(remote-desktop): stop unsolicited clipboard read prompts (#1482)
* fix(remote-desktop): read clipboard only on explicit paste
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* chore: simplify plugin readmes, changelogs and manifest descriptions
* fix: keep plugins loaded after signing in from the login screen
* fix(file-manager): accept OS file drops over existing rows (#1484)
* fix(file-manager): accept OS file drops over existing rows
* test(file-manager): target the file label in drop tests
* test(css): exercise the configured PostCSS plugin chain
* test(identity): preserve full Ed25519 public keys in fixtures
* test(remote-desktop): always mutate ciphertext in tamper test
* fix(file-manager): pipeline SFTP download reads (#1485)
* feat: add plugin sdk build to beta release
* fix: make link-store secret check immune to random base64 matches
* fix(ldap): preserve the directory-provided bind DN (#1486)
* fix: compute beta changes across dev branches and move the beta tag
* chore: untrack plugin sdk ARCHITECTURE.md
* chore: remove references to plugin sdk ARCHITECTURE.md
* feat: merge host editor terminal tabs into one owned by ssh-terminal
* feat: add descriptions to plugin host enable toggles
* fix(file-manager): show download progress in file viewers (#1487)
* fix(electron): signal backend readiness over IPC (#1488)
* fix(split-screen): allow layout creation without randomUUID (#1490)
* fix(split-screen): allow layout creation without randomUUID
* test(credentials): avoid flaky Ed25519 key generation
* fix(sidebar): remeasure visible rows after density changes (#1489)
* fix(sidebar): remeasure visible rows after density changes
* test(credentials): avoid flaky Ed25519 key generation
* feat(split-screen): redesign split screen with free-form panes and pane picker
* feat(sidebar): add host click behavior setting, default to always opening a new tab
* feat(snippets): redesign panel to match the host list
* fix(session-recording): stop session logs panel from refetching in a loop
* feat(snippets): bring back sharing, target hosts and drag reorder
* feat(ssh-terminal): add toolbar position, start state, display and fade host settings
* feat(ssh-terminal): add host setting to turn off password prompt auto-fill
* fix(ai): stop streams getting cut off and keep tool steps across messages
* feat: improve quick connect system using plugin api
* fix: proxmox guest sync, startup snippet sync, drop dead cloudflare ssh transport
* feat: move terminal settings, look and ssh tools into ssh-terminal plugin
* feat: move snippets, keybinding actions and macros out of core into plugins
* feat: store rdp, vnc and telnet logins in a generic per-protocol table
* feat: type the plugin host record, move plugin strings out of core and add lint checks
* feat: replace core homepage widget registry with generic extension points
* feat: plugins only see their own host settings in the browser
* feat: harden plugin api for separate repos, signed updates and generic external login
* fix: keep plugin css below core utilities and version plugin assets by their css
* fix: load and save plugin host settings in the editor and tighten settings row spacing
* feat: redesign user host defaults system
* fix(remote-desktop): release held keys on focus loss (#1498)
* fix(session-sharing): replace local presentation after takeover (#1500)
* fix(file-manager): refresh disk usage with directory listing (#1503)
* fix(ssh-terminal): preserve input order during local echo reconciliation (#1505)
* feat(remote-desktop): expose display zoom controls for RDP (#1514)
* fix(file-manager): restore text selection and copying in Markdown previews (#1518)
* fix(sidebar): keep compact host addresses visible (#1524)
* fix(hosts): preserve plugin settings when reopening the editor (#1499)
* fix(ssh-terminal): close owner sockets when presentations unmount (#1501)
* fix(session-sharing): synchronize shared SSH terminal dimensions (#1502)
* fix(session-sharing): show participant presence in member terminals (#1504)
* fix(ai): sync provider configuration and keys across linked devices (#1506)
* feat(ssh-terminal): add a duplicate tab action (#1510)
* feat(file-manager): allow disabling move-to-trash confirmation (#1511)
* feat(file-manager): default to the remote login directory (#1512)
* feat(homepage): synchronize canvas layouts across devices (#1515)
* fix(shortcuts): prevent accidental double-Shift palette activation (#1519)
* feat(tmux-monitor): add a unified multi-host view (#1520)
* fix(electron): allow explicit quit with active connections (#1522)
* feat: reconnect all disconnected terminal tabs (#1523)
* fix(hosts): preserve recording and sharing settings in the dev editor (#1509)
* fix(database): preserve RDP domain during startup backfill (#1497)
* feat(sidebar): add keyboard type-ahead search for hosts (#1516)
* feat(automations): add per-host maintenance mode and recurring schedules (#1521)
* feat(tmux): add a per-host mouse toggle and preserve remote configuration (#1513)
* fix(session-sharing): use the linked server for desktop meetings and links (#1507)
* fix(tunnels): support TOTP for client tunnels and jump hosts (#1508)
* fix: type errors and lint failures from merged plugin prs
* feat(installer): make the Windows desktop shortcut optional (#1517)
* fix: ship the plugin sdk and serialport in desktop builds
* fix: prefer ipv4 when resolving ssh host names (#1365)
* fix: readable terminal selection with the light theme (#1319)
* fix: build desktop session share links on the linked server (#1350)
* fix: show meeting guests to members and hide the roster from guests (#1353)
* fix: reliable file manager saves with sudo prompt and no base64 guessing (#1335)
* fix: show disk usage for the mount being browsed (#1347)
* fix: load the linux tray icon reliably and log tray failures (#1332)
* fix: cover the gnome close path in the desktop quit test (#1332)
* fix: prompt for jump host TOTP in terminal, file manager and metrics (#1312)
* feat: optional auto reconnect for dropped ssh sessions (#1321)
* feat: tighter compact host rows with inline tags (#1325)
* feat: bring back the alert feed homepage widget
* feat: pick target terminals for snippets again
* feat: share button on connection tabs again
* feat: recent servers list when linking the desktop app
* feat: list the fixed app shortcuts in keyboard shortcuts
* fix: translate hardcoded toasts and drop em dashes from ui text
* fix: translate leftover hardcoded ui text
* fix: toolbar files button opens at the shell directory again
* fix: show the remote desktop note under connection origin again
* chore: openapi docs for host metrics manager routes
* chore: openapi docs for undocumented routes and fix two broken doc blocks
* fix: declare terminal.listSessions use for snippet targets
* revert: drop the alert feed widget, the notification center replaces it
* feat: restore the alert feed homepage widget
* fix: quick connect fills the host address from plugin auth editors like tailscale
* fix: docker container logs use the base background color
* chore: fix dependabot alerts and apply safe npm upgrades
* fix: proxmox stats tab reads its switch from plugin host settings
* fix: count plugin ssh logins toward host status and stop showing reachable as a warning
* fix: hide the built-in source pill in the host editor
* fix: host status shows online when the port answers and only warns on a failed login
* fix: drop the top border on the split layout menu
* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default
* fix: remote desktop auth method labels and notes for vnc and telnet
* fix: add description to the web endpoint enable switch
* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release
* feat: move telemetry out of core into a usage statistics plugin
* fix: recent activity card no longer cuts off host names early
* fix: remove duplicate top border on split screen menu
* fix: redesign session log player controls, fix tooltip overlap and drop dot separators
* fix: file manager and sftp text showing raw i18n keys
* fix: onboarding rerunning for existing users after upgrading
* fix: fleet detail buttons overflowing the sidebar
* fix: session log player scrolling off screen horizontally
* fix: network graph lag when adding hosts and on every render
* fix: maintenance index name, shared host read only view and badge polling
* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag
* chore: update release notes
* fix: remote desktop tabs reconnecting on every tab switch
* fix: build plugin sdk before linting in release workflow
* chore: lint, format, and bump version to 2.9.0
* chore: skip crowdin pre-translate in release for now
* chore: sync Crowdin translations for 2.9.0
---------
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Koi <38512047+datlt4@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
This commit is contained in:
4405 files changed
+1341026
-289722
No files matched your search
@@ -10,7 +10,6 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAllServerStatuses, getSSHHosts } from "@/main-axios";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import {
|
||||
ServerStatusStore,
|
||||
type ServerStatusEntry,
|
||||
@@ -73,21 +72,7 @@ export function ServerStatusProvider({
|
||||
const enabled = new Set<number>();
|
||||
|
||||
hosts.forEach((host) => {
|
||||
const statsConfig = (() => {
|
||||
try {
|
||||
if (!host.statsConfig) return DEFAULT_STATS_CONFIG;
|
||||
if (typeof host.statsConfig === "string") {
|
||||
return JSON.parse(host.statsConfig);
|
||||
}
|
||||
return host.statsConfig;
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
})();
|
||||
|
||||
if (statsConfig.statusCheckEnabled !== false) {
|
||||
enabled.add(host.id);
|
||||
}
|
||||
if (host.statusCheckEnabled !== false) enabled.add(host.id);
|
||||
});
|
||||
|
||||
store.setEnabledHostIds(enabled);
|
||||
@@ -322,3 +307,30 @@ export function useServerStatusMeta(): {
|
||||
isLoading: store.getIsLoading(),
|
||||
};
|
||||
}
|
||||
|
||||
const noStatus = () => "";
|
||||
|
||||
/**
|
||||
* One host's status for plugin views, which can render outside the app shell
|
||||
* (a standalone window). Null there, and while the host has not been checked.
|
||||
*/
|
||||
export function useOptionalHostStatusEntry(hostId: number | undefined): {
|
||||
status: StatusValue;
|
||||
reason?: "host_key_changed";
|
||||
} | null {
|
||||
const store = useContext(StatusStoreContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
(onChange) =>
|
||||
store && hostId !== undefined
|
||||
? store.subscribeHost(hostId, onChange)
|
||||
: () => {},
|
||||
() => (store && hostId !== undefined ? store.getHostSnapshot(hostId) : ""),
|
||||
noStatus,
|
||||
);
|
||||
if (!snapshot) return null;
|
||||
const [status] = snapshot.split(":", 1);
|
||||
if (!status) return null;
|
||||
return snapshot.endsWith(":host_key_changed")
|
||||
? { status: status as StatusValue, reason: "host_key_changed" }
|
||||
: { status: status as StatusValue };
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
interface SimpleLoaderProps {
|
||||
visible: boolean;
|
||||
message?: string;
|
||||
className?: string;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
export function SimpleLoader({
|
||||
visible,
|
||||
message,
|
||||
className,
|
||||
backgroundColor,
|
||||
}: SimpleLoaderProps) {
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.simple-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-base);
|
||||
border-top-color: var(--foreground);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex items-center justify-center z-[100]",
|
||||
className,
|
||||
)}
|
||||
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="simple-spinner"></div>
|
||||
{message && (
|
||||
<p className="text-sm text-foreground-secondary font-medium">
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { tabTypeForActivity } from "@/shell/tab-registry";
|
||||
|
||||
export interface ActivityTarget {
|
||||
icon: ComponentType<{ className?: string }> | undefined;
|
||||
/** Tab type the entry reopens. */
|
||||
tab: string;
|
||||
labelKey: string | undefined;
|
||||
}
|
||||
|
||||
/** Recent-activity types core itself records. None today. */
|
||||
const CORE_ACTIVITY: Record<string, ActivityTarget> = {};
|
||||
|
||||
/**
|
||||
* What a recent-activity entry opens and how it is labelled. Plugin tabs
|
||||
* claim their activity types through the registry, so an entry whose plugin
|
||||
* is gone simply has no target.
|
||||
*/
|
||||
export function activityTarget(type: string): ActivityTarget | undefined {
|
||||
const core = CORE_ACTIVITY[type];
|
||||
if (core) return core;
|
||||
const def = tabTypeForActivity(type);
|
||||
if (!def) return undefined;
|
||||
return { icon: def.icon, tab: def.id, labelKey: def.titleKey };
|
||||
}
|
||||
@@ -25,3 +25,35 @@ export function dispatchCtrlW(target: EventTarget | null) {
|
||||
});
|
||||
return !target.dispatchEvent(event);
|
||||
}
|
||||
|
||||
/** Match two consecutive Shift presses, never a typing or IME sequence. */
|
||||
export function createCommandPaletteShortcutMatcher() {
|
||||
let lastShift: number | null = null;
|
||||
const reset = () => {
|
||||
lastShift = null;
|
||||
};
|
||||
const matches = (event: KeyboardEvent, now = Date.now()): boolean => {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.isComposing ||
|
||||
event.keyCode === 229 ||
|
||||
event.repeat ||
|
||||
event.altKey
|
||||
) {
|
||||
reset();
|
||||
return false;
|
||||
}
|
||||
if (isShiftKey(event) && !event.ctrlKey && !event.metaKey) {
|
||||
const matched = lastShift !== null && now - lastShift < 300;
|
||||
lastShift = matched ? null : now;
|
||||
return matched;
|
||||
}
|
||||
reset();
|
||||
return (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!event.shiftKey &&
|
||||
event.code === "KeyK"
|
||||
);
|
||||
};
|
||||
return { matches, reset };
|
||||
}
|
||||
@@ -6,9 +6,18 @@ import {
|
||||
getCredentialSidebarPreferences,
|
||||
saveCredentialSidebarPreferences,
|
||||
} from "@/main-axios";
|
||||
import { PRESETS, type UiPreset } from "@/types/ui-preferences";
|
||||
import {
|
||||
PRESETS,
|
||||
type UiPreset,
|
||||
type UiRailPreferences,
|
||||
} from "@/types/ui-preferences";
|
||||
import {
|
||||
listRegisteredRailItems,
|
||||
type RailItemDef,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { sanitizeHostSidebarPreferences } from "@/types/host-sidebar-preferences";
|
||||
import { sanitizeCredentialSidebarPreferences } from "@/types/credential-sidebar-preferences";
|
||||
import { getRegisteredDashboardCard } from "@/dashboard/dashboard-cards-registry";
|
||||
|
||||
/**
|
||||
* Seeding a preset into the stores that already own their settings.
|
||||
@@ -25,31 +34,36 @@ import { sanitizeCredentialSidebarPreferences } from "@/types/credential-sidebar
|
||||
* overwrites layouts the user may have arranged by hand.
|
||||
*/
|
||||
|
||||
const DASHBOARD_SLOT_HEIGHTS: Record<string, number | null> = {
|
||||
// Core's own cards. A plugin's card carries its own height and panel on its
|
||||
// registration (registerDashboardCard's defaultHeight/defaultPanel), read
|
||||
// below, so this preset never has to spell the plugin's card id itself.
|
||||
const CORE_DASHBOARD_SLOT_HEIGHTS: Record<string, number | null> = {
|
||||
stats_bar: 96,
|
||||
counters_bar: 48,
|
||||
quick_actions: 160,
|
||||
host_status: null,
|
||||
recent_activity: null,
|
||||
network_graph: 360,
|
||||
service_links: 200,
|
||||
homepage_preview: 320,
|
||||
};
|
||||
|
||||
/** Cards that belong in the narrower side column rather than the main one. */
|
||||
const DASHBOARD_SIDE_CARDS = new Set(["recent_activity", "service_links"]);
|
||||
/** Core cards that belong in the narrower side column rather than the main one. */
|
||||
const CORE_DASHBOARD_SIDE_CARDS = new Set(["recent_activity"]);
|
||||
|
||||
function buildDashboardSlots(cardIds: string[]) {
|
||||
export function buildDashboardSlots(cardIds: string[]) {
|
||||
let mainOrder = 0;
|
||||
let sideOrder = 0;
|
||||
return cardIds.map((id) => {
|
||||
const panel = DASHBOARD_SIDE_CARDS.has(id) ? "side" : "main";
|
||||
const registered = getRegisteredDashboardCard(id);
|
||||
const panel =
|
||||
registered?.defaultPanel ??
|
||||
(CORE_DASHBOARD_SIDE_CARDS.has(id) ? "side" : "main");
|
||||
const height =
|
||||
registered?.defaultHeight ?? CORE_DASHBOARD_SLOT_HEIGHTS[id] ?? null;
|
||||
return {
|
||||
key: `${id}_0`,
|
||||
id,
|
||||
panel,
|
||||
order: panel === "side" ? sideOrder++ : mainOrder++,
|
||||
height: DASHBOARD_SLOT_HEIGHTS[id] ?? null,
|
||||
height,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -62,6 +76,23 @@ function writeLocal(key: string, value: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail views a preset hides: its own list, plus every plugin item that
|
||||
* does not ask to stay when the preset hides plugin items.
|
||||
*/
|
||||
export function presetHiddenRailTabs(
|
||||
rail: UiRailPreferences,
|
||||
pluginItems: Pick<RailItemDef, "id" | "simplePreset" | "hideable">[],
|
||||
): string[] {
|
||||
if (!rail.hidePluginItems) return [...rail.hiddenTabs];
|
||||
return [
|
||||
...rail.hiddenTabs,
|
||||
...pluginItems
|
||||
.filter((item) => !item.simplePreset && item.hideable !== false)
|
||||
.map((item) => item.id),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a preset's values into the stores that own them. Safe to call when
|
||||
* nothing changed; every write is idempotent.
|
||||
@@ -81,7 +112,9 @@ export async function applyPresetSideEffects(
|
||||
|
||||
// Rail visibility lives on user_preferences and is mirrored to localStorage
|
||||
// with a change event, the same way the settings toggles write it.
|
||||
const hiddenRailTabs = JSON.stringify(target.rail.hiddenTabs);
|
||||
const hiddenRailTabs = JSON.stringify(
|
||||
presetHiddenRailTabs(target.rail, listRegisteredRailItems()),
|
||||
);
|
||||
writeLocal("hiddenRailTabs", hiddenRailTabs);
|
||||
window.dispatchEvent(new Event("hiddenRailTabsChanged"));
|
||||
if (isCloud) {
|
||||
@@ -132,10 +165,4 @@ export async function applyPresetSideEffects(
|
||||
JSON.stringify(buildDashboardSlots(target.dashboard.enabledCards)),
|
||||
);
|
||||
window.dispatchEvent(new Event("dashboardSlotsChanged"));
|
||||
|
||||
writeLocal("termix-terminal-toolbar-density", target.terminal.toolbarDensity);
|
||||
window.dispatchEvent(new Event("terminalToolbarDensityChanged"));
|
||||
|
||||
writeLocal("fileManagerViewMode", target.fileManager.viewMode);
|
||||
window.dispatchEvent(new Event("fileManagerViewModeChanged"));
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { GuacamoleConfig } from "@/types/guacamole-config";
|
||||
import type { TerminalConfig } from "@/types";
|
||||
|
||||
export type TerminalDefaults = Partial<
|
||||
Pick<
|
||||
TerminalConfig,
|
||||
| "cursorBlink"
|
||||
| "cursorStyle"
|
||||
| "fontSize"
|
||||
| "fontFamily"
|
||||
| "letterSpacing"
|
||||
| "lineHeight"
|
||||
| "theme"
|
||||
| "scrollback"
|
||||
| "bellStyle"
|
||||
| "minimumContrastRatio"
|
||||
| "backgroundImage"
|
||||
| "backgroundImageOpacity"
|
||||
| "customThemeColors"
|
||||
>
|
||||
>;
|
||||
|
||||
export type RemoteDesktopDefaults = Partial<GuacamoleConfig>;
|
||||
|
||||
function parseObject<T extends object>(value?: string | null): Partial<T> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Partial<T>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTerminalDefaults(value?: string | null): TerminalDefaults {
|
||||
return parseObject<TerminalDefaults>(value);
|
||||
}
|
||||
|
||||
export function parseRemoteDesktopDefaults(
|
||||
value?: string | null,
|
||||
): RemoteDesktopDefaults {
|
||||
return parseObject<RemoteDesktopDefaults>(value);
|
||||
}
|
||||
|
||||
// Booleans have three states in the defaults UI: unset means the host decides.
|
||||
export type TriState = "inherit" | "on" | "off";
|
||||
|
||||
export function toTriState(value?: boolean): TriState {
|
||||
if (value === undefined) return "inherit";
|
||||
return value ? "on" : "off";
|
||||
}
|
||||
|
||||
export function fromTriState(value: TriState): boolean | undefined {
|
||||
if (value === "inherit") return undefined;
|
||||
return value === "on";
|
||||
}
|
||||
|
||||
export function resolveConnectionDefaults<T extends object>(
|
||||
defaults: Partial<T>,
|
||||
overrides?: Partial<T> | null,
|
||||
): Partial<T> {
|
||||
return { ...defaults, ...overrides };
|
||||
}
|
||||
@@ -1,45 +1,42 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||
import { getLinkedSession } from "@/lib/linked-server";
|
||||
|
||||
export type ConnectionOrigin = "local" | "remote";
|
||||
|
||||
interface OriginResolvableHost {
|
||||
connectionType?: string | null;
|
||||
connectionOrigin?: ConnectionOrigin | null;
|
||||
}
|
||||
|
||||
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, remote desktop) should dial: the desktop app's embedded local
|
||||
* backend, or the server it is linked to. 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
|
||||
* need a guacd, which the desktop does not ship, so originating them here
|
||||
* only works once the user has pointed Termix at one of their own (the
|
||||
* global guacd URL setting, or a host's guacd Proxy override). Making that
|
||||
* opt-in per host keeps an upgrade from moving working connections onto a
|
||||
* guacd that isn't there -- see Termix-SSH/Support#1240.
|
||||
* `defaultRemote` makes a host left on Default resolve to "remote" instead of
|
||||
* following the desktop-wide setting. Remote desktop passes it: it needs a
|
||||
* guacd, which the desktop does not ship, so originating it here only works
|
||||
* once the user has pointed Termix at one of their own. Making that opt-in
|
||||
* per host keeps an upgrade from moving working connections onto a guacd
|
||||
* that isn't there -- see Termix-SSH/Support#1240.
|
||||
*/
|
||||
export async function resolveConnectionOrigin(
|
||||
host: OriginResolvableHost,
|
||||
options: { defaultRemote?: boolean } = {},
|
||||
): Promise<ConnectionOrigin> {
|
||||
if (host.connectionType === "serial") {
|
||||
return "local";
|
||||
}
|
||||
if (!isElectron()) {
|
||||
return "local";
|
||||
}
|
||||
if (host.connectionOrigin === "local" || host.connectionOrigin === "remote") {
|
||||
return host.connectionOrigin;
|
||||
}
|
||||
if (GUACAMOLE_CONNECTION_TYPES.has(host.connectionType ?? "")) {
|
||||
if (options.defaultRemote) {
|
||||
return "remote";
|
||||
}
|
||||
|
||||
@@ -59,26 +56,14 @@ export interface RemoteConnectionTarget {
|
||||
}
|
||||
|
||||
async function getRemoteConnectionTarget(): Promise<RemoteConnectionTarget | null> {
|
||||
try {
|
||||
const [config, jwt] = await Promise.all([
|
||||
window.electronAPI?.invoke?.("get-remote-sync-config") as Promise<{
|
||||
serverUrl?: string;
|
||||
} | null>,
|
||||
window.electronAPI?.invoke?.("get-remote-sync-jwt") as Promise<
|
||||
string | null
|
||||
>,
|
||||
]);
|
||||
if (!config?.serverUrl) return null;
|
||||
return { serverUrl: config.serverUrl, jwt: jwt ?? null };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const linked = await getLinkedSession();
|
||||
return linked ? { serverUrl: linked.serverUrl, jwt: linked.token } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the base WebSocket URL for an interactive connection protocol,
|
||||
* given a resolved origin. Returns null when origin is "remote" but no
|
||||
* remote server is connected -- callers must show a blocking message
|
||||
* given a resolved origin. Returns null when origin is "remote" but this
|
||||
* desktop is not linked to a server -- callers must show a blocking message
|
||||
* rather than attempting to connect.
|
||||
*/
|
||||
export interface WebSocketConnectionTarget {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** A random id, with a fallback for insecure contexts that lack randomUUID. */
|
||||
export function createId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import type { KeyCombo } from "@/types/keybindings";
|
||||
|
||||
export interface DefaultKeybindingInfo {
|
||||
id: string;
|
||||
combo: KeyCombo;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const BUILT_IN_DEFAULTS: DefaultKeybindingInfo[] = [
|
||||
{
|
||||
id: "default-copy-ctrlc",
|
||||
combo: {
|
||||
key: "c",
|
||||
isCode: false,
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: false,
|
||||
},
|
||||
description: "Copy selection (Ctrl+C, only when text is selected)",
|
||||
},
|
||||
{
|
||||
id: "default-copy-ctrlshiftc",
|
||||
combo: {
|
||||
key: "c",
|
||||
isCode: false,
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: true,
|
||||
meta: false,
|
||||
},
|
||||
description: "Copy selection (Ctrl+Shift+C)",
|
||||
},
|
||||
{
|
||||
id: "default-copy-cmdc",
|
||||
combo: {
|
||||
key: "c",
|
||||
isCode: false,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
meta: true,
|
||||
},
|
||||
description: "Copy selection (Cmd+C)",
|
||||
},
|
||||
{
|
||||
id: "default-paste-ctrlshiftv",
|
||||
combo: {
|
||||
key: "v",
|
||||
isCode: false,
|
||||
ctrl: true,
|
||||
alt: false,
|
||||
shift: true,
|
||||
meta: false,
|
||||
},
|
||||
description: "Paste from clipboard (Ctrl+Shift+V)",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltw",
|
||||
combo: {
|
||||
key: "w",
|
||||
isCode: false,
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
shift: false,
|
||||
meta: false,
|
||||
},
|
||||
description: "Send Ctrl+W to shell (blocked from closing browser tab)",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltt",
|
||||
combo: {
|
||||
key: "t",
|
||||
isCode: false,
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
shift: false,
|
||||
meta: false,
|
||||
},
|
||||
description: "Send Ctrl+T to shell (blocked from opening browser tab)",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltn",
|
||||
combo: {
|
||||
key: "n",
|
||||
isCode: false,
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
shift: false,
|
||||
meta: false,
|
||||
},
|
||||
description: "Send Ctrl+N to shell (blocked from opening browser window)",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltq",
|
||||
combo: {
|
||||
key: "q",
|
||||
isCode: false,
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
shift: false,
|
||||
meta: false,
|
||||
},
|
||||
description: "Send Ctrl+Q to shell (blocked from quitting browser)",
|
||||
},
|
||||
];
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { FileItem } from "@/types/index";
|
||||
import { createKeyedRequestCache } from "./keyed-request-cache";
|
||||
|
||||
export interface FileContentResult {
|
||||
content: string;
|
||||
path: string;
|
||||
encoding?: "base64" | "utf8";
|
||||
}
|
||||
|
||||
const cache = createKeyedRequestCache<FileContentResult>(30_000, 24);
|
||||
const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`;
|
||||
|
||||
export function getCachedFileContent(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
loader: () => Promise<FileContentResult>,
|
||||
force = false,
|
||||
): Promise<FileContentResult> {
|
||||
return cache.get(keyFor(sessionId, path), loader, { force });
|
||||
}
|
||||
|
||||
export function invalidateCachedFileContent(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): void {
|
||||
cache.invalidate(keyFor(sessionId, path));
|
||||
}
|
||||
|
||||
export function shouldPrefetchFileContent(
|
||||
file: FileItem,
|
||||
maxPrefetchBytes: number,
|
||||
): boolean {
|
||||
return (
|
||||
file.type === "file" &&
|
||||
typeof file.size === "number" &&
|
||||
file.size <= maxPrefetchBytes
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { FileItem } from "@/types/index";
|
||||
import { createKeyedRequestCache } from "./keyed-request-cache";
|
||||
|
||||
export interface FileListResult {
|
||||
files: FileItem[];
|
||||
path: string;
|
||||
}
|
||||
|
||||
const cache = createKeyedRequestCache<FileListResult>(5_000, 150);
|
||||
|
||||
const keyFor = (sessionId: string, path: string) => `${sessionId}\0${path}`;
|
||||
|
||||
export function getCachedFileList(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
loader: () => Promise<FileListResult>,
|
||||
force = false,
|
||||
): Promise<FileListResult> {
|
||||
return cache.get(keyFor(sessionId, path), loader, { force });
|
||||
}
|
||||
|
||||
export function peekCachedFileList(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
maxAgeMs = 30_000,
|
||||
): FileListResult | null {
|
||||
return cache.peek(keyFor(sessionId, path), maxAgeMs);
|
||||
}
|
||||
|
||||
export function invalidateCachedFileList(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
): void {
|
||||
cache.invalidate(keyFor(sessionId, path));
|
||||
}
|
||||
|
||||
export function updateCachedFileList(
|
||||
sessionId: string,
|
||||
path: string,
|
||||
files: FileItem[],
|
||||
): void {
|
||||
cache.set(keyFor(sessionId, path), { files, path });
|
||||
}
|
||||
@@ -4,7 +4,6 @@ export interface LogContext {
|
||||
operation?: string;
|
||||
userId?: string;
|
||||
hostId?: number;
|
||||
tunnelName?: string;
|
||||
sessionId?: string;
|
||||
requestId?: string;
|
||||
duration?: number;
|
||||
@@ -53,7 +52,6 @@ class FrontendLogger {
|
||||
if (context.operation) contextParts.push(context.operation);
|
||||
if (context.userId) contextParts.push(`user:${context.userId}`);
|
||||
if (context.hostId) contextParts.push(`host:${context.hostId}`);
|
||||
if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`);
|
||||
if (context.sessionId) contextParts.push(`session:${context.sessionId}`);
|
||||
if (context.responseTime) contextParts.push(`${context.responseTime}ms`);
|
||||
if (context.status) contextParts.push(`status:${context.status}`);
|
||||
@@ -161,10 +159,6 @@ class FrontendLogger {
|
||||
this.info(`SSH: ${message}`, { ...context, operation: "ssh" });
|
||||
}
|
||||
|
||||
tunnel(message: string, context?: LogContext): void {
|
||||
this.info(`TUNNEL: ${message}`, { ...context, operation: "tunnel" });
|
||||
}
|
||||
|
||||
file(message: string, context?: LogContext): void {
|
||||
this.info(`FILE: ${message}`, { ...context, operation: "file" });
|
||||
}
|
||||
@@ -375,10 +369,14 @@ class FrontendLogger {
|
||||
export const apiLogger = new FrontendLogger("API", "🌐", "#3b82f6");
|
||||
export const authLogger = new FrontendLogger("AUTH", "🔐", "#dc2626");
|
||||
export const sshLogger = new FrontendLogger("SSH", "🖥️", "#1e3a8a");
|
||||
export const tunnelLogger = new FrontendLogger("TUNNEL", "📡", "#1e3a8a");
|
||||
export const fileLogger = new FrontendLogger("FILE", "📁", "#1e3a8a");
|
||||
export const statsLogger = new FrontendLogger("STATS", "📊", "#22c55e");
|
||||
export const systemLogger = new FrontendLogger("SYSTEM", "🚀", "#1e3a8a");
|
||||
export const dashboardLogger = new FrontendLogger("DASHBOARD", "📊", "#ec4899");
|
||||
|
||||
export const logger = systemLogger;
|
||||
/** A named console logger, for plugins that want the same log format. */
|
||||
export function createFrontendLogger(
|
||||
serviceName: string,
|
||||
serviceIcon = "🧩",
|
||||
serviceColor = "#6b7280",
|
||||
) {
|
||||
return new FrontendLogger(serviceName, serviceIcon, serviceColor);
|
||||
}
|
||||
@@ -1,40 +1,37 @@
|
||||
import type { Host, TabType } from "@/types/ui-types";
|
||||
import {
|
||||
defaultConnectAction,
|
||||
hostActionsFor,
|
||||
listHostActions,
|
||||
} from "@/sidebar/host-contributions";
|
||||
|
||||
type ConnectionTabType = "terminal" | "rdp" | "vnc" | "telnet";
|
||||
|
||||
function isConnectionTabType(type: TabType): type is ConnectionTabType {
|
||||
return (
|
||||
type === "terminal" || type === "rdp" || type === "vnc" || type === "telnet"
|
||||
);
|
||||
}
|
||||
|
||||
function isConnectionEnabled(host: Host, type: ConnectionTabType): boolean {
|
||||
switch (type) {
|
||||
case "terminal":
|
||||
return host.enableSsh;
|
||||
case "rdp":
|
||||
return host.enableRdp;
|
||||
case "vnc":
|
||||
return host.enableVnc;
|
||||
case "telnet":
|
||||
return host.enableTelnet;
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultConnectionTab(host: Host): ConnectionTabType {
|
||||
if (host.enableSsh) return "terminal";
|
||||
if (host.enableRdp) return "rdp";
|
||||
if (host.enableVnc) return "vnc";
|
||||
if (host.enableTelnet) return "telnet";
|
||||
return "terminal";
|
||||
/**
|
||||
* Which tab a click on a host opens. The ways to connect (the SSH terminal,
|
||||
* remote desktop) are host actions that plugins register, so the answer is the
|
||||
* highest-priority connect action the host allows, or null when no running
|
||||
* plugin can connect to it.
|
||||
*/
|
||||
export function getDefaultConnectionTab(host: Host): TabType | null {
|
||||
return defaultConnectAction(listHostActions(), host)?.tabType ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The preferred tab if the host allows it. A connect tab type the host has
|
||||
* switched off falls back to the host's default; any other type is taken as
|
||||
* asked.
|
||||
*/
|
||||
export function resolveHostTabType(
|
||||
host: Host,
|
||||
preferredType?: TabType,
|
||||
): TabType {
|
||||
): TabType | null {
|
||||
if (!preferredType) return getDefaultConnectionTab(host);
|
||||
if (!isConnectionTabType(preferredType)) return preferredType;
|
||||
if (isConnectionEnabled(host, preferredType)) return preferredType;
|
||||
return getDefaultConnectionTab(host);
|
||||
const all = listHostActions();
|
||||
const isConnectType = all.some(
|
||||
(action) => action.kind === "connect" && action.tabType === preferredType,
|
||||
);
|
||||
if (!isConnectType) return preferredType;
|
||||
const allowed = hostActionsFor(all, host, "connect").some(
|
||||
(action) => action.tabType === preferredType,
|
||||
);
|
||||
return allowed ? preferredType : getDefaultConnectionTab(host);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
/**
|
||||
* The host editor's view of host defaults, for the fields it draws.
|
||||
*
|
||||
* In a host, a field either follows its default (and says where from) or is
|
||||
* the host's own (and can be reset). In the defaults editor, a field is either
|
||||
* set at the level being edited (and can be cleared) or shows what it
|
||||
* inherits. Outside either editor there is no provider and nothing renders.
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import type {
|
||||
HostDefaultSource,
|
||||
HostDefaultsLevel,
|
||||
} from "@/types/host-defaults";
|
||||
import { usePluginScope } from "@/plugin-host/scope";
|
||||
|
||||
export interface HostDefaultsContextValue {
|
||||
mode: "host" | "defaults";
|
||||
/** The level being edited, in the defaults editor. */
|
||||
level?: HostDefaultsLevel;
|
||||
/** A host's own value, or a value set at the level being edited. */
|
||||
isOwn: (fullKey: string) => boolean;
|
||||
/** Where the value it would otherwise have comes from. */
|
||||
source: (fullKey: string) => HostDefaultSource | undefined;
|
||||
/** Follows the default again, or clears the value at this level. */
|
||||
reset: (fullKey: string) => void;
|
||||
/** Whether the key can have a default here at all. */
|
||||
isAvailable: (fullKey: string) => boolean;
|
||||
}
|
||||
|
||||
export const HostDefaultsContext =
|
||||
createContext<HostDefaultsContextValue | null>(null);
|
||||
|
||||
export function useHostDefaults(): HostDefaultsContextValue | null {
|
||||
return useContext(HostDefaultsContext);
|
||||
}
|
||||
|
||||
/** Whether the editor is editing a level of defaults rather than a host. */
|
||||
export function useIsDefaultsEditor(): boolean {
|
||||
return useContext(HostDefaultsContext)?.mode === "defaults";
|
||||
}
|
||||
|
||||
/**
|
||||
* A short key ("fontSize") is the calling plugin's own; core passes a full
|
||||
* one ("core.sshPort").
|
||||
*/
|
||||
export function useDefaultKey(settingKey: string | undefined): string | null {
|
||||
const scope = usePluginScope();
|
||||
if (!settingKey) return null;
|
||||
if (settingKey.includes(".")) return settingKey;
|
||||
return scope ? `${scope}.${settingKey}` : null;
|
||||
}
|
||||
|
||||
export function useSourceLabel(): (
|
||||
source: HostDefaultSource | undefined,
|
||||
) => string {
|
||||
const { t } = useTranslation();
|
||||
return (source) => {
|
||||
switch (source?.level) {
|
||||
case "folder":
|
||||
return t("hostDefaults.source.folder", {
|
||||
name: source.folderName ?? "",
|
||||
});
|
||||
case "user":
|
||||
return t("hostDefaults.source.user");
|
||||
case "admin":
|
||||
return t("hostDefaults.source.admin");
|
||||
default:
|
||||
return t("hostDefaults.source.builtin");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker next to a field's label: where an inherited value comes from,
|
||||
* or a control to hand an own value back.
|
||||
*/
|
||||
export function HostDefaultBadge({ settingKey }: { settingKey: string }) {
|
||||
const { t } = useTranslation();
|
||||
const context = useHostDefaults();
|
||||
const fullKey = useDefaultKey(settingKey);
|
||||
const sourceLabel = useSourceLabel();
|
||||
if (!context || !fullKey || !context.isAvailable(fullKey)) return null;
|
||||
|
||||
const own = context.isOwn(fullKey);
|
||||
const source = context.source(fullKey);
|
||||
const from = sourceLabel(source);
|
||||
|
||||
if (own && (context.mode === "defaults" || source)) {
|
||||
const defaults = context.mode === "defaults";
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => context.reset(fullKey)}
|
||||
title={
|
||||
defaults
|
||||
? t("hostDefaults.clearTitle", { source: from })
|
||||
: t("hostDefaults.resetTitle", { source: from })
|
||||
}
|
||||
className="inline-flex items-center gap-0.5 border border-accent-brand/40 px-1 text-[10px] font-medium normal-case tracking-normal text-accent-brand hover:bg-accent-brand/10"
|
||||
>
|
||||
{defaults ? (
|
||||
<X className="size-2.5" />
|
||||
) : (
|
||||
<RotateCcw className="size-2.5" />
|
||||
)}
|
||||
{defaults ? t("hostDefaults.setHere") : t("hostDefaults.custom")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (own || source?.level === "builtin") return null;
|
||||
return (
|
||||
<span
|
||||
title={
|
||||
source
|
||||
? t("hostDefaults.inheritedTitle", { source: from })
|
||||
: t("hostDefaults.notSetTitle")
|
||||
}
|
||||
className="border border-border px-1 text-[10px] font-normal normal-case tracking-normal text-muted-foreground"
|
||||
>
|
||||
{source ? from : t("hostDefaults.notSet")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders its children only when the key can have a default in this editor. */
|
||||
export function DefaultsOnly({
|
||||
settingKey,
|
||||
children,
|
||||
}: {
|
||||
settingKey: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const context = useHostDefaults();
|
||||
const fullKey = useDefaultKey(settingKey);
|
||||
if (
|
||||
context?.mode === "defaults" &&
|
||||
fullKey &&
|
||||
!context.isAvailable(fullKey)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/** Renders its children only when editing a host, never a level of defaults. */
|
||||
export function HostOnly({ children }: { children: React.ReactNode }) {
|
||||
return useIsDefaultsEditor() ? null : <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
/** The shell's Host in the SSHHost shape the connection surfaces take. */
|
||||
export function hostToSSHHost(h: Host): SSHHost {
|
||||
return {
|
||||
id: parseInt(h.id, 10),
|
||||
name: h.name,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
username: h.username,
|
||||
folder: h.folder ?? "",
|
||||
tags: h.tags ?? [],
|
||||
pin: h.pin ?? false,
|
||||
authType: h.authType,
|
||||
password: h.password,
|
||||
hasPassword: h.hasPassword,
|
||||
key: h.key,
|
||||
keyPassword: h.keyPassword,
|
||||
hasKey: h.hasKey,
|
||||
hasKeyPassword: h.hasKeyPassword,
|
||||
keyType: h.keyType,
|
||||
credentialId: h.credentialId ? parseInt(h.credentialId, 10) : undefined,
|
||||
terminalConfig: h.terminalConfig,
|
||||
sshOptions: h.sshOptions,
|
||||
hasSudoPassword: h.hasSudoPassword,
|
||||
pluginSettings: h.pluginSettings ?? {},
|
||||
connectionType: "ssh",
|
||||
connectionOrigin: h.connectionOrigin ?? null,
|
||||
isShared: h.isShared ?? false,
|
||||
// Carries the host's identity to a delegated backend. Without it the
|
||||
// remote side resolves our local row id against its own table.
|
||||
syncId: h.syncId ?? null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
} as unknown as SSHHost;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SSHHost, SSHFolder } from "@/types/index";
|
||||
import type { ServerStatus } from "@/main-axios";
|
||||
import { isElectron } from "./electron";
|
||||
import { SYNC_CHANGED_EVENT } from "./sync-events";
|
||||
import { createTtlRequestCache } from "./ttl-request-cache";
|
||||
|
||||
/** Host list changes less often than status; keep a short shared window. */
|
||||
@@ -16,7 +16,6 @@ const statusCache =
|
||||
const foldersCache = createTtlRequestCache<SSHFolder[]>(FOLDERS_TTL_MS);
|
||||
|
||||
let listenersBound = false;
|
||||
let lastRemoteSyncCompleteAt: string | null = null;
|
||||
|
||||
function bindInvalidationListeners(): void {
|
||||
if (listenersBound || typeof window === "undefined") return;
|
||||
@@ -29,32 +28,10 @@ function bindInvalidationListeners(): void {
|
||||
window.addEventListener("ssh-hosts:changed", invalidateHosts);
|
||||
window.addEventListener("hosts:refresh", invalidateHosts);
|
||||
|
||||
if (isElectron()) {
|
||||
window.electronAPI
|
||||
?.invoke?.("get-remote-sync-status")
|
||||
.then((status) => {
|
||||
const syncedAt =
|
||||
status && typeof status === "object"
|
||||
? (status as { lastSyncedAt?: unknown }).lastSyncedAt
|
||||
: null;
|
||||
lastRemoteSyncCompleteAt =
|
||||
typeof syncedAt === "string" ? syncedAt : null;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
window.electronAPI?.onRemoteSyncStatusChanged?.((status) => {
|
||||
if (!status || status.syncing || status.lastError) return;
|
||||
const syncedAt =
|
||||
typeof status.lastSyncedAt === "string" ? status.lastSyncedAt : null;
|
||||
if (!syncedAt || syncedAt === lastRemoteSyncCompleteAt) return;
|
||||
|
||||
lastRemoteSyncCompleteAt = syncedAt;
|
||||
invalidateHostsAndStatusCaches();
|
||||
window.dispatchEvent(new CustomEvent("hosts:refresh"));
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
});
|
||||
}
|
||||
// A finished sync pass may have changed hosts and their statuses.
|
||||
window.addEventListener(SYNC_CHANGED_EVENT, () => {
|
||||
invalidateHostsAndStatusCaches();
|
||||
});
|
||||
}
|
||||
|
||||
export function getCachedSSHHosts(
|
||||
@@ -70,10 +47,6 @@ export function getCachedServerStatuses(
|
||||
return statusCache.get(loader);
|
||||
}
|
||||
|
||||
export function invalidateSSHHostsCache(): void {
|
||||
hostsCache.invalidate();
|
||||
}
|
||||
|
||||
export function invalidateServerStatusCache(): void {
|
||||
statusCache.invalidate();
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* The shape an axios rejection actually has at the call sites that inspect it.
|
||||
* A `catch` binding is `unknown`, and every one of these sites was reaching
|
||||
* into it directly; this gives them one narrowing helper instead.
|
||||
*/
|
||||
export interface HttpLikeError {
|
||||
message?: string;
|
||||
code?: string;
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
code?: string;
|
||||
fileNotFound?: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function asHttpError(error: unknown): HttpLikeError {
|
||||
return (error ?? {}) as HttpLikeError;
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { KeybindingAction } from "@/types/keybindings";
|
||||
import {
|
||||
hasSnippetInputs,
|
||||
resolveSnippetContent,
|
||||
type SnippetHostContext,
|
||||
} from "@/lib/snippet-variables";
|
||||
|
||||
export interface KeybindingDispatchContext {
|
||||
terminal: Terminal;
|
||||
webSocketRef: React.MutableRefObject<WebSocket | null>;
|
||||
writeTextToClipboard: (text: string) => Promise<boolean>;
|
||||
readTextFromClipboard: () => Promise<string>;
|
||||
getSnippetById: (id: string) => Promise<{ content: string } | undefined>;
|
||||
/** Host context used to silently resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
|
||||
hostContext?: SnippetHostContext | null;
|
||||
/**
|
||||
* Called instead of sending the snippet directly when its content still has
|
||||
* unresolved $INPUT_n placeholders after host-variable substitution -- the
|
||||
* caller is expected to collect values (e.g. via a dialog) and send itself.
|
||||
*/
|
||||
onSnippetNeedsInputs?: (snippet: { id: string; content: string }) => void;
|
||||
}
|
||||
|
||||
export function sendRawToSocket(
|
||||
webSocketRef: React.MutableRefObject<WebSocket | null>,
|
||||
data: string,
|
||||
): void {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchKeybindingAction(
|
||||
action: KeybindingAction,
|
||||
ctx: KeybindingDispatchContext,
|
||||
): void {
|
||||
const sendRaw = (data: string) => sendRawToSocket(ctx.webSocketRef, data);
|
||||
|
||||
switch (action.type) {
|
||||
case "copy": {
|
||||
const selection = ctx.terminal.getSelection();
|
||||
if (selection) {
|
||||
ctx.writeTextToClipboard(selection);
|
||||
ctx.terminal.clearSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "paste": {
|
||||
ctx.readTextFromClipboard().then((text) => {
|
||||
if (text) ctx.terminal.paste(text);
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "sendControlCode": {
|
||||
if (!action.controlCode) return;
|
||||
const code = action.controlCode.toLowerCase().charCodeAt(0) - 96;
|
||||
if (code >= 1 && code <= 26) sendRaw(String.fromCharCode(code));
|
||||
return;
|
||||
}
|
||||
case "sendText": {
|
||||
sendRaw((action.text ?? "") + (action.appendEnter ? "\r" : ""));
|
||||
return;
|
||||
}
|
||||
case "runSnippet": {
|
||||
if (!action.snippetId) return;
|
||||
ctx.getSnippetById(action.snippetId).then((snippet) => {
|
||||
if (!snippet) return;
|
||||
if (hasSnippetInputs(snippet.content)) {
|
||||
ctx.onSnippetNeedsInputs?.({
|
||||
id: action.snippetId!,
|
||||
content: snippet.content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const resolved = resolveSnippetContent(
|
||||
snippet.content,
|
||||
ctx.hostContext ?? null,
|
||||
{},
|
||||
);
|
||||
sendRaw(resolved + (action.appendEnter !== false ? "\r" : ""));
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "nextTab":
|
||||
case "previousTab":
|
||||
case "openCommandPalette": {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:global-keybinding", {
|
||||
detail: { type: action.type },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
export interface KeyedRequestCache<T> {
|
||||
get(
|
||||
key: string,
|
||||
loader: () => Promise<T>,
|
||||
options?: { force?: boolean },
|
||||
): Promise<T>;
|
||||
peek(key: string, maxAgeMs?: number): T | null;
|
||||
set(key: string, value: T): void;
|
||||
invalidate(key?: string): void;
|
||||
}
|
||||
|
||||
/** Bounded per-key cache with in-flight request coalescing. */
|
||||
export function createKeyedRequestCache<T>(
|
||||
ttlMs: number,
|
||||
maxEntries = 100,
|
||||
): KeyedRequestCache<T> {
|
||||
const values = new Map<string, { value: T; storedAt: number }>();
|
||||
const inflight = new Map<string, Promise<T>>();
|
||||
const versions = new Map<string, number>();
|
||||
|
||||
const store = (key: string, value: T) => {
|
||||
values.delete(key);
|
||||
values.set(key, { value, storedAt: Date.now() });
|
||||
while (values.size > Math.max(1, maxEntries)) {
|
||||
const oldest = values.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
values.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get(key, loader, options = {}) {
|
||||
const cached = values.get(key);
|
||||
if (!options.force && cached && Date.now() - cached.storedAt < ttlMs) {
|
||||
return Promise.resolve(cached.value);
|
||||
}
|
||||
|
||||
const pending = inflight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const version = versions.get(key) ?? 0;
|
||||
const request = loader()
|
||||
.then((value) => {
|
||||
if ((versions.get(key) ?? 0) === version) store(key, value);
|
||||
return value;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inflight.get(key) === request) inflight.delete(key);
|
||||
});
|
||||
inflight.set(key, request);
|
||||
return request;
|
||||
},
|
||||
|
||||
peek(key, maxAgeMs = Number.POSITIVE_INFINITY) {
|
||||
const cached = values.get(key);
|
||||
if (!cached || Date.now() - cached.storedAt > maxAgeMs) return null;
|
||||
values.delete(key);
|
||||
values.set(key, cached);
|
||||
return cached.value;
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1);
|
||||
inflight.delete(key);
|
||||
store(key, value);
|
||||
},
|
||||
|
||||
invalidate(key) {
|
||||
if (key === undefined) {
|
||||
for (const activeKey of inflight.keys()) {
|
||||
versions.set(activeKey, (versions.get(activeKey) ?? 0) + 1);
|
||||
}
|
||||
values.clear();
|
||||
inflight.clear();
|
||||
} else {
|
||||
versions.set(key, (versions.get(key) ?? 0) + 1);
|
||||
values.delete(key);
|
||||
inflight.delete(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { authApi } from "@/main-axios";
|
||||
import { SYNC_CHANGED_EVENT } from "@/lib/sync-events";
|
||||
|
||||
export { SYNC_CHANGED_EVENT };
|
||||
|
||||
/** The server this desktop is linked to, and the session it syncs with. */
|
||||
export interface LinkedSession {
|
||||
serverUrl: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
const CACHE_MS = 60_000;
|
||||
let cached: { at: number; value: LinkedSession | null } | null = null;
|
||||
let inflight: Promise<LinkedSession | null> | null = null;
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(SYNC_CHANGED_EVENT, () => {
|
||||
cached = null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Null on the web, or when this desktop is not linked or signed out. */
|
||||
export async function getLinkedSession(): Promise<LinkedSession | null> {
|
||||
if (!isElectron()) return null;
|
||||
if (cached && Date.now() - cached.at < CACHE_MS) return cached.value;
|
||||
if (!inflight) {
|
||||
inflight = authApi
|
||||
.get("/sync/link/session")
|
||||
.then((response) => {
|
||||
const { serverUrl, token } = response.data ?? {};
|
||||
const value =
|
||||
typeof serverUrl === "string" && typeof token === "string"
|
||||
? { serverUrl: serverUrl.replace(/\/$/, ""), token }
|
||||
: null;
|
||||
cached = { at: Date.now(), value };
|
||||
return value;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
}
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function notifySyncChanged(): void {
|
||||
cached = null;
|
||||
window.dispatchEvent(new CustomEvent(SYNC_CHANGED_EVENT));
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Thin, typed access to the desktop app's local filesystem bridge
|
||||
// (electron/local-files.cjs via preload.js). Everything here resolves to
|
||||
// "unavailable" outside Electron so callers can feature-detect cheaply.
|
||||
|
||||
import type {
|
||||
LocalDirectoryListing,
|
||||
LocalFsHomeInfo,
|
||||
LocalFsResult,
|
||||
LocalTrashResult,
|
||||
LocalWalkResult,
|
||||
} from "@/types/electron";
|
||||
import { isElectron } from "./electron";
|
||||
|
||||
export function isLocalFileBrowserAvailable(): boolean {
|
||||
if (!isElectron()) return false;
|
||||
const api = window.electronAPI;
|
||||
return !!api?.localFs && !!api?.localTransfer;
|
||||
}
|
||||
|
||||
function requireLocalFs() {
|
||||
const api = window.electronAPI?.localFs;
|
||||
if (!api) {
|
||||
throw new Error("Local file access is only available in the desktop app");
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
function unwrap<T>(result: LocalFsResult<T>): T {
|
||||
if (!result || result.success !== true) {
|
||||
const message =
|
||||
result && "error" in result && result.error
|
||||
? result.error
|
||||
: "Local file operation failed";
|
||||
const error = new Error(message) as Error & { code?: string };
|
||||
if (result && "code" in result) error.code = result.code;
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getLocalHome(): Promise<LocalFsHomeInfo> {
|
||||
return unwrap(await requireLocalFs().home());
|
||||
}
|
||||
|
||||
export async function listLocalDirectory(
|
||||
dirPath: string,
|
||||
): Promise<LocalDirectoryListing> {
|
||||
return unwrap(await requireLocalFs().list(dirPath));
|
||||
}
|
||||
|
||||
export async function createLocalFolder(
|
||||
parentPath: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().mkdir(parentPath, name)).path;
|
||||
}
|
||||
|
||||
export async function createLocalFile(
|
||||
parentPath: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().createFile(parentPath, name)).path;
|
||||
}
|
||||
|
||||
export async function renameLocalEntry(
|
||||
oldPath: string,
|
||||
newName: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().rename(oldPath, newName)).path;
|
||||
}
|
||||
|
||||
/** Moves entries to the OS trash; resolves with per-path failures, if any. */
|
||||
export async function trashLocalPaths(
|
||||
paths: string[],
|
||||
): Promise<LocalTrashResult> {
|
||||
return unwrap(await requireLocalFs().trash(paths));
|
||||
}
|
||||
|
||||
/** Subset of `paths` that already exist on disk. */
|
||||
export async function localPathsExist(paths: string[]): Promise<string[]> {
|
||||
if (paths.length === 0) return [];
|
||||
return unwrap(await requireLocalFs().exists(paths)).existing;
|
||||
}
|
||||
|
||||
/** Creates `dirPath` (and parents). With `rootPath`, refuses anything outside it. */
|
||||
export async function ensureLocalDirectory(
|
||||
dirPath: string,
|
||||
rootPath?: string,
|
||||
): Promise<string> {
|
||||
return unwrap(await requireLocalFs().ensureDir(dirPath, rootPath)).path;
|
||||
}
|
||||
|
||||
export async function walkLocalPaths(
|
||||
paths: string[],
|
||||
): Promise<LocalWalkResult> {
|
||||
return unwrap(await requireLocalFs().walk(paths));
|
||||
}
|
||||
|
||||
export async function revealLocalPath(targetPath: string): Promise<void> {
|
||||
unwrap(await requireLocalFs().reveal(targetPath));
|
||||
}
|
||||
|
||||
export async function openLocalPath(targetPath: string): Promise<void> {
|
||||
unwrap(await requireLocalFs().open(targetPath));
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { isMacPlatform } from "@/lib/tab-jump-hotkey";
|
||||
|
||||
export function getMacLineNavigationSequence(e: KeyboardEvent): string | null {
|
||||
if (e.type !== "keydown" || !isMacPlatform()) return null;
|
||||
if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return null;
|
||||
|
||||
switch (e.code) {
|
||||
case "ArrowLeft":
|
||||
return "\x01";
|
||||
case "ArrowRight":
|
||||
return "\x05";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Frontend copy of the permission matching rules.
|
||||
*
|
||||
* This mirrors PermissionManager.hasPermission in
|
||||
* src/backend/utils/permission-manager.ts and has to stay in step with it. It
|
||||
* is a second implementation of one rule, which is a real cost, accepted here
|
||||
* only because this copy is cosmetic: it decides what UI to show, never what
|
||||
* a request is allowed to do. The server checks again on every route.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether a granted permission set covers the one required.
|
||||
*
|
||||
* Order matches the backend: the "*" superuser grant, an exact match, then
|
||||
* progressively shorter dotted wildcards, then admin last.
|
||||
*/
|
||||
export function matchesPermission(
|
||||
granted: readonly string[],
|
||||
isAdmin: boolean,
|
||||
required: string,
|
||||
): boolean {
|
||||
if (granted.includes("*")) return true;
|
||||
if (granted.includes(required)) return true;
|
||||
|
||||
const parts = required.split(".");
|
||||
for (let i = parts.length; i > 0; i--) {
|
||||
if (granted.includes(parts.slice(0, i).join(".") + ".*")) return true;
|
||||
}
|
||||
|
||||
return isAdmin;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Where a plugin's i18n keys resolve from.
|
||||
*
|
||||
* A manifest writes plugin-relative keys ("permissions.devices.view.title") so
|
||||
* it never has to know where the strings live. They live in the plugin's own
|
||||
* locales/en.json, loaded into an i18next namespace named after the plugin,
|
||||
* so the qualified key is "<pluginId>:<key>". A key that already names a
|
||||
* namespace is left alone. Missing keys fall back to core strings.
|
||||
*/
|
||||
export function pluginKey(pluginId: string, key: string): string;
|
||||
export function pluginKey(
|
||||
pluginId: string,
|
||||
key: string | undefined,
|
||||
): string | undefined;
|
||||
export function pluginKey(
|
||||
pluginId: string,
|
||||
key: string | undefined,
|
||||
): string | undefined {
|
||||
if (!key) return key;
|
||||
return key.includes(":") ? key : `${pluginId}:${key}`;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { icons, type LucideProps } from "lucide-react";
|
||||
import { Puzzle } from "lucide-react";
|
||||
|
||||
/**
|
||||
* A manifest names its icon as a Lucide component name ("Sparkles").
|
||||
*
|
||||
* An unknown or missing name falls back to a generic plugin glyph rather than
|
||||
* rendering nothing, so a plugin shipping a typo still gets a row an admin can
|
||||
* read.
|
||||
*/
|
||||
export function PluginIcon({
|
||||
name,
|
||||
...props
|
||||
}: { name?: string } & LucideProps) {
|
||||
const Icon = (name && icons[name as keyof typeof icons]) || Puzzle;
|
||||
return <Icon {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* How a plugin frontend reaches its own backend.
|
||||
*
|
||||
* Every plugin serves HTTP under /plugin-api/<id>/ and WebSockets under
|
||||
* /plugin-ws/<id>/<path> on the main backend. No plugin has a port of its own,
|
||||
* so these two helpers are all a plugin frontend needs, in every deployment:
|
||||
*
|
||||
* - web, same origin, optionally under a base path;
|
||||
* - Docker behind nginx, which proxies both prefixes;
|
||||
* - the Vite dev server, which proxies through /__termix_api/30001;
|
||||
* - Electron with its embedded local backend;
|
||||
* - Electron pointed at a remote Termix server, for hosts whose connection
|
||||
* origin resolves there.
|
||||
*
|
||||
* A7 re-exports both through @termix/plugin-sdk/frontend. They live here for
|
||||
* now because plugin frontends are still bundled with the shell.
|
||||
*/
|
||||
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { authApi, createRemoteOriginApiInstance } from "@/main-axios";
|
||||
import { getBasePath } from "@/lib/base-path";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getDeviceId } from "@/lib/device-id";
|
||||
import { websocketAuthProtocols } from "@/lib/ws-auth";
|
||||
import {
|
||||
buildOriginWsUrl,
|
||||
type ConnectionOrigin,
|
||||
type WebSocketConnectionTarget,
|
||||
} from "@/lib/connection-origin";
|
||||
|
||||
/** The backend port. Everything plugin-facing rides the main server. */
|
||||
const BACKEND_PORT = 30001;
|
||||
|
||||
function pluginApiPath(pluginId: string, path = ""): string {
|
||||
const suffix = path && !path.startsWith("/") ? `/${path}` : path;
|
||||
return `/plugin-api/${pluginId}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* An axios client rooted at this plugin's /plugin-api mount point.
|
||||
*
|
||||
* Built on the shared authenticated instance, so a plugin inherits the same
|
||||
* interceptors as core: auth headers, session expiry handling and error
|
||||
* reporting all behave identically.
|
||||
*/
|
||||
export function createPluginApi(pluginId: string): AxiosInstance {
|
||||
const prefix = pluginApiPath(pluginId);
|
||||
|
||||
// A thin proxy rather than a new axios instance: the shared one resolves its
|
||||
// baseURL per deployment already, and duplicating that here is how the two
|
||||
// drift apart.
|
||||
return new Proxy(authApi, {
|
||||
get(target, property, receiver) {
|
||||
const value = Reflect.get(target, property, receiver);
|
||||
if (typeof value !== "function") return value;
|
||||
|
||||
if (
|
||||
property === "get" ||
|
||||
property === "delete" ||
|
||||
property === "head" ||
|
||||
property === "options" ||
|
||||
property === "post" ||
|
||||
property === "put" ||
|
||||
property === "patch"
|
||||
) {
|
||||
return (url: string, ...rest: unknown[]) =>
|
||||
(value as (...args: unknown[]) => unknown).call(
|
||||
target,
|
||||
`${prefix}${url.startsWith("/") ? url : `/${url}`}`,
|
||||
...rest,
|
||||
);
|
||||
}
|
||||
|
||||
return value.bind(target);
|
||||
},
|
||||
}) as AxiosInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw fetch on this plugin's /plugin-api mount point, for a response the
|
||||
* caller reads as a stream (server-sent events). axios buffers the whole
|
||||
* body, so it cannot. Sends the same auth the shared client does.
|
||||
*/
|
||||
export function pluginFetch(
|
||||
pluginId: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
const base = (authApi.defaults.baseURL ?? "").replace(/\/+$/, "");
|
||||
const headers = new Headers(init.headers);
|
||||
const deviceId = getDeviceId();
|
||||
if (deviceId) headers.set("X-Termix-Device-ID", deviceId);
|
||||
if (isElectron()) {
|
||||
headers.set("X-Electron-App", "true");
|
||||
const jwt = localStorage.getItem("jwt");
|
||||
if (jwt) headers.set("Authorization", `Bearer ${jwt}`);
|
||||
}
|
||||
return fetch(`${base}${pluginApiPath(pluginId, path)}`, {
|
||||
credentials: "include",
|
||||
...init,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
const remotePluginApis = new Map<string, AxiosInstance>();
|
||||
|
||||
/**
|
||||
* The same client pointed at the connected remote server, for a desktop app
|
||||
* host whose connection origin is "remote". Outside Electron, and for
|
||||
* "local", it is the ordinary client.
|
||||
*/
|
||||
export function pluginApiFor(
|
||||
pluginId: string,
|
||||
origin: ConnectionOrigin | undefined,
|
||||
local: AxiosInstance,
|
||||
): AxiosInstance {
|
||||
if (origin !== "remote" || !isElectron()) return local;
|
||||
let client = remotePluginApis.get(pluginId);
|
||||
if (!client) {
|
||||
client = createRemoteOriginApiInstance(pluginApiPath(pluginId));
|
||||
remotePluginApis.set(pluginId, client);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
|
||||
* carry the JWT.
|
||||
*
|
||||
* In Electron the right backend depends on where the host actually lives, so
|
||||
* pass the resolved origin; without one it targets the embedded local backend,
|
||||
* which is what a plugin with no per-host notion of origin wants.
|
||||
*/
|
||||
export async function pluginWsUrl(
|
||||
pluginId: string,
|
||||
path: string,
|
||||
options: { origin?: ConnectionOrigin } = {},
|
||||
): Promise<WebSocketConnectionTarget | null> {
|
||||
const suffix = path.startsWith("/") ? path : `/${path}`;
|
||||
const route = `/plugin-ws/${pluginId}${suffix}`;
|
||||
|
||||
if (isElectron()) {
|
||||
return buildOriginWsUrl({
|
||||
origin: options.origin ?? "local",
|
||||
localPort: BACKEND_PORT,
|
||||
localPath: route,
|
||||
remotePath: route,
|
||||
});
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("jwt");
|
||||
const protocols = websocketAuthProtocols(token);
|
||||
|
||||
// Dev without a configured API host goes through Vite's proxy, which is
|
||||
// reached on the page's own origin rather than the backend port.
|
||||
const devProxy =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
!import.meta.env.VITE_API_HOST &&
|
||||
(window.location.port === "3000" || window.location.port === "5173");
|
||||
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
|
||||
if (devProxy) {
|
||||
return {
|
||||
url: `${wsProtocol}://${window.location.host}/__termix_api/${BACKEND_PORT}${route}`,
|
||||
protocols,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: `${wsProtocol}://${window.location.host}${getBasePath()}${route}`,
|
||||
protocols,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* A keyed, reactive list of contributions.
|
||||
*
|
||||
* Plugins register and unregister at runtime, so anything the shell renders
|
||||
* from a registry has to re-render when it changes. The snapshot array is
|
||||
* cached until the next change because useSyncExternalStore compares by
|
||||
* identity and loops forever on a fresh array.
|
||||
*/
|
||||
export interface Registry<T extends { id: string }> {
|
||||
/** Adds or replaces by id. The disposer only removes this exact entry. */
|
||||
register: (item: T) => () => void;
|
||||
unregister: (id: string) => void;
|
||||
get: (id: string) => T | undefined;
|
||||
list: () => T[];
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
/** React hook over list(). */
|
||||
useList: () => T[];
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export function createRegistry<T extends { id: string }>(
|
||||
sort?: (a: T, b: T) => number,
|
||||
): Registry<T> {
|
||||
const items = new Map<string, T>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: T[] | null = null;
|
||||
|
||||
const emit = () => {
|
||||
snapshot = null;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
const list = () => {
|
||||
if (!snapshot) {
|
||||
snapshot = [...items.values()];
|
||||
if (sort) snapshot.sort(sort);
|
||||
}
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
register(item) {
|
||||
items.set(item.id, item);
|
||||
emit();
|
||||
return () => {
|
||||
if (items.get(item.id) === item) {
|
||||
items.delete(item.id);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
},
|
||||
unregister(id) {
|
||||
if (items.delete(id)) emit();
|
||||
},
|
||||
get: (id) => items.get(id),
|
||||
list,
|
||||
subscribe,
|
||||
useList: () => useSyncExternalStore(subscribe, list, list),
|
||||
reset() {
|
||||
items.clear();
|
||||
emit();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Orders by `order`, then id, so plugin contributions sort stably. */
|
||||
export function byOrderThenId<T extends { id: string; order?: number }>(
|
||||
a: T,
|
||||
b: T,
|
||||
): number {
|
||||
return (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
const ROOT_PX = 14;
|
||||
|
||||
/**
|
||||
* A length in px at the Normal interface size, as rem, so it follows the
|
||||
* interface size setting like Tailwind classes do.
|
||||
*/
|
||||
export function rem(px: number): string {
|
||||
return `${px / ROOT_PX}rem`;
|
||||
}
|
||||
|
||||
/** How many real pixels one Normal-size pixel takes right now. */
|
||||
export function remScale(): number {
|
||||
const size = parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize || `${ROOT_PX}`,
|
||||
);
|
||||
return Number.isFinite(size) && size > 0 ? size / ROOT_PX : 1;
|
||||
}
|
||||
@@ -1,97 +1,34 @@
|
||||
import type { AxiosInstance } from "axios";
|
||||
import { getRemoteStatsApi } from "@/main-axios";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { getRemoteCoreApi } from "@/main-axios";
|
||||
import { getLinkedSession } from "@/lib/linked-server";
|
||||
|
||||
export function markRemoteSharedHosts(hosts: SSHHost[]): SSHHost[] {
|
||||
return hosts
|
||||
.filter((host) => host.isShared)
|
||||
.map((host) => ({
|
||||
...host,
|
||||
// Local SQLite ids are positive. Negative ids keep remote-only shared
|
||||
// rows distinct while syncId remains the delegated backend identity.
|
||||
id: -Math.abs(host.id),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface SharedHostConnectionAuth {
|
||||
username?: string | null;
|
||||
authType?: string | null;
|
||||
password?: string | null;
|
||||
key?: string | null;
|
||||
keyPassword?: string | null;
|
||||
keyType?: string | null;
|
||||
}
|
||||
|
||||
export async function getRemoteSharedHostConnectionAuth(
|
||||
localHostId: number,
|
||||
): Promise<SharedHostConnectionAuth> {
|
||||
if (localHostId >= 0) {
|
||||
throw new Error("Expected a remote shared host id");
|
||||
}
|
||||
const api = await getConnectedRemoteApi();
|
||||
if (!api) throw new Error("Remote server is not connected");
|
||||
const response = await api.get(
|
||||
`/host/db/host/${Math.abs(localHostId)}/local-connection-auth`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function hydrateLocalSharedHostAuth<
|
||||
T extends {
|
||||
id?: number;
|
||||
isShared?: unknown;
|
||||
syncId?: string | null;
|
||||
credentialId?: number;
|
||||
username: string;
|
||||
authType?: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
},
|
||||
>(host: T): Promise<T> {
|
||||
if (!host.isShared || typeof host.id !== "number" || host.id >= 0) {
|
||||
return host;
|
||||
}
|
||||
|
||||
const auth = await getRemoteSharedHostConnectionAuth(host.id);
|
||||
return {
|
||||
...host,
|
||||
// This row deliberately does not exist in the embedded database. Avoid
|
||||
// asking the local backend to resolve its remote sync identity again.
|
||||
syncId: null,
|
||||
credentialId: undefined,
|
||||
username: auth.username || host.username,
|
||||
authType: auth.authType || host.authType,
|
||||
password: auth.password || undefined,
|
||||
key: auth.key || undefined,
|
||||
keyPassword: auth.keyPassword || undefined,
|
||||
keyType: auth.keyType || undefined,
|
||||
};
|
||||
/**
|
||||
* Hosts shared with a linked account arrive through sync as read-only local
|
||||
* copies that already carry the auth the owner shared, so a connection needs
|
||||
* nothing extra. Kept for plugins that still call it.
|
||||
*/
|
||||
export async function hydrateLocalSharedHostAuth<T>(host: T): Promise<T> {
|
||||
return host;
|
||||
}
|
||||
|
||||
/** The linked server's core API, or null when this desktop is not linked. */
|
||||
export async function getConnectedRemoteApi(): Promise<AxiosInstance | null> {
|
||||
if (!isElectron()) return null;
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
return config?.serverUrl ? getRemoteStatsApi() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return (await getLinkedSession()) ? getRemoteCoreApi() : null;
|
||||
}
|
||||
|
||||
/** The linked server's own id for a host, found by its sync id. */
|
||||
export async function resolveRemoteHostId(
|
||||
syncId: string | null | undefined,
|
||||
): Promise<number | null> {
|
||||
if (!syncId) return null;
|
||||
const api = await getConnectedRemoteApi();
|
||||
if (!api) return null;
|
||||
const response = await api.get("/sync/hosts");
|
||||
const host = (response.data?.rows ?? []).find(
|
||||
(row: { syncId?: string }) => row.syncId === syncId,
|
||||
);
|
||||
return typeof host?.id === "number" ? host.id : null;
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/sync/v2/hosts/${encodeURIComponent(syncId)}`,
|
||||
);
|
||||
return typeof response.data?.id === "number" ? response.data.id : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
|
||||
export async function requestRemoteSync(): Promise<void> {
|
||||
if (!isElectron()) return;
|
||||
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
if (config?.serverUrl) {
|
||||
await window.electronAPI?.invoke?.("remote-sync-now");
|
||||
}
|
||||
} catch {
|
||||
// The periodic sync remains the fallback when IPC or the server is down.
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export function normalizeServiceLinkUrl(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)
|
||||
? trimmed
|
||||
: `http://${trimmed}`;
|
||||
}
|
||||
|
||||
export function isValidServiceLinkUrl(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(normalizeServiceLinkUrl(value));
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
export interface SnippetInput {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SnippetHostContext {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
|
||||
export function extractSnippetInputs(content: string): SnippetInput[] {
|
||||
const seen = new Map<string, SnippetInput>();
|
||||
let match: RegExpExecArray | null;
|
||||
INPUT_PATTERN.lastIndex = 0;
|
||||
while ((match = INPUT_PATTERN.exec(content)) !== null) {
|
||||
const digits = match[1] ?? match[3];
|
||||
const key = `INPUT_${digits}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, { key, label: match[2]?.trim() || `Input ${digits}` });
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
export function hasSnippetInputs(content: string): boolean {
|
||||
INPUT_PATTERN.lastIndex = 0;
|
||||
return INPUT_PATTERN.test(content);
|
||||
}
|
||||
|
||||
function replaceVar(
|
||||
content: string,
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
): string {
|
||||
if (value === undefined) return content;
|
||||
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
|
||||
return content.replace(pattern, value);
|
||||
}
|
||||
|
||||
export function resolveSnippetContent(
|
||||
content: string,
|
||||
host: SnippetHostContext | null,
|
||||
inputValues: Record<string, string> = {},
|
||||
): string {
|
||||
let resolved = content;
|
||||
|
||||
resolved = replaceVar(resolved, "HOST", host?.ip);
|
||||
resolved = replaceVar(resolved, "USER", host?.username);
|
||||
resolved = replaceVar(
|
||||
resolved,
|
||||
"PORT",
|
||||
host?.port !== undefined ? String(host.port) : undefined,
|
||||
);
|
||||
resolved = replaceVar(resolved, "NAME", host?.name);
|
||||
|
||||
resolved = resolved.replace(
|
||||
INPUT_PATTERN,
|
||||
(
|
||||
fullMatch,
|
||||
braceDigits: string | undefined,
|
||||
_label,
|
||||
plainDigits: string | undefined,
|
||||
) => {
|
||||
const key = `INPUT_${braceDigits ?? plainDigits}`;
|
||||
return key in inputValues ? inputValues[key] : fullMatch;
|
||||
},
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// Module-level flag: true while a split pane divider is being dragged.
|
||||
// TerminalTab reads this to suppress fit() calls during drag.
|
||||
export const splitDragState = { active: false };
|
||||
|
||||
// Callbacks registered by terminal instances to trigger a fit after drag ends.
|
||||
const fitCallbacks = new Set<() => void>();
|
||||
|
||||
export function registerFitCallback(fn: () => void) {
|
||||
fitCallbacks.add(fn);
|
||||
return () => fitCallbacks.delete(fn);
|
||||
}
|
||||
|
||||
export function notifyDragEnd() {
|
||||
for (const fn of fitCallbacks) fn();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Fired whenever the desktop's link or its synced data changed. */
|
||||
export const SYNC_CHANGED_EVENT = "termix:sync-changed";
|
||||
@@ -0,0 +1,86 @@
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { getSyncStatus, type SyncStatus } from "@/api/sync-api";
|
||||
import { notifySyncChanged } from "@/lib/linked-server";
|
||||
|
||||
/**
|
||||
* The desktop's sync status, polled from the embedded backend while anything
|
||||
* shows it. When a pass finishes, the views that show synced data are told
|
||||
* to reload.
|
||||
*/
|
||||
|
||||
const IDLE_MS = 15_000;
|
||||
const BUSY_MS = 2_000;
|
||||
|
||||
let snapshot: SyncStatus | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastSyncAt: string | null | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function announce(): void {
|
||||
notifySyncChanged();
|
||||
window.dispatchEvent(new CustomEvent("hosts:refresh"));
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
|
||||
window.dispatchEvent(new CustomEvent("termix:plugins-changed"));
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
timer = null;
|
||||
try {
|
||||
const next = await getSyncStatus();
|
||||
const syncedAt = next.lastSyncAt ?? null;
|
||||
if (lastSyncAt !== undefined && syncedAt !== lastSyncAt) announce();
|
||||
if (snapshot && snapshot.linked !== next.linked) notifySyncChanged();
|
||||
lastSyncAt = syncedAt;
|
||||
snapshot = next;
|
||||
emit();
|
||||
} catch {
|
||||
// The backend may still be starting; the next poll tries again.
|
||||
}
|
||||
schedule();
|
||||
}
|
||||
|
||||
function schedule(delay?: number): void {
|
||||
if (listeners.size === 0 || timer) return;
|
||||
const busy = snapshot?.status === "syncing" || !!snapshot?.pending;
|
||||
timer = setTimeout(() => void poll(), delay ?? (busy ? BUSY_MS : IDLE_MS));
|
||||
}
|
||||
|
||||
export function subscribeSyncStatus(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
if (isElectron() && listeners.size === 1 && !timer) void poll();
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0 && timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getSyncStatusSnapshot(): SyncStatus | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Takes a status a request already returned, and checks again soon. */
|
||||
export function setSyncStatus(next: SyncStatus): void {
|
||||
snapshot = next;
|
||||
if (lastSyncAt !== undefined && (next.lastSyncAt ?? null) !== lastSyncAt) {
|
||||
announce();
|
||||
}
|
||||
lastSyncAt = next.lastSyncAt ?? null;
|
||||
emit();
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
schedule(BUSY_MS);
|
||||
}
|
||||
|
||||
export function refreshSyncStatus(): void {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
void poll();
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TerminalLocalEcho, resolveLocalEchoMode } from "./terminal-local-echo";
|
||||
|
||||
describe("TerminalLocalEcho", () => {
|
||||
it("renders immediately and suppresses the matching remote echo", () => {
|
||||
const echo = new TerminalLocalEcho("on");
|
||||
expect(echo.handleInput("a")).toBe("a");
|
||||
expect(echo.handleOutput("a")).toBe("");
|
||||
});
|
||||
|
||||
it("rolls back a prediction when remote output differs", () => {
|
||||
const echo = new TerminalLocalEcho("on");
|
||||
echo.handleInput("a");
|
||||
expect(echo.handleOutput("z")).toBe("\x1b[1D\x1b[1Xz");
|
||||
});
|
||||
|
||||
it("only erases the predicted cells during rollback", () => {
|
||||
const echo = new TerminalLocalEcho("on");
|
||||
echo.handleInput("a");
|
||||
echo.handleInput("b");
|
||||
expect(echo.handleOutput("\x1b[C")).toBe("\x1b[2D\x1b[2X\x1b[C");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["split prompt", ["Pass", "word: "]],
|
||||
["sudo prompt", ["[sudo] password for alice: "]],
|
||||
["ssh-keygen prompt", ["Enter passphrase (empty for no passphrase): "]],
|
||||
["passwd prompt", ["New password: "]],
|
||||
])("does not expose input after a %s", (_name, chunks) => {
|
||||
const echo = new TerminalLocalEcho("on");
|
||||
for (const chunk of chunks) expect(echo.handleOutput(chunk)).toBe(chunk);
|
||||
expect(echo.handleInput("s")).toBe("");
|
||||
});
|
||||
|
||||
it("does not treat ordinary password text as a hidden-input prompt", () => {
|
||||
const echo = new TerminalLocalEcho("on");
|
||||
expect(echo.handleOutput("Your password has expired\r\n$ ")).toBe(
|
||||
"Your password has expired\r\n$ ",
|
||||
);
|
||||
expect(echo.handleInput("s")).toBe("s");
|
||||
});
|
||||
|
||||
it("does not predict control input, paste, or wide characters", () => {
|
||||
const echo = new TerminalLocalEcho("on");
|
||||
expect(echo.handleInput("\t")).toBe("");
|
||||
expect(echo.handleInput("paste")).toBe("");
|
||||
expect(echo.handleInput("界")).toBe("");
|
||||
});
|
||||
|
||||
it("enables automatic prediction after repeated slow echoes", () => {
|
||||
let now = 0;
|
||||
const echo = new TerminalLocalEcho("auto", () => now, 100);
|
||||
expect(echo.handleInput("a")).toBe("");
|
||||
now = 150;
|
||||
expect(echo.handleOutput("a")).toBe("a");
|
||||
expect(echo.handleInput("b")).toBe("");
|
||||
now = 300;
|
||||
expect(echo.handleOutput("b")).toBe("b");
|
||||
expect(echo.handleInput("c")).toBe("c");
|
||||
});
|
||||
|
||||
it("uses an explicit host mode before the global mode", () => {
|
||||
expect(resolveLocalEchoMode("off", "on")).toBe("off");
|
||||
expect(resolveLocalEchoMode("default", "on")).toBe("on");
|
||||
expect(resolveLocalEchoMode(undefined, null)).toBe("auto");
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
export type LocalEchoMode = "off" | "auto" | "on";
|
||||
|
||||
const PASSWORD_PROMPT =
|
||||
/(?:password|passphrase|verification code|one[- ]time|otp|token)(?:[^\r\n::]{0,160})?[::]\s*$/i;
|
||||
const SAFE_INPUT = /^[\x20-\x7e]$/;
|
||||
|
||||
type PendingCharacter = { value: string; sentAt: number; predicted: boolean };
|
||||
|
||||
export class TerminalLocalEcho {
|
||||
private pending: PendingCharacter[] = [];
|
||||
private slowSamples = 0;
|
||||
private secretInput = false;
|
||||
private outputTail = "";
|
||||
|
||||
constructor(
|
||||
private readonly mode: LocalEchoMode,
|
||||
private readonly now: () => number = () => performance.now(),
|
||||
private readonly latencyThreshold = 120,
|
||||
) {}
|
||||
|
||||
handleInput(data: string): string {
|
||||
if (data === "\r" || data === "\n") {
|
||||
return "";
|
||||
}
|
||||
if (this.secretInput || !SAFE_INPUT.test(data)) return "";
|
||||
|
||||
const predicted =
|
||||
this.mode === "on" || (this.mode === "auto" && this.slowSamples >= 2);
|
||||
this.pending.push({ value: data, sentAt: this.now(), predicted });
|
||||
return predicted ? data : "";
|
||||
}
|
||||
|
||||
handleOutput(data: string): string {
|
||||
const plainOutput = this.stripAnsi(data);
|
||||
this.outputTail = (this.outputTail + plainOutput).slice(-128);
|
||||
if (PASSWORD_PROMPT.test(this.outputTail)) {
|
||||
const rollback = this.rollback();
|
||||
this.secretInput = true;
|
||||
return rollback + data;
|
||||
}
|
||||
|
||||
if (this.secretInput) {
|
||||
if (data.includes("\n") || data.includes("\r")) this.secretInput = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
if (this.pending.length === 0) return data;
|
||||
|
||||
let consumed = 0;
|
||||
while (
|
||||
consumed < data.length &&
|
||||
consumed < this.pending.length &&
|
||||
data[consumed] === this.pending[consumed].value
|
||||
) {
|
||||
const item = this.pending[consumed];
|
||||
if (
|
||||
!item.predicted &&
|
||||
this.now() - item.sentAt >= this.latencyThreshold
|
||||
) {
|
||||
this.slowSamples += 1;
|
||||
}
|
||||
consumed += 1;
|
||||
}
|
||||
|
||||
if (consumed > 0) {
|
||||
const matched = this.pending.splice(0, consumed);
|
||||
const predictedCount = matched.filter((item) => item.predicted).length;
|
||||
return data.slice(predictedCount);
|
||||
}
|
||||
|
||||
return this.rollback() + data;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.pending = [];
|
||||
this.slowSamples = 0;
|
||||
this.secretInput = false;
|
||||
this.outputTail = "";
|
||||
}
|
||||
|
||||
private rollback() {
|
||||
const count = this.pending.filter((item) => item.predicted).length;
|
||||
this.pending = [];
|
||||
return count > 0 ? `\x1b[${count}D\x1b[${count}X` : "";
|
||||
}
|
||||
|
||||
private stripAnsi(value: string) {
|
||||
return value.replace(/\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveLocalEchoMode(
|
||||
hostMode: "default" | LocalEchoMode | undefined,
|
||||
storedMode: string | null,
|
||||
): LocalEchoMode {
|
||||
if (hostMode && hostMode !== "default") return hostMode;
|
||||
return storedMode === "on" || storedMode === "off" || storedMode === "auto"
|
||||
? storedMode
|
||||
: "auto";
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
export type MacroStep =
|
||||
| { id: string; type: "send"; text: string; pressEnter: boolean }
|
||||
| { id: string; type: "delay"; milliseconds: number }
|
||||
| {
|
||||
id: string;
|
||||
type: "wait";
|
||||
pattern: string;
|
||||
isRegex?: boolean;
|
||||
flags?: string;
|
||||
timeoutMs: number;
|
||||
onTimeout: "stop" | "continue";
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "if";
|
||||
pattern: string;
|
||||
isRegex?: boolean;
|
||||
flags?: string;
|
||||
then: MacroStep[];
|
||||
else: MacroStep[];
|
||||
}
|
||||
| { id: string; type: "repeat"; count: number; steps: MacroStep[] };
|
||||
|
||||
export interface TerminalMacro {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: MacroStep[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MacroTerminalAdapter {
|
||||
send(data: string): void;
|
||||
subscribe(listener: (data: string) => void): () => void;
|
||||
}
|
||||
|
||||
export type MacroRunEvent =
|
||||
| { type: "step"; stepId: string }
|
||||
| { type: "matched"; stepId: string; match: string }
|
||||
| { type: "timeout"; stepId: string }
|
||||
| { type: "complete" };
|
||||
|
||||
const MAX_OUTPUT = 100_000;
|
||||
|
||||
function bounded(value: number, minimum: number, maximum: number): number {
|
||||
return Number.isFinite(value)
|
||||
? Math.min(maximum, Math.max(minimum, Math.floor(value)))
|
||||
: minimum;
|
||||
}
|
||||
|
||||
function escapeLiteral(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function regex(pattern: string, flags = "", isRegex = true): RegExp {
|
||||
const source = isRegex ? pattern : escapeLiteral(pattern);
|
||||
return new RegExp(source, flags.replace(/[gy]/g, ""));
|
||||
}
|
||||
|
||||
// Literal text is the default in the editor, so a bad regex should never kill a
|
||||
// run. Fall back to matching the pattern literally instead.
|
||||
function safeRegex(pattern: string, flags = "", isRegex = true): RegExp {
|
||||
try {
|
||||
return regex(pattern, flags, isRegex);
|
||||
} catch {
|
||||
return new RegExp(escapeLiteral(pattern), flags.replace(/[gy]/g, ""));
|
||||
}
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("Macro cancelled", "AbortError");
|
||||
}
|
||||
|
||||
function delay(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(abortError());
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, milliseconds);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runTerminalMacro(
|
||||
macro: TerminalMacro,
|
||||
terminal: MacroTerminalAdapter,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
onEvent?: (event: MacroRunEvent) => void;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
let output = "";
|
||||
let scanFrom = 0;
|
||||
let wake: (() => void) | null = null;
|
||||
const unsubscribe = terminal.subscribe((data) => {
|
||||
const combined = output + data;
|
||||
const removed = Math.max(0, combined.length - MAX_OUTPUT);
|
||||
output = combined.slice(removed);
|
||||
scanFrom = Math.max(0, scanFrom - removed);
|
||||
wake?.();
|
||||
});
|
||||
|
||||
const runSteps = async (steps: MacroStep[]): Promise<void> => {
|
||||
for (const step of steps) {
|
||||
if (options.signal?.aborted) throw abortError();
|
||||
options.onEvent?.({ type: "step", stepId: step.id });
|
||||
|
||||
if (step.type === "send") {
|
||||
scanFrom = output.length;
|
||||
terminal.send(step.text + (step.pressEnter ? "\r" : ""));
|
||||
continue;
|
||||
}
|
||||
if (step.type === "delay") {
|
||||
await delay(bounded(step.milliseconds, 0, 300_000), options.signal);
|
||||
continue;
|
||||
}
|
||||
if (step.type === "if") {
|
||||
await runSteps(
|
||||
safeRegex(step.pattern, step.flags, step.isRegex !== false).test(
|
||||
output,
|
||||
)
|
||||
? step.then
|
||||
: step.else,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (step.type === "repeat") {
|
||||
const count = bounded(step.count, 1, 100);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
await runSteps(step.steps);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const matcher = safeRegex(
|
||||
step.pattern,
|
||||
step.flags,
|
||||
step.isRegex !== false,
|
||||
);
|
||||
const deadline = Date.now() + bounded(step.timeoutMs, 100, 300_000);
|
||||
let match = matcher.exec(output.slice(scanFrom));
|
||||
while (!match && Date.now() < deadline) {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
}),
|
||||
delay(Math.min(250, deadline - Date.now()), options.signal),
|
||||
]);
|
||||
wake = null;
|
||||
match = matcher.exec(output.slice(scanFrom));
|
||||
}
|
||||
if (match) {
|
||||
scanFrom += match.index + match[0].length;
|
||||
options.onEvent?.({
|
||||
type: "matched",
|
||||
stepId: step.id,
|
||||
match: match[0],
|
||||
});
|
||||
} else {
|
||||
options.onEvent?.({ type: "timeout", stepId: step.id });
|
||||
if (step.onTimeout === "stop") {
|
||||
throw new Error(`Timed out waiting for /${step.pattern}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await runSteps(macro.steps);
|
||||
options.onEvent?.({ type: "complete" });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTerminalMacros(value?: string | null): TerminalMacro[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
let remainingSteps = 500;
|
||||
const sanitizeSteps = (input: unknown, depth: number): MacroStep[] => {
|
||||
if (!Array.isArray(input) || depth > 4 || remainingSteps <= 0) return [];
|
||||
return input.flatMap((raw): MacroStep[] => {
|
||||
if (!raw || typeof raw !== "object" || remainingSteps-- <= 0) return [];
|
||||
const step = raw as Record<string, unknown>;
|
||||
const stepId =
|
||||
typeof step.id === "string" ? step.id : `step-${remainingSteps}`;
|
||||
if (step.type === "send") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "send",
|
||||
text:
|
||||
typeof step.text === "string" ? step.text.slice(0, 65_536) : "",
|
||||
pressEnter: step.pressEnter !== false,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "delay") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "delay",
|
||||
milliseconds: bounded(Number(step.milliseconds), 0, 300_000),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "wait") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "wait",
|
||||
pattern:
|
||||
typeof step.pattern === "string"
|
||||
? step.pattern.slice(0, 1000)
|
||||
: "",
|
||||
isRegex: step.isRegex !== false,
|
||||
flags:
|
||||
typeof step.flags === "string"
|
||||
? step.flags.replace(/[^imsu]/g, "")
|
||||
: undefined,
|
||||
timeoutMs: bounded(Number(step.timeoutMs), 100, 300_000),
|
||||
onTimeout: step.onTimeout === "continue" ? "continue" : "stop",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "if") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "if",
|
||||
pattern:
|
||||
typeof step.pattern === "string"
|
||||
? step.pattern.slice(0, 1000)
|
||||
: "",
|
||||
isRegex: step.isRegex !== false,
|
||||
flags:
|
||||
typeof step.flags === "string"
|
||||
? step.flags.replace(/[^imsu]/g, "")
|
||||
: undefined,
|
||||
then: sanitizeSteps(step.then, depth + 1),
|
||||
else: sanitizeSteps(step.else, depth + 1),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "repeat") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "repeat",
|
||||
count: bounded(Number(step.count), 1, 100),
|
||||
steps: sanitizeSteps(step.steps, depth + 1),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
return parsed.slice(0, 100).flatMap((raw): TerminalMacro[] => {
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const macro = raw as Record<string, unknown>;
|
||||
if (typeof macro.id !== "string" || typeof macro.name !== "string")
|
||||
return [];
|
||||
const now = new Date().toISOString();
|
||||
return [
|
||||
{
|
||||
id: macro.id,
|
||||
name: macro.name.slice(0, 200),
|
||||
description:
|
||||
typeof macro.description === "string"
|
||||
? macro.description.slice(0, 2000)
|
||||
: undefined,
|
||||
steps: sanitizeSteps(macro.steps, 0),
|
||||
createdAt:
|
||||
typeof macro.createdAt === "string" ? macro.createdAt : now,
|
||||
updatedAt:
|
||||
typeof macro.updatedAt === "string" ? macro.updatedAt : now,
|
||||
},
|
||||
];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
brightBlack: "\x1b[90m",
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightCyan: "\x1b[96m",
|
||||
bold: "\x1b[1m",
|
||||
underline: "\x1b[4m",
|
||||
} as const;
|
||||
|
||||
export interface SyntaxHighlightOptions {
|
||||
logLevels?: boolean;
|
||||
paths?: boolean;
|
||||
timestamps?: boolean;
|
||||
ipAddresses?: boolean;
|
||||
urls?: boolean;
|
||||
numbers?: boolean;
|
||||
}
|
||||
|
||||
interface HighlightPattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
category: keyof SyntaxHighlightOptions;
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
start: number;
|
||||
end: number;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
// capture group offset: some patterns capture a sub-group rather than whole match
|
||||
captureStart?: number;
|
||||
captureEnd?: number;
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
isAnsi: boolean;
|
||||
content: string;
|
||||
// the last active SGR code before this segment (for state restoration)
|
||||
activeSgr?: string;
|
||||
}
|
||||
|
||||
interface ProtectedRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
|
||||
// Cursor-positioning, erase, and private mode sequences used by TUI apps
|
||||
// (mc, nano, vim, htop). If a chunk contains these, highlighting can inject
|
||||
// extra SGR bytes into a full-screen redraw and corrupt xterm's cursor state.
|
||||
const TUI_SEQUENCE = /\x1b\[(?:[\d;]*[ABCDEFGHJKST]|\?[\d;]*[hl])/;
|
||||
const TUI_FRAME_SEQUENCE = /[\u2500-\u257f]|\x1b[()][0B]|\x1b\[[0-9;]*[~`]/;
|
||||
const CONTROL_STRING_SEQUENCE = /\x1b[\]P^_]/;
|
||||
|
||||
// A bare \r (not immediately followed by \n) means the terminal is overwriting
|
||||
// the current line (shell prompts, progress bars). Highlighting mid-rewrite
|
||||
// chunks corrupts the cursor state, so we skip the whole chunk.
|
||||
const MID_LINE_CR = /\r(?!\n)/;
|
||||
|
||||
// Detects shell prompt lines (user@host:/path$ or similar) after stripping ANSI.
|
||||
// These should not be highlighted — the server already colored them, and injecting
|
||||
// additional ANSI codes into the prompt fragments causes display corruption.
|
||||
//
|
||||
// The parameter-byte class (0-9;?>=!) intentionally also includes `<` and `:` —
|
||||
// SGR mouse-tracking reports (`ESC[<Cb;Cx;CyM`) use `<` as their private-mode
|
||||
// marker. Without it, mouse reports from TUI apps (especially through a
|
||||
// multiplexer like screen) aren't recognized as escape sequences and leak
|
||||
// through as literal "35;191;1M" text on screen.
|
||||
const STRIP_ANSI_RE = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
|
||||
const SSH_BRACKET_HEADING_RE =
|
||||
/(?:(?<=^)|(?<=\s))\[[\w.-]+@[\w.-]+(?:[^\]\r\n]*)?\]/g;
|
||||
|
||||
function isShellPromptLine(bare: string): boolean {
|
||||
const plain = bare.replace(STRIP_ANSI_RE, "");
|
||||
// Matches a trailing prompt: "user@host:~$ ", "root@pi:/home/pi# ", "[user@host dir]$ "
|
||||
if (/(?:[\w.-]+@[\w.-]+|[\w.-]+).*?[$#%>]\s*$/.test(plain)) return true;
|
||||
// Matches a leading prompt followed by a command: "user@host:/path$ cmd arg"
|
||||
// This is the echoed command line — the shell colors the prompt prefix itself,
|
||||
// so injecting extra ANSI codes into it causes visual corruption / doubled paths.
|
||||
if (/^(?:\[?[\w.-]+@[\w.-]+[\w./ ~-]*\]?|[\w.-]+).*?[$#%>]\s+\S/.test(plain))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Matches any complete ANSI escape sequence
|
||||
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
|
||||
|
||||
// Matches SGR sequences (color/style setters) specifically — used to track active color state
|
||||
const SGR_REGEX = /\x1b\[[0-9;]*m/;
|
||||
|
||||
// All patterns, ordered roughly by specificity. Priority determines which wins on overlap.
|
||||
const ALL_PATTERNS: HighlightPattern[] = [
|
||||
// IPv4 with optional :port — well-bounded, very specific
|
||||
{
|
||||
name: "ipv4",
|
||||
regex:
|
||||
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
|
||||
ansiCode: ANSI.magenta,
|
||||
priority: 10,
|
||||
category: "ipAddresses",
|
||||
},
|
||||
|
||||
// Bracket timestamps [HH:MM] or [HH:MM:SS] — tight range checks prevent false positives
|
||||
{
|
||||
name: "timestamp-bracket",
|
||||
regex: /\[(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\]/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// ISO 8601 date with optional time. Require non-digit before to avoid matching
|
||||
// inside version strings like "1.2024.3". The \b on the right ensures we don't
|
||||
// match partial tokens.
|
||||
{
|
||||
name: "timestamp-iso",
|
||||
regex:
|
||||
/(?<![0-9])\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// Error-level keywords — case-insensitive, catches "Error", "error", "FAILED", etc.
|
||||
{
|
||||
name: "log-error",
|
||||
regex:
|
||||
/\b(?:error|fatal|critical|fail(?:ed)?|denied|exception)\b|\[(?:error|fatal|critical)\]/gi,
|
||||
ansiCode: ANSI.brightRed,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Warning-level keywords — case-insensitive
|
||||
{
|
||||
name: "log-warn",
|
||||
regex: /\b(?:warn(?:ing)?|alert|caution)\b|\[warn(?:ing)?\]/gi,
|
||||
ansiCode: ANSI.brightYellow,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Success keywords — kept conservative to avoid noise.
|
||||
// Negative lookahead prevents matching when directly followed by a path separator
|
||||
// (e.g. shell `cd` output that prints "success~/new/dir").
|
||||
{
|
||||
name: "log-success",
|
||||
regex:
|
||||
/\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b(?![\\/~])/gi,
|
||||
ansiCode: ANSI.brightGreen,
|
||||
priority: 8,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// URLs — blue + underline
|
||||
{
|
||||
name: "url",
|
||||
regex: /https?:\/\/[^\s\])}>"']+/g,
|
||||
ansiCode: `${ANSI.blue}${ANSI.underline}`,
|
||||
priority: 8,
|
||||
category: "urls",
|
||||
},
|
||||
|
||||
// Absolute paths — permissive character class that allows *, ?, [], globs, etc.
|
||||
// Requires at least one slash after the root segment so we don't match bare /dev or /tmp.
|
||||
// Boundary: must start at beginning of text or after whitespace/colon/comma/paren.
|
||||
{
|
||||
name: "path-absolute",
|
||||
regex:
|
||||
/(?:(?<=^)|(?<=[\s:,;('"]))\/[^\s"'`|<>&;\\]+(?:\/[^\s"'`|<>&;\\]+)+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Home-relative paths
|
||||
{
|
||||
name: "path-home",
|
||||
regex: /~\/[^\s"'`|<>&;(){}[\]\\]+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Info keyword
|
||||
{
|
||||
name: "log-info",
|
||||
regex: /\binfo\b|\[info\]/gi,
|
||||
ansiCode: ANSI.blue,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Debug/trace
|
||||
{
|
||||
name: "log-debug",
|
||||
regex: /\b(?:debug|trace|verbose)\b|\[(?:debug|trace)\]/gi,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Labeled numbers: port 8080, exit 1, code 127, status 404, signal 9, returned 0
|
||||
// Captures just the number portion so only the digit is highlighted.
|
||||
{
|
||||
name: "number-labeled",
|
||||
regex: /\b(?:port|exit|code|status|signal|returned?)\s+(\d+)\b/gi,
|
||||
ansiCode: ANSI.brightCyan,
|
||||
priority: 5,
|
||||
category: "numbers",
|
||||
},
|
||||
];
|
||||
|
||||
function hasIncompleteAnsiSequence(text: string): boolean {
|
||||
return /\x1b\[[0-9:;<=>?!]*$/.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether the stream is inside a control string (OSC/DCS/APC/PM) across
|
||||
* chunk boundaries.
|
||||
*
|
||||
* A control string carries text that must never reach the screen — an OSC 0
|
||||
* title, for instance, contains the user, host and path. Its opening `ESC ]`
|
||||
* and its terminator often land in different websocket frames, and the
|
||||
* continuation frame contains no escape byte at all, so every single-chunk
|
||||
* guard here misses it. Highlighting that continuation injects an SGR sequence
|
||||
* into the middle of the string, which aborts it early in xterm.js and dumps
|
||||
* the rest of the payload on screen as ordinary text.
|
||||
*
|
||||
* A trailing lone ESC counts as active for the same reason: its intent is only
|
||||
* knowable from the next chunk.
|
||||
*/
|
||||
export function updateControlStringMode(
|
||||
output: string,
|
||||
currentMode: boolean,
|
||||
): { isActive: boolean; wasActive: boolean } {
|
||||
const wasActive = currentMode;
|
||||
let isActive = currentMode;
|
||||
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
const char = output[i];
|
||||
|
||||
if (isActive) {
|
||||
if (char === "\x07") {
|
||||
isActive = false;
|
||||
} else if (char === "\x1b") {
|
||||
// ST (ESC \) closes it; any other ESC aborts it.
|
||||
isActive = false;
|
||||
if (output[i + 1] === "\\") i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char !== "\x1b") continue;
|
||||
|
||||
const next = output[i + 1];
|
||||
if (next === undefined) return { isActive: true, wasActive };
|
||||
if (next === "]" || next === "P" || next === "^" || next === "_") {
|
||||
isActive = true;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { isActive, wasActive };
|
||||
}
|
||||
|
||||
function parseAnsiSegments(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
ANSI_REGEX.lastIndex = 0;
|
||||
let lastIndex = 0;
|
||||
let activeSgr = "";
|
||||
let match;
|
||||
|
||||
while ((match = ANSI_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex, match.index),
|
||||
activeSgr,
|
||||
});
|
||||
}
|
||||
const seq = match[0];
|
||||
segments.push({ isAnsi: true, content: seq });
|
||||
// Track the active SGR state so we can restore it after a highlight reset
|
||||
if (SGR_REGEX.test(seq)) {
|
||||
activeSgr = seq === ANSI.reset ? "" : seq;
|
||||
}
|
||||
lastIndex = ANSI_REGEX.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ isAnsi: false, content: text.slice(lastIndex), activeSgr });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function highlightPlainText(
|
||||
text: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
activeSgr: string,
|
||||
protectedRanges: ProtectedRange[],
|
||||
): string {
|
||||
if (text.length > MAX_LINE_LENGTH || !text.trim()) return text;
|
||||
|
||||
const matches: MatchResult[] = [];
|
||||
|
||||
for (const pattern of activePatterns) {
|
||||
pattern.regex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = pattern.regex.exec(text)) !== null) {
|
||||
// For patterns with a capture group (labeled numbers), highlight only the capture
|
||||
if (m[1] !== undefined) {
|
||||
const captureOffset = m[0].indexOf(m[1], m[0].search(/\d/));
|
||||
const captureStart = m.index + captureOffset;
|
||||
const captureEnd = captureStart + m[1].length;
|
||||
if (isProtectedRange(captureStart, captureEnd, protectedRanges)) {
|
||||
continue;
|
||||
}
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
captureStart,
|
||||
captureEnd,
|
||||
});
|
||||
} else {
|
||||
if (isProtectedRange(m.index, m.index + m[0].length, protectedRanges)) {
|
||||
continue;
|
||||
}
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
// Highest priority wins; ties broken by earlier start position
|
||||
matches.sort((a, b) =>
|
||||
a.priority !== b.priority ? b.priority - a.priority : a.start - b.start,
|
||||
);
|
||||
|
||||
const used: Array<{ start: number; end: number }> = [];
|
||||
const final = matches.filter((m) => {
|
||||
const overlaps = used.some(
|
||||
(r) =>
|
||||
(m.start >= r.start && m.start < r.end) ||
|
||||
(m.end > r.start && m.end <= r.end) ||
|
||||
(m.start <= r.start && m.end >= r.end),
|
||||
);
|
||||
if (!overlaps) {
|
||||
used.push({ start: m.start, end: m.end });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Apply in reverse order so indices stay valid as we insert escape codes
|
||||
final.sort((a, b) => a.start - b.start).reverse();
|
||||
|
||||
let result = text;
|
||||
for (const m of final) {
|
||||
const hs = m.captureStart ?? m.start;
|
||||
const he = m.captureEnd ?? m.end;
|
||||
|
||||
// After reset, re-emit the SGR that was active before this plain-text segment
|
||||
// so we don't clobber colors set by the server earlier in the line.
|
||||
const restore = activeSgr || "";
|
||||
result =
|
||||
result.slice(0, hs) +
|
||||
m.ansiCode +
|
||||
result.slice(hs, he) +
|
||||
ANSI.reset +
|
||||
restore +
|
||||
result.slice(he);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getProtectedRanges(text: string): ProtectedRange[] {
|
||||
const ranges: ProtectedRange[] = [];
|
||||
SSH_BRACKET_HEADING_RE.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = SSH_BRACKET_HEADING_RE.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isProtectedRange(
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ProtectedRange[],
|
||||
): boolean {
|
||||
return ranges.some((range) => start < range.end && end > range.start);
|
||||
}
|
||||
|
||||
function buildActivePatterns(
|
||||
options: SyntaxHighlightOptions,
|
||||
): HighlightPattern[] {
|
||||
return ALL_PATTERNS.filter((p) => options[p.category] !== false);
|
||||
}
|
||||
|
||||
function highlightLine(
|
||||
line: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
): string {
|
||||
const cr = line.endsWith("\r");
|
||||
const bare = cr ? line.slice(0, -1) : line;
|
||||
|
||||
if (!bare.trim()) return line;
|
||||
if (bare.length > MAX_LINE_LENGTH) return line;
|
||||
if (isShellPromptLine(bare)) return line;
|
||||
|
||||
// Compute protected ranges (e.g. SSH bracket headings) against the fully
|
||||
// stripped line rather than per-ANSI-segment text. A colored prompt theme
|
||||
// (e.g. "[<color>user<reset>@<color>host<reset>]") splits the heading across
|
||||
// multiple plain-text segments, so matching per-segment would miss it and
|
||||
// let a username like "warning" get wrongly highlighted as a log level.
|
||||
const plainLine = bare.replace(STRIP_ANSI_RE, "");
|
||||
const lineProtectedRanges = getProtectedRanges(plainLine);
|
||||
|
||||
const segments = parseAnsiSegments(bare);
|
||||
let plainOffset = 0;
|
||||
const result = segments
|
||||
.map((s) => {
|
||||
if (s.isAnsi) return s.content;
|
||||
const segmentStart = plainOffset;
|
||||
plainOffset += s.content.length;
|
||||
const localRanges = lineProtectedRanges
|
||||
.map((r) => ({
|
||||
start: r.start - segmentStart,
|
||||
end: r.end - segmentStart,
|
||||
}))
|
||||
.filter((r) => r.start < s.content.length && r.end > 0);
|
||||
return highlightPlainText(
|
||||
s.content,
|
||||
activePatterns,
|
||||
s.activeSgr ?? "",
|
||||
localRanges,
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
return cr ? result + "\r" : result;
|
||||
}
|
||||
|
||||
export function highlightTerminalOutput(
|
||||
text: string,
|
||||
options: SyntaxHighlightOptions = {},
|
||||
): string {
|
||||
if (!text || !text.trim()) return text;
|
||||
if (hasIncompleteAnsiSequence(text)) return text;
|
||||
|
||||
if (TUI_SEQUENCE.test(text)) return text;
|
||||
if (TUI_FRAME_SEQUENCE.test(text)) return text;
|
||||
if (CONTROL_STRING_SEQUENCE.test(text)) return text;
|
||||
if (MID_LINE_CR.test(text)) return text;
|
||||
|
||||
const activePatterns = buildActivePatterns(options);
|
||||
if (activePatterns.length === 0) return text;
|
||||
|
||||
const lines = text.split("\n");
|
||||
const endsWithNewline = text.endsWith("\n");
|
||||
const hasMultipleLines = lines.length > 1;
|
||||
|
||||
// When a multi-line chunk has a trailing fragment without \n, that fragment
|
||||
// could be a live readline input line that bash will redraw with \r +
|
||||
// cursor-positioning sequences. Injecting ANSI bytes into it adds invisible
|
||||
// chars that bash doesn't count, so bash's cursor arithmetic diverges from
|
||||
// xterm's actual column position — causing the cursor to jump and text to
|
||||
// shift when the user types or navigates with arrow keys.
|
||||
// Only skip the last fragment when the chunk already has complete lines
|
||||
// before it (single-line chunks with no \n are plain output, not input).
|
||||
const highlightCount =
|
||||
hasMultipleLines && !endsWithNewline ? lines.length - 1 : lines.length;
|
||||
|
||||
return lines
|
||||
.map((line, i) =>
|
||||
i < highlightCount ? highlightLine(line, activePatterns) : line,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -1,867 +0,0 @@
|
||||
export interface TerminalTheme {
|
||||
name: string;
|
||||
category: "dark" | "light" | "colorful";
|
||||
colors: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor?: string;
|
||||
cursorAccent?: string;
|
||||
selectionBackground?: string;
|
||||
selectionForeground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||
termix: {
|
||||
name: "Termix Default",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#0c0d0b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
|
||||
termixDark: {
|
||||
name: "Termix Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#0c0d0b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
|
||||
termixLight: {
|
||||
name: "Termix Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#ffffff",
|
||||
foreground: "#18181b",
|
||||
cursor: "#18181b",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#d1d5db",
|
||||
black: "#18181b",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
yellow: "#ca8a04",
|
||||
blue: "#2563eb",
|
||||
magenta: "#9333ea",
|
||||
cyan: "#0891b2",
|
||||
white: "#f4f4f5",
|
||||
brightBlack: "#71717a",
|
||||
brightRed: "#ef4444",
|
||||
brightGreen: "#22c55e",
|
||||
brightYellow: "#eab308",
|
||||
brightBlue: "#3b82f6",
|
||||
brightMagenta: "#a855f7",
|
||||
brightCyan: "#06b6d4",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
dracula: {
|
||||
name: "Dracula",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#44475a",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
monokai: {
|
||||
name: "Monokai",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f0",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "#49483e",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
},
|
||||
},
|
||||
|
||||
nord: {
|
||||
name: "Nord",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "#434c5e",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
},
|
||||
},
|
||||
|
||||
gruvboxDark: {
|
||||
name: "Gruvbox Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "#504945",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
},
|
||||
},
|
||||
|
||||
gruvboxLight: {
|
||||
name: "Gruvbox Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fbf1c7",
|
||||
foreground: "#3c3836",
|
||||
cursor: "#3c3836",
|
||||
cursorAccent: "#fbf1c7",
|
||||
selectionBackground: "#d5c4a1",
|
||||
black: "#fbf1c7",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#7c6f64",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#9d0006",
|
||||
brightGreen: "#79740e",
|
||||
brightYellow: "#b57614",
|
||||
brightBlue: "#076678",
|
||||
brightMagenta: "#8f3f71",
|
||||
brightCyan: "#427b58",
|
||||
brightWhite: "#3c3836",
|
||||
},
|
||||
},
|
||||
|
||||
solarizedDark: {
|
||||
name: "Solarized Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#839496",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "#073642",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
solarizedLight: {
|
||||
name: "Solarized Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fdf6e3",
|
||||
foreground: "#657b83",
|
||||
cursor: "#657b83",
|
||||
cursorAccent: "#fdf6e3",
|
||||
selectionBackground: "#eee8d5",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
oneDark: {
|
||||
name: "One Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#282c34",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#282c34",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#e5c07b",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
tokyoNight: {
|
||||
name: "Tokyo Night",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1a1b26",
|
||||
foreground: "#a9b1d6",
|
||||
cursor: "#a9b1d6",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "#283457",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
},
|
||||
},
|
||||
|
||||
ayuDark: {
|
||||
name: "Ayu Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0a0e14",
|
||||
foreground: "#b3b1ad",
|
||||
cursor: "#e6b450",
|
||||
cursorAccent: "#0a0e14",
|
||||
selectionBackground: "#253340",
|
||||
black: "#01060e",
|
||||
red: "#ea6c73",
|
||||
green: "#91b362",
|
||||
yellow: "#f9af4f",
|
||||
blue: "#53bdfa",
|
||||
magenta: "#fae994",
|
||||
cyan: "#90e1c6",
|
||||
white: "#c7c7c7",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#f07178",
|
||||
brightGreen: "#c2d94c",
|
||||
brightYellow: "#ffb454",
|
||||
brightBlue: "#59c2ff",
|
||||
brightMagenta: "#ffee99",
|
||||
brightCyan: "#95e6cb",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
ayuLight: {
|
||||
name: "Ayu Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fafafa",
|
||||
foreground: "#5c6166",
|
||||
cursor: "#ff9940",
|
||||
cursorAccent: "#fafafa",
|
||||
selectionBackground: "#d1e4f4",
|
||||
black: "#000000",
|
||||
red: "#f51818",
|
||||
green: "#86b300",
|
||||
yellow: "#f2ae49",
|
||||
blue: "#399ee6",
|
||||
magenta: "#a37acc",
|
||||
cyan: "#4cbf99",
|
||||
white: "#c7c7c7",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#ff3333",
|
||||
brightGreen: "#b8e532",
|
||||
brightYellow: "#ffc849",
|
||||
brightBlue: "#59c2ff",
|
||||
brightMagenta: "#bf7ce0",
|
||||
brightCyan: "#5cf7a0",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
materialTheme: {
|
||||
name: "Material Theme",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#263238",
|
||||
foreground: "#eeffff",
|
||||
cursor: "#ffcc00",
|
||||
cursorAccent: "#263238",
|
||||
selectionBackground: "#546e7a",
|
||||
black: "#000000",
|
||||
red: "#e53935",
|
||||
green: "#91b859",
|
||||
yellow: "#ffb62c",
|
||||
blue: "#6182b8",
|
||||
magenta: "#7c4dff",
|
||||
cyan: "#39adb5",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#546e7a",
|
||||
brightRed: "#ff5370",
|
||||
brightGreen: "#c3e88d",
|
||||
brightYellow: "#ffcb6b",
|
||||
brightBlue: "#82aaff",
|
||||
brightMagenta: "#c792ea",
|
||||
brightCyan: "#89ddff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
palenight: {
|
||||
name: "Palenight",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#292d3e",
|
||||
foreground: "#a6accd",
|
||||
cursor: "#ffcc00",
|
||||
cursorAccent: "#292d3e",
|
||||
selectionBackground: "#676e95",
|
||||
black: "#292d3e",
|
||||
red: "#f07178",
|
||||
green: "#c3e88d",
|
||||
yellow: "#ffcb6b",
|
||||
blue: "#82aaff",
|
||||
magenta: "#c792ea",
|
||||
cyan: "#89ddff",
|
||||
white: "#d0d0d0",
|
||||
brightBlack: "#434758",
|
||||
brightRed: "#ff8b92",
|
||||
brightGreen: "#ddffa7",
|
||||
brightYellow: "#ffe585",
|
||||
brightBlue: "#9cc4ff",
|
||||
brightMagenta: "#e1acff",
|
||||
brightCyan: "#a3f7ff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
oceanicNext: {
|
||||
name: "Oceanic Next",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1b2b34",
|
||||
foreground: "#cdd3de",
|
||||
cursor: "#c0c5ce",
|
||||
cursorAccent: "#1b2b34",
|
||||
selectionBackground: "#343d46",
|
||||
black: "#343d46",
|
||||
red: "#ec5f67",
|
||||
green: "#99c794",
|
||||
yellow: "#fac863",
|
||||
blue: "#6699cc",
|
||||
magenta: "#c594c5",
|
||||
cyan: "#5fb3b3",
|
||||
white: "#cdd3de",
|
||||
brightBlack: "#65737e",
|
||||
brightRed: "#ec5f67",
|
||||
brightGreen: "#99c794",
|
||||
brightYellow: "#fac863",
|
||||
brightBlue: "#6699cc",
|
||||
brightMagenta: "#c594c5",
|
||||
brightCyan: "#5fb3b3",
|
||||
brightWhite: "#d8dee9",
|
||||
},
|
||||
},
|
||||
|
||||
nightOwl: {
|
||||
name: "Night Owl",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#011627",
|
||||
foreground: "#d6deeb",
|
||||
cursor: "#80a4c2",
|
||||
cursorAccent: "#011627",
|
||||
selectionBackground: "#1d3b53",
|
||||
black: "#011627",
|
||||
red: "#ef5350",
|
||||
green: "#22da6e",
|
||||
yellow: "#c5e478",
|
||||
blue: "#82aaff",
|
||||
magenta: "#c792ea",
|
||||
cyan: "#21c7a8",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#575656",
|
||||
brightRed: "#ef5350",
|
||||
brightGreen: "#22da6e",
|
||||
brightYellow: "#ffeb95",
|
||||
brightBlue: "#82aaff",
|
||||
brightMagenta: "#c792ea",
|
||||
brightCyan: "#7fdbca",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
synthwave84: {
|
||||
name: "Synthwave '84",
|
||||
category: "colorful",
|
||||
colors: {
|
||||
background: "#241b2f",
|
||||
foreground: "#f92aad",
|
||||
cursor: "#f92aad",
|
||||
cursorAccent: "#241b2f",
|
||||
selectionBackground: "#495495",
|
||||
black: "#000000",
|
||||
red: "#f6188f",
|
||||
green: "#1eff8e",
|
||||
yellow: "#ffe261",
|
||||
blue: "#03edf9",
|
||||
magenta: "#f10596",
|
||||
cyan: "#03edf9",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#5a5a5a",
|
||||
brightRed: "#ff1a8e",
|
||||
brightGreen: "#1eff8e",
|
||||
brightYellow: "#ffff00",
|
||||
brightBlue: "#00d8ff",
|
||||
brightMagenta: "#ff00d4",
|
||||
brightCyan: "#00ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
cobalt2: {
|
||||
name: "Cobalt2",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#193549",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#f0cc09",
|
||||
cursorAccent: "#193549",
|
||||
selectionBackground: "#0050a4",
|
||||
black: "#000000",
|
||||
red: "#ff0000",
|
||||
green: "#38de21",
|
||||
yellow: "#ffe50a",
|
||||
blue: "#1460d2",
|
||||
magenta: "#ff005d",
|
||||
cyan: "#00bbbb",
|
||||
white: "#bbbbbb",
|
||||
brightBlack: "#555555",
|
||||
brightRed: "#f40e17",
|
||||
brightGreen: "#3bd01d",
|
||||
brightYellow: "#edc809",
|
||||
brightBlue: "#5555ff",
|
||||
brightMagenta: "#ff55ff",
|
||||
brightCyan: "#6ae3fa",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
snazzy: {
|
||||
name: "Snazzy",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282a36",
|
||||
foreground: "#eff0eb",
|
||||
cursor: "#97979b",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#97979b",
|
||||
black: "#282a36",
|
||||
red: "#ff5c57",
|
||||
green: "#5af78e",
|
||||
yellow: "#f3f99d",
|
||||
blue: "#57c7ff",
|
||||
magenta: "#ff6ac1",
|
||||
cyan: "#9aedfe",
|
||||
white: "#f1f1f0",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#ff5c57",
|
||||
brightGreen: "#5af78e",
|
||||
brightYellow: "#f3f99d",
|
||||
brightBlue: "#57c7ff",
|
||||
brightMagenta: "#ff6ac1",
|
||||
brightCyan: "#9aedfe",
|
||||
brightWhite: "#eff0eb",
|
||||
},
|
||||
},
|
||||
|
||||
atomOneDark: {
|
||||
name: "Atom One Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1e2127",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#1e2127",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#000000",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#d19a66",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#d19a66",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
catppuccinMocha: {
|
||||
name: "Catppuccin Mocha",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "#585b70",
|
||||
black: "#45475a",
|
||||
red: "#f38ba8",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
blue: "#89b4fa",
|
||||
magenta: "#f5c2e7",
|
||||
cyan: "#94e2d5",
|
||||
white: "#bac2de",
|
||||
brightBlack: "#585b70",
|
||||
brightRed: "#f38ba8",
|
||||
brightGreen: "#a6e3a1",
|
||||
brightYellow: "#f9e2af",
|
||||
brightBlue: "#89b4fa",
|
||||
brightMagenta: "#f5c2e7",
|
||||
brightCyan: "#94e2d5",
|
||||
brightWhite: "#a6adc8",
|
||||
},
|
||||
},
|
||||
|
||||
gentlemansChoice: {
|
||||
name: "Gentleman's Choice",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1a1c1a",
|
||||
foreground: "#d1c7a3",
|
||||
cursor: "#d1c7a3",
|
||||
cursorAccent: "#1a1c1a",
|
||||
selectionBackground: "#3e4437",
|
||||
black: "#1a1c1a",
|
||||
red: "#9d3a3a",
|
||||
green: "#5a7a3a",
|
||||
yellow: "#b39a3a",
|
||||
blue: "#3a5a7a",
|
||||
magenta: "#7a3a5a",
|
||||
cyan: "#3a7a7a",
|
||||
white: "#d1c7a3",
|
||||
brightBlack: "#3e4437",
|
||||
brightRed: "#bf4a4a",
|
||||
brightGreen: "#7a9a4a",
|
||||
brightYellow: "#d1b34a",
|
||||
brightBlue: "#4a7abf",
|
||||
brightMagenta: "#9a4abf",
|
||||
brightCyan: "#4abfbf",
|
||||
brightWhite: "#e3dbc3",
|
||||
},
|
||||
},
|
||||
|
||||
midnightEspresso: {
|
||||
name: "Midnight Espresso",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#120f0d",
|
||||
foreground: "#ceb195",
|
||||
cursor: "#ceb195",
|
||||
cursorAccent: "#120f0d",
|
||||
selectionBackground: "#3d2b1f",
|
||||
black: "#120f0d",
|
||||
red: "#a05a4a",
|
||||
green: "#7a8a5a",
|
||||
yellow: "#b08a4a",
|
||||
blue: "#5a7a9a",
|
||||
magenta: "#8a5a7a",
|
||||
cyan: "#5a8a8a",
|
||||
white: "#ceb195",
|
||||
brightBlack: "#3d2b1f",
|
||||
brightRed: "#c07a6a",
|
||||
brightGreen: "#9aaa7a",
|
||||
brightYellow: "#d0aa6a",
|
||||
brightBlue: "#7a9aba",
|
||||
brightMagenta: "#aa7aba",
|
||||
brightCyan: "#7ababa",
|
||||
brightWhite: "#e0cbb5",
|
||||
},
|
||||
},
|
||||
|
||||
custom: {
|
||||
name: "Custom",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#0c0d0b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TERMINAL_FONTS = [
|
||||
{
|
||||
value: "Caskaydia Cove Nerd Font Mono",
|
||||
label: "Caskaydia Cove Nerd Font Mono",
|
||||
fallback:
|
||||
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "JetBrains Mono",
|
||||
label: "JetBrains Mono",
|
||||
fallback:
|
||||
'"JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Fira Code",
|
||||
label: "Fira Code",
|
||||
fallback: '"Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Cascadia Code",
|
||||
label: "Cascadia Code",
|
||||
fallback:
|
||||
'"Cascadia Code", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Source Code Pro",
|
||||
label: "Source Code Pro",
|
||||
fallback:
|
||||
'"Source Code Pro", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "SF Mono",
|
||||
label: "SF Mono",
|
||||
fallback: '"SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Consolas",
|
||||
label: "Consolas",
|
||||
fallback: 'Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Monaco",
|
||||
label: "Monaco",
|
||||
fallback: 'Monaco, "Liberation Mono", monospace',
|
||||
},
|
||||
];
|
||||
|
||||
// Unknown values are treated as a custom font installed on the user's system.
|
||||
export function resolveTerminalFontFamily(fontFamily?: string): string {
|
||||
const known = TERMINAL_FONTS.find((f) => f.value === fontFamily);
|
||||
if (known) return known.fallback;
|
||||
if (fontFamily && fontFamily.trim()) {
|
||||
return `"${fontFamily.trim()}", "SF Mono", Consolas, "Liberation Mono", monospace`;
|
||||
}
|
||||
return TERMINAL_FONTS[0].fallback;
|
||||
}
|
||||
|
||||
export const CURSOR_STYLES = [
|
||||
{ value: "block", label: "Block" },
|
||||
{ value: "underline", label: "Underline" },
|
||||
{ value: "bar", label: "Bar" },
|
||||
] as const;
|
||||
|
||||
export const BELL_STYLES = [
|
||||
{ value: "none", label: "None" },
|
||||
{ value: "sound", label: "Sound" },
|
||||
{ value: "visual", label: "Visual" },
|
||||
{ value: "both", label: "Both" },
|
||||
] as const;
|
||||
|
||||
export const FAST_SCROLL_MODIFIERS = [
|
||||
{ value: "alt", label: "Alt" },
|
||||
{ value: "ctrl", label: "Ctrl" },
|
||||
{ value: "shift", label: "Shift" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_TERMINAL_CONFIG = {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar" as const,
|
||||
fontSize: 14,
|
||||
fontFamily: "Caskaydia Cove Nerd Font Mono",
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1.0,
|
||||
theme: "termix",
|
||||
|
||||
scrollback: 10000,
|
||||
bellStyle: "none" as const,
|
||||
rightClickSelectsWord: false,
|
||||
macOptionIsMeta: false,
|
||||
fastScrollModifier: "alt" as const,
|
||||
fastScrollSensitivity: 5,
|
||||
minimumContrastRatio: 1,
|
||||
|
||||
backspaceMode: "normal" as const,
|
||||
agentForwarding: false,
|
||||
environmentVariables: [] as Array<{ key: string; value: string }>,
|
||||
startupSnippetId: null as number | null,
|
||||
autoMosh: false,
|
||||
moshCommand: "mosh-server new -s -l LANG=en_US.UTF-8",
|
||||
sudoPasswordAutoFill: false,
|
||||
keepaliveInterval: undefined as number | undefined,
|
||||
keepaliveCountMax: undefined as number | undefined,
|
||||
autoTmux: false,
|
||||
backgroundImage: "" as string,
|
||||
backgroundImageOpacity: 0.15,
|
||||
};
|
||||
|
||||
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
||||
+21
-44
@@ -2,7 +2,6 @@ import type {
|
||||
DashboardCardConfig,
|
||||
FontSizeId,
|
||||
UiFontId,
|
||||
SplitMode,
|
||||
} from "@/types/ui-types";
|
||||
|
||||
export const DASHBOARD_CARDS: DashboardCardConfig[] = [
|
||||
@@ -15,7 +14,7 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
|
||||
{
|
||||
id: "counters_bar",
|
||||
label: "Counters Bar",
|
||||
description: "Total hosts, credentials, and tunnels count",
|
||||
description: "Host and credential totals, plus any counters plugins add",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
@@ -36,24 +35,6 @@ export const DASHBOARD_CARDS: DashboardCardConfig[] = [
|
||||
description: "Feed of recent connection events",
|
||||
defaultEnabled: true,
|
||||
},
|
||||
{
|
||||
id: "network_graph",
|
||||
label: "Network Graph",
|
||||
description: "Visual map of host network topology",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "service_links",
|
||||
label: "Service Links",
|
||||
description: "Clickable buttons linking to services on your servers",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "homepage_preview",
|
||||
label: "Homepage",
|
||||
description: "Scaled preview of your Homepage canvas",
|
||||
defaultEnabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const ACCENT_PRESET_COLORS = [
|
||||
@@ -83,11 +64,29 @@ export const FONT_SIZES: { id: FontSizeId; label: string }[] = [
|
||||
{ id: "xl", label: "XL" },
|
||||
];
|
||||
|
||||
/** The root font size of each interface size, relative to Normal. */
|
||||
export const FONT_SIZE_SCALE: Record<FontSizeId, number> = {
|
||||
xs: 12 / 14,
|
||||
sm: 13 / 14,
|
||||
md: 1,
|
||||
lg: 17 / 14,
|
||||
xl: 20 / 14,
|
||||
};
|
||||
|
||||
export function applyFontSize(id: FontSizeId) {
|
||||
const root = document.documentElement;
|
||||
const size = id in FONT_SIZE_SCALE ? id : "md";
|
||||
root.classList.remove("fs-xs", "fs-sm", "fs-md", "fs-lg", "fs-xl");
|
||||
root.classList.add(`fs-${id}`);
|
||||
localStorage.setItem("termix-font-size", id);
|
||||
root.classList.add(`fs-${size}`);
|
||||
localStorage.setItem("termix-font-size", size);
|
||||
|
||||
// Every length is in rem, so the root size scales the whole interface. An
|
||||
// earlier desktop build zoomed the window instead; undo that if it did.
|
||||
(
|
||||
window as Window & {
|
||||
electronAPI?: { setZoomFactor?: (factor: number) => void };
|
||||
}
|
||||
).electronAPI?.setZoomFactor?.(1);
|
||||
}
|
||||
|
||||
export const UI_FONTS: { id: UiFontId; label: string; family: string }[] = [
|
||||
@@ -134,25 +133,3 @@ export const FOLDER_COLORS = [
|
||||
"#ec4899",
|
||||
"#6b7280",
|
||||
];
|
||||
|
||||
export const SPLIT_MODES: { id: SplitMode; label: string }[] = [
|
||||
{ id: "none", label: "None" },
|
||||
{ id: "2-way", label: "2-Way" },
|
||||
{ id: "2-way-horizontal", label: "2-Way (H)" },
|
||||
{ id: "3-way", label: "3-Way (V)" },
|
||||
{ id: "3-way-horizontal", label: "3-Way (H)" },
|
||||
{ id: "4-way", label: "4-Way" },
|
||||
{ id: "5-way", label: "5-Way" },
|
||||
{ id: "6-way", label: "6-Way" },
|
||||
];
|
||||
|
||||
export const PANE_COUNTS: Record<SplitMode, number> = {
|
||||
none: 0,
|
||||
"2-way": 2,
|
||||
"2-way-horizontal": 2,
|
||||
"3-way": 3,
|
||||
"3-way-horizontal": 3,
|
||||
"4-way": 4,
|
||||
"5-way": 5,
|
||||
"6-way": 6,
|
||||
};
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
|
||||
|
||||
interface UseConnectionRetryOptions {
|
||||
connect: () => void | Promise<void>;
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
interface UseConnectionRetryResult {
|
||||
status: ConnectionStatus;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
nextRetryInMs: number | null;
|
||||
markConnected: () => void;
|
||||
markFailed: () => void;
|
||||
retryNow: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const COUNTDOWN_TICK_MS = 250;
|
||||
const RETRY_JITTER_RATIO = 0.1;
|
||||
|
||||
export function computeReconnectDelay(
|
||||
attempt: number,
|
||||
baseDelayMs: number,
|
||||
maxDelayMs: number,
|
||||
random = Math.random,
|
||||
): number {
|
||||
const base = Math.min(
|
||||
baseDelayMs * Math.pow(2, Math.max(0, attempt - 1)),
|
||||
maxDelayMs,
|
||||
);
|
||||
const jitter = base * RETRY_JITTER_RATIO * (random() * 2 - 1);
|
||||
return Math.max(baseDelayMs, Math.min(maxDelayMs, Math.round(base + jitter)));
|
||||
}
|
||||
|
||||
// Backoff curve mirrors Terminal.tsx's proven attemptReconnection(): 2s, 4s,
|
||||
// 8s, capped at 8s, up to 8 attempts before falling back to a manual retry.
|
||||
export function useConnectionRetry({
|
||||
connect,
|
||||
maxAttempts = 8,
|
||||
baseDelayMs = 2000,
|
||||
maxDelayMs = 8000,
|
||||
enabled = true,
|
||||
autoStart = true,
|
||||
}: UseConnectionRetryOptions): UseConnectionRetryResult {
|
||||
const [status, setStatus] = useState<ConnectionStatus>("connecting");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [nextRetryInMs, setNextRetryInMs] = useState<number | null>(null);
|
||||
|
||||
const connectRef = useRef(connect);
|
||||
connectRef.current = connect;
|
||||
|
||||
const enabledRef = useRef(enabled);
|
||||
enabledRef.current = enabled;
|
||||
|
||||
const attemptRef = useRef(0);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
|
||||
null,
|
||||
);
|
||||
const isMountedRef = useRef(true);
|
||||
const markFailedRef = useRef<() => void>(() => {});
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runConnect = useCallback(() => {
|
||||
if (!isMountedRef.current) return;
|
||||
setStatus("connecting");
|
||||
try {
|
||||
void Promise.resolve(connectRef.current()).catch(() => {
|
||||
markFailedRef.current();
|
||||
});
|
||||
} catch {
|
||||
markFailedRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleRetry = useCallback(() => {
|
||||
if (attemptRef.current >= maxAttempts) {
|
||||
setStatus("disconnected");
|
||||
setNextRetryInMs(null);
|
||||
return;
|
||||
}
|
||||
|
||||
attemptRef.current += 1;
|
||||
setAttempt(attemptRef.current);
|
||||
|
||||
const delay = computeReconnectDelay(
|
||||
attemptRef.current,
|
||||
baseDelayMs,
|
||||
maxDelayMs,
|
||||
);
|
||||
|
||||
let remaining = delay;
|
||||
setNextRetryInMs(remaining);
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
remaining = Math.max(0, remaining - COUNTDOWN_TICK_MS);
|
||||
setNextRetryInMs(remaining);
|
||||
}, COUNTDOWN_TICK_MS);
|
||||
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
clearTimers();
|
||||
if (!isMountedRef.current || !enabledRef.current) return;
|
||||
runConnect();
|
||||
}, delay);
|
||||
}, [baseDelayMs, maxDelayMs, maxAttempts, clearTimers, runConnect]);
|
||||
|
||||
const markConnected = useCallback(() => {
|
||||
clearTimers();
|
||||
attemptRef.current = 0;
|
||||
setAttempt(0);
|
||||
setNextRetryInMs(null);
|
||||
if (isMountedRef.current) setStatus("connected");
|
||||
}, [clearTimers]);
|
||||
|
||||
const markFailed = useCallback(() => {
|
||||
clearTimers();
|
||||
if (!isMountedRef.current) return;
|
||||
setStatus("error");
|
||||
if (enabledRef.current) {
|
||||
scheduleRetry();
|
||||
} else {
|
||||
setNextRetryInMs(null);
|
||||
}
|
||||
}, [clearTimers, scheduleRetry]);
|
||||
markFailedRef.current = markFailed;
|
||||
|
||||
const retryNow = useCallback(() => {
|
||||
clearTimers();
|
||||
attemptRef.current = 0;
|
||||
setAttempt(0);
|
||||
setNextRetryInMs(null);
|
||||
runConnect();
|
||||
}, [clearTimers, runConnect]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearTimers();
|
||||
attemptRef.current = 0;
|
||||
setAttempt(0);
|
||||
setNextRetryInMs(null);
|
||||
if (isMountedRef.current) setStatus("connecting");
|
||||
}, [clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
if (autoStart) {
|
||||
runConnect();
|
||||
}
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearTimers();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && status === "error" && !retryTimeoutRef.current) {
|
||||
scheduleRetry();
|
||||
}
|
||||
if (!enabled) {
|
||||
clearTimers();
|
||||
setNextRetryInMs(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled]);
|
||||
|
||||
return {
|
||||
status,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
nextRetryInMs,
|
||||
markConnected,
|
||||
markFailed,
|
||||
retryNow,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* setInterval that pauses while the document is hidden and fires once on resume.
|
||||
* Returns a cleanup function for useEffect.
|
||||
*/
|
||||
export function runVisibleInterval(
|
||||
tick: () => void,
|
||||
intervalMs: number,
|
||||
): () => void {
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const start = () => {
|
||||
if (intervalId !== null || intervalMs <= 0) return;
|
||||
intervalId = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
tick();
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
if (intervalId === null) return;
|
||||
clearInterval(intervalId);
|
||||
intervalId = null;
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
tick();
|
||||
start();
|
||||
};
|
||||
|
||||
if (
|
||||
typeof document === "undefined" ||
|
||||
document.visibilityState !== "hidden"
|
||||
) {
|
||||
start();
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
|
||||
return () => {
|
||||
stop();
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
/**
|
||||
* Builds the URL a web endpoint opens at.
|
||||
*
|
||||
* Direct access points at the host's own address. Tunnel access points at a
|
||||
* port the backend has forwarded to the endpoint's port on the host -- so the
|
||||
* endpoint's own port is deliberately absent from a tunnel URL.
|
||||
*
|
||||
* Expects an endpoint whose `path` has already been normalized by
|
||||
* `normalizeWebEndpoints` (it always begins with "/"); a raw unvalidated path
|
||||
* would produce a malformed URL.
|
||||
*/
|
||||
export function resolveWebEndpointUrl({
|
||||
hostAddress,
|
||||
endpoint,
|
||||
localPort,
|
||||
tunnelHost,
|
||||
}: {
|
||||
hostAddress: string;
|
||||
endpoint: WebEndpoint;
|
||||
localPort?: number;
|
||||
/**
|
||||
* Where the caller can reach the forward the backend bound. Must never be
|
||||
* the host string the browser is currently reaching Termix at -- see
|
||||
* `separatedTunnelHost`. Defaults to loopback.
|
||||
*/
|
||||
tunnelHost?: string;
|
||||
}): string {
|
||||
const path = endpoint.path && endpoint.path.length > 0 ? endpoint.path : "/";
|
||||
|
||||
if (endpoint.access === "tunnel") {
|
||||
if (!localPort) {
|
||||
throw new Error("A tunnel endpoint needs a local port to resolve a URL");
|
||||
}
|
||||
const authority = bracketIfIpv6(tunnelHost?.trim() || "127.0.0.1");
|
||||
return `${endpoint.scheme}://${authority}:${localPort}${path}`;
|
||||
}
|
||||
|
||||
return `${endpoint.scheme}://${bracketIfIpv6(hostAddress)}:${endpoint.port}${path}`;
|
||||
}
|
||||
|
||||
/** A bare IPv6 literal has to be bracketed to be a legal URL authority. */
|
||||
function bracketIfIpv6(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
/** Host spellings that mean "this machine". */
|
||||
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
||||
|
||||
/**
|
||||
* Each loopback spelling mapped to a DIFFERENT one that reaches the same
|
||||
* machine. Being a different string is the entire point.
|
||||
*/
|
||||
const LOOPBACK_ALIASES: Record<string, string> = {
|
||||
localhost: "127.0.0.1",
|
||||
"127.0.0.1": "localhost",
|
||||
"::1": "127.0.0.1",
|
||||
"[::1]": "127.0.0.1",
|
||||
};
|
||||
|
||||
/**
|
||||
* A host string that reaches the same machine as `pageHost` but is a
|
||||
* different key in the browser's cookie jar -- or null when none can be
|
||||
* derived.
|
||||
*
|
||||
* This is a security mitigation, not a cosmetic choice. Cookies are keyed by
|
||||
* host and IGNORE the port, and SameSite computes "site" as scheme +
|
||||
* registrable domain -- also ignoring the port. So a forward reached at the
|
||||
* very host string serving Termix is same-site with Termix, and two things
|
||||
* follow:
|
||||
*
|
||||
* 1. the browser attaches Termix's `jwt` cookie to every request the framed
|
||||
* third-party server sees, including its access log; and
|
||||
* 2. a `Set-Cookie: jwt=...` from that server lands in the jar Termix's own
|
||||
* API calls read from -- and both auth middlewares read the cookie
|
||||
* BEFORE the Authorization header, so it decides who Termix acts as.
|
||||
*
|
||||
* Both directions were reproduced against a real SSH forward.
|
||||
*
|
||||
* Only loopback literals get an alias. A same-registrable-domain name would
|
||||
* not be safe even though it is a different host: the target could answer
|
||||
* `Set-Cookie: jwt=...; Domain=example.com`, which reaches Termix anyway.
|
||||
* Loopback literals cannot carry a Domain attribute, so they are the only
|
||||
* spellings that close both directions.
|
||||
*/
|
||||
export function separatedTunnelHost(pageHost: string): string | null {
|
||||
return LOOPBACK_ALIASES[pageHost.trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
/** Lowercase, trim, drop surrounding IPv6 brackets and a single trailing dot. */
|
||||
function normalizeHost(host: string): string {
|
||||
let h = host.trim().toLowerCase();
|
||||
if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
|
||||
if (h.endsWith(".")) h = h.slice(0, -1);
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a browser would attach Termix's `jwt` cookie to a request for
|
||||
* `targetHost` while the page is served from `pageHost` -- or accept a
|
||||
* `Set-Cookie` from `targetHost` into the jar Termix's own API reads.
|
||||
*
|
||||
* The web client sets `jwt` host-only (no `Domain`), so the port is irrelevant
|
||||
* and the disclosure direction is exactly an equal host. The reverse direction
|
||||
* -- the target answering `Set-Cookie: jwt=...; Domain=<parent>` -- reaches
|
||||
* Termix whenever the two share a domain, so a parent/sub relationship counts
|
||||
* too. The suffix check is anchored on a dot boundary so `eviltermix.example`
|
||||
* is not treated as same-site with `termix.example`.
|
||||
*
|
||||
* A precise registrable-domain (eTLD+1) test would also catch sibling
|
||||
* subdomains under a shared parent, but needs a public-suffix list this app
|
||||
* does not bundle; the exact/parent/sub check closes the reported same-host
|
||||
* case only. This is defense in depth, not cookie isolation: embedded frames
|
||||
* must use credentialless plus an opaque sandbox origin, including redirects.
|
||||
*/
|
||||
export function sharesCookieSiteWithPage(
|
||||
targetHost: string,
|
||||
pageHost: string,
|
||||
): boolean {
|
||||
const t = normalizeHost(targetHost);
|
||||
const p = normalizeHost(pageHost);
|
||||
if (!t || !p) return false;
|
||||
if (t === p) return true;
|
||||
return t.endsWith("." + p) || p.endsWith("." + t);
|
||||
}
|
||||
|
||||
function defaultPageHost(): string {
|
||||
return typeof window === "undefined" ? "127.0.0.1" : window.location.hostname;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a web endpoint must not be opened from this client.
|
||||
*
|
||||
* `loopback-bind-on-remote-backend` -- the forward binds where the backend
|
||||
* runs. In a browser that is the server, so a loopback bind answers only to
|
||||
* the server itself: the open succeeds, the port exists, and the browser
|
||||
* still gets nothing.
|
||||
*
|
||||
* `shares-session-cookie-with-termix` -- the tunnel URL would resolve to the
|
||||
* same host string the page is served from, handing Termix's session token to
|
||||
* the tunnelled service. See `separatedTunnelHost`.
|
||||
*
|
||||
* `direct-shares-session-cookie` -- the same leak without a tunnel: a direct
|
||||
* endpoint's target host shares Termix's cookie site, so the browser attaches
|
||||
* the `jwt` to it (and accepts one back). A different port is not a different
|
||||
* cookie key. See `sharesCookieSiteWithPage`.
|
||||
*/
|
||||
export type WebEndpointRefusalReason =
|
||||
| "loopback-bind-on-remote-backend"
|
||||
| "shares-session-cookie-with-termix"
|
||||
| "direct-shares-session-cookie";
|
||||
|
||||
/**
|
||||
* Why a web endpoint must not be opened from this client, or null when it may.
|
||||
*
|
||||
* Covers both access modes. A tunnel is refused when its forward would either
|
||||
* be unreachable (loopback bind on a remote backend) or resolve to the page's
|
||||
* own host string (`shares-session-cookie-with-termix`). A DIRECT endpoint is
|
||||
* refused when its target host shares Termix's cookie site
|
||||
* (`direct-shares-session-cookie`) -- the same leak, reached without a tunnel:
|
||||
* cookies ignore the port, so a UI on the very host serving Termix receives
|
||||
* the session. `hostAddress` is the direct endpoint's target host and is
|
||||
* ignored for tunnels.
|
||||
*
|
||||
* The desktop is exempt: its session is Bearer-only with no `jwt` in the jar,
|
||||
* and its API base ("localhost") is already separated from where tunnels
|
||||
* resolve ("127.0.0.1").
|
||||
*/
|
||||
export function webEndpointRefusalReason(
|
||||
endpoint: Pick<WebEndpoint, "access" | "bindHost">,
|
||||
runningInElectron: boolean,
|
||||
hostAddress: string | undefined,
|
||||
pageHost: string = defaultPageHost(),
|
||||
): WebEndpointRefusalReason | null {
|
||||
if (runningInElectron) return null;
|
||||
|
||||
if (endpoint.access === "direct") {
|
||||
if (hostAddress && sharesCookieSiteWithPage(hostAddress, pageHost)) {
|
||||
return "direct-shares-session-cookie";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (endpoint.access !== "tunnel") return null;
|
||||
|
||||
const bindHost = (endpoint.bindHost ?? "").trim().toLowerCase();
|
||||
// Reported first when both apply: the bind address is what the user has to
|
||||
// change either way, and it is the more specific instruction.
|
||||
if (!bindHost || LOOPBACK_HOSTS.has(bindHost)) {
|
||||
return "loopback-bind-on-remote-backend";
|
||||
}
|
||||
|
||||
if (!separatedTunnelHost(pageHost)) {
|
||||
return "shares-session-cookie-with-termix";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where this client should reach a forward the backend bound, or null when no
|
||||
* host string is both reachable and safe -- in which case
|
||||
* `unreachableTunnelReason` explains why and the caller must not open.
|
||||
*/
|
||||
export function currentTunnelHost(
|
||||
runningInElectron: boolean,
|
||||
pageHost: string = defaultPageHost(),
|
||||
): string | null {
|
||||
if (runningInElectron) return "127.0.0.1";
|
||||
return separatedTunnelHost(pageHost);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { WebEndpoint } from "@/types/index";
|
||||
|
||||
/**
|
||||
* Editor-side validation for web endpoint rows.
|
||||
*
|
||||
* The authority on what a stored endpoint may look like is
|
||||
* `normalizeWebEndpoints` in
|
||||
* src/backend/database/routes/host-web-endpoints.ts, and it DROPS any row it
|
||||
* refuses rather than reporting it -- so without a check here the user adds a
|
||||
* row, saves, sees no error, and finds the endpoint gone on reload.
|
||||
*
|
||||
* This deliberately does NOT restate that module's full rule set, and does not
|
||||
* import it: the renderer does not import backend route modules (the same
|
||||
* reason MAX_WEB_ENDPOINTS lives in src/types rather than beside the
|
||||
* validator). It covers only the conditions a user can reach by typing.
|
||||
* src/ui/tests/lib/web-endpoint-validation.test.ts runs both implementations
|
||||
* over the same samples so the two cannot drift apart silently.
|
||||
*/
|
||||
|
||||
export const MIN_WEB_ENDPOINT_PORT = 1;
|
||||
export const MAX_WEB_ENDPOINT_PORT = 65535;
|
||||
|
||||
export type WebEndpointFieldError =
|
||||
"label" | "duplicateLabel" | "port" | "path";
|
||||
|
||||
const ERROR_KEYS: Record<WebEndpointFieldError, string> = {
|
||||
label: "hosts.webUiErrorLabelRequired",
|
||||
duplicateLabel: "hosts.webUiErrorLabelDuplicate",
|
||||
port: "hosts.webUiErrorPortRange",
|
||||
path: "hosts.webUiErrorPathInvalid",
|
||||
};
|
||||
|
||||
/**
|
||||
* The i18n key for a field error. Every key takes a `row` interpolation so the
|
||||
* message can say which endpoint is at fault.
|
||||
*/
|
||||
export function webEndpointErrorKey(error: WebEndpointFieldError): string {
|
||||
return ERROR_KEYS[error];
|
||||
}
|
||||
|
||||
export function isWebEndpointPortValid(port: unknown): boolean {
|
||||
return (
|
||||
typeof port === "number" &&
|
||||
Number.isInteger(port) &&
|
||||
port >= MIN_WEB_ENDPOINT_PORT &&
|
||||
port <= MAX_WEB_ENDPOINT_PORT
|
||||
);
|
||||
}
|
||||
|
||||
function hasControlCharacter(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code <= 0x1f || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the normalizer would accept this path. An empty or absent path is
|
||||
* fine -- the normalizer defaults it to "/" -- and so is a path with no
|
||||
* leading slash, which the normalizer coerces.
|
||||
*/
|
||||
export function isWebEndpointPathValid(
|
||||
raw: string | undefined | null,
|
||||
): boolean {
|
||||
if (raw === undefined || raw === null || raw === "") return true;
|
||||
if (hasControlCharacter(raw)) return false;
|
||||
|
||||
const path = raw.startsWith("/") ? raw : `/${raw}`;
|
||||
|
||||
// A protocol-relative or absolute path would send the iframe or the link
|
||||
// somewhere other than the endpoint.
|
||||
if (path.startsWith("//")) return false;
|
||||
if (/^\/[a-z][a-z0-9+.-]*:/i.test(path)) return false;
|
||||
if (path.includes("\\")) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first thing wrong with one row, or null if the normalizer would keep it.
|
||||
* Label is checked before port so the message points at the field the user
|
||||
* most likely just left blank.
|
||||
*/
|
||||
export function webEndpointRowError(
|
||||
endpoint: WebEndpoint,
|
||||
): WebEndpointFieldError | null {
|
||||
if (typeof endpoint.label !== "string" || endpoint.label.trim() === "") {
|
||||
return "label";
|
||||
}
|
||||
if (!isWebEndpointPortValid(endpoint.port)) return "port";
|
||||
if (!isWebEndpointPathValid(endpoint.path)) return "path";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first row the normalizer would drop, so a save can be blocked with a
|
||||
* message naming it. Returns null when every row would survive.
|
||||
*
|
||||
* Duplicate LABELS are reported even though the normalizer tolerates them:
|
||||
* the sidebar picker shows labels, so two identical ones are indistinguishable
|
||||
* to the user. Duplicate ids are generated, never typed.
|
||||
*/
|
||||
export function findInvalidWebEndpoint(
|
||||
endpoints: WebEndpoint[] | undefined | null,
|
||||
): { index: number; error: WebEndpointFieldError } | null {
|
||||
if (!Array.isArray(endpoints)) return null;
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < endpoints.length; index += 1) {
|
||||
const endpoint = endpoints[index];
|
||||
const error = webEndpointRowError(endpoint);
|
||||
if (error) return { index, error };
|
||||
|
||||
const label = endpoint.label.trim().toLowerCase();
|
||||
if (seen.has(label)) return { index, error: "duplicateLabel" };
|
||||
seen.add(label);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
interface WidgetTitleProps {
|
||||
title: string | null | undefined;
|
||||
icon?: React.ReactElement<LucideIcon>;
|
||||
fallback?: string;
|
||||
}
|
||||
|
||||
export function WidgetTitle({ title, icon, fallback }: WidgetTitleProps) {
|
||||
const label = title || fallback;
|
||||
if (!label) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 border-b border-border/50 shrink-0">
|
||||
{icon && <span className="text-accent-brand shrink-0">{icon}</span>}
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider truncate">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user