Developer documentation: architecture, build/release, platforms, gotchas
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,13 @@ remote server over SSH/SFTP, so you can move files **local↔remote**,
|
|||||||
> **Name & icon are placeholders.** See [Rebranding](#rebranding) — it's a
|
> **Name & icon are placeholders.** See [Rebranding](#rebranding) — it's a
|
||||||
> one-file change.
|
> one-file change.
|
||||||
|
|
||||||
|
## Developer docs
|
||||||
|
|
||||||
|
Dense reference material lives in [docs/](docs/): [architecture](docs/architecture.md)
|
||||||
|
(processes, windows, RPC, every module/service), [build & release](docs/build-and-release.md)
|
||||||
|
(Build-n versioning, per-target release pipeline, CI), [platform notes](docs/platform-notes.md)
|
||||||
|
(macOS/Windows matrix + smoke tests), and [gotchas](docs/gotchas.md) (known landmines).
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Dual-panel commander UI; either panel can be Local or any saved connection.
|
- Dual-panel commander UI; either panel can be Local or any saved connection.
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# 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. 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<T>` (`{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, ctx?)` injects `__APP_PLATFORM`,
|
||||||
|
`__APP_MODE` (`main|editor|manager`), `__APP_CTX` before any page script.
|
||||||
|
`ui/src/main.ts` picks the root component from `APP_MODE`; all three roots use
|
||||||
|
selector `app-root` (index.html hosts only that tag). `openSecondaryWindow` is
|
||||||
|
the ONLY place that branches platform for secondary chrome: 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<Rec>` (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/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:<id>` /
|
||||||
|
`open-stack:<id>` 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-<n>`; `drop()` (session died) vs `remove()` (clean).
|
||||||
|
- `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/<id>/` + `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.
|
||||||
|
- `context-menu.ts` — native row/empty-space menus + the column picker.
|
||||||
|
Local shell actions (reveal/open/copy-path) run bun-side via Utils; others
|
||||||
|
forward through `menuAction` and the UI applies them to the row it
|
||||||
|
remembered (`FileActionsService.menuTarget`). Labels are per-platform.
|
||||||
|
- `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.
|
||||||
|
- `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 services (`ui/src/app/`)
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `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/TextSize` set `data-*` attributes on
|
||||||
|
`<html>`); `applyAll` also serves the `settingsChanged` broadcast so editor
|
||||||
|
and manager windows restyle live (they render no settings UI, so no
|
||||||
|
persist echo). 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.
|
||||||
|
- `file-actions.service.ts` — toolbar/keyboard/context-menu file ops, always
|
||||||
|
against an explicit panel; `menuTarget` remembers the right-clicked row.
|
||||||
|
- `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_CHROME`, `IS_WINDOWS`, `MOD_LABEL`/`ALT_LABEL`)
|
||||||
|
+ `blockNativeContextMenu` (kills WebKit's Reload/Inspect menu outside
|
||||||
|
text fields).
|
||||||
|
|
||||||
|
## 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. 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. Text-size levels set `--row-h`,
|
||||||
|
`--list-font` etc. per `data-text` attribute, consumed via `h-(--row-h)` /
|
||||||
|
`text-(length:--list-font)` arbitrary utilities.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Build & release
|
||||||
|
|
||||||
|
## Versioning — "Build n"
|
||||||
|
|
||||||
|
Sublime-style: the version IS the commit count. `scripts/gen-version.ts` runs
|
||||||
|
`git rev-list --count HEAD` + `git rev-parse --short HEAD` and writes the
|
||||||
|
**gitignored** `src/shared/version.gen.ts` (`BUILD = {number, commit, date,
|
||||||
|
electrobun}`). It is regenerated by `postinstall` and at the start of every
|
||||||
|
`dev`/`build`/`start`/`release` script; committing it would bump the count it
|
||||||
|
records, hence gitignored. Fresh checkouts fail `tsc` until `bun install` has
|
||||||
|
run once (postinstall creates the file). `BRANDING.version` stays
|
||||||
|
semver-shaped (`0.1.<n>`) for electrobun's config/updater; `BRANDING.buildLabel`
|
||||||
|
("Build n") is what humans see (About box, options popover).
|
||||||
|
|
||||||
|
## Dev loop
|
||||||
|
|
||||||
|
- `bun run dev` — gen-version → scripts/dev.ts: starts `ng serve` (HMR) and
|
||||||
|
`electrobun dev --watch`, with `DEV_SERVER_URL` pointing the webviews at the
|
||||||
|
dev server (only honored on the dev channel and only if the server answers).
|
||||||
|
- `bun run build` — gen-version → Angular build → `electrobun build`
|
||||||
|
(**dev channel** — fine for boot checks, NOT a release).
|
||||||
|
- `scripts/node-version-shim.ts` (preloaded via `bunfig.toml`, root AND ui/)
|
||||||
|
fakes a Node version the Angular CLI accepts under Bun. Both preloads are
|
||||||
|
load-bearing.
|
||||||
|
|
||||||
|
## Release builds
|
||||||
|
|
||||||
|
`bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]` (default: host).
|
||||||
|
Steps: gen-version → gen-icons → Angular build → `electrobun build
|
||||||
|
--env=stable` → copy/rename artifacts into
|
||||||
|
`release/<target>/miniSCP-Build<n>-<target>.*`.
|
||||||
|
|
||||||
|
Facts the script encodes:
|
||||||
|
|
||||||
|
- **`--env=stable` is mandatory for releases** — the CLI defaults to the dev
|
||||||
|
channel (different app name `miniSCP-dev`, no distributable artifacts).
|
||||||
|
- **No OS cross-compile.** The CLI builds for its host (its `targets` config
|
||||||
|
field is unread in 1.18.1); packaging needs host tools (codesign/hdiutil on
|
||||||
|
macOS, rcedit/PowerShell on Windows). Hence one script run per artifact, on
|
||||||
|
the matching machine; `win-x64` on a non-Windows host errors out.
|
||||||
|
- **mac-x64 from the arm64 Mac (Rosetta)**: the script downloads electrobun's
|
||||||
|
x64 CLI binary once (`electrobun-cli-darwin-x64.tar.gz` from the GitHub
|
||||||
|
release matching the installed version, cached at
|
||||||
|
`~/.cache/miniscp/electrobun-cli-x64-<ver>/`) and runs it directly —
|
||||||
|
Rosetta translates it, the CLI sees `process.arch === "x64"`, downloads the
|
||||||
|
`dist-macos-x64` core into the electrobun package dir on first use, and
|
||||||
|
packages a genuine x64 app. Note: Bun's `fetch`→`Bun.write` streaming hung
|
||||||
|
on the GitHub redirect chain; the script uses `curl -fsSL` deliberately.
|
||||||
|
- **Core binary downloads**: the installed npm package ships mac-arm64
|
||||||
|
binaries only; other `dist-<os>-<arch>` cores are fetched from
|
||||||
|
`github.com/blackboardsh/electrobun/releases` at build time — network to
|
||||||
|
GitHub is a build-time requirement on every fresh machine/runner.
|
||||||
|
- **Artifact shapes** (stable channel, in `artifacts/`): macOS →
|
||||||
|
`stable-macos-<arch>-miniSCP.dmg` + `.app.tar.zst` + `update.json`
|
||||||
|
(updater feed); Windows → `stable-win-x64-miniSCP-Setup.zip`
|
||||||
|
(self-extracting installer → `%LOCALAPPDATA%`) plus the script's own
|
||||||
|
**portable zip** made from the assembled app folder under
|
||||||
|
`build/stable-win-x64/` (layout to confirm on the first real Windows build —
|
||||||
|
the step warns instead of failing).
|
||||||
|
- The script filters artifacts by app name, so stale artifacts from older
|
||||||
|
brandings don't get collected.
|
||||||
|
|
||||||
|
## CI (Gitea Actions)
|
||||||
|
|
||||||
|
`.gitea/workflows/release.yml` — tag-triggered (`build-*`), macos + windows
|
||||||
|
runner matrix, each running the same release script; inert until act_runners
|
||||||
|
with those labels are registered. Needs `fetch-depth: 0` (the build number is
|
||||||
|
the commit count). Runner prereqs are commented in the workflow file.
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
`scripts/gen-icons.ts` (resvg, dev-time): renders `assets/logo.svg` into the
|
||||||
|
macOS iconset + `icon.png`, a multi-size `assets/icon.ico` (Windows installer/
|
||||||
|
taskbar, wired via `electrobun.config.ts win.icon`), the macOS tray template
|
||||||
|
PNG from `assets/tray/tray-glyph.svg`, and a small colored Windows tray PNG.
|
||||||
|
Rerun after touching either SVG; outputs are committed (build inputs).
|
||||||
|
|
||||||
|
## electrobun config notes
|
||||||
|
|
||||||
|
`electrobun.config.ts`: app identity comes from `BRANDING`; `build.bun.external`
|
||||||
|
must keep `["cpu-features", "*.node"]` (ssh2's optional native accelerators —
|
||||||
|
bundling them breaks the build; runtime falls back to pure JS);
|
||||||
|
`build.copy` ships the Angular dist and tray assets into `views://`;
|
||||||
|
`bundleCEF: false` everywhere (system WebKit / WebView2).
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Gotchas — landmines this codebase has already stepped on
|
||||||
|
|
||||||
|
Every one of these caused a real bug or debugging spiral. Read before
|
||||||
|
touching the adjacent code.
|
||||||
|
|
||||||
|
## Angular (v22, zoneless, signals, OnPush)
|
||||||
|
|
||||||
|
- **Never consume push streams through signal+effect.** An effect that reads
|
||||||
|
a signal and (directly or transitively) writes signals re-triggers itself —
|
||||||
|
this froze the entire app on the first transfer. Push messages
|
||||||
|
(transferUpdate etc.) go through plain callback registries in RpcService.
|
||||||
|
Signal coalescing would also silently drop intermediate progress snapshots.
|
||||||
|
- **No signal writes during template render** (NG0600). Anything that loads
|
||||||
|
panels from code paths reachable during render defers with
|
||||||
|
`queueMicrotask` (see `PanelsService.newWorkspace`).
|
||||||
|
- **`[value]` on `<select>` is applied before `@for` options render** and
|
||||||
|
silently selects nothing. Use per-option `[selected]` bindings (connection
|
||||||
|
manager stack picker).
|
||||||
|
- Template bindings with special characters: `[class.bg-primary/15]` works;
|
||||||
|
`[class.shadow-[...]]` (brackets) does NOT parse — use `[style.box-shadow]`.
|
||||||
|
Static class attributes may use arbitrary values freely.
|
||||||
|
- Functions called from templates ARE reactive: signals read inside them
|
||||||
|
register on the view's consumer (call-stack-based tracking). That's why
|
||||||
|
`iconClass(entry)` reacting to `settings.iconColors()` works.
|
||||||
|
- Dedicated tools > shell: IDE diagnostics between paired template/class
|
||||||
|
edits are often stale mid-sequence — trust `bun run build:ui` (full AOT
|
||||||
|
type-check), not the first red squiggle.
|
||||||
|
- Tailwind v4 canonical class names: `wrap-break-word` not `break-words`,
|
||||||
|
`accent-primary` not `accent-[var(--primary)]`, `z-60` not `z-[60]`.
|
||||||
|
Every color used in a template must resolve through `@theme inline`.
|
||||||
|
|
||||||
|
## Electrobun
|
||||||
|
|
||||||
|
- **Window `close` is not cancellable** (response type `{}`; only the
|
||||||
|
app-level `before-quit` supports `event.response = {allow:false}`). All
|
||||||
|
unsaved-state guards must either intercept app-controlled close paths
|
||||||
|
(⌘W, buttons) or survive the close (editor draft mirroring).
|
||||||
|
- **One rpc instance per window** — BrowserView binding steals the rpc's
|
||||||
|
transport; a shared instance silently sends to the wrong webview.
|
||||||
|
- **RPC before socket open is silently lost.** Everything gates on the
|
||||||
|
socket-open promise (RpcService `ready`).
|
||||||
|
- The emitter has no `off()`. Window event closures must not capture records
|
||||||
|
you want garbage collected.
|
||||||
|
- `electrobun build` defaults to the **dev** channel; release builds need
|
||||||
|
`--env=stable`. The stable app name differs from the dev one
|
||||||
|
(`miniSCP` vs `miniSCP-dev`), including the app-data dir implications when
|
||||||
|
testing.
|
||||||
|
- Tray: never call `setImage` after construction on macOS — the template
|
||||||
|
treatment is lost and the icon renders black on dark menu bars.
|
||||||
|
- `ApplicationMenu` is macOS-only in our usage; accelerators on Windows are
|
||||||
|
single-character only. ContextMenu exists on macOS+Windows, not Linux.
|
||||||
|
- Dev vs prod URL: `getMainViewUrl()` resolves the ng-serve dev server only
|
||||||
|
when `DEV_SERVER_URL` is set AND the updater channel is `dev` AND the
|
||||||
|
server answers a HEAD request; secondary windows must reuse the resolved
|
||||||
|
URL, never hardcode `views://`.
|
||||||
|
- Upstream Bun panic (`FFI_Callback_threadsafe_call` → `web_worker.spin`):
|
||||||
|
rare segfault under long sessions; upstream electrobun×Bun issue, restart
|
||||||
|
recovers, nothing app-side to fix.
|
||||||
|
|
||||||
|
## Domain logic
|
||||||
|
|
||||||
|
- **sessionId ≠ filesystem identity.** Two panels on the same server are two
|
||||||
|
SSH sessions. Every same-file/nested-path guard compares `fsKey`
|
||||||
|
(`resolvedIP:port` / `"local"`); comparing sessionId caused a
|
||||||
|
truncate-to-zero data-loss bug. When fsKeys differ but paths collide, the
|
||||||
|
probe-file check unmasks aliased hosts. Never "simplify" this back.
|
||||||
|
- `createWriteStream` truncates BEFORE the source is read — that's why
|
||||||
|
self-copy is destructive, and why guards must run before any stream opens.
|
||||||
|
- The synthetic `'..'` row: type `'dir'`, must be excluded from selection,
|
||||||
|
targets(), filtering match-count, kind/rights/owner display, and drag
|
||||||
|
sources. Any new row-level feature must decide what `'..'` does.
|
||||||
|
- `Semaphore.wake()` must reserve slots synchronously; waking waiters that
|
||||||
|
increment asynchronously loses budget under concurrent release.
|
||||||
|
- Settings: the backend spread-merges (`{...DEFAULT_SETTINGS, ...data}`) so
|
||||||
|
new keys auto-default, but the UI SettingsService reads field-by-field —
|
||||||
|
a new setting silently stays at default until wired in all five spots.
|
||||||
|
- The editor draft mirror sends full content per tick (2 MB cap upstream);
|
||||||
|
don't shrink the 400 ms debounce.
|
||||||
|
- `panel.touched` gates localDir auto-navigation; `connectToken` guards
|
||||||
|
against a slow connect resolving into a panel the user already reused.
|
||||||
|
|
||||||
|
## Build / tooling
|
||||||
|
|
||||||
|
- Angular CLI rejects Bun's Node-compat version — `scripts/node-version-shim.ts`
|
||||||
|
is preloaded via `bunfig.toml` at the root AND in `ui/`. Removing either
|
||||||
|
breaks `ng`.
|
||||||
|
- ssh2's optional native deps must stay `external: ["cpu-features", "*.node"]`
|
||||||
|
in `electrobun.config.ts` — bundling them breaks the build; ssh2 falls back
|
||||||
|
to pure JS at runtime.
|
||||||
|
- `bun run --cwd ui build` (flag order matters; `bun --cwd ui run build` does
|
||||||
|
not do what you want).
|
||||||
|
- `version.gen.ts` is gitignored and regenerated by postinstall + every build
|
||||||
|
script; a fresh checkout fails `tsc` until `bun install` (or any script)
|
||||||
|
has run once. Never commit it — it would bump the commit count it records.
|
||||||
|
- Icon pipeline is `scripts/gen-icons.ts` (resvg) — regenerate after touching
|
||||||
|
`assets/logo.svg` or `assets/tray/tray-glyph.svg`; the .ico and tray PNGs
|
||||||
|
are build inputs, not build outputs.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Platform notes — macOS / Windows behavior matrix
|
||||||
|
|
||||||
|
macOS is the primary target; Windows 11+ x64 is a **beta** target (win-arm64
|
||||||
|
deferred; Linux untargeted — electrobun lacks menus/context menus there).
|
||||||
|
Every platform seam carries a `PORTABILITY:` comment at the branch site.
|
||||||
|
|
||||||
|
## Seam inventory (where platform branches live)
|
||||||
|
|
||||||
|
| Concern | File | mac | win | other |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Window chrome | `src/bun/windows.ts`, `index.ts` | hiddenInset + traffic-light offset, UI drag header | native titlebar, compact header | same as win |
|
||||||
|
| External editor launch | `src/bun/edits.ts launchEditor` | `open [-a app]` | `cmd /c start "" [app] file` | app directly / `xdg-open` |
|
||||||
|
| Editor app picker | `rpc-handlers.ts pickEditorApp` | /Applications, `.app` | %ProgramFiles%, `.exe` | home, any |
|
||||||
|
| Owner/group names | `fs/local.ts resolveId` | `id -nu` + `dscacheutil` | undefined → "—" | `id -nu` + `getent` |
|
||||||
|
| Drive roots | `fs/local.ts listLocalDrives` | empty | `fsutil fsinfo drives` | empty |
|
||||||
|
| chmod/Permissions | context-menu.ts, file-panel, file-actions | full | hidden for LOCAL panels (only flips read-only); SFTP unaffected | full |
|
||||||
|
| Shell wording | `context-menu.ts` | Reveal in Finder / Move to Trash | Show in Explorer / Move to Recycle Bin | Show in File Manager |
|
||||||
|
| Shortcut labels | `ui platform.ts MOD_LABEL/ALT_LABEL` | ⌘ / ⌥ | Ctrl+ / Alt+ | Ctrl+ / Alt+ |
|
||||||
|
| Tray | `src/bun/tray.ts` | 22 pt template PNG | 16 px colored PNG | colored |
|
||||||
|
| App menu | `src/bun/menu.ts` | full menu incl. About | none (early return) — About lives in the options popover | none |
|
||||||
|
| connections.json protection | `store/connections.ts` | chmod 0600 | `icacls` current-user ACL (0600 is meaningless) | 0600 |
|
||||||
|
| App data dir | `store/paths.ts` | ~/Library/Application Support | %APPDATA% | $XDG_CONFIG_HOME/.config |
|
||||||
|
| ssh-agent | `fs/sftp.ts` | $SSH_AUTH_SOCK | `\\.\pipe\openssh-ssh-agent` fallback | $SSH_AUTH_SOCK |
|
||||||
|
| Paths in UI | `ui format.ts joinPath/isRootPath` | "/" | separator detected from the dir string; drive-root regex | "/" |
|
||||||
|
|
||||||
|
Remote (SFTP) paths are ALWAYS POSIX regardless of host or server OS
|
||||||
|
(`path.posix` throughout `fs/sftp.ts`).
|
||||||
|
|
||||||
|
## Windows known issues (upstream electrobun, documented not fixed)
|
||||||
|
|
||||||
|
- **HiDPI blur** — WebView2 renders at 1x on scaled displays
|
||||||
|
(blackboardsh/electrobun#324, open). Most user-visible defect; wait for
|
||||||
|
upstream DPI awareness.
|
||||||
|
- **Notifications** are legacy balloon style (`Shell_NotifyIcon`), not
|
||||||
|
Windows toasts.
|
||||||
|
- **Unsigned binaries** — Setup.exe and the portable exe both trigger
|
||||||
|
SmartScreen / Mark-of-the-Web on first run. No Authenticode support in
|
||||||
|
electrobun 1.18.1 (signtool post-hook or electrobun-builder are options
|
||||||
|
later). Installer unpacks to `%LOCALAPPDATA%`, desktop shortcut only — no
|
||||||
|
Start Menu entry or uninstaller.
|
||||||
|
- `hiddenInset` degrades to `hidden` on Windows — irrelevant here because
|
||||||
|
non-mac windows use the native titlebar.
|
||||||
|
- `findInPage` is a `window.find()` shim on WebView2 — irrelevant here, the
|
||||||
|
editor find is custom.
|
||||||
|
|
||||||
|
## First-Windows-boot smoke test
|
||||||
|
|
||||||
|
Code paths written blind (no Windows box was available); verify in order:
|
||||||
|
|
||||||
|
1. Build: `bun scripts/release.ts --target win-x64` on the Windows machine —
|
||||||
|
core binaries download from GitHub releases on first run; Setup.zip AND
|
||||||
|
the portable zip appear under `release/win-x64/` (the portable step warns
|
||||||
|
instead of failing if the assembled-app folder layout differs — fix the
|
||||||
|
`appDir` path in release.ts if so).
|
||||||
|
2. App boots, tray icon renders colored, window has a native titlebar.
|
||||||
|
3. Drives dropdown in each panel lists `C:\` (+ others); navigating to a
|
||||||
|
drive root hides the `'..'` row; the path bar shows consistent `\` paths.
|
||||||
|
4. local↔remote transfers both directions; self-copy guard still blocks
|
||||||
|
same-path copies.
|
||||||
|
5. External editor: default app opens via `cmd /c start`; the Choose… picker
|
||||||
|
starts in Program Files and accepts an .exe; remote temp-edit → save →
|
||||||
|
auto-upload works (directory watcher on NTFS).
|
||||||
|
6. Context menus: Show in Explorer, Move to Recycle Bin (item restorable from
|
||||||
|
the bin), Copy Path; Permissions ABSENT on local rows, present on remote.
|
||||||
|
7. Ctrl+T/W/F/S and Ctrl+digit tab jumps; tooltips read "Ctrl+…".
|
||||||
|
8. connections.json ACL: `icacls connections.json` shows only the current
|
||||||
|
user after saving a connection.
|
||||||
|
9. Editor/manager windows open, modal bounce works, About shows from the
|
||||||
|
options popover.
|
||||||
|
10. Known-issue confirmation: HiDPI blur on a scaled display (expected until
|
||||||
|
upstream fixes it).
|
||||||
Reference in New Issue
Block a user