From c7271fb7cfd0307f549e3a405eb370a9f1ff6a41 Mon Sep 17 00:00:00 2001 From: Jokin Date: Tue, 14 Jul 2026 00:02:42 +0200 Subject: [PATCH] Windows compatibility pass (beta target) External editor via cmd/start + exe picker; Permissions hidden for Windows-local (chmod only flips read-only there); Explorer/Recycle Bin wording; Ctrl vs Cmd shortcut labels; separator-aware path joins + drive-root detection; drives list in the panel source picker; icon.ico + colored tray icon; icacls ACL on connections.json; README Windows (beta) section with upstream known issues and a smoke-test checklist. Runtime paths pending first boot on a real Windows machine. Co-Authored-By: Claude Fable 5 --- README.md | 34 +++++++++++++++----- assets/icon.ico | Bin 0 -> 285478 bytes assets/tray/tray-32-color.png | Bin 0 -> 1226 bytes bun.lock | 15 +++++++-- electrobun.config.ts | 3 +- package.json | 1 + scripts/gen-icons.ts | 13 ++++++-- src/bun/context-menu.ts | 25 +++++++++++--- src/bun/edits.ts | 23 +++++++++---- src/bun/fs/local.ts | 16 +++++++++ src/bun/rpc-handlers.ts | 18 +++++++++-- src/bun/store/connections.ts | 19 +++++++++++ src/bun/tray.ts | 21 +++++++----- src/shared/rpc.ts | 2 ++ ui/src/app/editor-window/editor-window.html | 6 ++-- ui/src/app/editor-window/editor-window.ts | 4 ++- ui/src/app/file-actions.service.ts | 3 ++ ui/src/app/file-panel/file-panel.html | 27 ++++++++++++---- ui/src/app/file-panel/file-panel.ts | 16 +++++++++ ui/src/app/format.ts | 13 ++++++-- ui/src/app/panels.service.ts | 8 ++--- ui/src/app/platform.ts | 6 ++++ ui/src/app/tab-bar/tab-bar.ts | 7 ++-- 23 files changed, 223 insertions(+), 57 deletions(-) create mode 100644 assets/icon.ico create mode 100644 assets/tray/tray-32-color.png diff --git a/README.md b/README.md index 926a8ec..0e81e79 100644 --- a/README.md +++ b/README.md @@ -173,15 +173,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 diff --git a/assets/icon.ico b/assets/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..58fbb4b482b52a20c9d0f405c6d177e83c40cb9f GIT binary patch literal 285478 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 literal 0 HcmV?d00001 diff --git a/assets/tray/tray-32-color.png b/assets/tray/tray-32-color.png new file mode 100644 index 0000000000000000000000000000000000000000..9d8723624a8010308607422c3c0d921dc9fd33e0 GIT binary patch literal 1226 zcmV;*1U37KP))&(M zTHj*NblM1A=D4!JxC^X%b0p@uYLW6HC7$n-@A~9!n(o2*qq2&VEuc3Njf>wjNui*01V@S>^Wzi#j^m#nv3D#%3E0;|_z0nf(*VKc;;&*E7Z3`d_syZ4j}R}9Ch`e9qS<^}|SVes#~eKi#q zkCngj(l8wx@bC=Jb)8Xo28e+=v)~!d{`>YvWRMt+m8-D;-vEHf5QZ7QY11{RAZq{7 zphj8}xPmc*#49D{?CTt7|ZXg5B`f&494WBuyfVOQJ zp*Gv0q9UTyFn{5o<{aUi+kS})R}9CBl_$(wfMFSMK5yANl{UPVq?>P@| zz504^D;pbz=$}J;BLEnP7X0aO)}CK$g)4^R*%es8!vd$l`2%;|v`E%_Uh`xTU4P?b zDlCjpUvHW|+MdXQu+Il)-`=rMxMDb#Eyn^1p8)^@1@OQ_27bq#lc~NTN>d67&_(1r z4n4PYfclUAkD3579-Q;f`<@Z57>=dOj^ix|7swB1J^n6f z!Pfg|_Jd!cHUR3(GJfB|mxU{aqwc9e8^1Pj0|2oA(>YXl#*o{}h@w%CeBY&H>ZJZ( zuUd0EeY^7r&6zuc9-7rGKrQ3>hJNd65Uv=Gx&?!_$6rJmVql@dGX{jiwBUItFHlu| z6V0idfk{LaGd~ZWvrxmC>)3bxu|c?EI2O)7W&?sBLjwyHp3MNAS@1lR;e733q*4w& zI@>IO8qQo7`h$PHC0sEa3u^jpPepygdetM9u=S*ohJ^m1sm$6*p;N z0Gh??%*x|+o$*@6=)>*O&lCB4>dxDjz-&Q=U_&RJsJsd2d8#mhq?)uZ0gB ol)#r&W9GGI#<&Yys<^ViDMDcHdjnP)&j0`b07*qoM6N<$g0nbEVE_OC literal 0 HcmV?d00001 diff --git a/bun.lock b/bun.lock index c87fc44..98aa74f 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/electrobun.config.ts b/electrobun.config.ts index 18930be..40798eb 100644 --- a/electrobun.config.ts +++ b/electrobun.config.ts @@ -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; diff --git a/package.json b/package.json index 0ed5567..496656c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,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" }, "trustedDependencies": [ diff --git a/scripts/gen-icons.ts b/scripts/gen-icons.ts index 273bb21..ee7df9b 100644 --- a/scripts/gen-icons.ts +++ b/scripts/gen-icons.ts @@ -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"); diff --git a/src/bun/context-menu.ts b/src/bun/context-menu.ts index 647985e..8e0f05b 100644 --- a/src/bun/context-menu.ts +++ b/src/bun/context-menu.ts @@ -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" }); - items.push({ label: "Permissions", action: "ctx-chmod" }); + // 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" }); diff --git a/src/bun/edits.ts b/src/bun/edits.ts index d0592f8..dec7186 100644 --- a/src/bun/edits.ts +++ b/src/bun/edits.ts @@ -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" }); } diff --git a/src/bun/fs/local.ts b/src/bun/fs/local.ts index e0b036d..9d4aeba 100644 --- a/src/bun/fs/local.ts +++ b/src/bun/fs/local.ts @@ -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, diff --git a/src/bun/rpc-handlers.ts b/src/bun/rpc-handlers.ts index d8f177a..ae3ff7d 100644 --- a/src/bun/rpc-handlers.ts +++ b/src/bun/rpc-handlers.ts @@ -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"; @@ -101,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, @@ -241,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, @@ -301,6 +311,8 @@ export function createAppRPC({ events.showAbout(); return {}; }), + + listDrives: () => wrap(async () => ({ drives: listLocalDrives() })), }, }, }); diff --git a/src/bun/store/connections.ts b/src/bun/store/connections.ts index 88ee3b0..5c71572 100644 --- a/src/bun/store/connections.ts +++ b/src/bun/store/connections.ts @@ -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 + } } } diff --git a/src/bun/tray.ts b/src/bun/tray.ts index 167d2a0..a906839 100644 --- a/src/bun/tray.ts +++ b/src/bun/tray.ts @@ -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()); } diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index dbf65b1..6909dd1 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -139,6 +139,8 @@ export type AppRPC = { showColumnsMenu: { params: { visible: ColumnKey[] }; response: Result }; /** Native About box (name, Build n, commit, engine versions). */ showAbout: { params: Record; response: Result }; + /** Windows drive roots for the local panel ("C:\", …); empty elsewhere. */ + listDrives: { params: Record; response: Result<{ drives: string[] }> }; // editor windows /** Main window only: open (or focus) a native editor window. */ openEditor: { params: EditorOpenContext; response: Result }; diff --git a/ui/src/app/editor-window/editor-window.html b/ui/src/app/editor-window/editor-window.html index 54b9ed5..94278c3 100644 --- a/ui/src/app/editor-window/editor-window.html +++ b/ui/src/app/editor-window/editor-window.html @@ -28,7 +28,7 @@
- + @if (chmodAvailable()) { + + } + } + @if (connections.grouped().loose.length || connections.grouped().stacks.length) {
} diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index b6e4186..6294e5b 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -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([]); + /** 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>('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) diff --git a/ui/src/app/format.ts b/ui/src/app/format.ts index 56611cf..e7c287e 100644 --- a/ui/src/app/format.ts +++ b/ui/src/app/format.ts @@ -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" */ diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index 55e0587..373b798 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -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. */ diff --git a/ui/src/app/platform.ts b/ui/src/app/platform.ts index 4a81697..2aa1e41 100644 --- a/ui/src/app/platform.ts +++ b/ui/src/app/platform.ts @@ -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'; diff --git a/ui/src/app/tab-bar/tab-bar.ts b/ui/src/app/tab-bar/tab-bar.ts index c9d43cd..b9ab007 100644 --- a/ui/src/app/tab-bar/tab-bar.ts +++ b/ui/src/app/tab-bar/tab-bar.ts @@ -3,7 +3,7 @@ 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'; @@ -78,7 +78,7 @@ const CONCURRENCY_CHOICES = [2, 3, 4, 6, 8] as const; @if (panels.workspaces().length > 1) {