v1.2: overwrite confirm, transfer speed/ETA, settings + themes, fused macOS titlebar
- Overwrite confirmation: name-collision detection before enqueue; dialog shows source vs destination (size, mtime, 'newer' badge) with per-item Skip/Replace and batch Skip all / Replace all; type mismatches auto-skip with a toast; cancel aborts the whole operation - Transfer speed + ETA: UI computes smoothed bytes/sec from progress deltas (callback bus makes deltas meaningful); shown in the expanded queue rows and the status-bar summary; unit follows the speed-unit setting - Settings: bun-side SettingsStore (settings.json, 0600, atomic) + get/save RPC; SettingsService; gear popover on the tab row (theme segmented control, speed unit); persisted across restarts - Light theme: data-theme on <html> drives token overrides; absent = follow OS - macOS fused titlebar: hiddenInset + trafficLightOffset, tab row becomes the draggable titlebar (app-region drag/no-drag classes); other platforms keep a separate titlebar (gated by a platform value injected via window preload) - README: known-issues note for the upstream Bun/Electrobun FFI crash Verified: tsc + ui build clean; SettingsStore roundtrip + 0600 headless; app boots and the UI fetches settings over RPC at startup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+13
-1
@@ -3,6 +3,7 @@ import { BRANDING } from "../shared/branding";
|
||||
import { SessionRegistry } from "./fs/registry";
|
||||
import { createAppRPC } from "./rpc-handlers";
|
||||
import { ConnectionStore } from "./store/connections";
|
||||
import { SettingsStore } from "./store/settings";
|
||||
import { TransferEngine } from "./transfers/engine";
|
||||
|
||||
const PROD_URL = "views://app/index.html";
|
||||
@@ -28,10 +29,13 @@ async function getMainViewUrl(): Promise<string> {
|
||||
const registry = new SessionRegistry();
|
||||
const engine = new TransferEngine(registry);
|
||||
const store = new ConnectionStore();
|
||||
const settings = new SettingsStore();
|
||||
const events = { sessionClosed: (_info: { sessionId: string; reason: string }) => {} };
|
||||
const rpc = createAppRPC({ registry, engine, store, events });
|
||||
const rpc = createAppRPC({ registry, engine, store, settings, events });
|
||||
const url = await getMainViewUrl();
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
new BrowserWindow({
|
||||
title: BRANDING.appName,
|
||||
url,
|
||||
@@ -41,6 +45,14 @@ new BrowserWindow({
|
||||
x: 120,
|
||||
y: 120,
|
||||
},
|
||||
// macOS: fuse the tab bar with the titlebar — traffic lights overlay the
|
||||
// UI's h-10 tab row (offset centers them vertically). Other platforms keep
|
||||
// the native titlebar and the tab bar stays a separate row.
|
||||
...(isMac
|
||||
? { titleBarStyle: "hiddenInset" as const, trafficLightOffset: { x: 12, y: 13 } }
|
||||
: {}),
|
||||
// lets the UI pick the fused layout before first paint
|
||||
preload: `window.__BUNSCP_PLATFORM=${JSON.stringify(process.platform)};`,
|
||||
rpc,
|
||||
});
|
||||
|
||||
|
||||
+12
-1
@@ -4,6 +4,7 @@ import type { Result } from "../shared/types";
|
||||
import type { SessionRegistry } from "./fs/registry";
|
||||
import { SftpProvider } from "./fs/sftp";
|
||||
import type { ConnectionStore } from "./store/connections";
|
||||
import type { SettingsStore } from "./store/settings";
|
||||
import type { TransferEngine } from "./transfers/engine";
|
||||
import { removeRecursive } from "./transfers/walk";
|
||||
|
||||
@@ -15,6 +16,7 @@ export interface AppDeps {
|
||||
registry: SessionRegistry;
|
||||
engine: TransferEngine;
|
||||
store: ConnectionStore;
|
||||
settings: SettingsStore;
|
||||
/** Assigned after the rpc object exists (it needs rpc.send). */
|
||||
events: { sessionClosed: (info: { sessionId: string; reason: string }) => void };
|
||||
}
|
||||
@@ -27,7 +29,7 @@ async function wrap<T extends object>(fn: () => Promise<T> | T): Promise<Result<
|
||||
}
|
||||
}
|
||||
|
||||
export function createAppRPC({ registry, engine, store, events }: AppDeps) {
|
||||
export function createAppRPC({ registry, engine, store, settings, events }: AppDeps) {
|
||||
return BrowserView.defineRPC<AppRPC>({
|
||||
maxRequestTime: MAX_REQUEST_TIME_MS,
|
||||
handlers: {
|
||||
@@ -135,6 +137,15 @@ export function createAppRPC({ registry, engine, store, events }: AppDeps) {
|
||||
await store.deleteFolder(id);
|
||||
return {};
|
||||
}),
|
||||
|
||||
// ---- settings ----
|
||||
getSettings: () => wrap(async () => ({ settings: await settings.load() })),
|
||||
|
||||
saveSettings: ({ settings: next }) =>
|
||||
wrap(async () => {
|
||||
await settings.save(next);
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_SETTINGS, type AppSettings } from "../../shared/types";
|
||||
import { appDataDir } from "./paths";
|
||||
|
||||
/** App settings persisted bun-side (WKWebView localStorage on views:// is unreliable). */
|
||||
export class SettingsStore {
|
||||
private readonly file = path.join(appDataDir(), "settings.json");
|
||||
|
||||
async load(): Promise<AppSettings> {
|
||||
try {
|
||||
const data = JSON.parse(await fs.readFile(this.file, "utf8"));
|
||||
return { ...DEFAULT_SETTINGS, ...data };
|
||||
} catch {
|
||||
return { ...DEFAULT_SETTINGS };
|
||||
}
|
||||
}
|
||||
|
||||
async save(settings: AppSettings): Promise<void> {
|
||||
await fs.mkdir(path.dirname(this.file), { recursive: true });
|
||||
const tmp = `${this.file}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(settings, null, "\t"), { mode: 0o600 });
|
||||
await fs.rename(tmp, this.file);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
ConnFolder,
|
||||
ConnectionConfig,
|
||||
ConnectionsFile,
|
||||
@@ -67,6 +68,12 @@ export type AppRPC = {
|
||||
deleteConnection: { params: { id: string }; response: Result };
|
||||
upsertFolder: { params: { folder: ConnFolder }; response: Result<{ id: string }> };
|
||||
deleteFolder: { params: { id: string }; response: Result };
|
||||
// settings
|
||||
getSettings: {
|
||||
params: Record<string, never>;
|
||||
response: Result<{ settings: AppSettings }>;
|
||||
};
|
||||
saveSettings: { params: { settings: AppSettings }; response: Result };
|
||||
};
|
||||
messages: Record<never, never>;
|
||||
};
|
||||
|
||||
@@ -41,6 +41,16 @@ export interface ConnectionsFile {
|
||||
connections: SavedConnection[];
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
theme: "system" | "light" | "dark";
|
||||
speedUnit: "MBps" | "Mbps";
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
theme: "system",
|
||||
speedUnit: "MBps",
|
||||
};
|
||||
|
||||
export type TransferState =
|
||||
| "queued"
|
||||
| "scanning"
|
||||
|
||||
Reference in New Issue
Block a user