Compare commits
4
Commits
14b92b33a4
...
dc5cb1f707
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc5cb1f707 | ||
|
|
2585dd128b | ||
|
|
c7271fb7cf | ||
|
|
d80d5c23dc |
@@ -0,0 +1,46 @@
|
||||
# Release matrix — INERT until act_runner instances with these labels are
|
||||
# registered on git.teknes.es. Each OS builds its own artifacts (electrobun
|
||||
# cannot cross-compile: packaging needs codesign/hdiutil on macOS and
|
||||
# rcedit/PowerShell on Windows).
|
||||
#
|
||||
# Runner prerequisites:
|
||||
# macos — arm64 Mac: bun, Xcode CLT, Rosetta 2 (softwareupdate
|
||||
# --install-rosetta), network to github.com (electrobun downloads
|
||||
# its per-arch core binaries on first build)
|
||||
# windows — Windows 11 x64: bun, network to github.com; WebView2 runtime is
|
||||
# preinstalled on Win 11
|
||||
#
|
||||
# Trigger: push a tag like build-62 (git tag build-$(git rev-list --count HEAD)).
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "build-*"
|
||||
|
||||
jobs:
|
||||
mac:
|
||||
runs-on: macos
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # full history — the build number is the commit count
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun scripts/release.ts --target mac-arm64
|
||||
- run: bun scripts/release.ts --target mac-x64
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: mac
|
||||
path: release/**
|
||||
|
||||
windows:
|
||||
runs-on: windows
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- run: bun install --frozen-lockfile
|
||||
- run: bun scripts/release.ts --target win-x64
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: windows
|
||||
path: release/**
|
||||
@@ -6,3 +6,5 @@ artifacts/
|
||||
.DS_Store
|
||||
ui/dist/
|
||||
ui/.angular/
|
||||
src/shared/version.gen.ts
|
||||
release/
|
||||
|
||||
@@ -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
|
||||
> 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
|
||||
|
||||
- Dual-panel commander UI; either panel can be Local or any saved connection.
|
||||
@@ -87,6 +94,29 @@ This starts the Angular dev server (hot reload for UI code) and launches the
|
||||
Electrobun app pointed at it. Edits under `ui/` reload instantly; edits to the
|
||||
Bun main process (`src/bun/**`, RPC schema) trigger an app relaunch.
|
||||
|
||||
## Releases
|
||||
|
||||
Versioning is Sublime-style: **Build n**, where n is the commit count
|
||||
(`git rev-list --count HEAD`), regenerated into the gitignored
|
||||
`src/shared/version.gen.ts` by every build script (and `postinstall`).
|
||||
|
||||
One artifact per invocation, on the matching host — electrobun cannot
|
||||
cross-compile between OSes:
|
||||
|
||||
```sh
|
||||
bun run release -- --target mac-arm64 # on the Mac (native)
|
||||
bun run release -- --target mac-x64 # on the same arm64 Mac, via Rosetta 2
|
||||
bun run release -- --target win-x64 # on a Windows 11 x64 machine
|
||||
```
|
||||
|
||||
Artifacts land in `release/<target>/miniSCP-Build<n>-<target>.*` — a `.dmg`
|
||||
per mac arch; on Windows a `-Setup.zip` installer **and** a `-portable.zip`
|
||||
(unzip anywhere and run). The mac-x64 build fetches electrobun's x64 CLI once
|
||||
into `~/.cache/miniscp/` and runs it under Rosetta so the whole pipeline
|
||||
believes it is on an Intel Mac. CI: `.gitea/workflows/release.yml` runs the
|
||||
same script on a macos + windows runner matrix once act_runners are
|
||||
registered; trigger by pushing a `build-<n>` tag.
|
||||
|
||||
## Build a packaged app
|
||||
|
||||
```bash
|
||||
@@ -109,7 +139,7 @@ Everything user-facing reads from **[`src/shared/branding.ts`](src/shared/brandi
|
||||
export const BRANDING = {
|
||||
appName: "miniSCP", // window title, bundle name, app-data folder
|
||||
identifier: "es.teknes.miniscp",
|
||||
legacyNames: ["BunSCP"], // previous names; their data dirs migrate on boot
|
||||
legacyNames: [], // previous names; their data dirs migrate on boot
|
||||
version: "0.1.0",
|
||||
icon: "assets/icon.iconset",
|
||||
} as const;
|
||||
@@ -173,15 +203,33 @@ Be careful when two panels reach the same data through different mount points.
|
||||
|
||||
## Portability notes
|
||||
|
||||
The codebase is platform-neutral except for two seams, both macOS-only today:
|
||||
Platform seams are isolated behind `process.platform` / `PLATFORM` branches
|
||||
marked with `PORTABILITY:` comments: editor launch (`open` / `cmd /c start` /
|
||||
`xdg-open`), app pickers, owner/group lookups, tray images, window chrome,
|
||||
shell-integration wording, drive listing, and the connections-file ACL. The
|
||||
application menu ([src/bun/menu.ts](src/bun/menu.ts)) is macOS-only for now;
|
||||
the in-app UI covers every menu action.
|
||||
|
||||
- **External editor launch** (`launchEditor` in [src/bun/edits.ts](src/bun/edits.ts)):
|
||||
uses macOS `open [-a app] file`. A Windows port should use
|
||||
`cmd /c start "" [app] file`; Linux `xdg-open file` (or invoke the configured
|
||||
editor binary directly). The `.app` picker in the `pickEditorApp` handler
|
||||
needs the equivalent executable picker per platform.
|
||||
- **Application menu** ([src/bun/menu.ts](src/bun/menu.ts)): electrobun menus
|
||||
are macOS-only; the in-app UI covers every menu action.
|
||||
## Windows (beta)
|
||||
|
||||
Windows 11+ x64 is supported as a **beta** target (win-arm64 deferred). All
|
||||
electrobun APIs the app uses are implemented on Windows; known issues are
|
||||
upstream:
|
||||
|
||||
- **HiDPI blur** ([electrobun#324](https://github.com/blackboardsh/electrobun/issues/324)):
|
||||
WebView2 renders at 1x on scaled displays until upstream adds DPI awareness.
|
||||
- **Notifications** use legacy balloon style, not Windows toast.
|
||||
- **Unsigned binaries**: both the Setup.exe installer and the portable exe
|
||||
trigger SmartScreen/Mark-of-the-Web warnings on first run. No Start Menu
|
||||
entry or uninstaller (installer unpacks to `%LOCALAPPDATA%`).
|
||||
- `connections.json` is protected with an `icacls` current-user ACL instead of
|
||||
POSIX 0600 (which is meaningless on Windows).
|
||||
|
||||
First-Windows-boot smoke test: drives dropdown lists C:\ etc.; navigate to a
|
||||
drive root ('..' disappears); local↔remote transfers; external editor launch
|
||||
(`cmd /c start`) and the .exe picker; Show in Explorer / Recycle Bin; tray icon
|
||||
renders colored; Permissions absent on local panels; Ctrl+labels in tooltips;
|
||||
portable zip runs from any folder.
|
||||
|
||||
## Known issues
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
@@ -13,6 +13,7 @@
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@types/bun": "latest",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"png-to-ico": "^3.0.2",
|
||||
"typescript": "~5.9.3",
|
||||
},
|
||||
},
|
||||
@@ -930,9 +931,9 @@
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
|
||||
"png-to-ico": ["png-to-ico@3.0.2", "", { "dependencies": { "@types/node": "^25.5.0", "minimist": "^1.2.8", "pngjs": "^7.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-36vvp3/YF7LPYkUEOj08/WgB1wI63qW391YfOhOWckgOtGkarr9+EhPBimcCmRP7fJaG4EaXQhTTaQ8qqdj8aA=="],
|
||||
|
||||
"pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"postcss": ["postcss@8.5.16", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg=="],
|
||||
|
||||
@@ -1144,6 +1145,8 @@
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"electrobun/png-to-ico": ["png-to-ico@2.1.8", "", { "dependencies": { "@types/node": "^17.0.36", "minimist": "^1.2.6", "pngjs": "^6.0.0" }, "bin": { "png-to-ico": "bin/cli.js" } }, "sha512-Nf+IIn/cZ/DIZVdGveJp86NG5uNib1ZXMiDd/8x32HCTeKSvgpyg6D/6tUBn1QO/zybzoMK0/mc3QRgAyXdv9w=="],
|
||||
|
||||
"hosted-git-info/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
@@ -1166,7 +1169,7 @@
|
||||
|
||||
"path-scurry/lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
|
||||
|
||||
"png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
||||
"png-to-ico/@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="],
|
||||
|
||||
"proxy-agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
@@ -1190,12 +1193,18 @@
|
||||
|
||||
"@npmcli/promise-spawn/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||
|
||||
"electrobun/png-to-ico/@types/node": ["@types/node@17.0.45", "", {}, "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="],
|
||||
|
||||
"electrobun/png-to-ico/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||
|
||||
"minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
|
||||
|
||||
"node-gyp/which/isexe": ["isexe@4.0.0", "", {}, "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw=="],
|
||||
|
||||
"png-to-ico/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"sass/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||
|
||||
@@ -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).
|
||||
@@ -27,7 +27,8 @@ export default {
|
||||
bundleCEF: false,
|
||||
},
|
||||
win: {
|
||||
bundleCEF: false,
|
||||
bundleCEF: false, // system WebView2 keeps the bundle small
|
||||
icon: "assets/icon.ico",
|
||||
},
|
||||
},
|
||||
} satisfies ElectrobunConfig;
|
||||
|
||||
+12
-5
@@ -3,13 +3,17 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": ["ui"],
|
||||
"workspaces": [
|
||||
"ui"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "bun scripts/dev.ts",
|
||||
"dev": "bun scripts/gen-version.ts && bun scripts/dev.ts",
|
||||
"dev:ui": "bun run --cwd ui start",
|
||||
"build:ui": "bun run --cwd ui build",
|
||||
"build": "bun run build:ui && bunx electrobun build",
|
||||
"start": "bun run build:ui && bunx electrobun dev"
|
||||
"build": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun build",
|
||||
"start": "bun scripts/gen-version.ts && bun run build:ui && bunx electrobun dev",
|
||||
"postinstall": "bun scripts/gen-version.ts",
|
||||
"release": "bun scripts/release.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"electrobun": "1.18.1",
|
||||
@@ -20,7 +24,10 @@
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@types/bun": "latest",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"png-to-ico": "^3.0.2",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"trustedDependencies": ["@parcel/watcher"]
|
||||
"trustedDependencies": [
|
||||
"@parcel/watcher"
|
||||
]
|
||||
}
|
||||
|
||||
+10
-3
@@ -1,10 +1,12 @@
|
||||
// Regenerate every raster icon asset from the SVG sources (dev-time only).
|
||||
// bun scripts/gen-icons.ts
|
||||
// Produces: assets/icon.png (1024), assets/icon.iconset/* (macOS app icon),
|
||||
// assets/tray/tray-44-template.png.
|
||||
// assets/icon.ico (Windows), assets/tray/tray-44-template.png (macOS tray),
|
||||
// assets/tray/tray-32-color.png (Windows tray).
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { Resvg } from "@resvg/resvg-js";
|
||||
import pngToIco from "png-to-ico";
|
||||
|
||||
const root = path.join(import.meta.dir, "..");
|
||||
const assets = path.join(root, "assets");
|
||||
@@ -27,8 +29,13 @@ for (const size of [16, 32, 128, 256, 512]) {
|
||||
await writePng(path.join(assets, "icon.iconset", `icon_${size}x${size}@2x.png`), logo, size * 2);
|
||||
}
|
||||
|
||||
// ---- tray base (22pt status item @2x) ----
|
||||
// ---- Windows app icon (.ico: 16/32/48/256, used by installer + taskbar) ----
|
||||
const icoPngs = [16, 32, 48, 256].map((size) => Buffer.from(renderPng(logo, size)));
|
||||
await Bun.write(path.join(assets, "icon.ico"), await pngToIco(icoPngs));
|
||||
|
||||
// ---- tray: macOS 22pt template @2x; Windows small colored icon ----
|
||||
const trayGlyph = await Bun.file(path.join(assets, "tray", "tray-glyph.svg")).text();
|
||||
await writePng(path.join(assets, "tray", "tray-44-template.png"), trayGlyph, 44);
|
||||
await writePng(path.join(assets, "tray", "tray-32-color.png"), logo, 32);
|
||||
|
||||
console.log("icons regenerated: app iconset + tray template");
|
||||
console.log("icons regenerated: app iconset + icon.ico + tray images");
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Writes src/shared/version.gen.ts (gitignored) with the "Build n" version:
|
||||
// n = commit count since the repo root, Sublime Text style. Regenerated by
|
||||
// postinstall and by every dev/build/release script — never committed, since
|
||||
// committing it would bump the very count it records.
|
||||
import path from "node:path";
|
||||
|
||||
function git(args: string[]): string | null {
|
||||
try {
|
||||
const proc = Bun.spawnSync(["git", ...args], {
|
||||
cwd: import.meta.dir,
|
||||
stdout: "pipe",
|
||||
stderr: "ignore",
|
||||
});
|
||||
if (!proc.success) return null;
|
||||
return proc.stdout.toString().trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const number = Number(git(["rev-list", "--count", "HEAD"]) ?? 0);
|
||||
const commit = git(["rev-parse", "--short", "HEAD"]) ?? "dev";
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let electrobun = "unknown";
|
||||
try {
|
||||
const pkg = await Bun.file(
|
||||
path.join(import.meta.dir, "..", "node_modules", "electrobun", "package.json"),
|
||||
).json();
|
||||
electrobun = pkg.version;
|
||||
} catch {
|
||||
// not installed yet (postinstall ordering) — fine
|
||||
}
|
||||
|
||||
const out = `// GENERATED by scripts/gen-version.ts — do not edit, do not commit.
|
||||
export const BUILD = {
|
||||
number: ${number},
|
||||
commit: ${JSON.stringify(commit)},
|
||||
date: ${JSON.stringify(date)},
|
||||
electrobun: ${JSON.stringify(electrobun)},
|
||||
} as const;
|
||||
`;
|
||||
|
||||
const target = path.join(import.meta.dir, "..", "src", "shared", "version.gen.ts");
|
||||
await Bun.write(target, out);
|
||||
console.log(`[version] Build ${number} (${commit}) -> ${path.relative(process.cwd(), target)}`);
|
||||
@@ -0,0 +1,128 @@
|
||||
// Build one release artifact for one target, on the matching host:
|
||||
// bun scripts/release.ts [--target mac-arm64|mac-x64|win-x64]
|
||||
// Default target = the host. electrobun cannot cross-compile OSes (its
|
||||
// packaging needs host tools: codesign/hdiutil on mac, rcedit/PowerShell on
|
||||
// Windows), so win-x64 must run on a Windows machine. mac-x64 IS buildable on
|
||||
// an arm64 Mac: we fetch electrobun's x64 CLI binary and run it under
|
||||
// Rosetta 2 — it sees an x64 host, pulls the x64 core, and packages mac-x64.
|
||||
// Artifacts land in release/<target>/miniSCP-Build<N>-<target>.<ext>.
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = path.join(import.meta.dir, "..");
|
||||
const TARGETS = ["mac-arm64", "mac-x64", "win-x64"] as const;
|
||||
type Target = (typeof TARGETS)[number];
|
||||
|
||||
function hostTarget(): Target {
|
||||
if (process.platform === "win32") return "win-x64";
|
||||
return process.arch === "arm64" ? "mac-arm64" : "mac-x64";
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(`\n[release] ERROR: ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(cmd: string[], opts: { cwd?: string } = {}): void {
|
||||
console.log(`[release] $ ${cmd.join(" ")}`);
|
||||
const proc = Bun.spawnSync(cmd, {
|
||||
cwd: opts.cwd ?? ROOT,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
if (!proc.success) fail(`command failed: ${cmd.join(" ")}`);
|
||||
}
|
||||
|
||||
// ---- target selection + host guards ----
|
||||
|
||||
const argIndex = process.argv.indexOf("--target");
|
||||
const requested = (argIndex >= 0 ? process.argv[argIndex + 1] : hostTarget()) as Target;
|
||||
if (!TARGETS.includes(requested)) fail(`unknown target "${requested}" (${TARGETS.join(", ")})`);
|
||||
|
||||
if (requested === "win-x64" && process.platform !== "win32") {
|
||||
fail("win-x64 must be built on a Windows machine/runner — electrobun cannot cross-compile.");
|
||||
}
|
||||
if (requested.startsWith("mac") && process.platform !== "darwin") {
|
||||
fail(`${requested} must be built on macOS.`);
|
||||
}
|
||||
const viaRosetta = requested === "mac-x64" && process.arch === "arm64";
|
||||
|
||||
// ---- prepare: version, icons, UI bundle ----
|
||||
|
||||
run(["bun", "scripts/gen-version.ts"]);
|
||||
run(["bun", "scripts/gen-icons.ts"]);
|
||||
run(["bun", "run", "--cwd", "ui", "build"]);
|
||||
|
||||
const { BUILD } = await import("../src/shared/version.gen");
|
||||
const { BRANDING } = await import("../src/shared/branding");
|
||||
const buildName = `Build${BUILD.number}`;
|
||||
|
||||
// ---- electrobun build (native, or x64 CLI under Rosetta) ----
|
||||
|
||||
async function x64Cli(): Promise<string> {
|
||||
const pkg = await Bun.file(path.join(ROOT, "node_modules", "electrobun", "package.json")).json();
|
||||
const dir = path.join(os.homedir(), ".cache", "miniscp", `electrobun-cli-x64-${pkg.version}`);
|
||||
const bin = path.join(dir, "electrobun");
|
||||
if (existsSync(bin)) return bin;
|
||||
console.log(`[release] downloading electrobun x64 CLI ${pkg.version} (one-time)…`);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
const url = `https://github.com/blackboardsh/electrobun/releases/download/v${pkg.version}/electrobun-cli-darwin-x64.tar.gz`;
|
||||
const tarball = path.join(dir, "cli.tar.gz");
|
||||
run(["curl", "-fsSL", "-o", tarball, url]);
|
||||
run(["tar", "-xzf", tarball, "-C", dir]);
|
||||
if (!existsSync(bin)) fail(`x64 CLI tarball did not contain "electrobun" (${dir})`);
|
||||
run(["chmod", "+x", bin]);
|
||||
return bin;
|
||||
}
|
||||
|
||||
if (viaRosetta) {
|
||||
// x64 Mach-O runs under Rosetta transparently; it sees process.arch === x64
|
||||
const rosetta = Bun.spawnSync(["arch", "-x86_64", "true"]);
|
||||
if (!rosetta.success) {
|
||||
fail("Rosetta 2 is not installed — run: softwareupdate --install-rosetta --agree-to-license");
|
||||
}
|
||||
run([await x64Cli(), "build", "--env=stable"]);
|
||||
} else {
|
||||
run(["bunx", "electrobun", "build", "--env=stable"]);
|
||||
}
|
||||
|
||||
// ---- collect artifacts ----
|
||||
|
||||
const [osName, archName] = requested === "win-x64" ? ["win", "x64"] : ["macos", requested.slice(4)];
|
||||
const prefix = `stable-${osName}-${archName}-`;
|
||||
const artifactsDir = path.join(ROOT, "artifacts");
|
||||
const outDir = path.join(ROOT, "release", requested);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const produced = readdirSync(artifactsDir).filter(
|
||||
(f) => f.startsWith(prefix) && f.includes(BRANDING.appName),
|
||||
);
|
||||
if (!produced.length) fail(`no ${prefix}* artifacts found in artifacts/ — did the build run?`);
|
||||
|
||||
const out: string[] = [];
|
||||
for (const file of produced) {
|
||||
const ext = file.endsWith(".dmg") ? ".dmg" : file.endsWith("-Setup.zip") ? "-Setup.zip" : null;
|
||||
if (!ext) continue; // skip the raw .app.tar.zst + update.json
|
||||
const name = `${BRANDING.appName}-${buildName}-${requested}${ext}`;
|
||||
cpSync(path.join(artifactsDir, file), path.join(outDir, name));
|
||||
out.push(name);
|
||||
}
|
||||
|
||||
// Windows: also produce a portable zip from the assembled app folder (the
|
||||
// Setup.exe is an installer; portable = unzip anywhere and run). Best-effort:
|
||||
// the folder layout is confirmed by the first real Windows build.
|
||||
if (requested === "win-x64") {
|
||||
const appDir = path.join(ROOT, "build", `stable-${osName}-${archName}`, BRANDING.appName);
|
||||
if (existsSync(appDir)) {
|
||||
const portable = path.join(outDir, `${BRANDING.appName}-${buildName}-win-x64-portable.zip`);
|
||||
run(["powershell", "-Command", `Compress-Archive -Force -Path '${appDir}\\*' -DestinationPath '${portable}'`]);
|
||||
out.push(path.basename(portable));
|
||||
} else {
|
||||
console.warn(`[release] WARN: portable skipped — app folder not found at ${appDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!out.length) fail("artifacts were found but none matched a known package extension.");
|
||||
console.log(`\n[release] ${BRANDING.appName} ${BRANDING.buildLabel} (${BUILD.commit}) — ${requested}`);
|
||||
for (const name of out) console.log(`[release] release/${requested}/${name}`);
|
||||
+19
-4
@@ -11,6 +11,16 @@ const COLUMNS: { key: ColumnKey; label: string }[] = [
|
||||
{ key: "group", label: "Group" },
|
||||
];
|
||||
|
||||
// PORTABILITY: shell-integration wording per OS
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const REVEAL_LABEL =
|
||||
process.platform === "darwin"
|
||||
? "Reveal in Finder"
|
||||
: IS_WIN
|
||||
? "Show in Explorer"
|
||||
: "Show in File Manager";
|
||||
const TRASH_LABEL = IS_WIN ? "Move to Recycle Bin" : "Move to Trash";
|
||||
|
||||
export interface RowMenuContext {
|
||||
path: string;
|
||||
name: string;
|
||||
@@ -84,7 +94,7 @@ export class PanelContextMenu {
|
||||
items.push({ label: "Refresh", action: "ctx-refresh" });
|
||||
if (ctx.isLocal) {
|
||||
items.push({ type: "separator" });
|
||||
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
|
||||
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
@@ -99,18 +109,23 @@ export class PanelContextMenu {
|
||||
}
|
||||
items.push({ label: "Copy to Other Panel", action: "ctx-copy" });
|
||||
items.push({ label: "Rename", action: "ctx-rename" });
|
||||
// chmod on Windows local files only flips the read-only bit — hide the lie
|
||||
if (!(ctx.isLocal && IS_WIN)) {
|
||||
items.push({ label: "Permissions", action: "ctx-chmod" });
|
||||
}
|
||||
items.push({ type: "separator" });
|
||||
items.push({ label: "Copy Path", action: "ctx-copy-path" });
|
||||
if (ctx.isLocal) {
|
||||
items.push({ label: "Reveal in Finder", action: "ctx-reveal" });
|
||||
items.push({ label: REVEAL_LABEL, action: "ctx-reveal" });
|
||||
}
|
||||
items.push({ type: "separator" });
|
||||
if (ctx.isLocal) {
|
||||
items.push({
|
||||
label: "Move to Trash",
|
||||
label: TRASH_LABEL,
|
||||
action: "ctx-trash",
|
||||
tooltip: "Finder's Restore is not available for items trashed from here",
|
||||
...(process.platform === "darwin"
|
||||
? { tooltip: "Finder's Restore is not available for items trashed from here" }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
items.push({ label: "Delete", action: "ctx-delete" });
|
||||
|
||||
+17
-6
@@ -48,14 +48,25 @@ export function writeFileText(
|
||||
* Launch a file in an external editor.
|
||||
*
|
||||
* PORTABILITY: this is the only platform-specific piece of the edit feature.
|
||||
* macOS delivers the file to any GUI app uniformly via `open [-a app]`.
|
||||
* A Windows port should use `cmd /c start "" [app] file` and a Linux port
|
||||
* `xdg-open file` (or `app file` when an editor is configured) — swap the
|
||||
* spawn below and the .app picker in pickEditorApp; everything else
|
||||
* (temp download, directory watch, auto-upload) is platform-neutral.
|
||||
* macOS delivers the file to any GUI app via `open [-a app]`; Windows via
|
||||
* `cmd /c start` (empty "" arg = window title slot); Linux via the app
|
||||
* directly or `xdg-open`. Everything else (temp download, directory watch,
|
||||
* auto-upload) is platform-neutral. Windows path pending first smoke test.
|
||||
*/
|
||||
function launchEditor(filePath: string, editorApp: string): void {
|
||||
const args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
|
||||
let args: string[];
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
args = editorApp ? ["open", "-a", editorApp, filePath] : ["open", filePath];
|
||||
break;
|
||||
case "win32":
|
||||
args = editorApp
|
||||
? ["cmd", "/c", "start", "", editorApp, filePath]
|
||||
: ["cmd", "/c", "start", "", filePath];
|
||||
break;
|
||||
default:
|
||||
args = editorApp ? [editorApp, filePath] : ["xdg-open", filePath];
|
||||
}
|
||||
Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,22 @@ function resolveId(kind: "user" | "group", id: number): string | undefined {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive roots for the Windows panel source picker ("C:\", "D:\", …).
|
||||
* PORTABILITY: win32-only concept; empty elsewhere ("/" is always reachable).
|
||||
*/
|
||||
export function listLocalDrives(): string[] {
|
||||
if (process.platform !== "win32") return [];
|
||||
try {
|
||||
const proc = Bun.spawnSync(["fsutil", "fsinfo", "drives"], { stdout: "pipe", stderr: "ignore" });
|
||||
if (!proc.success) return [];
|
||||
// output: "Drives: C:\ D:\ ..."
|
||||
return [...proc.stdout.toString().matchAll(/[A-Za-z]:\\/g)].map((m) => m[0]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function toEntry(name: string, full: string, st: Stats): FileEntry {
|
||||
return {
|
||||
name,
|
||||
|
||||
@@ -51,6 +51,7 @@ const events = {
|
||||
openEditor: (_ctx: EditorOpenContext) => {},
|
||||
settingsChanged: (_settings: AppSettings) => {},
|
||||
openConnectionManager: () => {},
|
||||
showAbout: () => {},
|
||||
};
|
||||
const rpc = createAppRPC({ registry, engine, store, settings, edits, contextMenu, events });
|
||||
const url = await getMainViewUrl();
|
||||
@@ -104,6 +105,20 @@ events.settingsChanged = (s) => {
|
||||
manager.broadcastSettings(s);
|
||||
};
|
||||
events.openConnectionManager = () => manager.open();
|
||||
|
||||
function showAbout(): void {
|
||||
const { build } = BRANDING;
|
||||
void Utils.showMessageBox({
|
||||
type: "info",
|
||||
title: BRANDING.appName,
|
||||
message: BRANDING.appName,
|
||||
detail: `${BRANDING.buildLabel} (${build.commit}) · ${build.date}\n\nElectrobun ${build.electrobun} · Bun ${Bun.version}`,
|
||||
buttons: ["OK"],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
});
|
||||
}
|
||||
events.showAbout = showAbout;
|
||||
// clean editors close with the main window; dirty ones get to ask first,
|
||||
// and the modal manager has nothing to guard
|
||||
win.on("close", () => {
|
||||
@@ -204,6 +219,10 @@ ApplicationMenu.on("application-menu-clicked", (event) => {
|
||||
manager.open();
|
||||
return;
|
||||
}
|
||||
if (action === "about") {
|
||||
showAbout();
|
||||
return;
|
||||
}
|
||||
// the manager is modal to the main window — don't drive main behind it
|
||||
if (manager.isOpen()) return;
|
||||
rpc.send.menuAction({ action });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ApplicationMenu, type ApplicationMenuItemConfig } from "electrobun/bun";
|
||||
import { BRANDING } from "../shared/branding";
|
||||
import type { SavedConnection } from "../shared/types";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
|
||||
@@ -32,6 +33,8 @@ export async function applyApplicationMenu(store: ConnectionStore): Promise<void
|
||||
ApplicationMenu.setApplicationMenu([
|
||||
{
|
||||
submenu: [
|
||||
{ label: `About ${BRANDING.appName}`, action: "about" },
|
||||
{ type: "separator" },
|
||||
{ role: "hide" },
|
||||
{ role: "hideOthers" },
|
||||
{ role: "showAll" },
|
||||
|
||||
+22
-3
@@ -3,6 +3,7 @@ import type { AppRPC, EditorOpenContext } from "../shared/rpc";
|
||||
import { effectiveConcurrency, type AppSettings, type Result } from "../shared/types";
|
||||
import type { PanelContextMenu } from "./context-menu";
|
||||
import { readFileText, writeFileText, type EditManager } from "./edits";
|
||||
import { listLocalDrives } from "./fs/local";
|
||||
import type { SessionRegistry } from "./fs/registry";
|
||||
import { SftpProvider } from "./fs/sftp";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
@@ -30,6 +31,7 @@ export interface AppDeps {
|
||||
openEditor: (ctx: EditorOpenContext) => void;
|
||||
settingsChanged: (settings: AppSettings) => void;
|
||||
openConnectionManager: () => void;
|
||||
showAbout: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,8 +102,9 @@ function connectionStoreHandlers(store: ConnectionStore, onChanged: () => void)
|
||||
const pickKeyFileHandler = () =>
|
||||
wrap(async () => {
|
||||
const { homedir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const picked = await Utils.openFileDialog({
|
||||
startingFolder: `${homedir()}/.ssh`,
|
||||
startingFolder: join(homedir(), ".ssh"),
|
||||
allowedFileTypes: "*",
|
||||
canChooseFiles: true,
|
||||
canChooseDirectory: false,
|
||||
@@ -240,9 +243,17 @@ export function createAppRPC({
|
||||
|
||||
pickEditorApp: () =>
|
||||
wrap(async () => {
|
||||
// PORTABILITY: apps live in different places per OS
|
||||
const { homedir } = await import("node:os");
|
||||
const [startingFolder, allowedFileTypes] =
|
||||
process.platform === "darwin"
|
||||
? ["/Applications", "app"]
|
||||
: process.platform === "win32"
|
||||
? [process.env.ProgramFiles ?? "C:\\Program Files", "exe"]
|
||||
: [homedir(), "*"];
|
||||
const picked = await Utils.openFileDialog({
|
||||
startingFolder: "/Applications",
|
||||
allowedFileTypes: "app",
|
||||
startingFolder,
|
||||
allowedFileTypes,
|
||||
canChooseFiles: true,
|
||||
canChooseDirectory: false,
|
||||
allowsMultipleSelection: false,
|
||||
@@ -294,6 +305,14 @@ export function createAppRPC({
|
||||
events.openConnectionManager();
|
||||
return {};
|
||||
}),
|
||||
|
||||
showAbout: () =>
|
||||
wrap(async () => {
|
||||
events.showAbout();
|
||||
return {};
|
||||
}),
|
||||
|
||||
listDrives: () => wrap(async () => ({ drives: listLocalDrives() })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
|
||||
import { appDataDir } from "./paths";
|
||||
@@ -68,5 +69,23 @@ export class ConnectionStore {
|
||||
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
|
||||
await fs.rename(tmp, this.file);
|
||||
await fs.chmod(this.file, 0o600);
|
||||
this.restrictOnWindows();
|
||||
}
|
||||
|
||||
/**
|
||||
* PORTABILITY: 0o600 only toggles the read-only bit on Windows, so the
|
||||
* plaintext passwords in this file would be world-readable there. Replace
|
||||
* inheritance with a current-user-only ACL, best effort.
|
||||
*/
|
||||
private restrictOnWindows(): void {
|
||||
if (process.platform !== "win32") return;
|
||||
try {
|
||||
Bun.spawn(
|
||||
["icacls", this.file, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`],
|
||||
{ stdout: "ignore", stderr: "ignore" },
|
||||
);
|
||||
} catch {
|
||||
// ACL tooling unavailable — file still works, just unprotected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+13
-8
@@ -1,21 +1,26 @@
|
||||
import { Tray, type BrowserWindow } from "electrobun/bun";
|
||||
|
||||
/**
|
||||
* Menu-bar icon: the app glyph as a macOS template image (adapts to
|
||||
* light/dark). Clicking it brings the app window to the front. No menu is ever
|
||||
* set, so clicks arrive directly; no setImage swaps, so the template
|
||||
* treatment from construction always applies.
|
||||
* Menu-bar / notification-area icon. Clicking it brings the app window to the
|
||||
* front. No menu is ever set, so clicks arrive directly; no setImage swaps, so
|
||||
* the image treatment from construction always applies.
|
||||
*
|
||||
* PORTABILITY: macOS uses a monochrome template image (adapts to light/dark
|
||||
* menu bars); Windows ignores `template` and wants a small colored icon.
|
||||
*/
|
||||
export class AppTray {
|
||||
private tray: Tray;
|
||||
|
||||
constructor(win: BrowserWindow) {
|
||||
const isMac = process.platform === "darwin";
|
||||
this.tray = new Tray({
|
||||
title: "",
|
||||
image: "views://assets/tray/tray-44-template.png",
|
||||
template: true,
|
||||
width: 22,
|
||||
height: 22,
|
||||
image: isMac
|
||||
? "views://assets/tray/tray-44-template.png"
|
||||
: "views://assets/tray/tray-32-color.png",
|
||||
template: isMac,
|
||||
width: isMac ? 22 : 16,
|
||||
height: isMac ? 22 : 16,
|
||||
});
|
||||
this.tray.on("tray-clicked", () => win.activate());
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// The single place to rebrand the app. Everything (window title, bundle name,
|
||||
// app-data directory, UI header, icon) reads from here.
|
||||
import { BUILD } from "./version.gen";
|
||||
|
||||
export const BRANDING = {
|
||||
appName: "miniSCP",
|
||||
identifier: "es.teknes.miniscp",
|
||||
// Previous app names: their app-data folders are migrated on boot
|
||||
// Previous app names whose app-data folders migrate on boot after a rename
|
||||
// (see migrateLegacyDataDir in src/bun/store/paths.ts).
|
||||
legacyNames: ["BunSCP"],
|
||||
version: "0.1.0",
|
||||
legacyNames: [] as string[],
|
||||
// Sublime-style versioning: n = commit count (scripts/gen-version.ts).
|
||||
// `version` stays semver-shaped for electrobun's config/updater.
|
||||
version: `0.1.${BUILD.number}`,
|
||||
buildLabel: `Build ${BUILD.number}`,
|
||||
build: BUILD,
|
||||
// macOS build converts this .iconset folder into AppIcon.icns.
|
||||
// Regenerate from assets/icon.png with scripts/gen-iconset.sh.
|
||||
icon: "assets/icon.iconset",
|
||||
|
||||
@@ -137,6 +137,10 @@ export type AppRPC = {
|
||||
moveToTrash: { params: { path: string }; response: Result };
|
||||
/** Native column-picker menu (right-click on the list headers). */
|
||||
showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result };
|
||||
/** Native About box (name, Build n, commit, engine versions). */
|
||||
showAbout: { params: Record<string, never>; response: Result };
|
||||
/** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */
|
||||
listDrives: { params: Record<string, never>; response: Result<{ drives: string[] }> };
|
||||
// editor windows
|
||||
/** Main window only: open (or focus) a native editor window. */
|
||||
openEditor: { params: EditorOpenContext; response: Result };
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="flex-1"></div>
|
||||
<button
|
||||
(click)="toggleWrap()"
|
||||
title="Wrap long lines — hides line numbers (⌥Z)"
|
||||
[title]="'Wrap long lines — hides line numbers (' + altLabel + 'Z)'"
|
||||
class="rounded-md px-2 py-1 text-[11px] electrobun-webkit-app-region-no-drag hover:bg-accent"
|
||||
[class.bg-accent]="wrap()"
|
||||
[class.text-foreground]="wrap()"
|
||||
@@ -38,7 +38,7 @@
|
||||
</button>
|
||||
<button
|
||||
(click)="openFind()"
|
||||
title="Find (⌘F)"
|
||||
[title]="'Find (' + modLabel + 'F)'"
|
||||
class="grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaSearch" size="0.85rem" />
|
||||
@@ -142,7 +142,7 @@
|
||||
</div>
|
||||
|
||||
<footer class="flex shrink-0 items-center gap-2 border-t border-border px-4 py-2">
|
||||
<span class="text-[11px] text-muted-foreground">⌘S save · ⌘F find · ⌘W close</span>
|
||||
<span class="text-[11px] text-muted-foreground">{{ modLabel }}S save · {{ modLabel }}F find · {{ modLabel }}W close</span>
|
||||
<span class="text-[11px] text-muted-foreground">·</span>
|
||||
<span class="text-[11px] tabular-nums text-muted-foreground">
|
||||
{{ lineCount() }} {{ lineCount() === 1 ? 'line' : 'lines' }}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME } from '../platform';
|
||||
import { ALT_LABEL, blockNativeContextMenu, EDITOR_CONTEXT, IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { ToastService } from '../toast.service';
|
||||
@@ -40,6 +40,8 @@ const DRAFT_DEBOUNCE_MS = 400;
|
||||
export class EditorWindow {
|
||||
readonly ctx = EDITOR_CONTEXT;
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly altLabel = ALT_LABEL;
|
||||
|
||||
readonly loading = signal(true);
|
||||
readonly loadError = signal<string | null>(null);
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { FileEntry } from '@shared/types';
|
||||
import { DialogsService } from './dialogs.service';
|
||||
import { joinPath } from './format';
|
||||
import { type PanelState, type Side } from './panels.service';
|
||||
import { IS_WINDOWS } from './platform';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { ToastService } from './toast.service';
|
||||
@@ -157,6 +158,8 @@ export class FileActionsService {
|
||||
}
|
||||
|
||||
chmod(panel: PanelState): void {
|
||||
// Windows local chmod only flips the read-only bit — don't pretend
|
||||
if (IS_WINDOWS && panel.sessionId() === 'local') return;
|
||||
const targets = this.targets(panel);
|
||||
if (!targets.length) return;
|
||||
this.dialogs.chmod.set({
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
>
|
||||
<ng-icon name="mynaFilePlus" size="var(--action-icon)" />
|
||||
</button>
|
||||
@if (chmodAvailable()) {
|
||||
<button
|
||||
(click)="run('chmod')"
|
||||
title="Permissions"
|
||||
@@ -70,6 +71,7 @@
|
||||
>
|
||||
<ng-icon name="mynaLock" size="var(--action-icon)" />
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
(click)="run('delete')"
|
||||
title="Delete (F8 / Del)"
|
||||
@@ -95,6 +97,17 @@
|
||||
}
|
||||
</button>
|
||||
|
||||
<!-- Windows drive roots (empty list elsewhere) -->
|
||||
@for (drive of drives(); track drive) {
|
||||
<button
|
||||
(click)="pickDrive(drive)"
|
||||
class="flex w-full items-center gap-2 py-1.5 pr-3 pl-6 text-left text-(length:--path-font) hover:bg-accent"
|
||||
>
|
||||
<ng-icon name="mynaHardDrive" size="var(--action-icon)" class="shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 font-mono">{{ drive }}</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (connections.grouped().loose.length || connections.grouped().stacks.length) {
|
||||
<div class="my-1 border-t border-border"></div>
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { FileActionsService } from '../file-actions.service';
|
||||
import { fileGlyph, fileIconClass, fileKind } from '../file-icons';
|
||||
import { formatDate, formatMode, formatSize } from '../format';
|
||||
import { PanelsService, type Side, type SortKey } from '../panels.service';
|
||||
import { IS_WINDOWS } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
import { TransfersService } from '../transfers.service';
|
||||
@@ -56,6 +57,10 @@ export class FilePanel {
|
||||
|
||||
readonly pathDraft = signal('');
|
||||
readonly menuOpen = signal(false);
|
||||
/** Windows drive roots for the source picker; always empty elsewhere. */
|
||||
readonly drives = signal<string[]>([]);
|
||||
/** chmod on Windows local files only flips the read-only bit — hide it. */
|
||||
readonly chmodAvailable = computed(() => !(IS_WINDOWS && !this.isRemote()));
|
||||
|
||||
private readonly listEl = viewChild.required<ElementRef<HTMLDivElement>>('list');
|
||||
private lastClickedIndex = 0;
|
||||
@@ -79,7 +84,18 @@ export class FilePanel {
|
||||
void this.rpc.request.showColumnsMenu({ visible: this.settings.columns() });
|
||||
}
|
||||
|
||||
pickDrive(drive: string): void {
|
||||
this.menuOpen.set(false);
|
||||
this.activate();
|
||||
void this.panel().load(drive);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (IS_WINDOWS && this.rpc.available) {
|
||||
void this.rpc.request.listDrives({}).then((res) => {
|
||||
if (res.ok) this.drives.set(res.drives);
|
||||
});
|
||||
}
|
||||
// keep the editable path bar in sync with the actual cwd
|
||||
effect(() => this.pathDraft.set(this.panel().cwd()));
|
||||
// focus follows the active side (Tab switches panels, dialogs refocus on close)
|
||||
|
||||
+11
-2
@@ -42,9 +42,18 @@ export function formatEta(seconds: number): string {
|
||||
return h ? `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}` : mmss;
|
||||
}
|
||||
|
||||
/** POSIX-style join for UI-composed paths (both panels use "/" on macOS + remote). */
|
||||
/**
|
||||
* Separator-aware join for UI-composed paths: remote and POSIX-local dirs use
|
||||
* "/", a Windows-local dir ("C:\Users\x") keeps "\" so paths stay consistent.
|
||||
*/
|
||||
export function joinPath(dir: string, name: string): string {
|
||||
return dir.endsWith('/') ? dir + name : `${dir}/${name}`;
|
||||
const sep = dir.includes('\\') && !dir.includes('/') ? '\\' : '/';
|
||||
return dir.endsWith(sep) ? dir + name : `${dir}${sep}${name}`;
|
||||
}
|
||||
|
||||
/** "/" (POSIX) or a drive root like "C:\" — nothing above to navigate to. */
|
||||
export function isRootPath(p: string): boolean {
|
||||
return p === '/' || /^[A-Za-z]:[\\/]?$/.test(p);
|
||||
}
|
||||
|
||||
/** 0o755 -> "rwxr-xr-x" */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable, computed, inject, signal, type WritableSignal } from '@angu
|
||||
import type { ColumnKey, ConnectionConfig, FileEntry, SavedConnection } from '@shared/types';
|
||||
import { fileKind } from './file-icons';
|
||||
import { FocusService } from './focus.service';
|
||||
import { formatMode } from './format';
|
||||
import { formatMode, isRootPath, joinPath } from './format';
|
||||
import { RpcService } from './rpc.service';
|
||||
import { ToastService } from './toast.service';
|
||||
|
||||
@@ -73,8 +73,8 @@ export class PanelState {
|
||||
return cmp * dir;
|
||||
});
|
||||
const cwd = this.cwd();
|
||||
if (cwd && cwd !== '/') {
|
||||
list.unshift({ name: '..', path: `${cwd}/..`, type: 'dir', size: 0, mtime: 0, mode: 0 });
|
||||
if (cwd && !isRootPath(cwd)) {
|
||||
list.unshift({ name: '..', path: joinPath(cwd, '..'), type: 'dir', size: 0, mtime: 0, mode: 0 });
|
||||
}
|
||||
return list;
|
||||
});
|
||||
@@ -144,7 +144,7 @@ export class PanelState {
|
||||
|
||||
up(): void {
|
||||
if (!this.cwd()) return;
|
||||
void this.load(`${this.cwd()}/..`);
|
||||
void this.load(joinPath(this.cwd(), '..'));
|
||||
}
|
||||
|
||||
/** Update the filter and park the cursor on the first real match. */
|
||||
|
||||
@@ -18,6 +18,12 @@ export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?
|
||||
/** True when the window uses macOS hiddenInset chrome (header fused with titlebar). */
|
||||
export const IS_MAC_CHROME = PLATFORM === 'darwin';
|
||||
|
||||
export const IS_WINDOWS = PLATFORM === 'win32';
|
||||
|
||||
/** Shortcut-hint labels: "⌘W" on macOS, "Ctrl+W" elsewhere. */
|
||||
export const MOD_LABEL = IS_MAC_CHROME ? '⌘' : 'Ctrl+';
|
||||
export const ALT_LABEL = IS_MAC_CHROME ? '⌥' : 'Alt+';
|
||||
|
||||
export const APP_MODE: 'main' | 'editor' | 'manager' =
|
||||
typeof window !== 'undefined' ? (window.__APP_MODE ?? 'main') : 'main';
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, ElementRef, effect, inject, signal, viewChild } from '@angular/core';
|
||||
import { NgIcon } from '@ng-icons/core';
|
||||
import { BRANDING } from '@shared/branding';
|
||||
import type { AccentTone } from '@shared/types';
|
||||
import { PanelsService } from '../panels.service';
|
||||
import { IS_MAC_CHROME } from '../platform';
|
||||
import { IS_MAC_CHROME, MOD_LABEL } from '../platform';
|
||||
import { RpcService } from '../rpc.service';
|
||||
import { SettingsService } from '../settings.service';
|
||||
|
||||
@@ -77,7 +78,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
@if (panels.workspaces().length > 1) {
|
||||
<button
|
||||
(click)="close($event, ws.id)"
|
||||
title="Close tab (⌘W)"
|
||||
[title]="'Close tab (' + modLabel + 'W)'"
|
||||
class="mr-1 grid size-4 shrink-0 place-items-center rounded text-muted-foreground opacity-0 group-hover:opacity-100 hover:bg-border hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaX" size="0.7rem" />
|
||||
@@ -93,7 +94,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</div>
|
||||
<button
|
||||
(click)="panels.newWorkspace()"
|
||||
title="New tab (⌘T)"
|
||||
[title]="'New tab (' + modLabel + 'T)'"
|
||||
class="ml-0.5 grid size-6 place-items-center rounded-md text-muted-foreground electrobun-webkit-app-region-no-drag hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ng-icon name="mynaPlus" size=".8rem" />
|
||||
@@ -339,6 +340,14 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const;
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- about footer -->
|
||||
<button
|
||||
(click)="showAbout()"
|
||||
class="mt-3 w-full border-t border-border pt-2 text-center text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{{ appName }} · {{ buildLabel }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -350,6 +359,9 @@ export class TabBar {
|
||||
private readonly rpc = inject(RpcService);
|
||||
readonly settingsOpen = signal(false);
|
||||
readonly isMacChrome = IS_MAC_CHROME;
|
||||
readonly modLabel = MOD_LABEL;
|
||||
readonly appName = BRANDING.appName;
|
||||
readonly buildLabel = BRANDING.buildLabel;
|
||||
readonly themes = THEMES;
|
||||
readonly darkTones = DARK_TONES;
|
||||
readonly accents = ACCENTS;
|
||||
@@ -392,6 +404,10 @@ export class TabBar {
|
||||
void this.rpc.request.openConnectionManager({});
|
||||
}
|
||||
|
||||
showAbout(): void {
|
||||
void this.rpc.request.showAbout({});
|
||||
}
|
||||
|
||||
async pickEditorApp(): Promise<void> {
|
||||
const res = await this.rpc.request.pickEditorApp({});
|
||||
if (res.ok && res.path) this.settings.setEditorApp(res.path);
|
||||
|
||||
Reference in New Issue
Block a user