app
Hero Studio
Public Made by Adomby adom
The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its P
← Commit history
Publish 0.21.3
11 files changed
+251−34
SKILL.md+13−1@@ -245,7 +245,19 @@ hero-studio doctor Run the printed fix commands yourself: you are already inside the container (no SSH, no outside access involved), and install already warned about this at-`pkg install` time. Typical light-container fix: `npm install puppeteer-core@24`+`pkg install` time. install.sh installs puppeteer-core itself when nothing+resolves, into `~/.cache/hero-studio` (OUTSIDE the package directory, which a+package update replaces node_modules and all; that is how a running studio+once lost its renderer between a passing doctor and the human's click,+hero-studio#58), pinned by this Node (24.x below 22.12). The gate shows a+renderer line under Generate, refreshed before every Generate and once a+minute: `renderer: local`, `via pup`, or RENDERER NOT READY with the exact+fix, and Generate refuses with `errorCode: RENDERER_NOT_READY` instead of+walking through two dead legs. Fixes take effect on the next click, no+restart. The same line says when a newer package is installed on disk under+this running server (`onDisk`), which only a restart picks up.+`npm run test:renderer` covers the pin, the cache lookup, the refusal and the+recovery. Typical light-container fix: `npm install puppeteer-core@24` (Node below 22.12 cannot use 25.x), `npx @puppeteer/browsers install chrome@stable --path ~/.cache/puppeteer`, then the apt library list doctor prints (note: `libasound2t64` on Ubuntu 24.04). Re-run doctor until it passes,
doctor.mjs+15−25@@ -16,35 +16,26 @@ import { fileURLToPath } from 'url'; const DIR = path.dirname(fileURLToPath(import.meta.url)); -// ---- shared discovery (render-hero.mjs imports these; keep them in sync-proof) ------export function puppeteerCandidates() {- return [- process.env.PUPPETEER_CORE,- path.join(DIR, 'node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js'),- '/home/adom/project/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',- homedir() + '/node_modules/puppeteer-core/lib/puppeteer/puppeteer-core.js',- ].filter(Boolean);-}+// ---- shared discovery: renderer-paths.cjs is the one list (hero-studio#58);+// render-hero.mjs and server.js read the same file ----+import { createRequire } from 'module';+const RP = createRequire(import.meta.url)('./renderer-paths.cjs');+export const puppeteerCandidates = RP.puppeteerCandidates;+export const puppeteerInstallCommand = RP.puppeteerInstallCommand;+export const quickRendererStatus = RP.quickStatus; export async function loadPuppeteer() { for (const c of puppeteerCandidates()) { try { return (await import(c)).default; } catch {} }- try { return (await import('puppeteer-core')).default; } catch {}- throw new Error('puppeteer-core not found. Set PUPPETEER_CORE to its puppeteer-core.js path.');+ if (!RP.ISOLATED) { try { return (await import('puppeteer-core')).default; } catch {} }+ throw new Error('puppeteer-core not found. Fix: ' + RP.puppeteerInstallCommand()); } export function findChrome() {- if (process.env.CHROME && existsSync(process.env.CHROME)) return process.env.CHROME;- const base = homedir() + '/.cache/puppeteer/chrome';- if (existsSync(base)) {- for (const d of readdirSync(base).sort().reverse()) {- const p = base + '/' + d + '/chrome-linux64/chrome';- if (existsSync(p)) return p;- }- }- throw new Error('Chrome not found. Set CHROME to a Chrome/Chromium binary path.');+ const c = RP.chromePath();+ if (c) return c;+ throw new Error('Chrome not found. Set CHROME to a Chrome/Chromium binary path, or: ' + RP.chromeInstallCommand()); } // ---- checks ----@@ -57,7 +48,7 @@ const run = (cmd, args, timeout = 15000) => new Promise((res) => { function puppeteerEngineFit() { // puppeteer-core 25.x imports fine on Node 18 and then fails at launch, so // the mismatch never surfaces where you would look. Check engines up front.- for (const c of puppeteerCandidates().map((p) => p.replace(/lib\/puppeteer\/puppeteer-core\.js$/, 'package.json'))) {+ for (const c of RP.puppeteerPackages()) { try { const pkg = JSON.parse(readFileSync(c, 'utf8')); const want = pkg.engines && pkg.engines.node;@@ -89,8 +80,7 @@ export async function runChecks() { if (!eng.fit) engineWarn = `engines want node ${eng.want}; this is ${process.versions.node}`; pass('puppeteer-core', 'resolves' + (eng.version ? ` (${eng.version})` : '') + (engineWarn ? `; warning: ${engineWarn}, trusting the launch check` : '')); } catch (e) {- fail('puppeteer-core', e.message,- `cd ${DIR} && npm install puppeteer-core@24 # 24.x supports Node 18; 25.x wants >=22.12`);+ fail('puppeteer-core', e.message, RP.puppeteerInstallCommand()); } // 2. a Chrome binary exists@@ -139,7 +129,7 @@ export async function runChecks() { } catch (e) { fail('headless launch', String(e && e.message || e).split('\n')[0], engineWarn- ? `cd ${DIR} && npm install puppeteer-core@24 # ${engineWarn}; the pin is the first fix to try`+ ? RP.puppeteerInstallCommand() + ' # ' + engineWarn + '; the pin is the first fix to try' : 'read the launch error; check PUPPETEER_CORE/CHROME env overrides and OS libraries'); } }
install.sh+17@@ -7,6 +7,23 @@ echo "installed: hero-studio" echo "run 'hero-studio' (serves on port 8846), then open /proxy/8846/ in a webview" echo "refresh the sample data with ./refresh-data.sh (needs the adom-wiki CLI)" +# hero-studio#58: a package update replaces this directory, node_modules+# included, so puppeteer-core lives OUTSIDE it (~/.cache/hero-studio) and an+# update cannot take the renderer away from a running studio. Installed here+# when nothing resolves; the pin follows this Node (24.x below 22.12).+RH="$HOME/.cache/hero-studio"+if node -e "process.exit(require('$PWD/renderer-paths.cjs').puppeteerPath() ? 0 : 1)" 2>/dev/null; then+ echo "renderer dependency: puppeteer-core resolves"+else+ PIN="$(node -e "console.log(require('$PWD/renderer-paths.cjs').puppeteerPin())")"+ mkdir -p "$RH"+ if command -v npm >/dev/null 2>&1 && npm install --prefix "$RH" --no-audit --no-fund --loglevel=error "puppeteer-core@$PIN" >/dev/null 2>&1; then+ echo "renderer dependency: installed puppeteer-core@$PIN into $RH (outside the package dir; updates keep it)"+ else+ echo "!! renderer dependency: could not install puppeteer-core@$PIN; run: npm install --prefix $RH puppeteer-core@$PIN"+ fi+fi+ # Renderer preflight, warn mode: install stays exit 0 (driving the studio and # fixing deps later are legitimate on light containers), but the message must # say which renderer actually verified. "OK" while local Chrome was missing is
package.json+4−3@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.21.2",+ "version": "0.21.3", "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is fixed for all. The floating app window is the showcase: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.", "license": "MIT", "tags": [@@ -65,7 +65,8 @@ "install": "./install.sh", "uninstall": "./uninstall.sh", "test:brand": "node test/brand.mjs",- "test:render": "node test/render-view.mjs"+ "test:render": "node test/render-view.mjs",+ "test:renderer": "node test/renderer.mjs" }, "needs_sudo": false, "hero": {@@ -82,6 +83,7 @@ "pose.mjs", "drive.mjs", "doctor.mjs",+ "renderer-paths.cjs", "bridge.mjs", "capture.mjs", "bin/hero-studio",@@ -98,4 +100,3 @@ "screenshots/hero.png" ] }-
page.json+4−2@@ -2,7 +2,7 @@ "slug": "hero-studio", "title": "Hero Studio", "type": "app",- "version": "0.21.2",+ "version": "0.21.3", "description": "The hero-image lab with a human gate: `hero-studio pose <owner/slug>` opens the studio in front of the human, posed for their package, and blocks until they click Generate, producing the provenance-stamped 2000x1250 PNG the registry's hero gate requires. The image carries the title and artwork; the exported image also bakes the type pill (top right) and full wiki slug (bottom right); the page shows the hero bare, and only the homepage marquee overlays its own dynamic info. Every hero wears its PAGE OWNER's brand guide (<owner>/brand): colors, type, logo and background themes, Adom's included; an unresolved guide holds unbranded rather than borrowing Adom's look. Placement is fixed for all. The floating app window is the showcase: it auto-loads a real screenshot from the package's wiki page (screenshots/, docs/), takes a manual import, or a full-hero import (the human-ratified re-stamp path for existing heroes). The pose gate opens in verification mode: per-item checklist (title, subtitle, showcase, type match) and a Generate that unlocks only fully confirmed. Includes showcase bleeds, brand tile themes, gradient angles, pixel-faithful wiki marquee and index-card previews, and a component-hero mode with a photo/raytrace/phong fallback ladder.", "license": "MIT", "tags": [@@ -65,7 +65,8 @@ "install": "./install.sh", "uninstall": "./uninstall.sh", "test:brand": "node test/brand.mjs",- "test:render": "node test/render-view.mjs"+ "test:render": "node test/render-view.mjs",+ "test:renderer": "node test/renderer.mjs" }, "needs_sudo": false, "hero": {@@ -83,6 +84,7 @@ "pose.mjs", "drive.mjs", "doctor.mjs",+ "renderer-paths.cjs", "bridge.mjs", "capture.mjs", "bin/hero-studio",
public/index.html+2@@ -177,6 +177,7 @@ <label class="chk" style="display:none"><input type="checkbox" id="chk-author"></label> <button class="btn btn-primary" id="btn-generate" data-tip="Render the current hero to a 2000x1250 PNG on the server, with provenance metadata">Generate hero PNG</button> <button class="btn" id="btn-reset">Reset to proposed default</button>+ <div id="renderer-line" class="hint"></div> </div> <div id="stage">@@ -359,6 +360,7 @@ <button class="btn btn-primary" id="wbtn-generate" style="display:none">Generate hero PNG</button> <button class="btn small btn-primary" id="wbtn-next">Next</button> </div>+ <div id="wrenderer" class="hint" style="display:none"></div> <div id="wcardsrow"> <button class="warrow" id="warr-l">‹</button> <div id="wcards"></div>
public/js/app.js+38@@ -242,7 +242,45 @@ function fitWindowToShowcase() { win.style.height = (bodyH + tbh).toFixed(2) + 'px'; } +/// hero-studio#58: what will render the next Generate, live from the server+/// (refreshed before every Generate and once a minute). A dead renderer+/// disables the gate buttons and names the fix; a newer package on disk+/// under this running server says so, since only a restart picks it up.+function rendererDead() {+ const rs = state.renderer;+ return !!(rs && !rs.local.ok && !rs.pup.ok);+}+function renderRendererLine() {+ const rs = state.renderer;+ const dead = rendererDead();+ let txt = '';+ if (rs) {+ if (rs.local.ok) txt = 'renderer: local headless Chrome in this container';+ else if (rs.pup.ok) txt = 'renderer: host browser via pup (local ' + rs.local.why + '; for full fidelity: ' + rs.local.fix + ')';+ else txt = 'RENDERER NOT READY, Generate will fail: ' + rs.local.why + '; pup fallback: ' + rs.pup.why + '. Fix: ' + (rs.local.fix || 'hero-studio doctor') + ';+ if (rs.onDisk) txt += ' | hero-studio ' + rs.onDisk + ' is installed on disk; this studio runs ' + rs.running + ': restart it to pick up the update';+ }+ // the classic panel line always says what will render+ const el = $('renderer-line');+ if (el) { el.textContent = txt; el.classList.toggle('bad', dead); }+ // the wizard is what the human sees: its line appears only when there is+ // something to act on (dead renderer, pup fallback, newer package on disk)+ const wl = $('wrenderer');+ if (wl) {+ const noteworthy = !!rs && (dead || !rs.local.ok || !!rs.onDisk);+ wl.style.display = noteworthy ? '' : 'none';+ wl.textContent = txt;+ wl.classList.toggle('bad', dead);+ }+ ['btn-generate', 'v-generate'].forEach((id) => {+ const b = $(id);+ if (!b) return;+ if (dead) { b.disabled = true; b.dataset.rdead = '1'; }+ else if (b.dataset.rdead) { delete b.dataset.rdead; b.disabled = false; }+ });+} function render() {+ renderRendererLine(); if (!state || !pages.length) return; if (window.wizardRender) { try { wizardRender(); } catch (e) { console.error('wizard render:', e); } } const p = pageByKey(state.page);
public/js/wizard.js+3−2@@ -843,8 +843,9 @@ function wizardRender() { gbtn.style.display = s.review ? '' : 'none'; if (s.review) { wizApplySeasonedPretick(); // must precede the disabled derivation below- gbtn.disabled = !wizAllConfirmed();- gbtn.title = gbtn.disabled ? 'Confirm the four checks first' : '';+ const dead = typeof rendererDead === 'function' && rendererDead();+ gbtn.disabled = !wizAllConfirmed() || dead;+ gbtn.title = dead ? 'no renderer: see the renderer line under Generate' : (gbtn.disabled ? 'Confirm the four checks first' : ''); } const cards = $('wcards');
public/studio.css+2@@ -102,6 +102,8 @@ label.chk { display:flex; align-items:center; gap:8px; padding:5px 0; cursor:poi label.chk input { accent-color:var(--accent); width:14px; height:14px; cursor:pointer; } .hint { font-size:11.5px; color:var(--text-2); line-height:1.45; margin-top:6px; }+#renderer-line.bad, #wrenderer.bad { color:#ff7b72; font-weight:600; }+#wrenderer { padding:4px 14px 0; font-size:11.5px; } .btn { width:100%; margin-top:8px; padding:8px 10px; border-radius:8px; border:1px solid var(--border);
renderer-paths.cjsadded+112@@ -0,0 +1,112 @@+// Where the local renderer's pieces live, shared by doctor.mjs (the full+// preflight), server.js (the cheap status the gate shows) and install.sh.+// One list, so a path added here is seen by every reader (hero-studio#58).+//+// puppeteer-core is installed OUTSIDE the package directory, under+// ~/.cache/hero-studio, because a package update replaces the package+// directory including its node_modules, which is how a running studio lost+// its renderer between a passing doctor and the human's Generate click.+const fs = require('fs');+const path = require('path');+const os = require('os');++const DIR = __dirname;+const RENDERER_HOME = process.env.HERO_STUDIO_RENDERER_HOME || path.join(os.homedir(), '.cache', 'hero-studio');+// Test-only: consult nothing but the env override and RENDERER_HOME, so a+// box that has puppeteer-core in a shared node_modules can still exercise+// the "no renderer" path.+const ISOLATED = process.env.HERO_STUDIO_RENDERER_ISOLATED === '1';++// Package roots, in the order they win. The entry file inside differs by+// line (25.x: lib/puppeteer/puppeteer-core.js; 24.x: lib/esm/puppeteer/...),+// so it is read from each package.json rather than assumed.+function puppeteerRoots() {+ const list = [path.join(RENDERER_HOME, 'node_modules', 'puppeteer-core')];+ if (!ISOLATED) {+ list.push(path.join(DIR, 'node_modules', 'puppeteer-core'), '/home/adom/project/node_modules/puppeteer-core', path.join(os.homedir(), 'node_modules', 'puppeteer-core'));+ }+ return list;+}++function entryOf(root) {+ try {+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));+ const ex = pkg.exports && pkg.exports['.'];+ const rel = (ex && (typeof ex === 'string' ? ex : (ex.import || ex.default || ex.require))) || pkg.main;+ const f = rel ? path.join(root, rel) : null;+ return f && fs.existsSync(f) ? f : null;+ } catch { return null; }+}++// Entry files worth trying, best first: the env override (a file), then each+// root's own entry.+function puppeteerCandidates() {+ const list = [];+ if (process.env.PUPPETEER_CORE) list.push(process.env.PUPPETEER_CORE);+ for (const r of puppeteerRoots()) { const e = entryOf(r); if (e) list.push(e); }+ return list;+}++// package.json paths for the same roots (the engines check reads them).+function puppeteerPackages() {+ return puppeteerRoots().map((r) => path.join(r, 'package.json')).filter((f) => fs.existsSync(f));+}++// The first puppeteer-core entry file that exists, else the package as Node+// resolves it from here, else null.+function puppeteerPath() {+ for (const c of puppeteerCandidates()) if (fs.existsSync(c)) return c;+ if (ISOLATED) return null;+ try { return require.resolve('puppeteer-core'); } catch { return null; }+}++function chromePath() {+ if (process.env.CHROME && fs.existsSync(process.env.CHROME)) return process.env.CHROME;+ if (ISOLATED) return null;+ const base = path.join(os.homedir(), '.cache', 'puppeteer', 'chrome');+ if (fs.existsSync(base)) {+ for (const d of fs.readdirSync(base).sort().reverse()) {+ const p = path.join(base, d, 'chrome-linux64', 'chrome');+ if (fs.existsSync(p)) return p;+ }+ }+ return null;+}++// puppeteer-core 25.x wants Node >= 22.12 (it imports on 18 and then fails at+// launch); 24.x is the last line that runs on older Node.+function puppeteerPin(nodeVersion) {+ const [a, b] = String(nodeVersion || process.versions.node).split('.').map(Number);+ return a > 22 || (a === 22 && b >= 12) ? 'latest' : '24';+}++function puppeteerInstallCommand() {+ return 'npm install --prefix ' + RENDERER_HOME + ' --no-audit --no-fund puppeteer-core@' + puppeteerPin() ++ ' # outside the package dir, so a package update cannot remove it; no restart needed';+}++function chromeInstallCommand() {+ return 'npx @puppeteer/browsers install chrome@stable --path ' + path.join(os.homedir(), '.cache', 'puppeteer');+}++// The cheap, synchronous status the gate shows: files exist, nothing is+// launched. doctor.mjs is the ground truth (it launches Chrome); this is what+// can run before every Generate and once a minute without cost.+function quickStatus() {+ const pptr = puppeteerPath();+ const chrome = chromePath();+ const proxied = !!process.env.VSCODE_PROXY_URI;+ return {+ local: {+ ok: !!(pptr && chrome),+ puppeteer: pptr,+ chrome,+ why: !pptr ? 'puppeteer-core not found' : (!chrome ? 'Chrome not found' : null),+ fix: !pptr ? puppeteerInstallCommand() : (!chrome ? chromeInstallCommand() : null),+ },+ pup: { ok: proxied, why: proxied ? null : 'VSCODE_PROXY_URI is not set, so the host browser cannot reach the studio' },+ checkedAt: Date.now(),+ };+}++module.exports = { DIR, RENDERER_HOME, ISOLATED, puppeteerRoots, puppeteerCandidates, puppeteerPackages, puppeteerPath, chromePath, puppeteerPin, puppeteerInstallCommand, chromeInstallCommand, quickStatus };
server.js+41−1@@ -42,6 +42,29 @@ const PAGES = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'pages.jso // The provenance stamp reads the REAL version so it stays truthful across // releases (hero-studio#2: a hardcoded '0.5.0' cost Arav a wrong-release chase). const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;+const RP = require('./renderer-paths.cjs');+// hero-studio#58: the renderer status the gate shows. Cheap (files exist,+// nothing launched), refreshed before every Generate and once a minute, so a+// package update that removed puppeteer-core under a running studio shows up+// at the gate instead of as two failing render legs. `onDisk` names a newer+// package version installed under this running one (restart to pick it up).+function rendererStatus() {+ const s = RP.quickStatus();+ let onDisk = null;+ try {+ const v = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')).version;+ if (v !== PKG_VERSION) onDisk = v;+ } catch {}+ return { ...s, running: PKG_VERSION, onDisk };+}+function refreshRenderer() {+ const next = rendererStatus();+ const strip = (x) => x ? JSON.stringify({ ...x, checkedAt: 0 }) : '';+ const changed = strip(state.renderer) !== strip(next);+ state.renderer = next;+ if (changed) { try { broadcast('state', state); } catch {} }+ return next;+} const COMPONENTS = JSON.parse(fs.readFileSync(path.join(__dirname, 'data', 'components.json'), 'utf8')); let state = {@@ -58,6 +81,7 @@ let state = { // the PAGE OWNER's colors, type and logo, never Adom's for someone else, so // this replaces the two free-floating accent/bg fields it used to carry. guide: null, // { owner, status, sha, version, brand, assets, can_edit, editable_by, url }+ renderer: null, // hero-studio#58: { local:{ok,why,fix}, pup:{ok,why}, running, onDisk, checkedAt } brandEdits: {}, // staged Brand-step edits, dot path -> value, until a human confirms verify: false, // the pose gate turns this on: verification chrome + checklist confirm: { title: false, subtitle: false, showcase: false, type: false },@@ -309,6 +333,7 @@ if (!KEEP_ALIVE) { if (Date.now() - lastActivity > TTL_HOURS * 3600e3) reap(); }, 60e3).unref(); }+setInterval(refreshRenderer, 60e3).unref(); process.on('SIGTERM', () => { deregisterInstance(); process.exit(0); }); process.on('SIGINT', () => { deregisterInstance(); process.exit(0); }); @@ -1377,6 +1402,18 @@ function route(req, res, url, p) { return refuse('hard-fail layout findings lock Generate', 'fix the failing items in state.lint first; they lock the human gate and automated mode alike'); }+ // hero-studio#58: nothing can render -> say so now, with the fix, instead+ // of letting the click walk through two failing legs. A pinned leg is the+ // caller's choice and still runs.+ if (!pinned) {+ const rs = refreshRenderer();+ if (!rs.local.ok && !rs.pup.ok) {+ res.writeHead(200, { 'Content-Type': 'application/json' });+ return res.end(JSON.stringify({ ok: false, errorCode: 'RENDERER_NOT_READY',+ error: 'no renderer: ' + rs.local.why + '; pup fallback: ' + rs.pup.why,+ hint: (rs.local.fix || "run 'hero-studio doctor'") + '; then retry, the renderer is spawned per Generate' }));+ }+ } // hero-studio#51: the AUTOMATED path must not trust findings measured // against a pose that no longer exists. The human gate is exempt: human // eyes are at that gate by definition.@@ -1514,7 +1551,9 @@ function route(req, res, url, p) { // Renderer ladder: local headless Chrome first (full fidelity), then the // host browser via pup for containers that cannot run Chrome (#7). const attempts = [];- if (pinned !== 'pup') {+ // a local leg known to be missing its pieces is skipped, not tried (#58)+ const localKnownDead = !!(state.renderer && !state.renderer.local.ok);+ if (pinned !== 'pup' && (pinned === 'local' || !localKnownDead)) { attempts.push({ script: 'render-hero.mjs', url: 'http://localhost:' + PORT + '/?render=1', label: 'local', timeout: 60000 }); } const proxyBase = (process.env.VSCODE_PROXY_URI || '').replace('{{port}}', String(PORT));@@ -1701,6 +1740,7 @@ try { } } catch {} +refreshRenderer(); server.listen(PORT, () => { PORT = server.address().port; writeRegistry();