Skin is a launch-time choice; stop applying it to a live window
Switching Modern -> Win7 at runtime broke the UI. SettingsService.applySkin toggled the `.win7` class the moment the setting changed — harmless when there was one component tree, and a broken window as soon as there were two. The tree is bootstrapped once from the preload-baked __APP_SKIN, so the live flip painted 7.css onto shadlike markup that has no .window.glass, no .title-bar, no menu[role=tablist] and no <table> ListView. The CSS landed on a tree that cannot receive it. A reload would not have helped, and neither would anything short of a restart: - The preload is a persistent WebKitUserScript injected at DOCUMENT_START, so its string is frozen at webview construction — loadURL() re-runs it with the OLD __APP_SKIN. updatePreloadScriptToWebView exists in the FFI but is reachable only from electrobun's internal <electrobun-webview> handler. - titleBarStyle/transparent have no setters; a live window cannot be re-framed. win7 -> modern is the dangerous direction — the modern tree draws no title bar, so it would land in a frameless transparent window with no way to move, minimize or close it. - Recreating the main window is also out: exitOnLastWindowClosed is true, the main rpc is captured by six push closures, the tray captures `win`, and listeners cannot be removed. So: applySkin is deleted outright, from setSkin AND from applyAll — the latter matters independently, since applyAll runs on the settingsChanged push and was therefore breaking every open editor and manager window too. Clicking a skin now only persists it. Also fixed a second leak found while tracing this: manager-window and editor-windows build from `currentSettings()`, the LIVE snapshot, so a window opened after a pending change would boot the new tree and frame while main still ran the old one. `initSessionSkin` now freezes the skin at startup before any window exists, and buildPreload/chromeOptions read that pinned value — chromeOptions loses its parameter so no call site can get it wrong. The options row shows the saved value as pending (skinPending() compares settings.skin() against APP_SKIN) with the hint retexted in all 7 locales; it said "Reopen the window to change its frame", which described the deleted design. Verified: tsc + build:ui clean; both skins boot correctly from a persisted setting (win7 frameless, modern with the native frame). The live flip is now impossible by construction, which is the check that matters here — the UI bundle contains no classList write for win7 at all, the only writer is buildPreload, and sessionSkin has exactly one assignment site. Not verified: the click itself and the pending hint appearing, which need an interaction pass — no window automation is available here. Both are present in the built bundle. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+14
-3
@@ -15,7 +15,13 @@ import { migrateLegacyDataDir } from "./store/paths";
|
||||
import { SettingsStore } from "./store/settings";
|
||||
import { AppTray } from "./tray";
|
||||
import { TransferEngine } from "./transfers/engine";
|
||||
import { buildPreload, chromeOptions, showWhenReady, trackMaximizeState } from "./windows";
|
||||
import {
|
||||
buildPreload,
|
||||
chromeOptions,
|
||||
initSessionSkin,
|
||||
showWhenReady,
|
||||
trackMaximizeState,
|
||||
} from "./windows";
|
||||
|
||||
const PROD_URL = "views://app/index.html";
|
||||
|
||||
@@ -76,6 +82,11 @@ const url = await getMainViewUrl();
|
||||
// fresh via the saveSettings flow below.
|
||||
let currentSettings = await settings.load();
|
||||
setBackendLocale(currentSettings.locale); // native menus/errors follow the UI language
|
||||
// Freeze the skin for this process BEFORE any window is constructed. It picks
|
||||
// a component tree and a native frame, neither of which can change on a live
|
||||
// window, so the persisted value may only take effect at the next start — see
|
||||
// initSessionSkin.
|
||||
initSessionSkin(currentSettings.skin);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
title: BRANDING.appName,
|
||||
@@ -87,8 +98,8 @@ const win = new BrowserWindow({
|
||||
y: 120,
|
||||
},
|
||||
// Frame decision lives in chromeOptions — see windows.ts. Constructor-only,
|
||||
// so a skin change reaches the frame on the NEXT open, not live.
|
||||
...chromeOptions(currentSettings.skin),
|
||||
// and read from the pinned session skin, not from currentSettings.
|
||||
...chromeOptions(),
|
||||
// lets the UI pick the fused layout + theme before first paint
|
||||
preload: buildPreload("main", currentSettings),
|
||||
rpc,
|
||||
|
||||
+36
-11
@@ -1,6 +1,27 @@
|
||||
import { BrowserWindow } from "electrobun/bun";
|
||||
import type { AppSettings } from "../shared/types";
|
||||
import { DEFAULT_SETTINGS, type AppSettings } from "../shared/types";
|
||||
|
||||
/**
|
||||
* The skin THIS PROCESS booted with, frozen at startup by `initSessionSkin`.
|
||||
*
|
||||
* Every window must be built from this, never from the live settings snapshot.
|
||||
* The skin picks a whole component tree (bootstrapped from the preload-baked
|
||||
* __APP_SKIN) and the native frame (constructor-only), so it cannot change
|
||||
* without a restart — but the persisted setting CAN change mid-session. Reading
|
||||
* the live value would boot a newly opened editor or manager into the pending
|
||||
* skin while the main window still runs the old one: two windows, two trees,
|
||||
* two frames.
|
||||
*
|
||||
* Pinning it in one place makes that impossible rather than making each call
|
||||
* site remember. Everything else in the preload keeps using the live snapshot,
|
||||
* because theme/tone/accent/text-size genuinely do apply live.
|
||||
*/
|
||||
let sessionSkin: AppSettings["skin"] = DEFAULT_SETTINGS.skin;
|
||||
|
||||
/** Call once, at startup, before any window is constructed. */
|
||||
export function initSessionSkin(skin: AppSettings["skin"]): void {
|
||||
sessionSkin = skin;
|
||||
}
|
||||
|
||||
/**
|
||||
* The preload globals every window gets. Single composition point: the UI
|
||||
@@ -18,16 +39,19 @@ export function buildPreload(
|
||||
settings: AppSettings,
|
||||
ctx?: unknown,
|
||||
): string {
|
||||
// sessionSkin, NOT settings.skin — a pending skin change must not leak into
|
||||
// a window opened before the restart. See initSessionSkin.
|
||||
let script =
|
||||
`window.__APP_PLATFORM=${JSON.stringify(process.platform)};` +
|
||||
`window.__APP_MODE=${JSON.stringify(mode)};` +
|
||||
`window.__APP_SKIN=${JSON.stringify(settings.skin)};`;
|
||||
`window.__APP_SKIN=${JSON.stringify(sessionSkin)};`;
|
||||
if (ctx !== undefined) script += `window.__APP_CTX=${JSON.stringify(ctx)};`;
|
||||
|
||||
// A class, not a data-* attribute: 7.scoped.css is precompiled to require a
|
||||
// literal `.win7` ancestor, so the name is not ours to choose. Mirrors
|
||||
// SettingsService.applySkin, which owns it once the page is live.
|
||||
if (settings.skin === "win7") {
|
||||
// literal `.win7` ancestor, so the name is not ours to choose. This is the
|
||||
// ONLY place it is ever written — the UI must never toggle it live, because
|
||||
// each skin's CSS is written against its own tree's markup.
|
||||
if (sessionSkin === "win7") {
|
||||
script += `document.documentElement.classList.add("win7");`;
|
||||
}
|
||||
|
||||
@@ -46,12 +70,13 @@ export function buildPreload(
|
||||
* Native chrome options for a window. The ONE place the frame decision is made —
|
||||
* both the main window (src/bun/index.ts) and openSecondaryWindow spread this.
|
||||
*
|
||||
* Both values are BrowserWindow CONSTRUCTOR options, which is exactly why a
|
||||
* skin change only reaches the frame on the next window open: there is no API
|
||||
* to re-decorate a live window. SettingsService.applySkin flips the CSS half
|
||||
* immediately, and the options row tells the user about the gap.
|
||||
* Both values are BrowserWindow CONSTRUCTOR options — there is no API to
|
||||
* re-decorate a live window — which is half of why the skin is a launch-time
|
||||
* choice. Takes no argument on purpose: it reads the pinned sessionSkin, so no
|
||||
* call site can accidentally frame a window from a pending setting.
|
||||
*/
|
||||
export function chromeOptions(skin: AppSettings["skin"]) {
|
||||
export function chromeOptions() {
|
||||
const skin = sessionSkin;
|
||||
// win7 draws its own Aero title bar (drag region + our minimize/maximize/
|
||||
// close), so the window is frameless on EVERY platform. `titleBarStyle:
|
||||
// "hidden"` clears `Titled` from the Cocoa style mask, which is also what
|
||||
@@ -141,7 +166,7 @@ export function openSecondaryWindow(opts: SecondaryWindowOptions): BrowserWindow
|
||||
title: opts.title,
|
||||
url: opts.url,
|
||||
frame: opts.frame,
|
||||
...chromeOptions(opts.settings.skin),
|
||||
...chromeOptions(),
|
||||
preload: buildPreload(opts.mode, opts.settings, opts.ctx),
|
||||
rpc: opts.rpc,
|
||||
hidden: true,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const de = {
|
||||
label: "Optik",
|
||||
modern: "Modern",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Fenster neu öffnen, um den Rahmen zu ändern",
|
||||
restartHint: "Wird beim nächsten Start von miniSCP übernommen",
|
||||
},
|
||||
theme: { label: "Design", system: "System", light: "Hell", dark: "Dunkel" },
|
||||
tone: { label: "Ton", dark: "Dunkel", darker: "Dunkler" },
|
||||
|
||||
@@ -41,7 +41,7 @@ export const en = {
|
||||
label: "Skin",
|
||||
modern: "Modern",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Reopen the window to change its frame",
|
||||
restartHint: "Applies next time you start miniSCP",
|
||||
},
|
||||
theme: { label: "Theme", system: "System", light: "Light", dark: "Dark" },
|
||||
tone: { label: "Tone", dark: "Dark", darker: "Darker" },
|
||||
|
||||
@@ -37,7 +37,7 @@ export const es = {
|
||||
label: "Aspecto",
|
||||
modern: "Moderno",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Vuelve a abrir la ventana para cambiar su marco",
|
||||
restartHint: "Se aplica la próxima vez que abras miniSCP",
|
||||
},
|
||||
theme: { label: "Tema", system: "Sistema", light: "Claro", dark: "Oscuro" },
|
||||
tone: { label: "Tono", dark: "Oscuro", darker: "Más oscuro" },
|
||||
|
||||
@@ -39,7 +39,7 @@ export const eu = {
|
||||
label: "Itxura",
|
||||
modern: "Modernoa",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Ireki leihoa berriro bere markoa aldatzeko",
|
||||
restartHint: "Hurrengoan miniSCP abiaraztean aplikatuko da",
|
||||
},
|
||||
theme: { label: "Gaia", system: "Sistema", light: "Argia", dark: "Iluna" },
|
||||
tone: { label: "Tonua", dark: "Iluna", darker: "Ilunagoa" },
|
||||
|
||||
@@ -37,7 +37,7 @@ export const fr = {
|
||||
label: "Apparence",
|
||||
modern: "Moderne",
|
||||
win7: "Windows 7",
|
||||
restartHint: "Rouvrez la fenêtre pour changer son cadre",
|
||||
restartHint: "Prend effet au prochain démarrage de miniSCP",
|
||||
},
|
||||
theme: { label: "Thème", system: "Système", light: "Clair", dark: "Sombre" },
|
||||
tone: { label: "Ton", dark: "Sombre", darker: "Plus sombre" },
|
||||
|
||||
@@ -38,7 +38,7 @@ export const ja = {
|
||||
label: "外観",
|
||||
modern: "モダン",
|
||||
win7: "Windows 7",
|
||||
restartHint: "ウィンドウの枠を変更するには開き直してください",
|
||||
restartHint: "次回 miniSCP を起動したときに適用されます",
|
||||
},
|
||||
theme: { label: "テーマ", system: "システム", light: "ライト", dark: "ダーク" },
|
||||
tone: { label: "トーン", dark: "ダーク", darker: "より暗く" },
|
||||
|
||||
@@ -38,7 +38,7 @@ export const zh = {
|
||||
label: "外观",
|
||||
modern: "现代",
|
||||
win7: "Windows 7",
|
||||
restartHint: "重新打开窗口以更改窗口边框",
|
||||
restartHint: "下次启动 miniSCP 时生效",
|
||||
},
|
||||
theme: { label: "主题", system: "系统", light: "浅色", dark: "深色" },
|
||||
tone: { label: "色调", dark: "深色", darker: "更深" },
|
||||
|
||||
Reference in New Issue
Block a user