Rename to miniSCP; migrate legacy data dirs on boot

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jokin
2026-07-12 01:53:26 +02:00
co-authored by Claude Fable 5
parent 320080de6d
commit ba0b2434fb
7 changed files with 54 additions and 24 deletions
+10 -9
View File
@@ -1,4 +1,4 @@
# BunSCP # miniSCP
A cross-platform, dual-panel SFTP/local file manager — a small WinSCP-style A cross-platform, dual-panel SFTP/local file manager — a small WinSCP-style
commander built on **Bun**, **Electrobun**, and **Angular**. commander built on **Bun**, **Electrobun**, and **Angular**.
@@ -90,7 +90,7 @@ Bun main process (`src/bun/**`, RPC schema) trigger an app relaunch.
## Build a packaged app ## Build a packaged app
```bash ```bash
bun run build # ng build (ui/dist) + electrobun build -> build/*/BunSCP*.app bun run build # ng build (ui/dist) + electrobun build -> build/*/miniSCP*.app
``` ```
The Angular bundle is copied into the app under `views://app`. The macOS icon is The Angular bundle is copied into the app under `views://app`. The macOS icon is
@@ -107,8 +107,9 @@ Everything user-facing reads from **[`src/shared/branding.ts`](src/shared/brandi
```ts ```ts
export const BRANDING = { export const BRANDING = {
appName: "BunSCP", // window title, bundle name, app-data folder appName: "miniSCP", // window title, bundle name, app-data folder
identifier: "es.teknes.bunscp", identifier: "es.teknes.miniscp",
legacyNames: ["BunSCP"], // previous names; their data dirs migrate on boot
version: "0.1.0", version: "0.1.0",
icon: "assets/icon.iconset", icon: "assets/icon.iconset",
} as const; } as const;
@@ -120,14 +121,14 @@ To change the icon: edit `assets/logo.svg` (app icon) and
tray template image, and the tray progress frames from the SVGs tray template image, and the tray progress frames from the SVGs
(`@resvg/resvg-js`, dev-only dependency). (`@resvg/resvg-js`, dev-only dependency).
> Renaming `appName` changes the app-data directory, so existing saved > Renaming `appName` changes the app-data directory. Add the old name to
> connections won't be found under the new name — copy `connections.json` across > `legacyNames` and the app moves the previous folder (connections, settings,
> if you need them. > edit temp files) into place on the next boot.
## Where connections are stored ## Where connections are stored
Saved connections live in a plain JSON file in the OS app-data directory Saved connections live in a plain JSON file in the OS app-data directory
(macOS: `~/Library/Application Support/BunSCP/connections.json`), written with (macOS: `~/Library/Application Support/miniSCP/connections.json`), written with
`0600` permissions. `0600` permissions.
> ⚠️ **Passwords are stored in plaintext** in this file (a deliberate v1 > ⚠️ **Passwords are stored in plaintext** in this file (a deliberate v1
@@ -148,7 +149,7 @@ Saved connections live in a plain JSON file in the OS app-data directory
## Same-file protection ## Same-file protection
Copying a file or folder onto itself would truncate it to zero bytes (the Copying a file or folder onto itself would truncate it to zero bytes (the
destination is opened for writing before the source is read). BunSCP blocks destination is opened for writing before the source is read). miniSCP blocks
this at several layers: this at several layers:
- **Same session / same host**: every connection carries a filesystem identity - **Same session / same host**: every connection carries a filesystem identity
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "bunscp", "name": "miniscp",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
+5 -1
View File
@@ -7,6 +7,7 @@ import { SessionRegistry } from "./fs/registry";
import { applyApplicationMenu } from "./menu"; import { applyApplicationMenu } from "./menu";
import { createAppRPC } from "./rpc-handlers"; import { createAppRPC } from "./rpc-handlers";
import { ConnectionStore } from "./store/connections"; import { ConnectionStore } from "./store/connections";
import { migrateLegacyDataDir } from "./store/paths";
import { SettingsStore } from "./store/settings"; import { SettingsStore } from "./store/settings";
import { AppTray } from "./tray"; import { AppTray } from "./tray";
import { TransferEngine } from "./transfers/engine"; import { TransferEngine } from "./transfers/engine";
@@ -31,6 +32,9 @@ async function getMainViewUrl(): Promise<string> {
} }
} }
// after a rebrand, adopt the previous name's data dir before stores read it
migrateLegacyDataDir();
const registry = new SessionRegistry(); const registry = new SessionRegistry();
const engine = new TransferEngine(registry); const engine = new TransferEngine(registry);
const store = new ConnectionStore(); const store = new ConnectionStore();
@@ -62,7 +66,7 @@ const win = new BrowserWindow({
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } } ? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
: {}), : {}),
// lets the UI pick the fused layout before first paint // lets the UI pick the fused layout before first paint
preload: `window.__BUNSCP_PLATFORM=${JSON.stringify(process.platform)};`, preload: `window.__APP_PLATFORM=${JSON.stringify(process.platform)};`,
rpc, rpc,
}); });
+24 -7
View File
@@ -1,18 +1,35 @@
import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { BRANDING } from "../../shared/branding"; import { BRANDING } from "../../shared/branding";
export function appDataDir(): string { function dirFor(appName: string): string {
const home = os.homedir(); const home = os.homedir();
switch (process.platform) { switch (process.platform) {
case "darwin": case "darwin":
return path.join(home, "Library", "Application Support", BRANDING.appName); return path.join(home, "Library", "Application Support", appName);
case "win32": case "win32":
return path.join( return path.join(process.env.APPDATA ?? path.join(home, "AppData", "Roaming"), appName);
process.env.APPDATA ?? path.join(home, "AppData", "Roaming"),
BRANDING.appName,
);
default: default:
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), BRANDING.appName); return path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), appName);
}
}
export function appDataDir(): string {
return dirFor(BRANDING.appName);
}
/** One-time move of a previous name's data dir (connections, settings, edits)
* after a rebrand. Runs at boot, before anything touches the stores. */
export function migrateLegacyDataDir(): void {
const current = appDataDir();
if (fs.existsSync(current)) return;
for (const legacy of BRANDING.legacyNames) {
const old = dirFor(legacy);
if (!fs.existsSync(old)) continue;
fs.mkdirSync(path.dirname(current), { recursive: true });
fs.renameSync(old, current);
console.log(`[store] migrated data dir ${legacy} -> ${BRANDING.appName}`);
return;
} }
} }
+6 -2
View File
@@ -1,11 +1,14 @@
import { pipeline } from "node:stream/promises"; import { pipeline } from "node:stream/promises";
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
import { BRANDING } from "../../shared/branding";
import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types"; import type { EntryType, TransferSnapshot, TransferState } from "../../shared/types";
import type { FileSystemProvider } from "../fs/provider"; import type { FileSystemProvider } from "../fs/provider";
import type { SessionRegistry } from "../fs/registry"; import type { SessionRegistry } from "../fs/registry";
import { walkTree } from "./walk"; import { walkTree } from "./walk";
const PROGRESS_INTERVAL_MS = 100; const PROGRESS_INTERVAL_MS = 100;
// same-fs probe temp files stay attributable to the app that wrote them
const PROBE_PREFIX = `.${BRANDING.appName.toLowerCase()}-probe-`;
// workers per job; the shared slot budget is the real limit // workers per job; the shared slot budget is the real limit
const MAX_WORKERS_PER_JOB = 8; const MAX_WORKERS_PER_JOB = 8;
@@ -359,7 +362,8 @@ export class TransferEngine {
dir: string, dir: string,
): Promise<boolean> { ): Promise<boolean> {
const token = crypto.randomUUID(); const token = crypto.randomUUID();
const probePath = dst.join(dir, `.bunscp-probe-${token.slice(0, 8)}`); const probeName = `${PROBE_PREFIX}${token.slice(0, 8)}`;
const probePath = dst.join(dir, probeName);
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
const stream = dst.createWriteStream(probePath); const stream = dst.createWriteStream(probePath);
@@ -368,7 +372,7 @@ export class TransferEngine {
}); });
try { try {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
for await (const chunk of src.createReadStream(src.join(dir, `.bunscp-probe-${token.slice(0, 8)}`))) { for await (const chunk of src.createReadStream(src.join(dir, probeName))) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
} }
const seen = Buffer.concat(chunks).toString("utf8") === token; const seen = Buffer.concat(chunks).toString("utf8") === token;
+5 -2
View File
@@ -1,8 +1,11 @@
// The single place to rebrand the app. Everything (window title, bundle name, // The single place to rebrand the app. Everything (window title, bundle name,
// app-data directory, UI header, icon) reads from here. // app-data directory, UI header, icon) reads from here.
export const BRANDING = { export const BRANDING = {
appName: "BunSCP", appName: "miniSCP",
identifier: "es.teknes.bunscp", identifier: "es.teknes.miniscp",
// Previous app names: their app-data folders are migrated on boot
// (see migrateLegacyDataDir in src/bun/store/paths.ts).
legacyNames: ["BunSCP"],
version: "0.1.0", version: "0.1.0",
// macOS build converts this .iconset folder into AppIcon.icns. // macOS build converts this .iconset folder into AppIcon.icns.
// Regenerate from assets/icon.png with scripts/gen-iconset.sh. // Regenerate from assets/icon.png with scripts/gen-iconset.sh.
+3 -2
View File
@@ -1,11 +1,12 @@
// Injected by the bun process via BrowserWindow preload before any page script. // Injected by the bun process via BrowserWindow preload before any page script.
// Brand-neutral name so rebrands never touch this file.
declare global { declare global {
interface Window { interface Window {
__BUNSCP_PLATFORM?: string; __APP_PLATFORM?: string;
} }
} }
export const PLATFORM = typeof window !== 'undefined' ? (window.__BUNSCP_PLATFORM ?? '') : ''; export const PLATFORM = typeof window !== 'undefined' ? (window.__APP_PLATFORM ?? '') : '';
/** True when the window uses macOS hiddenInset chrome (tab bar fused with titlebar). */ /** True when the window uses macOS hiddenInset chrome (tab bar fused with titlebar). */
export const IS_MAC_CHROME = PLATFORM === 'darwin'; export const IS_MAC_CHROME = PLATFORM === 'darwin';