storebound/apps/desktop/build/freeze-safe.ts
CRT bd3ab20e1d Desktop: never a blank window (frozen-prototype fix, error screens, logs, state migration, boot check)
The signed release showed only its background colour and never started the sidecar. Tauri's freezePrototype
freezes Object.prototype before the bundle runs; d3-color and d3-format (under the visx charts) assign
`constructor` / `toString` on plain objects while loading, which throws on a frozen prototype, so the bundle
stopped before React drew anything. The saved state was not the cause.

- build/freeze-safe.ts: Vite plugin that rewrites those assignments in dependencies into a helper that defines
  the property when the frozen prototype would refuse it
- error boundaries for the window, each screen and each app tab (Copy details, Reload, Reset saved state with a
  backup); public/boot.js shows the same panel when the bundle fails or the window stays empty
- logs/host.log (start, saved state, sidecar start/stop, panics) and logs/webview.log (log_webview), rotated,
  home as ~, keys, tokens and e-mail addresses replaced
- state.json `version` 2 with an explicit 1 -> 2 migration (original kept as state.json.v1.bak), per-entry
  tolerant parsing, partial settings keep their fields, unreadable files kept aside
- folders that are gone: "Folder not found" with Locate... (project_locate) / Remove; portfolio reads skip them
- tools/desktop-boot-check.ts: the built window with the release CSP and the frozen prototype, a mocked host from
  the state fixtures (empty, D1 with folders gone, D3) and the real sidecar, through every screen; in bun run ci,
  the Forgejo CI workflow and the macOS release
2026-10-11 09:36:34 +08:00

68 lines
4.4 KiB
TypeScript

// Tauri's `freezePrototype: true` (tauri.conf.json) runs Object.freeze(Object.prototype) before any page script. In
// strict code, assigning a property that an object only inherits from the frozen Object.prototype (`constructor`,
// `toString`, ...) then throws "Cannot assign to read only property": the JavaScript "override mistake". d3-color and
// d3-format (under @visx/scale) do exactly that while their modules load, so the bundle stopped before React rendered
// anything and the release window showed only its background colour.
//
// This plugin rewrites every `x.constructor = v`, `x.toString = v`, ... in dependencies into a call that does the same
// assignment and, only when that assignment hits the frozen prototype, defines the property on `x` itself instead
// (Object.defineProperty is allowed there). The release boot check (tools/desktop-boot-check.ts) loads the built
// bundle with the prototype frozen, as Tauri does, and fails on any uncaught error.
import type { Plugin } from 'vite';
/** The writable data properties of Object.prototype that freezing turns read-only for every plain object. */
export const FROZEN_NAMES = ['constructor', 'toString', 'toLocaleString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable'] as const;
const QUICK = new RegExp(`\\.(?:${FROZEN_NAMES.join('|')})\\s*=(?!=)`);
export const HELPER = '__storeboundFreezeSafeSet';
// Appended (function declarations hoist), so a leading "use strict" directive stays first. Works in strict and sloppy
// code: when `k` is only inherited from a frozen (read-only) data property, define it on `o`; otherwise assign.
const HELPER_SRC = `\nfunction ${HELPER}(o, k, v) { if (o !== null && (typeof o === 'object' || typeof o === 'function') && !Object.prototype.hasOwnProperty.call(o, k)) { for (var p = Object.getPrototypeOf(o); p !== null; p = Object.getPrototypeOf(p)) { var d = Object.getOwnPropertyDescriptor(p, k); if (!d) continue; if ('value' in d && !d.writable) { Object.defineProperty(o, k, { value: v, writable: true, enumerable: true, configurable: true }); return v; } break; } } o[k] = v; return v; }\n`;
interface Node { type: string; start: number; end: number; [k: string]: unknown }
function walk(node: unknown, visit: (n: Node) => void): void {
if (!node || typeof node !== 'object') return;
if (Array.isArray(node)) { for (const n of node) walk(n, visit); return; }
const n = node as Node;
if (typeof n.type === 'string') visit(n);
for (const k of Object.keys(n)) if (k !== 'type' && k !== 'start' && k !== 'end') walk(n[k], visit);
}
/** Rewrites the assignments in one module's code; null when there is nothing to change. */
export function rewrite(code: string, parse: (code: string) => unknown): string | null {
if (!QUICK.test(code)) return null;
const edits: { start: number; end: number; text: (inner: (s: number, e: number) => string) => string }[] = [];
walk(parse(code), n => {
if (n.type !== 'AssignmentExpression' || n.operator !== '=') return;
const left = n.left as Node & { object: Node; property: Node & { name?: string }; computed: boolean; optional?: boolean };
if (left.type !== 'MemberExpression' || left.computed || left.object.type === 'Super') return;
if (!(FROZEN_NAMES as readonly string[]).includes(left.property.name ?? '')) return;
const right = n.right as Node;
edits.push({ start: n.start, end: n.end, text: inner => `${HELPER}(${inner(left.object.start, left.object.end)}, ${JSON.stringify(left.property.name)}, ${inner(right.start, right.end)})` });
});
if (!edits.length) return null;
// outermost edits only carry their inner text through `inner`, which applies nested edits recursively
edits.sort((a, b) => a.start - b.start || b.end - a.end);
const render = (s: number, e: number): string => {
let out = '', at = s;
for (const ed of edits) {
if (ed.start < at || ed.end > e || ed.start < s) continue;
out += code.slice(at, ed.start) + ed.text(render);
at = ed.end;
}
return out + code.slice(at, e);
};
return render(0, code.length) + HELPER_SRC;
}
export function freezeSafe(): Plugin {
return {
name: 'storebound-freeze-safe',
transform(code, id) {
const file = id.split('?')[0]!;
if (!/node_modules/.test(file) || !/\.(?:m?js|cjs)$/.test(file)) return null;
const out = rewrite(code, c => this.parse(c));
return out === null ? null : { code: out, map: null };
},
};
}