mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
+3





![dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>](/assets/img/avatar_default.png)



Luke Gustafson
LukeGus
ZacharyZcR
dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Brennan Neoh
Claude Sonnet 5
kacperpietrzyk
Max
XtraLarge
XtraLarge
Devin Dissanayaka
Peter Cinibulk
Med Ali Ezzeddine
a64c956c5b
* fix: preserve remote sync references (#1092) * fix: centralize outbound address validation (#1093) * fix: preserve architecture in unpacked ASAR path (#1094) * fix: allow sharing empty folders (#1096) * fix: preserve WoL broadcast address (#1097) * fix: deduplicate shared hosts (#1098) * fix snippet execution result handling (#1099) * fix SSH login alert delivery (#1100) * fix outbound DNS lookup callback shape (#1101) * fix OIDC verification for JWKs without alg (#1102) * fix file manager navigation after permission errors (#1103) * fix database persistence during container shutdown (#1104) * fix: persist host command history setting (#1107) * fix: recognize Windows terminal Tab events (#1109) * fix: recognize Windows terminal Tab events * style: format terminal key event test * fix: export repository user record (#1111) * fix: keep localhost database export same-origin (#1112) * fix: support Tailscale auth in tmux monitor (#1113) * fix: forward Android hardware keyboard keys (#1114) * fix: expose jump tunnels to guacd (#1115) * fix OIDC login with unverifiable ID tokens (#1117) verifyOIDCToken passed the raw id_token straight to jose's jwtVerify, which throws JWSInvalid when the token is not a three-segment compact JWS. Authentik issues an encrypted JWE id_token when the provider has an encryption key set, so the callback threw and every OIDC login failed with 'Invalid Compact JWS'. 2.5.0 hid this behind a catch-all that decoded the unverified payload; removing that fallback fixed the trust bug but turned the pre-existing verification failure into a hard login failure. Check the segment count before verifying and raise a distinct OIDCTokenFormatError, which the callback treats as 'no usable claims here' and falls through to the userinfo endpoint. Signature and claim failures still reject the login. Fixes Termix-SSH/Support#1016 Fixes Termix-SSH/Support#1018 * refuse to start with an empty database when data exists elsewhere (#1118) When the data directory holds no database, startup treats it as a first run and silently creates an empty one. A deployment that loses DATA_DIR — an .env file the service no longer loads, a volume that did not mount — lands in exactly that state, so the user is asked to register an admin account again while the real database sits untouched one directory over. It is indistinguishable from the upgrade having deleted everything. Check the known data locations before creating a new database and refuse to start when one of them already holds a database, naming both directories. ALLOW_EMPTY_DATA_DIR=true starts anyway for anyone deliberately starting over. This matches how a failed decryption already behaves: it throws rather than falling back to an empty database. Closes Termix-SSH/Support#1006 * stop read-only shared hosts from being dragged into folders (#1119) Shared hosts hide their edit, share and delete actions based on the recipient's permission level, but the sidebar row stays draggable regardless. Dropping one on a folder issues a bulk folder update the server rejects, so a recipient without edit rights gets a failure toast for an action the UI offered them. Gate draggable on canEditHost, and skip hosts the recipient cannot edit in the move handler so a mixed selection moves what it can instead of failing whole. Closes Termix-SSH/Support#1011 * apply the configured RDP resolution to the session (#1120) The host editor stores width and height in guacamoleConfig, and the backend passes them to guacd in the connection token. The renderer then appends its own width and height query parameters measured from the container, which take precedence, so a configured resolution never reached the session — only dpi did, because that was the one display field GuacamoleApp read back. Pass the configured width and height alongside dpi, and skip the container-driven sendSize on connect and on resize when a resolution is pinned. rescaleDisplay still fits the fixed display into the available space. Closes Termix-SSH/Support#1039 * honour per-host recording flags and explain a missing recording (#1121) The session recording section offers a recording path, a filename template and four content toggles, but the backend overwrote five of the six on every connection. A host could set none of them and get no indication why. Location and filename genuinely are not the host's to choose — recordings are indexed by them for playback and the backend refuses to read outside its recordings directory — so drop those two inputs rather than keep pretending they apply. The content flags are a host-level decision, so default them instead of forcing them. That still leaves the reported case, where guacd writes the file somewhere the backend cannot see it. The warning now reports both paths and names the two env vars that align them, which is otherwise guesswork for a split-container setup. Closes Termix-SSH/Support#1041 * route desktop guacd calls to the connected remote server (#1122) resolveConnectionOrigin() pins RDP/VNC/Telnet to "remote" because the embedded desktop backend does not bundle guacd, and the Guacamole websocket already follows that. The status check and both token calls did not: they use the shared authApi, which in Electron is hard-coded to the embedded backend. So the desktop app asked the backend without guacd whether guacd was available, got "disconnected", and refused to connect — while the connected server it would actually have used reports it as connected and serves the same host fine from the web client. Send those three calls through a remote-origin instance in Electron, alongside the existing file-manager, tunnel and stats ones. Closes Termix-SSH/Support#1043 * move the Homebrew cask to where a tap looks for it (#1123) A tap discovers casks in a top-level Casks/ directory. The cask sat in packaging/Casks/, so tapping the repository succeeded and every subsequent brew install --cask termix reported that no cask with that name exists. Move it and repoint the five workflow references. The release job still rewrites the version and checksum in place, and the electron job still copies it into the generated and submission trees. Closes Termix-SSH/Support#1044 * stop highlighting inside a split control string (#1124) A control string (OSC/DCS/APC/PM) carries text that must never be displayed — an OSC 0 title holds the user, host and path, and PROMPT_COMMAND emits one on every prompt. Its opener and its terminator routinely land in different websocket frames, and the continuation frame contains no escape byte at all, so every guard in the highlighter misses it: TUI_SEQUENCE, CONTROL_STRING_SEQUENCE and hasIncompleteAnsiSequence all only look at one chunk. Highlighting that continuation injects an SGR sequence into the middle of the open string, which aborts it early in xterm.js and prints the remainder as ordinary text — the stray ~/path glued to the prompt, and the cursor arithmetic drift behind the duplicate prompts and Ctrl+R corruption. Track the state across chunks the way alternate-screen mode already is, and skip any chunk that starts or ends inside a control string. A trailing lone ESC counts as inside, since its meaning only arrives with the next chunk. Closes Termix-SSH/Support#1025 * stop session-log route test importing the real repository layer (#1125) The test mocks db, logger and AuthManager, but the route module also calls PermissionManager.getInstance() at import time and pulls in the repository factory, which loads the drizzle schema and the better-sqlite3 native binding. Importing that costs seconds when the full suite runs its projects concurrently, and the test times out at 5s. On its own it passes, so it read as flaky rather than as a missing mock. Mock both. None of it is under test here, and the file now imports in milliseconds regardless of load. * fail the guacamole-lite patch when an anchor is gone (#1126) Each patch bails out with a console.log and process.exit(0) when its anchor string is missing. The write-back happens at the end of the file, so an upstream release that moves any one anchor drops every patch, exits successfully, and leaves postinstall reporting nothing wrong. Termix then builds and starts normally and drops VNC/RDP sessions at runtime — with no signal pointing at the patch. Every patch here is required for correctness: protocol negotiation, the guacd 1.6.0 name handshake, dynamic argument answering, UTF-8 tokens, read-only joins. A missing anchor means the patch no longer applies, so exit non-zero and say which one and what to do. Unchanged: a missing guacamole-lite still skips quietly, and an already-patched tree still exits 0. * fix: clarify desktop local profile (#1095) * fix: clarify desktop local profile * cover the AccordionSection hidden branch The desktop build hides the Security section because the embedded profile signs in automatically and has no login password, so the controls there would imply a protection that does not exist. Nothing asserted that hidden actually keeps the children out of the DOM rather than merely collapsing them. Export the component and cover both states, including that an expanded hidden section still renders nothing. * fix: show remote sync account identity (#1110) * fix: show remote sync account identity * cover getRemoteSyncUserInfo and make its null contract hold Nothing asserted the renderer-side gate: browser builds must not reach for the IPC bridge, and a missing bridge, an unconfigured server, an expired JWT or a failed channel all have to degrade to no identity rather than throw. Writing that turned up a mismatch — with no preload bridge the optional chain resolved to undefined while the signature promises null. The only caller uses ??, so nothing is broken today, but the type was not telling the truth. The main-process half (token expiry, /users/me, the roles fallback) stays uncovered: remote-sync.cjs requires electron at load, so exercising it means stubbing safeStorage and the filesystem, which is a bigger change than this PR warrants. * improve settings navigation and legal disclosure (#1105) * fix desktop preference synchronization (#1106) * fix: use jump host SOCKS proxy settings (#1116) * ci(deps): bump the github-actions group with 2 updates (#1086) Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [useblacksmith/setup-docker-builder](https://github.com/useblacksmith/setup-docker-builder). Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) Updates `useblacksmith/setup-docker-builder` from 1 to 2 - [Release notes](https://github.com/useblacksmith/setup-docker-builder/releases) - [Commits](https://github.com/useblacksmith/setup-docker-builder/compare/v1...v2) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: useblacksmith/setup-docker-builder dependency-version: '2' 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 23 updates (#1087) Bumps the dev-patch-updates group with 23 updates: | Package | From | To | | --- | --- | --- | | [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.5.4` | `2.5.5` | | [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.17` | `1.2.20` | | [@radix-ui/react-alert-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/alert-dialog) | `1.1.20` | `1.1.23` | | [@radix-ui/react-checkbox](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/checkbox) | `1.3.8` | `1.3.11` | | [@radix-ui/react-dialog](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dialog) | `1.1.20` | `1.1.23` | | [@radix-ui/react-dropdown-menu](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/dropdown-menu) | `2.1.21` | `2.1.24` | | [@radix-ui/react-label](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/label) | `2.1.12` | `2.1.15` | | [@radix-ui/react-popover](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/popover) | `1.1.20` | `1.1.23` | | [@radix-ui/react-progress](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/progress) | `1.1.13` | `1.1.16` | | [@radix-ui/react-scroll-area](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/scroll-area) | `1.2.15` | `1.2.18` | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.3.4` | `2.3.7` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.12` | `1.1.15` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.4.4` | `1.4.7` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.3.0` | `1.3.3` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.3.4` | `1.3.7` | | [@radix-ui/react-tabs](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tabs) | `1.1.18` | `1.1.21` | | [@radix-ui/react-tooltip](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/tooltip) | `1.2.13` | `1.2.16` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.3` | `6.0.4` | | [concurrently](https://github.com/open-cli-tools/concurrently) | `10.0.3` | `10.0.4` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.6.3` | `1.6.7` | | [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.7` | `19.2.8` | | [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.7` | `19.2.8` | | [react-i18next](https://github.com/i18next/react-i18next) | `17.0.10` | `17.0.11` | Updates `@biomejs/biome` from 2.5.4 to 2.5.5 - [Release notes](https://github.com/biomejs/biome/releases) - [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md) - [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.5/packages/@biomejs/biome) Updates `@radix-ui/react-accordion` from 1.2.17 to 1.2.20 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/accordion/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/accordion) Updates `@radix-ui/react-alert-dialog` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/alert-dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/alert-dialog) Updates `@radix-ui/react-checkbox` from 1.3.8 to 1.3.11 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/checkbox/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/checkbox) Updates `@radix-ui/react-dialog` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dialog/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dialog) Updates `@radix-ui/react-dropdown-menu` from 2.1.21 to 2.1.24 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/dropdown-menu/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/dropdown-menu) Updates `@radix-ui/react-label` from 2.1.12 to 2.1.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/label/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/label) Updates `@radix-ui/react-popover` from 1.1.20 to 1.1.23 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/popover/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/popover) Updates `@radix-ui/react-progress` from 1.1.13 to 1.1.16 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/progress/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/progress) Updates `@radix-ui/react-scroll-area` from 1.2.15 to 1.2.18 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/scroll-area) Updates `@radix-ui/react-select` from 2.3.4 to 2.3.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-separator` from 1.1.12 to 1.1.15 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/separator/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/separator) Updates `@radix-ui/react-slider` from 1.4.4 to 1.4.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.3.0 to 1.3.3 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.3.4 to 1.3.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `@radix-ui/react-tabs` from 1.1.18 to 1.1.21 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tabs/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tabs) Updates `@radix-ui/react-tooltip` from 1.2.13 to 1.2.16 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/tooltip/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/tooltip) Updates `@vitejs/plugin-react` from 6.0.3 to 6.0.4 - [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.0.4/packages/plugin-react) Updates `concurrently` from 10.0.3 to 10.0.4 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v10.0.3...v10.0.4) Updates `radix-ui` from 1.6.3 to 1.6.7 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/1.6.7/packages/react/radix-ui) Updates `react` from 19.2.7 to 19.2.8 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.2.8/packages/react) Updates `react-dom` from 19.2.7 to 19.2.8 - [Release notes](https://github.com/react/react/releases) - [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom) Updates `react-i18next` from 17.0.10 to 17.0.11 - [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md) - [Commits](https://github.com/i18next/react-i18next/compare/v17.0.10...v17.0.11) --- updated-dependencies: - dependency-name: "@biomejs/biome" dependency-version: 2.5.5 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-accordion" dependency-version: 1.2.20 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-alert-dialog" dependency-version: 1.1.23 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-checkbox" dependency-version: 1.3.11 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dialog" dependency-version: 1.1.23 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-dropdown-menu" dependency-version: 2.1.24 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-label" dependency-version: 2.1.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-popover" dependency-version: 1.1.23 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-progress" dependency-version: 1.1.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-scroll-area" dependency-version: 1.2.18 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-separator" dependency-version: 1.1.15 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tabs" dependency-version: 1.1.21 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@radix-ui/react-tooltip" dependency-version: 1.2.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: "@vitejs/plugin-react" dependency-version: 6.0.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: concurrently dependency-version: 10.0.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: radix-ui dependency-version: 1.6.7 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react dependency-version: 19.2.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react-dom dependency-version: 19.2.8 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-patch-updates - dependency-name: react-i18next dependency-version: 17.0.11 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 3 updates (#1088) Bumps the prod-patch-updates group with 3 updates: [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual), [jose](https://github.com/panva/jose) and [js-yaml](https://github.com/nodeca/js-yaml). Updates `@tanstack/react-virtual` from 3.14.6 to 3.14.8 - [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.8/packages/react-virtual) Updates `jose` from 6.2.3 to 6.2.4 - [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.3...v6.2.4) Updates `js-yaml` from 5.2.1 to 5.2.2 - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/5.2.1...5.2.2) --- updated-dependencies: - dependency-name: "@tanstack/react-virtual" dependency-version: 3.14.8 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: jose dependency-version: 6.2.4 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: prod-patch-updates - dependency-name: js-yaml dependency-version: 5.2.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> * chore(deps): bump undici in the prod-minor-updates group (#1089) Bumps the prod-minor-updates group with 1 update: [undici](https://github.com/nodejs/undici). Updates `undici` from 8.7.0 to 8.9.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.7.0...v8.9.0) --- updated-dependencies: - dependency-name: undici dependency-version: 8.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-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 major-updates group with 4 updates (#1090) Bumps the major-updates group with 4 updates: [better-sqlite3](https://github.com/WiseLibs/better-sqlite3), [chalk](https://github.com/chalk/chalk), [@testing-library/jest-dom](https://github.com/testing-library/jest-dom) and [typescript](https://github.com/microsoft/TypeScript). Updates `better-sqlite3` from 12.11.1 to 13.0.1 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.11.1...v13.0.1) Updates `chalk` from 5.6.2 to 6.0.0 - [Release notes](https://github.com/chalk/chalk/releases) - [Commits](https://github.com/chalk/chalk/compare/v5.6.2...v6.0.0) Updates `@testing-library/jest-dom` from 6.9.1 to 7.0.0 - [Release notes](https://github.com/testing-library/jest-dom/releases) - [Changelog](https://github.com/testing-library/jest-dom/blob/main/CHANGELOG.md) - [Commits](https://github.com/testing-library/jest-dom/compare/v6.9.1...v7.0.0) Updates `typescript` from 6.0.3 to 7.0.2 - [Release notes](https://github.com/microsoft/TypeScript/releases) - [Commits](https://github.com/microsoft/TypeScript/commits) --- updated-dependencies: - dependency-name: better-sqlite3 dependency-version: 13.0.1 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: chalk dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: major-updates - dependency-name: "@testing-library/jest-dom" dependency-version: 7.0.0 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates - dependency-name: typescript dependency-version: 7.0.2 dependency-type: direct:development update-type: version-update:semver-major dependency-group: major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * restore lint by pinning typescript below 7 (#1131) #1090 bumped typescript to 7.0.2. typescript-eslint declares `typescript: >=4.8.4 <6.1.0`, and TypeScript 7 removed `ts.Extension`, which @typescript-eslint/typescript-estree dereferences at import time: node_modules/@typescript-eslint/typescript-estree/dist/create-program/shared.js:59 ts.Extension.Cjs, TypeError: Cannot read properties of undefined (reading 'Cjs') ESLint hits that while loading eslint.config.mjs, so `npm run lint` fails before linting anything. Node reports it as ERR_INTERNAL_ASSERTION, which hides the cause. Every open PR fails this check, not just new ones. Even the latest typescript-eslint prerelease still caps at <6.1.0, so there is nothing to upgrade to yet. Pin back to ~6.0.3 and tell dependabot to hold major typescript bumps until the ecosystem catches up. Also fixes biome.json pointing vcs.defaultBranch at dev-2.5.0, a branch that no longer exists. * make the repository layer engine-agnostic (#1127) DatabaseContext handed every repository a raw better-sqlite3 handle alongside drizzle, and three of them used it for retention queries built on datetime('now', ?) — a SQLite-only function. That handle is the one thing standing between the repository layer and a second engine. Drop it. The two time-based prunes compute their cutoff in JS against the CURRENT_TIMESTAMP text format, which every engine writes the same way and which compares correctly as a string; the health-history prune becomes a select of the rows to keep followed by a NOT IN delete. All three turn async, so their two callers await them. Name the dialect rather than repeating a string literal, so adding an engine is one edit instead of a search. Tests built their schema through context.sqlite?.exec(). Optional chaining meant removing the field type-checked cleanly and then silently created no tables, so the fixture now owns exec() and a raw handle for direct assertions — schema setup belongs to the test harness, not to the interface repositories consume. No behaviour change, and no Postgres yet: this only removes the coupling that would have to be undone first. * keep audit trails and recordings when a user is deleted (#1128) audit_logs and session_recordings both referenced users with ON DELETE CASCADE, so removing an account erased everything it had ever done. An audit trail that disappears with the account it recorded cannot answer the question it exists for, and a recording is evidence about a host as much as about a person. Both foreign keys become ON DELETE SET NULL. audit_logs already denormalises username, so an entry still names who acted once the reference is gone. session_recordings did not, so the column is added and backfilled first — otherwise relaxing the constraint would only trade deleted evidence for anonymous evidence. SQLite cannot alter a foreign key in place, so existing databases are migrated by copy-and-swap, guarded by a PRAGMA check that makes it idempotent. Fresh databases are created in the target shape and skip it. Recordings still cascade from their host. * audit the remaining remote access paths (#1129) Only SSH terminal sessions were audited. Opening a file manager session, an RDP, VNC or Telnet desktop, a Docker session or an SSH tunnel left no audit entry at all — which covers most of the ways data leaves a host or a foothold is established. Each of those four now writes an entry when the session is established, matching the existing ssh_connect: who, which host, from what address, and for tunnels the endpoint and local port being forwarded. Audit writes are fire-and-forget so they cannot delay or fail the connection, consistent with logAudit already swallowing its own errors. getAuditUsername was defined identically in two route files and is needed in four more, so it moves next to logAudit. * fix: honor lookupOptions.all in custom DNS lookup hook (#1084) Node's happy-eyeballs autoSelectFamily calls custom dns lookup functions with all:true and expects the full address array back. Always replying with a single (address, family) pair corrupted net's internal state, surfacing as "Invalid IP address: undefined" instead of a real connect error, breaking outbound notification delivery (webhook/ntfy). Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * fix: SSH-login alerts silently dropped (channel load + auth middleware ordering) (#1083) * fix: load notification channels on mount in AlertsPanel Channels only loaded when the Channels tab was visited, so opening Edit Alert Rule before ever switching to that tab showed the channel picker as empty even when channels existed. (cherry picked from commit caed913ee91990a853f5a048849c67ed3f7c329e) * fix: register login-alert route before auth middleware Global JWT auth middleware ran before this internal service-to-service route, rejecting it with 401 before its own IP+token check ever ran — silently dropped every SSH-login alert. Also surface non-OK responses instead of swallowing them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: add coverage for alert-notification fixes Channel-load-on-mount, login-alert non-OK handling, and a source-order guard for the route/auth-middleware regression. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * format AlertsPanel test with prettier --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * stop deleting audit trails, and say when they are dropped (#1132) Two ways audit evidence still disappeared silently. Deleting an account removed its audit entries and session recordings outright. #1128 relaxed those foreign keys to ON DELETE SET NULL, but deleteUserAndRelatedData deletes the rows explicitly, so the schema change had no effect on the path that actually matters. Both repositories gain anonymizeByUserId, which nulls the reference and leaves the row; username is already denormalised on both tables, so entries stay attributable to whoever acted. Separately, the log pruned itself at a hard-coded 10000 rows with no signal. Entries well inside any retention window were discarded and nothing recorded it. Retention is now configurable by age via AUDIT_LOG_RETENTION_DAYS, the row cap via AUDIT_LOG_MAX_ENTRIES, and the two are reported differently: expiring an old entry is routine and logged at info, while hitting the cap means the ceiling is too low for how much this install audits and is logged at warn, naming the range discarded and how to stop it. * let the audit log leave the box (#1133) Retention became configurable in #1132, which only helps if entries can be moved somewhere before they expire. Until now the only way out was two GET endpoints built for the UI. Adds GET /audit-logs/export, taking the same filters as the list endpoint and streaming the whole matching set as CSV or NDJSON in batches, so an export is not bounded by the 200-row page cap and does not buffer the result set. Reading the entire trail is itself recorded as export_audit_logs. CSV fields starting with =, +, - or @ are prefixed with a quote. Audit rows carry attacker-influenced values like resource names, and spreadsheet software treats those as formulas on open. Adds optional live forwarding to a collector via AUDIT_LOG_FORWARD_URL, with an optional bearer token. Delivery goes through safeOutboundFetch so a misconfigured URL cannot be turned into an internal network probe, and it is fire-and-forget: the local write stays the source of truth and a dead SIEM must never delay or fail the operation being audited. Repeated failures are reported five times and then suppressed until delivery recovers, so an outage does not bury the logs it is supposed to appear in. * encrypt SSO secrets instead of base64-encoding them (#1135) The OIDC client secret and LDAP bind password were stored behind an encoded: prefix that is base64, not encryption. Anyone reading the database read the secrets. A second path wrote the same thing behind an encrypted: prefix, which was also base64 — and the reader even documented that it could not decrypt it. These belong to the installation rather than to a user: sso_providers has no userId, and the values must be readable during login, before anyone has authenticated, so the per-user DEK used elsewhere does not apply. They are now sealed with AES-256-GCM under the system encryption key, which already protects other installation-level material. Reading handles both legacy prefixes so an existing install is not locked out of SSO login, and a legacy value is upgraded the next time the provider is saved. The three scattered encode/decode sites are replaced by one module. * remove the unwired field encryption boundary (#1136) FieldEncryptionBoundary declared a full sensitive/plaintext policy for six tables and was referenced only by its own test. Nothing in production used it. Its policy is byte-for-byte the same as FieldCrypto.ENCRYPTED_FIELDS, which is the copy that actually runs, so nothing is lost by deleting it. Keeping a second list is the real risk: someone adds a field to this one, sees it classified as sensitive, and ships something that was never encrypted. The one apparent improvement it had — requiring an explicit recordId instead of DataCrypto's temp-${Date.now()} fallback — turns out to guard against nothing. decryptField derives its context from the recordId stored inside the ciphertext, not from the argument, so a temporary id at encryption time still decrypts. * load the database file when encryption is off (#1137) * Groundwork for Postgres and MySQL backends (#1134) * groundwork for postgres and mysql backends #1127 made the repository layer dialect-agnostic. This adds the pieces needed to actually target a second engine, as a foundation only — nothing is wired up and sqlite remains the sole runtime path. - DatabaseDialect covers sqlite, postgres and mysql, resolved from DATABASE_DIALECT and defaulting to sqlite so nothing changes for existing deployments or the desktop build - a column kit holding the per-dialect type choices in one file: booleans are integers on sqlite and native elsewhere, autoincrement differs three ways, and MySQL cannot index unbounded TEXT so key columns need varchar - settings and users declared for all three dialects as a proof slice, chosen because between them they use every construct the real schema does - pg and mysql2 added as dependencies The tests build real queries for all three engines without a server, asserting identifier quoting, placeholder style and boolean storage, so the property the repositories depend on is verified rather than assumed. * verify foreign keys and unique constraints port across dialects The first slice only covered plain columns. The real schema also has 92 foreign keys (80 cascade, 12 set null) and 14 unique columns, so the approach is only viable if those survive the port. Adds audit_logs and ssh_folders to the proof slice: one nullable reference with ON DELETE SET NULL, one required reference with ON DELETE CASCADE, a unique column, and an autoincrement surrogate key — which is spelled three different ways underneath (integer primary key autoincrement, serial, int auto_increment). All of it holds. Worth noting for whoever picks this up: getTableConfig is dialect-specific and silently fails on a table from another dialect, so the test uses each engine's own. * generate the postgres and mysql schemas instead of hand-writing them The proof slice showed the constructs port, but left the maintenance question open. Three hand-written copies of 52 tables is the wrong answer: with foreign keys the copies cross-reference each other, so a renamed table has to land in three places consistently or a key silently points at the wrong one. The mapping is mechanical, so a script does it. schema.ts stays the single source of truth and schema.pg.ts / schema.mysql.ts are derived, covering all 52 tables — the column kit and the two-table portable slice are gone, since the generator now holds those decisions. The transforms are the ones the kit enumerated: integer-backed booleans become native, autoincrement keys become serial or int auto_increment, real becomes double precision or double, and any column that is a primary key, is unique, or sits on either end of a foreign key becomes varchar because MySQL cannot index unbounded TEXT. > termix@2.6.0 lint > node scripts/generate-dialect-schema.cjs --check && eslint . /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-favicon-routes.ts 99:12 warning 'err' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-ping-routes.ts 123:12 warning 'err' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/homepage-rss-routes.ts 144:12 warning 'err' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/routes/session-log-routes.ts 46:16 warning 'canAccessRecording' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/hosts/vault-signer-core.ts 55:12 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 75:13 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/tests/hosts/auth-manager.test.ts 18:73 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/tests/utils/shared-host-secrets-manager.test.ts 7:6 warning 'SecretRow' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/utils/auth-manager.ts 510:13 warning 'affectedUsers' is assigned a value but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/utils/notification-sender.ts 48:12 warning 'firstErr' is defined but never used unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/api/ssh-file-operations-api.ts 35:10 warning 'buildFileManagerUrl' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/components/folder-style.tsx 61:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 116:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 121:14 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 149:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/components/proxmox/ProxmoxDiscoverDialog.tsx 109:19 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any 190:19 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/HomepageCanvas.tsx 345:15 warning Empty block statement no-empty 388:15 warning Empty block statement no-empty 415:15 warning Empty block statement no-empty /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/dialogs/SingleHostEditForm.tsx 24:6 warning React Hook useEffect has a missing dependency: 'filter'. Either include it or remove the dependency array. If 'setHosts' needs the current value of 'filter', you can also switch to useReducer instead of useState and read 'filter' in the reducer react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/AlertFeedWidget.tsx 93:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/CustomApiWidget.tsx 77:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/DockerActivityWidget.tsx 50:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/DockerWidget.tsx 16:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/FileManagerWidget.tsx 16:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/HostGridWidget.tsx 61:6 warning React Hook useCallback has a missing dependency: 'hostIds'. Either include it or remove the dependency array react-hooks/exhaustive-deps 61:7 warning React Hook useCallback has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/MetricsChartWidget.tsx 168:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/PingStatusWidget.tsx 79:6 warning React Hook useEffect has a missing dependency: 'fetchAll'. Either include it or remove the dependency array react-hooks/exhaustive-deps 79:7 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/QuickConnectWidget.tsx 64:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/RecentActivityWidget.tsx 82:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps 82:17 warning React Hook useEffect has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SshQuickConnectWidget.tsx 67:6 warning React Hook useCallback has a missing dependency: 'hostIds'. Either include it or remove the dependency array react-hooks/exhaustive-deps 67:7 warning React Hook useCallback has a complex expression in the dependency array. Extract it to a separate variable so it can be statically checked react-hooks/exhaustive-deps 99:17 warning 'online' is assigned a value but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SshTerminalWidget.tsx 17:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/SystemOverviewWidget.tsx 72:6 warning React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array react-hooks/exhaustive-deps /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/homepage/widgets/TunnelWidget.tsx 15:10 warning Fast refresh only works when a file has exports. Move your component(s) to a separate file react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/features/host-metrics/cards/CpuCard.tsx 14:10 warning 'computeChartData' is defined but never used. Allowed unused vars must match /^_/u unused-imports/no-unused-vars /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/sidebar/FolderPathPicker.tsx 15:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components 22:17 warning Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components react-refresh/only-export-components /mnt/c/Users/29037/WebstormProjects/Termix/src/ui/sidebar/HostsPanel.tsx 601:52 warning Unexpected any. Specify a different type @typescript-eslint/no-explicit-any ✖ 44 problems (0 errors, 44 warnings) now fails if the generated files are out of date, so editing the schema without regenerating cannot reach main. * select durability behaviour per dialect, and document the backends The onWrite hook every repository receives exists to serialise the in-memory SQLite database back to its encrypted file. On a client-server engine a committed write is already durable and there is nothing to flush, so the factory now installs no hook at all rather than one that does nothing. Repositories call it as this.onWrite?.(), so none of the 43 of them change. Also adds docs/database-backends.md, mostly to be explicit about encryption, which is the part most likely to be misread. Field-level encryption is identical on all three engines and covers every credential. Whole-file encryption has no equivalent on Postgres or MySQL, so host names, snippet contents, audit entries and backups are only as protected as the storage underneath them — that is the operator's responsibility and the docs should not imply otherwise. * generate DDL with drizzle-kit, and give settings a synchronous path Two of the three remaining blockers. DDL: db/index.ts hand-writes 67 CREATE TABLE statements and 122 ADD COLUMN migrations, all in SQLite dialect. Rather than port them, drizzle-kit now generates migrations from the schema modules — 817 lines for Postgres, 869 for MySQL, with the type mapping already correct because the schemas it reads are themselves generated. > termix@2.6.0 schema:migrations > drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts Reading config file '/mnt/c/Users/29037/WebstormProjects/Termix/drizzle.config.pg.ts' 52 tables alert_firings 11 columns 0 indexes 2 fks alert_rule_channels 3 columns 0 indexes 2 fks alert_rules 11 columns 0 indexes 2 fks api_keys 9 columns 0 indexes 1 fks audit_logs 13 columns 0 indexes 1 fks c2s_tunnel_presets 8 columns 0 indexes 1 fks command_history 5 columns 0 indexes 2 fks dashboard_service_links 8 columns 0 indexes 1 fks dismissed_alerts 4 columns 0 indexes 1 fks file_manager_pinned 6 columns 0 indexes 2 fks file_manager_recent 6 columns 0 indexes 2 fks file_manager_shortcuts 6 columns 0 indexes 2 fks homepage_items 9 columns 0 indexes 1 fks homepage_layouts 4 columns 0 indexes 1 fks host_access 11 columns 0 indexes 5 fks host_health_checks 7 columns 0 indexes 2 fks host_health_history 8 columns 0 indexes 2 fks host_metrics_history 8 columns 0 indexes 1 fks host_metrics_preferences 6 columns 0 indexes 2 fks ssh_data 94 columns 0 indexes 6 fks network_topology 5 columns 0 indexes 1 fks notification_channels 7 columns 0 indexes 1 fks opkssh_tokens 12 columns 0 indexes 2 fks recent_activity 6 columns 0 indexes 2 fks roles 8 columns 0 indexes 0 fks session_recordings 15 columns 0 indexes 3 fks session_share_participants 6 columns 0 indexes 2 fks session_shares 15 columns 0 indexes 3 fks sessions 11 columns 0 indexes 1 fks settings 2 columns 0 indexes 0 fks shared_host_secrets 15 columns 0 indexes 3 fks snippet_access 8 columns 0 indexes 4 fks snippet_folders 8 columns 0 indexes 1 fks snippets 11 columns 0 indexes 1 fks ssh_credential_usage 5 columns 0 indexes 3 fks ssh_credentials 21 columns 0 indexes 1 fks ssh_folders 9 columns 0 indexes 2 fks sso_providers 8 columns 0 indexes 0 fks sync_tombstones 5 columns 0 indexes 1 fks termix_identities 6 columns 0 indexes 1 fks termix_identity_ca 8 columns 0 indexes 2 fks termix_identity_keys 12 columns 0 indexes 3 fks tmux_session_tags 6 columns 0 indexes 2 fks transfer_recent 7 columns 0 indexes 3 fks trusted_devices 8 columns 0 indexes 1 fks user_open_tabs 9 columns 0 indexes 2 fks user_preferences 23 columns 0 indexes 1 fks user_roles 5 columns 0 indexes 3 fks users 20 columns 0 indexes 0 fks vault_profiles 18 columns 0 indexes 1 fks vault_tokens 8 columns 0 indexes 2 fks webauthn_credentials 12 columns 0 indexes 1 fks No schema changes, nothing to migrate 😴 Reading config file '/mnt/c/Users/29037/WebstormProjects/Termix/drizzle.config.mysql.ts' Reading schema files: /mnt/c/Users/29037/WebstormProjects/Termix/src/backend/database/db/schema.mysql.ts 52 tables alert_firings 11 columns 0 indexes 2 fks alert_rule_channels 3 columns 0 indexes 2 fks alert_rules 11 columns 0 indexes 2 fks api_keys 9 columns 0 indexes 1 fks audit_logs 13 columns 0 indexes 1 fks c2s_tunnel_presets 8 columns 0 indexes 1 fks command_history 5 columns 0 indexes 2 fks dashboard_service_links 8 columns 0 indexes 1 fks dismissed_alerts 4 columns 0 indexes 1 fks file_manager_pinned 6 columns 0 indexes 2 fks file_manager_recent 6 columns 0 indexes 2 fks file_manager_shortcuts 6 columns 0 indexes 2 fks homepage_items 9 columns 0 indexes 1 fks homepage_layouts 4 columns 0 indexes 1 fks host_access 11 columns 0 indexes 5 fks host_health_checks 7 columns 0 indexes 2 fks host_health_history 8 columns 0 indexes 2 fks host_metrics_history 8 columns 0 indexes 1 fks host_metrics_preferences 6 columns 0 indexes 2 fks ssh_data 94 columns 0 indexes 6 fks network_topology 5 columns 0 indexes 1 fks notification_channels 7 columns 0 indexes 1 fks opkssh_tokens 12 columns 0 indexes 2 fks recent_activity 6 columns 0 indexes 2 fks roles 8 columns 0 indexes 0 fks session_recordings 15 columns 0 indexes 3 fks session_share_participants 6 columns 0 indexes 2 fks session_shares 15 columns 0 indexes 3 fks sessions 11 columns 0 indexes 1 fks settings 2 columns 0 indexes 0 fks shared_host_secrets 15 columns 0 indexes 3 fks snippet_access 8 columns 0 indexes 4 fks snippet_folders 8 columns 0 indexes 1 fks snippets 11 columns 0 indexes 1 fks ssh_credential_usage 5 columns 0 indexes 3 fks ssh_credentials 21 columns 0 indexes 1 fks ssh_folders 9 columns 0 indexes 2 fks sso_providers 8 columns 0 indexes 0 fks sync_tombstones 5 columns 0 indexes 1 fks termix_identities 6 columns 0 indexes 1 fks termix_identity_ca 8 columns 0 indexes 2 fks termix_identity_keys 12 columns 0 indexes 3 fks tmux_session_tags 6 columns 0 indexes 2 fks transfer_recent 7 columns 0 indexes 3 fks trusted_devices 8 columns 0 indexes 1 fks user_open_tabs 9 columns 0 indexes 2 fks user_preferences 23 columns 0 indexes 1 fks user_roles 5 columns 0 indexes 3 fks users 20 columns 0 indexes 0 fks vault_profiles 18 columns 0 indexes 1 fks vault_tokens 8 columns 0 indexes 2 fks webauthn_credentials 12 columns 0 indexes 1 fks No schema changes, nothing to migrate 😴 regenerates both. Settings: 27 call sites read settings synchronously, during startup and inside request handlers. better-sqlite3 can do that; Postgres and MySQL cannot, and making all 27 async would push await through code that has no reason to be asynchronous. Settings are a handful of rarely-changing rows read constantly, so they are cached in full — primed at startup, kept in step by SettingsRepository on every set/delete/deleteLike. SQLite keeps reading the database directly and stays authoritative; only the other engines use the cache. Opening a connection is still not done. DatabaseContext.drizzle is typed as BetterSQLite3Database and 43 repositories depend on that inference; the three drizzle instance types are not interchangeable, so widening it is a design decision rather than a mechanical change. * exclude drizzle-kit output from prettier The generated migrations and snapshots are tool output; their formatting is drizzle-kit's to decide, and prettier cannot parse the .sql files at all. * absorb the RETURNING gap so mysql stays reachable MySQL has no RETURNING clause and drizzle's mysql-core does not expose the method, while 156 call sites here read the result of a write. That is the real blocker for MySQL, not the connection layer. Classifying those call sites showed the split is favourable: 92 of them only read .length, which every engine reports — as a returned array on sqlite and postgres, as affectedRows on MySQL. rowsAffected() reads both shapes, so those sites need no change in query shape. insertedId() does the same for the autoincrement key, which MySQL reports as insertId. What is left is the ~34 sites that genuinely consume the returned rows. Those cannot be emulated without reading first, which needs a transaction to stay correct under concurrency, so they will be handled individually rather than behind a helper that quietly adds a round trip. supportsReturning() is the seam for that. Identifying the mysql2 result by its own fields rather than by array shape matters: it hands back [ResultSetHeader, fields], which is an array, so shape alone cannot tell it apart from a returning() result. * name the portable database type, and open remote connections Two pieces of the connection layer. drizzle's three Database classes share no base class and their signatures are incompatible, so there is no honest type that covers all three: a union is not callable and a generic would have to be threaded through 43 repositories and every method on them. DatabaseContext.drizzle is now PortableDatabase, still the SQLite type underneath, but named and documented as the deliberate approximation it is. What makes it safe is that the equivalence is asserted in multi-dialect.test.ts rather than assumed, and the one place the surfaces truly differ — RETURNING — is handled explicitly in mutation-result.ts. connect.ts opens Postgres and MySQL from DATABASE_URL, with the schema module and driver imported lazily so neither is loaded on a SQLite deployment. The URL scheme is checked against the configured dialect first: a postgres:// URL with DATABASE_DIALECT=mysql otherwise surfaces as a driver error deep in a stack that never mentions the actual misconfiguration. * open postgres and mysql at startup * count writes without RETURNING * read affected rows without RETURNING on mysql * insert without RETURNING, and split the sync transactions * stop pretending the generated schemas are used at runtime * run the dialect checks in CI * mysql rejects a bare CURRENT_TIMESTAMP default on text * make the read-back mismatch loud, and stop the next bare returning() * run the repository tests on the real schema * skip the byte-level assertions off sqlite * move generated ids past the seeded ones * keep the export order the same on every engine * stop reading better-sqlite3 fields off every write * read counts as numbers, not whatever the driver returns * make the fixture usable against a live server * upsert on the engine that has no ON CONFLICT * run the repository suite on all three engines in CI * mysql cannot index a text column without a length * document how to actually run on postgres or mysql * keep the sqlite-era migrations off the other engines * concat strings in a way mysql agrees with * run every repository test on every engine * bound how long replicas can disagree about settings * generate the sqlite migrations alongside the others * Bump version from 2.6.0 to 2.6.1 * resolve the dialect in the repository factory instead of assuming sqlite (#1143) createCurrentRepositoryContext() hardcoded `dialect: "sqlite"` while the runtime already carried all three engines. That field is not decoration: returning.ts reads it to decide whether it can ask for RETURNING, and whether an upsert spells itself onConflictDoUpdate or onDuplicateKeyUpdate. Reporting sqlite while connected to MySQL means the first upsert calls onConflictDoUpdate on a mysql2 insert builder, which does not have it -- a TypeError, not a rejected query, as the note in returning.ts warned. So MySQL never worked outside the tests, and Postgres worked only because it also supports RETURNING and shares the conflict syntax. Three things were supposed to catch this and none could. The repository suite builds its own DatabaseContext in test-support.ts, verify-dialects.mjs builds its own, and the CI matrix runs both against real Postgres and MySQL containers -- all of them bypassing the one function the application calls. Green on three engines, broken on two. Resolve it from the environment, and test the factory itself rather than a hand-built context: the default, each configured dialect, the write hook it installs only for sqlite, and that an unsupported value throws rather than falling back. Reverting the fix fails two of them. Fixes Termix-SSH/Support#282 * fix remote sync stalling after the first pass and never propagating deletions (#1140) The incremental cursor never matched. updated_at/deleted_at are TEXT columns written by CURRENT_TIMESTAMP ("2026-07-29 10:11:21"), while the client sends an ISO 8601 since ("2026-07-29T10:06:55.172Z"). Both comparisons are lexical and ' ' sorts below 'T', so a newer row lost at position 10 and every ?since= query came back empty. Pass 1 syncs everything (since is null) and persists a cursor; every pass after it returns nothing with lastError: null and reports success. Normalize since into the stored shape on the way in, leaving an already-normalized value alone -- parsing that would treat it as local time and, west of UTC, push the cursor past unsynced rows. POST /sync/tombstones was unreachable. It was registered after POST /:entityType, and "tombstones" is a valid :entityType, so the wildcard answered it with 400 "Unknown entity type" and the handler never ran. The pass has no per-entity error handling, so that 400 also discarded the state of every entity type already synced in the same pass. Move it ahead of the wildcards. The tombstone guard consulted the incremental window. A row deleted on one side and untouched on the other -- the shape every ordinary deletion takes once the two sides converge -- is not in that window, so the tombstone was skipped, and skipped again on each later pass as it slid out of its own window. The guard cannot just be dropped: recording a tombstone for a row that was already gone hands the sender a fresh one to push back, and the two trade the same deletion forever. So only a delete that removed something records a tombstone, which makes the endpoint idempotent and lets the client push every tombstone unconditionally. Deletions missed while the cursor was broken stay missed -- their tombstones predate the persisted cursor. Ordinary edits do come through, since the row's updatedAt is still newer than it. Fixes Termix-SSH/Support#1050 Fixes Termix-SSH/Support#1051 * report why every JWKS fetch failed instead of swallowing the reason (#1142) An OIDC login that cannot reach the provider's keys ends in "Failed to fetch JWKS from any URL" and nothing else. Getting there discards everything worth knowing: a non-2xx response hit an empty else branch, a thrown request hit a bare `continue`, and discovery only logged when it threw -- a 404 or a document without jwks_uri passed in silence. An administrator cannot tell an issuer URL typo from a proxy, a private CA, or an outage at the provider, and neither can anyone reading the report. Collect each attempt with its reason and put them in the thrown error. It reaches the log through the existing "OIDC callback failed" handler; the browser still gets the same generic message it did before. Unwrapping the cause is the part that matters: undici reports every transport failure as "fetch failed" and hangs the real reason -- ENOTFOUND, ECONNREFUSED, a certificate that will not verify -- off error.cause. An attempt list built from the outer messages would be as useless as the single line it replaces. Also require jwks_uri to be a string before using it, so a malformed discovery document is reported as such rather than as a failed fetch of "[object Object]". Refs Termix-SSH/Support#1047 * restore the closing quote on the version string (#1147) "Bump version from 2.6.0 to 2.6.1" (2a66775) wrote "version": "2.6.1, dropping the closing quote, so package.json has not been valid JSON since. Anything that parses it fails: npm install, npm run build, and every CI run on this branch -- vitest cannot even load its config, because vite reads package.json before it gets to the test files. 2.6.1 cannot be built or released until this is fixed, which is why it goes in on its own rather than riding along with anything else. * Revert "fix remote sync stalling after the first pass and never propagating deletions (#1140)" (#1146) This reverts commitca7abf8426. Reverted for process, not for content. Both defects were reported by @kacperpietrzyk in Support#1050 and Support#1051, and he opened #1138 and #1139 fixing them 4.5 hours before #1140 was filed. Merging #1140 made two PRs from the person who found and diagnosed the bugs redundant. #1138 and #1139 stand on their own: the same root-cause analysis, complete regression tests, and a tombstone guard that only pays for its extra lookup on a pass that actually carries a deletion. There is no technical reason to prefer the reverted commit over them. The sync fixes land through those two PRs instead. * fix: make sync deletions reach the other side (#1139) * fix: apply sync tombstones to rows outside the incremental window Deletions never reached the other side. `syncEntity` decides whether to apply a tombstone by looking the row up in `localBySyncId` / `remoteBySyncId`, which are built from `pullSide(..., since)` -- the incremental window. A row deleted on one side and untouched on the other is by definition absent from that window, which is the shape every ordinary deletion takes once the two sides have converged, so the tombstone was silently skipped and never retried. The guard cannot simply be dropped. `POST /sync/tombstones` records a tombstone on the receiving side, so an unconditional push would give the other side a fresh tombstone to push back on the following pass, and the two would trade deletions forever. Instead ask the receiving side what it still holds, ignoring the window, and only when there is a deletion to apply -- so an ordinary pass costs nothing extra, and a pass carrying a deletion costs one additional list per affected entity type. Once the row is gone the push stops, so nothing ping-pongs. Note this only becomes observable together with the cursor fix in Termix-SSH/Support#1050: while that defect is present the tombstone endpoint returns nothing at all, so there is no tombstone to apply in the first place. Refs Termix-SSH/Support#1034 * fix: make the sync tombstone endpoint reachable `POST /sync/tombstones` was registered after `POST /:entityType`, and Express matches in registration order, so every deletion push was swallowed by the wildcard: "tombstones" is a perfectly good value for :entityType, fails isValidEntityType, and comes back as 400 "Unknown entity type". The handler below it has never run. Registering the literal path before the parameterised one restores it. The regression test reads the router stack rather than the source, so a future re-order fails the test rather than silently disabling deletions again. The GET pair is unaffected -- "/:entityType/tombstones" and "/:entityType" have different segment counts, so they cannot shadow each other. * feat: add host export dialog with host and field selection (#1108) * fix: compare sync cursors independently of timestamp layout (#1138) * fix: compare sync cursors independently of timestamp layout Incremental sync returned nothing after its first pass. `GET /sync/:entityType` filters with `gt(table.updatedAt, since)` on a TEXT column, and the tombstone endpoint does the same through `listSince`, but the two sides of that comparison are written in different layouts: the columns default to `CURRENT_TIMESTAMP` ("2026-07-29 10:11:21") while the desktop engine sends `new Date().toISOString()` ("2026-07-29T10:06:55.172Z"). Text comparison is decided at position 10, where ' ' (0x20) sorts below 'T' (0x54), so the predicate answers on layout rather than on time and is false for every CURRENT_TIMESTAMP row however new it is. The engine only sends a cursor from the second pass onward, so pass 1 synced everything and passes 2..n pulled zero rows and zero tombstones while reporting success -- edits and deletions silently stopped propagating in both directions. This was masked until now: before the reference fix in #1092 the loop threw before persisting state, so the cursor never advanced past null and every cycle was a full sync. Comparing "YYYY-MM-DD HH:MM:SS" on both sides is layout-independent. `replace` and `substr` are used rather than `datetime()` to keep the expression portable across engines, since the repository layer is deliberately drizzle-only. The comparison is `>=` because normalising truncates sub-second precision, and a strict `>` would permanently skip rows written in the cursor's own second; the re-sent boundary rows are a no-op, as the engine pushes only when one side is strictly newer. `updatedAt` is written in both layouts across the codebase (14 sites use toISOString, 11 use CURRENT_TIMESTAMP), so the tests cover rows of each kind. Closes Termix-SSH/Support#1050 * test: seed the cursor tests against the migrated schema #1134 moved schema creation into the repository test harness, so the hand-written CREATE TABLE blocks here collided with tables that already existed. Seeding into the real tables instead surfaced two constraints the local definitions had papered over: the harness enables foreign keys and both `sync_tombstones.user_id` and `ssh_credentials.user_id` reference `users`, so the owning row has to be seeded first; and `auth_type` is NOT NULL with no default, unlike the local copy. `exec` is awaited, since it only returns synchronously on SQLite. The assertions are unchanged. * Make Proxmox guest discovery and import reliable over a jump host (#1144) * fix: repair unterminated version string in package.json The version field on dev-2.6.1 reads "2.6.1, (no closing quote), which makes package.json invalid JSON and breaks every npm invocation on the branch. Close the string so the branch builds. * fix(proxmox): reliable guest discovery and import over jump hosts Importing Proxmox guests from a node reachable only through a jump host (with the guests behind the same jump) failed in a chain of small ways. - Discovery timed out intermittently: execCommand capped every pvesh call at 8s, but a single call over a jump measured ~8.3s. Raised to 25s for core calls and 12s for best-effort agent/interface lookups. - No IPs were resolved (so nothing imported): resolveIp fanned out 6 concurrent pvesh calls; on a small node they contend (3 concurrent already exceeded the timeout), so every IP came back empty. Lowered CONCURRENCY to 2. - RDP guests aborted the whole sync via NOT NULL on ssh_data.username; use "" instead of null (matches the normal create path). - Guests without a resolvable IP (e.g. QEMU with no guest agent) were skipped entirely; they now import with a 0.0.0.0 placeholder, and re-sync preserves any manually entered IP (guest.ip || existing.ip). - Manual import did not inherit the source host's jump chain or credential (guests ended up unreachable with authType "none"). The discovery result now carries the source jumpHosts, and resolveProxmoxImportAuth uses an available credential even under the default "password" authType (explicit secretless choices still win). - Long discoveries had no feedback and fought client/proxy timeouts; added an SSE endpoint GET /proxmox/discover/stream (heartbeat + n/N progress), keeping POST /discover as a fallback. Also always render the IP cell in the discovery table so IP-less rows stay aligned. Adds a unit test for resolveProxmoxImportAuth covering the credential inheritance behaviour. * test(proxmox): lock resolveProxmoxImportAuth matrix on both copies; fix agent secretless drift - extract the backend decision into src/backend/database/routes/proxmox-import-auth.ts (leaf module mirroring the UI copy) so it is unit-testable without pulling the whole backend module graph into the test env - add src/backend/tests/database/routes/proxmox-import-auth.test.ts asserting the shared matrix (lifted from #1141, thanks @ZacharyZcR) - consolidate the UI test into src/ui/tests/components/proxmox/proxmox-import-auth.test.ts and drop the duplicate src/ui/tests/proxmox/ copy - add 'agent' to the UI SECRETLESS_AUTH_TYPES: the one real auth type where the two copies still diverged (UI -> credential, backend -> passthrough) * fix(hosts): parse portKnockSequence JSON in host-resolver (#1149) host-resolver JSON-parses jumpHosts/tunnelConnections/statsConfig/ terminalConfig/socks5ProxyChain/quickActions but NOT portKnockSequence. Empty knock is stored as the string "[]" (UI save of empty array); the terminal code then checks portKnockSequence.length > 0 on the STRING, so "[]".length === 2 is truthy -> logs 'Loaded 2 port knock(s)' and attempts a bogus knock. Real knock sequences (JSON string) are likewise never parsed to the Array<{port,...}> that performPortKnocking expects, so a genuine knock would never fire. Parse portKnockSequence like the other JSON columns: '[]' -> [] (length 0, no knock), real seq -> array. Adds unit tests for both cases. Co-authored-by: XtraLarge <xtralarge@users.noreply.github.com> * Feature request map OIDC provider groups to RBAC roles (#1148) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * Feature request map OIDC provider groups to RBAC roles Group membership from an OIDC provider currently drives only a single boolean: OIDC_ADMIN_GROUP toggles isAdmin and switches the user between the built-in `admin` and `user` roles. There is no way to map a provider group onto a custom role, so deployments that use host_access grants for environment-scoped access (e.g. a role that can reach staging hosts and another that can reach production) have to assign those roles by hand for every user. Add OIDC_ROLE_MAP, a comma- or newline-separated list of `group:role` pairs, reconciled against the user's roles on each OIDC login: OIDC_ROLE_MAP=devops-interns:devops-intern,devops-seniors:devops-senior Only roles named in the map are ever added or removed. Roles assigned by hand, and the admin/user pair maintained by the existing admin-group sync, are deliberately left untouched so the two mechanisms don't fight each other. Group names are matched case-insensitively with leading slashes stripped, so providers that emit full group paths (Keycloak's "Full group path" option) work without extra configuration. Reuses the existing extractOidcGroups claim handling, so custom claim paths via OIDC_GROUP_CLAIM are supported too, and invalidates the permission cache when roles change so new grants apply to the session that triggered the sync. Malformed map entries are skipped and a failed sync is logged but non-fatal — neither can block a valid login. Adds unit tests for the parser and resolver covering full group paths, multi-group membership, colons in group names and malformed input. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: support for overriding shared host ssh credentials (#1145) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * feat: support for overriding ssh credentials --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * ci(deps): bump the github-actions group with 2 updates (#1150) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * Update README to remove Tailscale and add Ginernet Removed Tailscale logo and link from the README. Added Ginernet logo and link. * Update README.md * ci(deps): bump the github-actions group with 2 updates Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [useblacksmith/setup-docker-builder](https://github.com/useblacksmith/setup-docker-builder). Updates `actions/setup-node` from 6 to 7 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v6...v7) Updates `useblacksmith/setup-docker-builder` from 1 to 2 - [Release notes](https://github.com/useblacksmith/setup-docker-builder/releases) - [Commits](https://github.com/useblacksmith/setup-docker-builder/compare/v1...v2) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: useblacksmith/setup-docker-builder dependency-version: '2' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group with 3 updates (#1154) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * Update README to remove Tailscale and add Ginernet Removed Tailscale logo and link from the README. Added Ginernet logo and link. * Update README.md * chore(deps): bump the prod-minor-updates group with 3 updates Bumps the prod-minor-updates group with 3 updates: [axios](https://github.com/axios/axios), [motion](https://github.com/motiondivision/motion) and [undici](https://github.com/nodejs/undici). Updates `axios` from 1.18.1 to 1.19.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.18.1...v1.19.0) Updates `motion` from 12.42.2 to 12.43.0 - [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md) - [Commits](https://github.com/motiondivision/motion/compare/v12.42.2...v12.43.0) Updates `undici` from 8.7.0 to 8.9.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.7.0...v8.9.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.19.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: motion dependency-version: 12.43.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.9.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * fix: data guard test failure * chore(deps): bump 23 dependencies and fix dialect-unsafe queries Applies the non-major updates from the open dependabot PRs directly, since dependabot rebases against main and could not resolve its lockfiles against this branch. Holds back typescript 7 and jsdom 30; those majors need their own pass. Reformats with prettier 3.9.6, which collapses short union types onto one line. Formatting only: the compiled backend output is byte for byte identical. Also fixes two lint errors in the shared host auth override repository, where onConflictDoUpdate and .returning() are SQLite-only and broke the Postgres and MySQL builds, and drops unused imports left over from the shared host auth override merge. * chore: reversal of legal work * feat: improve pin side rail button position and added env var for telemetrics * Add Ctrl+F terminal search (#1156) * Bump version from 2.6.0 to 2.6.1 in package-lock.json * Fix formatting issue in package-lock.json * Update README to remove Tailscale and add Ginernet Removed Tailscale logo and link from the README. Added Ginernet logo and link. * Update README.md * Add Ctrl+F terminal search --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * fix: host export dialog using incorrect widths * fix: made logger display expanded errors * feat: added support for multi disk usage in file manager and host metrics * chore: harden nginx headers and improve static asset caching * chore: format * chore: update release notes * fix: default font size to md instead of lg * feat: support Tailscale SSH check mode * chore: sync Crowdin translations for 2.6.1 --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Brennan Neoh <497569+brennanneoh@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: kacperpietrzyk <105545577+kacperpietrzyk@users.noreply.github.com> Co-authored-by: Max <50905012+maxiwolleb@users.noreply.github.com> Co-authored-by: XtraLarge <eMail@WilliWerres.de> Co-authored-by: XtraLarge <xtralarge@users.noreply.github.com> Co-authored-by: Devin Dissanayaka <dsdissanayaka2002@gmail.com> Co-authored-by: Peter Cinibulk <petercinibulk@gmail.com> Co-authored-by: Med Ali Ezzeddine <47082236+xDaly@users.noreply.github.com>
3124 lines
85 KiB
JavaScript
3124 lines
85 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 fs = require("fs");
|
|
const os = require("os");
|
|
const https = require("https");
|
|
const http = require("http");
|
|
const net = require("net");
|
|
const { URL } = require("url");
|
|
const { fork, spawn } = require("child_process");
|
|
const WebSocket = require("ws");
|
|
const remoteSync = require("./remote-sync.cjs");
|
|
|
|
// 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 getServerConfigPath() {
|
|
return path.join(app.getPath("userData"), "server-config.json");
|
|
}
|
|
|
|
function getServerConfigSync() {
|
|
try {
|
|
const configPath = getServerConfigPath();
|
|
if (!fs.existsSync(configPath)) return null;
|
|
return JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// The only remaining "connected remote server" a self-signed/invalid
|
|
// certificate could legitimately apply to is the Remote Sync server
|
|
// (also used for C2S tunnel relaying, see getC2SRelayUrl).
|
|
const config = remoteSync.getRemoteSyncConfig();
|
|
if (!config?.allowInvalidCertificate || !config?.serverUrl) return false;
|
|
|
|
return getOrigin(url) === getOrigin(config.serverUrl);
|
|
}
|
|
|
|
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 ? getTlsVerificationOptions(url) : {}),
|
|
};
|
|
|
|
const req = client.request(url, requestOptions, (res) => {
|
|
let data = "";
|
|
res.on("data", (chunk) => (data += chunk));
|
|
res.on("end", () => {
|
|
resolve({
|
|
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
status: res.statusCode,
|
|
text: () => Promise.resolve(data),
|
|
json: () => Promise.resolve(JSON.parse(data)),
|
|
});
|
|
});
|
|
});
|
|
|
|
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");
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
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;
|
|
let backendProcess = null;
|
|
let backendStartFailed = false;
|
|
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);
|
|
});
|
|
}
|
|
|
|
app.on(
|
|
"certificate-error",
|
|
(event, _webContents, url, error, certificate, callback) => {
|
|
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);
|
|
},
|
|
);
|
|
|
|
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 every port the backend binds (30001, 30003-30008, 30010,
|
|
// 30012...). 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();
|
|
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);
|
|
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);
|
|
}
|
|
});
|
|
|
|
backendProcess.stderr.on("data", (data) => {
|
|
logToFile("[backend:stderr]", data.toString().trim());
|
|
});
|
|
|
|
backendProcess.on("exit", (code, signal) => {
|
|
logToFile(`Backend process exited with code ${code}, signal ${signal}`);
|
|
if (!resolved && code !== 0) {
|
|
backendStartFailed = true;
|
|
}
|
|
backendProcess = null;
|
|
clearBackendPidFile();
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearTimeout(readyTimeout);
|
|
resolve(false);
|
|
}
|
|
});
|
|
|
|
backendProcess.on("error", (err) => {
|
|
logToFile("Failed to start backend process:", err.message);
|
|
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...");
|
|
|
|
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();
|
|
});
|
|
}
|
|
|
|
const gotTheLock = 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 = path.join(publicRoot, "icons", "32x32.png");
|
|
}
|
|
|
|
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;
|
|
app.quit();
|
|
},
|
|
},
|
|
]);
|
|
|
|
tray.setContextMenu(contextMenu);
|
|
|
|
tray.on("click", () => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isVisible()) {
|
|
mainWindow.hide();
|
|
} else {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log("System tray created successfully");
|
|
} catch (err) {
|
|
console.error("Failed to create system tray:", err);
|
|
}
|
|
}
|
|
|
|
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,
|
|
webSecurity: false,
|
|
preload: path.join(__dirname, "preload.js"),
|
|
partition: termixSessionPartition,
|
|
allowRunningInsecureContent: true,
|
|
webviewTag: true,
|
|
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.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: 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.on("close", (event) => {
|
|
if (!isQuitting && tray && !tray.isDestroyed()) {
|
|
event.preventDefault();
|
|
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" };
|
|
});
|
|
}
|
|
|
|
ipcMain.handle("get-app-version", () => {
|
|
return app.getVersion();
|
|
});
|
|
|
|
const GITHUB_API_BASE = "https://api.github.com";
|
|
const REPO_OWNER = "Termix-SSH";
|
|
const REPO_NAME = "Termix";
|
|
|
|
const githubCache = new Map();
|
|
const CACHE_DURATION = 30 * 60 * 1000;
|
|
|
|
async function fetchGitHubAPI(endpoint, cacheKey) {
|
|
const cached = githubCache.get(cacheKey);
|
|
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
|
|
return {
|
|
data: cached.data,
|
|
cached: true,
|
|
cache_age: Date.now() - cached.timestamp,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const response = await httpFetch(`${GITHUB_API_BASE}${endpoint}`, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
"User-Agent": "TermixElectronUpdateChecker/1.0",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
timeout: 10000,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`GitHub API error: ${response.status} ${response.statusText}`,
|
|
);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
githubCache.set(cacheKey, {
|
|
data,
|
|
timestamp: Date.now(),
|
|
});
|
|
|
|
return {
|
|
data: data,
|
|
cached: false,
|
|
};
|
|
} catch (error) {
|
|
console.error("Failed to fetch from GitHub API:", error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
ipcMain.handle("check-electron-update", async () => {
|
|
try {
|
|
const localVersion = app.getVersion();
|
|
|
|
const releaseData = await fetchGitHubAPI(
|
|
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
|
|
"latest_release_electron",
|
|
);
|
|
|
|
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
|
|
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
|
|
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
|
|
|
|
if (!remoteVersion) {
|
|
return {
|
|
success: false,
|
|
error: "Remote version not found",
|
|
localVersion,
|
|
};
|
|
}
|
|
|
|
const versionComparison = compareSemver(localVersion, remoteVersion);
|
|
const status =
|
|
versionComparison === null || versionComparison === 0
|
|
? "up_to_date"
|
|
: versionComparison > 0
|
|
? "beta"
|
|
: "requires_update";
|
|
|
|
const result = {
|
|
success: true,
|
|
status,
|
|
localVersion: localVersion,
|
|
remoteVersion: remoteVersion,
|
|
latest_release: {
|
|
tag_name: releaseData.data.tag_name,
|
|
name: releaseData.data.name,
|
|
published_at: releaseData.data.published_at,
|
|
html_url: releaseData.data.html_url,
|
|
body: releaseData.data.body,
|
|
},
|
|
cached: releaseData.cached,
|
|
cache_age: releaseData.cache_age,
|
|
};
|
|
|
|
return result;
|
|
} catch (error) {
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
localVersion: app.getVersion(),
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("get-platform", () => {
|
|
return process.platform;
|
|
});
|
|
|
|
ipcMain.handle("get-embedded-server-status", () => {
|
|
return {
|
|
running:
|
|
backendProcess !== null && !backendProcess.killed && !backendStartFailed,
|
|
dataDir: isDev ? null : getBackendDataDir(),
|
|
};
|
|
});
|
|
|
|
// OIDC System Browser Authentication (RFC 8252)
|
|
ipcMain.handle(
|
|
"oidc-system-browser-auth",
|
|
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");
|
|
|
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
res.end(
|
|
`<html><body><h2>${success === "true" ? "Authentication successful!" : "Authentication failed."}</h2><p>You can close this tab and return to Termix.</p><script>window.close()</script></body></html>`,
|
|
);
|
|
|
|
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("OIDC authentication timed out"));
|
|
},
|
|
5 * 60 * 1000,
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
ipcMain.handle("get-server-config", () => {
|
|
try {
|
|
return getServerConfigSync();
|
|
} catch (error) {
|
|
console.error("Error reading server config:", error);
|
|
return null;
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("save-server-config", (event, config) => {
|
|
try {
|
|
const userDataPath = app.getPath("userData");
|
|
const configPath = getServerConfigPath();
|
|
|
|
if (!fs.existsSync(userDataPath)) {
|
|
fs.mkdirSync(userDataPath, { recursive: true });
|
|
}
|
|
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error("Error saving server config:", error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
// --- Remote sync (optional desktop <-> self-hosted server sync) ---
|
|
|
|
// Surfaces the pre-standalone-rework server-config.json (if a serverUrl was
|
|
// ever set in it) so the renderer can prompt upgraded installs to set up
|
|
// Remote Sync -- their hosts live on that old server and won't appear
|
|
// locally until sync is enabled. A fresh install never had this file, so
|
|
// this is naturally false for anyone who never used the old architecture.
|
|
ipcMain.handle("get-legacy-server-config", () => {
|
|
const config = getServerConfigSync();
|
|
return { serverUrl: config?.serverUrl || null };
|
|
});
|
|
|
|
ipcMain.handle("get-desktop-settings", () => {
|
|
return remoteSync.getDesktopSettings();
|
|
});
|
|
|
|
ipcMain.handle("save-desktop-settings", (_event, settings) => {
|
|
return remoteSync.saveDesktopSettings(settings);
|
|
});
|
|
|
|
ipcMain.handle("get-remote-sync-config", () => {
|
|
return remoteSync.getRemoteSyncConfig();
|
|
});
|
|
|
|
ipcMain.handle("save-remote-sync-config", (_event, config) => {
|
|
return remoteSync.saveRemoteSyncConfig(config);
|
|
});
|
|
|
|
ipcMain.handle("clear-remote-sync-config", async () => {
|
|
const result = remoteSync.clearRemoteSyncConfig();
|
|
remoteSync.clearRemoteSyncJwt();
|
|
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
|
connected: false,
|
|
syncing: false,
|
|
needsReauth: false,
|
|
lastError: null,
|
|
});
|
|
return result;
|
|
});
|
|
|
|
ipcMain.handle("save-remote-sync-jwt", (_event, token) => {
|
|
const result = remoteSync.saveRemoteSyncJwt(token);
|
|
if (result.success) {
|
|
remoteSync.getRemoteSyncEngine()?.updateStatus({
|
|
connected: true,
|
|
needsReauth: false,
|
|
lastError: null,
|
|
});
|
|
remoteSync.getRemoteSyncEngine()?.syncNow();
|
|
}
|
|
return result;
|
|
});
|
|
|
|
ipcMain.handle("get-remote-sync-jwt", () => {
|
|
return remoteSync.getRemoteSyncJwt();
|
|
});
|
|
|
|
ipcMain.handle("clear-remote-sync-jwt", () => {
|
|
return remoteSync.clearRemoteSyncJwt();
|
|
});
|
|
|
|
ipcMain.handle("get-remote-sync-status", () => {
|
|
return remoteSync.getRemoteSyncEngine()?.status || null;
|
|
});
|
|
|
|
ipcMain.handle("get-remote-sync-user-info", () => {
|
|
return remoteSync.getRemoteSyncUserInfo();
|
|
});
|
|
|
|
ipcMain.handle("remote-sync-now", async () => {
|
|
return (await remoteSync.getRemoteSyncEngine()?.syncNow()) || null;
|
|
});
|
|
|
|
ipcMain.handle("notify-local-login", (_event, token) => {
|
|
remoteSync.getRemoteSyncEngine()?.setLocalJwt(token);
|
|
return { success: true };
|
|
});
|
|
|
|
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);
|
|
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}:${sourcePort}`;
|
|
if (autoStartRemoteListeners.has(listenerKey)) {
|
|
return {
|
|
success: false,
|
|
error: `Another auto-start client tunnel already uses remote ${listenerKey}`,
|
|
};
|
|
}
|
|
autoStartRemoteListeners.add(listenerKey);
|
|
continue;
|
|
}
|
|
|
|
const bindHost = tunnel.bindHost || "127.0.0.1";
|
|
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 [bindHost, sourcePort] = listenerKey.split(":");
|
|
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 });
|
|
});
|
|
});
|
|
}
|
|
|
|
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 a connected, self-hosted
|
|
// Termix server -- the same "remote server" concept Remote Sync connects
|
|
// to, not the always-local embedded backend. There's no separate C2S
|
|
// server-URL setting in the UI; it has always shared whatever remote
|
|
// server the rest of the app was pointed at. Before the standalone-first
|
|
// rework that was server-config.json; now it's remote-sync-config.json,
|
|
// since that's the only remaining notion of "a connected remote server."
|
|
function getC2SRelayUrl() {
|
|
const config = remoteSync.getRemoteSyncConfig();
|
|
const serverUrl = config?.serverUrl;
|
|
if (!serverUrl) {
|
|
throw new Error(
|
|
"No remote Termix server connected -- enable Remote Sync first",
|
|
);
|
|
}
|
|
|
|
const base = serverUrl.replace(/\/$/, "");
|
|
const relayHttpUrl = `${base}/ssh/tunnel/c2s/stream`;
|
|
return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
}
|
|
|
|
async function getC2SRelayHeaders() {
|
|
const jwt = remoteSync.getRemoteSyncJwt();
|
|
if (!jwt) return {};
|
|
|
|
return {
|
|
Authorization: `Bearer ${jwt}`,
|
|
};
|
|
}
|
|
|
|
function getC2STunnelName(tunnel, index = 0) {
|
|
if (tunnel.name) return tunnel.name;
|
|
return [
|
|
"c2s",
|
|
index,
|
|
tunnel.sourceHostId || 0,
|
|
tunnel.mode || tunnel.tunnelType || "local",
|
|
tunnel.bindHost || "127.0.0.1",
|
|
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,
|
|
) {
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders();
|
|
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 }),
|
|
);
|
|
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: tunnel,
|
|
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") {
|
|
logToFile("[c2s] relay error:", message.error);
|
|
setC2STunnelError(
|
|
tunnelName,
|
|
message.error || "Relay rejected the client tunnel",
|
|
);
|
|
cleanup();
|
|
}
|
|
} catch (error) {
|
|
logToFile("[c2s] invalid relay message:", error.message);
|
|
setC2STunnelError(tunnelName, error.message || "Invalid relay response");
|
|
cleanup();
|
|
}
|
|
});
|
|
}
|
|
|
|
async function testC2SRelay(tunnel, targetHost, targetPort) {
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders();
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const settle = (result) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
try {
|
|
ws.close();
|
|
} catch {
|
|
// expected during shutdown
|
|
}
|
|
resolve(result);
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
settle({ success: false, error: "Tunnel test timed out" });
|
|
}, 15000);
|
|
|
|
ws.on("open", () => {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "test",
|
|
tunnelConfig: tunnel,
|
|
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({
|
|
success: false,
|
|
error: message.error || "Tunnel test failed",
|
|
});
|
|
}
|
|
} catch (error) {
|
|
clearTimeout(timer);
|
|
settle({ success: false, error: error.message });
|
|
}
|
|
});
|
|
ws.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
settle({ success: false, error: error.message });
|
|
});
|
|
ws.on("close", () => {
|
|
clearTimeout(timer);
|
|
settle({ success: false, error: "Tunnel test connection closed" });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function testC2STunnel(tunnel, index = 0) {
|
|
const mode = tunnel.mode || tunnel.tunnelType || "local";
|
|
const testTunnel = {
|
|
...tunnel,
|
|
name: `${getC2STunnelName(tunnel, index)}::test`,
|
|
mode,
|
|
};
|
|
const bindHost = tunnel.bindHost || "127.0.0.1";
|
|
const sourcePort = Number(tunnel.sourcePort);
|
|
const endpointPort = Number(tunnel.endpointPort);
|
|
|
|
if (!tunnel.sourceHostId) {
|
|
return { success: false, error: "Endpoint SSH host is required" };
|
|
}
|
|
|
|
if (mode === "remote") {
|
|
const localTarget = await checkTcpConnection(bindHost, endpointPort);
|
|
if (!localTarget.success) {
|
|
return {
|
|
success: false,
|
|
error: `Local target ${bindHost}:${endpointPort} is not reachable: ${localTarget.error}`,
|
|
};
|
|
}
|
|
|
|
return testC2SRelay(testTunnel, undefined, undefined);
|
|
}
|
|
|
|
if (!Number.isInteger(sourcePort) || sourcePort < 1 || sourcePort > 65535) {
|
|
return { success: false, error: "Invalid local port" };
|
|
}
|
|
|
|
const runtime = c2sTunnelRuntimes.get(getC2STunnelName(tunnel, index));
|
|
if (!runtime) {
|
|
const availability = await checkLocalPortAvailable(bindHost, sourcePort);
|
|
if (!availability.available) {
|
|
return {
|
|
success: false,
|
|
error: `Local listener ${bindHost}:${sourcePort} is not available: ${availability.error}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (mode === "dynamic") {
|
|
return testC2SRelay(testTunnel, undefined, undefined);
|
|
}
|
|
|
|
if (!Number.isInteger(endpointPort) || endpointPort < 1) {
|
|
return { success: false, error: "Invalid remote port" };
|
|
}
|
|
|
|
return testC2SRelay(
|
|
testTunnel,
|
|
tunnel.targetHost || "127.0.0.1",
|
|
endpointPort,
|
|
);
|
|
}
|
|
|
|
function handleC2SDynamicConnection(tunnel, socket) {
|
|
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).catch(
|
|
(error) => {
|
|
logToFile("[c2s] dynamic relay failed:", error.message);
|
|
fail(0x05, error.message || "Dynamic relay failed");
|
|
},
|
|
);
|
|
}
|
|
} 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) {
|
|
const tunnelName = tunnel.name || getC2STunnelName(tunnel);
|
|
const targetHost = tunnel.targetHost || "127.0.0.1";
|
|
const targetPort = Number(tunnel.endpointPort);
|
|
openC2SRelay(tunnel, targetHost, targetPort, socket).catch((error) => {
|
|
logToFile("[c2s] local relay failed:", error.message);
|
|
setC2STunnelError(tunnelName, error.message || "Local relay failed");
|
|
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) {
|
|
const tunnelName = getC2STunnelName(tunnel, index);
|
|
const localHost = tunnel.bindHost || "127.0.0.1";
|
|
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.sourcePort === remotePort
|
|
) {
|
|
return {
|
|
success: false,
|
|
error: `Another client remote tunnel already uses ${remotePort} on this endpoint`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const relayUrl = getC2SRelayUrl();
|
|
const headers = await getC2SRelayHeaders();
|
|
const ws = new WebSocket(
|
|
relayUrl,
|
|
getWebSocketOptions(relayUrl, { headers }),
|
|
);
|
|
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,
|
|
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,
|
|
remotePort,
|
|
localHost,
|
|
localPort,
|
|
});
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "open",
|
|
tunnelConfig: { ...tunnel, name: tunnelName, mode: "remote" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
ws.on("message", (data, isBinary) => {
|
|
if (isBinary) return;
|
|
|
|
let message;
|
|
try {
|
|
message = JSON.parse(data.toString());
|
|
} catch (error) {
|
|
setC2STunnelError(tunnelName, error.message || "Invalid relay message");
|
|
cleanup();
|
|
settle({ success: false, error: error.message });
|
|
return;
|
|
}
|
|
|
|
if (message.type === "ready") {
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: true,
|
|
status: "CONNECTED",
|
|
});
|
|
settle({ success: true, tunnelName });
|
|
return;
|
|
}
|
|
|
|
if (message.type === "error") {
|
|
const error = message.error || "Relay rejected the client tunnel";
|
|
setC2STunnelError(tunnelName, error);
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle({ success: false, error });
|
|
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) => {
|
|
setC2STunnelError(tunnelName, error.message || "Relay connection failed");
|
|
cleanup();
|
|
c2sTunnelRuntimes.delete(tunnelName);
|
|
emitC2STunnelStatuses();
|
|
settle({ success: false, error: error.message });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function startC2STunnel(tunnel, index = 0) {
|
|
const mode = tunnel.mode || tunnel.tunnelType || "local";
|
|
const tunnelName = getC2STunnelName(tunnel, index);
|
|
const bindHost = tunnel.bindHost || "127.0.0.1";
|
|
const sourcePort = Number(tunnel.sourcePort);
|
|
logToFile(`[c2s] starting tunnel ${tunnelName}`, {
|
|
mode,
|
|
bindHost,
|
|
sourcePort,
|
|
sourceHostId: tunnel.sourceHostId,
|
|
endpointPort: tunnel.endpointPort,
|
|
});
|
|
|
|
if (mode === "remote") {
|
|
return startC2SRemoteTunnel(tunnel, index);
|
|
}
|
|
if (!tunnel.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) => {
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
if (mode === "dynamic") {
|
|
handleC2SDynamicConnection({ ...tunnel, name: tunnelName, mode }, socket);
|
|
} else {
|
|
handleC2SLocalConnection({ ...tunnel, name: tunnelName, mode }, socket);
|
|
}
|
|
});
|
|
|
|
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: true,
|
|
status: "CONNECTED",
|
|
});
|
|
resolve({ success: true, tunnelName });
|
|
});
|
|
});
|
|
}
|
|
|
|
async function stopC2STunnel(tunnelName) {
|
|
const runtime = c2sTunnelRuntimes.get(tunnelName);
|
|
if (!runtime) {
|
|
return { success: true };
|
|
}
|
|
|
|
setC2STunnelStatus(tunnelName, {
|
|
connected: false,
|
|
status: "DISCONNECTING",
|
|
});
|
|
|
|
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 {
|
|
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) => {
|
|
try {
|
|
return await startC2STunnel(tunnel, Number(index) || 0);
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("test-c2s-tunnel", async (_event, tunnel, index) => {
|
|
try {
|
|
return await testC2STunnel(tunnel, Number(index) || 0);
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
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("show-save-dialog", async (_event, options) => {
|
|
return dialog.showSaveDialog(mainWindow, options || {});
|
|
});
|
|
|
|
ipcMain.handle("show-open-dialog", async (_event, options) => {
|
|
return dialog.showOpenDialog(mainWindow, options || {});
|
|
});
|
|
|
|
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 };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("test-server-connection", async (event, serverUrl) => {
|
|
try {
|
|
const normalizedServerUrl = serverUrl.replace(/\/$/, "");
|
|
const healthUrl = `${normalizedServerUrl}/health`;
|
|
|
|
// This is a best-effort reachability probe, not a hard gate: a reverse
|
|
// proxy doing SSO in front of the real server (Pangolin, Authelia,
|
|
// Cloudflare Access, etc.) intercepts this unauthenticated request
|
|
// before it ever reaches Termix's own /health route, and returns its
|
|
// own login page (HTML, or a redirect) instead of {"status":"ok"}.
|
|
// That's a legitimate, working setup -- the login iframe shown right
|
|
// after this check is what actually proves the server is real, by
|
|
// completing an authenticated round-trip. So any response at all here
|
|
// (any status code, any body) means "something is there, let the user
|
|
// proceed"; only a network-level failure (nothing answered at all)
|
|
// blocks continuing.
|
|
try {
|
|
const response = await httpFetch(healthUrl, {
|
|
method: "GET",
|
|
timeout: 10000,
|
|
});
|
|
|
|
const data = await response.text();
|
|
const looksLikeHtml =
|
|
data.includes("<html") ||
|
|
data.includes("<!DOCTYPE") ||
|
|
data.includes("<head>") ||
|
|
data.includes("<body>");
|
|
|
|
if (response.ok && !looksLikeHtml) {
|
|
try {
|
|
const healthData = JSON.parse(data);
|
|
if (
|
|
healthData &&
|
|
(healthData.status === "ok" ||
|
|
healthData.status === "healthy" ||
|
|
healthData.healthy === true ||
|
|
healthData.database === "connected")
|
|
) {
|
|
return {
|
|
success: true,
|
|
status: response.status,
|
|
testedUrl: healthUrl,
|
|
};
|
|
}
|
|
} catch (parseError) {
|
|
console.log("Health endpoint did not return valid JSON");
|
|
}
|
|
}
|
|
|
|
// Reachable, but not a recognized Termix health response -- likely a
|
|
// proxy/SSO login page in front of the real server. Let the user
|
|
// proceed; the login step next will fail clearly if this really
|
|
// isn't a Termix server.
|
|
return {
|
|
success: true,
|
|
status: response.status,
|
|
testedUrl: healthUrl,
|
|
warning: looksLikeHtml
|
|
? "Could not confirm this is a Termix server (the response looked like an HTML page, which can happen behind a login-protected reverse proxy). You can continue, and the next step will fail clearly if this isn't actually a Termix server."
|
|
: "Server responded, but not with the expected health check format. Continuing anyway.",
|
|
};
|
|
} catch (urlError) {
|
|
console.error("Health check failed:", urlError);
|
|
return {
|
|
success: false,
|
|
error:
|
|
"Server is not responding. Please ensure the server is running and accessible.",
|
|
};
|
|
}
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
});
|
|
|
|
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();
|
|
remoteSync.initRemoteSync(() => mainWindow);
|
|
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);
|
|
});
|