```
├── .commitlintrc.json (100 tokens)
├── .dockerignore (100 tokens)
├── .editorconfig (omitted)
├── .gitattributes (omitted)
├── .github/
   ├── ISSUE_TEMPLATE/
      ├── config.yml (100 tokens)
   ├── dependabot.yml (400 tokens)
   ├── pull_request_template.md (100 tokens)
   ├── workflows/
      ├── dependabot-retarget.yml (1300 tokens)
      ├── docker.yml (700 tokens)
      ├── electron.yml (8.1k tokens)
      ├── openapi.yml (100 tokens)
      ├── pr-check.yml (100 tokens)
      ├── release.yml (3.8k tokens)
├── .gitignore (100 tokens)
├── .husky/
   ├── commit-msg
   ├── pre-commit
├── .npmrc
├── .nvmrc
├── .prettierignore (omitted)
├── .prettierrc
├── CODE_OF_CONDUCT.md (1000 tokens)
├── CONTRIBUTING.md (1100 tokens)
├── Casks/
   ├── termix.rb (200 tokens)
├── LICENSE (omitted)
├── README.md (2.5k tokens)
├── RELEASE_NOTES.md (500 tokens)
├── SECURITY.md
├── build/
   ├── Termix_Mac_App_Store.provisionprofile
   ├── entitlements.mac.inherit.plist (100 tokens)
   ├── entitlements.mac.plist (100 tokens)
   ├── entitlements.mas.inherit.plist (100 tokens)
   ├── entitlements.mas.plist (100 tokens)
   ├── notarize.cjs (200 tokens)
├── chocolatey/
   ├── termix-ssh.nuspec (400 tokens)
   ├── tools/
      ├── chocolateyinstall.ps1 (100 tokens)
      ├── chocolateyuninstall.ps1 (200 tokens)
├── components.json (100 tokens)
├── crowdin.yml
├── docker/
   ├── Dockerfile (500 tokens)
   ├── compose-dev.yml (100 tokens)
   ├── docker-compose.yml (100 tokens)
   ├── entrypoint.sh (1000 tokens)
   ├── nginx-https.conf (4.8k tokens)
   ├── nginx.conf (4.9k tokens)
├── electron-builder.json (800 tokens)
├── electron/
   ├── main.cjs (14.6k tokens)
   ├── preload.js (500 tokens)
├── eslint.config.mjs (300 tokens)
├── flatpak/
   ├── com.karmaa.termix.desktop (100 tokens)
   ├── com.karmaa.termix.flatpakref (200 tokens)
   ├── com.karmaa.termix.metainfo.xml (500 tokens)
   ├── com.karmaa.termix.yml (600 tokens)
   ├── flathub.json
├── index.html (300 tokens)
├── knip.json (500 tokens)
├── package-lock.json (omitted)
├── package.json (1600 tokens)
├── public/
   ├── favicon.ico
   ├── fonts/
      ├── CaskaydiaCoveNerdFontMono-Bold.ttf
      ├── CaskaydiaCoveNerdFontMono-BoldItalic.ttf
      ├── CaskaydiaCoveNerdFontMono-Italic.ttf
      ├── CaskaydiaCoveNerdFontMono-Regular.ttf
   ├── full-icon.png
   ├── icon-mac.png
   ├── icon.icns
   ├── icon.ico
   ├── icon.png
   ├── icon.svg (3.6k tokens)
   ├── icons/
      ├── 1024x1024.png
      ├── 128x128.png
      ├── 16x16.png
      ├── 24x24.png
      ├── 256x256.png
      ├── 32x32.png
      ├── 48x48.png
      ├── 512x512.png
      ├── 64x64.png
      ├── icon.icns
      ├── icon.ico
   ├── manifest.json (200 tokens)
   ├── pdf.worker.min.js (omitted)
   ├── sw.js (400 tokens)
├── readme/
   ├── HOST-TO-HOST-TRANSFER.md (3.5k tokens)
   ├── README-AR.md (2.3k tokens)
   ├── README-CN.md (1800 tokens)
   ├── README-DE.md (2.6k tokens)
   ├── README-ES.md (2.6k tokens)
   ├── README-FR.md (2.7k tokens)
   ├── README-HI.md (2.4k tokens)
   ├── README-IT.md (2.6k tokens)
   ├── README-JA.md (2000 tokens)
   ├── README-KO.md (1900 tokens)
   ├── README-PT.md (2.6k tokens)
   ├── README-RU.md (2.6k tokens)
   ├── README-TR.md (2.5k tokens)
   ├── README-VI.md (2.4k tokens)
├── repo-images/
   ├── Image 1.png
   ├── Image 10.png
   ├── Image 11.png
   ├── Image 12.png
   ├── Image 13.png
   ├── Image 14.png
   ├── Image 2.png
   ├── Image 3.png
   ├── Image 4.png
   ├── Image 5.png
   ├── Image 6.png
   ├── Image 7.png
   ├── Image 8.png
   ├── Image 9.png
   ├── Repo of the Day.png
   ├── Termix Header.png
   ├── YouTube.png
├── scripts/
   ├── crowdin-pretranslate.cjs (700 tokens)
   ├── generate-icons.mjs (800 tokens)
   ├── generate-release-body.cjs (1100 tokens)
   ├── latest-dev-branch.cjs (200 tokens)
   ├── latest-dev-branch.test.ts (200 tokens)
   ├── parse-dev-branch.cjs (200 tokens)
   ├── parse-dev-branch.test.ts (200 tokens)
   ├── patch-app-builder-lib.cjs (1700 tokens)
   ├── patch-better-sqlite3.cjs (600 tokens)
   ├── patch-guacamole-lite.cjs (500 tokens)
   ├── patch-nan.cjs (800 tokens)
   ├── publish-youtube.cjs (600 tokens)
   ├── publish-youtube.test.ts (300 tokens)
   ├── sync-version.cjs (400 tokens)
   ├── sync-version.test.ts (500 tokens)
   ├── write-electron-build-info.cjs (100 tokens)
├── src/
   ├── backend/
      ├── dashboard.ts (2k tokens)
      ├── database/
         ├── database.ts (12.6k tokens)
         ├── db/
            ├── index.ts (13k tokens)
            ├── schema.ts (5.2k tokens)
         ├── routes/
            ├── alerts.ts (1600 tokens)
            ├── audit-log-routes.ts (1000 tokens)
            ├── c2s-tunnel-presets.ts (1500 tokens)
            ├── credential-deploy-routes.ts (3.4k tokens)
            ├── credential-key-routes.ts (2.9k tokens)
            ├── credentials.ts (6.2k tokens)
            ├── delete-user-data.ts (600 tokens)
            ├── host-autostart-routes.ts (2000 tokens)
            ├── host-bulk-routes.ts (3.8k tokens)
            ├── host-command-history-routes.ts (900 tokens)
            ├── host-file-manager-bookmark-routes.ts (3.5k tokens)
            ├── host-folder-routes.ts (2.6k tokens)
            ├── host-internal-routes.ts (1200 tokens)
            ├── host-network-routes.ts (800 tokens)
            ├── host-normalizers.test.ts (1300 tokens)
            ├── host-normalizers.ts (1900 tokens)
            ├── host-opkssh-routes.ts (6.1k tokens)
            ├── host.ts (14.4k tokens)
            ├── ldap-auth-routes.ts (2.6k tokens)
            ├── network-topology.ts (1200 tokens)
            ├── open-tabs.ts (2.1k tokens)
            ├── opkssh-html.ts (1600 tokens)
            ├── proxmox.ts (3.8k tokens)
            ├── rbac.ts (8.8k tokens)
            ├── session-log-routes.test.ts (700 tokens)
            ├── session-log-routes.ts (1900 tokens)
            ├── snippets-reorder.ts (200 tokens)
            ├── snippets.ts (7.9k tokens)
            ├── sso-provider-routes.ts (2.7k tokens)
            ├── tailscale-routes.ts (600 tokens)
            ├── terminal.ts (2.6k tokens)
            ├── user-admin-routes.ts (2000 tokens)
            ├── user-api-key-routes.ts (1600 tokens)
            ├── user-data-access-routes.ts (700 tokens)
            ├── user-oidc-account-routes.ts (2.3k tokens)
            ├── user-oidc-utils.test.ts (1100 tokens)
            ├── user-oidc-utils.ts (2000 tokens)
            ├── user-password-reset-routes.ts (3.1k tokens)
            ├── user-preferences.ts (1900 tokens)
            ├── user-session-routes.ts (1900 tokens)
            ├── user-settings-routes.ts (3.5k tokens)
            ├── user-totp-routes.test.ts (500 tokens)
            ├── user-totp-routes.ts (3.6k tokens)
            ├── users.ts (15.6k tokens)
      ├── guacamole/
         ├── guacamole-server.ts (800 tokens)
         ├── routes.ts (3.2k tokens)
         ├── token-service.ts (900 tokens)
      ├── package.json
      ├── scripts/
         ├── enable-ssl.sh (400 tokens)
         ├── setup-ssl.sh (600 tokens)
      ├── ssh/
         ├── auth-manager.ts (2.2k tokens)
         ├── credential-username.test.ts (200 tokens)
         ├── credential-username.ts (200 tokens)
         ├── docker-console.ts (4.3k tokens)
         ├── docker-container-routes.ts (5.9k tokens)
         ├── docker.ts (12.4k tokens)
         ├── file-manager-action-routes.ts (3.3k tokens)
         ├── file-manager-content-routes.ts (8.4k tokens)
         ├── file-manager-download-routes.ts (1500 tokens)
         ├── file-manager-list-routes.ts (3.2k tokens)
         ├── file-manager-log.ts (100 tokens)
         ├── file-manager-operation-routes.ts (5.1k tokens)
         ├── file-manager-session.ts (1000 tokens)
         ├── file-manager-utils.test.ts (700 tokens)
         ├── file-manager-utils.ts (600 tokens)
         ├── file-manager.ts (16.9k tokens)
         ├── host-key-verifier.ts (2.4k tokens)
         ├── host-metrics-helpers.test.ts (400 tokens)
         ├── host-metrics-helpers.ts (200 tokens)
         ├── host-metrics-jump-hosts.ts (1900 tokens)
         ├── host-metrics-preferences-routes.ts (1600 tokens)
         ├── host-metrics-preferences.test.ts (600 tokens)
         ├── host-metrics-sessions.ts (400 tokens)
         ├── host-metrics-settings-routes.ts (1100 tokens)
         ├── host-metrics-state.ts (1300 tokens)
         ├── host-metrics-viewer-routes.ts (1800 tokens)
         ├── host-metrics.ts (14.9k tokens)
         ├── host-resolver.ts (1400 tokens)
         ├── host-transfer.ts (18.6k tokens)
         ├── jump-host-chain.ts (1900 tokens)
         ├── managers/
            ├── cron.ts (900 tokens)
            ├── exec-elevated.test.ts (800 tokens)
            ├── exec-elevated.ts (1100 tokens)
            ├── firewall.ts (800 tokens)
            ├── health.ts (1600 tokens)
            ├── index.ts (400 tokens)
            ├── logs.ts (600 tokens)
            ├── managers.test.ts (2.8k tokens)
            ├── packages.ts (900 tokens)
            ├── platform.ts (400 tokens)
            ├── processes.ts (800 tokens)
            ├── route-helpers.ts (400 tokens)
            ├── services.ts (800 tokens)
            ├── simple-reads.ts (800 tokens)
            ├── ssl.ts (2.1k tokens)
            ├── types.ts (200 tokens)
            ├── users.ts (900 tokens)
            ├── validation.ts (700 tokens)
         ├── opkssh-auth.ts (5.8k tokens)
         ├── opkssh-cert-auth.ts (2.2k tokens)
         ├── ssh-connection-pool.ts (1200 tokens)
         ├── terminal-auth-helpers.ts (400 tokens)
         ├── terminal-jump-hosts.ts (2k tokens)
         ├── terminal-session-manager.test.ts (800 tokens)
         ├── terminal-session-manager.ts (3.1k tokens)
         ├── terminal.ts (16.4k tokens)
         ├── tmux-helper.ts (1200 tokens)
         ├── tmux-monitor-helpers.test.ts (1300 tokens)
         ├── tmux-monitor-helpers.ts (1300 tokens)
         ├── tmux-monitor.ts (5.8k tokens)
         ├── transfer-paths.ts (900 tokens)
         ├── transfer-routing.ts (1000 tokens)
         ├── tunnel-c2s-relay-utils.ts (600 tokens)
         ├── tunnel-c2s-relay.ts (2.4k tokens)
         ├── tunnel-socks5-relay.ts (600 tokens)
         ├── tunnel-ssh-primitives.ts (900 tokens)
         ├── tunnel-utils.ts (700 tokens)
         ├── tunnel.ts (14.7k tokens)
         ├── widgets/
            ├── common-utils.test.ts (200 tokens)
            ├── common-utils.ts (500 tokens)
            ├── cpu-collector.test.ts (200 tokens)
            ├── cpu-collector.ts (500 tokens)
            ├── disk-collector.ts (400 tokens)
            ├── firewall-collector.ts (1200 tokens)
            ├── login-stats-collector.ts (800 tokens)
            ├── memory-collector.ts (300 tokens)
            ├── network-collector.ts (400 tokens)
            ├── ports-collector.ts (800 tokens)
            ├── processes-collector.ts (400 tokens)
            ├── system-collector.ts (200 tokens)
            ├── uptime-collector.ts (200 tokens)
      ├── starter.ts (1600 tokens)
      ├── swagger.ts (800 tokens)
      ├── utils/
         ├── audit-logger.test.ts (700 tokens)
         ├── audit-logger.ts (400 tokens)
         ├── auth-manager.ts (6.9k tokens)
         ├── auto-ssl-setup.ts (1600 tokens)
         ├── cors-config.ts (400 tokens)
         ├── credential-system-encryption-migration.ts (800 tokens)
         ├── data-crypto.ts (3.2k tokens)
         ├── database-file-encryption.test.ts (500 tokens)
         ├── database-file-encryption.ts (4.4k tokens)
         ├── database-migration.ts (2.4k tokens)
         ├── database-save-trigger.ts (600 tokens)
         ├── field-crypto.test.ts (600 tokens)
         ├── field-crypto.ts (600 tokens)
         ├── lazy-field-encryption.ts (2.7k tokens)
         ├── logger.ts (1800 tokens)
         ├── login-rate-limiter.test.ts (700 tokens)
         ├── login-rate-limiter.ts (1600 tokens)
         ├── opkssh-binary-manager.ts (1200 tokens)
         ├── permission-manager.test.ts (500 tokens)
         ├── permission-manager.ts (2.1k tokens)
         ├── proxy-agent.ts (200 tokens)
         ├── proxy-helper.ts (2.1k tokens)
         ├── request-origin.ts (400 tokens)
         ├── shared-credential-manager.ts (3.6k tokens)
         ├── simple-db-ops.ts (900 tokens)
         ├── socks5-helper.ts
         ├── ssh-algorithms.ts (500 tokens)
         ├── ssh-key-utils.test.ts (800 tokens)
         ├── ssh-key-utils.ts (2.5k tokens)
         ├── system-crypto.ts (2.5k tokens)
         ├── user-agent-parser.test.ts (800 tokens)
         ├── user-agent-parser.ts (1500 tokens)
         ├── user-crypto.ts (3.4k tokens)
         ├── user-data-export.ts (1600 tokens)
         ├── wake-on-lan.test.ts (400 tokens)
         ├── wake-on-lan.ts (200 tokens)
   ├── main.tsx (1700 tokens)
   ├── package.json
   ├── types/
      ├── connection-log.ts (200 tokens)
      ├── electron.d.ts (omitted)
      ├── guacamole-common-js.d.ts (omitted)
      ├── host-metrics.ts (700 tokens)
      ├── index.ts (4.8k tokens)
      ├── proxmox.ts (100 tokens)
      ├── stats-widgets.ts (300 tokens)
      ├── ui-types.ts (1400 tokens)
   ├── ui/
      ├── AppShell.tsx (9.7k tokens)
      ├── api/
         ├── audit-log-api.ts (400 tokens)
         ├── command-history-api.ts (300 tokens)
         ├── credentials-api.ts (1900 tokens)
         ├── dashboard-api.ts (400 tokens)
         ├── docker-api.ts (1700 tokens)
         ├── file-manager-data-api.ts (700 tokens)
         ├── file-manager-metadata-api.ts (600 tokens)
         ├── guacamole-api.ts (1300 tokens)
         ├── host-metrics-api.ts (600 tokens)
         ├── host-metrics-status-api.ts (1500 tokens)
         ├── oidc-account-api.ts (200 tokens)
         ├── open-tabs-api.ts (600 tokens)
         ├── rbac-api.ts (1000 tokens)
         ├── session-log-api.ts (200 tokens)
         ├── settings-api.ts (800 tokens)
         ├── snippets-api.ts (900 tokens)
         ├── ssh-file-operations-api.ts (3.8k tokens)
         ├── ssh-host-management-api.ts (1400 tokens)
         ├── sso-provider-api.ts (400 tokens)
         ├── system-status-api.ts (900 tokens)
         ├── tmux-monitor-api.ts (800 tokens)
         ├── tunnel-api.ts (700 tokens)
         ├── user-management-api.ts (1400 tokens)
      ├── auth/
         ├── Auth.tsx (11k tokens)
         ├── ElectronLoginForm.tsx (1300 tokens)
         ├── ElectronServerConfig.tsx (1900 tokens)
         ├── LoginPage.tsx (13.1k tokens)
         ├── LoginScreen.tsx (200 tokens)
         ├── silent-signin.ts (100 tokens)
      ├── components/
         ├── accordion.tsx (400 tokens)
         ├── alert-dialog.tsx (800 tokens)
         ├── alert.tsx (300 tokens)
         ├── badge.tsx (300 tokens)
         ├── button-group.tsx (300 tokens)
         ├── button.tsx (600 tokens)
         ├── card-grid/
            ├── CardGridCanvas.tsx (2.9k tokens)
            ├── layout-utils.test.ts (800 tokens)
            ├── layout-utils.ts (600 tokens)
            ├── types.ts (200 tokens)
         ├── card.tsx (500 tokens)
         ├── charts/
            ├── BarSeries.tsx (300 tokens)
            ├── RadialGauge.tsx (500 tokens)
            ├── Sparkline.tsx (500 tokens)
            ├── StatRow.tsx (300 tokens)
            ├── geometry.test.ts (500 tokens)
            ├── geometry.ts (400 tokens)
            ├── index.ts (100 tokens)
         ├── checkbox.tsx (200 tokens)
         ├── command.tsx (700 tokens)
         ├── dialog.tsx (800 tokens)
         ├── dropdown-menu.tsx (1800 tokens)
         ├── folder-style.tsx (1100 tokens)
         ├── folder.tsx (3.7k tokens)
         ├── form.tsx (800 tokens)
         ├── input.tsx (200 tokens)
         ├── kbd.tsx (200 tokens)
         ├── label.tsx (100 tokens)
         ├── password-input.tsx (200 tokens)
         ├── popover.tsx (300 tokens)
         ├── proxmox/
            ├── ProxmoxDiscoverDialog.tsx (2.6k tokens)
         ├── scroll-area.tsx (300 tokens)
         ├── section-card.tsx (500 tokens)
         ├── select.tsx (1300 tokens)
         ├── separator.tsx (100 tokens)
         ├── shadcn-io/
            ├── status/
               ├── index.tsx (400 tokens)
         ├── sheet.tsx (900 tokens)
         ├── sidebar.tsx (4.3k tokens)
         ├── skeleton.tsx (100 tokens)
         ├── slider.tsx (400 tokens)
         ├── sonner.tsx (400 tokens)
         ├── switch.tsx (200 tokens)
         ├── table.tsx (500 tokens)
         ├── tabs.tsx (400 tokens)
         ├── textarea.tsx (200 tokens)
         ├── theme-provider.tsx (400 tokens)
         ├── tooltip.tsx (400 tokens)
         ├── version-alert.tsx (700 tokens)
      ├── dashboard/
         ├── DashboardTab.tsx (10.9k tokens)
         ├── cards/
            ├── NetworkGraphCard.tsx (9.5k tokens)
         ├── panels/
            ├── UpdateLog.tsx (1500 tokens)
            ├── alerts/
               ├── AlertCard.tsx (800 tokens)
               ├── AlertManager.tsx (900 tokens)
      ├── features/
         ├── FullScreenAppWrapper.tsx (800 tokens)
         ├── docker/
            ├── DockerApp.tsx (300 tokens)
            ├── DockerManager.tsx (5.5k tokens)
            ├── components/
               ├── ConsoleTerminal.tsx (3.5k tokens)
               ├── ContainerCard.tsx (2000 tokens)
               ├── ContainerDetail.tsx (1000 tokens)
               ├── ContainerList.tsx (500 tokens)
               ├── ContainerStats.tsx (1600 tokens)
               ├── LogViewer.tsx (1700 tokens)
         ├── file-manager/
            ├── FileManager.tsx (17.1k tokens)
            ├── FileManagerApp.tsx (300 tokens)
            ├── FileManagerContextMenu.tsx (3.4k tokens)
            ├── FileManagerDialogs.tsx (800 tokens)
            ├── FileManagerGrid.tsx (8.4k tokens)
            ├── FileManagerSidebar.tsx (5.4k tokens)
            ├── FileManagerToolbar.tsx (2.4k tokens)
            ├── SudoPasswordDialog.tsx (600 tokens)
            ├── TransferMonitor.tsx (500 tokens)
            ├── components/
               ├── AudioPreview.tsx (600 tokens)
               ├── CodeEditor.tsx (800 tokens)
               ├── CompressDialog.tsx (1100 tokens)
               ├── DiffViewer.tsx (2.1k tokens)
               ├── DiffWindow.tsx (300 tokens)
               ├── DraggableWindow.tsx (2.5k tokens)
               ├── FileViewer.tsx (6.4k tokens)
               ├── FileWindow.tsx (2.4k tokens)
               ├── ImagePreview.tsx (700 tokens)
               ├── MarkdownRenderer.tsx (900 tokens)
               ├── PdfPreview.tsx (1100 tokens)
               ├── PermissionsDialog.tsx (1700 tokens)
               ├── TerminalWindow.tsx (600 tokens)
               ├── TransferProgressToast.tsx (900 tokens)
               ├── TransferToHostDialog.tsx (7.8k tokens)
               ├── WindowManager.tsx (800 tokens)
            ├── file-manager-types.ts (200 tokens)
            ├── file-manager-utils.test.ts (200 tokens)
            ├── file-manager-utils.ts (100 tokens)
            ├── hooks/
               ├── useDragAndDrop.ts (900 tokens)
               ├── useDragToDesktop.ts (1500 tokens)
               ├── useDragToSystemDesktop.ts (1700 tokens)
               ├── useFileSelection.test.ts (500 tokens)
               ├── useFileSelection.ts (500 tokens)
            ├── transferMetricsFormat.ts (400 tokens)
            ├── transferNotificationStore.ts (300 tokens)
            ├── transferProgressMonitor.tsx (1900 tokens)
         ├── guacamole/
            ├── GuacamoleApp.tsx (1300 tokens)
            ├── GuacamoleDisplay.tsx (3.8k tokens)
            ├── GuacamoleToolbar.tsx (3k tokens)
         ├── host-metrics/
            ├── HostMetricsApp.tsx (300 tokens)
            ├── HostMetricsTab.tsx (5.1k tokens)
            ├── cards/
               ├── CpuCard.tsx (300 tokens)
               ├── DiskCard.tsx (300 tokens)
               ├── FirewallCard.tsx (400 tokens)
               ├── LoginStatsCard.tsx (500 tokens)
               ├── MemoryCard.tsx (300 tokens)
               ├── MetricCard.tsx (300 tokens)
               ├── NetworkCard.tsx (500 tokens)
               ├── PortsCard.tsx (800 tokens)
               ├── ProcessesCard.tsx (300 tokens)
               ├── SystemCard.tsx (300 tokens)
               ├── UptimeCard.tsx (200 tokens)
               ├── index.tsx (1100 tokens)
               ├── managers/
                  ├── CronManagerCard.tsx (1000 tokens)
                  ├── FirewallManagerCard.tsx (1600 tokens)
                  ├── HealthCheckCard.tsx (2k tokens)
                  ├── LogViewerCard.tsx (1200 tokens)
                  ├── ManagerCardShell.tsx (400 tokens)
                  ├── ManagerToolbar.tsx (300 tokens)
                  ├── PackageManagerCard.tsx (700 tokens)
                  ├── ProcessInspectorCard.tsx (1300 tokens)
                  ├── ServiceManagerCard.tsx (1000 tokens)
                  ├── SimpleManagerCards.tsx (900 tokens)
                  ├── SslManagerCard.tsx (2000 tokens)
                  ├── UserManagerCard.tsx (1000 tokens)
                  ├── useManagerData.ts (600 tokens)
            ├── hooks/
               ├── useHostMetricsPreferences.ts (500 tokens)
         ├── terminal/
            ├── Terminal.tsx (18.2k tokens)
            ├── TerminalApp.tsx (400 tokens)
            ├── TerminalPreview.tsx (800 tokens)
            ├── command-history/
               ├── CommandAutocomplete.tsx (400 tokens)
               ├── CommandHistoryContext.tsx (500 tokens)
               ├── useCommandHistory.ts (700 tokens)
               ├── useCommandTracker.ts (700 tokens)
            ├── terminal-global-styles.ts (500 tokens)
            ├── terminal-theme.ts (300 tokens)
            ├── terminal-types.ts (100 tokens)
         ├── tmux-monitor/
            ├── PanePreview.tsx (1200 tokens)
            ├── SearchResults.tsx (500 tokens)
            ├── SessionTree.tsx (4k tokens)
            ├── TmuxMonitor.tsx (9.2k tokens)
            ├── TmuxMonitorApp.tsx (100 tokens)
            ├── format.ts (100 tokens)
            ├── types.ts
         ├── tunnel/
            ├── TunnelApp.tsx (400 tokens)
            ├── TunnelInlineControls.tsx (900 tokens)
            ├── TunnelModeSelector.tsx (400 tokens)
            ├── TunnelTab.tsx (3.5k tokens)
            ├── tunnel-form-utils.test.ts (500 tokens)
            ├── tunnel-form-utils.ts (300 tokens)
      ├── hooks/
         ├── use-confirmation.ts (900 tokens)
         ├── use-mobile.test.ts (200 tokens)
         ├── use-mobile.ts (100 tokens)
         ├── use-service-worker.ts (500 tokens)
      ├── i18n/
         ├── i18n.ts (700 tokens)
      ├── index.css (2.9k tokens)
      ├── lib/
         ├── ServerStatusContext.tsx (1100 tokens)
         ├── SimpleLoader.tsx (300 tokens)
         ├── base-path.test.ts (200 tokens)
         ├── base-path.ts (100 tokens)
         ├── client-cache-version.ts (300 tokens)
         ├── clipboard-provider.ts (300 tokens)
         ├── clipboard.test.ts (500 tokens)
         ├── clipboard.ts (300 tokens)
         ├── db-health-monitor.ts (800 tokens)
         ├── electron.test.ts (200 tokens)
         ├── electron.ts (100 tokens)
         ├── frontend-logger.test.ts (500 tokens)
         ├── frontend-logger.ts (2.1k tokens)
         ├── splitDragging.ts (100 tokens)
         ├── terminal-syntax-highlighter.test.ts (1400 tokens)
         ├── terminal-syntax-highlighter.ts (1900 tokens)
         ├── terminal-themes.ts (4k tokens)
         ├── theme.ts (600 tokens)
         ├── utils.test.ts (100 tokens)
         ├── utils.ts
      ├── locales/
         ├── README.md
         ├── en.json (24k tokens)
         ├── translated/
            ├── af_ZA.json (21.4k tokens)
            ├── ar_SA.json (20k tokens)
            ├── bg_BG.json (22.9k tokens)
            ├── bn_BD.json (22k tokens)
            ├── ca_ES.json (22.8k tokens)
            ├── cs_CZ.json (21.3k tokens)
            ├── da_DK.json (21k tokens)
            ├── de_DE.json (22.4k tokens)
            ├── el_GR.json (23.1k tokens)
            ├── es_ES.json (22.2k tokens)
            ├── fi_FI.json (21.8k tokens)
            ├── fr_FR.json (23.1k tokens)
            ├── he_IL.json (19.2k tokens)
            ├── hi_IN.json (21.5k tokens)
            ├── hu_HU.json (22.5k tokens)
            ├── id_ID.json (21.4k tokens)
            ├── it_IT.json (22.1k tokens)
            ├── ja_JP.json (16.9k tokens)
            ├── ko_KR.json (16.8k tokens)
            ├── nl_NL.json (21.9k tokens)
            ├── no_NO.json (20.9k tokens)
            ├── pl_PL.json (21.7k tokens)
            ├── pt_BR.json (21.8k tokens)
            ├── pt_PT.json (21.8k tokens)
            ├── ro_RO.json (21.8k tokens)
            ├── ru_RU.json (21.7k tokens)
            ├── sr_SP.json (21.7k tokens)
            ├── sv_SE.json (21.2k tokens)
            ├── th_TH.json (20.9k tokens)
            ├── tr_TR.json (22.2k tokens)
            ├── uk_UA.json (21.8k tokens)
            ├── vi_VN.json (21.8k tokens)
            ├── zh_CN.json (14.9k tokens)
            ├── zh_TW.json (14.9k tokens)
      ├── main-axios.ts (11k tokens)
      ├── shell/
         ├── CommandPalette.tsx (4.7k tokens)
         ├── MobileBottomBar.tsx (1000 tokens)
         ├── SplitView.tsx (4.7k tokens)
         ├── Tab.tsx (2.7k tokens)
         ├── TabBar.tsx (4.1k tokens)
         ├── TabContext.tsx (2.2k tokens)
         ├── tabUtils.tsx (1500 tokens)
      ├── sidebar/
         ├── AdminApiKeysSection.tsx (1900 tokens)
         ├── AdminAuditLogSection.tsx (2.8k tokens)
         ├── AdminManagementSections.tsx (3.4k tokens)
         ├── AdminSettingsPanel.tsx (5.3k tokens)
         ├── AdminSettingsSections.tsx (5.5k tokens)
         ├── AdminSettingsShared.tsx (300 tokens)
         ├── AdminUserDialogs.tsx (4k tokens)
         ├── AppRail.tsx (2.2k tokens)
         ├── ConnectionsPanel.tsx (2.5k tokens)
         ├── CredentialEditorView.tsx (4.5k tokens)
         ├── CredentialsPanel.tsx (2.1k tokens)
         ├── FolderMetadataDialog.tsx (1000 tokens)
         ├── FolderPathPicker.test.ts (300 tokens)
         ├── FolderPathPicker.tsx (1500 tokens)
         ├── HistoryPanel.tsx (1200 tokens)
         ├── HostCredentialList.tsx (2.9k tokens)
         ├── HostEditor.tsx (14.2k tokens)
         ├── HostEditorData.test.ts (400 tokens)
         ├── HostEditorData.ts (2.5k tokens)
         ├── HostEditorFeatureTabs.tsx (1400 tokens)
         ├── HostEditorGeneralTab.tsx (6.4k tokens)
         ├── HostEditorGuacamoleTabs.tsx (11.3k tokens)
         ├── HostEditorStatsTab.tsx (1600 tokens)
         ├── HostManager.tsx (4.3k tokens)
         ├── HostManagerData.ts (900 tokens)
         ├── HostManagerTabs.tsx (900 tokens)
         ├── HostShareModal.tsx (2.7k tokens)
         ├── HostsPanel.tsx (7.3k tokens)
         ├── QuickConnectPanel.tsx (1600 tokens)
         ├── SSOProviderDialog.tsx (4.5k tokens)
         ├── SessionLogsPanel.tsx (3k tokens)
         ├── SidebarTree.tsx (13.2k tokens)
         ├── SnippetsPanel.tsx (10.1k tokens)
         ├── SplitScreenPanel.tsx (3k tokens)
         ├── SshToolsPanel.tsx (1700 tokens)
         ├── UserProfilePanel.tsx (15.2k tokens)
      ├── ssh/
         ├── connection-log/
            ├── ConnectionLog.tsx (1200 tokens)
            ├── ConnectionLogContext.tsx (500 tokens)
         ├── dialogs/
            ├── HostKeyVerificationDialog.tsx (1600 tokens)
            ├── OPKSSHDialog.tsx (1100 tokens)
            ├── PassphraseDialog.tsx (600 tokens)
            ├── SSHAuthDialog.tsx (2.2k tokens)
            ├── TOTPDialog.tsx (600 tokens)
            ├── TmuxSessionPicker.tsx (900 tokens)
            ├── WarpgateDialog.tsx (900 tokens)
      ├── user/
         ├── C2STunnelPresetManager.tsx (8.8k tokens)
         ├── ElectronVersionCheck.tsx (900 tokens)
         ├── LanguageSwitcher.tsx (600 tokens)
   ├── vite-env.d.ts (omitted)
├── tsconfig.app.json (200 tokens)
├── tsconfig.json (100 tokens)
├── tsconfig.node.json (200 tokens)
├── vendor/
   ├── rimraf-compat/
      ├── index.cjs (200 tokens)
      ├── package.json
├── vite.config.ts (600 tokens)
├── vitest.config.ts (300 tokens)
├── vitest.setup.ts (100 tokens)
```


## /.commitlintrc.json

```json path="/.commitlintrc.json" 
{
  "extends": ["@commitlint/config-conventional"],
  "rules": {
    "type-enum": [
      2,
      "always",
      [
        "feat",
        "fix",
        "docs",
        "style",
        "refactor",
        "perf",
        "test",
        "chore",
        "revert"
      ]
    ],
    "subject-case": [0]
  }
}

```

## /.dockerignore

```dockerignore path="/.dockerignore" 
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*

dist
build
.next
.nuxt

.env.local
.env.development.local
.env.test.local
.env.production.local

.vscode
.idea
*.swp
*.swo
*~

.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db

.git
.gitignore

README.md
CONTRIBUTING.md
LICENSE

repo-images/

uploads/

electron/
electron-builder.json

*.log
*.tmp
*.temp

logs
*.log

pids
*.pid
*.seed
*.pid.lock

coverage

.nyc_output

jspm_packages/

.npm

.eslintcache

.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/

.node_repl_history

*.tgz

.yarn-integrity

.cache
.parcel-cache

.next

.nuxt

.vuepress/dist

.serverless

.fusebox/

.dynamodb/

.tern-port

```

## /.github/ISSUE_TEMPLATE/config.yml

```yml path="/.github/ISSUE_TEMPLATE/config.yml" 
blank_issues_enabled: false
contact_links:
  - name: Support Center
    url: https://github.com/Termix-SSH/Support/issues
    about: Report any feature requests or bugs in the support center
  - name: Discord
    url: https://discord.gg/jVQGdvHDrf
    about: Official Termix Discord server for general discussion and quick support

```

## /.github/dependabot.yml

```yml path="/.github/dependabot.yml" 
version: 2
updates:
  # npm dependencies (single root package.json, no workspaces)
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
    open-pull-requests-limit: 15
    labels:
      - "dependencies"
      - "npm"
    commit-message:
      prefix: "chore"
      prefix-development: "chore"
      include: "scope"
    groups:
      dev-patch-updates:
        dependency-type: "development"
        update-types:
          - "patch"
      dev-minor-updates:
        dependency-type: "development"
        update-types:
          - "minor"
      prod-patch-updates:
        dependency-type: "production"
        update-types:
          - "patch"
      prod-minor-updates:
        dependency-type: "production"
        update-types:
          - "minor"
      # Major bumps grouped so they land as a single reviewable PR instead of
      # one-per-package noise. These often need manual follow-up (Electron,
      # React, Vite, Tailwind, Express 5, etc.).
      major-updates:
        update-types:
          - "major"

  # Docker base images (docker/Dockerfile + docker-compose / compose-dev)
  - package-ecosystem: "docker"
    directory: "/docker"
    schedule:
      interval: "weekly"
      day: "monday"
    open-pull-requests-limit: 10
    labels:
      - "dependencies"
      - "docker"
    commit-message:
      prefix: "chore"
      include: "scope"
    groups:
      docker-patch-updates:
        update-types:
          - "patch"
      docker-minor-updates:
        update-types:
          - "minor"
      docker-major-updates:
        update-types:
          - "major"

  # GitHub Actions used across the workflows in .github/workflows
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
      day: "monday"
    open-pull-requests-limit: 10
    labels:
      - "dependencies"
      - "github-actions"
    commit-message:
      prefix: "ci"
      include: "scope"
    groups:
      github-actions:
        update-types:
          - "patch"
          - "minor"
          - "major"

```

## /.github/pull_request_template.md

# Overview

_Short summary of what this PR does_

- [ ] Added: ...
- [ ] Updated: ...
- [ ] Removed: ...
- [ ] Fixed: ...

# Changes Made

_Detailed explanation of changes (if needed)_

- ...

# Related Issues

_Link any issues this PR addresses_

- Closes #ISSUE_NUMBER
- Related to #ISSUE_NUMBER

# Screenshots / Demos

_(Optional: add before/after screenshots, GIFs, or console output)_

# Checklist

- [ ] Code follows project style guidelines
- [ ] Supports mobile and desktop UI/app (if applicable)
- [ ] I have read [Contributing.md](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md)
- [ ] This is not a translation request. See [docs](https://docs.termix.site/translations)


## /.github/workflows/dependabot-retarget.yml

```yml path="/.github/workflows/dependabot-retarget.yml" 
name: Retarget and Merge Dependabot PRs

on:
  schedule:
    - cron: "0 6 * * *"
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

jobs:
  retarget-and-merge:
    runs-on: blacksmith-2vcpu-ubuntu-2404
    steps:
      - name: Checkout repository
        uses: actions/checkout@v5
        with:
          fetch-depth: 1

      - name: Resolve newest dev branch
        id: dev
        env:
          GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
        run: |
          REFS=$(gh api "repos/${{ github.repository }}/branches" --paginate -q '.[].name')
          # The helper exits non-zero when no dev-X.Y.Z branch exists; treat that
          # as "nothing to do" rather than a workflow failure.
          if DEV_BRANCH=$(printf '%s\n' "$REFS" | node scripts/latest-dev-branch.cjs 2>/dev/null); then
            echo "Newest dev branch: $DEV_BRANCH"
            echo "branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
          else
            echo "No dev-X.Y.Z branch open; nothing to retarget."
            echo "branch=" >> "$GITHUB_OUTPUT"
          fi

      - name: Retarget and merge Dependabot PRs
        if: ${{ steps.dev.outputs.branch != '' }}
        env:
          GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
          DEV_BRANCH: ${{ steps.dev.outputs.branch }}
          REPO: ${{ github.repository }}
        run: |
          set -uo pipefail

          CONFLICT_LABEL="dependabot-rebase-requested"

          # Ensure the bookkeeping label exists (no-op if it already does).
          gh label create "$CONFLICT_LABEL" --repo "$REPO" \
            --color "D93F0B" --description "Retarget workflow asked Dependabot to rebase a conflicting PR" \
            2>/dev/null || true

          # True if the PR already carries the conflict label.
          has_conflict_label() {
            gh pr view "$1" --repo "$REPO" --json labels \
              -q '.labels[].name' | grep -qx "$CONFLICT_LABEL"
          }

          # Wait until GitHub has a definite mergeable verdict for a PR (it
          # returns UNKNOWN while recomputing after a base change or a push).
          # Echoes "<mergeable> <mergeStateStatus>".
          wait_for_verdict() {
            local pr="$1" mergeable state
            for _ in $(seq 1 30); do
              read -r mergeable state < <(gh pr view "$pr" --repo "$REPO" \
                --json mergeable,mergeStateStatus \
                -q '.mergeable + " " + .mergeStateStatus')
              if [ "$mergeable" != "UNKNOWN" ] && [ "$state" != "UNKNOWN" ]; then
                echo "$mergeable $state"
                return 0
              fi
              sleep 20
            done
            echo "$mergeable $state"
          }

          # Phase 1: retarget every open Dependabot PR from main onto the dev
          # branch. This kicks off a Dependabot rebase for each.
          PR_NUMBERS=$(gh pr list --repo "$REPO" \
            --author "app/dependabot" \
            --base main \
            --state open \
            --json number -q '.[].number')

          # Pick up PRs already sitting on the dev branch from a previous run too.
          PR_NUMBERS="$PR_NUMBERS $(gh pr list --repo "$REPO" \
            --author "app/dependabot" \
            --base "$DEV_BRANCH" \
            --state open \
            --json number -q '.[].number')"

          PR_NUMBERS=$(printf '%s\n' $PR_NUMBERS | sort -un)

          if [ -z "$PR_NUMBERS" ]; then
            echo "No open Dependabot PRs to process."
            exit 0
          fi

          for PR in $PR_NUMBERS; do
            BASE=$(gh pr view "$PR" --repo "$REPO" --json baseRefName -q .baseRefName)
            if [ "$BASE" != "$DEV_BRANCH" ]; then
              echo "Retargeting PR #$PR ($BASE -> $DEV_BRANCH)"
              gh pr edit "$PR" --repo "$REPO" --base "$DEV_BRANCH"
            fi
          done

          # Phase 2: merge one at a time. Each merge can make the remaining npm
          # PRs stale, so re-check immediately before merging and rebase stragglers.
          for PR in $PR_NUMBERS; do
            echo "::group::PR #$PR"

            read -r MERGEABLE STATE < <(wait_for_verdict "$PR")
            echo "  mergeable=$MERGEABLE mergeStateStatus=$STATE"

            # BEHIND = clean but needs the latest base; ask Dependabot to rebase
            # and skip for now (next run merges it once it is up to date).
            if [ "$STATE" = "BEHIND" ]; then
              echo "  PR #$PR is behind $DEV_BRANCH; asking Dependabot to rebase."
              gh pr comment "$PR" --repo "$REPO" --body "@dependabot rebase"
              echo "::endgroup::"
              continue
            fi

            # DIRTY / CONFLICTING = a real conflict. Try a rebase once (label it so
            # we can tell next time); if it is STILL conflicting on a later run
            # despite already being labelled, the rebase failed for good - close it
            # so Dependabot reopens a fresh PR against the current dev branch.
            if [ "$MERGEABLE" = "CONFLICTING" ] || [ "$STATE" = "DIRTY" ]; then
              if has_conflict_label "$PR"; then
                echo "  PR #$PR still conflicts after a prior rebase request; closing so Dependabot reopens it fresh."
                gh pr close "$PR" --repo "$REPO" --delete-branch \
                  --comment "Closing: this PR still conflicts with $DEV_BRANCH after a rebase attempt (its changes are likely already merged). Dependabot will reopen a fresh PR computed against the current $DEV_BRANCH."
              else
                echo "  PR #$PR conflicts with $DEV_BRANCH; requesting a rebase and labelling it."
                gh pr edit "$PR" --repo "$REPO" --add-label "$CONFLICT_LABEL"
                gh pr comment "$PR" --repo "$REPO" --body "@dependabot rebase"
              fi
              echo "::endgroup::"
              continue
            fi

            # A clean PR that was previously flagged has recovered - drop the label.
            if has_conflict_label "$PR"; then
              gh pr edit "$PR" --repo "$REPO" --remove-label "$CONFLICT_LABEL" || true
            fi

            echo "  Squash-merging PR #$PR"
            if gh pr merge "$PR" --repo "$REPO" --squash --admin; then
              echo "  Merged PR #$PR"
              # Give GitHub a moment to mark the now-stale siblings BEHIND.
              sleep 15
            else
              echo "  Could not merge PR #$PR now; it will be retried next run."
            fi

            echo "::endgroup::"
          done

```

## /.github/workflows/docker.yml

```yml path="/.github/workflows/docker.yml" 
name: Build and Push Docker Image

on:
  workflow_dispatch:
    inputs:
      version:
        description: "Version to build (e.g., 1.8.0)"
        required: true
      build_type:
        description: "Build type"
        required: true
        default: "Development"
        type: choice
        options:
          - Development
          - Production
  workflow_call:
    inputs:
      version:
        description: "Version to build (e.g., 1.8.0)"
        required: true
        type: string
      build_type:
        description: "Build type (Development or Production)"
        required: true
        type: string
      dry_run:
        description: "Build the image but do not push to any registry"
        required: false
        type: boolean
        default: false

jobs:
  build:
    runs-on: blacksmith-8vcpu-ubuntu-2404
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v4
        with:
          platforms: linux/amd64,linux/arm64

      - name: Setup Docker Buildx
        uses: useblacksmith/setup-docker-builder@v1

      - name: Determine tags
        id: tags
        run: |
          VERSION=${{ inputs.version }}
          BUILD_TYPE=${{ inputs.build_type }}

          TAGS=()
          ALL_TAGS=()

          if [ "$BUILD_TYPE" = "Production" ]; then
            TAGS+=("release-$VERSION" "$VERSION" "latest")
            for tag in "${TAGS[@]}"; do
              ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
              ALL_TAGS+=("docker.io/bugattiguy527/termix:$tag")
            done
          else
            TAGS+=("dev-$VERSION")
            for tag in "${TAGS[@]}"; do
              ALL_TAGS+=("ghcr.io/lukegus/termix:$tag")
            done
          fi

          echo "ALL_TAGS=$(IFS=,; echo "${ALL_TAGS[*]}")" >> $GITHUB_ENV

      - name: Login to GHCR
        if: ${{ !inputs.dry_run }}
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: lukegus
          password: ${{ secrets.GHCR_TOKEN }}

      - name: Login to Docker Hub (prod only)
        if: ${{ inputs.build_type == 'Production' && !inputs.dry_run }}
        uses: docker/login-action@v4
        with:
          username: bugattiguy527
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build and push multi-arch image
        uses: useblacksmith/build-push-action@v2
        with:
          context: .
          file: ./docker/Dockerfile
          push: ${{ !inputs.dry_run }}
          platforms: linux/amd64,linux/arm64
          tags: ${{ env.ALL_TAGS }}
          build-args: |
            BUILDKIT_CONTEXT_KEEP_GIT_DIR=1
          labels: |
            org.opencontainers.image.source=https://github.com/${{ github.repository }}
            org.opencontainers.image.revision=${{ github.sha }}
            org.opencontainers.image.created=${{ github.run_id }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          outputs: ${{ inputs.dry_run && 'type=cacheonly' || 'type=registry,compression=zstd' }}

      - name: Cleanup Docker
        if: always()
        run: |
          docker image prune -af
          docker system prune -af --volumes

```

## /.github/workflows/electron.yml

```yml path="/.github/workflows/electron.yml" 
name: Build and Push Electron App

on:
  workflow_dispatch:
    inputs:
      build_type:
        description: "Platform to build for"
        required: true
        default: "all"
        type: choice
        options:
          - all
          - windows
          - linux
          - macos
      artifact_destination:
        description: "What to do with the built app"
        required: true
        default: "file"
        type: choice
        options:
          - none
          - file
          - release
          - submit
  workflow_call:
    inputs:
      build_type:
        description: "Platform to build for (all, windows, linux, macos)"
        required: true
        type: string
      artifact_destination:
        description: "What to do with the built app (none, file, release, submit)"
        required: true
        type: string
      release_tag:
        description: "Explicit release tag to upload assets to (defaults to latest release when empty)"
        required: false
        type: string
        default: ""
    outputs:
      macos_universal_dmg_sha256:
        description: "SHA256 of the universal macOS DMG (for Homebrew cask)"
        value: ${{ jobs.build-macos.outputs.dmg_sha256 }}

jobs:
  build-windows:
    runs-on: blacksmith-4vcpu-windows-2025
    if: (inputs.build_type == 'all' || inputs.build_type == 'windows' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
    permissions:
      contents: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: ".nvmrc"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Get version
        id: package-version
        run: |
          $VERSION = (Get-Content package.json | ConvertFrom-Json).version
          echo "version=$VERSION" >> $env:GITHUB_OUTPUT

      - name: Build Windows (All Architectures)
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: npm run build && npx electron-builder --win --x64 --ia32

      - name: Upload Windows x64 NSIS Installer
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_windows_x64_nsis.exe') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_windows_x64_nsis
          path: release/termix_windows_x64_nsis.exe
          retention-days: 30

      - name: Upload Windows ia32 NSIS Installer
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_windows_ia32_nsis.exe') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_windows_ia32_nsis
          path: release/termix_windows_ia32_nsis.exe
          retention-days: 30

      - name: Upload Windows x64 MSI Installer
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_windows_x64_msi.msi') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_windows_x64_msi
          path: release/termix_windows_x64_msi.msi
          retention-days: 30

      - name: Upload Windows ia32 MSI Installer
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_windows_ia32_msi.msi') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_windows_ia32_msi
          path: release/termix_windows_ia32_msi.msi
          retention-days: 30

      - name: Create Windows x64 Portable zip
        if: hashFiles('release/win-unpacked/*') != ''
        run: |
          Compress-Archive -Path "release\win-unpacked\*" -DestinationPath "termix_windows_x64_portable.zip"

      - name: Create Windows ia32 Portable zip
        if: hashFiles('release/win-ia32-unpacked/*') != ''
        run: |
          Compress-Archive -Path "release\win-ia32-unpacked\*" -DestinationPath "termix_windows_ia32_portable.zip"

      - name: Upload Windows x64 Portable
        uses: actions/upload-artifact@v7
        if: hashFiles('termix_windows_x64_portable.zip') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_windows_x64_portable
          path: termix_windows_x64_portable.zip
          retention-days: 30

      - name: Upload Windows ia32 Portable
        uses: actions/upload-artifact@v7
        if: hashFiles('termix_windows_ia32_portable.zip') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_windows_ia32_portable
          path: termix_windows_ia32_portable.zip
          retention-days: 30

  build-linux:
    runs-on: blacksmith-8vcpu-ubuntu-2404
    if: (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '') && inputs.artifact_destination != 'submit'
    permissions:
      contents: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: ".nvmrc"
          cache: "npm"

      - name: Install system dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y libfuse2 flatpak flatpak-builder imagemagick

      - name: Install dependencies
        run: |
          npm ci
          npm install --force @rollup/rollup-linux-x64-gnu
          npm install --force @rollup/rollup-linux-arm64-gnu
          npm install --force @rollup/rollup-linux-arm-gnueabihf

      - name: Build Linux x64
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          DEBUG: electron-builder
        run: npm run build && npx electron-builder --linux --x64

      - name: Build Linux arm64 and armv7l
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: npx electron-builder --linux --arm64 --armv7l

      - name: Rename Linux artifacts for consistency
        run: |
          cd release

          if [ -f "termix_linux_amd64_deb.deb" ]; then
            mv "termix_linux_amd64_deb.deb" "termix_linux_x64_deb.deb"
          fi

          if [ -f "termix_linux_x86_64_appimage.AppImage" ]; then
            mv "termix_linux_x86_64_appimage.AppImage" "termix_linux_x64_appimage.AppImage"
          fi

          cd ..

      - name: Upload Linux x64 AppImage
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_x64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_x64_appimage
          path: release/termix_linux_x64_appimage.AppImage
          retention-days: 30

      - name: Upload Linux arm64 AppImage
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_arm64_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_arm64_appimage
          path: release/termix_linux_arm64_appimage.AppImage
          retention-days: 30

      - name: Upload Linux armv7l AppImage
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_armv7l_appimage.AppImage') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_armv7l_appimage
          path: release/termix_linux_armv7l_appimage.AppImage
          retention-days: 30

      - name: Upload Linux x64 DEB
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_x64_deb.deb') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_x64_deb
          path: release/termix_linux_x64_deb.deb
          retention-days: 30

      - name: Upload Linux arm64 DEB
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_arm64_deb.deb') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_arm64_deb
          path: release/termix_linux_arm64_deb.deb
          retention-days: 30

      - name: Upload Linux armv7l DEB
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_armv7l_deb.deb') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_armv7l_deb
          path: release/termix_linux_armv7l_deb.deb
          retention-days: 30

      - name: Upload Linux x64 tar.gz
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_x64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_x64_portable
          path: release/termix_linux_x64_portable.tar.gz
          retention-days: 30

      - name: Upload Linux arm64 tar.gz
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_arm64_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_arm64_portable
          path: release/termix_linux_arm64_portable.tar.gz
          retention-days: 30

      - name: Upload Linux armv7l tar.gz
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_armv7l_portable.tar.gz') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_armv7l_portable
          path: release/termix_linux_armv7l_portable.tar.gz
          retention-days: 30

      - name: Add Flathub repository
        run: |
          sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo

      - name: Install Flatpak runtime and SDK
        run: |
          sudo flatpak install -y flathub org.freedesktop.Platform//24.08
          sudo flatpak install -y flathub org.freedesktop.Sdk//24.08
          sudo flatpak install -y flathub org.electronjs.Electron2.BaseApp//24.08

      - name: Get version for Flatpak
        id: flatpak-version
        run: |
          VERSION=$(node -p "require('./package.json').version")
          RELEASE_DATE=$(date +%Y-%m-%d)
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT

      - name: Prepare Flatpak files
        run: |
          VERSION="${{ steps.flatpak-version.outputs.version }}"
          RELEASE_DATE="${{ steps.flatpak-version.outputs.release_date }}"

          CHECKSUM_X64=$(sha256sum "release/termix_linux_x64_appimage.AppImage" | awk '{print $1}')
          CHECKSUM_ARM64=$(sha256sum "release/termix_linux_arm64_appimage.AppImage" | awk '{print $1}')

          mkdir -p flatpak-build
          cp flatpak/com.karmaa.termix.yml flatpak-build/
          cp flatpak/com.karmaa.termix.desktop flatpak-build/
          cp flatpak/com.karmaa.termix.metainfo.xml flatpak-build/
          cp public/icon.svg flatpak-build/com.karmaa.termix.svg
          convert public/icon.png -resize 256x256 flatpak-build/icon-256.png
          convert public/icon.png -resize 128x128 flatpak-build/icon-128.png

          cd flatpak-build
          sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_x64_appimage.AppImage|file://$(realpath ../release/termix_linux_x64_appimage.AppImage)|g" com.karmaa.termix.yml
          sed -i "s|https://github.com/Termix-SSH/Termix/releases/download/release-VERSION_PLACEHOLDER-tag/termix_linux_arm64_appimage.AppImage|file://$(realpath ../release/termix_linux_arm64_appimage.AppImage)|g" com.karmaa.termix.yml
          sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" com.karmaa.termix.yml
          sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" com.karmaa.termix.yml
          sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" com.karmaa.termix.metainfo.xml
          sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" com.karmaa.termix.metainfo.xml

      - name: Build Flatpak bundle
        run: |
          cd flatpak-build
          flatpak-builder --repo=repo --force-clean --disable-rofiles-fuse build-dir com.karmaa.termix.yml

          ARCH=$(uname -m)
          if [ "$ARCH" = "x86_64" ]; then
            FLATPAK_ARCH="x86_64"
          elif [ "$ARCH" = "aarch64" ]; then
            FLATPAK_ARCH="aarch64"
          else
            FLATPAK_ARCH="$ARCH"
          fi

          flatpak build-bundle repo ../release/termix_linux_flatpak.flatpak com.karmaa.termix --runtime-repo=https://flathub.org/repo/flathub.flatpakrepo

      - name: Create flatpakref file
        run: |
          VERSION="${{ steps.flatpak-version.outputs.version }}"
          cp flatpak/com.karmaa.termix.flatpakref release/
          sed -i "s|VERSION_PLACEHOLDER|release-${VERSION}-tag|g" release/com.karmaa.termix.flatpakref

      - name: Upload Flatpak bundle
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_linux_flatpak.flatpak') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_flatpak
          path: release/termix_linux_flatpak.flatpak
          retention-days: 30

      - name: Upload Flatpakref
        uses: actions/upload-artifact@v7
        if: hashFiles('release/com.karmaa.termix.flatpakref') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_linux_flatpakref
          path: release/com.karmaa.termix.flatpakref
          retention-days: 30

  build-macos:
    runs-on: blacksmith-6vcpu-macos-latest
    if: (inputs.build_type == 'macos' || inputs.build_type == 'all') && inputs.artifact_destination != 'submit'
    needs: []
    permissions:
      contents: write
    outputs:
      dmg_sha256: ${{ steps.dmg-checksum.outputs.sha256 }}

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: ".nvmrc"
          cache: "npm"

      - name: Install dependencies
        run: |
          npm ci
          npm install --force @rollup/rollup-darwin-arm64
          npm install dmg-license

      - name: Check for Code Signing Certificates
        id: check_certs
        run: |
          if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then
            echo "has_certs=true" >> $GITHUB_OUTPUT
          fi

      - name: Import Code Signing Certificates
        if: steps.check_certs.outputs.has_certs == 'true'
        env:
          MAC_BUILD_CERTIFICATE_BASE64: ${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}
          MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_CERTIFICATE_BASE64 }}
          MAC_P12_PASSWORD: ${{ secrets.MAC_P12_PASSWORD }}
          MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}
        run: |
          APP_CERT_PATH=$RUNNER_TEMP/app_certificate.p12
          INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH

          if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then
            echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH
          fi

          security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH

          if [ -f "$INSTALLER_CERT_PATH" ]; then
            security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          fi

          security list-keychain -d user -s $KEYCHAIN_PATH

          security find-identity -v -p codesigning $KEYCHAIN_PATH

      - name: Build macOS App Store Package
        if: steps.check_certs.outputs.has_certs == 'true'
        env:
          ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NODE_OPTIONS: --max-old-space-size=4096
        run: |
          CURRENT_VERSION=$(node -p "require('./package.json').version")
          BUILD_VERSION="${{ github.run_number }}"

          npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"

      - name: Clean up MAS keychain before DMG build
        if: steps.check_certs.outputs.has_certs == 'true'
        run: |
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true

      - name: Check for Developer ID Certificates
        id: check_dev_id_certs
        run: |
          if [ -n "${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.DEVELOPER_ID_P12_PASSWORD }}" ]; then
            echo "has_dev_id_certs=true" >> $GITHUB_OUTPUT
          fi

      - name: Import Developer ID Certificates
        if: steps.check_dev_id_certs.outputs.has_dev_id_certs == 'true'
        env:
          DEVELOPER_ID_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_CERTIFICATE_BASE64 }}
          DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64 }}
          DEVELOPER_ID_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_P12_PASSWORD }}
          MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}
        run: |
          DEV_CERT_PATH=$RUNNER_TEMP/dev_certificate.p12
          DEV_INSTALLER_CERT_PATH=$RUNNER_TEMP/dev_installer_certificate.p12
          KEYCHAIN_PATH=$RUNNER_TEMP/dev-signing.keychain-db

          echo -n "$DEVELOPER_ID_CERTIFICATE_BASE64" | base64 --decode -o $DEV_CERT_PATH

          if [ -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" ]; then
            echo -n "$DEVELOPER_ID_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $DEV_INSTALLER_CERT_PATH
          fi

          security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          security import $DEV_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH

          if [ -f "$DEV_INSTALLER_CERT_PATH" ]; then
            security import $DEV_INSTALLER_CERT_PATH -P "$DEVELOPER_ID_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          fi

          security list-keychain -d user -s $KEYCHAIN_PATH

          security find-identity -v -p codesigning $KEYCHAIN_PATH

      - name: Build macOS DMG
        env:
          ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
          APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
          NODE_OPTIONS: --max-old-space-size=4096
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          if [ "${{ steps.check_certs.outputs.has_certs }}" != "true" ]; then
            npm run build
          fi
          npx electron-builder --mac dmg --universal --x64 --arm64 --publish never

      - name: Upload macOS MAS PKG
        if: steps.check_certs.outputs.has_certs == 'true' && hashFiles('release/termix_macos_universal_mas.pkg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release' || inputs.artifact_destination == 'submit')
        uses: actions/upload-artifact@v7
        with:
          name: termix_macos_universal_mas
          path: release/termix_macos_universal_mas.pkg
          retention-days: 30
          if-no-files-found: warn

      - name: Upload macOS Universal DMG
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_macos_universal_dmg
          path: release/termix_macos_universal_dmg.dmg
          retention-days: 30

      - name: Upload macOS x64 DMG
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_macos_x64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_macos_x64_dmg
          path: release/termix_macos_x64_dmg.dmg
          retention-days: 30

      - name: Upload macOS arm64 DMG
        uses: actions/upload-artifact@v7
        if: hashFiles('release/termix_macos_arm64_dmg.dmg') != '' && inputs.artifact_destination != 'none'
        with:
          name: termix_macos_arm64_dmg
          path: release/termix_macos_arm64_dmg.dmg
          retention-days: 30

      - name: Get version for Homebrew
        id: homebrew-version
        run: |
          VERSION=$(node -p "require('./package.json').version")
          echo "version=$VERSION" >> $GITHUB_OUTPUT

      - name: Compute universal DMG checksum
        id: dmg-checksum
        if: hashFiles('release/termix_macos_universal_dmg.dmg') != ''
        run: |
          CHECKSUM=$(shasum -a 256 "release/termix_macos_universal_dmg.dmg" | awk '{print $1}')
          echo "sha256=$CHECKSUM" >> $GITHUB_OUTPUT

      - name: Generate Homebrew Cask
        if: hashFiles('release/termix_macos_universal_dmg.dmg') != '' && (inputs.artifact_destination == 'file' || inputs.artifact_destination == 'release')
        run: |
          VERSION="${{ steps.homebrew-version.outputs.version }}"
          DMG_PATH="release/termix_macos_universal_dmg.dmg"

          CHECKSUM=$(shasum -a 256 "$DMG_PATH" | awk '{print $1}')

          mkdir -p homebrew-generated
          cp Casks/termix.rb homebrew-generated/termix.rb

          sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-generated/termix.rb
          sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-generated/termix.rb
          sed -i '' "s|version \".*\"|version \"$VERSION\"|g" homebrew-generated/termix.rb
          sed -i '' "s|sha256 \".*\"|sha256 \"$CHECKSUM\"|g" homebrew-generated/termix.rb
          sed -i '' "s|release-[0-9.]*-tag|release-$VERSION-tag|g" homebrew-generated/termix.rb

      - name: Upload Homebrew Cask as artifact
        uses: actions/upload-artifact@v7
        if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.artifact_destination == 'file'
        with:
          name: termix_macos_homebrew_cask
          path: homebrew-generated/termix.rb
          retention-days: 30

      - name: Upload Homebrew Cask to release
        if: hashFiles('homebrew-generated/termix.rb') != '' && inputs.artifact_destination == 'release'
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          VERSION="${{ steps.homebrew-version.outputs.version }}"
          RELEASE_TAG="release-$VERSION-tag"

          gh release list --repo ${{ github.repository }} --limit 100 | grep -q "$RELEASE_TAG" || {
            echo "Release $RELEASE_TAG not found"
            exit 1
          }

          gh release upload "$RELEASE_TAG" homebrew-generated/termix.rb --repo ${{ github.repository }} --clobber

      - name: Clean up keychains
        if: always()
        run: |
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
          security delete-keychain $RUNNER_TEMP/dev-signing.keychain-db || true

  submit-to-chocolatey:
    runs-on: blacksmith-4vcpu-windows-2025
    if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'windows' || inputs.build_type == '')
    permissions:
      contents: read

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Get version from package.json
        id: package-version
        run: |
          $VERSION = (Get-Content package.json | ConvertFrom-Json).version
          echo "version=$VERSION" >> $env:GITHUB_OUTPUT

      - name: Download and prepare MSI info from public release
        id: msi-info
        run: |
          $VERSION = "${{ steps.package-version.outputs.version }}"
          $MSI_NAME = "termix_windows_x64_msi.msi"
          $DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$($VERSION)-tag/$($MSI_NAME)"

          Write-Host "Downloading from $DOWNLOAD_URL"
          New-Item -ItemType Directory -Force -Path "release_asset"
          $DOWNLOAD_PATH = "release_asset\$MSI_NAME"

          try {
            Invoke-WebRequest -Uri $DOWNLOAD_URL -OutFile $DOWNLOAD_PATH -UseBasicParsing
          } catch {
            Write-Error "Failed to download MSI from $DOWNLOAD_URL. Please ensure the release and asset exist."
            exit 1
          }

          $CHECKSUM = (Get-FileHash -Path $DOWNLOAD_PATH -Algorithm SHA256).Hash
          echo "msi_name=$MSI_NAME" >> $env:GITHUB_OUTPUT
          echo "checksum=$CHECKSUM" >> $env:GITHUB_OUTPUT

      - name: Prepare Chocolatey package
        run: |
          $VERSION = "${{ steps.package-version.outputs.version }}"
          $CHECKSUM = "${{ steps.msi-info.outputs.checksum }}"
          $MSI_NAME = "${{ steps.msi-info.outputs.msi_name }}"

          $DOWNLOAD_URL = "https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$MSI_NAME"

          New-Item -ItemType Directory -Force -Path "choco-build"
          Copy-Item -Path "chocolatey\*" -Destination "choco-build" -Recurse -Force

          $installScript = Get-Content "choco-build\tools\chocolateyinstall.ps1" -Raw -Encoding UTF8
          $installScript = $installScript -replace 'DOWNLOAD_URL_PLACEHOLDER', $DOWNLOAD_URL
          $installScript = $installScript -replace 'CHECKSUM_PLACEHOLDER', $CHECKSUM
          [System.IO.File]::WriteAllText("$PWD\choco-build\tools\chocolateyinstall.ps1", $installScript, [System.Text.UTF8Encoding]::new($false))

          $nuspec = Get-Content "choco-build\termix-ssh.nuspec" -Raw -Encoding UTF8
          $nuspec = $nuspec -replace 'VERSION_PLACEHOLDER', $VERSION
          [System.IO.File]::WriteAllText("$PWD\choco-build\termix-ssh.nuspec", $nuspec, [System.Text.UTF8Encoding]::new($false))

      - name: Install Chocolatey
        run: |
          Set-ExecutionPolicy Bypass -Scope Process -Force
          [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
          iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))

      - name: Pack Chocolatey package
        run: |
          cd choco-build
          choco pack termix-ssh.nuspec

          if ($LASTEXITCODE -ne 0) {
            throw "Chocolatey push failed with exit code $LASTEXITCODE"
          }

      - name: Check for Chocolatey API Key
        id: check_choco_key
        run: |
          if ("${{ secrets.CHOCOLATEY_API_KEY }}" -ne "") {
            echo "has_key=true" >> $env:GITHUB_OUTPUT
          }

      - name: Push to Chocolatey
        if: steps.check_choco_key.outputs.has_key == 'true'
        run: |
          $VERSION = "${{ steps.package-version.outputs.version }}"
          cd choco-build
          choco apikey --key "${{ secrets.CHOCOLATEY_API_KEY }}" --source https://push.chocolatey.org/

          try {
            choco push "termix-ssh.$VERSION.nupkg" --source https://push.chocolatey.org/
            if ($LASTEXITCODE -eq 0) {
            } else {
              throw "Chocolatey push failed with exit code $LASTEXITCODE"
            }
          } catch {
          }

      - name: Upload Chocolatey package as artifact
        uses: actions/upload-artifact@v7
        with:
          name: chocolatey-package
          path: choco-build/*.nupkg
          retention-days: 30

  submit-to-flatpak:
    runs-on: blacksmith-8vcpu-ubuntu-2404
    if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'linux' || inputs.build_type == '')
    needs: []
    permissions:
      contents: read

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Get version from package.json
        id: package-version
        run: |
          VERSION=$(node -p "require('./package.json').version")
          RELEASE_DATE=$(date +%Y-%m-%d)
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "release_date=$RELEASE_DATE" >> $GITHUB_OUTPUT

      - name: Download and prepare AppImage info from public release
        id: appimage-info
        run: |
          VERSION="${{ steps.package-version.outputs.version }}"
          mkdir -p release_assets

          APPIMAGE_X64_NAME="termix_linux_x64_appimage.AppImage"
          URL_X64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_X64_NAME"
          PATH_X64="release_assets/$APPIMAGE_X64_NAME"
          echo "Downloading x64 AppImage from $URL_X64"
          curl -L -o "$PATH_X64" "$URL_X64"
          chmod +x "$PATH_X64"
          CHECKSUM_X64=$(sha256sum "$PATH_X64" | awk '{print $1}')

          APPIMAGE_ARM64_NAME="termix_linux_arm64_appimage.AppImage"
          URL_ARM64="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$APPIMAGE_ARM64_NAME"
          PATH_ARM64="release_assets/$APPIMAGE_ARM64_NAME"
          echo "Downloading arm64 AppImage from $URL_ARM64"
          curl -L -o "$PATH_ARM64" "$URL_ARM64"
          chmod +x "$PATH_ARM64"
          CHECKSUM_ARM64=$(sha256sum "$PATH_ARM64" | awk '{print $1}')

          echo "appimage_x64_name=$APPIMAGE_X64_NAME" >> $GITHUB_OUTPUT
          echo "checksum_x64=$CHECKSUM_X64" >> $GITHUB_OUTPUT
          echo "appimage_arm64_name=$APPIMAGE_ARM64_NAME" >> $GITHUB_OUTPUT
          echo "checksum_arm64=$CHECKSUM_ARM64" >> $GITHUB_OUTPUT

      - name: Install dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y imagemagick

      - name: Prepare Flatpak submission files
        run: |
          VERSION="${{ steps.package-version.outputs.version }}"
          CHECKSUM_X64="${{ steps.appimage-info.outputs.checksum_x64 }}"
          CHECKSUM_ARM64="${{ steps.appimage-info.outputs.checksum_arm64 }}"
          RELEASE_DATE="${{ steps.package-version.outputs.release_date }}"
          APPIMAGE_X64_NAME="${{ steps.appimage-info.outputs.appimage_x64_name }}"
          APPIMAGE_ARM64_NAME="${{ steps.appimage-info.outputs.appimage_arm64_name }}"

          mkdir -p flatpak-submission

          cp flatpak/com.karmaa.termix.yml flatpak-submission/
          cp flatpak/com.karmaa.termix.desktop flatpak-submission/
          cp flatpak/com.karmaa.termix.metainfo.xml flatpak-submission/
          cp flatpak/flathub.json flatpak-submission/

          cp public/icon.svg flatpak-submission/com.karmaa.termix.svg
          convert public/icon.png -resize 256x256 flatpak-submission/icon-256.png
          convert public/icon.png -resize 128x128 flatpak-submission/icon-128.png

          sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.yml
          sed -i "s/CHECKSUM_X64_PLACEHOLDER/$CHECKSUM_X64/g" flatpak-submission/com.karmaa.termix.yml
          sed -i "s/CHECKSUM_ARM64_PLACEHOLDER/$CHECKSUM_ARM64/g" flatpak-submission/com.karmaa.termix.yml

          sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" flatpak-submission/com.karmaa.termix.metainfo.xml
          sed -i "s/DATE_PLACEHOLDER/$RELEASE_DATE/g" flatpak-submission/com.karmaa.termix.metainfo.xml

      - name: Upload Flatpak submission as artifact
        uses: actions/upload-artifact@v7
        with:
          name: flatpak-submission
          path: flatpak-submission/*
          retention-days: 30

  submit-to-homebrew:
    runs-on: blacksmith-6vcpu-macos-latest
    if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
    needs: []
    permissions:
      contents: read

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Get version from package.json
        id: package-version
        run: |
          VERSION=$(node -p "require('./package.json').version")
          echo "version=$VERSION" >> $GITHUB_OUTPUT

      - name: Download and prepare DMG info from public release
        id: dmg-info
        run: |
          VERSION="${{ steps.package-version.outputs.version }}"
          DMG_NAME="termix_macos_universal_dmg.dmg"
          URL="https://github.com/Termix-SSH/Termix/releases/download/release-$VERSION-tag/$DMG_NAME"

          mkdir -p release_asset
          DOWNLOAD_PATH="release_asset/$DMG_NAME"
          echo "Downloading DMG from $URL"

          if command -v curl &> /dev/null; then
            curl -L -o "$DOWNLOAD_PATH" "$URL"
          elif command -v wget &> /dev/null; then
            wget -O "$DOWNLOAD_PATH" "$URL"
          else
            echo "Neither curl nor wget is available, installing curl"
            brew install curl
            curl -L -o "$DOWNLOAD_PATH" "$URL"
          fi

          CHECKSUM=$(shasum -a 256 "$DOWNLOAD_PATH" | awk '{print $1}')

          echo "dmg_name=$DMG_NAME" >> $GITHUB_OUTPUT
          echo "checksum=$CHECKSUM" >> $GITHUB_OUTPUT

      - name: Prepare Homebrew submission files
        run: |
          VERSION="${{ steps.package-version.outputs.version }}"
          CHECKSUM="${{ steps.dmg-info.outputs.checksum }}"
          DMG_NAME="${{ steps.dmg-info.outputs.dmg_name }}"

          mkdir -p homebrew-submission/Casks/t

          cp Casks/termix.rb homebrew-submission/Casks/t/termix.rb

          sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb
          sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb

      - name: Verify Cask syntax
        run: |
          ruby -c homebrew-submission/Casks/t/termix.rb

      - name: Upload Homebrew submission as artifact
        uses: actions/upload-artifact@v7
        with:
          name: homebrew-submission
          path: homebrew-submission/*
          retention-days: 30

  upload-to-release:
    runs-on: blacksmith-8vcpu-ubuntu-2404
    if: inputs.artifact_destination == 'release'
    needs: [build-windows, build-linux, build-macos]
    permissions:
      contents: write

    steps:
      - name: Download all artifacts
        uses: actions/download-artifact@v8
        with:
          path: artifacts

      - name: Resolve release tag
        id: get_release
        env:
          GH_TOKEN: ${{ github.token }}
          INPUT_TAG: ${{ inputs.release_tag }}
        run: |
          if [ -n "$INPUT_TAG" ]; then
            echo "RELEASE_TAG=$INPUT_TAG" >> $GITHUB_ENV
          else
            echo "RELEASE_TAG=$(gh release list --repo ${{ github.repository }} --limit 1 --json tagName -q '.[0].tagName')" >> $GITHUB_ENV
          fi

      - name: Upload artifacts to latest release
        run: |
          cd artifacts
          for dir in */; do
            cd "$dir"
            for file in *;
            do
              if [ -f "$file" ]; then
                gh release upload "$RELEASE_TAG" "$file" --repo ${{ github.repository }} --clobber
              fi
            done
            cd ..
          done
        env:
          GH_TOKEN: ${{ github.token }}

  submit-to-app-store:
    runs-on: blacksmith-6vcpu-macos-latest
    if: inputs.artifact_destination == 'submit' && (inputs.build_type == 'all' || inputs.build_type == 'macos')
    needs: []
    permissions:
      contents: write

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: ".nvmrc"
          cache: "npm"

      - name: Install dependencies
        run: |
          npm ci
          npm install --force @rollup/rollup-darwin-arm64
          npm install dmg-license

      - name: Check for Code Signing Certificates
        id: check_certs
        run: |
          if [ -n "${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}" ] && [ -n "${{ secrets.MAC_P12_PASSWORD }}" ]; then
            echo "has_certs=true" >> $GITHUB_OUTPUT
          fi

      - name: Import Code Signing Certificates
        if: steps.check_certs.outputs.has_certs == 'true'
        env:
          MAC_BUILD_CERTIFICATE_BASE64: ${{ secrets.MAC_BUILD_CERTIFICATE_BASE64 }}
          MAC_INSTALLER_CERTIFICATE_BASE64: ${{ secrets.MAC_INSTALLER_CERTIFICATE_BASE64 }}
          MAC_P12_PASSWORD: ${{ secrets.MAC_P12_PASSWORD }}
          MAC_KEYCHAIN_PASSWORD: ${{ secrets.MAC_KEYCHAIN_PASSWORD }}
        run: |
          APP_CERT_PATH=$RUNNER_TEMP/app_certificate.p12
          INSTALLER_CERT_PATH=$RUNNER_TEMP/installer_certificate.p12
          KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db

          echo -n "$MAC_BUILD_CERTIFICATE_BASE64" | base64 --decode -o $APP_CERT_PATH

          if [ -n "$MAC_INSTALLER_CERTIFICATE_BASE64" ]; then
            echo -n "$MAC_INSTALLER_CERTIFICATE_BASE64" | base64 --decode -o $INSTALLER_CERT_PATH
          fi

          security create-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
          security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
          security unlock-keychain -p "$MAC_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH

          security import $APP_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH

          if [ -f "$INSTALLER_CERT_PATH" ]; then
            security import $INSTALLER_CERT_PATH -P "$MAC_P12_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
          fi

          security list-keychain -d user -s $KEYCHAIN_PATH

          security find-identity -v -p codesigning $KEYCHAIN_PATH

      - name: Build macOS App Store Package
        if: steps.check_certs.outputs.has_certs == 'true'
        env:
          ELECTRON_BUILDER_ALLOW_UNRESOLVED_DEPENDENCIES: true
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NODE_OPTIONS: --max-old-space-size=4096
        run: |
          BUILD_VERSION="${{ github.run_number }}"
          npm run build && npx electron-builder --mac mas --universal --config.buildVersion="$BUILD_VERSION"

      - name: Check for App Store Connect API credentials
        id: check_asc_creds
        run: |
          if [ -n "${{ secrets.APPLE_KEY_ID }}" ] && [ -n "${{ secrets.APPLE_ISSUER_ID }}" ] && [ -n "${{ secrets.APPLE_KEY_CONTENT }}" ]; then
            echo "has_credentials=true" >> $GITHUB_OUTPUT
          fi

      - name: Setup Ruby for Fastlane
        if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: "3.3"
          bundler-cache: false

      - name: Install Fastlane
        if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
        run: gem install fastlane -N

      - name: Upload and submit to Mac App Store
        if: steps.check_certs.outputs.has_certs == 'true' && steps.check_asc_creds.outputs.has_credentials == 'true'
        env:
          APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
          APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
          APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
        run: |
          PKG_FILE=$(find release -name "termix_macos_universal_mas.pkg" -type f | head -n 1)
          if [ -z "$PKG_FILE" ]; then
            echo "PKG file not found in release/; aborting."
            exit 1
          fi

          VERSION=$(node -p "require('./package.json').version")

          # Write API key JSON that Fastlane deliver expects
          mkdir -p /tmp/asc_keys
          KEY_P8_PATH="/tmp/asc_keys/AuthKey_${APPLE_KEY_ID}.p8"
          API_KEY_JSON="/tmp/asc_keys/api_key.json"

          echo "$APPLE_KEY_CONTENT" | base64 --decode > "$KEY_P8_PATH"

          printf '{\n  "key_id": "%s",\n  "issuer_id": "%s",\n  "key": "%s",\n  "in_house": false\n}\n' \
            "$APPLE_KEY_ID" \
            "$APPLE_ISSUER_ID" \
            "$(tr -d '\n' < "$KEY_P8_PATH")" \
            > "$API_KEY_JSON"

          fastlane deliver \
            --pkg "$PKG_FILE" \
            --api_key_path "$API_KEY_JSON" \
            --app_version "$VERSION" \
            --skip_metadata true \
            --skip_screenshots true \
            --submit_for_review true \
            --automatic_release true \
            --force true

      - name: Clean up keychains
        if: always()
        run: |
          security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
          rm -rf /tmp/asc_keys

```

## /.github/workflows/openapi.yml

```yml path="/.github/workflows/openapi.yml" 
name: Generate OpenAPI Specification

on:
  workflow_dispatch:

jobs:
  generate-openapi:
    name: Generate OpenAPI JSON
    runs-on: blacksmith-2vcpu-ubuntu-2404

    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: ".nvmrc"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Generate OpenAPI specification
        run: npm run generate:openapi

      - name: Upload OpenAPI artifact
        uses: actions/upload-artifact@v7
        with:
          name: openapi-spec
          path: openapi.json
          retention-days: 90

```

## /.github/workflows/pr-check.yml

```yml path="/.github/workflows/pr-check.yml" 
name: PR Check

on:
  pull_request:
    branches: [main, dev-*]

jobs:
  lint-and-build:
    runs-on: blacksmith-2vcpu-ubuntu-2404

    env:
      NODE_OPTIONS: "--max-old-space-size=4096"

    steps:
      - name: Checkout code
        uses: actions/checkout@v6

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: ".nvmrc"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npx eslint .

      - name: Run Prettier check
        run: npx prettier --check .

      - name: Type check
        run: npx tsc --noEmit

      - name: Build
        run: npm run build

```

## /.github/workflows/release.yml

```yml path="/.github/workflows/release.yml" 
name: Release

on:
  workflow_dispatch:
    inputs:
      mode:
        description: "Release mode"
        required: true
        default: Everything
        type: choice
        options:
          - Everything
          - Overwrite release
          - Dry run
          - Skip submit

permissions:
  contents: write

jobs:
  prep:
    runs-on: blacksmith-2vcpu-ubuntu-2404
    outputs:
      version: ${{ steps.info.outputs.version }}
      release_tag: ${{ steps.info.outputs.release_tag }}
      mobile_version: ${{ steps.info.outputs.mobile_version }}
      dev_branch: ${{ steps.info.outputs.dev_branch }}
    steps:
      - name: Guard branch (release modes)
        if: ${{ inputs.mode != 'Overwrite release' && !startsWith(github.ref, 'refs/heads/dev-') }}
        run: |
          echo "This mode must be run from a dev branch (got ${{ github.ref }})."
          exit 1

      - name: Guard branch (overwrite mode)
        if: ${{ inputs.mode == 'Overwrite release' && github.ref != 'refs/heads/main' }}
        run: |
          echo "Overwrite release must be run from main (got ${{ github.ref }})."
          exit 1

      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: ".nvmrc"

      - name: Resolve versions
        id: info
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
            DEV_BRANCH=""
            VERSION=$(node -p "require('./package.json').version")
          else
            DEV_BRANCH="${GITHUB_REF#refs/heads/}"
            VERSION=$(node scripts/parse-dev-branch.cjs "$DEV_BRANCH")
          fi
          echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
          echo "version=$VERSION" >> "$GITHUB_OUTPUT"
          echo "release_tag=release-$VERSION-tag" >> "$GITHUB_OUTPUT"

          MOBILE_RAW=$(gh release view -R Termix-SSH/Mobile --json tagName -q .tagName)
          MOBILE_VERSION=$(echo "$MOBILE_RAW" | sed -E 's/^release-//; s/-tag$//')
          if [ -z "$MOBILE_VERSION" ]; then
            echo "Failed to resolve the latest Termix-SSH/Mobile release version."
            exit 1
          fi
          echo "mobile_version=$MOBILE_VERSION" >> "$GITHUB_OUTPUT"

      - name: Validate release notes
        run: |
          node scripts/generate-release-body.cjs \
            --version "${{ steps.info.outputs.version }}" \
            --mobile-version "${{ steps.info.outputs.mobile_version }}" \
            --notes RELEASE_NOTES.md > /dev/null

  normalize:
    needs: [prep]
    if: ${{ inputs.mode != 'Overwrite release' }}
    runs-on: blacksmith-2vcpu-ubuntu-2404
    steps:
      - name: Checkout dev branch
        uses: actions/checkout@v5
        with:
          ref: ${{ needs.prep.outputs.dev_branch }}
          fetch-depth: 0
          token: ${{ secrets.GHCR_TOKEN }}

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: ".nvmrc"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Lint and format
        run: |
          npm run lint:fix || true
          npm run format

      - name: Run unit tests
        run: npm run test

      - name: Sync version
        run: node scripts/sync-version.cjs --version "${{ needs.prep.outputs.version }}"

      - name: Commit changes to dev branch
        run: |
          git config user.name "LukeGus"
          git config user.email "bugattiguy527@gmail.com"

          if git diff --quiet; then
            echo "No lint/format/version changes to commit."
            exit 0
          fi

          if [ "${{ inputs.mode }}" = "Dry run" ]; then
            echo "DRY RUN: would commit and push the following changes to ${{ needs.prep.outputs.dev_branch }}:"
            git --no-pager diff --stat
            exit 0
          fi

          git add -A
          git commit -m "chore: lint, format, and bump version to ${{ needs.prep.outputs.version }}"
          git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"

  crowdin:
    needs: [prep, normalize]
    if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' }}
    runs-on: blacksmith-2vcpu-ubuntu-2404
    steps:
      - name: Checkout dev branch
        uses: actions/checkout@v5
        with:
          ref: ${{ needs.prep.outputs.dev_branch }}
          fetch-depth: 0
          token: ${{ secrets.GHCR_TOKEN }}

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: ".nvmrc"

      - name: Clean up stale Crowdin git integration branch
        continue-on-error: true
        env:
          GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
        run: |
          PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head i18n_translate --state open --json number -q '.[0].number' || true)
          if [ -n "$PR_NUMBER" ]; then
            gh pr close "$PR_NUMBER" --repo ${{ github.repository }} --delete-branch || true
          fi
          git push origin --delete i18n_translate || true

      - name: Upload sources to Crowdin
        uses: crowdin/github-action@v2
        with:
          upload_sources: true
          upload_translations: false
          download_translations: false
          create_pull_request: false
          push_translations: false
          token: ${{ secrets.CROWDIN_API_KEY }}
          project_id: "858252"
        env:
          CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}

      - name: Machine pre-translate untranslated strings
        env:
          CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }}
        run: node scripts/crowdin-pretranslate.cjs

      - name: Download translations from Crowdin
        uses: crowdin/github-action@v2
        with:
          upload_sources: false
          upload_translations: false
          download_translations: true
          create_pull_request: false
          push_translations: false
          token: ${{ secrets.CROWDIN_API_KEY }}
          project_id: "858252"
        env:
          CROWDIN_API_TOKEN: ${{ secrets.CROWDIN_API_KEY }}

      - name: Commit translations to dev branch
        run: |
          git config user.name "LukeGus"
          git config user.email "bugattiguy527@gmail.com"

          git add src/ui/locales/translated
          if git diff --cached --quiet; then
            echo "No translation changes to commit."
            exit 0
          fi
          git commit -m "chore: sync Crowdin translations for ${{ needs.prep.outputs.version }}"
          git push origin HEAD:"${{ needs.prep.outputs.dev_branch }}"

  merge-to-main:
    needs: [prep, normalize, crowdin]
    if: ${{ always() && needs.prep.result == 'success' && (needs.normalize.result == 'success' || needs.normalize.result == 'skipped') && (needs.crowdin.result == 'success' || needs.crowdin.result == 'skipped') }}
    runs-on: blacksmith-2vcpu-ubuntu-2404
    outputs:
      main_sha: ${{ steps.merge.outputs.main_sha }}
      build_ref: ${{ steps.merge.outputs.build_ref }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6
        with:
          fetch-depth: 1

      - name: Setup Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: ".nvmrc"

      - name: Generate PR body
        id: body
        run: |
          node scripts/generate-release-body.cjs \
            --version "${{ needs.prep.outputs.version }}" \
            --mobile-version "${{ needs.prep.outputs.mobile_version }}" \
            --notes RELEASE_NOTES.md > PR_BODY.md

      - name: Open and squash-merge PR to main
        id: merge
        env:
          GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
        run: |
          DEV_BRANCH="${{ needs.prep.outputs.dev_branch }}"
          TITLE="release-${{ needs.prep.outputs.version }}"

          if [ "${{ inputs.mode }}" = "Overwrite release" ]; then
            echo "OVERWRITE: no merge; building from main as-is."
            echo "build_ref=main" >> "$GITHUB_OUTPUT"
            echo "main_sha=$(gh api repos/${{ github.repository }}/commits/main -q .sha)" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          if [ "${{ inputs.mode }}" = "Dry run" ]; then
            echo "DRY RUN: would open and squash-merge a PR from $DEV_BRANCH into main."
            echo "DRY RUN: downstream jobs will build from $DEV_BRANCH instead of main."
            echo "build_ref=$DEV_BRANCH" >> "$GITHUB_OUTPUT"
            echo "main_sha=" >> "$GITHUB_OUTPUT"
            exit 0
          fi

          PR_NUMBER=$(gh pr list --repo ${{ github.repository }} --head "$DEV_BRANCH" --base main --state open --json number -q '.[0].number' || true)
          if [ -z "$PR_NUMBER" ]; then
            PR_NUMBER=$(gh pr create --repo ${{ github.repository }} \
              --base main --head "$DEV_BRANCH" \
              --title "$TITLE" --body-file PR_BODY.md \
              | grep -oE '[0-9]+$')
          fi

          gh pr merge "$PR_NUMBER" --repo ${{ github.repository }} --squash --admin

          STATE=$(gh pr view "$PR_NUMBER" --repo ${{ github.repository }} --json state -q .state)
          if [ "$STATE" != "MERGED" ]; then
            echo "PR #$PR_NUMBER did not merge (state: $STATE)."
            exit 1
          fi

          MAIN_SHA=$(gh api repos/${{ github.repository }}/commits/main -q .sha)
          echo "main_sha=$MAIN_SHA" >> "$GITHUB_OUTPUT"
          echo "build_ref=main" >> "$GITHUB_OUTPUT"

  docker:
    needs: [prep, merge-to-main]
    uses: ./.github/workflows/docker.yml
    with:
      version: ${{ needs.prep.outputs.version }}
      build_type: Production
      dry_run: ${{ inputs.mode == 'Dry run' }}
    secrets: inherit

  create-release:
    needs: [prep, merge-to-main, docker]
    if: ${{ inputs.mode != 'Dry run' }}
    runs-on: blacksmith-2vcpu-ubuntu-2404
    steps:
      - name: Checkout main
        uses: actions/checkout@v5
        with:
          ref: main
          fetch-depth: 1

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: ".nvmrc"

      - name: Clear existing release artifacts (overwrite mode)
        if: ${{ inputs.mode == 'Overwrite release' }}
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          TAG="${{ needs.prep.outputs.release_tag }}"
          if ! gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
            echo "Overwrite release: no existing release $TAG to overwrite."
            exit 1
          fi
          echo "Clearing existing assets from $TAG..."
          gh release view "$TAG" --repo ${{ github.repository }} --json assets -q '.assets[].name' | while read -r ASSET; do
            [ -n "$ASSET" ] && gh release delete-asset "$TAG" "$ASSET" --repo ${{ github.repository }} --yes || true
          done

      - name: Generate release body
        if: ${{ inputs.mode != 'Overwrite release' }}
        run: |
          node scripts/generate-release-body.cjs \
            --version "${{ needs.prep.outputs.version }}" \
            --mobile-version "${{ needs.prep.outputs.mobile_version }}" \
            --notes RELEASE_NOTES.md > RELEASE_BODY.md

      - name: Create or update GitHub release
        if: ${{ inputs.mode != 'Overwrite release' }}
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          TAG="${{ needs.prep.outputs.release_tag }}"
          TITLE="release-${{ needs.prep.outputs.version }}"
          if gh release view "$TAG" --repo ${{ github.repository }} >/dev/null 2>&1; then
            gh release edit "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md
          else
            gh release create "$TAG" --repo ${{ github.repository }} --title "$TITLE" --notes-file RELEASE_BODY.md --target "${{ needs.merge-to-main.outputs.main_sha }}"
          fi

  electron-release:
    needs: [prep, merge-to-main, create-release]
    if: ${{ always() && needs.merge-to-main.result == 'success' && (needs.create-release.result == 'success' || needs.create-release.result == 'skipped') }}
    uses: ./.github/workflows/electron.yml
    with:
      build_type: all
      artifact_destination: ${{ inputs.mode == 'Dry run' && 'file' || 'release' }}
      release_tag: ${{ needs.prep.outputs.release_tag }}
    secrets: inherit

  electron-submit:
    needs: [prep, electron-release]
    if: ${{ inputs.mode != 'Dry run' && inputs.mode != 'Skip submit' }}
    uses: ./.github/workflows/electron.yml
    with:
      build_type: all
      artifact_destination: submit
    secrets: inherit

  cask-commit-back:
    needs: [prep, electron-release]
    if: ${{ inputs.mode != 'Dry run' }}
    runs-on: blacksmith-2vcpu-ubuntu-2404
    permissions:
      contents: write
    steps:
      - name: Checkout main
        uses: actions/checkout@v6
        with:
          ref: main
          fetch-depth: 1
          token: ${{ secrets.GHCR_TOKEN }}

      - name: Bump and commit Homebrew cask
        env:
          VERSION: ${{ needs.prep.outputs.version }}
          DMG_SHA256: ${{ needs.electron-release.outputs.macos_universal_dmg_sha256 }}
        run: |
          if [ -z "$DMG_SHA256" ]; then
            echo "No universal DMG checksum available (macOS build unsigned or skipped); leaving cask unchanged."
            exit 0
          fi

          sed -i "s|version \".*\"|version \"$VERSION\"|g" Casks/termix.rb
          sed -i "s|sha256 \".*\"|sha256 \"$DMG_SHA256\"|g" Casks/termix.rb

          if git diff --quiet Casks/termix.rb; then
            echo "Cask already up to date."
            exit 0
          fi

          git config user.name "LukeGus"
          git config user.email "bugattiguy527@gmail.com"
          git add Casks/termix.rb
          git stash
          git pull --rebase origin main
          git stash pop
          git commit -m "chore: bump Homebrew cask to $VERSION"
          git push origin HEAD:main

  docs:
    needs: [prep, merge-to-main]
    if: ${{ always() && needs.merge-to-main.result == 'success' && inputs.mode != 'Overwrite release' }}
    runs-on: blacksmith-2vcpu-ubuntu-2404
    steps:
      - name: Checkout Termix
        uses: actions/checkout@v5
        with:
          ref: ${{ needs.merge-to-main.outputs.build_ref }}
          fetch-depth: 1
          path: termix

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: "termix/.nvmrc"
          cache: "npm"
          cache-dependency-path: termix/package-lock.json

      - name: Generate OpenAPI spec
        working-directory: termix
        run: |
          npm ci
          npm run generate:openapi

      - name: Checkout Docs repository
        uses: actions/checkout@v5
        with:
          repository: Termix-SSH/Docs
          ref: main
          fetch-depth: 0
          token: ${{ secrets.GHCR_TOKEN }}
          path: docs-repo

      - name: Create docs release branch
        working-directory: docs-repo
        run: git checkout -B "dev-${{ needs.prep.outputs.version }}"

      - name: Overwrite OpenAPI spec and regenerate API docs
        working-directory: docs-repo
        run: |
          cp ../termix/openapi.json static/openapi.json
          npm ci
          npm run docusaurus gen-api-docs termix

      - name: Commit and push docs branch
        working-directory: docs-repo
        run: |
          git config user.name "LukeGus"
          git config user.email "bugattiguy527@gmail.com"

          if git diff --quiet; then
            echo "No docs changes to commit."
            exit 0
          fi

          if [ "${{ inputs.mode }}" = "Dry run" ]; then
            echo "DRY RUN: would commit and push the following docs changes to dev-${{ needs.prep.outputs.version }}:"
            git --no-pager diff --stat
            exit 0
          fi

          git add -A
          git commit -m "feat: update API docs for ${{ needs.prep.outputs.version }}"
          git push --force origin "dev-${{ needs.prep.outputs.version }}"

      - name: Open and squash-merge docs PR
        if: ${{ inputs.mode != 'Dry run' }}
        working-directory: docs-repo
        env:
          GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
        run: |
          BRANCH="dev-${{ needs.prep.outputs.version }}"
          if ! git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then
            echo "No docs branch pushed (nothing changed); skipping PR."
            exit 0
          fi

          PR_NUMBER=$(gh pr list --repo Termix-SSH/Docs --head "$BRANCH" --base main --state open --json number -q '.[0].number' || true)
          if [ -z "$PR_NUMBER" ]; then
            PR_NUMBER=$(gh pr create --repo Termix-SSH/Docs \
              --base main --head "$BRANCH" \
              --title "release-${{ needs.prep.outputs.version }}" \
              --body "API docs for ${{ needs.prep.outputs.version }}" \
              | grep -oE '[0-9]+$')
          fi

          gh pr merge "$PR_NUMBER" --repo Termix-SSH/Docs --squash --admin

  publish-youtube:
    needs: [prep, electron-release]
    if: ${{ always() && inputs.mode != 'Dry run' && inputs.mode != 'Overwrite release' && needs.electron-release.result == 'success' }}
    runs-on: blacksmith-2vcpu-ubuntu-2404
    steps:
      - name: Checkout main
        uses: actions/checkout@v5
        with:
          ref: main
          fetch-depth: 1

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version-file: ".nvmrc"

      - name: Set release video to public
        env:
          YOUTUBE_CLIENT_ID: ${{ secrets.YOUTUBE_CLIENT_ID }}
          YOUTUBE_CLIENT_SECRET: ${{ secrets.YOUTUBE_CLIENT_SECRET }}
          YOUTUBE_REFRESH_TOKEN: ${{ secrets.YOUTUBE_REFRESH_TOKEN }}
        run: node scripts/publish-youtube.cjs

  cleanup:
    needs:
      [
        prep,
        merge-to-main,
        electron-release,
        cask-commit-back,
        docs,
        publish-youtube,
      ]
    if: ${{ always() && (inputs.mode == 'Everything' || inputs.mode == 'Skip submit') && needs.merge-to-main.result == 'success' && needs.electron-release.result == 'success' && needs.cask-commit-back.result == 'success' && needs.docs.result == 'success' && needs.publish-youtube.result == 'success' }}
    runs-on: blacksmith-2vcpu-ubuntu-2404
    steps:
      - name: Delete dev branch in Termix
        env:
          GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
        run: |
          git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/${{ github.repository }}.git \
            --delete "${{ needs.prep.outputs.dev_branch }}" || true

      - name: Delete dev branch in Docs
        env:
          GH_TOKEN: ${{ secrets.GHCR_TOKEN }}
        run: |
          git push https://x-access-token:${{ secrets.GHCR_TOKEN }}@github.com/Termix-SSH/Docs.git \
            --delete "dev-${{ needs.prep.outputs.version }}" || true

```

## /.gitignore

```gitignore path="/.gitignore" 
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
src/mcp-server/node_modules
dist
dist-ssr
coverage
*.local

.vscode/
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

/db/
/release/
/.claude/
/ssl/
/uploads/
/nul
.env
electron/build-info.cjs
/.mcp.json
/CLAUDE.md
/old_db/

```

## /.husky/commit-msg

```husky/commit-msg path="/.husky/commit-msg" 
npx --no -- commitlint --edit $1

```

## /.husky/pre-commit

```husky/pre-commit path="/.husky/pre-commit" 
npx lint-staged

```

## /.npmrc

```npmrc path="/.npmrc" 
legacy-peer-deps=true

```

## /.nvmrc

```nvmrc path="/.nvmrc" 
24

```

## /.prettierrc

```prettierrc path="/.prettierrc" 
{
  "semi": true,
  "singleQuote": false,
  "tabWidth": 2,
  "trailingComma": "all",
  "printWidth": 80,
  "arrowParens": "always",
  "endOfLine": "lf"
}

```

## /CODE_OF_CONDUCT.md

# Contributor Covenant Code of Conduct

## Our Pledge

We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.

We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.

## Our Standards

Examples of behavior that contributes to a positive environment for our
community include:

- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
  and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
  overall community

Examples of unacceptable behavior include:

- The use of sexualized language or imagery, and sexual attention or
  advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
  address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
  professional setting

## Enforcement Responsibilities

Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.

Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.

## Scope

This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.

## Enforcement

Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
mail@termix.site.
All complaints will be reviewed and investigated promptly and fairly.

All community leaders are obligated to respect the privacy and security of the
reporter of any incident.

## Enforcement Guidelines

Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:

### 1. Correction

**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.

**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.

### 2. Warning

**Community Impact**: A violation through a single incident or series
of actions.

**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.

### 3. Temporary Ban

**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.

**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.

### 4. Permanent Ban

**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.

**Consequence**: A permanent ban from any sort of public interaction within
the community.

## Attribution

This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.

Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).

[homepage]: https://www.contributor-covenant.org

For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.


## /CONTRIBUTING.md

# Contributing

## Prerequisites

- [Node.js](https://nodejs.org/en/download/) (built with v24)
- [NPM](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm)
- [Git](https://git-scm.com/downloads)

## Installation

1. Clone the repository:
   ```sh
   git clone https://github.com/Termix-SSH/Termix
   ```
2. Install the dependencies:
   ```sh
   npm install
   ```

## Running the development server

Run the following commands:

```sh
npm run dev
npm run dev:backend
```

This will start the backend and the frontend Vite server. You can access Termix by going to `http://localhost:5174/`.

## Contributing

1. **Fork the repository**: Click the "Fork" button at the top right of
   the [repository page](https://github.com/Termix-SSH/Termix).
2. **Create a new branch**:
   ```sh
   git checkout -b feature/my-new-feature
   ```
3. **Make your changes**: Implement your feature, fix, or improvement.
4. **Commit your changes**:
   ```sh
   git commit -m "Feature request my new feature"
   ```
5. **Push to your fork**:
   ```sh
   git push origin feature/my-feature-request
   ```
6. **Open a pull request**: Go to the original repository and create a PR with a clear description.

## Guidelines

- Follow the existing code style. Use Tailwind CSS with shadcn components.
- Use the below color scheme with the respective CSS variable placed in the `className` of a div/component.
- Place all API routes in the `main-axios.ts` file. Updating the `openapi.json` is unneeded.
- Include meaningful commit messages.
- Link related issues when applicable.
- `MobileApp.tsx` renders when the users screen width is less than 768px, otherwise it loads the usual `DesktopApp.tsx`.

## Color Scheme

### Background Colors

| CSS Variable                  | Color Value | Usage                       | Description                              |
| ----------------------------- | ----------- | --------------------------- | ---------------------------------------- |
| `--color-dark-bg`             | `#18181b`   | Main dark background        | Primary dark background color            |
| `--color-dark-bg-darker`      | `#0e0e10`   | Darker backgrounds          | Darker variant for panels and containers |
| `--color-dark-bg-darkest`     | `#09090b`   | Darkest backgrounds         | Darkest background (terminal)            |
| `--color-dark-bg-light`       | `#141416`   | Light dark backgrounds      | Lighter variant of dark background       |
| `--color-dark-bg-very-light`  | `#101014`   | Very light dark backgrounds | Very light variant of dark background    |
| `--color-dark-bg-panel`       | `#1b1b1e`   | Panel backgrounds           | Background for panels and cards          |
| `--color-dark-bg-panel-hover` | `#232327`   | Panel hover states          | Background for panels on hover           |

### Element-Specific Backgrounds

| CSS Variable             | Color Value | Usage              | Description                                   |
| ------------------------ | ----------- | ------------------ | --------------------------------------------- |
| `--color-dark-bg-input`  | `#222225`   | Input fields       | Background for input fields and form elements |
| `--color-dark-bg-button` | `#23232a`   | Button backgrounds | Background for buttons and clickable elements |
| `--color-dark-bg-active` | `#1d1d1f`   | Active states      | Background for active/selected elements       |
| `--color-dark-bg-header` | `#131316`   | Header backgrounds | Background for headers and navigation bars    |

### Border Colors

| CSS Variable                 | Color Value | Usage           | Description                              |
| ---------------------------- | ----------- | --------------- | ---------------------------------------- |
| `--color-dark-border`        | `#303032`   | Default borders | Standard border color                    |
| `--color-dark-border-active` | `#2d2d30`   | Active borders  | Border color for active elements         |
| `--color-dark-border-hover`  | `#434345`   | Hover borders   | Border color on hover states             |
| `--color-dark-border-light`  | `#5a5a5d`   | Light borders   | Lighter border color for subtle elements |
| `--color-dark-border-medium` | `#373739`   | Medium borders  | Medium weight border color               |
| `--color-dark-border-panel`  | `#222224`   | Panel borders   | Border color for panels and cards        |

### Interactive States

| CSS Variable             | Color Value | Usage             | Description                                   |
| ------------------------ | ----------- | ----------------- | --------------------------------------------- |
| `--color-dark-hover`     | `#2d2d30`   | Hover states      | Background color for hover effects            |
| `--color-dark-active`    | `#2a2a2c`   | Active states     | Background color for active elements          |
| `--color-dark-pressed`   | `#1a1a1c`   | Pressed states    | Background color for pressed/clicked elements |
| `--color-dark-hover-alt` | `#2a2a2d`   | Alternative hover | Alternative hover state color                 |

## Support

If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`.
Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
channel, however, response times may be longer.


## /Casks/termix.rb

```rb path="/Casks/termix.rb" 
cask "termix" do
  version "2.4.0"
  sha256 "3cc1afc2c62ce9f40124561fb40374b34e41dcf8610b62f773a4661e9c83ca85"

  url "https://github.com/Termix-SSH/Termix/releases/download/release-#{version}-tag/termix_macos_universal_dmg.dmg"
  name "Termix"
  desc "Web-based server management platform with SSH terminal, tunneling, and file editing"
  homepage "https://github.com/Termix-SSH/Termix"

  livecheck do
    url :url
    strategy :github_latest
  end

  app "Termix.app"

  zap trash: [
    "~/Library/Application Support/termix",
    "~/Library/Caches/com.karmaa.termix",
    "~/Library/Caches/com.karmaa.termix.ShipIt",
    "~/Library/Preferences/com.karmaa.termix.plist",
    "~/Library/Saved Application State/com.karmaa.termix.savedState",
  ]
end

```

## /README.md

<div align="center">

<img src="./public/icon.svg" width="120" height="120" alt="Termix Logo" />

<h1>Termix</h1>

<p>Self-hosted SSH management and remote desktop access</p>

<p>
  English ·
  <a href="readme/README-CN.md">中文</a> ·
  <a href="readme/README-JA.md">日本語</a> ·
  <a href="readme/README-KO.md">한국어</a> ·
  <a href="readme/README-FR.md">Français</a> ·
  <a href="readme/README-DE.md">Deutsch</a> ·
  <a href="readme/README-ES.md">Español</a> ·
  <a href="readme/README-PT.md">Português</a> ·
  <a href="readme/README-RU.md">Русский</a> ·
  <a href="readme/README-AR.md">العربية</a> ·
  <a href="readme/README-HI.md">हिन्दी</a> ·
  <a href="readme/README-TR.md">Türkçe</a> ·
  <a href="readme/README-VI.md">Tiếng Việt</a> ·
  <a href="readme/README-IT.md">Italiano</a>
</p>

<p>
  <img src="https://img.shields.io/github/stars/Termix-SSH/Termix?style=flat&label=Stars&color=F39044&labelColor=1a1a1a" />
  <img src="https://img.shields.io/github/forks/Termix-SSH/Termix?style=flat&label=Forks&color=F39044&labelColor=1a1a1a" />
  <img src="https://img.shields.io/github/v/release/Termix-SSH/Termix?style=flat&label=Release&color=F39044&labelColor=1a1a1a&v=1" />
  <a href="https://discord.gg/jVQGdvHDrf"><img alt="Discord" src="https://img.shields.io/discord/1347374268253470720?color=F39044&labelColor=1a1a1a" /></a>
</p>

<br />

<img src="./repo-images/Termix Header.png" alt="Termix Banner" width="900" />

<br />
<br />

<p>
  <img src="repo-images/Repo of the Day.png" alt="Repo of the Day Achievement" width="280" />
  <br />
  <sub>Achieved on September 1st, 2025</sub>
</p>

</div>

<br />

## Overview

Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote file management, and many other tools. Termix is the perfect free and self-hosted alternative to Termius available for all platforms.

<br />

## Features

<table>
<tr>
<td width="50%" valign="top">

**SSH Terminal Access:**
Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components.

</td>
<td width="50%" valign="top">

**Remote Desktop Access:**
RDP, VNC, and Telnet support over the browser with complete customization and split screening.

</td>
</tr>
<tr>
<td width="50%" valign="top">

**SSH Tunnel Management:**
Create and manage server-to-server SSH tunnels with automatic reconnection, health monitoring, and local, remote, or dynamic SOCKS forwarding. Desktop client-to-server tunnel settings are stored locally per desktop install, optional C2S preset snapshots can be saved to the server, renamed, loaded, or deleted when you want to move a local tunnel configuration between clients.

</td>
<td width="50%" valign="top">

**Remote File Manager:**
Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly with sudo support. Includes support for moving files from server to server.

</td>
</tr>
<tr>
<td width="50%" valign="top">

**Docker Management:**
Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.

</td>
<td width="50%" valign="top">

**SSH Host Manager:**
Save, organize, and manage your SSH connections with tags and folders (folder customization and nested folder support), and easily save reusable login info while being able to automate the deployment of SSH keys.

</td>
</tr>
<tr>
<td width="50%" valign="top">

**Host Metrics:**
View CPU, memory, disk usage, network, uptime, system information, firewall, port monitor, log viewer, users/permissions, certificates, and many more which work on most Linux based servers.

</td>
<td width="50%" valign="top">

**User Authentication:**
Secure user management with admin controls and OIDC/LDAP/SSO (with access control) and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together. View audit log of all users actions.

</td>
</tr>
<tr>
<td width="50%" valign="top">

**Tailscale Integration:**
List devices from your tailnet to quickly add them as hosts, and connect using Tailscale SSH as an authentication method, letting your tailnet ACLs handle authorization without storing credentials.

</td>
<td width="50%" valign="top">

**RBAC:**
Create roles and share hosts across users/roles.

</td>
</tr>
<tr>
<td width="50%" valign="top">

**Database Encryption:**
Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.

</td>
<td width="50%" valign="top">

**Network Graph:**
Customize your Dashboard to visualize your homelab based off your SSH connections with status support.

</td>
</tr>
<tr>
<td width="50%" valign="top">

**SSH Tools:**
Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.

</td>
<td width="50%" valign="top">

**Persistent Tabs:**
SSH sessions and tabs stay open across devices/refreshes if enabled in user profile.

</td>
</tr>
<tr>
<td width="50%" valign="top">

**Languages:**
Built-in support ~30 languages (managed by [Crowdin](https://docs.termix.site/translations)).

</td>
</tr>
</table>

<br />

<details>
<summary><b>More features</b></summary>
<br />

- **Dashboard** - View server information at a glance on your dashboard
- **API Keys** - Create user-scoped API keys with expiration dates to be used for automation/CI
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between many different UI themes including light, dark, Dracula, etc. Use URL routes to open any connection in full-screen.
- **Command History** - Auto-complete and view previously ran SSH commands
- **Quick Connect** - Connect to a server without having to save the connection data
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
- **Proxmox Integration** - Auto-add hosts into Termix from your Proxmox instance
- **SSH Feature Rich** - Supports jump hosts, Warpgate, TOTP based connections, SOCKS5, host key verification, password autofill, [OPKSSH](https://github.com/openpubkey/opkssh), tmux, port knocking, terminal logging, etc.

</details>

<br />

## Platform Support

<table align="center">
<tr>
<th align="center">Platform</th>
<th align="center">Distribution</th>
</tr>
<tr>
<td align="center"><b>Web</b></td>
<td>Any modern browser (Chrome, Safari, Firefox) · PWA support</td>
</tr>
<tr>
<td align="center"><b>Windows</b> <sub>x64/ia32</sub></td>
<td>Portable · MSI Installer · Chocolatey</td>
</tr>
<tr>
<td align="center"><b>Linux</b> <sub>x64/ia32</sub></td>
<td>Portable · AUR · AppImage · Deb · Flatpak</td>
</tr>
<tr>
<td align="center"><b>macOS</b> <sub>x64/ia32, v12.0+</sub></td>
<td>Apple App Store · DMG · Homebrew</td>
</tr>
<tr>
<td align="center"><b>iOS/iPadOS</b> <sub>v15.1+</sub></td>
<td>Apple App Store · IPA</td>
</tr>
<tr>
<td align="center"><b>Android</b> <sub>v7.0+</sub></td>
<td>Google Play Store · APK</td>
</tr>
</table>

<br />

## Installation

Visit the [Termix Docs](https://docs.termix.site/install) for full installation instructions across all platforms.

Sample Docker Compose file (you can omit `guacd` and the network if you don't plan on using remote desktop features):

```yaml
services:
  termix:
    image: ghcr.io/lukegus/termix:latest
    container_name: termix
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - termix-data:/app/data
    environment:
      PORT: "8080"
    depends_on:
      - guacd
    networks:
      - termix-net

  guacd:
    image: guacamole/guacd:1.6.0
    container_name: guacd
    restart: unless-stopped
    ports:
      - "4822:4822"
    networks:
      - termix-net

volumes:
  termix-data:
    driver: local

networks:
  termix-net:
    driver: bridge
```

<br />

## Screenshots

<div align="center">

<br />

[![YouTube](./repo-images/YouTube.png)](https://www.youtube.com/@TermixSSH/videos)

<sub>Watch update overviews on YouTube</sub>

<br />
<br />

<table>
<tr>
<td><img src="./repo-images/Image 1.png" alt="Termix Screenshot 1" width="400" /></td>
<td><img src="./repo-images/Image 2.png" alt="Termix Screenshot 2" width="400" /></td>
</tr>
<tr>
<td><img src="./repo-images/Image 3.png" alt="Termix Screenshot 3" width="400" /></td>
<td><img src="./repo-images/Image 4.png" alt="Termix Screenshot 4" width="400" /></td>
</tr>
<tr>
<td><img src="./repo-images/Image 5.png" alt="Termix Screenshot 5" width="400" /></td>
<td><img src="./repo-images/Image 6.png" alt="Termix Screenshot 6" width="400" /></td>
</tr>
<tr>
<td><img src="./repo-images/Image 7.png" alt="Termix Screenshot 7" width="400" /></td>
<td><img src="./repo-images/Image 8.png" alt="Termix Screenshot 8" width="400" /></td>
</tr>
<tr>
<td><img src="./repo-images/Image 9.png" alt="Termix Screenshot 9" width="400" /></td>
<td><img src="./repo-images/Image 10.png" alt="Termix Screenshot 10" width="400" /></td>
</tr>
<tr>
<td><img src="./repo-images/Image 11.png" alt="Termix Screenshot 11" width="400" /></td>
<td><img src="./repo-images/Image 12.png" alt="Termix Screenshot 12" width="400" /></td>
</tr>
<tr>
<td><img src="./repo-images/Image 13.png" alt="Termix Screenshot 13" width="400" /></td>
<td><img src="./repo-images/Image 14.png" alt="Termix Screenshot 14" width="400" /></td>
</tr>
</table>

<sub>Some videos and images may be out of date or may not perfectly showcase features.</sub>

</div>

<br />

## Planned Features

See [Projects](https://github.com/orgs/Termix-SSH/projects/5) for all planned features. If you are looking to contribute, see [Contributing](https://github.com/Termix-SSH/Termix/blob/main/CONTRIBUTING.md).

<br />

## Sponsors

<div align="center">

<br />

<a href="https://www.digitalocean.com/">
  <img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="40" alt="DigitalOcean" />
</a>
&nbsp;&nbsp;&nbsp;
<a href="https://crowdin.com/">
  <img src="https://support.crowdin.com/assets/logos/core-logo/svg/crowdin-core-logo-cDark.svg" height="40" alt="Crowdin" />
</a>
&nbsp;&nbsp;&nbsp;
<a href="https://www.blacksmith.sh/">
  <img src="https://cdn.prod.website-files.com/681bfb0c9a4601bc6e288ec4/683ca9e2c5186757092611b8_e8cb22127df4da0811c4120a523722d2_logo-backsmith-wordmark-light.svg" height="40" alt="Blacksmith" />
</a>
&nbsp;&nbsp;&nbsp;
<a href="https://www.cloudflare.com/">
  <img src="https://sirv.sirv.com/website/screenshots/cloudflare/cloudflare-logo.png?w=300" height="40" alt="Cloudflare" />
</a>
&nbsp;&nbsp;&nbsp;
<a href="https://tailscale.com/">
  <img src="https://drive.google.com/uc?export=view&id=1lIxkJuX6M23bW-2FElhT0rQieTrzaVSL" height="40" alt="Tailscale" />
</a>
&nbsp;&nbsp;&nbsp;
<a href="https://akamai.com/">
  <img src="https://upload.wikimedia.org/wikipedia/commons/8/8b/Akamai_logo.svg" height="40" alt="Akamai" />
</a>
&nbsp;&nbsp;&nbsp;
<a href="https://aws.amazon.com/">
  <img src="https://upload.wikimedia.org/wikipedia/commons/thumb/9/93/Amazon_Web_Services_Logo.svg/960px-Amazon_Web_Services_Logo.svg.png" height="40" alt="AWS" />
</a>

</div>

<br />

## Support

If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`. Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support channel, however, response times may be longer.

<br />

## License

Distributed under the Apache License Version 2.0. See `LICENSE` for more information.


## /RELEASE_NOTES.md

<!-- SUMMARY -->

Bug fixes and new features, including Proxmox integration, SSO/OIDC redesign, revamped host metrics, tmux session management, and numerous UI and stability improvements.

<!-- /SUMMARY -->

<!-- YOUTUBE -->

https://youtu.be/ImwAbm4hW-k

<!-- /YOUTUBE -->

<!-- UPDATE_LOG -->

- Improved terminal syntax highlighting with more customizability and reliability (toggle setting moved from user profile to host editor)
- Tmux session monitor/management
- Tailscale SSH authentication and device listing support
- SSO/OIDC redesign (multiple OIDC providers, LDAP, Google, GitHub support)
- Terminal session logging
- Customize side rail tab visibility
- Admin audit log
- Added `x.x.x` version tag alongside `release-x.x.x` for Docker
- OIDC custom group claim support
- Renamed server stats to host metrics
- Fully revamped host metrics page with new cards and dashboard like organizing system (services, process inspector, log viewer, cron jobs, packages, ssl cert management, firewall, user/permissions, health checks, disk breakdown, timers, and top by memory)
- Proxmox guest discovery and import integration
- Moved ssh host config outside of top tab bar and into new tab bar visible on SSH tab
- Improved folder management (nested folders, folder icons, folder colors, better folder selection, etc.)
- Storage preference to user profile settings (store/load toggles locally or in the DB)
- Sort/filter functions to credential list (copy of host list)
<!-- /UPDATE_LOG -->

<!-- BUG_FIXES -->

- SFTP jump-host fallback from host data
- Guacd password incorrectly passed for app view
- Admin page failing to load admin information
- Disabled hardware acceleration on Windows to prevent startup crash
- Dashboard total credentials stuck at 0
- Host username ignored when credential attached
- Clone host cannot switch auth method
- File manager context menu off-screen
- File delete affecting inactive tabs
- Silent delete failure on Windows hosts
- iPad host tab does nothing
- Docker console terminal background using incorrect colors
- Reliable OIDC group syncing for admin roles
- Guacd connections using incorrect screen height
- 2FA failing to disable
- Hostname fill entire column and truncate at proper spot in dashboard
- Make credentials start collapsed
- Incorrect JSDoc comments
<!-- /BUG_FIXES -->


## /SECURITY.md

# Security Policy

## Reporting a Vulnerability

Please report any vulnerabilities to [GitHub Security](https://github.com/Termix-SSH/Termix/security/advisories).


## /build/Termix_Mac_App_Store.provisionprofile

Binary file available at https://raw.githubusercontent.com/LukeGus/Termix/refs/heads/main/build/Termix_Mac_App_Store.provisionprofile

## /build/entitlements.mac.inherit.plist

```plist path="/build/entitlements.mac.inherit.plist" 
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
	<true/>
	<key>com.apple.security.cs.disable-library-validation</key>
	<true/>
	<key>com.apple.security.cs.allow-dyld-environment-variables</key>
	<true/>
</dict>
</plist>

```

## /build/entitlements.mac.plist

```plist path="/build/entitlements.mac.plist" 
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
	<true/>
	<key>com.apple.security.cs.disable-library-validation</key>
	<true/>
	<key>com.apple.security.cs.allow-dyld-environment-variables</key>
	<true/>
</dict>
</plist>

```

## /build/entitlements.mas.inherit.plist

```plist path="/build/entitlements.mas.inherit.plist" 
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
	<key>com.apple.security.inherit</key>
	<true/>
	<key>com.apple.security.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
	<true/>
	<key>com.apple.security.cs.disable-library-validation</key>
	<true/>
</dict>
</plist>

```

## /build/entitlements.mas.plist

```plist path="/build/entitlements.mas.plist" 
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>com.apple.security.app-sandbox</key>
	<true/>
	<key>com.apple.security.network.client</key>
	<true/>
	<key>com.apple.security.network.server</key>
	<true/>
	<key>com.apple.security.files.user-selected.read-write</key>
	<true/>
	<key>com.apple.security.cs.allow-jit</key>
	<true/>
	<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
	<true/>
	<key>com.apple.security.cs.disable-library-validation</key>
	<true/>
</dict>
</plist>

```

## /build/notarize.cjs

```cjs path="/build/notarize.cjs" 
const { notarize } = require('@electron/notarize');

exports.default = async function notarizing(context) {
  const { electronPlatformName, appOutDir } = context;

  if (electronPlatformName !== 'darwin') {
    return;
  }

  const appleId = process.env.APPLE_ID;
  const appleIdPassword = process.env.APPLE_ID_PASSWORD;
  const teamId = process.env.APPLE_TEAM_ID;

  if (!appleId || !appleIdPassword || !teamId) {
    return;
  }

  const appName = context.packager.appInfo.productFilename;

  try {
    await notarize({
      appBundleId: 'com.karmaa.termix',
      appPath: `${appOutDir}/${appName}.app`,
      appleId: appleId,
      appleIdPassword: appleIdPassword,
      teamId: teamId,
    });
  } catch (error) {
    console.error('Notarization failed:', error);
  }
};

```

## /chocolatey/termix-ssh.nuspec

```nuspec path="/chocolatey/termix-ssh.nuspec" 
<?xml version="1.0" encoding="utf-8"?>
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
  <metadata>
    <id>termix-ssh</id>
    <version>VERSION_PLACEHOLDER</version>
    <packageSourceUrl>https://github.com/Termix-SSH/Termix</packageSourceUrl>
    <owners>bugattiguy527</owners>
    <title>Termix SSH</title>
    <authors>bugattiguy527</authors>
    <projectUrl>https://github.com/Termix-SSH/Termix</projectUrl>
    <iconUrl>https://raw.githubusercontent.com/Termix-SSH/Termix/main/public/icon.png</iconUrl>
    <licenseUrl>https://raw.githubusercontent.com/Termix-SSH/Termix/refs/heads/main/LICENSE</licenseUrl>
    <requireLicenseAcceptance>false</requireLicenseAcceptance>
    <projectSourceUrl>https://github.com/Termix-SSH/Termix</projectSourceUrl>
    <docsUrl>https://docs.termix.site/install</docsUrl>
    <bugTrackerUrl>https://github.com/Termix-SSH/Support/issues</bugTrackerUrl>
    <tags>docker ssh terminal telnet self-hosted rdp file-management vnc ssh-tunnel server-stats termix</tags>
    <summary>Self-hosted SSH and remote desktop management.</summary>
    <description>
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal access, remote desktop control (RDP, VNC, Telnet), SSH tunneling capabilities, remote file management, and many other tools. Termix is the perfect free and self-hosted alternative to Termius available for all platforms.

This package installs the desktop application version of Termix.
    </description>
    <releaseNotes>https://github.com/Termix-SSH/Termix/releases</releaseNotes>
  </metadata>
  <files>
    <file src="tools\**" target="tools" />
  </files>
</package>

```

## /chocolatey/tools/chocolateyinstall.ps1

```ps1 path="/chocolatey/tools/chocolateyinstall.ps1" 
$ErrorActionPreference = 'Stop'

$packageName = 'termix-ssh'
$toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
$url64 = 'DOWNLOAD_URL_PLACEHOLDER'
$checksum64 = 'CHECKSUM_PLACEHOLDER'
$checksumType64 = 'sha256'

$packageArgs = @{
  packageName    = $packageName
  fileType       = 'msi'
  url64bit       = $url64
  softwareName   = 'Termix*'
  checksum64     = $checksum64
  checksumType64 = $checksumType64
  silentArgs     = "/qn /norestart /l*v `"$($env:TEMP)\$($packageName).$($env:chocolateyPackageVersion).MsiInstall.log`""
  validExitCodes = @(0, 3010, 1641)
}

Install-ChocolateyPackage @packageArgs

```

## /chocolatey/tools/chocolateyuninstall.ps1

```ps1 path="/chocolatey/tools/chocolateyuninstall.ps1" 
$ErrorActionPreference = 'Stop'

$packageName = 'termix-ssh'
$softwareName = 'Termix*'
$installerType = 'msi'

$silentArgs = '/qn /norestart'
$validExitCodes = @(0, 3010, 1605, 1614, 1641)

[array]$key = Get-UninstallRegistryKey -SoftwareName $softwareName

if ($key.Count -eq 1) {
  $key | % {
    $file = "$($_.UninstallString)"

    if ($installerType -eq 'msi') {
      $silentArgs = "$($_.PSChildName) $silentArgs"
      $file = ''
    }

    Uninstall-ChocolateyPackage -PackageName $packageName `
                                 -FileType $installerType `
                                 -SilentArgs "$silentArgs" `
                                 -ValidExitCodes $validExitCodes `
                                 -File "$file"
  }
} elseif ($key.Count -eq 0) {
  Write-Warning "$packageName has already been uninstalled by other means."
} elseif ($key.Count -gt 1) {
  Write-Warning "$($key.Count) matches found!"
  Write-Warning "To prevent accidental data loss, no programs will be uninstalled."
  $key | % {Write-Warning "- $($_.DisplayName)"}
}

```

## /components.json

```json path="/components.json" 
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "radix-lyra",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "",
    "css": "src/index.css",
    "baseColor": "neutral",
    "cssVariables": true,
    "prefix": ""
  },
  "iconLibrary": "lucide",
  "rtl": false,
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "menuColor": "default",
  "menuAccent": "subtle",
  "registries": {}
}

```

## /crowdin.yml

```yml path="/crowdin.yml" 
project_id: "858252"
api_token: "env:CROWDIN_API_TOKEN"

files:
  - source: /src/ui/locales/en.json
    translation: /src/ui/locales/translated/%locale_with_underscore%.json

```

## /docker/Dockerfile

``` path="/docker/Dockerfile" 
# Stage 1: Install dependencies
FROM node:24-slim AS deps
WORKDIR /app

RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*

COPY package*.json ./
COPY .npmrc ./
COPY vendor ./vendor

COPY scripts/patch-guacamole-lite.cjs ./scripts/

RUN npm ci --ignore-scripts && \
    node scripts/patch-guacamole-lite.cjs && \
    npm cache clean --force

# Stage 2: Build frontend
FROM deps AS frontend-builder
WORKDIR /app

COPY . .

RUN find public/fonts -name "*.ttf" ! -name "*Regular.ttf" ! -name "*Bold.ttf" ! -name "*Italic.ttf" -delete

RUN npm cache clean --force && \
    NODE_OPTIONS="--max-old-space-size=3072" npm run build

# Stage 3: Build backend
FROM deps AS backend-builder
WORKDIR /app

COPY . .

RUN npm rebuild better-sqlite3

RUN npm run build:backend

# Stage 4: Production dependencies only
FROM node:24-slim AS production-deps
WORKDIR /app

RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*

COPY package*.json ./
COPY .npmrc ./
COPY vendor ./vendor

COPY scripts/patch-guacamole-lite.cjs ./scripts/

RUN npm ci --omit=dev --ignore-scripts && \
    node scripts/patch-guacamole-lite.cjs && \
    npm rebuild better-sqlite3 bcryptjs ssh2 && \
    npm cache clean --force

# Stage 5: Final optimized image
FROM node:24-slim
WORKDIR /app

ENV DATA_DIR=/app/data \
    PORT=8080 \
    NODE_ENV=production

RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \
    update-ca-certificates && \
    rm -rf /var/lib/apt/lists/* && \
    mkdir -p /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx && \
    chown -R node:node /app /tmp/nginx && \
    chmod 755 /app/data /app/uploads /app/data/.opk /app/nginx /tmp/nginx

COPY docker/nginx.conf /app/nginx/nginx.conf.template
COPY docker/nginx-https.conf /app/nginx/nginx-https.conf.template

COPY --chown=node:node --from=frontend-builder /app/dist /app/html

COPY --chown=node:node --from=production-deps /app/node_modules /app/node_modules
COPY --chown=node:node --from=backend-builder /app/dist/backend ./dist/backend
COPY --chown=node:node package.json ./

VOLUME ["/app/data"]

EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006

HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD wget -q -O /dev/null http://localhost:30001/health || exit 1

COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

CMD ["/entrypoint.sh"]

```

## /docker/compose-dev.yml

```yml path="/docker/compose-dev.yml" 
services:
  termix-dev:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    container_name: termix-dev
    restart: unless-stopped
    ports:
      - "8081:8080"
    volumes:
      - termix-dev-data:/app/data
    environment:
      PORT: "8080"
      NODE_ENV: development
    depends_on:
      - guacd-dev
    networks:
      - termix-dev-net

  guacd-dev:
    image: guacamole/guacd:1.6.0
    container_name: guacd-dev
    restart: unless-stopped
    networks:
      - termix-dev-net

volumes:
  termix-dev-data:
    driver: local

networks:
  termix-dev-net:
    driver: bridge

```

## /docker/docker-compose.yml

```yml path="/docker/docker-compose.yml" 
services:
  termix:
    image: ghcr.io/lukegus/termix:latest
    container_name: termix
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - termix-data:/app/data
    environment:
      PORT: "8080"
      GUACD_HOST: "guacd"
    depends_on:
      - guacd
    networks:
      - termix-net

  guacd:
    image: guacamole/guacd:1.6.0
    container_name: guacd
    restart: unless-stopped
    networks:
      - termix-net

volumes:
  termix-data:
    driver: local

networks:
  termix-net:
    driver: bridge

```

## /docker/entrypoint.sh

```sh path="/docker/entrypoint.sh" 
#!/bin/sh
set -e

PUID=${PUID:-1000}
PGID=${PGID:-1000}

if [ "$(id -u)" = "0" ]; then
    if [ "$PUID" = "0" ]; then
        echo "Running as root (PUID=0, PGID=$PGID)"
        chown -R root:root /app/data /app/uploads /tmp/nginx 2>/dev/null || true
    else
        echo "Setting up user permissions (PUID: $PUID, PGID: $PGID)..."

        groupmod -o -g "$PGID" node 2>/dev/null || true
        usermod -o -u "$PUID" node 2>/dev/null || true

        chown -R node:node /app/data /app/uploads /tmp/nginx 2>/dev/null || true

        echo "User node is now UID: $PUID, GID: $PGID"

        exec gosu node:node "$0" "$@"
    fi
fi

export PORT=${PORT:-8080}
export ENABLE_SSL=${ENABLE_SSL:-false}
export SSL_PORT=${SSL_PORT:-8443}
export SSL_CERT_PATH=${SSL_CERT_PATH:-/app/data/ssl/termix.crt}
export SSL_KEY_PATH=${SSL_KEY_PATH:-/app/data/ssl/termix.key}

echo "Configuring web UI to run on port: $PORT"

if [ "$ENABLE_SSL" = "true" ]; then
    echo "SSL enabled - using HTTPS configuration with redirect"
    NGINX_CONF_SOURCE="/app/nginx/nginx-https.conf.template"
else
    echo "SSL disabled - using HTTP-only configuration (default)"
    NGINX_CONF_SOURCE="/app/nginx/nginx.conf.template"
fi

mkdir -p /tmp/nginx
envsubst '${PORT} ${SSL_PORT} ${SSL_CERT_PATH} ${SSL_KEY_PATH}' < $NGINX_CONF_SOURCE > /tmp/nginx/nginx.conf

mkdir -p /app/data /app/uploads /app/data/.opk
chmod 755 /app/data /app/uploads /app/data/.opk 2>/dev/null || true

if [ -w /app/data ]; then
    echo "Data directory is writable"
else
    echo "WARNING: Data directory is not writable. OPKSSH may fail."
    ls -ld /app/data
fi

if [ -w /app/data/.opk ]; then
    echo "OPKSSH directory is writable"
else
    echo "WARNING: OPKSSH directory is not writable. OPKSSH authentication will fail."
    ls -ld /app/data/.opk
fi

OPKSSH_DIR="${DATA_DIR:-/app/data}/opkssh"
if [ ! -d "$OPKSSH_DIR" ]; then
    echo "WARNING: OPKSSH binary directory not found at $OPKSSH_DIR"
    echo "OPKSSH will be downloaded automatically on first use."
else
    echo "OPKSSH binary directory found at $OPKSSH_DIR"
fi

if [ "$ENABLE_SSL" = "true" ]; then
    echo "Checking SSL certificate configuration..."
    mkdir -p /app/data/ssl
    chmod 755 /app/data/ssl 2>/dev/null || true

    DOMAIN=${SSL_DOMAIN:-localhost}
    
    if [ -f "/app/data/ssl/termix.crt" ] && [ -f "/app/data/ssl/termix.key" ]; then
        echo "SSL certificates found, checking validity..."
        
        if openssl x509 -in /app/data/ssl/termix.crt -checkend 2592000 -noout >/dev/null 2>&1; then
            echo "SSL certificates are valid and will be reused for domain: $DOMAIN"
        else
            echo "SSL certificate is expired or expiring soon, regenerating..."
            rm -f /app/data/ssl/termix.crt /app/data/ssl/termix.key
        fi
    else
        echo "SSL certificates not found, will generate new ones..."
    fi
    
    if [ ! -f "/app/data/ssl/termix.crt" ] || [ ! -f "/app/data/ssl/termix.key" ]; then
        echo "Generating SSL certificates for domain: $DOMAIN"

        cat > /app/data/ssl/openssl.conf << EOF
[req]
default_bits = 2048
prompt = no
default_md = sha256
distinguished_name = dn
req_extensions = v3_req

[dn]
C=US
ST=State
L=City
O=Termix
OU=IT Department
CN=$DOMAIN

[v3_req]
basicConstraints = CA:FALSE
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
subjectAltName = @alt_names

[alt_names]
DNS.1 = $DOMAIN
DNS.2 = localhost
DNS.3 = 127.0.0.1
IP.1 = 127.0.0.1
IP.2 = ::1
IP.3 = 0.0.0.0
EOF

        openssl genrsa -out /app/data/ssl/termix.key 2048

        openssl req -new -x509 -key /app/data/ssl/termix.key -out /app/data/ssl/termix.crt -days 365 -config /app/data/ssl/openssl.conf -extensions v3_req

        chmod 600 /app/data/ssl/termix.key
        chmod 644 /app/data/ssl/termix.crt

        rm -f /app/data/ssl/openssl.conf
        
        echo "SSL certificates generated successfully for domain: $DOMAIN"
    fi
fi

echo "Starting nginx..."
nginx -c /tmp/nginx/nginx.conf

# Inject runtime BASE_PATH into frontend if configured
if [ -n "$BASE_PATH" ]; then
    echo "Injecting BASE_PATH: $BASE_PATH"
    # Strip trailing slash for use as a path prefix
    CLEAN_BASE_PATH="${BASE_PATH%/}"
    find /app/html -name "index.html" -exec sed -i "s|window.__TERMIX_BASE_PATH__ = \"\"|window.__TERMIX_BASE_PATH__ = \"$CLEAN_BASE_PATH\"|g" {} \;
    # Patch sw.js static asset paths with the base path prefix
    find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__|$CLEAN_BASE_PATH|g" {} \;
else
    # No base path - replace placeholder with empty string so paths stay absolute from root
    find /app/html -name "sw.js" -exec sed -i "s|__TERMIX_SW_BASE_PATH__||g" {} \;
fi

echo "Starting backend services..."
cd /app
export NODE_ENV=production

if [ -f "package.json" ]; then
    VERSION=$(grep '"version"' package.json | sed 's/.*"version": *"\([^"]*\)".*/\1/')
    if [ -n "$VERSION" ]; then
        export VERSION
    else
        echo "Warning: Could not extract version from package.json"
    fi
else
    echo "Warning: package.json not found"
fi

node dist/backend/backend/starter.js

echo "All services started"

tail -f /dev/null
```

## /docker/nginx-https.conf

```conf path="/docker/nginx-https.conf" 
worker_processes 1;
master_process off;
pid /tmp/nginx/nginx.pid;
error_log /tmp/nginx/error.log warn;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    access_log /tmp/nginx/access.log;

    client_body_temp_path /tmp/nginx/client_body;
    proxy_temp_path /tmp/nginx/proxy_temp;
    fastcgi_temp_path /tmp/nginx/fastcgi_temp;
    uwsgi_temp_path /tmp/nginx/uwsgi_temp;
    scgi_temp_path /tmp/nginx/scgi_temp;

    sendfile on;
    keepalive_timeout 65;
    client_header_timeout 300s;

    set_real_ip_from 127.0.0.1;
    real_ip_header X-Forwarded-For;

    map $http_x_forwarded_proto $proxy_x_forwarded_proto {
        default $http_x_forwarded_proto;
        ''      $scheme;
    }

    map $http_x_forwarded_host $proxy_x_forwarded_host {
        default $http_x_forwarded_host;
        ''      $http_host;
    }

    map $http_x_forwarded_port $proxy_x_forwarded_port {
        default $http_x_forwarded_port;
        ''      '';
    }

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    server {
        listen ${PORT};
        server_name _;

        return 301 https://$host:${SSL_PORT}$request_uri;
    }

    server {
        listen ${SSL_PORT} ssl;
        server_name _;

        absolute_redirect off;

        ssl_certificate ${SSL_CERT_PATH};
        ssl_certificate_key ${SSL_KEY_PATH};

        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options nosniff always;
        add_header X-XSS-Protection "1; mode=block" always;

        location = /sw.js {
            root /app/html;
            expires off;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
            try_files $uri =404;
        }

        location = /manifest.json {
            root /app/html;
            expires off;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
            try_files $uri =404;
        }

        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
            root /app/html;
            expires 1y;
            add_header Cache-Control "public, max-age=31536000, immutable" always;
            try_files $uri =404;
        }

        location / {
            root /app/html;
            index index.html index.htm;
            expires off;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
            try_files $uri $uri/ /index.html;
        }

        location ~* \.map$ {
            return 404;
            access_log off;
            log_not_found off;
        }

        location ~ ^/users/sessions(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
        }

        location ~ ^/users(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
            proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
            proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
        }

        location ~ ^/version(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/releases(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/alerts(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/rbac(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/credentials(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;
        }

        location ~ ^/snippets(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/c2s-tunnel-presets(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/audit-logs(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/terminal(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/open-tabs(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/user-preferences(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/database(/.*)?$ {
            client_max_body_size 5G;
            client_body_timeout 300s;

            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;

            proxy_request_buffering off;
            proxy_buffering off;
        }

        location ~ ^/db(/.*)?$ {
            client_max_body_size 5G;
            client_body_timeout 300s;

            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;

            proxy_request_buffering off;
            proxy_buffering off;
        }

        location ~ ^/encryption(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/quick-connect {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $http_host;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/host/opkssh-chooser(/.*)?$ {
            proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
            proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;

            proxy_cache_bypass 1;
            proxy_no_cache 1;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
        }

        location ~ ^/host/opkssh-callback(/.*)?$ {
            proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
            proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;

            proxy_cache_bypass 1;
            proxy_no_cache 1;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
        }

        location /host/ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /session_logs/ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /tailscale/ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /ssh/websocket/ {
            proxy_pass http://127.0.0.1:30002/;
            proxy_http_version 1.1;

            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
            proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
            proxy_cache_bypass $http_upgrade;

            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;
            proxy_connect_timeout 10s;

            proxy_buffering off;
            proxy_request_buffering off;

            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
        }

        location ^~ /guacamole/websocket/ {
            proxy_pass http://127.0.0.1:30008/;
            proxy_http_version 1.1;

            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $http_host;
            proxy_cache_bypass $http_upgrade;

            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Port $server_port;
            proxy_set_header X-Forwarded-Host $http_host;

            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;
            proxy_connect_timeout 10s;

            proxy_buffering off;
            proxy_request_buffering off;

            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
        }

        location ~ ^/guacamole(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/tunnel/ {
            proxy_pass http://127.0.0.1:30003;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /ssh/tunnel/ {
            proxy_pass http://127.0.0.1:30003;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;
            proxy_buffering off;
            proxy_cache off;
        }

        location /host/file_manager/recent {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/file_manager/pinned {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/file_manager/shortcuts {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/file_manager/sudo-password {
            proxy_pass http://127.0.0.1:30004;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /ssh/file_manager/ {
            client_max_body_size 5G;
            client_body_timeout 300s;

            add_header Cache-Control "no-store, no-cache, must-revalidate" always;

            proxy_pass http://127.0.0.1:30004;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;

            proxy_request_buffering off;
            proxy_buffering off;
        }

        location /host/file_manager/ssh/ {
            client_max_body_size 5G;
            client_body_timeout 300s;

            add_header Cache-Control "no-store, no-cache, must-revalidate" always;

            proxy_pass http://127.0.0.1:30004;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;

            proxy_request_buffering off;
            proxy_buffering off;
        }

        location ~ ^/network-topology(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /health {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/status(/.*)?$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/metrics(/.*)?$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 60s;
            proxy_read_timeout 60s;
        }

        location ~ ^/(refresh|host-updated)$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/host-metrics(/.*)?$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 600s;
            proxy_send_timeout 600s;
            proxy_read_timeout 600s;
        }

        location ~ ^/global-settings(/.*)?$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/uptime(/.*)?$ {
            proxy_pass http://127.0.0.1:30006;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/activity(/.*)?$ {
            proxy_pass http://127.0.0.1:30006;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ^~ /docker/console/ {
            proxy_pass http://127.0.0.1:30009/;
            proxy_http_version 1.1;

            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $http_host;
            proxy_cache_bypass $http_upgrade;

            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Port $server_port;
            proxy_set_header X-Forwarded-Host $http_host;

            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;
            proxy_connect_timeout 10s;

            proxy_buffering off;
            proxy_request_buffering off;

            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
        }

        # --- tmux-monitor begin ---
        location ~ ^/tmux_monitor(/.*)?$ {
            proxy_pass http://127.0.0.1:30010;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;
        }
        # --- tmux-monitor end ---

        location ~ ^/docker(/.*)?$ {
            proxy_pass http://127.0.0.1:30007;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;
        }

        error_page 500 502 503 504 /50x.html;
        location = /50x.html {
            root /app/html;
        }
    }
}

```

## /docker/nginx.conf

```conf path="/docker/nginx.conf" 
worker_processes 1;
master_process off;
pid /tmp/nginx/nginx.pid;
error_log /tmp/nginx/error.log warn;

events {
    worker_connections 1024;
}

http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    access_log /tmp/nginx/access.log;

    client_body_temp_path /tmp/nginx/client_body;
    proxy_temp_path /tmp/nginx/proxy_temp;
    fastcgi_temp_path /tmp/nginx/fastcgi_temp;
    uwsgi_temp_path /tmp/nginx/uwsgi_temp;
    scgi_temp_path /tmp/nginx/scgi_temp;

    sendfile on;
    keepalive_timeout 65;
    client_header_timeout 300s;

    set_real_ip_from 127.0.0.1;
    real_ip_header X-Forwarded-For;

    map $http_x_forwarded_proto $proxy_x_forwarded_proto {
        default $http_x_forwarded_proto;
        ''      $scheme;
    }

    map $http_x_forwarded_host $proxy_x_forwarded_host {
        default $http_x_forwarded_host;
        ''      $http_host;
    }

    map $http_x_forwarded_port $proxy_x_forwarded_port {
        default $http_x_forwarded_port;
        ''      '';
    }

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 10m;

    server {
        listen ${PORT};
        server_name _;

        absolute_redirect off;

        add_header X-Content-Type-Options nosniff always;
        add_header X-XSS-Protection "1; mode=block" always;

        location = /sw.js {
            root /app/html;
            expires off;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
            try_files $uri =404;
        }

        location = /manifest.json {
            root /app/html;
            expires off;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
            try_files $uri =404;
        }

        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
            root /app/html;
            expires 1y;
            add_header Cache-Control "public, max-age=31536000, immutable" always;
            try_files $uri =404;
        }

        location / {
            root /app/html;
            index index.html index.htm;
            expires off;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
            try_files $uri $uri/ /index.html;
        }

        location ~* \.map$ {
            return 404;
            access_log off;
            log_not_found off;
        }

        location ~ ^/users/sessions(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
        }

        location ~ ^/users(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
            proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
            proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
        }

        location ~ ^/version(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/releases(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/alerts(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/rbac(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/credentials(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;
        }

        location ~ ^/snippets(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/proxmox(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_connect_timeout 60s;
            proxy_send_timeout 120s;
            proxy_read_timeout 120s;
        }

        location ~ ^/c2s-tunnel-presets(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/audit-logs(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/terminal(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/open-tabs(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/user-preferences(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/database(/.*)?$ {
            client_max_body_size 5G;
            client_body_timeout 300s;

            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;

            proxy_request_buffering off;
            proxy_buffering off;
        }

        location ~ ^/db(/.*)?$ {
            client_max_body_size 5G;
            client_body_timeout 300s;

            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;

            proxy_request_buffering off;
            proxy_buffering off;
        }

        location ~ ^/encryption(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/quick-connect {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection 'upgrade';
            proxy_set_header Host $http_host;
            proxy_cache_bypass $http_upgrade;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/host/opkssh-chooser(/.*)?$ {
            proxy_pass http://127.0.0.1:30001/host/opkssh-chooser$1$is_args$args;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
            proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;

            proxy_cache_bypass 1;
            proxy_no_cache 1;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
        }

        location ~ ^/host/opkssh-callback(/.*)?$ {
            proxy_pass http://127.0.0.1:30001/host/opkssh-callback$1$is_args$args;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
            proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;

            proxy_cache_bypass 1;
            proxy_no_cache 1;
            add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
        }

        location /host/ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /session_logs/ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /tailscale/ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /ssh/websocket/ {
            proxy_pass http://127.0.0.1:30002/;
            proxy_http_version 1.1;

            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $http_host;
            proxy_set_header X-Forwarded-Host $proxy_x_forwarded_host;
            proxy_set_header X-Forwarded-Port $proxy_x_forwarded_port;
            proxy_set_header X-Forwarded-Proto $proxy_x_forwarded_proto;
            proxy_cache_bypass $http_upgrade;

            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;
            proxy_connect_timeout 10s;

            proxy_buffering off;
            proxy_request_buffering off;

            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
        }

        location ^~ /guacamole/websocket/ {
            proxy_pass http://127.0.0.1:30008/;
            proxy_http_version 1.1;

            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $http_host;
            proxy_cache_bypass $http_upgrade;

            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Port $server_port;
            proxy_set_header X-Forwarded-Host $http_host;

            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;
            proxy_connect_timeout 10s;

            proxy_buffering off;
            proxy_request_buffering off;

            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
        }

        location ~ ^/guacamole(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/tunnel/ {
            proxy_pass http://127.0.0.1:30003;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /ssh/tunnel/ {
            proxy_pass http://127.0.0.1:30003;
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;
            proxy_buffering off;
            proxy_cache off;
        }

        location /host/file_manager/recent {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/file_manager/pinned {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/file_manager/shortcuts {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /host/file_manager/sudo-password {
            proxy_pass http://127.0.0.1:30004;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /ssh/file_manager/ {
            client_max_body_size 5G;
            client_body_timeout 300s;

            add_header Cache-Control "no-store, no-cache, must-revalidate" always;

            proxy_pass http://127.0.0.1:30004;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;

            proxy_request_buffering off;
            proxy_buffering off;
        }

        location /host/file_manager/ssh/ {
            client_max_body_size 5G;
            client_body_timeout 300s;

            add_header Cache-Control "no-store, no-cache, must-revalidate" always;

            proxy_pass http://127.0.0.1:30004;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;

            proxy_request_buffering off;
            proxy_buffering off;
        }

        location ~ ^/network-topology(/.*)?$ {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /health {
            proxy_pass http://127.0.0.1:30001;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/status(/.*)?$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/metrics(/.*)?$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 60s;
            proxy_read_timeout 60s;
        }

        location ~ ^/(refresh|host-updated)$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/host-metrics(/.*)?$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 600s;
            proxy_send_timeout 600s;
            proxy_read_timeout 600s;
        }

        location ~ ^/global-settings(/.*)?$ {
            proxy_pass http://127.0.0.1:30005;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/uptime(/.*)?$ {
            proxy_pass http://127.0.0.1:30006;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/activity(/.*)?$ {
            proxy_pass http://127.0.0.1:30006;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ^~ /docker/console/ {
            proxy_pass http://127.0.0.1:30009/;
            proxy_http_version 1.1;

            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";
            proxy_set_header Host $http_host;
            proxy_cache_bypass $http_upgrade;

            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Port $server_port;
            proxy_set_header X-Forwarded-Host $http_host;

            proxy_read_timeout 86400s;
            proxy_send_timeout 86400s;
            proxy_connect_timeout 10s;

            proxy_buffering off;
            proxy_request_buffering off;

            proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
        }

        # --- tmux-monitor begin ---
        location ~ ^/tmux_monitor(/.*)?$ {
            proxy_pass http://127.0.0.1:30010;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;
        }
        # --- tmux-monitor end ---

        location ~ ^/docker(/.*)?$ {
            proxy_pass http://127.0.0.1:30007;
            proxy_http_version 1.1;
            proxy_set_header Host $http_host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;

            proxy_connect_timeout 60s;
            proxy_send_timeout 300s;
            proxy_read_timeout 300s;
        }

        error_page 500 502 503 504 /50x.html;
        location = /50x.html {
            root /app/html;
        }
    }
}

```

## /electron-builder.json

```json path="/electron-builder.json" 
{
  "appId": "com.karmaa.termix",
  "productName": "Termix",
  "publish": null,
  "directories": {
    "output": "release"
  },
  "asar": true,
  "asarUnpack": [
    "dist/backend/**/*",
    "node_modules/**/*",
    "public/icons/**/*",
    "public/icon.*"
  ],
  "files": [
    "dist/**/*",
    "electron/**/*",
    "public/**/*",
    "!src/**/*",
    "!*.md",
    "!tsconfig*.json",
    "!vite.config.ts",
    "!eslint.config.js",
    "!node_modules/@napi-rs/canvas*/**/*",
    "!node_modules/@rollup/rollup-darwin-*/**/*",
    "!node_modules/@rollup/rollup-linux-*/**/*",
    "!node_modules/@rollup/rollup-win32-*/**/*",
    "!dist/icon-mac.png",
    "!public/icon-mac.png",
    "!dist/icon.ico",
    "!public/icon.ico",
    "!dist/icon.icns",
    "!public/icon.icns",
    "!dist/icons/**/*",
    "!public/icons/**/*"
  ],
  "extraMetadata": {
    "main": "electron/main.cjs",
    "type": "commonjs"
  },
  "buildDependenciesFromSource": false,
  "nodeGypRebuild": false,
  "npmRebuild": true,
  "win": {
    "target": [
      {
        "target": "nsis",
        "arch": ["x64", "ia32"]
      },
      {
        "target": "msi",
        "arch": ["x64", "ia32"]
      }
    ],
    "icon": "public/icon.ico",
    "executableName": "Termix"
  },
  "nsis": {
    "oneClick": false,
    "allowToChangeInstallationDirectory": true,
    "artifactName": "termix_windows_${arch}_nsis.${ext}",
    "createDesktopShortcut": true,
    "createStartMenuShortcut": true,
    "shortcutName": "Termix",
    "uninstallDisplayName": "Termix"
  },
  "msi": {
    "artifactName": "termix_windows_${arch}_msi.${ext}"
  },
  "linux": {
    "artifactName": "termix_linux_${arch}_portable.${ext}",
    "target": [
      {
        "target": "AppImage",
        "arch": ["x64", "arm64", "armv7l"]
      },
      {
        "target": "deb",
        "arch": ["x64", "arm64", "armv7l"]
      },
      {
        "target": "tar.gz",
        "arch": ["x64", "arm64", "armv7l"]
      }
    ],
    "icon": "public/icon.png",
    "category": "Development",
    "executableName": "termix",
    "maintainer": "Termix <mail@termix.site>",
    "desktop": {
      "entry": {
        "Name": "Termix",
        "Comment": "Self-hosted SSH and remote desktop management. ",
        "Keywords": "docker;ssh;terminal;telnet;self-hosted;rdp;file-management;vnc;ssh-tunnel;server-stats;termix",
        "StartupWMClass": "termix"
      }
    }
  },
  "appImage": {
    "artifactName": "termix_linux_${arch}_appimage.${ext}"
  },
  "deb": {
    "artifactName": "termix_linux_${arch}_deb.${ext}"
  },

  "mac": {
    "target": [
      {
        "target": "mas",
        "arch": "universal"
      },
      {
        "target": "dmg",
        "arch": ["universal", "x64", "arm64"]
      }
    ],
    "icon": "public/icon.icns",
    "category": "public.app-category.developer-tools",
    "hardenedRuntime": true,
    "gatekeeperAssess": false,
    "entitlements": "build/entitlements.mac.plist",
    "entitlementsInherit": "build/entitlements.mac.inherit.plist",
    "type": "distribution",
    "minimumSystemVersion": "10.15",
    "mergeASARs": false,
    "singleArchFiles": "**/*.{node,bare}",
    "x64ArchFiles": "**/*.{node,bare}"
  },
  "dmg": {
    "artifactName": "termix_macos_${arch}_dmg.${ext}",
    "sign": true
  },
  "afterSign": "build/notarize.cjs",
  "mas": {
    "provisioningProfile": "build/Termix_Mac_App_Store.provisionprofile",
    "entitlements": "build/entitlements.mas.plist",
    "entitlementsInherit": "build/entitlements.mas.inherit.plist",
    "hardenedRuntime": false,
    "gatekeeperAssess": false,
    "type": "distribution",
    "category": "public.app-category.developer-tools",
    "artifactName": "termix_macos_${arch}_mas.${ext}",
    "extendInfo": {
      "ITSAppUsesNonExemptEncryption": false,
      "NSAppleEventsUsageDescription": "Termix needs access to control other applications for terminal operations."
    }
  },
  "generateUpdatesFilesForAllChannels": true
}

```

## /electron/main.cjs

```cjs path="/electron/main.cjs" 
const {
  app,
  BrowserWindow,
  shell,
  ipcMain,
  dialog,
  Menu,
  session,
  safeStorage,
  Tray,
  clipboard,
} = require("electron");
const path = require("path");
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 } = require("child_process");
const WebSocket = require("ws");

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
  }

  const config = getServerConfigSync();
  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");
}

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 isDev = process.env.NODE_ENV === "development" || !app.isPackaged;
const appRoot = isDev ? process.cwd() : path.join(__dirname, "..");
const electronCacheBuildPath = path.join(
  app.getPath("userData"),
  "client-cache-build.json",
);
const termixSessionPartition = "persist:termix";

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
  const unpackedRoot = appRoot.replace(
    /app\.asar(?!\.unpacked)/,
    "app.asar.unpacked",
  );
  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 startBackendServer() {
  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",
      },
      stdio: ["pipe", "pipe", "pipe", "ipc"],
    });

    logToFile("Backend process spawned, pid:", backendProcess.pid);

    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;
      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 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;
  });
}

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.asar", "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", "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,
    embedded: !isDev,
    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, reject) => {
      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>`,
          );

          server.close();
          if (success === "true") {
            resolve({ success: true, token });
          } else {
            resolve({
              success: false,
              error: error || "Authentication failed",
            });
          }
        }
      });

      server.listen(callbackPort, "127.0.0.1", () => {
        shell.openExternal(authUrl);
      });

      // Timeout after 5 minutes
      setTimeout(
        () => {
          server.close();
          reject(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 };
  }
});

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;

function getC2SRelayUrl() {
  const config = getServerConfigSync();
  const serverUrl =
    config?.serverUrl || (!isDev ? "http://127.0.0.1:30003" : null);
  if (!serverUrl) {
    throw new Error("No Termix server configured");
  }

  const base = serverUrl.replace(/\/$/, "");
  const relayHttpUrl = base.endsWith(":30003")
    ? `${base}/ssh/tunnel/c2s/stream`
    : `${base}/ssh/tunnel/c2s/stream`;
  return relayHttpUrl.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
}

async function getC2SRelayHeaders(relayUrl) {
  if (!mainWindow?.webContents?.session) return {};

  const cookieUrl = relayUrl
    .replace(/^ws:/, "http:")
    .replace(/^wss:/, "https:");
  const cookies = await mainWindow.webContents.session.cookies.get({
    url: cookieUrl,
    name: "jwt",
  });
  const jwt = cookies[0]?.value;
  if (!jwt) return {};

  return {
    Cookie: `jwt=${encodeURIComponent(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(relayUrl);
  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(relayUrl);
  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(relayUrl);
  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("test-server-connection", async (event, serverUrl) => {
  try {
    const normalizedServerUrl = serverUrl.replace(/\/$/, "");

    const healthUrl = `${normalizedServerUrl}/health`;

    try {
      const response = await httpFetch(healthUrl, {
        method: "GET",
        timeout: 10000,
      });

      if (response.ok) {
        const data = await response.text();

        if (
          data.includes("<html") ||
          data.includes("<!DOCTYPE") ||
          data.includes("<head>") ||
          data.includes("<body>")
        ) {
          return {
            success: false,
            error:
              "Server returned HTML instead of JSON. This does not appear to be a Termix server.",
          };
        }

        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");
        }
      }
    } catch (urlError) {
      console.error("Health check failed:", urlError);
    }

    try {
      const versionUrl = `${normalizedServerUrl}/version`;
      const response = await httpFetch(versionUrl, {
        method: "GET",
        timeout: 10000,
      });

      if (response.ok) {
        const data = await response.text();

        if (
          data.includes("<html") ||
          data.includes("<!DOCTYPE") ||
          data.includes("<head>") ||
          data.includes("<body>")
        ) {
          return {
            success: false,
            error:
              "Server returned HTML instead of JSON. This does not appear to be a Termix server.",
          };
        }

        try {
          const versionData = JSON.parse(data);
          if (
            versionData &&
            (versionData.status === "up_to_date" ||
              versionData.status === "requires_update" ||
              (versionData.localVersion &&
                versionData.version &&
                versionData.latest_release))
          ) {
            return {
              success: true,
              status: response.status,
              testedUrl: versionUrl,
              warning:
                "Health endpoint not available, but server appears to be running",
            };
          }
        } catch (parseError) {
          console.log("Version endpoint did not return valid JSON");
        }
      }
    } catch (versionError) {
      console.error("Version check failed:", versionError);
    }

    return {
      success: false,
      error:
        "Server is not responding or does not appear to be a valid Termix server. 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,
  );
  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();
  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...");
  stopAllC2STunnels();
  stopBackendServer();
});

process.on("uncaughtException", (error) => {
  console.error("Uncaught Exception:", error);
});

process.on("unhandledRejection", (reason, promise) => {
  console.error("Unhandled Rejection at:", promise, "reason:", reason);
});

```

## /electron/preload.js

```js path="/electron/preload.js" 
const { contextBridge, ipcRenderer } = require("electron");

contextBridge.exposeInMainWorld("electronAPI", {
  getAppVersion: () => ipcRenderer.invoke("get-app-version"),

  removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
  isElectron: true,
  isDev: process.env.NODE_ENV === "development",

  getSetting: (key) => ipcRenderer.invoke("get-setting", key),
  setSetting: (key, value) => ipcRenderer.invoke("set-setting", key, value),
  getC2STunnelConfig: () => ipcRenderer.invoke("get-c2s-tunnel-config"),
  saveC2STunnelConfig: (config) =>
    ipcRenderer.invoke("save-c2s-tunnel-config", config),
  checkLocalPortAvailable: (host, port) =>
    ipcRenderer.invoke("check-local-port-available", host, port),
  getC2STunnelPresetDefaultName: () =>
    ipcRenderer.invoke("get-c2s-tunnel-preset-default-name"),
  startC2STunnel: (tunnel, index) =>
    ipcRenderer.invoke("start-c2s-tunnel", tunnel, index),
  testC2STunnel: (tunnel, index) =>
    ipcRenderer.invoke("test-c2s-tunnel", tunnel, index),
  stopC2STunnel: (tunnelName) =>
    ipcRenderer.invoke("stop-c2s-tunnel", tunnelName),
  getC2STunnelStatuses: () => ipcRenderer.invoke("get-c2s-tunnel-statuses"),
  onC2STunnelStatuses: (callback) => {
    const listener = (_event, statuses) => callback(statuses);
    ipcRenderer.on("c2s-tunnel-statuses", listener);
    return () => ipcRenderer.removeListener("c2s-tunnel-statuses", listener);
  },
  startC2SAutoStartTunnels: () =>
    ipcRenderer.invoke("start-c2s-autostart-tunnels"),

  clearSessionCookies: () => ipcRenderer.invoke("clear-session-cookies"),
  getSessionCookie: (name, targetUrl) =>
    ipcRenderer.invoke("get-session-cookie", name, targetUrl),
  waitForSessionCookie: (name, targetUrl, previousValue, timeoutMs) =>
    ipcRenderer.invoke(
      "wait-session-cookie",
      name,
      targetUrl,
      previousValue,
      timeoutMs,
    ),

  oidcSystemBrowserAuth: (authUrl, callbackPort) =>
    ipcRenderer.invoke("oidc-system-browser-auth", authUrl, callbackPort),

  invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
});

contextBridge.exposeInMainWorld("electronClipboard", {
  writeText: (text) => ipcRenderer.invoke("clipboard-write-text", text),
  readText: () => ipcRenderer.invoke("clipboard-read-text"),
});

window.IS_ELECTRON = true;

```

## /flatpak/com.karmaa.termix.desktop

```desktop path="/flatpak/com.karmaa.termix.desktop" 
[Desktop Entry]
Name=Termix
Comment=Self-hosted SSH and remote desktop management.
Exec=run.sh %U
Icon=com.karmaa.termix
Terminal=false
Type=Application
Categories=Development;Network;System;
Keywords=ssh;terminal;server;management;tunnel;
StartupWMClass=termix
StartupNotify=true

```

## /flatpak/flathub.json

```json path="/flatpak/flathub.json" 
{
  "only-arches": ["x86_64", "aarch64"],
  "skip-icons-check": false,
  "skip-appstream-check": false
}

```

## /public/favicon.ico

Binary file available at https://raw.githubusercontent.com/LukeGus/Termix/refs/heads/main/public/favicon.ico

## /public/fonts/CaskaydiaCoveNerdFontMono-Bold.ttf

Binary file available at https://raw.githubusercontent.com/LukeGus/Termix/refs/heads/main/public/fonts/CaskaydiaCoveNerdFontMono-Bold.ttf

## /public/fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf

Binary file available at https://raw.githubusercontent.com/LukeGus/Termix/refs/heads/main/public/fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf

## /public/full-icon.png

Binary file available at https://raw.githubusercontent.com/LukeGus/Termix/refs/heads/main/public/full-icon.png


The content has been capped at 50000 tokens. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.