mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-12 06:41:23 +00:00
* 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>
3813 lines
107 KiB
JavaScript
3813 lines
107 KiB
JavaScript
const {
|
|
app,
|
|
BrowserWindow,
|
|
shell,
|
|
ipcMain,
|
|
dialog,
|
|
Menu,
|
|
session,
|
|
safeStorage,
|
|
Tray,
|
|
clipboard,
|
|
nativeImage,
|
|
} = require("electron");
|
|
const path = require("path");
|
|
const { getUnpackedAppRoot } = require("./backend-paths.cjs");
|
|
const { classifyBackendFailure } = require("./backend-failure.cjs");
|
|
const fs = require("fs");
|
|
const { renameLocalPath } = require("./rename-local-path.cjs");
|
|
const os = require("os");
|
|
const https = require("https");
|
|
const http = require("http");
|
|
const net = require("net");
|
|
const tls = require("tls");
|
|
const zlib = require("zlib");
|
|
const crypto = require("crypto");
|
|
const { URL, pathToFileURL } = require("url");
|
|
const { fork, spawn } = require("child_process");
|
|
const pty = require("node-pty");
|
|
const WebSocket = require("ws");
|
|
const linkedServer = require("./linked-server.cjs");
|
|
const desktopSettings = require("./desktop-settings.cjs");
|
|
const { launchNativeRdp } = require("./native-rdp.cjs");
|
|
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
|
|
const { quitApp } = require("./app-quit.cjs");
|
|
const { selectLinuxPasswordStore } = require("./linux-password-store.cjs");
|
|
const { resolveLocalShell } = require("./local-shell.cjs");
|
|
const { registerLocalFileHandlers } = require("./local-files.cjs");
|
|
|
|
const localTerminalSessions = new Map();
|
|
|
|
function ownedLocalTerminal(event, sessionId) {
|
|
if (typeof sessionId !== "string" || !/^[a-f0-9-]{36}$/.test(sessionId)) {
|
|
return null;
|
|
}
|
|
const session = localTerminalSessions.get(sessionId);
|
|
return session?.ownerId === event.sender.id ? session : null;
|
|
}
|
|
|
|
function closeLocalTerminalsFor(ownerId) {
|
|
for (const [sessionId, session] of localTerminalSessions) {
|
|
if (session.ownerId !== ownerId) continue;
|
|
session.process.kill();
|
|
localTerminalSessions.delete(sessionId);
|
|
}
|
|
}
|
|
|
|
// The main process's Node.js networking (the `https`/`http` modules used by
|
|
// httpFetch below and the global `fetch`) only
|
|
// trusts Node's bundled Mozilla CA list by default, not the OS/system trust
|
|
// store. Chromium (the renderer, i.e. the web app and the login iframe) uses
|
|
// the OS trust store instead, so a certificate that's valid in-browser --
|
|
// e.g. one issued by a reverse proxy's internal/corporate CA, or a system
|
|
// CA installed via Keychain/certmgr -- can still fail main-process requests
|
|
// with UNABLE_TO_VERIFY_LEAF_SIGNATURE. Merge the system store in so remote
|
|
// sync and the connection health check see the same trust as the browser.
|
|
try {
|
|
if (typeof tls.setDefaultCACertificates === "function") {
|
|
tls.setDefaultCACertificates([
|
|
...tls.getCACertificates("default"),
|
|
...tls.getCACertificates("system"),
|
|
]);
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to merge system CA certificates:", error);
|
|
}
|
|
|
|
// Portable mode: if a `.portable` marker exists next to the executable,
|
|
// store all data in a `data` folder beside the exe instead of %APPDATA%.
|
|
(function setupPortableDataDir() {
|
|
const exeDir = path.dirname(process.execPath);
|
|
const markerPath = path.join(exeDir, ".portable");
|
|
const envOverride = process.env.TERMIX_DATA_DIR;
|
|
|
|
let portableDataDir = null;
|
|
if (envOverride) {
|
|
portableDataDir = envOverride;
|
|
} else if (app.isPackaged && fs.existsSync(markerPath)) {
|
|
portableDataDir = path.join(exeDir, "data");
|
|
}
|
|
|
|
if (portableDataDir) {
|
|
try {
|
|
if (!fs.existsSync(portableDataDir)) {
|
|
fs.mkdirSync(portableDataDir, { recursive: true });
|
|
}
|
|
app.setPath("userData", portableDataDir);
|
|
} catch {
|
|
// Fall back to default userData if we can't create the directory.
|
|
}
|
|
}
|
|
})();
|
|
|
|
const logFile = path.join(app.getPath("userData"), "termix-main.log");
|
|
const electronAuthCookiesPath = path.join(
|
|
app.getPath("userData"),
|
|
"electron-auth-cookies.json",
|
|
);
|
|
const electronAuthCookies = new Map();
|
|
|
|
function logToFile(...args) {
|
|
const timestamp = new Date().toISOString();
|
|
const msg = args
|
|
.map((a) => (typeof a === "object" ? JSON.stringify(a) : String(a)))
|
|
.join(" ");
|
|
const line = `[${timestamp}] ${msg}\n`;
|
|
try {
|
|
fs.appendFileSync(logFile, line);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
console.log(...args);
|
|
}
|
|
|
|
function getCookieOrigin(url) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
const protocol =
|
|
parsedUrl.protocol === "ws:"
|
|
? "http:"
|
|
: parsedUrl.protocol === "wss:"
|
|
? "https:"
|
|
: parsedUrl.protocol;
|
|
return `${protocol}//${parsedUrl.host}`;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseCookieTarget(url) {
|
|
try {
|
|
const parsedUrl = new URL(url);
|
|
if (parsedUrl.protocol === "ws:") {
|
|
parsedUrl.protocol = "http:";
|
|
} else if (parsedUrl.protocol === "wss:") {
|
|
parsedUrl.protocol = "https:";
|
|
}
|
|
return parsedUrl;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getElectronAuthCookieKey(name, origin) {
|
|
return origin ? `${origin}|${name}` : null;
|
|
}
|
|
|
|
function getSafeStorageAvailable() {
|
|
try {
|
|
return safeStorage.isEncryptionAvailable();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function encodeElectronAuthCookieValue(value) {
|
|
return {
|
|
encrypted: true,
|
|
value: safeStorage.encryptString(value).toString("base64"),
|
|
};
|
|
}
|
|
|
|
function decodeElectronAuthCookieValue(record) {
|
|
if (!record.encrypted || !getSafeStorageAvailable()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return safeStorage.decryptString(Buffer.from(record.value, "base64"));
|
|
} catch (error) {
|
|
logToFile(
|
|
"Failed to decrypt persisted Electron auth cookie:",
|
|
error.message,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isElectronAuthCookieExpired(cookie) {
|
|
return Number.isFinite(cookie.expiresAt) && cookie.expiresAt <= Date.now();
|
|
}
|
|
|
|
function saveElectronAuthCookiesToDisk() {
|
|
try {
|
|
if (!getSafeStorageAvailable()) {
|
|
if (fs.existsSync(electronAuthCookiesPath)) {
|
|
fs.rmSync(electronAuthCookiesPath, { force: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
const records = [];
|
|
|
|
for (const [key, cookie] of electronAuthCookies.entries()) {
|
|
if (isElectronAuthCookieExpired(cookie)) {
|
|
electronAuthCookies.delete(key);
|
|
continue;
|
|
}
|
|
|
|
records.push({
|
|
key,
|
|
name: cookie.name,
|
|
origin: cookie.origin,
|
|
path: cookie.path,
|
|
expiresAt: cookie.expiresAt,
|
|
...encodeElectronAuthCookieValue(cookie.value),
|
|
});
|
|
}
|
|
|
|
fs.writeFileSync(
|
|
electronAuthCookiesPath,
|
|
JSON.stringify({ version: 1, records }, null, 2),
|
|
);
|
|
} catch (error) {
|
|
logToFile("Failed to persist Electron auth cookies:", error.message);
|
|
}
|
|
}
|
|
|
|
function loadElectronAuthCookiesFromDisk() {
|
|
electronAuthCookies.clear();
|
|
|
|
try {
|
|
if (!getSafeStorageAvailable()) {
|
|
if (fs.existsSync(electronAuthCookiesPath)) {
|
|
fs.rmSync(electronAuthCookiesPath, { force: true });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!fs.existsSync(electronAuthCookiesPath)) {
|
|
return;
|
|
}
|
|
|
|
const data = JSON.parse(fs.readFileSync(electronAuthCookiesPath, "utf8"));
|
|
const records = Array.isArray(data.records) ? data.records : [];
|
|
|
|
for (const record of records) {
|
|
if (
|
|
!record ||
|
|
typeof record.key !== "string" ||
|
|
typeof record.name !== "string" ||
|
|
typeof record.origin !== "string"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const value = decodeElectronAuthCookieValue(record);
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
|
|
const cookie = {
|
|
name: record.name,
|
|
value,
|
|
origin: record.origin,
|
|
path: typeof record.path === "string" ? record.path : "/",
|
|
expiresAt: Number.isFinite(record.expiresAt) ? record.expiresAt : null,
|
|
};
|
|
|
|
if (!isElectronAuthCookieExpired(cookie)) {
|
|
electronAuthCookies.set(record.key, cookie);
|
|
}
|
|
}
|
|
|
|
saveElectronAuthCookiesToDisk();
|
|
} catch (error) {
|
|
logToFile("Failed to load persisted Electron auth cookies:", error.message);
|
|
}
|
|
}
|
|
|
|
function clearPersistedElectronAuthCookies() {
|
|
electronAuthCookies.clear();
|
|
try {
|
|
if (fs.existsSync(electronAuthCookiesPath)) {
|
|
fs.rmSync(electronAuthCookiesPath, { force: true });
|
|
}
|
|
} catch (error) {
|
|
logToFile(
|
|
"Failed to clear persisted Electron auth cookies:",
|
|
error.message,
|
|
);
|
|
}
|
|
}
|
|
|
|
function parseSetCookieHeader(header) {
|
|
const [cookiePair, ...attributes] = String(header || "").split(";");
|
|
const separatorIndex = cookiePair.indexOf("=");
|
|
if (separatorIndex <= 0) return null;
|
|
|
|
const parsed = {
|
|
name: cookiePair.slice(0, separatorIndex).trim(),
|
|
value: cookiePair.slice(separatorIndex + 1).trim(),
|
|
path: "/",
|
|
maxAge: null,
|
|
expires: null,
|
|
};
|
|
|
|
for (const attribute of attributes) {
|
|
const [rawName, ...rawValueParts] = attribute.trim().split("=");
|
|
const attrName = rawName.toLowerCase();
|
|
const attrValue = rawValueParts.join("=");
|
|
|
|
if (attrName === "path" && attrValue) {
|
|
parsed.path = attrValue;
|
|
} else if (attrName === "max-age" && attrValue) {
|
|
const maxAge = Number(attrValue);
|
|
parsed.maxAge = Number.isFinite(maxAge) ? maxAge : null;
|
|
} else if (attrName === "expires" && attrValue) {
|
|
const expires = Date.parse(attrValue);
|
|
parsed.expires = Number.isFinite(expires) ? expires : null;
|
|
}
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function rememberElectronAuthCookieFromHeader(url, header) {
|
|
const origin = getCookieOrigin(url);
|
|
if (!origin) return;
|
|
|
|
const cookie = parseSetCookieHeader(header);
|
|
if (!cookie || cookie.name !== "jwt") return;
|
|
|
|
const key = getElectronAuthCookieKey(cookie.name, origin);
|
|
if (!key) return;
|
|
|
|
const expired =
|
|
cookie.maxAge === 0 ||
|
|
(cookie.expires !== null && cookie.expires <= Date.now());
|
|
|
|
if (expired || !cookie.value) {
|
|
electronAuthCookies.delete(key);
|
|
saveElectronAuthCookiesToDisk();
|
|
return;
|
|
}
|
|
|
|
const expiresAt =
|
|
cookie.maxAge !== null ? Date.now() + cookie.maxAge * 1000 : cookie.expires;
|
|
|
|
electronAuthCookies.set(key, {
|
|
name: cookie.name,
|
|
value: cookie.value,
|
|
origin,
|
|
path: cookie.path,
|
|
expiresAt,
|
|
});
|
|
saveElectronAuthCookiesToDisk();
|
|
}
|
|
|
|
function getRememberedElectronAuthCookie(name, targetUrl) {
|
|
const target = parseCookieTarget(targetUrl);
|
|
if (!target) return null;
|
|
|
|
const exactKey = getElectronAuthCookieKey(name, target.origin);
|
|
const exactCookie = exactKey ? electronAuthCookies.get(exactKey) : null;
|
|
if (exactCookie && !isElectronAuthCookieExpired(exactCookie)) {
|
|
return exactCookie;
|
|
}
|
|
|
|
if (target.protocol !== "https:") {
|
|
return null;
|
|
}
|
|
|
|
const httpOrigin = `http://${target.host}`;
|
|
const httpKey = getElectronAuthCookieKey(name, httpOrigin);
|
|
const httpCookie = httpKey ? electronAuthCookies.get(httpKey) : null;
|
|
return httpCookie && !isElectronAuthCookieExpired(httpCookie)
|
|
? httpCookie
|
|
: null;
|
|
}
|
|
|
|
function getHeaderName(headers, name) {
|
|
const lowerName = name.toLowerCase();
|
|
return Object.keys(headers || {}).find(
|
|
(key) => key.toLowerCase() === lowerName,
|
|
);
|
|
}
|
|
|
|
function setCookieHeaderValue(requestHeaders, name, value) {
|
|
const headerName = getHeaderName(requestHeaders, "Cookie") || "Cookie";
|
|
const existing = requestHeaders[headerName];
|
|
const existingValue = Array.isArray(existing)
|
|
? existing.join("; ")
|
|
: existing;
|
|
const nextCookie = `${name}=${value}`;
|
|
const otherCookies = String(existingValue || "")
|
|
.split(";")
|
|
.map((cookie) => cookie.trim())
|
|
.filter((cookie) => cookie && !cookie.startsWith(`${name}=`));
|
|
|
|
requestHeaders[headerName] =
|
|
otherCookies.length > 0
|
|
? `${otherCookies.join("; ")}; ${nextCookie}`
|
|
: nextCookie;
|
|
}
|
|
|
|
function parseSemver(version) {
|
|
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
|
if (!match) return null;
|
|
|
|
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
|
|
}
|
|
|
|
function compareSemver(a, b) {
|
|
const parsedA = parseSemver(a);
|
|
const parsedB = parseSemver(b);
|
|
if (!parsedA || !parsedB) return null;
|
|
|
|
for (let i = 0; i < 3; i += 1) {
|
|
if (parsedA[i] > parsedB[i]) return 1;
|
|
if (parsedA[i] < parsedB[i]) return -1;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
const INSECURE_MODE_VALUES = new Set(["true", "1", "yes"]);
|
|
|
|
function isInsecureModeEnabled() {
|
|
return INSECURE_MODE_VALUES.has(
|
|
String(process.env.ENABLE_INSECURE_MODE || "")
|
|
.trim()
|
|
.toLowerCase(),
|
|
);
|
|
}
|
|
|
|
function getOrigin(url) {
|
|
try {
|
|
return new URL(url).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isPrivateNetworkHost(hostname) {
|
|
if (
|
|
hostname === "localhost" ||
|
|
hostname === "127.0.0.1" ||
|
|
hostname === "::1"
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
const ipv4Match = hostname.match(
|
|
/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/,
|
|
);
|
|
if (ipv4Match) {
|
|
const [, a, b] = ipv4Match.map(Number);
|
|
return (
|
|
a === 10 ||
|
|
(a === 172 && b >= 16 && b <= 31) ||
|
|
(a === 192 && b === 168) ||
|
|
(a === 169 && b === 254)
|
|
);
|
|
}
|
|
|
|
if (hostname.startsWith("fc") || hostname.startsWith("fd")) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function isInvalidCertificateAllowedForUrl(url) {
|
|
if (isInsecureModeEnabled()) return true;
|
|
|
|
try {
|
|
const { hostname } = new URL(url);
|
|
if (isPrivateNetworkHost(hostname)) return true;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
|
|
// Otherwise only the server this desktop is linked to, when its link
|
|
// allows a self-signed certificate.
|
|
const linked = linkedServer.getLinkedServer();
|
|
if (!linked?.allowInvalidCertificate) return false;
|
|
return getOrigin(url) === linked.origin;
|
|
}
|
|
|
|
function getTlsVerificationOptions(url) {
|
|
return {
|
|
rejectUnauthorized: !isInvalidCertificateAllowedForUrl(url),
|
|
};
|
|
}
|
|
|
|
function getWebSocketOptions(url, options = {}) {
|
|
return {
|
|
...options,
|
|
...(String(url).startsWith("wss:") ? getTlsVerificationOptions(url) : {}),
|
|
};
|
|
}
|
|
|
|
function httpFetch(url, options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const urlObj = new URL(url);
|
|
const isHttps = urlObj.protocol === "https:";
|
|
const client = isHttps ? https : http;
|
|
|
|
const requestOptions = {
|
|
method: options.method || "GET",
|
|
headers: options.headers || {},
|
|
timeout: options.timeout || 10000,
|
|
...(isHttps
|
|
? options.allowInvalidCertificate
|
|
? { rejectUnauthorized: false }
|
|
: getTlsVerificationOptions(url)
|
|
: {}),
|
|
};
|
|
|
|
const req = client.request(url, requestOptions, (res) => {
|
|
const chunks = [];
|
|
// Reverse proxies (nginx and friends) commonly gzip/deflate/br-compress
|
|
// responses regardless of client Accept-Encoding. Unlike browser fetch,
|
|
// Node's http/https modules never auto-decompress, so an unhandled
|
|
// content-encoding here silently turns the body into garbage bytes.
|
|
let stream = res;
|
|
const maxResponseBytes = options.maxResponseBytes || 10 * 1024 * 1024;
|
|
let responseBytes = 0;
|
|
let settled = false;
|
|
const fail = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
stream.destroy();
|
|
req.destroy();
|
|
reject(error);
|
|
};
|
|
const encoding = (res.headers["content-encoding"] || "")
|
|
.toLowerCase()
|
|
.trim();
|
|
try {
|
|
if (encoding === "gzip" || encoding === "x-gzip") {
|
|
stream = res.pipe(zlib.createGunzip());
|
|
} else if (encoding === "br") {
|
|
stream = res.pipe(zlib.createBrotliDecompress());
|
|
} else if (encoding === "deflate") {
|
|
stream = res.pipe(zlib.createInflate());
|
|
}
|
|
} catch (decompressError) {
|
|
reject(decompressError);
|
|
return;
|
|
}
|
|
|
|
stream.on("data", (chunk) => {
|
|
responseBytes += chunk.length;
|
|
if (responseBytes > maxResponseBytes) {
|
|
fail(new Error(`Response exceeds ${maxResponseBytes} bytes`));
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
stream.on("end", () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
const data = Buffer.concat(chunks).toString("utf8");
|
|
resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
status: res.statusCode,
|
|
text: () => Promise.resolve(data),
|
|
json: () => Promise.resolve(JSON.parse(data)),
|
|
});
|
|
});
|
|
stream.on("error", fail);
|
|
});
|
|
|
|
req.on("error", reject);
|
|
req.on("timeout", () => {
|
|
req.destroy();
|
|
reject(new Error("Request timeout"));
|
|
});
|
|
|
|
if (options.body) {
|
|
req.write(options.body);
|
|
}
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
if (process.platform === "linux") {
|
|
app.commandLine.appendSwitch("--ozone-platform-hint=auto");
|
|
|
|
app.commandLine.appendSwitch("--enable-features=VaapiVideoDecoder");
|
|
|
|
// Fix click-coordinate misalignment with fractional display scaling on KDE/Wayland.
|
|
// Chromium's hit-testing uses unscaled coords while the compositor scales visually,
|
|
// so forcing scale factor 1 keeps them in sync. See: https://github.com/brave/brave-browser/issues/50028
|
|
app.commandLine.appendSwitch("--force-device-scale-factor", "1");
|
|
|
|
const passwordStore = selectLinuxPasswordStore(app.commandLine, process.env);
|
|
if (passwordStore) {
|
|
logToFile(
|
|
`[safeStorage] Selected the ${passwordStore} password store for this desktop.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (process.platform === "win32" || process.env.ELECTRON_DISABLE_GPU === "1") {
|
|
app.disableHardwareAcceleration();
|
|
}
|
|
|
|
if (isInsecureModeEnabled()) {
|
|
logToFile(
|
|
"[security] ENABLE_INSECURE_MODE is enabled; TLS certificate validation is disabled.",
|
|
);
|
|
app.commandLine.appendSwitch("--ignore-certificate-errors");
|
|
app.commandLine.appendSwitch("--ignore-ssl-errors");
|
|
app.commandLine.appendSwitch("--ignore-certificate-errors-spki-list");
|
|
}
|
|
app.commandLine.appendSwitch("--enable-features=NetworkService");
|
|
|
|
let mainWindow = null;
|
|
const { createIsolatedWindows } = require("./isolated-window.cjs");
|
|
const isolatedWindows = createIsolatedWindows({
|
|
BrowserWindow,
|
|
session,
|
|
getMainWindow: () => mainWindow,
|
|
});
|
|
|
|
// Requests from the embedded backend over the fork IPC channel (the other
|
|
// direction from the "shutdown" message main already sends it). A plugin's
|
|
// ctx.desktop.openIsolatedWindow is the one caller today.
|
|
const BACKEND_REQUEST_HANDLERS = {
|
|
"open-isolated-window": (payload) => isolatedWindows.open(payload),
|
|
// The OS client for a protocol. Only RDP has one wired up.
|
|
"launch-external-client": (payload) =>
|
|
payload?.protocol === "rdp"
|
|
? launchNativeRdp(payload)
|
|
: {
|
|
success: false,
|
|
error: `No external client for ${payload?.protocol ?? "this protocol"}`,
|
|
},
|
|
"sync-proxy-config": (payload) => {
|
|
linkedServer.setLinkedServer(payload);
|
|
return { success: true };
|
|
},
|
|
};
|
|
|
|
async function handleBackendRequest(msg) {
|
|
if (!msg || msg.type !== "backend-request") return;
|
|
const { id, channel, payload } = msg;
|
|
const handler = BACKEND_REQUEST_HANDLERS[channel];
|
|
const reply = (response) => {
|
|
if (backendProcess && !backendProcess.killed) {
|
|
try {
|
|
backendProcess.send({ type: "backend-response", id, ...response });
|
|
} catch (error) {
|
|
logToFile("Failed to reply to backend request:", error.message);
|
|
}
|
|
}
|
|
};
|
|
if (!handler) {
|
|
reply({ ok: false, error: `Unknown backend request channel: ${channel}` });
|
|
return;
|
|
}
|
|
try {
|
|
const result = await handler(payload);
|
|
reply({ ok: true, result });
|
|
} catch (error) {
|
|
reply({ ok: false, error: error.message || String(error) });
|
|
}
|
|
}
|
|
|
|
let backendProcess = null;
|
|
let backendStartFailed = false;
|
|
// Why the embedded backend died, once it has. Null while it is healthy
|
|
// or still starting. The renderer polls this to tell "still booting"
|
|
// (keep waiting) apart from "never coming back" (show the reason).
|
|
let backendFailure = null;
|
|
// Set while we are deliberately tearing the backend down on quit, so a
|
|
// SIGKILL we sent ourselves is not reported to the user as a crash.
|
|
let backendStopRequested = false;
|
|
// Tail of the embedded backend's stderr, kept only so a failed start can be
|
|
// classified after the fact.
|
|
const BACKEND_STDERR_TAIL_LIMIT = 8192;
|
|
let backendStderrTail = "";
|
|
let tray = null;
|
|
let isQuitting = false;
|
|
const tempFiles = new Map();
|
|
const externalEditorSessions = new Map();
|
|
|
|
const isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
|
|
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
|
|
const windowsAppUserModelId = "com.karmaa.termix";
|
|
const electronCacheBuildPath = path.join(
|
|
app.getPath("userData"),
|
|
"client-cache-build.json",
|
|
);
|
|
const termixSessionPartition = "persist:termix";
|
|
|
|
function getTempRoot() {
|
|
const tempRoot = path.join(app.getPath("temp"), "termix");
|
|
fs.mkdirSync(tempRoot, { recursive: true });
|
|
return tempRoot;
|
|
}
|
|
|
|
function sanitizeFileName(fileName) {
|
|
const baseName = path.basename(String(fileName || "file"));
|
|
return baseName.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_") || "file";
|
|
}
|
|
|
|
function decodeFileContent(content, encoding) {
|
|
if (encoding === "base64") {
|
|
return Buffer.from(String(content || ""), "base64");
|
|
}
|
|
return Buffer.from(String(content || ""), "utf8");
|
|
}
|
|
|
|
function createManagedTempFile(fileName, content, encoding = "utf8") {
|
|
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
|
|
const filePath = path.join(tempDir, sanitizeFileName(fileName));
|
|
fs.writeFileSync(filePath, decodeFileContent(content, encoding));
|
|
tempFiles.set(tempId, { path: filePath, dir: tempDir });
|
|
return { tempId, path: filePath };
|
|
}
|
|
|
|
function cleanupManagedTempFile(tempId) {
|
|
const temp = tempFiles.get(tempId);
|
|
if (!temp) return;
|
|
try {
|
|
fs.rmSync(temp.dir, { recursive: true, force: true });
|
|
} catch (error) {
|
|
logToFile("Failed to clean up temporary file:", error.message);
|
|
}
|
|
tempFiles.delete(tempId);
|
|
}
|
|
|
|
function closeExternalEditorSession(editId) {
|
|
const session = externalEditorSessions.get(editId);
|
|
if (!session) return;
|
|
if (session.timer) clearTimeout(session.timer);
|
|
try {
|
|
session.watcher.close();
|
|
} catch {
|
|
// watcher may already be closed
|
|
}
|
|
externalEditorSessions.delete(editId);
|
|
cleanupManagedTempFile(editId);
|
|
}
|
|
|
|
function notifyExternalEditorSaved(editId) {
|
|
const session = externalEditorSessions.get(editId);
|
|
if (!session || !mainWindow || mainWindow.isDestroyed()) return;
|
|
|
|
try {
|
|
const stat = fs.statSync(session.path);
|
|
if (stat.mtimeMs === session.lastMtimeMs) return;
|
|
session.lastMtimeMs = stat.mtimeMs;
|
|
|
|
const content = fs.readFileSync(session.path, "utf8");
|
|
mainWindow.webContents.send("external-editor-saved", {
|
|
editId,
|
|
content,
|
|
encoding: "utf8",
|
|
path: session.path,
|
|
});
|
|
} catch (error) {
|
|
logToFile("Failed to read external editor file:", error.message);
|
|
}
|
|
}
|
|
|
|
function openPathWithEditor(filePath, editorPath) {
|
|
if (!editorPath) {
|
|
return shell.openPath(filePath);
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const child = spawn(editorPath, [filePath], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
});
|
|
|
|
child.once("error", (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(error.message);
|
|
});
|
|
|
|
setTimeout(() => {
|
|
if (settled) return;
|
|
settled = true;
|
|
child.unref();
|
|
resolve("");
|
|
}, 500);
|
|
});
|
|
}
|
|
|
|
// Origins the renderer has explicitly marked as allowed to present an invalid
|
|
// certificate, for web endpoints whose ignoreCert is set. Session scoped and
|
|
// never persisted.
|
|
//
|
|
// Deliberately NOT wired into isInvalidCertificateAllowedForUrl: that function
|
|
// also governs this process's own outbound TLS via getTlsVerificationOptions,
|
|
// and widening it would grant more privilege than the feature needs.
|
|
//
|
|
// Entries expire rather than living until the app restarts (days, on a
|
|
// desktop app). Main has no host-database access, so it cannot verify a
|
|
// renderer's claim that an origin really is a configured web endpoint -- an
|
|
// unbounded allowance would make that an indefinite window rather than a
|
|
// momentary one. The renderer re-registers before each load, which refreshes
|
|
// the expiry, so a live endpoint keeps working while a stale entry lapses.
|
|
const WEB_ENDPOINT_CERTIFICATE_ALLOWLIST_TTL_MS = 5 * 60 * 1000;
|
|
const webEndpointCertificateAllowlist = new Map();
|
|
|
|
function isWebEndpointCertificateAllowed(url) {
|
|
try {
|
|
const parsed = new URL(url);
|
|
// The allowance exists solely to suppress a TLS certificate error, so a
|
|
// non-https origin in the map is meaningless at best.
|
|
if (parsed.protocol !== "https:") return false;
|
|
|
|
const expiresAt = webEndpointCertificateAllowlist.get(parsed.origin);
|
|
if (expiresAt === undefined) return false;
|
|
if (Date.now() >= expiresAt) {
|
|
webEndpointCertificateAllowlist.delete(parsed.origin);
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("allow-invalid-certificate-for-origin", (_event, origin) => {
|
|
try {
|
|
// Store the PARSED origin, never the caller's string, so a path or a
|
|
// wildcard cannot widen the allowance.
|
|
const parsed = new URL(String(origin));
|
|
if (parsed.protocol !== "https:") {
|
|
return { success: false };
|
|
}
|
|
webEndpointCertificateAllowlist.set(
|
|
parsed.origin,
|
|
Date.now() + WEB_ENDPOINT_CERTIFICATE_ALLOWLIST_TTL_MS,
|
|
);
|
|
return { success: true };
|
|
} catch {
|
|
return { success: false };
|
|
}
|
|
});
|
|
|
|
app.on(
|
|
"certificate-error",
|
|
(event, _webContents, url, error, certificate, callback) => {
|
|
if (
|
|
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
|
|
)
|
|
return;
|
|
if (isWebEndpointCertificateAllowed(url)) {
|
|
event.preventDefault();
|
|
logToFile("Allowed invalid certificate for configured web endpoint", {
|
|
url,
|
|
error,
|
|
issuer: certificate?.issuerName,
|
|
subject: certificate?.subjectName,
|
|
});
|
|
callback(true);
|
|
return;
|
|
}
|
|
|
|
if (isInvalidCertificateAllowedForUrl(url)) {
|
|
event.preventDefault();
|
|
logToFile("Allowed invalid certificate for configured server", {
|
|
url,
|
|
error,
|
|
issuer: certificate?.issuerName,
|
|
subject: certificate?.subjectName,
|
|
});
|
|
callback(true);
|
|
return;
|
|
}
|
|
|
|
callback(false);
|
|
},
|
|
);
|
|
|
|
// A reverse proxy in front of the linked server may ask for basic auth; the
|
|
// link's saved username and password answer it. Anything else gets the
|
|
// default behaviour.
|
|
app.on("login", (event, _webContents, details, authInfo, callback) => {
|
|
const credentials = linkedServer.answerLogin(authInfo, details?.url);
|
|
if (!credentials) return;
|
|
event.preventDefault();
|
|
callback(credentials.username, credentials.password || "");
|
|
});
|
|
|
|
function getElectronBuildTimestamp() {
|
|
try {
|
|
const buildInfo = require("./build-info.cjs");
|
|
if (Number.isInteger(buildInfo.buildTimestamp)) {
|
|
return buildInfo.buildTimestamp;
|
|
}
|
|
} catch {
|
|
// Development runs may not have generated build metadata yet.
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
async function clearElectronClientCacheIfBuildChanged() {
|
|
const buildTimestamp = getElectronBuildTimestamp();
|
|
let cacheTimestamp = 0;
|
|
|
|
try {
|
|
if (fs.existsSync(electronCacheBuildPath)) {
|
|
const data = JSON.parse(fs.readFileSync(electronCacheBuildPath, "utf8"));
|
|
cacheTimestamp = Number.isInteger(data.buildTimestamp)
|
|
? data.buildTimestamp
|
|
: 0;
|
|
}
|
|
} catch (error) {
|
|
logToFile(
|
|
"Failed to read Electron client cache build info:",
|
|
error.message,
|
|
);
|
|
}
|
|
|
|
if (cacheTimestamp === buildTimestamp) {
|
|
return;
|
|
}
|
|
|
|
const clearStep = async (label, action) => {
|
|
try {
|
|
await action();
|
|
} catch (error) {
|
|
logToFile(`Failed to clear Electron ${label}:`, error.message);
|
|
}
|
|
};
|
|
|
|
try {
|
|
const defaultSession = session.defaultSession;
|
|
await clearStep("HTTP cache", () => defaultSession.clearCache());
|
|
await clearStep("code cache", () =>
|
|
defaultSession.clearCodeCaches({ urls: [] }),
|
|
);
|
|
await clearStep("auth cache", () => defaultSession.clearAuthCache());
|
|
await clearStep("storage data", () =>
|
|
defaultSession.clearStorageData({
|
|
storages: [
|
|
"appcache",
|
|
"cookies",
|
|
"filesystem",
|
|
"shadercache",
|
|
"websql",
|
|
"serviceworkers",
|
|
"cachestorage",
|
|
],
|
|
}),
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
electronCacheBuildPath,
|
|
JSON.stringify(
|
|
{
|
|
buildTimestamp,
|
|
appVersion: app.getVersion(),
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
|
|
logToFile("Electron client cache cleared for build change", {
|
|
from: cacheTimestamp,
|
|
to: buildTimestamp,
|
|
appVersion: app.getVersion(),
|
|
});
|
|
} catch (error) {
|
|
logToFile("Failed to clear Electron client cache:", error.message);
|
|
}
|
|
}
|
|
|
|
function getCookieRemovalUrl(cookie) {
|
|
const scheme = cookie.secure ? "https" : "http";
|
|
const domain = cookie.domain?.startsWith(".")
|
|
? cookie.domain.slice(1)
|
|
: cookie.domain || "localhost";
|
|
return `${scheme}://${domain}${cookie.path || "/"}`;
|
|
}
|
|
|
|
async function clearElectronJwtCookiesAtStartup() {
|
|
loadElectronAuthCookiesFromDisk();
|
|
|
|
const targetSessions = new Set([
|
|
session.defaultSession,
|
|
session.fromPartition(termixSessionPartition),
|
|
]);
|
|
|
|
for (const targetSession of targetSessions) {
|
|
try {
|
|
const cookies = await targetSession.cookies.get({ name: "jwt" });
|
|
await Promise.all(
|
|
cookies.map((cookie) =>
|
|
targetSession.cookies.remove(
|
|
getCookieRemovalUrl(cookie),
|
|
cookie.name,
|
|
),
|
|
),
|
|
);
|
|
|
|
if (cookies.length > 0) {
|
|
logToFile("Cleared Electron JWT cookies from cookie store", {
|
|
count: cookies.length,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logToFile("Failed to clear Electron JWT cookies:", error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getBackendPaths() {
|
|
if (isDev) {
|
|
const backendDir = path.join(appRoot, "dist", "backend", "backend");
|
|
return {
|
|
entryPath: path.join(backendDir, "starter.js"),
|
|
backendCwd: backendDir,
|
|
};
|
|
}
|
|
// fork() does not go through Electron's asar redirector — use the unpacked path.
|
|
// On macOS multi-arch builds (mergeASARs: false), electron-builder names the ASAR
|
|
// app-arm64.asar / app-x64.asar instead of app.asar, so match all variants.
|
|
const unpackedRoot = getUnpackedAppRoot(appRoot);
|
|
const backendDir = path.join(unpackedRoot, "dist", "backend", "backend");
|
|
return {
|
|
entryPath: path.join(backendDir, "starter.js"),
|
|
backendCwd: backendDir,
|
|
};
|
|
}
|
|
|
|
function getBackendDataDir() {
|
|
const userDataPath = app.getPath("userData");
|
|
const dataDir = path.join(userDataPath, "server-data");
|
|
if (!fs.existsSync(dataDir)) {
|
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
}
|
|
return dataDir;
|
|
}
|
|
|
|
function getBackendPidFilePath() {
|
|
return path.join(app.getPath("userData"), "backend.pid");
|
|
}
|
|
|
|
// If the app was previously killed abnormally (crash, force-quit, Task
|
|
// Manager) rather than through the normal quit flow, will-quit never fires
|
|
// and stopBackendServer() never runs -- the forked backend child is a
|
|
// genuinely separate OS process on Windows/mac/Linux, so it keeps running
|
|
// and holding the port the backend binds (30001). Every subsequent
|
|
// launch's own backend then fails outright
|
|
// with EADDRINUSE and the app is stuck until something manually kills the
|
|
// orphan. Reap any such leftover process, identified by PID file, before
|
|
// spawning a new one.
|
|
function reapOrphanedBackendProcess() {
|
|
const pidFilePath = getBackendPidFilePath();
|
|
let recordedPid;
|
|
try {
|
|
recordedPid = parseInt(fs.readFileSync(pidFilePath, "utf8").trim(), 10);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (!Number.isInteger(recordedPid) || recordedPid <= 0) return;
|
|
|
|
try {
|
|
// Signal 0 does not kill the process -- it only checks whether a
|
|
// process with this PID exists and is signalable, throwing ESRCH if
|
|
// not. This avoids killing an unrelated process that happens to have
|
|
// reused the same PID since the last run.
|
|
process.kill(recordedPid, 0);
|
|
} catch {
|
|
// No live process at that PID; nothing to reap.
|
|
try {
|
|
fs.unlinkSync(pidFilePath);
|
|
} catch {
|
|
// already absent
|
|
}
|
|
return;
|
|
}
|
|
|
|
logToFile(
|
|
`Found orphaned backend process from a previous session (pid ${recordedPid}), terminating it before starting a new one`,
|
|
);
|
|
try {
|
|
process.kill(recordedPid, "SIGKILL");
|
|
} catch {
|
|
// already gone
|
|
}
|
|
try {
|
|
fs.unlinkSync(pidFilePath);
|
|
} catch {
|
|
// already absent
|
|
}
|
|
}
|
|
|
|
function startBackendServer() {
|
|
reapOrphanedBackendProcess();
|
|
backendFailure = null;
|
|
backendStopRequested = false;
|
|
backendStderrTail = "";
|
|
return new Promise((resolve) => {
|
|
const { entryPath, backendCwd } = getBackendPaths();
|
|
|
|
logToFile("isDev:", isDev, "appRoot:", appRoot);
|
|
logToFile("app.isPackaged:", app.isPackaged);
|
|
logToFile("process.env.NODE_ENV:", process.env.NODE_ENV);
|
|
|
|
if (!fs.existsSync(entryPath)) {
|
|
logToFile("Backend entry not found:", entryPath);
|
|
backendFailure = { reason: "crashed", port: null };
|
|
resolve(false);
|
|
return;
|
|
}
|
|
|
|
const dataDir = getBackendDataDir();
|
|
logToFile("Starting embedded backend server...");
|
|
logToFile("Backend entry:", entryPath);
|
|
logToFile("Data directory:", dataDir);
|
|
logToFile("Backend cwd:", backendCwd);
|
|
|
|
logToFile("Checking paths...");
|
|
logToFile(" entryPath exists:", fs.existsSync(entryPath));
|
|
logToFile(" dataDir exists:", fs.existsSync(dataDir));
|
|
logToFile(" backendCwd exists:", fs.existsSync(backendCwd));
|
|
|
|
backendProcess = fork(entryPath, [], {
|
|
cwd:
|
|
fs.existsSync(backendCwd) && fs.statSync(backendCwd).isDirectory()
|
|
? backendCwd
|
|
: dataDir,
|
|
env: {
|
|
...process.env,
|
|
DATA_DIR: dataDir,
|
|
NODE_ENV: "production",
|
|
ELECTRON_EMBEDDED: "true",
|
|
PORT: "30001",
|
|
VERSION: app.getVersion(),
|
|
},
|
|
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
|
});
|
|
|
|
logToFile("Backend process spawned, pid:", backendProcess.pid);
|
|
try {
|
|
fs.writeFileSync(getBackendPidFilePath(), String(backendProcess.pid));
|
|
} catch {
|
|
// Non-fatal: only means a future crash won't self-heal via reap.
|
|
}
|
|
|
|
let resolved = false;
|
|
const readyTimeout = setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
logToFile("Backend ready timeout (15s), proceeding anyway...");
|
|
resolve(true);
|
|
}
|
|
}, 15000);
|
|
|
|
backendProcess.stdout.on("data", (data) => {
|
|
const msg = data.toString().trim();
|
|
logToFile("[backend]", msg);
|
|
if (!resolved && msg.includes("started successfully")) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
logToFile("Backend ready signal received");
|
|
resolve(true);
|
|
}
|
|
});
|
|
|
|
// The reason for a failed start is only ever visible in what the child
|
|
// wrote to stderr before dying, so keep a bounded tail of it. Bounded
|
|
// because a backend that stays up for days can otherwise log without
|
|
// limit into a buffer nothing ever drains.
|
|
backendProcess.stderr.on("data", (data) => {
|
|
const chunk = data.toString();
|
|
backendStderrTail = (backendStderrTail + chunk).slice(
|
|
-BACKEND_STDERR_TAIL_LIMIT,
|
|
);
|
|
logToFile("[backend:stderr]", chunk.trim());
|
|
});
|
|
|
|
backendProcess.on("message", (msg) => {
|
|
if (!resolved && msg?.type === "backend-ready") {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
logToFile("Backend ready signal received via IPC");
|
|
resolve(true);
|
|
}
|
|
void handleBackendRequest(msg);
|
|
});
|
|
|
|
backendProcess.on("exit", (code, signal) => {
|
|
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
|
|
if (!resolved && code !== 0) {
|
|
backendStartFailed = true;
|
|
}
|
|
// Classified whether or not the ready promise already settled: the
|
|
// 15s ready timeout resolves optimistically, so a backend that dies
|
|
// at second 20 still has to be reported rather than silently dropped.
|
|
// backendStopRequested is the only evidence that an exit was asked
|
|
// for, so it is the sole guard here -- past it, every exit means the
|
|
// backend is gone and the renderer must stop waiting for it.
|
|
if (!backendStopRequested) {
|
|
backendFailure = classifyBackendFailure(backendStderrTail);
|
|
logToFile("Backend failure classified as:", backendFailure.reason);
|
|
}
|
|
backendProcess = null;
|
|
clearBackendPidFile();
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
resolve(false);
|
|
}
|
|
});
|
|
|
|
backendProcess.on("error", (err) => {
|
|
logToFile("Failed to start backend process:", err.message);
|
|
backendFailure = { reason: "crashed", port: null };
|
|
backendProcess = null;
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
resolve(false);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function clearBackendPidFile() {
|
|
try {
|
|
fs.unlinkSync(getBackendPidFilePath());
|
|
} catch {
|
|
// already absent
|
|
}
|
|
}
|
|
|
|
function stopBackendServer() {
|
|
if (!backendProcess) return;
|
|
|
|
console.log("Stopping embedded backend server...");
|
|
backendStopRequested = true;
|
|
|
|
try {
|
|
backendProcess.send({ type: "shutdown" });
|
|
} catch {
|
|
// IPC channel may already be closed
|
|
}
|
|
|
|
const forceKillTimeout = setTimeout(() => {
|
|
if (backendProcess) {
|
|
console.log("Force killing backend process...");
|
|
backendProcess.kill("SIGKILL");
|
|
backendProcess = null;
|
|
}
|
|
}, 5000);
|
|
|
|
backendProcess.on("exit", () => {
|
|
clearTimeout(forceKillTimeout);
|
|
backendProcess = null;
|
|
clearBackendPidFile();
|
|
});
|
|
}
|
|
|
|
// App Sandbox denies the singleton socket bind, which reads as a held lock and
|
|
// quits the app on every launch. macOS reactivates bundled apps anyway.
|
|
const gotTheLock = process.mas || app.requestSingleInstanceLock();
|
|
if (!gotTheLock) {
|
|
console.log("Another instance is already running, quitting...");
|
|
app.quit();
|
|
process.exit(0);
|
|
} else {
|
|
app.on("second-instance", (event, commandLine, workingDirectory) => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.focus();
|
|
mainWindow.show();
|
|
}
|
|
});
|
|
}
|
|
|
|
function createTray() {
|
|
try {
|
|
const { nativeImage } = require("electron");
|
|
|
|
// Native APIs (Tray, nativeImage) can't load files from inside app.asar —
|
|
// use the unpacked path so the OS sees a real file.
|
|
const publicRoot = isDev
|
|
? path.join(appRoot, "public")
|
|
: path.join(
|
|
appRoot.replace(
|
|
/app(-[a-z0-9]+)?\.asar(?!\.unpacked)/,
|
|
"app.asar.unpacked",
|
|
),
|
|
"public",
|
|
);
|
|
|
|
let trayIcon;
|
|
if (process.platform === "darwin") {
|
|
const iconPath = path.join(publicRoot, "icons", "16x16.png");
|
|
trayIcon = nativeImage.createFromPath(iconPath);
|
|
trayIcon.setTemplateImage(true);
|
|
} else if (process.platform === "win32") {
|
|
trayIcon = path.join(publicRoot, "icon.ico");
|
|
} else {
|
|
trayIcon = loadLinuxTrayIcon(nativeImage, publicRoot);
|
|
if (!trayIcon) {
|
|
logToFile("Tray icon not found, skipping the system tray");
|
|
return;
|
|
}
|
|
if (/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")) {
|
|
logToFile(
|
|
"GNOME shows tray icons only with the AppIndicator extension installed",
|
|
);
|
|
}
|
|
}
|
|
|
|
tray = new Tray(trayIcon);
|
|
tray.setToolTip("Termix");
|
|
|
|
const contextMenu = Menu.buildFromTemplate([
|
|
{
|
|
label: "Show Window",
|
|
click: () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
},
|
|
},
|
|
{
|
|
label: "Quit",
|
|
click: () => {
|
|
isQuitting = true;
|
|
quitApp(app, mainWindow);
|
|
},
|
|
},
|
|
]);
|
|
|
|
tray.setContextMenu(contextMenu);
|
|
|
|
tray.on("click", () => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isVisible()) {
|
|
mainWindow.hide();
|
|
} else {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
logToFile("System tray created");
|
|
} catch (err) {
|
|
tray = null;
|
|
logToFile("Failed to create system tray:", err?.message || String(err));
|
|
}
|
|
}
|
|
|
|
function loadLinuxTrayIcon(nativeImage, publicRoot) {
|
|
const roots = [
|
|
publicRoot,
|
|
path.join(process.resourcesPath || "", "app.asar.unpacked", "public"),
|
|
];
|
|
for (const root of roots) {
|
|
for (const size of ["32x32", "24x24", "48x48"]) {
|
|
const image = nativeImage.createFromPath(
|
|
path.join(root, "icons", `${size}.png`),
|
|
);
|
|
if (!image.isEmpty()) return image;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function createWindow() {
|
|
const appVersion = app.getVersion();
|
|
const electronVersion = process.versions.electron;
|
|
const platform =
|
|
process.platform === "win32"
|
|
? "Windows"
|
|
: process.platform === "darwin"
|
|
? "macOS"
|
|
: "Linux";
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: 1200,
|
|
height: 800,
|
|
minWidth: 800,
|
|
minHeight: 600,
|
|
title: "Termix",
|
|
icon: path.join(
|
|
appRoot,
|
|
"public",
|
|
process.platform === "win32" ? "icon.ico" : "icon.png",
|
|
),
|
|
webPreferences: {
|
|
nodeIntegration: false,
|
|
contextIsolation: true,
|
|
sandbox: true,
|
|
webSecurity: true,
|
|
preload: path.join(__dirname, "preload.js"),
|
|
partition: termixSessionPartition,
|
|
allowRunningInsecureContent: false,
|
|
webviewTag: false,
|
|
offscreen: false,
|
|
},
|
|
show: true,
|
|
});
|
|
|
|
mainWindow.webContents.session.setPermissionRequestHandler(
|
|
(webContents, permission, callback) => {
|
|
if (
|
|
permission === "clipboard-read" ||
|
|
permission === "clipboard-write" ||
|
|
permission === "clipboard-sanitized-write"
|
|
) {
|
|
callback(true);
|
|
return;
|
|
}
|
|
callback(false);
|
|
},
|
|
);
|
|
|
|
if (process.platform !== "darwin") {
|
|
mainWindow.setMenuBarVisibility(false);
|
|
}
|
|
|
|
const customUserAgent = `Termix-Desktop/${appVersion} (${platform}; Electron/${electronVersion})`;
|
|
mainWindow.webContents.setUserAgent(customUserAgent);
|
|
|
|
mainWindow.webContents.on("before-input-event", (event, input) => {
|
|
if (process.platform !== "win32" || !isCloseActiveTabInput(input)) return;
|
|
|
|
event.preventDefault();
|
|
mainWindow.webContents.send("close-active-tab");
|
|
});
|
|
|
|
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
|
(details, callback) => {
|
|
details.requestHeaders["X-Electron-App"] = "true";
|
|
|
|
details.requestHeaders["User-Agent"] = customUserAgent;
|
|
|
|
const rememberedJwt = getRememberedElectronAuthCookie("jwt", details.url);
|
|
if (rememberedJwt) {
|
|
setCookieHeaderValue(
|
|
details.requestHeaders,
|
|
rememberedJwt.name,
|
|
rememberedJwt.value,
|
|
);
|
|
}
|
|
|
|
callback({
|
|
requestHeaders: linkedServer.applyLinkHeaders(
|
|
details.url,
|
|
details.requestHeaders,
|
|
),
|
|
});
|
|
},
|
|
);
|
|
|
|
if (isDev) {
|
|
mainWindow.loadURL("http://localhost:5173");
|
|
mainWindow.webContents.openDevTools();
|
|
} else {
|
|
const indexPath = path.join(appRoot, "dist", "index.html");
|
|
mainWindow.loadFile(indexPath).catch((err) => {
|
|
console.error("Failed to load file:", err);
|
|
});
|
|
}
|
|
|
|
mainWindow.webContents.session.webRequest.onHeadersReceived(
|
|
(details, callback) => {
|
|
const headers = details.responseHeaders;
|
|
|
|
if (headers) {
|
|
delete headers["x-frame-options"];
|
|
delete headers["X-Frame-Options"];
|
|
|
|
if (headers["content-security-policy"]) {
|
|
headers["content-security-policy"] = headers[
|
|
"content-security-policy"
|
|
]
|
|
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
|
.filter((value) => value.trim().length > 0);
|
|
|
|
if (headers["content-security-policy"].length === 0) {
|
|
delete headers["content-security-policy"];
|
|
}
|
|
}
|
|
if (headers["Content-Security-Policy"]) {
|
|
headers["Content-Security-Policy"] = headers[
|
|
"Content-Security-Policy"
|
|
]
|
|
.map((value) => value.replace(/frame-ancestors[^;]*/gi, ""))
|
|
.filter((value) => value.trim().length > 0);
|
|
|
|
if (headers["Content-Security-Policy"].length === 0) {
|
|
delete headers["Content-Security-Policy"];
|
|
}
|
|
}
|
|
|
|
const setCookieHeaderName = getHeaderName(headers, "Set-Cookie");
|
|
if (setCookieHeaderName) {
|
|
const setCookieHeaders = Array.isArray(headers[setCookieHeaderName])
|
|
? headers[setCookieHeaderName]
|
|
: [headers[setCookieHeaderName]];
|
|
|
|
setCookieHeaders.forEach((cookie) => {
|
|
rememberElectronAuthCookieFromHeader(details.url, cookie);
|
|
});
|
|
|
|
headers[setCookieHeaderName] = setCookieHeaders.map((cookie) => {
|
|
let modified = cookie.replace(
|
|
/;\s*SameSite=Strict/gi,
|
|
"; SameSite=None",
|
|
);
|
|
modified = modified.replace(
|
|
/;\s*SameSite=Lax/gi,
|
|
"; SameSite=None",
|
|
);
|
|
if (!modified.includes("SameSite=")) {
|
|
modified += "; SameSite=None";
|
|
}
|
|
if (
|
|
!modified.includes("Secure") &&
|
|
details.url.startsWith("https")
|
|
) {
|
|
modified += "; Secure";
|
|
}
|
|
return modified;
|
|
});
|
|
}
|
|
}
|
|
|
|
callback({ responseHeaders: headers });
|
|
},
|
|
);
|
|
|
|
mainWindow.once("ready-to-show", () => {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
});
|
|
|
|
setTimeout(() => {
|
|
if (mainWindow && !mainWindow.isVisible()) {
|
|
mainWindow.show();
|
|
}
|
|
}, 3000);
|
|
|
|
mainWindow.webContents.on(
|
|
"did-fail-load",
|
|
(event, errorCode, errorDescription, validatedURL) => {
|
|
console.error(
|
|
"Failed to load:",
|
|
errorCode,
|
|
errorDescription,
|
|
validatedURL,
|
|
);
|
|
},
|
|
);
|
|
|
|
mainWindow.webContents.on("did-finish-load", () => {
|
|
console.log("Frontend loaded successfully");
|
|
});
|
|
|
|
mainWindow.webContents.on("will-prevent-unload", (event) => {
|
|
// Explicit Quit must not be vetoed by the renderer's connection guard.
|
|
// In this Electron event, preventDefault allows the unload to proceed.
|
|
if (isQuitting) event.preventDefault();
|
|
});
|
|
|
|
mainWindow.on("close", (event) => {
|
|
if (!isQuitting && tray && !tray.isDestroyed()) {
|
|
event.preventDefault();
|
|
// Stock GNOME has no tray to bring a hidden window back from.
|
|
if (
|
|
process.platform === "linux" &&
|
|
/gnome/i.test(process.env.XDG_CURRENT_DESKTOP || "")
|
|
) {
|
|
mainWindow.minimize();
|
|
} else {
|
|
mainWindow.hide();
|
|
}
|
|
}
|
|
});
|
|
|
|
mainWindow.on("closed", () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
try {
|
|
const parsed = new URL(url);
|
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
shell.openExternal(url);
|
|
}
|
|
} catch {
|
|
// invalid URL, ignore
|
|
}
|
|
return { action: "deny" };
|
|
});
|
|
|
|
mainWindow.webContents.on("will-navigate", (event, url) => {
|
|
const allowedUrl = isDev
|
|
? url.startsWith("http://localhost:5173/")
|
|
: url === pathToFileURL(path.join(appRoot, "dist", "index.html")).href;
|
|
if (!allowedUrl) event.preventDefault();
|
|
});
|
|
}
|
|
|
|
ipcMain.handle("get-app-version", () => {
|
|
return app.getVersion();
|
|
});
|
|
|
|
ipcMain.handle("get-platform", () => {
|
|
return process.platform;
|
|
});
|
|
|
|
ipcMain.handle("get-embedded-server-status", () => {
|
|
return {
|
|
running:
|
|
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
|
|
// A backend that is merely slow to boot reports no failure, so the
|
|
// renderer keeps waiting for it. Only a classified failure means the
|
|
// process is gone for good and waiting is pointless.
|
|
failure: backendFailure,
|
|
dataDir: isDev ? null : getBackendDataDir(),
|
|
};
|
|
});
|
|
|
|
// External login through the system browser (RFC 8252)
|
|
ipcMain.handle(
|
|
"external-browser-login",
|
|
async (_event, authUrl, callbackPort) => {
|
|
const http = require("http");
|
|
|
|
return new Promise((resolve) => {
|
|
let timeout;
|
|
let settled = false;
|
|
const server = http.createServer((req, res) => {
|
|
const url = new URL(req.url || "/", `http://localhost:${callbackPort}`);
|
|
if (url.pathname === "/oidc-callback") {
|
|
const success = url.searchParams.get("success");
|
|
const error = url.searchParams.get("error");
|
|
const token = url.searchParams.get("token");
|
|
const secondFactor = url.searchParams.get("second_factor") === "1";
|
|
|
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
res.end(
|
|
`<html><body><h2>${success === "true" || secondFactor ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
|
|
);
|
|
|
|
if (secondFactor) {
|
|
finish({
|
|
success: false,
|
|
secondFactor: true,
|
|
tempToken: url.searchParams.get("temp_token"),
|
|
factors: url.searchParams.get("second_factors"),
|
|
});
|
|
} else if (success === "true") {
|
|
finish({ success: true, token });
|
|
} else {
|
|
finish({
|
|
success: false,
|
|
error: error || "Authentication failed",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
res.end("Not found");
|
|
});
|
|
|
|
const finish = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeout) clearTimeout(timeout);
|
|
try {
|
|
server.close();
|
|
} catch {
|
|
// Server may not have started yet.
|
|
}
|
|
resolve(result);
|
|
};
|
|
|
|
const fail = (error) => {
|
|
finish({
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
};
|
|
|
|
server.once("error", fail);
|
|
|
|
server.listen(callbackPort, "localhost", async () => {
|
|
try {
|
|
await shell.openExternal(authUrl);
|
|
} catch (error) {
|
|
fail(error);
|
|
}
|
|
});
|
|
|
|
// Timeout after 5 minutes
|
|
timeout = setTimeout(
|
|
() => {
|
|
fail(new Error("External login timed out"));
|
|
},
|
|
5 * 60 * 1000,
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
// Desktop-only settings, such as whether hosts connect from this device or
|
|
// through the linked server by default.
|
|
ipcMain.handle("get-desktop-settings", () => {
|
|
return desktopSettings.getDesktopSettings();
|
|
});
|
|
|
|
ipcMain.handle("save-desktop-settings", (_event, settings) => {
|
|
return desktopSettings.saveDesktopSettings(settings);
|
|
});
|
|
|
|
// The server an older version of the app was pointed at, offered again when
|
|
// linking so upgraded installs do not have to look it up.
|
|
ipcMain.handle("get-previous-server-url", () => {
|
|
return { serverUrl: desktopSettings.getPreviousServerUrl() };
|
|
});
|
|
|
|
function getC2STunnelConfigPath() {
|
|
return path.join(app.getPath("userData"), "c2s-tunnels.json");
|
|
}
|
|
|
|
ipcMain.handle("get-c2s-tunnel-config", () => {
|
|
try {
|
|
const configPath = getC2STunnelConfigPath();
|
|
if (!fs.existsSync(configPath)) {
|
|
return [];
|
|
}
|
|
const configData = fs.readFileSync(configPath, "utf8");
|
|
const parsed = JSON.parse(configData);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch (error) {
|
|
console.error("Error reading C2S tunnel config:", error);
|
|
return [];
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("save-c2s-tunnel-config", async (_event, config) => {
|
|
try {
|
|
if (!Array.isArray(config)) {
|
|
return { success: false, error: "C2S tunnel config must be an array" };
|
|
}
|
|
const autoStartListeners = new Set();
|
|
const autoStartRemoteListeners = new Set();
|
|
for (const tunnel of config) {
|
|
if (!tunnel?.autoStart) continue;
|
|
const mode = tunnel.mode || tunnel.tunnelType || "local";
|
|
if (mode === "remote") {
|
|
const sourceHostId = Number(tunnel.sourceHostId);
|
|
const sourcePort = Number(tunnel.sourcePort);
|
|
const remoteAddress = getC2SRemoteAddress(tunnel);
|
|
if (
|
|
!Number.isInteger(sourceHostId) ||
|
|
sourceHostId < 1 ||
|
|
!Number.isInteger(sourcePort) ||
|
|
sourcePort < 1 ||
|
|
sourcePort > 65535
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: "Invalid remote client tunnel endpoint or port",
|
|
};
|
|
}
|
|
const listenerKey = `${sourceHostId}:${remoteAddress}:${sourcePort}`;
|
|
if (autoStartRemoteListeners.has(listenerKey)) {
|
|
return {
|
|
success: false,
|
|
error: `Another auto-start client tunnel already uses remote ${listenerKey}`,
|
|
};
|
|
}
|
|
autoStartRemoteListeners.add(listenerKey);
|
|
continue;
|
|
}
|
|
|
|
const bindHost = getC2SLocalAddress(tunnel);
|
|
const sourcePort = Number(tunnel.sourcePort);
|
|
const listenerKey = `${bindHost}:${sourcePort}`;
|
|
if (autoStartListeners.has(listenerKey)) {
|
|
return {
|
|
success: false,
|
|
error: `Another auto-start client tunnel already uses ${listenerKey}`,
|
|
};
|
|
}
|
|
autoStartListeners.add(listenerKey);
|
|
}
|
|
for (const listenerKey of autoStartListeners) {
|
|
const sourcePortSeparator = listenerKey.lastIndexOf(":");
|
|
const bindHost = listenerKey.slice(0, sourcePortSeparator);
|
|
const sourcePort = listenerKey.slice(sourcePortSeparator + 1);
|
|
const result = await checkLocalPortAvailable(
|
|
bindHost,
|
|
Number(sourcePort),
|
|
);
|
|
const ownedByClientTunnel = Array.from(c2sTunnelRuntimes.values()).some(
|
|
(runtime) =>
|
|
runtime.bindHost === bindHost &&
|
|
runtime.sourcePort === Number(sourcePort),
|
|
);
|
|
if (!result.available && !ownedByClientTunnel) {
|
|
return {
|
|
success: false,
|
|
error: `Cannot auto-start client tunnel on ${listenerKey}: ${result.error || "port is already in use"}`,
|
|
};
|
|
}
|
|
}
|
|
const userDataPath = app.getPath("userData");
|
|
if (!fs.existsSync(userDataPath)) {
|
|
fs.mkdirSync(userDataPath, { recursive: true });
|
|
}
|
|
fs.writeFileSync(getC2STunnelConfigPath(), JSON.stringify(config, null, 2));
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Error saving C2S tunnel config:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
function checkLocalPortAvailable(host, port) {
|
|
return new Promise((resolve) => {
|
|
const server = net.createServer();
|
|
server.once("error", (error) => {
|
|
resolve({ available: false, error: error.message });
|
|
});
|
|
server.once("listening", () => {
|
|
server.close(() => resolve({ available: true }));
|
|
});
|
|
server.listen({ host, port });
|
|
});
|
|
}
|
|
|
|
function checkTcpConnection(host, port) {
|
|
return new Promise((resolve) => {
|
|
const socket = net.createConnection({ host, port });
|
|
const timer = setTimeout(() => {
|
|
socket.destroy();
|
|
resolve({ success: false, error: "Connection timed out" });
|
|
}, 5000);
|
|
|
|
socket.once("connect", () => {
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
resolve({ success: true });
|
|
});
|
|
socket.once("error", (error) => {
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
resolve({ success: false, error: error.message });
|
|
});
|
|
});
|
|
}
|
|
|
|
function getC2SAddress(value, fallback) {
|
|
const address = typeof value === "string" ? value.trim() : "";
|
|
return address || fallback;
|
|
}
|
|
|
|
function getC2SLocalAddress(tunnel) {
|
|
return getC2SAddress(tunnel?.localAddress || tunnel?.bindHost, "127.0.0.1");
|
|
}
|
|
|
|
function getC2SRemoteAddress(tunnel) {
|
|
return getC2SAddress(
|
|
tunnel?.remoteAddress || tunnel?.targetHost,
|
|
"127.0.0.1",
|
|
);
|
|
}
|
|
|
|
function normalizeC2STunnelAddresses(tunnel) {
|
|
const localAddress = getC2SLocalAddress(tunnel);
|
|
const remoteAddress = getC2SRemoteAddress(tunnel);
|
|
return {
|
|
...tunnel,
|
|
localAddress,
|
|
remoteAddress,
|
|
bindHost: localAddress,
|
|
targetHost: remoteAddress,
|
|
};
|
|
}
|
|
|
|
const c2sTunnelRuntimes = new Map();
|
|
const C2S_WS_HIGH_WATERMARK = 1024 * 1024;
|
|
const C2S_WS_LOW_WATERMARK = 256 * 1024;
|
|
const C2S_STREAM_WRITE_LIMIT = 8 * 1024 * 1024;
|
|
|
|
// C2S (client-to-server) tunnels relay through the server this desktop is
|
|
// linked to, not the embedded backend.
|
|
function getC2SRemoteBaseUrl() {
|
|
const serverUrl = linkedServer.getLinkedServer()?.serverUrl;
|
|
if (!serverUrl) {
|
|
throw new Error(
|
|
"This device is not linked to a Termix server. Link it in Sync first.",
|
|
);
|
|
}
|
|
return serverUrl.replace(/\/$/, "");
|
|
}
|
|
|
|
// The socket path on the linked server that relays C2S streams. The plugin
|
|
// that owns the relay tells us where it is, so nothing here names it.
|
|
let c2sRelayPath = null;
|
|
const C2S_RELAY_PATH_PATTERN =
|
|
/^\/plugin-ws\/[a-z0-9][a-z0-9-]*\/[A-Za-z0-9/_-]+$/;
|
|
|
|
ipcMain.handle("set-c2s-relay-path", (_event, relayPath) => {
|
|
if (
|
|
typeof relayPath !== "string" ||
|
|
!C2S_RELAY_PATH_PATTERN.test(relayPath)
|
|
) {
|
|
return { success: false, error: "Invalid relay path" };
|
|
}
|
|
c2sRelayPath = relayPath;
|
|
return { success: true };
|
|
});
|
|
|
|
function getC2SRelayUrl() {
|
|
if (!c2sRelayPath) {
|
|
throw new Error("Client tunnels are not available yet. Try again shortly.");
|
|
}
|
|
const relayHttpUrl = `${getC2SRemoteBaseUrl()}${c2sRelayPath}`;
|
|
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
}
|
|
|
|
const C2S_REMOTE_SESSION_EXPIRED_ERROR =
|
|
"This device was signed out of its server. Sign in again from Sync and try again.";
|
|
|
|
function normalizeC2SAuthToken(authToken) {
|
|
return typeof authToken === "string" ? authToken.trim() : "";
|
|
}
|
|
|
|
function isC2SAuthError(message) {
|
|
const value = String(message || "")
|
|
.trim()
|
|
.toLowerCase();
|
|
return (
|
|
value === "authentication required" ||
|
|
value === "missing authentication token" ||
|
|
value === "invalid token" ||
|
|
value === "session expired" ||
|
|
value === "session not found" ||
|
|
// The plugin relay refuses an unauthenticated upgrade before any message.
|
|
value === "unexpected server response: 401"
|
|
);
|
|
}
|
|
|
|
function normalizeC2SErrorMessage(message, fallback = "Client tunnel failed") {
|
|
const value = message || fallback;
|
|
return isC2SAuthError(value) ? C2S_REMOTE_SESSION_EXPIRED_ERROR : value;
|
|
}
|
|
|
|
function createC2SFailure(message, fallback) {
|
|
const error = normalizeC2SErrorMessage(message, fallback);
|
|
return {
|
|
success: false,
|
|
error,
|
|
...(error === C2S_REMOTE_SESSION_EXPIRED_ERROR
|
|
? { code: "REMOTE_SESSION_EXPIRED" }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
async function fetchC2SRemoteJson(pathname) {
|
|
if (!linkedServer.getLinkedServer()?.token) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
|
|
const url = `${getC2SRemoteBaseUrl()}${pathname}`;
|
|
const response = await httpFetch(url, {
|
|
headers: linkedServer.linkedRequestHeaders(),
|
|
});
|
|
if (response.status === 401 || response.status === 403) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(`Request failed (${response.status}): ${url}`);
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
async function resolveC2SRemoteSourceHost(tunnel) {
|
|
const normalized = normalizeC2STunnelAddresses(tunnel);
|
|
const sourceHostSyncId =
|
|
typeof normalized.sourceHostSyncId === "string"
|
|
? normalized.sourceHostSyncId.trim()
|
|
: "";
|
|
if (!sourceHostSyncId) return normalized;
|
|
|
|
let remoteHost = null;
|
|
try {
|
|
remoteHost = await fetchC2SRemoteJson(
|
|
`/sync/v2/hosts/${encodeURIComponent(sourceHostSyncId)}`,
|
|
);
|
|
} catch (error) {
|
|
if (error.message === C2S_REMOTE_SESSION_EXPIRED_ERROR) throw error;
|
|
}
|
|
const remoteHostId = Number(remoteHost?.id);
|
|
if (!Number.isInteger(remoteHostId) || remoteHostId < 1) {
|
|
throw new Error(
|
|
"The intermediate host is not on the server yet. Sync, then try again.",
|
|
);
|
|
}
|
|
|
|
return {
|
|
...normalized,
|
|
sourceHostId: remoteHostId,
|
|
sourceHostSyncId,
|
|
sourceHostName: remoteHost.name || normalized.sourceHostName,
|
|
endpointHost: normalized.endpointHost || remoteHost.name,
|
|
};
|
|
}
|
|
|
|
async function getC2SRelayHeaders(relayUrl) {
|
|
if (linkedServer.getLinkedServer()?.token) {
|
|
return linkedServer.linkedRequestHeaders();
|
|
}
|
|
const headers = { "X-Electron-App": "true" };
|
|
|
|
if (!mainWindow?.webContents?.session) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
|
|
const cookieUrl = relayUrl
|
|
.replace(/^ws:/, "http:")
|
|
.replace(/^wss:/, "https:");
|
|
const cookies = await mainWindow.webContents.session.cookies.get({
|
|
url: cookieUrl,
|
|
name: "jwt",
|
|
});
|
|
const jwt = cookies[0]?.value;
|
|
if (!jwt) {
|
|
throw new Error(C2S_REMOTE_SESSION_EXPIRED_ERROR);
|
|
}
|
|
|
|
headers.Cookie = `jwt=${encodeURIComponent(jwt)}`;
|
|
return headers;
|
|
}
|
|
|
|
const attachC2SAuth = require("./c2s-auth.cjs").createC2SAuthBridge(
|
|
ipcMain,
|
|
() => mainWindow,
|
|
);
|
|
|
|
function getC2STunnelName(tunnel, index = 0) {
|
|
if (tunnel.name) return tunnel.name;
|
|
const localAddress = getC2SLocalAddress(tunnel);
|
|
const remoteAddress = getC2SRemoteAddress(tunnel);
|
|
return [
|
|
"c2s",
|
|
index,
|
|
tunnel.sourceHostId || 0,
|
|
tunnel.mode || tunnel.tunnelType || "local",
|
|
localAddress,
|
|
remoteAddress,
|
|
tunnel.sourcePort,
|
|
tunnel.endpointPort || 0,
|
|
].join("::");
|
|
}
|
|
|
|
function getC2STunnelStatus(tunnelName) {
|
|
return (
|
|
c2sTunnelRuntimes.get(tunnelName)?.status || {
|
|
connected: false,
|
|
status: "DISCONNECTED",
|
|
}
|
|
);
|
|
}
|
|
|
|
function getAllC2STunnelStatuses() {
|
|
const statuses = {};
|
|
for (const [tunnelName] of c2sTunnelRuntimes.entries()) {
|
|
statuses[tunnelName] = getC2STunnelStatus(tunnelName);
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
function emitC2STunnelStatuses() {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
mainWindow.webContents.send("c2s-tunnel-statuses", getAllC2STunnelStatuses());
|
|
}
|
|
|
|
function setC2STunnelStatus(tunnelName, status) {
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
if (runtime) {
|
|
runtime.status = status;
|
|
emitC2STunnelStatuses();
|
|
}
|
|
}
|
|
|
|
function setC2STunnelError(tunnelName, message) {
|
|
logToFile(`[c2s] ${tunnelName} failed:`, message);
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "ERROR",
|
|
reason: message,
|
|
});
|
|
}
|
|
|
|
function parseSocks5Target(buffer) {
|
|
if (buffer.length < 7 || buffer[0] !== 0x05 || buffer[1] !== 0x01) {
|
|
return null;
|
|
}
|
|
|
|
const addressType = buffer[3];
|
|
let offset = 4;
|
|
let host;
|
|
|
|
if (addressType === 0x01) {
|
|
if (buffer.length < offset + 4 + 2) return null;
|
|
host = Array.from(buffer.subarray(offset, offset + 4)).join(".");
|
|
offset += 4;
|
|
} else if (addressType === 0x03) {
|
|
const length = buffer[offset];
|
|
offset += 1;
|
|
if (buffer.length < offset + length + 2) return null;
|
|
host = buffer.subarray(offset, offset + length).toString("utf8");
|
|
offset += length;
|
|
} else if (addressType === 0x04) {
|
|
if (buffer.length < offset + 16 + 2) return null;
|
|
const parts = [];
|
|
for (let i = 0; i < 16; i += 2) {
|
|
parts.push(buffer.readUInt16BE(offset + i).toString(16));
|
|
}
|
|
host = parts.join(":");
|
|
offset += 16;
|
|
} else {
|
|
throw new Error("Unsupported SOCKS5 address type");
|
|
}
|
|
|
|
const port = buffer.readUInt16BE(offset);
|
|
return { host, port, bytesRead: offset + 2 };
|
|
}
|
|
|
|
async function openC2SRelay(
|
|
tunnel,
|
|
targetHost,
|
|
targetPort,
|
|
socket,
|
|
initialData,
|
|
authToken,
|
|
) {
|
|
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
|
logToFile(`[c2s] opening relay for ${tunnelName}`, {
|
|
relayUrl,
|
|
targetHost,
|
|
targetPort,
|
|
});
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "CONNECTING",
|
|
reason: `Opening relay to ${targetHost}:${targetPort}`,
|
|
});
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
|
const pendingChunks = [];
|
|
let ready = false;
|
|
let closed = false;
|
|
|
|
const cleanup = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
try {
|
|
socket.destroy();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
};
|
|
|
|
const sendChunk = (chunk) => {
|
|
if (ready && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(chunk);
|
|
} else {
|
|
pendingChunks.push(chunk);
|
|
}
|
|
};
|
|
|
|
socket.on("data", sendChunk);
|
|
socket.on("close", cleanup);
|
|
socket.on("error", (error) => {
|
|
setC2STunnelError(tunnelName, error.message || "Local socket error");
|
|
cleanup();
|
|
});
|
|
ws.on("close", cleanup);
|
|
ws.on("error", (error) => {
|
|
setC2STunnelError(tunnelName, error.message || "Relay connection failed");
|
|
cleanup();
|
|
});
|
|
|
|
ws.on("open", () => {
|
|
logToFile(`[c2s] relay connected for ${tunnelName}`);
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "open",
|
|
tunnelConfig,
|
|
targetHost,
|
|
targetPort,
|
|
}),
|
|
);
|
|
});
|
|
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) {
|
|
socket.write(Buffer.isBuffer(data) ? data : Buffer.from(data));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
if (message.type === "ready") {
|
|
ready = true;
|
|
logToFile(`[c2s] relay ready for ${tunnelName}`);
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: true,
|
|
status: "CONNECTED",
|
|
});
|
|
if (initialData?.length) {
|
|
ws.send(initialData);
|
|
}
|
|
while (pendingChunks.length > 0) {
|
|
ws.send(pendingChunks.shift());
|
|
}
|
|
} else if (message.type === "error") {
|
|
const relayError = normalizeC2SErrorMessage(
|
|
message.error,
|
|
"Relay rejected the client tunnel",
|
|
);
|
|
logToFile("[c2s] relay error:", relayError);
|
|
setC2STunnelError(tunnelName, relayError);
|
|
cleanup();
|
|
}
|
|
} catch (error) {
|
|
logToFile("[c2s] invalid relay message:", error.message);
|
|
setC2STunnelError(tunnelName, error.message || "Invalid relay response");
|
|
cleanup();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function testC2SRelay(
|
|
tunnel,
|
|
targetHost,
|
|
targetPort,
|
|
authToken,
|
|
runtime,
|
|
) {
|
|
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) !== runtime) {
|
|
return { success: false, error: "Tunnel stopped" };
|
|
}
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
|
|
|
if (runtime) runtime.authSocket = ws;
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const settle = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try {
|
|
if (!runtime || !result.success) ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
resolve(result);
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
settle({ success: false, error: "Tunnel test timed out" });
|
|
}, 180_000);
|
|
|
|
ws.on("open", () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "test",
|
|
keepAlive: !!runtime,
|
|
tunnelConfig,
|
|
targetHost,
|
|
targetPort,
|
|
}),
|
|
);
|
|
});
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) return;
|
|
|
|
try {
|
|
const message = JSON.parse(data.toString());
|
|
if (message.type === "ready") {
|
|
clearTimeout(timer);
|
|
settle({ success: true });
|
|
} else if (message.type === "error") {
|
|
clearTimeout(timer);
|
|
settle(createC2SFailure(message.error, "Tunnel test failed"));
|
|
}
|
|
} catch (error) {
|
|
clearTimeout(timer);
|
|
settle(createC2SFailure(error.message, "Tunnel test failed"));
|
|
}
|
|
});
|
|
ws.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
settle(createC2SFailure(error.message, "Tunnel test failed"));
|
|
});
|
|
ws.on("close", () => {
|
|
if (runtime && c2sTunnelRuntimes.get(tunnel.name) === runtime) {
|
|
setC2STunnelError(tunnel.name, "Endpoint SSH connection closed");
|
|
}
|
|
clearTimeout(timer);
|
|
settle({ success: false, error: "Tunnel test connection closed" });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function testC2STunnel(tunnel, index = 0, authToken) {
|
|
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
|
|
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
|
|
const testTunnel = {
|
|
...normalizeC2STunnelAddresses(resolvedTunnel),
|
|
name: `${getC2STunnelName(resolvedTunnel, index)}::test`,
|
|
mode,
|
|
};
|
|
const localAddress = getC2SLocalAddress(testTunnel);
|
|
const remoteAddress = getC2SRemoteAddress(testTunnel);
|
|
const sourcePort = Number(resolvedTunnel.sourcePort);
|
|
const endpointPort = Number(resolvedTunnel.endpointPort);
|
|
|
|
if (!resolvedTunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
|
|
if (mode === "remote") {
|
|
const localTarget = await checkTcpConnection(localAddress, endpointPort);
|
|
if (!localTarget.success) {
|
|
return {
|
|
success: false,
|
|
error: `Local target ${localAddress}:${endpointPort} is not reachable: ${localTarget.error}`,
|
|
};
|
|
}
|
|
|
|
return testC2SRelay(testTunnel, undefined, undefined, authToken);
|
|
}
|
|
|
|
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const runtime = c2sTunnelRuntimes.get(
|
|
getC2STunnelName(resolvedTunnel, index),
|
|
);
|
|
if (!runtime) {
|
|
const availability = await checkLocalPortAvailable(
|
|
localAddress,
|
|
sourcePort,
|
|
);
|
|
if (!availability.available) {
|
|
return {
|
|
success: false,
|
|
error: `Local listener ${localAddress}:${sourcePort} is not available: ${availability.error}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (mode === "dynamic") {
|
|
return testC2SRelay(testTunnel, undefined, undefined, authToken);
|
|
}
|
|
|
|
if (!Number.isInteger(endpointPort) || endpointPort < 1) {
|
|
return { success: false, error: "Invalid remote port" };
|
|
}
|
|
|
|
return testC2SRelay(testTunnel, remoteAddress, endpointPort, authToken);
|
|
}
|
|
|
|
function handleC2SDynamicConnection(tunnel, socket, authToken) {
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
let buffer = Buffer.alloc(0);
|
|
let stage = "greeting";
|
|
|
|
const fail = (code = 0x01, message = "SOCKS5 request failed") => {
|
|
setC2STunnelError(tunnelName, message);
|
|
if (!socket.destroyed) {
|
|
socket.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
socket.destroy();
|
|
}
|
|
};
|
|
|
|
const onData = (chunk) => {
|
|
buffer = Buffer.concat([buffer, chunk]);
|
|
|
|
try {
|
|
if (stage === "greeting") {
|
|
if (buffer.length < 2) return;
|
|
if (buffer[0] !== 0x05) {
|
|
fail(0x01, "Invalid SOCKS5 greeting");
|
|
return;
|
|
}
|
|
const methodsLength = buffer[1];
|
|
if (buffer.length < 2 + methodsLength) return;
|
|
socket.write(Buffer.from([0x05, 0x00]));
|
|
buffer = buffer.subarray(2 + methodsLength);
|
|
stage = "connect";
|
|
}
|
|
|
|
if (stage === "connect") {
|
|
const target = parseSocks5Target(buffer);
|
|
if (!target) return;
|
|
|
|
stage = "piping";
|
|
socket.off("data", onData);
|
|
const remainder = buffer.subarray(target.bytesRead);
|
|
socket.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
openC2SRelay(
|
|
tunnel,
|
|
target.host,
|
|
target.port,
|
|
socket,
|
|
remainder,
|
|
authToken,
|
|
).catch((error) => {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Dynamic relay failed",
|
|
);
|
|
logToFile("[c2s] dynamic relay failed:", message);
|
|
fail(0x05, message);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
logToFile("[c2s] SOCKS5 parse failed:", error.message);
|
|
fail(0x01, error.message || "SOCKS5 parse failed");
|
|
}
|
|
};
|
|
|
|
socket.on("data", onData);
|
|
socket.on("error", () => socket.destroy());
|
|
}
|
|
|
|
function handleC2SLocalConnection(tunnel, socket, authToken) {
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
const targetHost = getC2SRemoteAddress(tunnel);
|
|
const targetPort = Number(tunnel.endpointPort);
|
|
openC2SRelay(
|
|
tunnel,
|
|
targetHost,
|
|
targetPort,
|
|
socket,
|
|
undefined,
|
|
authToken,
|
|
).catch((error) => {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Local relay failed",
|
|
);
|
|
logToFile("[c2s] local relay failed:", message);
|
|
setC2STunnelError(tunnelName, message);
|
|
socket.destroy();
|
|
});
|
|
}
|
|
|
|
function pauseSourceForC2SWebSocket(ws, source) {
|
|
if (!source?.pause || !source?.resume) return;
|
|
if (ws.bufferedAmount <= C2S_WS_HIGH_WATERMARK) return;
|
|
|
|
source.pause();
|
|
const resumeTimer = setInterval(() => {
|
|
if (
|
|
ws.readyState !== WebSocket.OPEN ||
|
|
source.destroyed ||
|
|
ws.bufferedAmount <= C2S_WS_LOW_WATERMARK
|
|
) {
|
|
clearInterval(resumeTimer);
|
|
if (ws.readyState === WebSocket.OPEN && !source.destroyed) {
|
|
source.resume();
|
|
}
|
|
}
|
|
}, 25);
|
|
}
|
|
|
|
function sendC2SRemoteMessage(ws, message, source) {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify(message), (error) => {
|
|
if (error && source?.destroy) {
|
|
source.destroy(error);
|
|
}
|
|
});
|
|
pauseSourceForC2SWebSocket(ws, source);
|
|
}
|
|
}
|
|
|
|
function writeC2SRemoteChunk(target, chunk, ws, closeTarget) {
|
|
if (!target || target.destroyed) return;
|
|
|
|
if (target.writableLength > C2S_STREAM_WRITE_LIMIT) {
|
|
closeTarget();
|
|
return;
|
|
}
|
|
|
|
const canContinue = target.write(chunk);
|
|
if (!canContinue && typeof ws.pause === "function") {
|
|
ws.pause();
|
|
target.once("drain", () => {
|
|
if (ws.readyState === WebSocket.OPEN && typeof ws.resume === "function") {
|
|
ws.resume();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
async function startC2SRemoteTunnel(tunnel, index = 0, authToken) {
|
|
const tunnelName = getC2STunnelName(tunnel, index);
|
|
const tunnelConfig = normalizeC2STunnelAddresses(tunnel);
|
|
const localHost = getC2SLocalAddress(tunnelConfig);
|
|
const remoteHost = getC2SRemoteAddress(tunnelConfig);
|
|
const localPort = Number(tunnel.endpointPort);
|
|
const remotePort = Number(tunnel.sourcePort);
|
|
|
|
if (!tunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
if (!Number.isInteger(remotePort) || remotePort < 1 || remotePort > 65535) {
|
|
return { success: false, error: "Invalid remote port" };
|
|
}
|
|
if (!Number.isInteger(localPort) || localPort < 1 || localPort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const localTarget = await checkTcpConnection(localHost, localPort);
|
|
if (!localTarget.success) {
|
|
return {
|
|
success: false,
|
|
error: `Local target ${localHost}:${localPort} is not reachable: ${localTarget.error}`,
|
|
};
|
|
}
|
|
|
|
const existing = c2sTunnelRuntimes.get(tunnelName);
|
|
if (existing) {
|
|
return { success: true, tunnelName };
|
|
}
|
|
|
|
for (const runtime of c2sTunnelRuntimes.values()) {
|
|
if (
|
|
runtime.mode === "remote" &&
|
|
runtime.sourceHostId === Number(tunnel.sourceHostId) &&
|
|
runtime.remoteAddress === remoteHost &&
|
|
runtime.sourcePort === remotePort
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: `Another client remote tunnel already uses ${remoteHost}:${remotePort} on this endpoint`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders(relayUrl, authToken);
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
attachC2SAuth(ws, tunnel.name || getC2STunnelName(tunnel));
|
|
const sockets = new Map();
|
|
let closed = false;
|
|
|
|
const cleanup = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
for (const socket of sockets.values()) {
|
|
socket.destroy();
|
|
}
|
|
sockets.clear();
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
};
|
|
|
|
c2sTunnelRuntimes.set(tunnelName, {
|
|
ws,
|
|
sockets,
|
|
mode: "remote",
|
|
sourceHostId: Number(tunnel.sourceHostId),
|
|
sourcePort: remotePort,
|
|
bindHost: localHost,
|
|
remoteAddress: remoteHost,
|
|
status: { connected: false, status: "CONNECTING" },
|
|
close: cleanup,
|
|
});
|
|
emitC2STunnelStatuses();
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const settle = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(result);
|
|
};
|
|
|
|
ws.on("open", () => {
|
|
logToFile(`[c2s] opening remote tunnel ${tunnelName}`, {
|
|
relayUrl,
|
|
remoteHost,
|
|
remotePort,
|
|
localHost,
|
|
localPort,
|
|
});
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "open",
|
|
tunnelConfig: { ...tunnelConfig, name: tunnelName, mode: "remote" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) return;
|
|
|
|
let message;
|
|
try {
|
|
message = JSON.parse(data.toString());
|
|
} catch (error) {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Invalid relay message",
|
|
);
|
|
setC2STunnelError(tunnelName, message);
|
|
cleanup();
|
|
settle(createC2SFailure(message, "Invalid relay message"));
|
|
return;
|
|
}
|
|
|
|
if (message.type === "ready") {
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: true,
|
|
status: "CONNECTED",
|
|
});
|
|
settle({ success: true, tunnelName });
|
|
return;
|
|
}
|
|
|
|
if (message.type === "error") {
|
|
const error = normalizeC2SErrorMessage(
|
|
message.error,
|
|
"Relay rejected the client tunnel",
|
|
);
|
|
setC2STunnelError(tunnelName, error);
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle(createC2SFailure(error, "Relay rejected the client tunnel"));
|
|
return;
|
|
}
|
|
|
|
if (message.type === "connection" && message.streamId) {
|
|
const socket = net.createConnection(
|
|
{ host: localHost, port: localPort },
|
|
() => {
|
|
logToFile(`[c2s] remote stream ${message.streamId} connected`, {
|
|
tunnelName,
|
|
localHost,
|
|
localPort,
|
|
});
|
|
},
|
|
);
|
|
sockets.set(message.streamId, socket);
|
|
socket.on("data", (chunk) => {
|
|
sendC2SRemoteMessage(
|
|
ws,
|
|
{
|
|
type: "data",
|
|
streamId: message.streamId,
|
|
data: chunk.toString("base64"),
|
|
},
|
|
socket,
|
|
);
|
|
});
|
|
socket.on("close", () => {
|
|
sockets.delete(message.streamId);
|
|
sendC2SRemoteMessage(ws, {
|
|
type: "close",
|
|
streamId: message.streamId,
|
|
});
|
|
});
|
|
socket.on("error", (error) => {
|
|
logToFile(`[c2s] remote stream ${message.streamId} failed:`, {
|
|
tunnelName,
|
|
error: error.message,
|
|
});
|
|
sockets.delete(message.streamId);
|
|
sendC2SRemoteMessage(ws, {
|
|
type: "close",
|
|
streamId: message.streamId,
|
|
error: error.message,
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (message.type === "data" && message.streamId && message.data) {
|
|
const socket = sockets.get(message.streamId);
|
|
writeC2SRemoteChunk(
|
|
socket,
|
|
Buffer.from(message.data, "base64"),
|
|
ws,
|
|
() => {
|
|
if (socket) {
|
|
sockets.delete(message.streamId);
|
|
socket.destroy();
|
|
}
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (message.type === "close" && message.streamId) {
|
|
const socket = sockets.get(message.streamId);
|
|
if (socket) {
|
|
sockets.delete(message.streamId);
|
|
socket.destroy();
|
|
}
|
|
}
|
|
});
|
|
|
|
ws.on("close", () => {
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle({ success: false, error: "Remote tunnel relay closed" });
|
|
});
|
|
|
|
ws.on("error", (error) => {
|
|
const message = normalizeC2SErrorMessage(
|
|
error.message,
|
|
"Relay connection failed",
|
|
);
|
|
setC2STunnelError(tunnelName, message);
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle(createC2SFailure(message, "Relay connection failed"));
|
|
});
|
|
});
|
|
}
|
|
|
|
async function startC2STunnel(tunnel, index = 0, authToken) {
|
|
const resolvedTunnel = await resolveC2SRemoteSourceHost(tunnel);
|
|
const mode = resolvedTunnel.mode || resolvedTunnel.tunnelType || "local";
|
|
const tunnelName = getC2STunnelName(resolvedTunnel, index);
|
|
const tunnelConfig = {
|
|
...normalizeC2STunnelAddresses(resolvedTunnel),
|
|
sessionId: crypto.randomUUID(),
|
|
};
|
|
const bindHost = getC2SLocalAddress(tunnelConfig);
|
|
const sourcePort = Number(resolvedTunnel.sourcePort);
|
|
logToFile(`[c2s] starting tunnel ${tunnelName}`, {
|
|
mode,
|
|
bindHost,
|
|
remoteAddress: getC2SRemoteAddress(tunnelConfig),
|
|
sourcePort,
|
|
sourceHostId: resolvedTunnel.sourceHostId,
|
|
sourceHostSyncId: resolvedTunnel.sourceHostSyncId,
|
|
endpointPort: resolvedTunnel.endpointPort,
|
|
});
|
|
|
|
if (mode === "remote") {
|
|
return startC2SRemoteTunnel(resolvedTunnel, index, authToken);
|
|
}
|
|
if (!resolvedTunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const existing = c2sTunnelRuntimes.get(tunnelName);
|
|
if (existing) {
|
|
return { success: true, tunnelName };
|
|
}
|
|
|
|
for (const runtime of c2sTunnelRuntimes.values()) {
|
|
if (
|
|
runtime.mode !== "remote" &&
|
|
runtime.bindHost === bindHost &&
|
|
runtime.sourcePort === sourcePort
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: `Another client tunnel already uses ${bindHost}:${sourcePort}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const availability = await checkLocalPortAvailable(bindHost, sourcePort);
|
|
if (!availability.available) {
|
|
return {
|
|
success: false,
|
|
error: availability.error || "Port is already in use",
|
|
};
|
|
}
|
|
|
|
const sockets = new Set();
|
|
const server = net.createServer((socket) => {
|
|
if (!c2sTunnelRuntimes.get(tunnelName)?.status.connected) {
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
if (mode === "dynamic") {
|
|
handleC2SDynamicConnection(
|
|
{ ...tunnelConfig, name: tunnelName, mode },
|
|
socket,
|
|
authToken,
|
|
);
|
|
} else {
|
|
handleC2SLocalConnection(
|
|
{ ...tunnelConfig, name: tunnelName, mode },
|
|
socket,
|
|
authToken,
|
|
);
|
|
}
|
|
});
|
|
|
|
c2sTunnelRuntimes.set(tunnelName, {
|
|
server,
|
|
sockets,
|
|
bindHost,
|
|
sourcePort,
|
|
status: { connected: false, status: "CONNECTING" },
|
|
});
|
|
|
|
return new Promise((resolve) => {
|
|
server.once("error", (error) => {
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
logToFile(`[c2s] failed to listen for ${tunnelName}:`, error.message);
|
|
emitC2STunnelStatuses();
|
|
resolve({ success: false, error: error.message });
|
|
});
|
|
server.listen({ host: bindHost, port: sourcePort }, () => {
|
|
logToFile(
|
|
`[c2s] listening for ${tunnelName} on ${bindHost}:${sourcePort}`,
|
|
);
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "CONNECTING",
|
|
reason: "Verifying endpoint SSH connection",
|
|
});
|
|
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
const verifyTunnel = testC2SRelay(
|
|
{ ...tunnelConfig, name: tunnelName, mode },
|
|
mode === "dynamic" ? undefined : getC2SRemoteAddress(tunnelConfig),
|
|
mode === "dynamic" ? undefined : Number(tunnelConfig.endpointPort),
|
|
authToken,
|
|
runtime,
|
|
);
|
|
|
|
verifyTunnel
|
|
.then((result) => {
|
|
if (c2sTunnelRuntimes.get(tunnelName) !== runtime) return;
|
|
if (result.success) {
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: true,
|
|
status: "CONNECTED",
|
|
});
|
|
} else {
|
|
setC2STunnelError(
|
|
tunnelName,
|
|
result.error || "Endpoint SSH connection failed",
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
if (c2sTunnelRuntimes.get(tunnelName) === runtime) {
|
|
setC2STunnelError(
|
|
tunnelName,
|
|
error.message || "Endpoint SSH connection failed",
|
|
);
|
|
}
|
|
});
|
|
|
|
resolve({ success: true, tunnelName });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function stopC2STunnel(tunnelName) {
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
if (!runtime) {
|
|
return { success: true };
|
|
}
|
|
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "DISCONNECTING",
|
|
});
|
|
runtime.authSocket?.close();
|
|
|
|
return new Promise((resolve) => {
|
|
if (typeof runtime.close === "function") {
|
|
runtime.close();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
resolve({ success: true });
|
|
return;
|
|
}
|
|
|
|
for (const socket of runtime.sockets || []) {
|
|
socket.destroy();
|
|
}
|
|
runtime.server?.close(() => {
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
resolve({ success: true });
|
|
});
|
|
});
|
|
}
|
|
|
|
function stopAllC2STunnels() {
|
|
for (const [tunnelName, runtime] of c2sTunnelRuntimes.entries()) {
|
|
try {
|
|
runtime.authSocket?.close();
|
|
if (typeof runtime.close === "function") {
|
|
runtime.close();
|
|
} else {
|
|
for (const socket of runtime.sockets || []) {
|
|
socket.destroy();
|
|
}
|
|
runtime.server?.close();
|
|
}
|
|
} catch (error) {
|
|
logToFile(`[c2s] failed to stop tunnel ${tunnelName}:`, error.message);
|
|
}
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
}
|
|
emitC2STunnelStatuses();
|
|
}
|
|
|
|
async function startC2SAutoStartTunnels() {
|
|
const configPath = getC2STunnelConfigPath();
|
|
if (!fs.existsSync(configPath)) {
|
|
return { success: true, started: 0, errors: [] };
|
|
}
|
|
|
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
const tunnels = Array.isArray(config) ? config : [];
|
|
const errors = [];
|
|
let started = 0;
|
|
|
|
for (let index = 0; index < tunnels.length; index += 1) {
|
|
const tunnel = tunnels[index];
|
|
if (!tunnel?.autoStart) continue;
|
|
const result = await startC2STunnel(tunnel, index);
|
|
if (result.success) {
|
|
started += 1;
|
|
} else {
|
|
errors.push(result.error || "Failed to start client tunnel");
|
|
}
|
|
}
|
|
|
|
return { success: errors.length === 0, started, errors };
|
|
}
|
|
|
|
ipcMain.handle("check-local-port-available", async (_event, host, port) => {
|
|
const sourcePort = Number(port);
|
|
if (
|
|
!host ||
|
|
!Number.isInteger(sourcePort) ||
|
|
sourcePort < 1 ||
|
|
sourcePort > 65535
|
|
) {
|
|
return { available: false, error: "Invalid local bind address or port" };
|
|
}
|
|
return checkLocalPortAvailable(host, sourcePort);
|
|
});
|
|
|
|
ipcMain.handle("start-c2s-tunnel", async (_event, tunnel, index, authToken) => {
|
|
try {
|
|
return await startC2STunnel(
|
|
tunnel,
|
|
Number(index) || 0,
|
|
normalizeC2SAuthToken(authToken),
|
|
);
|
|
} catch (error) {
|
|
return createC2SFailure(error.message, "Failed to start client tunnel");
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index, authToken) => {
|
|
try {
|
|
return await testC2STunnel(
|
|
tunnel,
|
|
Number(index) || 0,
|
|
normalizeC2SAuthToken(authToken),
|
|
);
|
|
} catch (error) {
|
|
return createC2SFailure(error.message, "Failed to test client tunnel");
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("stop-c2s-tunnel", async (_event, tunnelName) => {
|
|
try {
|
|
return await stopC2STunnel(tunnelName);
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-c2s-tunnel-statuses", () => {
|
|
return getAllC2STunnelStatuses();
|
|
});
|
|
|
|
ipcMain.handle("start-c2s-autostart-tunnels", async () => {
|
|
try {
|
|
return await startC2SAutoStartTunnels();
|
|
} catch (error) {
|
|
return { success: false, started: 0, errors: [error.message] };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-c2s-tunnel-preset-default-name", () => {
|
|
const now = new Date();
|
|
const date = now.toISOString().slice(0, 10);
|
|
const platform =
|
|
process.platform === "darwin"
|
|
? "macOS"
|
|
: process.platform === "win32"
|
|
? "Windows"
|
|
: "Linux";
|
|
const release = os.release();
|
|
const computerName = os.hostname();
|
|
return `[${date}] ${computerName} (${platform} ${release})`;
|
|
});
|
|
|
|
ipcMain.handle("get-setting", (event, key) => {
|
|
try {
|
|
const userDataPath = app.getPath("userData");
|
|
const settingsPath = path.join(userDataPath, "settings.json");
|
|
|
|
if (!fs.existsSync(settingsPath)) {
|
|
return null;
|
|
}
|
|
|
|
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
|
const settings = JSON.parse(settingsData);
|
|
return settings[key] !== undefined ? settings[key] : null;
|
|
} catch (error) {
|
|
console.error("Error reading setting:", error);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("set-setting", (event, key, value) => {
|
|
try {
|
|
const userDataPath = app.getPath("userData");
|
|
const settingsPath = path.join(userDataPath, "settings.json");
|
|
|
|
if (!fs.existsSync(userDataPath)) {
|
|
fs.mkdirSync(userDataPath, { recursive: true });
|
|
}
|
|
|
|
let settings = {};
|
|
if (fs.existsSync(settingsPath)) {
|
|
const settingsData = fs.readFileSync(settingsPath, "utf8");
|
|
settings = JSON.parse(settingsData);
|
|
}
|
|
|
|
settings[key] = value;
|
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Error saving setting:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-session-cookie", async (_event, name, targetUrl) => {
|
|
try {
|
|
const ses = mainWindow?.webContents?.session;
|
|
if (!ses) return null;
|
|
const cookies = await ses.cookies.get({
|
|
name,
|
|
...(targetUrl ? { url: targetUrl } : {}),
|
|
});
|
|
const cookie = cookies.find((candidate) =>
|
|
cookieMatchesUrl(candidate, targetUrl),
|
|
);
|
|
return (
|
|
cookie?.value ||
|
|
getRememberedElectronAuthCookie(name, targetUrl)?.value ||
|
|
null
|
|
);
|
|
} catch (error) {
|
|
console.error("Failed to get session cookie:", error);
|
|
return getRememberedElectronAuthCookie(name, targetUrl)?.value || null;
|
|
}
|
|
});
|
|
|
|
function cookieMatchesUrl(cookie, targetUrl) {
|
|
if (!targetUrl) return true;
|
|
|
|
try {
|
|
const targetHost = new URL(targetUrl).hostname;
|
|
const cookieDomain = (cookie.domain || "").replace(/^\./, "");
|
|
|
|
return (
|
|
cookieDomain === targetHost ||
|
|
targetHost.endsWith(`.${cookieDomain}`) ||
|
|
(!cookieDomain && targetHost === "localhost")
|
|
);
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle(
|
|
"wait-session-cookie",
|
|
async (_event, name, targetUrl, previousValue, timeoutMs = 5000) => {
|
|
const ses = mainWindow?.webContents?.session;
|
|
if (!ses) return { success: false, error: "No Electron session" };
|
|
|
|
const existingCookies = await ses.cookies.get({
|
|
name,
|
|
...(targetUrl ? { url: targetUrl } : {}),
|
|
});
|
|
const existingCookie = existingCookies.find((cookie) =>
|
|
cookieMatchesUrl(cookie, targetUrl),
|
|
);
|
|
if (existingCookie?.value && existingCookie.value !== previousValue) {
|
|
return { success: true, value: existingCookie.value };
|
|
}
|
|
|
|
const rememberedCookie = getRememberedElectronAuthCookie(name, targetUrl);
|
|
if (rememberedCookie?.value && rememberedCookie.value !== previousValue) {
|
|
return { success: true, value: rememberedCookie.value };
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
const timeout = setTimeout(() => {
|
|
ses.cookies.off("changed", onCookieChanged);
|
|
resolve({ success: false, error: "Timed out waiting for cookie" });
|
|
}, timeoutMs);
|
|
|
|
function onCookieChanged(_event, cookie, _cause, removed) {
|
|
if (
|
|
removed ||
|
|
cookie.name !== name ||
|
|
!cookie.value ||
|
|
cookie.value === previousValue ||
|
|
!cookieMatchesUrl(cookie, targetUrl)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
clearTimeout(timeout);
|
|
ses.cookies.off("changed", onCookieChanged);
|
|
resolve({ success: true, value: cookie.value });
|
|
}
|
|
|
|
ses.cookies.on("changed", onCookieChanged);
|
|
});
|
|
},
|
|
);
|
|
|
|
ipcMain.handle("clear-session-cookies", async () => {
|
|
try {
|
|
clearPersistedElectronAuthCookies();
|
|
const ses = mainWindow?.webContents?.session;
|
|
if (ses) {
|
|
const cookies = await ses.cookies.get({});
|
|
for (const cookie of cookies) {
|
|
await ses.cookies.remove(getCookieRemovalUrl(cookie), cookie.name);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("Failed to clear session cookies:", error);
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("clipboard-write-text", (_event, text) => {
|
|
clipboard.writeText(typeof text === "string" ? text : String(text ?? ""));
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("clipboard-read-text", () => clipboard.readText());
|
|
|
|
ipcMain.handle("local-terminal-start", (event, dimensions = {}) => {
|
|
const cols = Math.min(500, Math.max(2, Number(dimensions.cols) || 80));
|
|
const rows = Math.min(300, Math.max(1, Number(dimensions.rows) || 24));
|
|
const sessionId = crypto.randomUUID();
|
|
const shellConfig = resolveLocalShell(process.platform, dimensions.shell);
|
|
const child = pty.spawn(shellConfig.file, shellConfig.args, {
|
|
name: "xterm-256color",
|
|
cols,
|
|
rows,
|
|
cwd: os.homedir(),
|
|
env: {
|
|
...process.env,
|
|
TERM: "xterm-256color",
|
|
COLORTERM: "truecolor",
|
|
},
|
|
});
|
|
const ownerId = event.sender.id;
|
|
const session = { ownerId, process: child, ready: false, buffered: "" };
|
|
localTerminalSessions.set(sessionId, session);
|
|
child.onData((data) => {
|
|
if (!session.ready) {
|
|
session.buffered = (session.buffered + data).slice(-1024 * 1024);
|
|
return;
|
|
}
|
|
if (!event.sender.isDestroyed()) {
|
|
event.sender.send(`local-terminal:data:${sessionId}`, data);
|
|
}
|
|
});
|
|
child.onExit(({ exitCode }) => {
|
|
localTerminalSessions.delete(sessionId);
|
|
if (!event.sender.isDestroyed()) {
|
|
event.sender.send(`local-terminal:exit:${sessionId}`, exitCode);
|
|
}
|
|
});
|
|
event.sender.once("destroyed", () => closeLocalTerminalsFor(ownerId));
|
|
return { sessionId, shell: shellConfig.file };
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-ready", (event, sessionId) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
if (!session) return false;
|
|
session.ready = true;
|
|
if (session.buffered && !event.sender.isDestroyed()) {
|
|
event.sender.send(`local-terminal:data:${sessionId}`, session.buffered);
|
|
session.buffered = "";
|
|
}
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-write", (event, sessionId, data) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
if (!session || typeof data !== "string" || data.length > 64 * 1024) {
|
|
return false;
|
|
}
|
|
session.process.write(data);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-resize", (event, sessionId, cols, rows) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
const width = Number(cols);
|
|
const height = Number(rows);
|
|
if (
|
|
!session ||
|
|
!Number.isInteger(width) ||
|
|
!Number.isInteger(height) ||
|
|
width < 2 ||
|
|
width > 500 ||
|
|
height < 1 ||
|
|
height > 300
|
|
) {
|
|
return false;
|
|
}
|
|
session.process.resize(width, height);
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("local-terminal-close", (event, sessionId) => {
|
|
const session = ownedLocalTerminal(event, sessionId);
|
|
if (!session) return false;
|
|
localTerminalSessions.delete(sessionId);
|
|
session.process.kill();
|
|
return true;
|
|
});
|
|
|
|
ipcMain.handle("show-save-dialog", async (_event, options) => {
|
|
return dialog.showSaveDialog(mainWindow, options || {});
|
|
});
|
|
|
|
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
|
return dialog.showOpenDialog(mainWindow, options || {});
|
|
});
|
|
|
|
function toLocalFileType(stat) {
|
|
if (stat.isDirectory()) return "directory";
|
|
if (stat.isFile()) return "file";
|
|
if (stat.isSymbolicLink()) return "link";
|
|
return "other";
|
|
}
|
|
|
|
function getLocalEntry(entryPath, name = path.basename(entryPath)) {
|
|
const stat = fs.lstatSync(entryPath);
|
|
return {
|
|
name,
|
|
path: entryPath,
|
|
type: toLocalFileType(stat),
|
|
size: stat.isFile() ? stat.size : 0,
|
|
created: stat.birthtime.toISOString(),
|
|
modified: stat.mtime.toISOString(),
|
|
modifiedTimestamp: stat.mtimeMs,
|
|
hidden: name.startsWith("."),
|
|
permissions: (stat.mode & 0o777).toString(8).padStart(3, "0"),
|
|
owner: String(stat.uid),
|
|
group: String(stat.gid),
|
|
};
|
|
}
|
|
|
|
function normalizeRelativeLocalPath(relativePath) {
|
|
return relativePath.split(path.sep).join("/");
|
|
}
|
|
|
|
function validateLocalName(name) {
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
throw new Error("Missing name");
|
|
}
|
|
const trimmed = name.trim();
|
|
if (
|
|
trimmed.includes("/") ||
|
|
trimmed.includes("\\") ||
|
|
trimmed === "." ||
|
|
trimmed === ".."
|
|
) {
|
|
throw new Error("Name cannot contain path separators");
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function ensureLocalDirectory(dirPath) {
|
|
if (typeof dirPath !== "string" || !dirPath) {
|
|
throw new Error("Missing directory path");
|
|
}
|
|
const stat = fs.statSync(dirPath);
|
|
if (!stat.isDirectory()) {
|
|
throw new Error("Path is not a directory");
|
|
}
|
|
}
|
|
|
|
function collectLocalFilesFromPath(rootPath, rootName, files, limit) {
|
|
if (files.length >= limit) return;
|
|
|
|
const stat = fs.lstatSync(rootPath);
|
|
if (stat.isSymbolicLink()) return;
|
|
|
|
if (stat.isFile()) {
|
|
files.push({
|
|
path: rootPath,
|
|
name: path.basename(rootPath),
|
|
relativePath: normalizeRelativeLocalPath(rootName),
|
|
size: stat.size,
|
|
created: stat.birthtime.toISOString(),
|
|
modified: stat.mtime.toISOString(),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!stat.isDirectory()) return;
|
|
|
|
for (const childName of fs.readdirSync(rootPath)) {
|
|
if (files.length >= limit) return;
|
|
const childPath = path.join(rootPath, childName);
|
|
collectLocalFilesFromPath(
|
|
childPath,
|
|
path.join(rootName, childName),
|
|
files,
|
|
limit,
|
|
);
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("get-local-home-directory", () => os.homedir());
|
|
|
|
ipcMain.handle("list-local-directory", (_event, dirPath) => {
|
|
try {
|
|
const requestedPath =
|
|
typeof dirPath === "string" && dirPath.trim() ? dirPath : os.homedir();
|
|
const stat = fs.statSync(requestedPath);
|
|
if (!stat.isDirectory()) {
|
|
return {
|
|
success: false,
|
|
path: requestedPath,
|
|
entries: [],
|
|
error: "Path is not a directory",
|
|
};
|
|
}
|
|
|
|
const entries = fs
|
|
.readdirSync(requestedPath, { withFileTypes: true })
|
|
.map((entry) => {
|
|
try {
|
|
return getLocalEntry(
|
|
path.join(requestedPath, entry.name),
|
|
entry.name,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
.sort((a, b) => {
|
|
if (a.type === "directory" && b.type !== "directory") return -1;
|
|
if (a.type !== "directory" && b.type === "directory") return 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
path: requestedPath,
|
|
parent: path.dirname(requestedPath),
|
|
entries,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
path: typeof dirPath === "string" ? dirPath : "",
|
|
entries: [],
|
|
error: error instanceof Error ? error.message : "Failed to list path",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("stat-local-paths", (_event, paths) => {
|
|
const pathList = Array.isArray(paths) ? paths : [];
|
|
return pathList.map((entryPath) => {
|
|
try {
|
|
return { success: true, ...getLocalEntry(entryPath) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
path: entryPath,
|
|
error: error instanceof Error ? error.message : "Failed to stat path",
|
|
};
|
|
}
|
|
});
|
|
});
|
|
|
|
ipcMain.handle("collect-local-files", (_event, paths) => {
|
|
try {
|
|
const pathList = Array.isArray(paths) ? paths : [];
|
|
const files = [];
|
|
const limit = 10000;
|
|
|
|
for (const entryPath of pathList) {
|
|
if (typeof entryPath !== "string" || !entryPath) continue;
|
|
collectLocalFilesFromPath(
|
|
entryPath,
|
|
path.basename(entryPath),
|
|
files,
|
|
limit + 1,
|
|
);
|
|
if (files.length > limit) break;
|
|
}
|
|
|
|
return {
|
|
success: true,
|
|
files: files.slice(0, limit),
|
|
truncated: files.length > limit,
|
|
};
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
files: [],
|
|
error: error instanceof Error ? error.message : "Failed to collect files",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("create-local-folder", (_event, parentPath, folderName) => {
|
|
try {
|
|
ensureLocalDirectory(parentPath);
|
|
const safeName = validateLocalName(folderName);
|
|
const targetPath = path.join(parentPath, safeName);
|
|
fs.mkdirSync(targetPath);
|
|
return { success: true, ...getLocalEntry(targetPath, safeName) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to create folder",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("rename-local-path", (_event, entryPath, newName) => {
|
|
try {
|
|
if (typeof entryPath !== "string" || !entryPath) {
|
|
throw new Error("Missing path");
|
|
}
|
|
const safeName = validateLocalName(newName);
|
|
const targetPath = path.join(path.dirname(entryPath), safeName);
|
|
renameLocalPath(entryPath, targetPath);
|
|
return { success: true, ...getLocalEntry(targetPath, safeName) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to rename path",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("trash-local-path", async (_event, entryPath) => {
|
|
try {
|
|
if (typeof entryPath !== "string" || !entryPath) {
|
|
throw new Error("Missing path");
|
|
}
|
|
await shell.trashItem(entryPath);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : "Failed to delete path",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("chmod-local-path", (_event, entryPath, permissions) => {
|
|
try {
|
|
if (typeof entryPath !== "string" || !entryPath) {
|
|
throw new Error("Missing path");
|
|
}
|
|
if (typeof permissions !== "string" || !/^[0-7]{3,4}$/.test(permissions)) {
|
|
throw new Error("Permissions must be an octal value");
|
|
}
|
|
fs.chmodSync(entryPath, parseInt(permissions, 8));
|
|
return { success: true, ...getLocalEntry(entryPath) };
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error:
|
|
error instanceof Error ? error.message : "Failed to update permissions",
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("create-temp-file", async (_event, fileData) => {
|
|
try {
|
|
const result = createManagedTempFile(
|
|
fileData?.fileName,
|
|
fileData?.content,
|
|
fileData?.encoding,
|
|
);
|
|
return { success: true, ...result };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("create-temp-folder", async (_event, folderData) => {
|
|
try {
|
|
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const tempDir = fs.mkdtempSync(path.join(getTempRoot(), `${tempId}-`));
|
|
const folderPath = path.join(
|
|
tempDir,
|
|
sanitizeFileName(folderData?.folderName || "files"),
|
|
);
|
|
fs.mkdirSync(folderPath, { recursive: true });
|
|
|
|
for (const file of folderData?.files || []) {
|
|
const relativePath = String(file.relativePath || "")
|
|
.split(/[\\/]+/)
|
|
.map(sanitizeFileName)
|
|
.filter(Boolean)
|
|
.join(path.sep);
|
|
if (!relativePath) continue;
|
|
const targetPath = path.join(folderPath, relativePath);
|
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
targetPath,
|
|
decodeFileContent(file.content, file.encoding),
|
|
);
|
|
}
|
|
|
|
tempFiles.set(tempId, { path: folderPath, dir: tempDir });
|
|
return { success: true, tempId, path: folderPath };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("start-drag-to-desktop", (event, dragData) => {
|
|
try {
|
|
const temp = tempFiles.get(dragData?.tempId);
|
|
if (!temp) return { success: false, error: "Temporary file not found" };
|
|
|
|
event.sender.startDrag({
|
|
file: temp.path,
|
|
icon: nativeImage.createEmpty(),
|
|
});
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("cleanup-temp-file", (_event, tempId) => {
|
|
try {
|
|
cleanupManagedTempFile(tempId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("open-external-editor", async (_event, fileData) => {
|
|
try {
|
|
const result = createManagedTempFile(
|
|
fileData?.fileName,
|
|
fileData?.content,
|
|
fileData?.encoding,
|
|
);
|
|
const editId = result.tempId;
|
|
const stat = fs.statSync(result.path);
|
|
const watcher = fs.watch(result.path, { persistent: false }, () => {
|
|
const session = externalEditorSessions.get(editId);
|
|
if (!session) return;
|
|
if (session.timer) clearTimeout(session.timer);
|
|
session.timer = setTimeout(() => notifyExternalEditorSaved(editId), 500);
|
|
});
|
|
|
|
externalEditorSessions.set(editId, {
|
|
path: result.path,
|
|
watcher,
|
|
timer: null,
|
|
lastMtimeMs: stat.mtimeMs,
|
|
});
|
|
|
|
const editorPath =
|
|
typeof fileData?.editorPath === "string" && fileData.editorPath.trim()
|
|
? fileData.editorPath.trim()
|
|
: null;
|
|
const openError = await openPathWithEditor(result.path, editorPath);
|
|
if (openError) {
|
|
closeExternalEditorSession(editId);
|
|
return { success: false, error: openError };
|
|
}
|
|
|
|
return { success: true, editId, path: result.path };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("close-external-editor", (_event, editId) => {
|
|
try {
|
|
closeExternalEditorSession(editId);
|
|
return { success: true };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
// Local disk browsing + streamed local<->remote transfers for the file
|
|
// manager's dual-pane mode (see electron/local-files.cjs).
|
|
registerLocalFileHandlers({ ipcMain, shell });
|
|
|
|
function createMenu() {
|
|
if (process.platform === "darwin") {
|
|
const template = [
|
|
{
|
|
label: app.name,
|
|
submenu: [
|
|
{ role: "about" },
|
|
{ type: "separator" },
|
|
{ role: "services" },
|
|
{ type: "separator" },
|
|
{ role: "hide" },
|
|
{ role: "hideOthers" },
|
|
{ role: "unhide" },
|
|
{ type: "separator" },
|
|
{ role: "quit" },
|
|
],
|
|
},
|
|
{
|
|
label: "Edit",
|
|
submenu: [
|
|
{ role: "undo" },
|
|
{ role: "redo" },
|
|
{ type: "separator" },
|
|
{ role: "cut" },
|
|
{ role: "copy" },
|
|
{ role: "paste" },
|
|
{ role: "selectAll" },
|
|
],
|
|
},
|
|
{
|
|
label: "View",
|
|
submenu: [
|
|
{ role: "reload" },
|
|
{ role: "forceReload" },
|
|
{ role: "toggleDevTools" },
|
|
{ type: "separator" },
|
|
{ role: "resetZoom" },
|
|
{ role: "zoomIn" },
|
|
{ role: "zoomOut" },
|
|
{ type: "separator" },
|
|
{ role: "togglefullscreen" },
|
|
],
|
|
},
|
|
{
|
|
label: "Window",
|
|
submenu: [
|
|
{ role: "minimize" },
|
|
{ role: "zoom" },
|
|
{ type: "separator" },
|
|
{ role: "front" },
|
|
{ type: "separator" },
|
|
{ role: "window" },
|
|
],
|
|
},
|
|
];
|
|
const menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
}
|
|
}
|
|
|
|
app.whenReady().then(async () => {
|
|
logToFile("=== App ready ===");
|
|
logToFile(
|
|
"isDev:",
|
|
isDev,
|
|
"platform:",
|
|
process.platform,
|
|
"arch:",
|
|
process.arch,
|
|
);
|
|
if (process.platform === "win32") {
|
|
app.setAppUserModelId(windowsAppUserModelId);
|
|
}
|
|
createMenu();
|
|
await clearElectronClientCacheIfBuildChanged();
|
|
await clearElectronJwtCookiesAtStartup();
|
|
|
|
if (!isDev) {
|
|
const result = await startBackendServer();
|
|
logToFile("startBackendServer result:", result);
|
|
} else {
|
|
logToFile(
|
|
"Skipping embedded backend (isDev=true) - expecting separate dev:backend process",
|
|
);
|
|
}
|
|
|
|
createTray();
|
|
createWindow();
|
|
desktopSettings.removeOldSyncFiles();
|
|
logToFile("=== Startup complete ===");
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (!tray || tray.isDestroyed()) {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on("activate", () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createWindow();
|
|
} else if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
|
|
app.on("before-quit", () => {
|
|
isQuitting = true;
|
|
});
|
|
|
|
app.on("will-quit", () => {
|
|
console.log("App will quit...");
|
|
for (const editId of externalEditorSessions.keys()) {
|
|
closeExternalEditorSession(editId);
|
|
}
|
|
for (const tempId of tempFiles.keys()) {
|
|
cleanupManagedTempFile(tempId);
|
|
}
|
|
stopAllC2STunnels();
|
|
stopBackendServer();
|
|
});
|
|
|
|
process.on("uncaughtException", (error) => {
|
|
console.error("Uncaught Exception:", error);
|
|
});
|
|
|
|
process.on("unhandledRejection", (reason, promise) => {
|
|
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
|
});
|