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.
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).
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)
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:
- Renderer is untrusted. It never imports Node modules. Everything crosses the
contextBridge, and the preload allow-lists every channel (preload/index.ts). - All real logic lives in
services/. Theipc/*.tsfiles are thin: validate → call a service method → return{…}or{ error }. - Renderer talks to main only through lib/ipc.ts. Components never touch
window.electronAPIdirectly. - 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.
| 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) |
Main — src/main/index.ts
- GPU policy (Linux): by default
app.disableHardwareAcceleration()+enable-unsafe-swiftshader(WSL2/headless GPU otherwise blanks or stalls the window). SetRC_ENABLE_GPU=1to keep the real GPU instead — needed for smooth<webview>/RDP rendering on a real desktop.log-level 3silences 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:falseis the electron-vite scaffold default — it lets the bundled preload use Node'srequire. Security here rests oncontextIsolation: trueplus 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 callwin.maximize()(that froze the WM on some Linux setups). Instead it saves the current bounds andsetBounds(screen.getDisplayMatching(...).workArea); toggling restores the saved bounds.window:control 'isMaximized'reportsfakeMaxBounds.has(win.id). Cleared onclosed. setWindowOpenHandlerdenies in-appwindow.openand 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,sandboxon,webSecurityon, and its popups are denied (setWindowOpenHandler → deny).app.on('certificate-error')→handleCertificateErroronly 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 itemswebContents.send('menu:*')— the renderer subscribes viaipc.menu.*. - Universal right-click context menu (
attachContextMenu, wired via the sameapp.on('web-contents-created')callback): every webContents — main window and each guest<webview>— gets a Cut/Copy/Paste/Select-All menu (plus Copy Link whenparams.linkURLis set). Editable fields show Cut/Copy/Paste/Select-All gated byeditFlags; non-editable selections show Copy; empty areas show nothing. Renderer components that own their own menu (RadixTabContextMenuon tabs, the SFTP file-row menu, and the xterm terminal canvas) callpreventDefaulton 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:controlhandler (registerWindowHandlers): the title bar's min/max/close/reload/devtools/fullscreen/openExternal all funnel through this oneinvokechannel.window-all-closed: callsdisconnectAll()on every service (+GuacamoleService.shutdown()), thenapp.quit()on non-macOS.
Preload — src/preload/index.ts
Exposes two globals via contextBridge:
window.electron— the standard@electron-toolkit/preloadAPI.window.electronAPI— our custom bridge with three methods, each allow-listed:invoke(channel, ...args)→ rejects unlesschannel ∈ INVOKE_CHANNELS.send(channel, ...args)→ no-op unlesschannel ∈ SEND_CHANNELS({ 'ssh:data', 'local:data' }— the two high-frequency keystroke streams).on(channel, cb)→ throws unlesschannelstarts with an allowed prefix inEVENT_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.
- 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:*. Theconnect-src ws://…entries are load-bearing — they allow the VNC and RDP WebSocket proxies. Removing them breaks VNC/RDP with a silent CSP block.
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.
- 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.sendwith a session-scoped suffix, e.g.ssh:data:<sessionId>,vnc:status:<sessionId>,sftp:progress:<transferId>,vpn:status:<vpnProfileId>. The renderer subscribes viaipc.<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>.
| 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.
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 fromStoreService, builds auth opts, opens anssh2client; forjumpHostprofiles it connects the bastion first thenforwardOutto the target (sock). Resolves after the shell is open, so the renderer treats resolution as "connected".- Auth (
buildAuthOpts):password→CredentialService.get(profileId);key→ readprivateKeyPathfrom disk + passphrase fromCredentialService.get(profileId:passphrase);agent→process.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,scheduleReconnectretries up toMAX_RETRIES = 3with backoffRETRY_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
SshServiceclient exists for the sameprofileId, SFTP rides on it (ownedClient = false) — otherwise it opens its own (ownedClient = true, closed on disconnect).connectreturns{ sessionId, localHome (os.homedir()), remoteHome (/home/<username>) }. list/listLocalreturnFileEntry[](name/size/mtime ms/permissions raw mode/isDir/ isSymlink), dirs first then alpha.listLocalusesfs/promises.- Transfers:
upload/downloadstream withcreateReadStream/createWriteStream, emitsftp:progress:<transferId>(transferred/total/speed/eta/status). Each transfer registers acancel()(destroys the streams).uploadFolderrecurses. deletetriesunlinkthenrmdir;mkdir/rename/chmodmap to sftp calls.
6.3 RdpService — RdpService.ts (external FreeRDP, the fallback path)
detectBinary()probesxfreerdp/xfreerdp3(POSIX) orwfreerdp(.exe)(Windows) viawhich/where.connect(profileId, {password?,width?,height?})spawns FreeRDP with/v /u /w /h /bpp /cert:… /p /d. Password comes fromoptsorCredentialService; 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-liteserver for the app lifetime, on a random127.0.0.1port, proxying to guacd at127.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()producesbase64(JSON{ iv, value })wherevalueisAES-256-CBC(JSON(payload))with a per-launch random 32-byte key — exactly whatguacamole-litedecrypts. The token carries the RDPsettings(hostname/port/username/ password/ignore-cert/resize-method/width/height/dpi/domain/color-depth). - Returns
{ sessionId, wsPort, token }|{ needsPassword }|{ error }. The renderer opens aGuacamole.WebSocketTunneltows://127.0.0.1:<wsPort>and connects withtoken=….shutdown()closes the shared server.
6.5 VncService — VncService.ts
- An in-process websockify: a
wsWebSocketServeron a random127.0.0.1port that, per client connection, opens a rawnetTCP socket tohost:<vncPort>and pipes bytes both ways.handleProtocolsaccepts thebinarysubprotocol noVNC requests. resolveVncPort:vncPort→5900 + vncDisplay→port→5900.- 15 s TCP connect timeout (then cleared). Emits
vnc:status:<id>connecting/connected/disconnected/error:<msg>. Returns the VNCpassword(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()(usesexecFileSync, no shell). - Elevation (
elevate): on POSIX non-root, prefixessudo -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 ausername, credentials are written to a temp 0600 file passed via--auth-user-pass <file> --auth-nocache(password decrypted frompasswordEnc). Keeps a rolling 4 KB log tail; abnormal exit before "connected" emitsextractError(tail). - WireGuard (
startWireGuard):wg-quick up <config>; exit 0 = connected, then reads the assigned IP viaip -4 addr show <iface>(iface = config basename). - Polling (
startPolling, every 3 s): OpenVPN → checkprocess.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()stripspasswordEncbefore returning to the renderer.
6.7 LocalTerminalService — LocalTerminalService.ts
- Wraps
node-pty, which is optional:loadPty()lazilyrequires it and cachesnullon failure.connect()throws a clear "install build-essential then node-pty" message when absent. Spawns$SHELL//bin/bash(POSIX) or%COMSPEC%/powershell.exe(Windows) inos.homedir(); pipesonData→local:data:<id>,onExit→local:status:<id>.
6.8 CredentialService — CredentialService.ts
- Primary:
keytarunder service nameRemoteCommander, account =profileId(password) orprofileId: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 viasecretCrypto, in-memory cache).
6.9 secretCrypto — secretCrypto.ts
encryptSecret:v1:<safeStorage base64>whensafeStorage.isEncryptionAvailable(), elseb64:<base64>(theb64:tag marks the insecure fallback path).decryptSecret: routes by prefix. Used by bothCredentialServiceandVpnService.
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.json→StoredVpnProfile[](kept separate so saving server profiles never clobbers VPN profiles).StoredVpnProfileincludespasswordEnc?.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, singleconnection_eventstable. logConnect(sessionId, profileId, protocol)inserts a row (resolving profile name/host/ user fromStoreService) and records the open row + start time inopenRows.logDisconnect(sessionId)fills indurationSeconds.- The
ipc/*.tshandlers callAuditService.logConnect/logDisconnectaround connect/ disconnect for ssh/sftp/rdp/vnc. query(filters)(LIMIT 1000, newest first) andexportCsv(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.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):
LayoutNodeis either{ type:'leaf'; paneId }or{ type:'split'; id; direction:'horizontal'|'vertical'; ratio; a; b }.PaneIdis 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 untilstore:loadProfileshydrates real data viasetFromStore.- CRUD for groups & profiles (
addProfilereturns id,duplicateProfile, etc.). - ⚠
historyhere 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()callsipc.vpn.listProfiles()then hydrates each status viaipc.vpn.getStatus.saveProfile/deleteProfile/setStatus/ statusOf. Live status comes fromipc.vpn.onStatussubscriptions set up inApp.tsx.
7.4 workspaceStore — workspaceStore.ts
load/saveCurrent(name)/remove/setDefault/restore.saveCurrentsnapshots the current tabs (profileId, protocol, label, paneId) + the layout tree.restorerebuilds them viatabStore.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.setDefaulttoggles; the default workspace auto-restores on startup (inApp.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.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).
- TitleBar — TitleBar.tsx: frameless drag bar (
-webkit-app-region: drag, controls markedno-drag), File/View/Help dropdowns wired to props +ipc.window.*, min/max/close (close hovers red#e81123). Maximize icon followswindow:state. - Sidebar — Sidebar.tsx: three views (Servers / History / Workspaces). Servers = searchable, collapsible groups with right-click rename/delete;
ProfileRowdouble-click connects (SSH rows also offer "Open SFTP Tab"). VPN-aware launch (launch): if a profile'svpnProfileIdis down, it either auto-connects (autoConnect) or showsVpnPreconnectDialog("Connect VPN & Open" / "Open Anyway").VpnDotshows a green/red lock. Hosts theProfileEditormodal. - SplitPane — SplitPane.tsx: recursively renders the
tabStorelayout tree (LayoutTree) — each split node lays out two children with a per-node draggableDivider(resizes only its own node), so panes nest arbitrarily (tmux-style). APaneLeafis aTabBar+PaneContent; an empty pane shows theWelcomeContentplaceholder. 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).VpnWarningBannerwarns above a session whose VPN is down. - TabBar — TabBar.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.
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.onData→ipc.<>.send;on*Dataevent →term.write; resize viaResizeObserver+ on-activatefit(). Ctrl+Shift+C/V copy/paste. AreconnectTickstate forces reconnect/restart. - VncTab — VncTab.tsx: creates the proxy
WebSocketitself (8 s timeout) then hands it to noVNCRFB. Password logic: explicit override > keytar value;null→ show in-appPasswordDialog;''→ connect with no auth.scaleViewport, Ctrl+Alt+Del, security/credential/desktopname events. - RdpTab — RdpTab.tsx: in-tab via
guacamole-common-js(WebSocketTunnel/Client/Mouse/Keyboard).scaleToFit+ debouncedsendSizeon resize. Ctrl+Alt+Del via X11 keysyms. OnneedsPasswordshows a dialog; on error offers "Open in external window" →launchExternal()(FreeRDP path).resumeModeRefremembers which path to retry after a password. - SftpTab — SftpTab.tsx: dual
FilePane(local/remote), aTransferQueue, drag-between-panes (via a shareddragRef), and thePermissionEditor. Toolbar upload/download act on the selected file;refreshKeys force pane reloads after transfers. Double-clicking a file opens aneditortab. - WebTab — WebTab.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-loadwith a-200…-219code shows a "your connection is not private" panel with the cert details fromipc.web.getCertError; Proceed anyway callsipc.web.allowInsecureCertsand reloads — session-scoped trust), and an optional per-profile proxy (ipc.web.setProxy).normalizeNavInputroutes typed text to search vs URL and allowsfile: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;lastUrlByTabrestores the URL on remount) — keeping them all live caused stalls. - EditorTab — EditorTab.tsx: a CodeMirror 6
editor (
@uiw/react-codemirror+ VS Code theme + per-extension language packs) for the file intab.editor. Reads viaipc.sftp.readFile/readLocalFile, saves viawriteFile/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.
- FilePane — FilePane.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.)
- TransferQueue — TransferQueue.tsx: exports the
useTransferQueuehook (aMapofTransferItem) + the collapsible UI with progress bars, speed/ETA, cancel/dismiss. Subscribes tosftp:progress:<id>. - PermissionEditor — PermissionEditor.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.
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.' }.
- 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-srctoself+ localhost WebSockets (the VNC/RDP proxies). - Proxies bind to
127.0.0.1only (VNCws, Guacamolews), never0.0.0.0. - Secrets never sent to the renderer except where the protocol client needs them in-
process (VNC password handed to noVNC). VPN
passwordEncis always stripped. - No shell interpolation in
VpnService(execFileSyncwith 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/brokenbetter-sqlite3→ audit becomes a no-op; missingkeytar→ 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),
safeStoragemay fall back to base64 (b64:tag) — recoverable by anyone with file access. This was a deliberate product choice (convenience over purist non-persistence). Keep theb64:tag so the weak path stays auditable.
- Handler return shape: success object or
{ error: string }; some add{ needsPassword: true }/{ cancelled: true }. Renderer code doesif ('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-activaterequestAnimationFrame(fit)keep dimensions right afterdisplay:nonetransitions. - 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:nodefor main/preload,typecheck:webfor 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;publishis a placeholder (https://example.com/auto-updates) — auto-update is not wired. .npmrcsets 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).
- 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 — setRC_ENABLE_GPU=1to use the real GPU (see §4). No OS keyring → credentials use the encrypted-file fallback. Treatnpm run build/typecheckas the verification path when you can't open a window. - Optional native modules:
node-pty(local terminal) andbetter-sqlite3(audit) need a C/C++ toolchain. They're loaded lazily and degrade gracefully.keytarfalls back to the file store. - 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 theipc/*.tshandlers). - Two
Protocoltypes:types/profile.tsProtocol= connection protocols (ssh|rdp|vnc|sftp|web);tabStore.tsProtocol= tab kinds (addsvpn|audit|local| editor). Don't unify them blindly. - FilePane drop quirk:
FilePane.handleDropbuilds a placeholderfakeEntryand the real source lookup happens inSftpTabviadragRef.current. The pane-levelentrieslookup in that function is intentionally unused. Don't "fix" it without re-routing the drag state. rdp:guacDisconnectonly logs audit — the shared guacd proxy stays up; closing the renderer's tunnel ends the guacd-side session.- RDP heuristic status (external path): "connected" after 3 s if the process lives; it's a heuristic, not a real handshake signal.
- 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 samewindow.electron.process.versionspattern.) - Secrets must never be committed.
.gitignoreexcludes*.pem/*.ovpn/*.key/*.p12/*.pfx. A.pemwas 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. - Frameless maximize uses "fake maximize" (§4): the maximize button calls
toggleFakeMaximize(resize toworkArea), notwin.maximize(), which froze the WM on some Linux setups.window:control 'isMaximized'therefore tracksfakeMaxBounds, not the real Electron maximized state. - Web tabs are active-only, editor tabs are keep-alive. Only the focused web tab holds a
live
<webview>(others unmount;lastUrlByTabrestores the URL); editor tabs stay mounted so unsaved edits survive switching. Don't flip either without accounting for stalls / lost edits.
- Add the name to
Chin channels.ts. - Implement the logic in the relevant
services/*.ts. - Add a thin
ipcMain.handle/.onin the matchingipc/*.ts. - Allow-list it in preload/index.ts
(
INVOKE_CHANNELS/SEND_CHANNELS, or anEVENT_PREFIXESentry for events). - Add a typed wrapper in lib/ipc.ts.
npm run typecheck.
- Create
services/FooService.ts(singleton,Mapof sessions,disconnectAll(), emitfoo:status:<id>etc.). - Add channels +
ipc/foo.ts; register it inindex.ts(registerFooHandlers()and inwindow-all-closed). - Add
'foo:'toEVENT_PREFIXESand the invoke channels to the allow-list. - Add
ipc.footolib/ipc.ts. - Add a
FooTabfollowing the keep-alive + ref-based-session pattern; render it in SplitPane.tsx and add aProtocolentry + badge intabStore.ts/TabBar.tsx. - Wire
AuditService.logConnect/logDisconnectin the handler if it's a session.
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 *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.
| 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 |