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 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-14 00:02:42 +02:00
co-authored by Claude Fable 5
parent d80d5c23dc
commit c7271fb7cf
23 changed files with 223 additions and 57 deletions
+20 -5
View File
@@ -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" });
+17 -6
View File
@@ -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" });
}
+16
View File
@@ -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,
+15 -3
View File
@@ -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() })),
},
},
});
+19
View File
@@ -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
View File
@@ -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());
}
+2
View File
@@ -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<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 };