Skip to content

Latest commit

 

History

History
724 lines (635 loc) · 56 KB

File metadata and controls

724 lines (635 loc) · 56 KB

Remote Commander — Architecture & Code Reference

Audience: maintainers and AI coding assistants. This document is an exhaustive, ground-truth map of the codebase: the process model, the full IPC contract, every service / store / component, the security model, cross-cutting conventions, known quirks, and step-by-step recipes for extending the app. Read this before changing code; update it when you change architecture.

Companion docs: README.md (features / landing page) · BUILD.md (dev setup + packaging).

Version described: 0.1.0. Last verified against source: 2026-05-22.


1. What this app is

Remote Commander is an Electron desktop app that unifies remote access across SSH, SFTP, RDP, VNC, and VPN (OpenVPN/WireGuard), plus a local terminal, in a single VS Code–styled, tabbed, split-pane window. Profiles are stored locally; credentials are encrypted at rest; protocol clients are either pure-JS (SSH/SFTP/VNC) or shell out to system tools (RDP via guacd/FreeRDP, VPN via openvpn/wg-quick).


2. Repository layout (read this first)

The git repo root is not the app. The Electron project lives one level down in a nested remoteCommander/ subfolder. All npm commands run in the nested folder.

remoteCommander/                  ← git repo root — docs + these guides live here
├── README.md                     ← GitHub landing page
├── ARCHITECTURE.md               ← this file
├── BUILD.md                      ← dev setup + packaging
├── LICENSE                       ← MIT
├── .gitignore                    ← ignores *.pem, *.ovpn, node_modules, out, dist, .claude …
├── .github/workflows/release.yml ← CI: builds Win/macOS/Linux installers + GitHub Release
├── docs/                         ← end-user install guides (install-windows/macos/linux.md)
├── imgs/                         ← logo.svg (app logo source) + screenshots + generate-icons.mjs
└── remoteCommander/              ← THE ELECTRON APP  (cd here for npm)
    ├── package.json              ← name "remotecommander", version 0.1.0
    ├── electron.vite.config.ts   ← electron-vite config (main/preload/renderer)
    ├── electron-builder.yml      ← installer/packaging config
    ├── tsconfig.{json,node,web}.json
    ├── .npmrc                    ← comment-only; Electron downloads use official GitHub sources
    ├── build/                    ← icon.png (1024 master; .ico/.icns generated by electron-builder) + mac entitlements
    ├── resources/                ← icon.png bundled into the app (asarUnpack)
    └── src/
        ├── main/                 ← Node/Electron main process
        │   ├── index.ts          ← bootstrap, window, menu, GPU/maximize/webview hardening, lifecycle
        │   ├── ipc/              ← thin ipcMain.handle wrappers, one file per domain
        │   │   ├── channels.ts   ← the Ch channel-name registry (single source of truth)
        │   │   ├── ssh.ts sftp.ts rdp.ts vnc.ts vpn.ts local.ts web.ts
        │   │   ├── credentials.ts profiles.ts audit.ts
        │   ├── services/         ← all real logic lives here
        │   │   ├── SshService.ts SftpService.ts
        │   │   ├── RdpService.ts GuacamoleService.ts
        │   │   ├── VncService.ts VpnService.ts LocalTerminalService.ts
        │   │   ├── WebSecurityService.ts             ← web-console TLS opt-in + per-partition proxy
        │   │   ├── CredentialService.ts secretCrypto.ts
        │   │   ├── StoreService.ts AuditService.ts
        │   ├── guacamole-lite.d.ts node-pty.d.ts   ← ambient types for untyped deps
        ├── preload/
        │   ├── index.ts          ← contextBridge: allow-listed invoke/send/on
        │   └── index.d.ts
        └── renderer/             ← React 19 UI (Chromium)
            ├── index.html        ← CSP lives here
            └── src/
                ├── main.tsx App.tsx
                ├── assets/base.css (theme tokens + scrollbars) main.css
                ├── lib/ipc.ts        ← typed wrapper over window.electronAPI (USE THIS, not raw)
                ├── lib/docFormat.ts  ← JSON/Markdown formatting for the web doc viewer
                ├── store/        ← Zustand: tabStore, profileStore, vpnStore, workspaceStore
                ├── types/        ← profile.ts, session.ts, transfer.ts, webview.d.ts, *.d.ts
                └── components/
                    ├── layout/   ← TitleBar, Sidebar, SplitPane, TabBar
                    ├── tabs/      ← Ssh, Sftp, Rdp, Vnc, Web, Editor, Vpn, LocalTerminal, Audit
                    ├── profiles/  ← ProfileEditor
                    ├── sftp/      ← FilePane, TransferQueue, PermissionEditor
                    ├── AboutDialog.tsx  ← Help → About (version/author/MIT/runtime versions)
                    └── Versions.tsx     ← UNUSED electron-vite template leftover (safe to delete)

3. High-level architecture

Electron's three contexts, and how a click becomes a remote session:

┌──────────────────────────── RENDERER (Chromium, React 19) ────────────────────────────┐
│  Components ──▶ Zustand stores ──▶ lib/ipc.ts (typed wrapper)                            │
│        ▲                                   │ window.electronAPI.invoke/send/on           │
│        │ main→renderer events              ▼                                             │
└────────┼─────────────────────────── contextBridge ──────────────────────────────────────┘
         │                                   │  (preload/index.ts — ALLOW-LISTS channels)
┌────────┼───────────────────────────────── MAIN (Node) ──────────────────────────────────┐
│   webContents.send(`ssh:data:<id>`)   ipcMain.handle(channel)                            │
│        │                                   │                                             │
│   ipc/*.ts (thin handlers) ──────────▶ services/*.ts (all logic, hold session state)     │
│                                            │                                             │
│   ssh2 │ ssh2-sftp │ ws+net (VNC) │ guacamole-lite │ child_process (FreeRDP/openvpn/wg)  │
│   node-pty │ keytar │ better-sqlite3 │ safeStorage │ fs (json stores)                    │
└──────────────────────────────────────────┼─────────────────────────────────────────────┘
                                            ▼
                 Remote hosts · guacd:4822 · sudo openvpn/wg-quick · local PTY

Golden rules of the codebase:

  1. Renderer is untrusted. It never imports Node modules. Everything crosses the contextBridge, and the preload allow-lists every channel (preload/index.ts).
  2. All real logic lives in services/. The ipc/*.ts files are thin: validate → call a service method → return {…} or { error }.
  3. Renderer talks to main only through lib/ipc.ts. Components never touch window.electronAPI directly.
  4. Channel names exist once, in channels.ts (Ch), and are mirrored as string literals in the preload allow-lists + lib/ipc.ts. Change all three together.

Tech stack

Layer Tech
Shell Electron 39, electron-vite 5, electron-builder 26
Language TypeScript 5.9 (strict via @electron-toolkit/tsconfig)
UI React 19, Tailwind CSS v4 (@tailwindcss/vite), Radix UI (dialog, context-menu, tabs)
State Zustand 5
Terminals xterm 5 + addons (fit, search, web-links)
SSH/SFTP ssh2, ssh2-sftp-client
VNC @novnc/novnc (canvas) + in-app ws/net proxy
RDP guacamole-lite (server proxy) + guacamole-common-js (canvas); FreeRDP CLI fallback
Local PTY node-pty (optional native module)
Secrets keytar (OS keychain) → encrypted-file fallback via Electron safeStorage
Audit better-sqlite3 (lazy/optional native module)

4. Process model & security

  • GPU policy (Linux): by default app.disableHardwareAcceleration() + enable-unsafe-swiftshader (WSL2/headless GPU otherwise blanks or stalls the window). Set RC_ENABLE_GPU=1 to keep the real GPU instead — needed for smooth <webview>/RDP rendering on a real desktop. log-level 3 silences benign Chromium/GPU chatter.
  • app.whenReady() registers all IPC handlers, builds the menu, creates the window.
  • Window: frame: false (frameless — renderer draws its own TitleBar), backgroundColor: '#1e1e1e', 1280×800 (min 800×500), contextIsolation: true, sandbox: false.
    • sandbox:false is the electron-vite scaffold default — it lets the bundled preload use Node's require. Security here rests on contextIsolation: true plus the allow-listing bridge, not on the OS sandbox: the renderer still has no direct Node access.
  • Emits window:state <bool> on maximize/unmaximize so the title bar can swap its icon.
  • "Fake maximize" (toggleFakeMaximize + fakeMaxBounds: Map<winId, Rectangle>): the frameless window does not call win.maximize() (that froze the WM on some Linux setups). Instead it saves the current bounds and setBounds(screen.getDisplayMatching(...).workArea); toggling restores the saved bounds. window:control 'isMaximized' reports fakeMaxBounds.has(win.id). Cleared on closed.
  • setWindowOpenHandler denies in-app window.open and routes URLs to the system browser.
  • Web-console hardening (will-attach-webview + app.on('web-contents-created')hardenGuestContents): every <webview> guest is forced to no Node integration, sandbox on, webSecurity on, and its popups are denied (setWindowOpenHandler → deny). app.on('certificate-error')handleCertificateError only ignores TLS errors for origins a user has explicitly opted in (persistently per profile, or for the session via the in-page interstitial); otherwise it rejects but stashes the cert details (recordCertError) so the renderer can show a browser-style "Proceed anyway" prompt (see §6.13).
  • Menu (buildMenu): File (Export/Import profiles, New Local Terminal, Quit), Edit (roles), View (Connection History, reload, devtools, fullscreen), Help (About → menu:about, opens the in-app About dialog). On macOS the app menu is prepended. Menu items webContents.send('menu:*') — the renderer subscribes via ipc.menu.*.
  • Universal right-click context menu (attachContextMenu, wired via the same app.on('web-contents-created') callback): every webContents — main window and each guest <webview> — gets a Cut/Copy/Paste/Select-All menu (plus Copy Link when params.linkURL is set). Editable fields show Cut/Copy/Paste/Select-All gated by editFlags; non-editable selections show Copy; empty areas show nothing. Renderer components that own their own menu (Radix TabContextMenu on tabs, the SFTP file-row menu, and the xterm terminal canvas) call preventDefault on the contextmenu event, which suppresses this fallback — no duplicate popups. Keyboard paths (Edit-menu Ctrl+C/V accelerators, terminal Ctrl+Shift+C/V) are unaffected.
  • window:control handler (registerWindowHandlers): the title bar's min/max/close/reload/devtools/fullscreen/openExternal all funnel through this one invoke channel.
  • window-all-closed: calls disconnectAll() on every service (+ GuacamoleService.shutdown()), then app.quit() on non-macOS.

Exposes two globals via contextBridge:

  • window.electron — the standard @electron-toolkit/preload API.
  • window.electronAPI — our custom bridge with three methods, each allow-listed:
    • invoke(channel, ...args) → rejects unless channel ∈ INVOKE_CHANNELS.
    • send(channel, ...args) → no-op unless channel ∈ SEND_CHANNELS ({ 'ssh:data', 'local:data' } — the two high-frequency keystroke streams).
    • on(channel, cb) → throws unless channel starts with an allowed prefix in EVENT_PREFIXES = ['ssh:', 'sftp:', 'rdp:', 'vnc:', 'vpn:', 'menu:', 'window:', 'local:']; returns an unsubscribe function.

When you add a channel you must add it to the relevant allow-list here, or the renderer call fails at runtime even though types compile.

Renderer security

  • CSP in index.html: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws://localhost:* ws://127.0.0.1:*. The connect-src ws://… entries are load-bearing — they allow the VNC and RDP WebSocket proxies. Removing them breaks VNC/RDP with a silent CSP block.

Secrets at rest

See §9. Summary: passwords/passphrases go through keytar (OS keychain) when available, else an encrypted JSON file; VPN passwords are encrypted inside the VPN profile and never sent to the renderer; profile export is AES-256-GCM with a scrypt-derived key.


5. The IPC contract (the heart of the app)

5.1 Naming conventions

  • Request/response: ipcMain.handle + ipcRenderer.invoke. Returns a result object or { error: string }. Some return { needsPassword: true }.
  • Fire-and-forget (renderer→main): ipcMain.on + ipcRenderer.send. Only the two keystroke channels (ssh:data, local:data).
  • Streaming (main→renderer): webContents.send with a session-scoped suffix, e.g. ssh:data:<sessionId>, vnc:status:<sessionId>, sftp:progress:<transferId>, vpn:status:<vpnProfileId>. The renderer subscribes via ipc.<domain>.onData/onStatus/onProgress(id, cb).
  • Session IDs are crypto.randomUUID() minted by the service on connect; the renderer keeps them in refs and uses them to subscribe + disconnect.

5.2 The Ch registry — channels.ts

Single source of truth for channel-name strings. Grouped by domain. Event-prefix constants (e.g. SSH_STATUS = 'ssh:status') document the prefix; the real channel adds :<id>.

5.3 Channel reference

Channel (const) Kind Args → Return Service
ssh:connect invoke (profileId){ sessionId, status } | { error } SshService.connect
ssh:data send (sessionId, data) user keystrokes SshService.send
ssh:resize invoke (sessionId, cols, rows){ ok } SshService.resize
ssh:disconnect invoke (sessionId){ ok } SshService.disconnect
ssh:data:<id> event shell output (binary string)
ssh:status:<id> event 'connecting'|'connected'|'reconnecting'|'disconnected'
sftp:connect invoke (profileId){ sessionId, localHome, remoteHome } | { error } SftpService.connect
sftp:list / sftp:listLocal invoke (sessionId, path){ entries } | { error } SftpService.list/listLocal
sftp:upload / sftp:download invoke (sessionId, a, b){ transferId } | { error } SftpService.upload/download
sftp:delete/mkdir/rename/chmod invoke (sessionId, …){ ok } | { error } SftpService.*
sftp:cancelTransfer invoke (transferId){ ok } SftpService.cancelTransfer
sftp:readFile / sftp:writeFile invoke (sessionId, path[, content]){ content }/{ ok } | { error } SftpService.* (editor, remote files)
sftp:readLocalFile / sftp:writeLocalFile invoke (path[, content]){ content }/{ ok } | { error } SftpService.* (editor, local files)
sftp:progress:<transferId> event TransferProgress (transferred/total/speed/eta/status)
sftp:status:<id> event 'disconnected' (owned-client drop)
rdp:detectBinary invoke (){ path: string|null } RdpService.detectBinary
rdp:connect invoke ({profileId,password?,width?,height?}){ sessionId } | { needsPassword } | { error } RdpService.connect (external FreeRDP)
rdp:disconnect invoke (sessionId){ ok } RdpService.disconnect
rdp:guacConnect invoke ({profileId,password?,width?,height?}){ sessionId, wsPort, token } | { needsPassword } | { error } GuacamoleService.connect (in-tab)
rdp:guacDisconnect invoke (sessionId){ ok } (audit only; shared proxy stays up)
rdp:status:<id> event 'connecting'|'connected'|'disconnected'|'error:<msg>' — (external path)
vnc:connect invoke (profileId){ sessionId, localWsPort, password } | { error } VncService.connect
vnc:disconnect invoke (sessionId){ ok } VncService.disconnect
vnc:status:<id> event 'connecting'|'connected'|'disconnected'|'error:<msg>'
web:allowInsecureCerts / web:revokeInsecureCerts invoke (url|origin){ origin }/{ ok } WebSecurityService (per-origin TLS opt-in)
web:getCertError invoke (webContentsId)CertErrorInfo | null WebSecurityService (cert details for the interstitial; read clears)
web:setProxy invoke (partition, proxyRules){ ok } | { error } WebSecurityService (per-partition proxy)
vpn:connect invoke (vpnProfileId){ ok } | { error } VpnService.connect
vpn:disconnect invoke (vpnProfileId){ ok } VpnService.disconnect
vpn:getStatus invoke (vpnProfileId)VpnStatus VpnService.getStatus
vpn:listProfiles invoke (){ profiles } (passwordEnc stripped) VpnService.listProfiles
vpn:saveProfile invoke (profile, password?){ ok } VpnService.saveProfile
vpn:deleteProfile invoke (vpnProfileId){ ok } VpnService.deleteProfile
vpn:status:<vpnProfileId> event VpnStatus ({ state, assignedIp?, error? })
local:connect invoke (cols, rows){ sessionId } | { error } LocalTerminalService.connect
local:data send (sessionId, data) keystrokes → PTY LocalTerminalService.send
local:resize / local:disconnect invoke (sessionId, …){ ok } LocalTerminalService.*
local:data:<id> / local:status:<id> event PTY output / 'disconnected'
credentials:set/get/delete invoke (account, secret?){ ok }/{ secret } CredentialService.*
store:loadProfiles invoke (){ version, groups, profiles } StoreService.load
store:saveProfiles invoke (groups, profiles){ ok } StoreService.save
workspaces:load / workspaces:save invoke ()/(workspaces)Workspace[]/{ ok } StoreService.*
audit:query invoke (filters){ events } AuditService.query
audit:export invoke (filters){ csv } AuditService.exportCsv
profiles:export invoke ({profiles,groups,password}){ ok } | { cancelled } | { error } profiles.ts (AES-256-GCM)
profiles:import invoke ({password}){ profiles, groups } | { cancelled } | { error } profiles.ts
dialog:openFile invoke (options){ filePath: string|null } profiles.ts
window:control invoke (action, arg?){ ok }/{ value } index.ts
menu:export-profiles / menu:import-profiles / menu:connection-history / menu:new-local-terminal / menu:about event menu → renderer index.ts

5.4 Renderer wrapper — lib/ipc.ts

Exposes a typed ipc object grouped by domain (ipc.ssh, ipc.sftp, ipc.rdp, ipc.vnc, ipc.vpn, ipc.local, ipc.credentials, ipc.store, ipc.workspaces, ipc.audit, ipc.profiles, ipc.dialog, ipc.window, ipc.menu). Also re-exports the renderer-side types VpnStatus, ConnectionEvent, AuditFilters, SftpFileEntry, SftpTransferProgress. All components import from here.


6. Main process — services

Every service is a module-singleton object holding a Map of live sessions keyed by a UUID (VPN keys by vpnProfileId). Each emits main→renderer events with BrowserWindow.getAllWindows()[0]?.webContents.send(...). Each exposes disconnectAll() for shutdown.

6.1 SshService — SshService.ts

  • sessions: Map<sessionId, { client, shell, jumpClient?, profileId, retries }>.
  • connect(profileId): resolves the profile from StoreService, builds auth opts, opens an ssh2 client; for jumpHost profiles it connects the bastion first then forwardOut to the target (sock). Resolves after the shell is open, so the renderer treats resolution as "connected".
  • Auth (buildAuthOpts): passwordCredentialService.get(profileId); key → read privateKeyPath from disk + passphrase from CredentialService.get(profileId:passphrase); agentprocess.env.SSH_AUTH_SOCK.
  • Shell: client.shell({ term: 'xterm-256color' }). Output is sent as a binary string (chunk.toString('binary')); the renderer writes it straight into xterm.
  • Auto-reconnect: on shell close, scheduleReconnect retries up to MAX_RETRIES = 3 with backoff RETRY_DELAY_MS(2000) * attempt, emitting 'reconnecting' then 'connected'/'disconnected'. Deleting the session (manual disconnect) bails the retry.

6.2 SftpService — SftpService.ts

  • Connection reuse: if a live SshService client exists for the same profileId, SFTP rides on it (ownedClient = false) — otherwise it opens its own (ownedClient = true, closed on disconnect). connect returns { sessionId, localHome (os.homedir()), remoteHome (/home/<username>) }.
  • list/listLocal return FileEntry[] (name/size/mtime ms/permissions raw mode/isDir/ isSymlink), dirs first then alpha. listLocal uses fs/promises.
  • Transfers: upload/download stream with createReadStream/createWriteStream, emit sftp:progress:<transferId> (transferred/total/speed/eta/status). Each transfer registers a cancel() (destroys the streams). uploadFolder recurses.
  • delete tries unlink then rmdir; mkdir/rename/chmod map to sftp calls.

6.3 RdpService — RdpService.ts (external FreeRDP, the fallback path)

  • detectBinary() probes xfreerdp/xfreerdp3 (POSIX) or wfreerdp(.exe) (Windows) via which/where.
  • connect(profileId, {password?,width?,height?}) spawns FreeRDP with /v /u /w /h /bpp /cert:… /p /d. Password comes from opts or CredentialService; returns the literal 'NEEDS_PASSWORD' if none. Resolution from opts or profile (rdpResolution/custom).
  • Heuristic status: a 3 s timer assumes "connected" if the process is still alive; stdout/stderr is regex-scanned for auth/connection failures.

6.4 GuacamoleService — GuacamoleService.ts (in-tab RDP, the primary path)

  • One shared guacamole-lite server for the app lifetime, on a random 127.0.0.1 port, proxying to guacd at 127.0.0.1:4822. ensureServer() lazily creates it.
  • probeGuacd() does a 2 s TCP check so the UI can fail fast and offer the FreeRDP fallback when guacd isn't running.
  • Token: encryptToken() produces base64(JSON{ iv, value }) where value is AES-256-CBC(JSON(payload)) with a per-launch random 32-byte key — exactly what guacamole-lite decrypts. The token carries the RDP settings (hostname/port/username/ password/ignore-cert/resize-method/width/height/dpi/domain/color-depth).
  • Returns { sessionId, wsPort, token } | { needsPassword } | { error }. The renderer opens a Guacamole.WebSocketTunnel to ws://127.0.0.1:<wsPort> and connects with token=…. shutdown() closes the shared server.

6.5 VncService — VncService.ts

  • An in-process websockify: a ws WebSocketServer on a random 127.0.0.1 port that, per client connection, opens a raw net TCP socket to host:<vncPort> and pipes bytes both ways. handleProtocols accepts the binary subprotocol noVNC requests.
  • resolveVncPort: vncPort5900 + vncDisplayport5900.
  • 15 s TCP connect timeout (then cleared). Emits vnc:status:<id> connecting/connected/disconnected/error:<msg>. Returns the VNC password (from keytar) to the renderer, which passes it to noVNC.

6.6 VpnService — VpnService.ts

  • sessions: Map<vpnProfileId, { type, configPath, process, status, pollTimer, elevated, authFile }>.
  • Binaries located with which() (uses execFileSync, no shell).
  • Elevation (elevate): on POSIX non-root, prefixes sudo -n <bin> … (non-interactive → fails fast with a clear message if NOPASSWD isn't configured, instead of hanging on a TTY-less password prompt). On Windows / when already root, runs directly.
  • OpenVPN (startOpenVpn): runs foreground, parses the log — Initialization Sequence Completed → connected; ifconfig <ip> / PUSH: … ifconfig <ip> → assigned IP; AUTH_FAILED → emit error. If the profile has a username, credentials are written to a temp 0600 file passed via --auth-user-pass <file> --auth-nocache (password decrypted from passwordEnc). Keeps a rolling 4 KB log tail; abnormal exit before "connected" emits extractError(tail).
  • WireGuard (startWireGuard): wg-quick up <config>; exit 0 = connected, then reads the assigned IP via ip -4 addr show <iface> (iface = config basename).
  • Polling (startPolling, every 3 s): OpenVPN → check process.exitCode; WireGuard → ip link show <iface>. Emits out-of-band disconnects.
  • Disconnect: elevated OpenVPN → sudo -n pkill -f <configPath> (a non-root parent can't signal the root child it launched via sudo); non-elevated → SIGTERM; WireGuard → wg-quick down. Cleans up the temp auth file.
  • listProfiles() strips passwordEnc before returning to the renderer.

6.7 LocalTerminalService — LocalTerminalService.ts

  • Wraps node-pty, which is optional: loadPty() lazily requires it and caches null on failure. connect() throws a clear "install build-essential then node-pty" message when absent. Spawns $SHELL//bin/bash (POSIX) or %COMSPEC%/powershell.exe (Windows) in os.homedir(); pipes onDatalocal:data:<id>, onExitlocal:status:<id>.

6.8 CredentialService — CredentialService.ts

  • Primary: keytar under service name RemoteCommander, account = profileId (password) or profileId:passphrase (key passphrase).
  • Fallback: if keytar is missing or throws at runtime (e.g. WSL2 with no keyring), it switches permanently (disableKeytar) to an encrypted JSON file <userData>/credentials.json (values via secretCrypto, in-memory cache).

6.9 secretCrypto — secretCrypto.ts

  • encryptSecret: v1:<safeStorage base64> when safeStorage.isEncryptionAvailable(), else b64:<base64> (the b64: tag marks the insecure fallback path).
  • decryptSecret: routes by prefix. Used by both CredentialService and VpnService.

6.10 StoreService — StoreService.ts

  • Plain JSON files in app.getPath('userData'):
    • profiles.json{ version, groups, profiles } (profiles are opaque — the renderer owns the shape).
    • vpn-profiles.jsonStoredVpnProfile[] (kept separate so saving server profiles never clobbers VPN profiles). StoredVpnProfile includes passwordEnc?.
    • workspaces.json → opaque workspace array.
  • All reads are crash-safe (return defaults on parse error).

6.11 AuditService — AuditService.ts

  • Lazy, optional better-sqlite3. loadDriver()/getDb() guard so a broken native build degrades audit to a no-op instead of crashing the app. DB at <userData>/audit.db, WAL mode, single connection_events table.
  • logConnect(sessionId, profileId, protocol) inserts a row (resolving profile name/host/ user from StoreService) and records the open row + start time in openRows. logDisconnect(sessionId) fills in durationSeconds.
  • The ipc/*.ts handlers call AuditService.logConnect/logDisconnect around connect/ disconnect for ssh/sftp/rdp/vnc.
  • query(filters) (LIMIT 1000, newest first) and exportCsv(filters) (CSV-escaped).

6.12 IPC handler files — src/main/ipc/

Thin. Pattern: ipcMain.handle(Ch.X, async (_e, …) => { try { return await Service.x(…) } catch (err) { return { error: String(err) } } }). ipc/profiles.ts is the exception — it contains the AES-256-GCM export/import crypto (scrypt N=32768,r=8,p=1; file { v, salt, iv, tag, data } hex; bundles per-profile credentials pulled from keytar) and the native file dialogs.

6.13 WebSecurityService — WebSecurityService.ts

Backs the web-console <webview> tabs (ipc/web.ts). It holds a Set of origins that have explicitly opted into ignoring TLS cert errors (allowInsecureCerts(url) → normalizes to an origin; revokeInsecureCerts). The main-process certificate-error handler (§4) consults this set — by default all TLS errors are rejected, so the relaxation is scoped to one origin a user chose. When a guest's cert is rejected, the handler also recordCertError(webContentsId, info)s the cert details (issuer/subject/validity/SHA-256) into a per-webContents map; the renderer pulls them via getCertError (read-and-clear, takeCertError) to populate the "Proceed anyway" interstitial. Proceeding calls allowInsecureCerts so the trust is session-scoped (in-memory, gone on restart), distinct from the profile's persistent webIgnoreCertErrors toggle. setProxy(partition, rules) calls session.fromPartition(partition).setProxy(...) so each web profile can route through its own SOCKS/HTTP proxy (e.g. an SSH tunnel to a bastion); empty rules clear it. Guests run in isolated persist:web-<profileId> partitions so sessions/cookies don't leak between profiles.


7. Renderer — state (Zustand stores)

7.1 tabStore — tabStore.ts

The window model. Protocol = 'ssh'|'sftp'|'rdp'|'vnc'|'web'|'vpn'|'audit'|'local'|'editor' (broader than the connection Protocol in types/profile.ts — see §13). A Tab also carries optional editor?: EditorFile ({ path, isLocal, sessionId }) and editorDirty?: boolean for editor tabs (setEditorDirty).

  • Pane layout is a binary tree (tmux-style, arbitrary nesting): LayoutNode is either { type:'leaf'; paneId } or { type:'split'; id; direction:'horizontal'|'vertical'; ratio; a; b }. PaneId is a uuid.
  • State: tabs: Tab[], layout: LayoutNode, activePaneId, activeTabByPane (active tab id per pane), draggingTabId (drives the cross-pane drop overlay). Starts with no tabs — a single empty leaf renders the welcome placeholder.
  • Tab actions: addTab (returns id), closeTab (pinned tabs protected; auto-collapses an emptied non-root pane), setActiveTab, renameTab, setTabStatus, pinTab, reorderTabs, moveTabToPane (cross-pane move; collapses an emptied source pane), setDraggingTab.
  • Pane actions: splitPane(paneId, direction) → replaces that leaf with a split node, returns the new (empty) pane's id; closePane (drops non-pinned tabs, then collapses); setSplitRatio(splitId, ratio) (clamped 0.1–0.9). Pure tree helpers: listLeafIds, replaceLeaf, removeLeaf (collapse parent), updateRatio.
  • restoreSession(tabs, layout) — used by workspaces.

7.2 profileStore — profileStore.ts

  • groups, profiles, history, collapsedGroups. Ships seed data (demo groups/ profiles/history) used only until store:loadProfiles hydrates real data via setFromStore.
  • CRUD for groups & profiles (addProfile returns id, duplicateProfile, etc.).
  • history here is an in-memory, seeded list shown in the Sidebar "History" view (capped 100). It is separate from the SQLite audit log shown in the Connection History tab. (See §13.)

7.3 vpnStore — vpnStore.ts

  • profiles, statuses (keyed by id), loaded. load() calls ipc.vpn.listProfiles() then hydrates each status via ipc.vpn.getStatus. saveProfile/deleteProfile/setStatus/ statusOf. Live status comes from ipc.vpn.onStatus subscriptions set up in App.tsx.

7.4 workspaceStore — workspaceStore.ts

  • load/saveCurrent(name)/remove/setDefault/restore. saveCurrent snapshots the current tabs (profileId, protocol, label, paneId) + the layout tree. restore rebuilds them via tabStore.restoreSession, migrating legacy {splitMode, splitRatio} + 'A'/'B' layouts to a tree (migrateLegacyLayout) and dropping any tab whose pane no longer exists into the first leaf. setDefault toggles; the default workspace auto-restores on startup (in App.tsx).

7.5 Types — types/profile.ts

Profile (connection Protocol = ssh|rdp|vnc|sftp|web, authMethod, optional jumpHost, rdp* fields, vnc* fields, web* fields (webUrl, webIgnoreCertErrors, webProxy, webBookmarks: WebBookmark[]), vpnProfileId), ProfileGroup, VpnProfile, WorkspaceTab/Workspace, ConnectionHistoryEntry. Also types/session.ts, types/transfer.ts, and ambient *.d.ts for noVNC / guacamole-common-js.


8. Renderer — components

8.1 App — App.tsx

Root layout: <TitleBar/> + (<Sidebar/> + <SplitPane/>) + a VS Code–style status bar (product name, VpnStatusIndicator, v0.1.0-dev). On mount: loads profiles, VPN profiles, and workspaces (auto-restoring the default). Subscribes to ipc.menu.* (export/import/ history/new-local-terminal/about) and to per-profile ipc.vpn.onStatus. Hosts the shared PasswordDialog (encrypted profile export/import) and the AboutDialog (app name/version, author, MIT license, GitHub link, and Electron/Chromium/Node versions read from window.electron.process.versions).

8.2 Layout

  • TitleBarTitleBar.tsx: frameless drag bar (-webkit-app-region: drag, controls marked no-drag), File/View/Help dropdowns wired to props + ipc.window.*, min/max/close (close hovers red #e81123). Maximize icon follows window:state.
  • SidebarSidebar.tsx: three views (Servers / History / Workspaces). Servers = searchable, collapsible groups with right-click rename/delete; ProfileRow double-click connects (SSH rows also offer "Open SFTP Tab"). VPN-aware launch (launch): if a profile's vpnProfileId is down, it either auto-connects (autoConnect) or shows VpnPreconnectDialog ("Connect VPN & Open" / "Open Anyway"). VpnDot shows a green/red lock. Hosts the ProfileEditor modal.
  • SplitPaneSplitPane.tsx: recursively renders the tabStore layout tree (LayoutTree) — each split node lays out two children with a per-node draggable Divider (resizes only its own node), so panes nest arbitrarily (tmux-style). A PaneLeaf is a TabBar + PaneContent; an empty pane shows the WelcomeContent placeholder. While a tab is being dragged, every other leaf shows a full-pane drop overlay for cross-pane moves. Keep-alive rendering (see §11): ssh/sftp/rdp/vnc/local/editor tabs stay mounted and are CSS-hidden (display:none) when inactive (editor so unsaved edits survive); vpn/audit/web render only when active (a live <webview> per tab stalled). VpnWarningBanner warns above a session whose VPN is down.
  • TabBarTabBar.tsx: per-pane tab strip with protocol badges, connection-status dot, pin glyph, inline rename, drag reorder (and drag a tab onto any pane to move it there), a right-click TabContextMenu (rename/pin/move-to-split right-or-down/close), a new-local-terminal button (+ opens a local shell), and per-pane split-right / split-down / close-pane controls.

8.3 Protocol tabs

All take { tab, isActive } and own their session lifecycle in useEffects, storing sessionId + cleanup in refs, and reporting status both locally and to tabStore.setTabStatus.

  • SshTab / LocalTerminalTab — xterm.js + FitAddon (+ Search/WebLinks for SSH) with the shared DARK_THEME. term.onDataipc.<>.send; on*Data event → term.write; resize via ResizeObserver + on-activate fit(). Ctrl+Shift+C/V copy/paste. A reconnectTick state forces reconnect/restart.
  • VncTabVncTab.tsx: creates the proxy WebSocket itself (8 s timeout) then hands it to noVNC RFB. Password logic: explicit override > keytar value; null → show in-app PasswordDialog; '' → connect with no auth. scaleViewport, Ctrl+Alt+Del, security/credential/desktopname events.
  • RdpTabRdpTab.tsx: in-tab via guacamole-common-js (WebSocketTunnel/Client/Mouse/Keyboard). scaleToFit + debounced sendSize on resize. Ctrl+Alt+Del via X11 keysyms. On needsPassword shows a dialog; on error offers "Open in external window"launchExternal() (FreeRDP path). resumeModeRef remembers which path to retry after a password.
  • SftpTabSftpTab.tsx: dual FilePane (local/remote), a TransferQueue, drag-between-panes (via a shared dragRef), and the PermissionEditor. Toolbar upload/download act on the selected file; refreshKeys force pane reloads after transfers. Double-clicking a file opens an editor tab.
  • WebTabWebTab.tsx: an embedded Chromium <webview> (reuses Electron's engine — no second browser). Address bar, back/forward/reload/stop, per-profile bookmarks, a browser-style cert interstitial (on a rejected TLS cert, did-fail-load with a -200…-219 code shows a "your connection is not private" panel with the cert details from ipc.web.getCertError; Proceed anyway calls ipc.web.allowInsecureCerts and reloads — session-scoped trust), and an optional per-profile proxy (ipc.web.setProxy). normalizeNavInput routes typed text to search vs URL and allows file: so local docs open. Built-in document viewer: PDFs render via Chromium's PDFium; JSON/Markdown are formatted by docFormat.ts and shown in a script-disabled sandbox iframe. Only the active web tab keeps a live <webview> (inactive ones unmount; lastUrlByTab restores the URL on remount) — keeping them all live caused stalls.
  • EditorTabEditorTab.tsx: a CodeMirror 6 editor (@uiw/react-codemirror + VS Code theme + per-extension language packs) for the file in tab.editor. Reads via ipc.sftp.readFile/readLocalFile, saves via writeFile/writeLocalFile. Tracks a dirty state ( in the tab, setTabEditorDirty), saves on Ctrl+S, and guards close when unsaved. 5 MB / text-only safety limits. Unlike web tabs it stays keep-alive mounted so unsaved edits survive tab switches.

8.4 SFTP sub-components

  • FilePaneFilePane.tsx: one side's file browser — list/navigate, breadcrumb, drag source, right-click menu. Remote side adds new-folder/rename/delete/permissions; local side is browse + drag only. (See the drop quirk in §13.)
  • TransferQueueTransferQueue.tsx: exports the useTransferQueue hook (a Map of TransferItem) + the collapsible UI with progress bars, speed/ETA, cancel/dismiss. Subscribes to sftp:progress:<id>.
  • PermissionEditorPermissionEditor.tsx: chmod modal — owner/group/other × r/w/x checkboxes synced with an octal input; saves via ipc.sftp.chmod.

8.5 ProfileEditor — ProfileEditor.tsx

The create/edit modal (editingId: 'new' | <id> | null). Sections: General (name/protocol/ port/host, default port follows protocol), Authentication (password/key/agent; RDP & VNC forced to password; VNC needs no username), Organization (group incl. inline "+ New Group", VPN profile assoc, tag chips, notes), Advanced jump host (SSH/SFTP only), and protocol- specific RDP (resolution/color-depth/domain/cert mode) and VNC (display/port/encoding) panels. On save: writes the profile to profileStore, persists secrets via ipc.credentials.set, and persists profiles via ipc.store.save.

8.6 AuditTab — AuditTab.tsx

Queries the SQLite audit log with filters (protocol/server/host/date range), renders a table, and exports CSV (client-side Blob download).

8.7 VpnTab — VpnTab.tsx

VPN profile manager: add/edit form (VpnForm — OpenVPN adds username + password fields), per-profile status badge, and a status-driven button: Connect / Stop (while connecting) / Disconnect (when connected). Shows assigned IP and per-profile errors.


9. Data & persistence

File (<userData>/) Written by Contents Sent to renderer?
profiles.json StoreService { version, groups, profiles } yes (full)
vpn-profiles.json StoreService StoredVpnProfile[] incl. passwordEnc no (passwordEnc stripped)
workspaces.json StoreService saved tab layouts yes
credentials.json CredentialService encrypted secrets (fallback only) no
audit.db AuditService connection_events (SQLite WAL) only via query/export

<userData> = ~/.config/remotecommander/ (Linux), ~/Library/Application Support/ remotecommander/ (macOS), %APPDATA%\remotecommander\ (Windows).

Credential storage tiers: keytar (OS keychain) → credentials.json encrypted with safeStorage (v1:) → base64 (b64:, insecure last resort). VPN passwords use the same secretCrypto inside vpn-profiles.json.

Encrypted profile export (.rcprofiles): AES-256-GCM, key = scrypt(password, salt, N=32768,r=8,p=1), file = { v, salt, iv, tag, data } hex. Bundles profiles + groups + credentials (pulled from keytar at export, re-stored on import). Wrong password → auth-tag failure → { error: 'Wrong password or corrupted file.' }.


10. Security model (consolidated)

  • Context isolation on, no Node in renderer. Preload bridge allow-lists every channel (invoke/send/event-prefix). Unknown channels are rejected at the boundary.
  • CSP restricts connect-src to self + localhost WebSockets (the VNC/RDP proxies).
  • Proxies bind to 127.0.0.1 only (VNC ws, Guacamole ws), never 0.0.0.0.
  • Secrets never sent to the renderer except where the protocol client needs them in- process (VNC password handed to noVNC). VPN passwordEnc is always stripped.
  • No shell interpolation in VpnService (execFileSync with arg arrays). VPN creds go through a 0600 temp file, never the command line/process list.
  • Privilege: VPN uses sudo -n (non-interactive) — the app never runs as root and never prompts for a password on a TTY-less stdio.
  • Native modules degrade gracefully: missing node-pty → local terminal shows an install hint; missing/broken better-sqlite3 → audit becomes a no-op; missing keytar → encrypted-file credential fallback. None crash the app.

Convenience trade-off (intentional): credentials persist on disk so they survive restarts. On platforms without an OS keychain (e.g. WSL2), safeStorage may fall back to base64 (b64: tag) — recoverable by anyone with file access. This was a deliberate product choice (convenience over purist non-persistence). Keep the b64: tag so the weak path stays auditable.


11. Cross-cutting conventions

  • Handler return shape: success object or { error: string }; some add { needsPassword: true } / { cancelled: true }. Renderer code does if ('error' in res).
  • Status strings: session tabs reduce events to connecting|connected|reconnecting| disconnected|error. Error events are often 'error:<message>' (split on the first colon).
  • Keep-alive tabs: terminal/canvas tabs (ssh/sftp/rdp/vnc/local) stay mounted for the pane's life and are toggled with display:none, so sessions survive tab switches and xterm/noVNC/guacamole don't lose buffers. VPN/audit are cheap and mount only when active. If you add a long-lived session tab, follow the keep-alive pattern in SplitPane.tsx.
  • xterm pattern: create the terminal once per tab.id; a separate effect keyed on [tab.id, profileId, reconnectTick] owns the connection; ResizeObserver + on-activate requestAnimationFrame(fit) keep dimensions right after display:none transitions.
  • Theme tokens: the VS Code Dark palette lives as CSS vars in base.css (--rc-*) and as inline hex in components. Common values: bg #1e1e1e, sidebar/menu #252526, tab bar #2d2d2d, accent #007acc, border #3e3e42, text #cccccc/#858585, status colors green #4ec9b0, amber #dcdcaa, red #f44747/#f48771.

12. Build & tooling (see BUILD.md for the full guide)

  • Dev: npm run dev (electron-vite, HMR). Renderer dev server: 0.0.0.0:5173.
  • Type-check: npm run typecheck (typecheck:node for main/preload, typecheck:web for renderer). This is the primary CI signal — the app can't be launched headless.
  • Bundle: npm run build (typecheck + electron-vite build → out/).
  • Installers: npm run build:{win,mac,linux}dist/ (NSIS / dmg / AppImage+deb). Config in electron-builder.yml; npmRebuild: false; publish is a placeholder (https://example.com/auto-updates) — auto-update is not wired.
  • .npmrc sets no registry/mirror override — Electron/electron-builder binaries come from their official GitHub release sources. Installers build locally per-OS or via the GitHub Actions release workflow (.github/workflows/release.yml).

13. Known limitations, quirks & gotchas

  1. WSL2 / Linux GPU: Electron can't launch without certain libs and, by default, runs with GPU accel disabled (SwiftShader) so WSL2/headless doesn't blank the window. On a real Linux desktop that makes <webview>/RDP feel sluggish — set RC_ENABLE_GPU=1 to use the real GPU (see §4). No OS keyring → credentials use the encrypted-file fallback. Treat npm run build/typecheck as the verification path when you can't open a window.
  2. Optional native modules: node-pty (local terminal) and better-sqlite3 (audit) need a C/C++ toolchain. They're loaded lazily and degrade gracefully. keytar falls back to the file store.
  3. Two "history" surfaces (easy to confuse): the Sidebar History view reads the in-memory, seeded profileStore.history; the Connection History tab reads the SQLite audit DB (AuditService). They are not the same data. Real connect/disconnect events are logged only to SQLite (from the ipc/*.ts handlers).
  4. Two Protocol types: types/profile.ts Protocol = connection protocols (ssh|rdp|vnc|sftp|web); tabStore.ts Protocol = tab kinds (adds vpn|audit|local| editor). Don't unify them blindly.
  5. FilePane drop quirk: FilePane.handleDrop builds a placeholder fakeEntry and the real source lookup happens in SftpTab via dragRef.current. The pane-level entries lookup in that function is intentionally unused. Don't "fix" it without re-routing the drag state.
  6. rdp:guacDisconnect only logs audit — the shared guacd proxy stays up; closing the renderer's tunnel ends the guacd-side session.
  7. RDP heuristic status (external path): "connected" after 3 s if the process lives; it's a heuristic, not a real handshake signal.
  8. Unused leftovers: Versions.tsx and the app-folder README.md are electron-vite template defaults, not referenced by the app. (The real runtime-version readout lives in AboutDialog, which reuses the same window.electron.process.versions pattern.)
  9. Secrets must never be committed. .gitignore excludes *.pem/*.ovpn/*.key/ *.p12/*.pfx. A .pem was leaked in earlier history and the repo history was reset to a single commit on 2026-05-22 — that key should be treated as compromised/rotated.
  10. Frameless maximize uses "fake maximize" (§4): the maximize button calls toggleFakeMaximize (resize to workArea), not win.maximize(), which froze the WM on some Linux setups. window:control 'isMaximized' therefore tracks fakeMaxBounds, not the real Electron maximized state.
  11. Web tabs are active-only, editor tabs are keep-alive. Only the focused web tab holds a live <webview> (others unmount; lastUrlByTab restores the URL); editor tabs stay mounted so unsaved edits survive switching. Don't flip either without accounting for stalls / lost edits.

14. How to extend (recipes)

Add a new IPC channel

  1. Add the name to Ch in channels.ts.
  2. Implement the logic in the relevant services/*.ts.
  3. Add a thin ipcMain.handle/.on in the matching ipc/*.ts.
  4. Allow-list it in preload/index.ts (INVOKE_CHANNELS / SEND_CHANNELS, or an EVENT_PREFIXES entry for events).
  5. Add a typed wrapper in lib/ipc.ts.
  6. npm run typecheck.

Add a new protocol / service

  1. Create services/FooService.ts (singleton, Map of sessions, disconnectAll(), emit foo:status:<id> etc.).
  2. Add channels + ipc/foo.ts; register it in index.ts (registerFooHandlers() and in window-all-closed).
  3. Add 'foo:' to EVENT_PREFIXES and the invoke channels to the allow-list.
  4. Add ipc.foo to lib/ipc.ts.
  5. Add a FooTab following the keep-alive + ref-based-session pattern; render it in SplitPane.tsx and add a Protocol entry + badge in tabStore.ts/TabBar.tsx.
  6. Wire AuditService.logConnect/logDisconnect in the handler if it's a session.

Add a new tab type (non-session, like Audit)

Add the Protocol literal + badge, open it from a menu/sidebar action via addTab, and render it "only when active" in SplitPane (don't keep-alive unless it holds a live session).

Add a persisted setting

Add a *StorePath() + load/save pair in StoreService.ts, expose store:*/workspaces:*-style channels, allow-list, wrap in lib/ipc.ts, and load it in App.tsx on mount.


15. File index (quick lookup)

Path Purpose
main/index.ts bootstrap, frameless window, menu, window:control, lifecycle
main/ipc/channels.ts Ch channel-name registry
main/ipc/*.ts thin handlers (ssh/sftp/rdp/vnc/vpn/local/web/credentials/profiles/audit)
main/services/SshService.ts SSH shell, jump host, auto-reconnect
main/services/SftpService.ts SFTP browse + streamed transfers
main/services/RdpService.ts external FreeRDP window
main/services/GuacamoleService.ts in-tab RDP via guacd (token crypto)
main/services/VncService.ts in-process websockify ↔ noVNC
main/services/VpnService.ts OpenVPN/WireGuard, sudo -n, cred injection, polling
main/services/LocalTerminalService.ts optional node-pty PTY
main/services/CredentialService.ts keytar → encrypted file
main/services/secretCrypto.ts safeStorage (v1:)/base64 (b64:)
main/services/StoreService.ts profiles/vpn/workspaces JSON
main/services/AuditService.ts lazy SQLite connection log
main/services/WebSecurityService.ts web-console per-origin TLS opt-in + per-partition proxy
preload/index.ts contextBridge + channel allow-lists
renderer/src/App.tsx root layout, startup hydration, status bar
renderer/src/lib/ipc.ts typed renderer→main wrapper
renderer/src/store/ tabStore, profileStore, vpnStore, workspaceStore
renderer/src/components/layout/ TitleBar, Sidebar, SplitPane, TabBar
renderer/src/components/tabs/ per-protocol + audit/vpn/web/editor tabs
renderer/src/components/AboutDialog.tsx About dialog (version/author/MIT/runtime versions)
renderer/src/lib/docFormat.ts JSON/Markdown formatting for the web doc viewer
renderer/src/components/sftp/ FilePane, TransferQueue, PermissionEditor
renderer/src/components/profiles/ProfileEditor.tsx profile create/edit modal
renderer/index.html CSP
renderer/src/assets/base.css theme tokens + scrollbars