Synthesize window resize for the frameless win7 skin (a documented stopgap)

The win7 skin is frameless so 7.css can draw the Aero caption, and on GTK that
means undecorated, which takes the window manager's resize borders and corner
grips with it. All three win7 windows were completely unresizable.

electrobun 1.18.1 offers nothing to re-enable, verified against the shipped
binary rather than assumed: libNativeWrapper exports startWindowMove/
stopWindowMove with no resize counterpart, imports gtk_window_begin_move_drag
but not gtk_window_begin_resize_drag, dragRegions.ts implements moving only,
and BrowserWindow has neither setResizable nor min-size options. But the
window is still programmatically resizable, so only the input affordance is
missing: eight handles overlay the frame and drag it through a new
windowGetFrame/windowSetFrame pair on the shared windowControlHandlers factory,
which gives all three window kinds resize with no per-window wiring.

Three things that would have made this quietly wrong:

- dragRegions.ts starts a window MOVE from a mousedown listener on document,
  never checks defaultPrevented, and is not suppressed by a pointerdown
  handler either (the compatibility mousedown still fires). Every handle
  carries app-region-no-drag, which short-circuits its check first.
- deltas come from screenX, not clientX: a north/west resize moves the window
  origin under a stationary pointer, so client coords feed that back into the
  next delta and the window runs away.
- the min-size clamp re-anchors the opposite edge; clamping width alone lets x
  keep tracking the pointer, walking the window off-screen while it looks
  frozen.

This is a stopgap and the point is that it is removable on sight. The frame
math is a dependency-free module so scripts/resize-math-probe.ts asserts all
eight edges without a window (verified it catches the unanchored-clamp bug:
x jumps to 5100 instead of parking at 460). scripts/check-electrobun-resize.ts
scans the installed electrobun for the primitives this is waiting on and prints
the removal checklist when they appear; it runs from postinstall so a version
bump surfaces it, never fails an install, and is silent otherwise. Both trigger
paths were simulated. The component header carries the full checklist; docs
cover the platform consequence and the dragRegions traps.

Harness diffs exactly 0 in both themes; hover and menu probes still clean.
Not yet exercised with a real pointer — that needs a live window.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-16 22:16:48 +02:00
co-authored by Claude
parent 5ed029a2b8
commit 4605a481f1
17 changed files with 673 additions and 4 deletions
+140
View File
@@ -0,0 +1,140 @@
// Watches for electrobun growing a real window-resize gesture, so the
// synthesized one can be deleted the moment it becomes dead weight.
//
// bun scripts/check-electrobun-resize.ts (also runs from `postinstall`)
//
// WHY THIS EXISTS
//
// The win7 skin is frameless, which on GTK means undecorated, which means the
// window manager supplies no resize borders. electrobun 1.18.1 exposes no
// native resize gesture at all, so the app synthesizes one: handles in the
// webview drag the frame via RPC. That is a genuine stopgap — jerkier than a
// compositor-side grab and always will be — and the thing most likely to
// happen to a stopgap is that it outlives its reason and nobody notices.
//
// So the trigger is automated rather than a comment somebody has to re-read: an
// electrobun bump runs `postinstall`, which runs this, which prints the removal
// checklist the moment upstream ships the primitive we are waiting for.
//
// It NEVER fails the install (always exit 0) and is silent when there is
// nothing to say — a check that cries wolf gets muted, and a muted check is
// worse than none.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
/** The version the synthesized resize was written against and verified on. If
* the installed version differs, this script says so once — that is the cue to
* re-check upstream's changelog and then bump this constant. */
const VALIDATED_VERSION = "1.18.1";
/**
* Symbols that would mean "upstream can resize now". Verified ABSENT in 1.18.1:
* the shipped library exports `startWindowMove`/`stopWindowMove` with no resize
* counterpart, and imports `gtk_window_begin_move_drag` but not the resize
* twin. Any of these appearing is the signal.
*
* Searched as raw ASCII in the binary rather than via `nm`/`strings`: only the
* host platform's library is installed (`dist-linux-x64/…so`, `.dylib`, `.dll`
* elsewhere), and `nm` does not exist on Windows. Symbol names live in the
* binary as plain text either way.
*/
const NATIVE_NEEDLES = [
"gtk_window_begin_resize_drag", // the GTK primitive, if they ever link it
"gtk_window_set_resizable",
"startWindowResize", // the obvious sibling of startWindowMove
"setWindowResizable",
"beginWindowResize",
];
/** Source-level signals in the shipped TS API. */
const SOURCE_NEEDLES: { file: string; needle: string; means: string }[] = [
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "setResizable",
means: "BrowserWindow gained a resizability setter",
},
{
file: "dist/api/bun/core/BrowserWindow.ts",
needle: "minWidth",
means: "the window constructor gained min-size options (our JS clamp may be redundant)",
},
{
file: "dist/api/bun/preload/dragRegions.ts",
needle: "resize",
means: "the drag-region preload now knows about resizing",
},
];
const CHECKLIST = `
┌────────────────────────────────────────────────────────────────────────┐
│ electrobun may now support window resizing natively. │
│ The synthesized resize is a STOPGAP and should be deleted. │
│ Full checklist: │
│ ui/src/app/skins/7like/window-resize/window-resize.ts │
└────────────────────────────────────────────────────────────────────────┘`;
function main(): void {
const pkgDir = path.join(import.meta.dir, "..", "node_modules", "electrobun");
if (!existsSync(pkgDir)) return; // not installed yet (fresh clone mid-install)
const hits: string[] = [];
// --- the shipped native library, whichever platform's is installed ---
for (const entry of readdirSync(pkgDir)) {
if (!entry.startsWith("dist-")) continue;
const dir = path.join(pkgDir, entry);
for (const file of readdirSync(dir)) {
if (!/^libNativeWrapper.*\.(so|dylib|dll)$/.test(file)) continue;
// latin1: we are searching for ASCII symbol names inside a binary, and
// utf-8 decoding would mangle bytes >0x7f (and can drop invalid
// sequences), which could hide a needle sitting next to one.
const text = readFileSync(path.join(dir, file), "latin1");
for (const needle of NATIVE_NEEDLES) {
if (text.includes(needle)) hits.push(`${entry}/${file} contains ${needle}`);
}
}
}
// --- the shipped TypeScript API ---
for (const { file, needle, means } of SOURCE_NEEDLES) {
const full = path.join(pkgDir, file);
if (!existsSync(full)) continue;
if (readFileSync(full, "utf8").includes(needle)) hits.push(`${file} mentions ${needle} — ${means}`);
}
const version = installedVersion(pkgDir);
if (hits.length) {
console.log(CHECKLIST);
console.log(` electrobun ${version ?? "?"} — what changed:`);
for (const hit of hits) console.log(` · ${hit}`);
console.log("");
return;
}
// No resize support. Stay quiet unless the version moved out from under the
// hack, in which case one line is the reminder to look.
if (version && version !== VALIDATED_VERSION) {
console.log(
`[electrobun] ${VALIDATED_VERSION} → ${version}: no window-resize API found, so the ` +
`synthesized resize stays. Re-check, then bump VALIDATED_VERSION in ` +
`scripts/check-electrobun-resize.ts to silence this.`,
);
}
}
function installedVersion(pkgDir: string): string | null {
try {
return JSON.parse(readFileSync(path.join(pkgDir, "package.json"), "utf8")).version ?? null;
} catch {
return null;
}
}
// A broken check must never break an install: swallow everything, exit 0.
try {
main();
} catch (error) {
// Deliberately quiet — this is advisory. Surface it only when run by hand.
if (process.env["DEBUG"]) console.error("[electrobun] resize check failed:", error);
}
+121
View File
@@ -0,0 +1,121 @@
// Programmatic test for the synthesized window resize's frame arithmetic.
//
// bun scripts/resize-math-probe.ts
//
// Why this exists: the win7 skin resizes itself by dragging handles and moving
// the native frame (see ui/src/app/skins/7like/window-resize/window-resize.ts —
// a STOPGAP with a removal checklist). Pointer drags cannot be synthesized
// headlessly on this machine, so the interaction itself needs a human; but the
// part most likely to be subtly wrong is pure arithmetic — eight edges, four of
// which move the window's ORIGIN as well as its size, plus a minimum-size clamp
// that must not let the anchored edge drift. That is all in `resize-math.ts`,
// which is deliberately dependency-free so this script can drive it directly.
//
// The failure this guards against is not "the window does not resize" (obvious
// on sight) but "dragging the west edge inward past the minimum walks the whole
// window across the screen" — correct-looking until you hit the limit.
import {
MIN_HEIGHT,
MIN_WIDTH,
RESIZE_EDGES,
type Frame,
type ResizeEdge,
resizeFrame,
} from "../ui/src/app/skins/7like/window-resize/resize-math";
const START: Frame = { x: 100, y: 200, width: 1000, height: 800 };
interface Case {
name: string;
edge: ResizeEdge;
dx: number;
dy: number;
want: Frame;
}
// Every expectation is written out in full rather than computed, so a bug in
// resizeFrame cannot be mirrored into the expectation by sharing its algebra.
const CASES: Case[] = [
// --- edges: each moves only what it owns ---
{ name: "east grows width only", edge: "e", dx: 50, dy: 999, want: { x: 100, y: 200, width: 1050, height: 800 } },
{ name: "south grows height only", edge: "s", dx: 999, dy: 40, want: { x: 100, y: 200, width: 1000, height: 840 } },
{ name: "west moves x AND width", edge: "w", dx: -30, dy: 0, want: { x: 70, y: 200, width: 1030, height: 800 } },
{ name: "north moves y AND height", edge: "n", dx: 0, dy: -25, want: { x: 100, y: 175, width: 1000, height: 825 } },
// --- corners: two axes at once ---
{ name: "south-east", edge: "se", dx: 10, dy: 20, want: { x: 100, y: 200, width: 1010, height: 820 } },
{ name: "north-west", edge: "nw", dx: -10, dy: -20, want: { x: 90, y: 180, width: 1010, height: 820 } },
{ name: "north-east", edge: "ne", dx: 10, dy: -20, want: { x: 100, y: 180, width: 1010, height: 820 } },
{ name: "south-west", edge: "sw", dx: -10, dy: 20, want: { x: 90, y: 200, width: 1010, height: 820 } },
// --- the anchored edge must not drift once clamped ---
// west dragged far east: width parks at MIN, and x parks where the EAST edge
// (100 + 1000 = 1100) minus MIN puts it. If x kept tracking dx, the window
// would slide away while appearing frozen.
{
name: "west clamps, east edge holds",
edge: "w",
dx: 5000,
dy: 0,
want: { x: 1100 - MIN_WIDTH, y: 200, width: MIN_WIDTH, height: 800 },
},
{
name: "north clamps, south edge holds",
edge: "n",
dx: 0,
dy: 5000,
want: { x: 100, y: 1000 - MIN_HEIGHT, width: 1000, height: MIN_HEIGHT },
},
// east/south clamp the size but never touch the origin
{ name: "east clamps, origin holds", edge: "e", dx: -5000, dy: 0, want: { x: 100, y: 200, width: MIN_WIDTH, height: 800 } },
{ name: "south clamps, origin holds", edge: "s", dx: 0, dy: -5000, want: { x: 100, y: 200, width: 1000, height: MIN_HEIGHT } },
// a corner clamping on BOTH axes at once
{
name: "north-west clamps both",
edge: "nw",
dx: 5000,
dy: 5000,
want: { x: 1100 - MIN_WIDTH, y: 1000 - MIN_HEIGHT, width: MIN_WIDTH, height: MIN_HEIGHT },
},
// a zero drag must be a no-op, or a click on a handle would nudge the window
{ name: "zero drag is identity", edge: "se", dx: 0, dy: 0, want: START },
];
const fmt = (f: Frame) => `${f.x},${f.y} ${f.width}x${f.height}`;
console.log("[resize-math] frame arithmetic:\n");
console.log(" case edge got verdict");
let failed = 0;
for (const c of CASES) {
const got = resizeFrame(c.edge, START, c.dx, c.dy);
const ok = fmt(got) === fmt(c.want);
if (!ok) failed++;
console.log(
` ${c.name.padEnd(32)} ${c.edge.padEnd(5)} ${fmt(got).padEnd(22)} ${ok ? "ok" : `FAIL: want ${fmt(c.want)}`}`,
);
}
// Structural check: every edge must be exercised above, so adding a ninth
// handle without a case cannot slip through.
const covered = new Set(CASES.map((c) => c.edge));
const missing = RESIZE_EDGES.filter((e) => !covered.has(e));
if (missing.length) {
console.log(`\n[resize-math] no case covers: ${missing.join(", ")}`);
failed++;
}
// The clamp must never produce a frame smaller than the minimum, whatever the
// drag — a cheap sweep over every edge and a range of deltas.
for (const edge of RESIZE_EDGES) {
for (const d of [-9999, -1000, -1, 0, 1, 1000, 9999]) {
const got = resizeFrame(edge, START, d, d);
if (got.width < MIN_WIDTH || got.height < MIN_HEIGHT) {
console.log(`\n[resize-math] ${edge} at delta ${d} produced ${fmt(got)} — under the minimum`);
failed++;
}
}
}
if (failed) {
console.log(`\n[resize-math] ${failed} failure(s)`);
process.exit(2);
}
console.log("\n[resize-math] clean");