# miniSCP architecture Dense reference for development and debugging. Companion files: [build-and-release.md](build-and-release.md), [platform-notes.md](platform-notes.md), [gotchas.md](gotchas.md). ## Process model - **Bun main process** (`src/bun/`): electrobun runs it inside a WebWorker of its native launcher. Owns every privileged capability: filesystem, SSH, window management, native menus/dialogs/tray, the connection/settings stores. - **Webviews** (`ui/` Angular): one per window, system WebKit on macOS / WebView2 on Windows / WebKitGTK on Linux. Zero privileged access — everything goes through RPC. - **RPC transport**: one WebSocket server in the bun process, multiplexed by `webviewId` (each webview auto-connects using natively injected globals). Requests are typed by the `AppRPC` schema in `src/shared/rpc.ts`. ### The one-schema rule `AppRPC` is a single superset schema shared by all window kinds. This works because electrobun allows **partial handler sets** (a side only implements what it serves) and **silently drops messages nobody listens to**. Every response crosses the bridge as `Result` (`{ok:true,…}|{ok:false,error}`) — exceptions never cross; `wrap()` in `src/bun/rpc-handlers.ts` enforces it. ### Per-window RPC instances (critical) Each `BrowserWindow` MUST receive its own `defineRPC(...)` instance: binding a BrowserView calls `rpc.setTransport(...)`, so a shared instance would have its transport stolen by the most recent window. `createAppRPC` (main), `createEditorRPC` (per editor window, hooks close over that window's record), `createManagerRPC` (the single manager window). `rpc.send.*` on an instance targets exactly its own webview. ### The `events` late-binding pattern `createAppRPC` needs to trigger things that need `rpc.send` (which doesn't exist until after construction). `AppDeps.events` is a mutable object of no-op functions, reassigned in `src/bun/index.ts` after everything exists: `sessionClosed`, `connectionsChanged`, `openEditor`, `settingsChanged`, `openConnectionManager`, `showAbout`. ## Window kinds `src/bun/windows.ts` — `buildPreload(mode, settings, ctx?)` injects `__APP_PLATFORM`, `__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script, plus the persisted theme attributes and interface scale so the first paint is already right. `ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use selector `app-root` (index.html hosts only that tag). `chromeOptions()` is the ONLY place the native frame is decided, spread by the main window and `openSecondaryWindow`: macOS `hiddenInset` + `trafficLightOffset {x:12,y:13}` (the UI renders an `h-10 pl-24` drag-region header via `IS_MAC_CHROME`); other platforms keep the native titlebar and a compact `h-9` non-drag header. `WindowRegistry` (same file): keyed window map with dedupe/activate, cascade placement off an anchor frame, focus tracking. electrobun's emitter has **no `off()`** — `track()` closures capture only `(this, key)` so closed records can be garbage collected. A future "window follows window" grouping belongs here (`anchor.on('move')` + `setPosition`). ### Editor windows (`src/bun/editor-windows.ts` + `ui/src/app/skins/shadlike/editor-window/`) One window per file, key `sessionId+path` (sessionId, not fsKey: saves route through a concrete session, and `local` is one shared session anyway). Re-opening focuses the existing window. The webview fetches its own content (`readFile`) — `openEditor` just creates the window with ctx in the preload. **Close safety** (electrobun window `close` is NOT cancellable — verified, no `allow` response like `before-quit` has): 1. ⌘W (routed via the app menu → `editorCloseRequest`) / Close button → styled in-window Save/Don't Save/Cancel; the confirm path calls `editorClose` which sets `suppressPrompt` before `win.close()`. 2. Traffic-light close → the window is already gone, but the editor mirrored its draft to bun (`editorState`, 400 ms debounce + flush on blur/dirty-flip) → native Save/Discard box from the held draft. A failed save (dead session) parks the draft via `EditManager.rescueDraft` into the edits-leftovers dir, offered back at next boot. Known limit: the last ≤400 ms of keystrokes can be lost on an instant type-then-close. 3. ⌘Q → the app-level `before-quit` guard (cancellable) counts dirty editors plus running transfers. `editorSave` is a dedicated request (not generic `writeFile`) so one handler atomically writes + cleans the bun record + retitles + pushes `pathRefresh` to the main window (panels showing that directory refresh). Editor upgrades: scroll-synced line gutter (hidden when wrap is on — wrapped line numbers would need per-edit height measurement), Alt/⌥Z wrap toggle, custom find bar (matches computed in TS, `setSelectionRange` + scrollTop math — deliberately not `findInPage`, which is a shim on WebView2). ### Connection-manager window (`src/bun/manager-window.ts`) Single instance, centered over main, **modal to main only** (editors stay usable). No native modality exists, so three layers approximate it: bun bounces main's `focus` event back to the manager; the main webview renders a click-swallowing overlay (`managerState` message); main drops its keyboard shortcuts and bun drops main-targeted menu actions while open. Connect / open-stack execute in the MAIN window via the `connect:` / `open-stack:` menuAction channel (the manager closes right after — a failed connect surfaces in main). Store edits push `connectionsChanged` so main's `ConnectionsService` reloads; a reload-retry in `App.findConnection` covers the save→connect race. `setAlwaysOnTop` is deliberately NOT used (it would float over editor windows). ## Bun modules - `fs/provider.ts` — `FileSystemProvider` interface: uniform local/SFTP ops + path helpers + streams + **`fsKey`** (see self-copy below). - `fs/local.ts` — node:fs, `lstat`-based, `fsKey="local"`. Owner/group via cached `id -nu` / `dscacheutil|getent` spawns (win32 → undefined). `listLocalDrives()` for the Windows drive picker (`fsutil`). - `fs/sftp.ts` — ssh2 under Bun (pure JS; native accelerators are build `external`s). POSIX paths always. `fsKey = resolvedIP:port` via `dns.lookup` at connect. Owner/group parsed from the ls-style readdir `longname`. Keepalive 15 s; `closedByUs` flag so `dispose()` doesn't fire the onClose callback. mtime seconds→ms; mode masked `& 0o7777`. - `fs/registry.ts` — sessionId → provider map; `"local"` is built-in; `add()` mints `sftp-`; `drop()` (session died) vs `remove()` (clean). - `fs/watcher.ts` — `LocalDirWatcher`: `fs.watch` handles keyed by dir, debounced ~150 ms (watch fires bursts). Drives local auto-refresh: the main webview sends `setWatchedDirs` (the active tab's local panel cwds); a change fires `onChange` → `rpc.send.pathRefresh({sessionId:"local", dir})`. `onChange` is late-bound in index.ts (the `events` pattern). SFTP has no watch, so remote panels are polled UI-side instead (see panels.service). - `transfers/engine.ts` — FIFO job queue. `Semaphore` with live resize (`wake()` reserves slots synchronously — an async waiter increment was a race). Per-job worker pool (`MAX_WORKERS_PER_JOB=8`) draws from the shared slot budget, so in-folder parallelism and cross-job fairness share one knob (`setConcurrency` from settings; `single` mode = 1). `activeStreams` set enables hard cancel. `emit` throttled 100 ms. `failSession` fails that session's jobs. Probe files: `.{appname}-probe-*`. - `transfers/walk.ts` — recursive tree walk (segment arrays, skips symlinks) + `removeRecursive`. - **Self-copy protection** (data-loss class): copying a file onto itself truncates it (`createWriteStream` O_TRUNCs before the read). Guards compare **fsKey, never sessionId** — two sessions to one server are distinct sessions on one filesystem. When fsKeys differ but paths collide, `probeSameFilesystem` writes a token via dst and reads it via src to unmask hidden aliases (SFTP has no dev/inode). Fails open (permissions produce their own error). Residual risk (documented in README): same filesystem mounted at different paths. - `edits.ts` — `readFileText`/`writeFileText` (2 MB cap) for the built-in editor; `EditManager` for the external-editor flow: temp-download under `appDataDir()/edits//` + `meta.json`, directory watch (survives atomic renames), 400 ms debounce → engine upload. Watchers die with their session; leftover dirs are offered for cleanup at boot. `launchEditor` is the platform seam (`open` / `cmd /c start` / `xdg-open`). - `store/paths.ts` — per-OS `appDataDir()` + `migrateLegacyDataDir()` (renames a `BRANDING.legacyNames` dir into place on boot; list currently empty). - `store/connections.ts` — plaintext-passwords JSON (deliberate v1 choice), atomic tmp+rename, 0600 + win32 `icacls` ACL. `store/settings.ts` — defaults spread-merge on load, so NEW SETTINGS KEYS need no backend change. - `menu.ts` — macOS-only app menu, rebuilt on every connections change (the Connect submenu mirrors the store). ⌘W is intercepted by the menu, so bun routes `close-tab` to the focused editor window first. Labels go through backend `i18n.t` and the menu is rebuilt on a locale change too. - `i18n.ts` — backend translator (native menus + thrown error messages, which no Angular service can reach). `setLocale(setting)` caches the resolved locale (from `settings.locale`, `system` via `Intl…resolvedOptions().locale`); `t(key, params)` shares the same `src/shared/i18n` dictionaries as the UI. Wired in index.ts on boot and on `settingsChanged` (relocalizes + rebuilds the app menu). The context menu no longer needs this — it is Angular and reads `I18nService` directly. - `tray.ts` — static icon, click = activate main. macOS template image; Windows colored icon. No `setImage` calls ever (they lose the template treatment) and no menu (clicks arrive directly). - `index.ts` — composition root: wiring order matters (events assigned after rpc; manager/editors constructed after win; quit guard; focus tracking for notification suppression across all windows). ## UI layout ``` ui/src/app/ core/ app.config.ts, icons.ts, platform.ts, format.ts, file-icons.ts, metrics.ts and every *.service.ts — the non-visual layer skins/shadlike/ the component tree: three window roots (SHAD_ROOTS barrel, picked by main.ts), dialogs, context menu ``` Components reach `core/` through the **`@core/*`** tsconfig alias (alongside `@shared/*` → `src/shared`), which is deliberate: components sit at varying depths and an alias keeps their imports identical wherever a file moves. Do not replace it with relative paths. **Where a new rule goes**: behaviour → `core/`; markup and appearance → the component; tokens → `ui/src/styles.css`. ## UI services (`ui/src/app/core/`) - `rpc.service.ts` — wraps `Electroview.defineRPC`. **Push messages fan out through plain callback registries, never signals**: a signal consumed from an effect that also writes signals re-triggers itself (this froze the whole app once), and signal coalescing drops progress snapshots. All requests are gated on the socket-open promise — calls fired during boot would otherwise be silently lost. `request` and `send` are ready-gated Proxies. - `panels.service.ts` — `PanelState` (per panel): sessionId, fsKey, cwd, entries, sort, cursor, selection, type-to-filter. `sorted` computed: filter → dirs-first sort (name/size/mtime numeric, kind/rights/owner/group via display-string compare) → synthetic `'..'` row unshifted unless `isRootPath(cwd)`. `touched` marks user navigation (connect flows only auto-load `localDir` into an untouched local panel). `connectToken` supersedes stale connects. `Workspace` = tab = left/right pair + activeSide. Remote goes RIGHT on connect/open-stack (commander convention); per-panel dropdown connects target their own panel. **Auto-refresh** (always on): `refresh({silent})` re-lists without the loading/busy flags and touches no DOM (scroll/cursor/selection/filter preserved), snapshotting sessionId+cwd to discard results if the user navigated mid-flight. Only the active tab is live: its local panels are fs.watched (backend `setWatchedDirs` effect + `pathRefresh` push → silent refresh), its remote panels polled every 3s (`REMOTE_POLL_MS`); switching tabs silently refreshes the newly-active pair. A future on/off setting is stubbed in commented breadcrumbs (types.ts, settings.service, tab-bar). - `transfers.service.ts` — jobMap from `transferUpdate` callbacks; EMA speed (`SPEED_SMOOTHING=0.7`); `copyToOtherPanel` guards same-fs+same-dir before enqueue and runs the collision flow (same-path auto-skip, type-mismatch skip, overwrite dialog with all/none batch modes). - `settings.service.ts` — one signal per setting; **imperative apply pattern** (`applyTheme/DarkTone/Accent` set `data-*` attributes on ``; `applyUiScale` sets its inline `font-size`); `applyAll` also serves the `settingsChanged` broadcast, so every window follows a save made in any other. All three window RPCs serve `saveSettings` (editor and manager windows save too — the scale shortcuts); bun's `settingsSaved()` in index.ts runs the side effects once and rebroadcasts to every window **except the sender**, so there is no echo to fight a held Cmd+. `applyAll` never persists. New setting = five touches here + types.ts (backend merges automatically). - `focus.service.ts` — dependency-free `tick`/`bump()`; dialogs bump it, the main window's panels listen. Exists so dialogs don't inject PanelsService (whose constructor spawns a workspace — fatal in secondary windows). - `connections.service.ts` — store mirror + ssh-config hosts; reloads on the `connectionsChanged` push. - `i18n.service.ts` — runtime translator. `locale` signal + `t('ns.key', params)` (dotted lookup, `{param}` interpolation, `{one,other}` plural maps via `Intl.PluralRules`); missing keys fall back to English then the raw key. **Reactive by design**: `t()` reads the `locale` signal, so every template binding that calls it re-renders live on a language switch (call-stack tracking, see gotchas.md) — no pipe. Also exposes locale-aware `date()`/`size()` /`speed()` so the file list reformats on switch. `SettingsService.applyLocale` resolves the `locale` setting (`system` → `navigator.language`) into it, on boot and on the `settingsChanged` broadcast (so editor/manager windows follow). - `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always against an explicit panel; `menuTarget` remembers the right-clicked row. - `context-menu.service.ts` + `skins/shadlike/context-menu.ts` — the panel and tab right-click menus, in HTML. Electrobun's native `showContextMenu` is a no-op on Linux, so it was retired on ALL platforms rather than kept as a second code path (see platform-notes.md). The service builds the item list (row / empty-space menu, and the header column picker) and holds the open request as a signal; the component renders it at the pointer with edge-flip clamping, arrow/Enter/Escape keys, and close-on-blur/resize/scroll. Picked items are emitted verbatim as `ctx-*` / `col:*` / `tab:*` strings through `RpcService.emitMenuAction`, so they land in the SAME `App.handleMenuAction` dispatcher the macOS app menubar uses — one handler, two sources. The three shell actions the webview cannot perform (reveal, open-with-default, copy path) go back to bun via the `shellItemAction` RPC, which is all that remains of the old native menu. `tabItems()` builds the workspace-tab menu (`tab:new`, and `tab:close|close-others|close-right|disconnect:`), served by `PanelsService.closeOtherWorkspaces` / `closeWorkspacesToRight` / `disconnectWorkspace`. **The target id travels inside the action string**: the right-clicked tab is not necessarily the active one, so nothing on that path may fall back to `activeWorkspaceId`. Illegal entries are `disabled`, not omitted — a menu whose items move between right-clicks is worse than one with a dead row. The disabled styling (`pointer-events: none`) only stops the pointer; the keyboard path never consults it — so `pick()` and the arrow-key walker check the flag themselves. - `file-icons.ts` — extension → category map driving glyphs, colors (fixed `--icon-*` tokens, never accent-tinted) and the Kind column. - `platform.ts` — preload-global readers (`PLATFORM`, `APP_MODE`, `EDITOR_CONTEXT`, `IS_MAC`/`IS_MAC_CHROME`, `IS_WINDOWS`, `IS_LINUX`, `MOD_LABEL`/`ALT_LABEL`) + `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside text fields — the app draws its own menu instead). - `updater.service.ts` — auto-updater state for the tab-bar pill. Signals `state` (`idle→available→downloading→ready`|`error`), `version`, `progress`. Subscribes the `updateStatus` push (backend `Updater.checkForUpdate` on launch+6h, `onStatusChange` progress); `ready` is derived when the `downloadUpdate` request resolves ok (not a pushed phase). `download()` / `apply()` (relaunch) / `check()` (menu). Backend wiring lives in `src/bun/index.ts`; feed is `release.baseUrl` (`https://miniscp.com/dl`, the `/dl` host in `site/`). See build-and-release.md "Auto-updater". ## Theming `ui/src/styles.css`: shadcn-style OKLCH tokens; dark values are duplicated in `:root[data-theme='dark']` AND a `prefers-color-scheme` media block (system mode) — every dark token change goes in BOTH. Light is the zinc family; the dark neutrals are a **warm gray family (hue 107)** anchored on `#1a1a19` (`--background`) — retoning one dark neutral means retoning them all, or warm and cool surfaces clash. Surface elevation ramp, both themes: `--chrome` (frame strips) sits *below* `--background` (content panes) < `--card` (popovers/dialogs) < `--accent` (hover). Dark ramp `#151514` → `#1a1a19` → `#20201f` → `#2e2e2d`. The "darker" tone is true-black AMOLED: `--chrome` AND `--background` are both `#000` (the frame/content step collapses on purpose), elevation survives only via `--card`/`--accent`, and `--border`/`--input` go to 16%/18% white because those lines then carry the whole layout. `bg-chrome` is on the tab bar, status bar, transfer bar, the editor/manager header+footer strips and the manager's connection sidebar; panel-internal toolbars deliberately stay on `--background`. Accent tones: one `--tone-h/--tone-c` line per tone + three formula blocks derive `--primary`, `--ring`, `--cursor-tint`; graphite = attribute removed = pristine zinc. `@theme inline` maps tokens to Tailwind utilities (`bg-primary`, `text-icon-folder`, `bg-cursor-tint`). Selected rows are `bg-primary/15` (follow the accent); the cursor row uses `--cursor-tint` so graphite stays pixel-identical to the old neutral hover. **Interface scale** (`settings.uiScale`, 0.5–4 in 0.1 steps): one root font-size lever — `applyUiScale` sets `html { font-size: 16px * scale }`, and since EVERY size in the app is rem (panel tokens like `--row-h`, all Tailwind utilities, ng-icon sizes), the whole UI scales, chrome included. Driven by the tab-bar stepper, Ctrl/Cmd +/−/0, and preload-baked for a flash-free first paint. It replaced the 3-tier `data-text` axis; the store migrates old `textSize` values. TS that measures rem-sized CSS converts through `@core/metrics` `remPx()` — never an assumed 16.