10 Commits
Author SHA1 Message Date
christianandClaude Opus 5.5 3891a027f3 Release 1.4.41
Browser tab: no more blue outline flashing around the search box while typing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:31:15 -04:00
christianandClaude Opus 5.5 e1f541d366 Browser tab: no focus highlight on the page
Every key on the app's keyboard takes focus from the page for a moment and
gives it back, so the site's focus ring (a 2px blue outline on the search
box, and a brighter border) flashed with each letter. Outline rings are
turned off with the page's clean-up CSS, and the guest preload strips
outline, border and box-shadow from the site's own :focus / :focus-within /
:focus-visible rules, keeping anything else those rules do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:28:59 -04:00
christianandClaude Opus 5.5 0f9c468b1e Release 1.4.40
Browser tab icon no longer looks selected when you're on another tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:27:24 -04:00
christianandClaude Opus 5.5 ede79f5c57 Browser tab icon dims like the others when the tab isn't selected
The globe was missing from the rule that dims tab icons at rest, so it
always showed at full brightness and looked like the active tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:25:35 -04:00
christianandClaude Opus 5.5 8ddbee199b Release 1.4.39
Browser tab: the keyboard opens right under the search box, and Enter
searches and puts it away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:20:34 -04:00
christianandClaude Opus 5.5 9d63e00632 Browser tab keyboard: open under the field being typed into; Enter hides it
The stage TV is portrait, and the keyboard opened at the bottom of the screen — a
reach to the floor to type into a search box at the top.

- browser-guest-preload.cjs reports the tapped field's position (and which field it
  is) with the tap, and again when focus moves to another field.
- browser-tab.js opens the keyboard directly under that field, centred on it and
  kept on screen; if there is no room under it, just above it. Focus moving to a
  different field (Tab, the next memorize blank) moves the keyboard with it.
  Re-tapping the same field leaves a dragged keyboard where it was.
- The keyboard's Enter submits to the page and then hides the keyboard, so search
  results are not covered. Tapping a field brings it back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:17:16 -04:00
christianandClaude Opus 5.5 94ec22d466 Release 1.4.38
Installs over the damaged 1.4.35-1.4.37 uninstallers, and ships a valid one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:09:32 -04:00
christianandClaude Opus 5.5 e7709e8756 Fix the damaged uninstaller that blocked every update from 1.4.35 on
On the TV the update stopped at "Bible App cannot be closed", and uninstalling
from Windows Settings showed "Installer integrity check has failed". The cause is
the uninstaller, not anything running.

An update runs the PREVIOUS version's uninstaller first. Versions 1.4.35-1.4.37
were built on macOS, where electron-builder can't run NSIS to write the uninstaller
and rebuilds it in JavaScript (UninstallerReader) from the installer's stub — without
NSIS's icon patch. makensis computes the uninstaller's CRC over the stub WITH the
uninstaller icon in it, so whenever the two icons differ the result fails NSIS's
integrity check. With no app icon configured, NSIS uses its two different default
icons, so every Mac-built uninstaller was damaged. Verified with a reimplementation
of NSIS's loadHeaders() check: the shipped installers pass; the captured uninstaller
failed (0x4235c8f5 vs stored 0x90f7de2c) and passes with this fix (0xb37e533c).

- build/installerIcon.ico: NSIS's own default install icon, so electron-builder uses
  ONE icon for installer and uninstaller (nothing looks different; the app's icon is
  unchanged). tools/build-win.cjs refuses to build without it.
- build/installer.nsh: for installs of 1.4.35-1.4.37 only, clear the old
  UninstallString so the damaged uninstaller is not run; the new files are copied over
  the old ones and a fresh, valid uninstaller is written. Every other version keeps
  the normal uninstall-then-install path.
- BUILD-AND-DEPLOY.md: why the icon file must stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 21:07:41 -04:00
christianandClaude Opus 5.5 478763c60e Release 1.4.37
The Browser tab, with an installer that closes everything running from the app folder
before copying (the 1.4.36 install stopped at "cannot be closed").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:39:44 -04:00
christianandClaude Opus 5.5 f0af453020 Installer: close everything running from the app's folder before copying
Updating 1.4.35 -> 1.4.36 on the TV stopped at "Bible App cannot be closed. Please
close it manually and click Retry" with the app plainly closed. That message also
comes from the installer's copy step (and from removing the old version) when any
file in the install folder is in use — not only when Bible App.exe is running.

The app runs programs from its own folder: the local AI server (llama-server.exe),
speech (whisper-cli.exe), the upscaler. electron-builder's stock check closes only
Bible App.exe, and when it has to force-close the app, the app's own shutdown (which
stops the AI server) never runs. The orphaned llama-server.exe then holds its files
open forever, so Retry can never succeed.

- build/installer.nsh: customCheckAppRunning replaces the stock check. It asks the
  app to close, force-closes any Bible App.exe left, force-closes the helpers by name,
  then, as a backstop, every process whose executable is inside the install folder
  (via WMI, which works from the 32-bit installer on 64-bit Windows; the folder is
  passed in an environment variable and a bare drive is refused). It runs before the
  old version is removed and before any file is copied.
- main.cjs: the in-app updater stops the AI server before launching the installer.

The fix is in the INSTALLER, so the TV still on 1.4.35 gets it with this release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:37:14 -04:00
10 changed files with 228 additions and 21 deletions
+5 -3
View File
@@ -1,6 +1,6 @@
# Browser tab # Browser tab
One web page, full screen in the tab: no address bar, bookmarks or scrollbars — just the page, in the One web page, full screen in the tab: no address bar, bookmarks, scrollbars or focus highlights — just the page, in the
same area the Graphics tab uses. It loads when the app starts and keeps running while other tabs are same area the Graphics tab uses. It loads when the app starts and keeps running while other tabs are
showing, so it is ready the moment it is opened. Built for It Is Written showing, so it is ready the moment it is opened. Built for It Is Written
(`https://it-is-written-waters-church.vercel.app/`), but any web page works. (`https://it-is-written-waters-church.vercel.app/`), but any web page works.
@@ -10,7 +10,7 @@ showing, so it is ready the moment it is opened. Built for It Is Written
| Do this | What happens | | Do this | What happens |
|---|---| |---|---|
| Drag a finger | The page scrolls. | | Drag a finger | The page scrolls. |
| Tap a text box on the page | The app's own keyboard (the Prayer tab's) comes up and types into it. Enter submits; the keyboard puts itself away when the page moves on (e.g. after a search). × closes it; tap the box again to bring it back. | | Tap a text box on the page | The app's own keyboard (the Prayer tab's) comes up right under the box (above it if the box is near the bottom of the screen — the stage TV is portrait) and types into it. Enter submits and puts the keyboard away; it also goes away when the page moves on. × closes it; tap the box again to bring it back. |
| Tap a scripture reference (e.g. "Isaiah 41:10") | It opens in a new Bible tab, at that verse, in the translation printed beside it (NIV, ESV…) when that translation is switched on. | | Tap a scripture reference (e.g. "Isaiah 41:10") | It opens in a new Bible tab, at that verse, in the translation printed beside it (NIV, ESV…) when that translation is switched on. |
| Tap anything else | Works on the page as normal. | | Tap anything else | Works on the page as normal. |
| Leave the tab and come back | The page is exactly as it was: same search, same place, same typing. | | Leave the tab and come back | The page is exactly as it was: same search, same place, same typing. |
@@ -65,7 +65,9 @@ the search and could put different results on the program feed.
- **`browser-guest-preload.cjs`** runs inside the page (invisible to the site) and reports what the app - **`browser-guest-preload.cjs`** runs inside the page (invisible to the site) and reports what the app
cannot see from outside: a tapped text field, a tapped reference (found with the app's own cannot see from outside: a tapped text field, a tapped reference (found with the app's own
`scripture-scan.js` — the same rules ProPresenter Sync uses), scroll, activity, five fingers, and — only `scripture-scan.js` — the same rules ProPresenter Sync uses), scroll, activity, five fingers, and — only
while a follower is linked — a script-free copy of the page's DOM. while a follower is linked — a script-free copy of the page's DOM. It also takes the look (outline,
border, glow) out of the site's focus styles: every key on the app's keyboard takes focus from the page
for a moment, so a focus highlight would flash with each letter typed.
- **`browser-tab.js`** owns the tab: the pages, the keyboard (via `createPrayerKeyboard` with a "sink"), - **`browser-tab.js`** owns the tab: the pages, the keyboard (via `createPrayerKeyboard` with a "sink"),
scripture taps, scrolling for the phone / OSC / admin, and the follower's copy. On the follower it scripture taps, scrolling for the phone / OSC / admin, and the follower's copy. On the follower it
shows that copy in sandboxed frames (no scripts); in a plain browser (admin offstage copy, `npm start`) shows that copy in sandboxed frames (no scripts); in a plain browser (admin offstage copy, `npm start`)
+6
View File
@@ -70,6 +70,12 @@ Notes:
for you. The TV's updater compares versions numerically and only offers a HIGHER version. for you. The TV's updater compares versions numerically and only offers a HIGHER version.
- If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment, - If `WIN_CSC_LINK` / `CSC_LINK` / `CSC_KEY_PASSWORD` / `CSC_KEYCHAIN` exist in your environment,
`build-win.cjs` removes them for the build (electron-builder aborts on an EMPTY `WIN_CSC_LINK`). `build-win.cjs` removes them for the build (electron-builder aborts on an EMPTY `WIN_CSC_LINK`).
- **Keep `build/installerIcon.ico`.** On macOS electron-builder rebuilds the uninstaller in JavaScript
and leaves out NSIS's icon patch; if the installer and uninstaller icons differ, the uninstaller fails
NSIS's integrity check, and that version can then never be updated or uninstalled. This file makes
both use one icon (NSIS's own default, so nothing looks different). `build-win.cjs` refuses to build
without it. Versions 1.4.35–1.4.37 shipped without it; `build/installer.nsh` installs over their
damaged uninstallers instead of running them.
- On Windows, if the build fails while extracting `winCodeSign` (symlink / permission errors), run the - On Windows, if the build fails while extracting `winCodeSign` (symlink / permission errors), run the
terminal as administrator or turn on Developer Mode, delete terminal as administrator or turn on Developer Mode, delete
`%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`, and build again. `%LOCALAPPDATA%\electron-builder\Cache\winCodeSign`, and build again.
+83 -2
View File
@@ -127,6 +127,19 @@ function restoreOsKeyboard(el) {
if (v === null) el.removeAttribute('inputmode'); else el.setAttribute('inputmode', v); if (v === null) el.removeAttribute('inputmode'); else el.setAttribute('inputmode', v);
} }
/*
* Where the field is, so the app can open its keyboard right under it: the stage TV is portrait, and a
* keyboard parked at the bottom of a 75" screen is a reach to the floor. `field` tells one field from
* another, so focus moving to a different one (Tab, the next memorize blank) can move the keyboard too.
*/
let fieldSeq = 0;
const fieldIds = new WeakMap();
function fieldInfo(el) {
if (!fieldIds.has(el)) fieldIds.set(el, ++fieldSeq);
const r = el.getBoundingClientRect();
return { field: fieldIds.get(el), rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } };
}
/* /*
* Only a TAP raises the keyboard. A page that focuses its own search box as it loads must not throw a * Only a TAP raises the keyboard. A page that focuses its own search box as it loads must not throw a
* keyboard over the stage while nobody is touching it; focus moving between fields (Tab, the next * keyboard over the stage while nobody is touching it; focus moving between fields (Tab, the next
@@ -137,10 +150,13 @@ window.addEventListener('pointerdown', (e) => {
const el = editableFrom(e.target); const el = editableFrom(e.target);
if (!el) return; if (!el) return;
quietOsKeyboard(el); quietOsKeyboard(el);
send('bibleapp:editable', { on: true, tap: true }); send('bibleapp:editable', Object.assign({ on: true, tap: true }, fieldInfo(el)));
}, true); }, true);
document.addEventListener('focusin', (e) => { document.addEventListener('focusin', (e) => {
send('bibleapp:editable', { on: isEditable(e.target), tap: false }); const el = e.target;
if (!isEditable(el)) { send('bibleapp:editable', { on: false, tap: false }); return; }
// Measured a frame later: focusing can scroll the field into view first.
requestAnimationFrame(() => send('bibleapp:editable', Object.assign({ on: true, tap: false }, fieldInfo(el))));
}, true); }, true);
document.addEventListener('focusout', (e) => { document.addEventListener('focusout', (e) => {
restoreOsKeyboard(e.target); restoreOsKeyboard(e.target);
@@ -151,6 +167,71 @@ document.addEventListener('focusout', (e) => {
}, 0); }, 0);
}, true); }, true);
// ------------------------------------------------------------------ no focus highlight
/*
* Nothing on the page shows that it has focus. The app's keyboard is outside the page, so every key press
* takes focus from the page for a moment and gives it straight back: a site's focus style would flash
* with each letter — and on stage it is clutter anyway. Outline rings (the browser's own and any site's)
* go with one rule in browser-tab.js's CLEAN_CSS; this takes the rest — a border that brightens, a glow —
* out of the site's own :focus / :focus-within / :focus-visible rules, and leaves everything else those
* rules do: a menu that opens on focus still opens. Sites add rules as they go (CSS-in-JS, late
* stylesheets), so this runs again just before anything can take focus. Another origin's stylesheets
* cannot be read and keep theirs; the outline rule still covers their rings.
*/
const FOCUS_LOOK = /^(?:outline|box-shadow|border)(?:-|$)/;
const seenRules = new WeakSet();
// Split at commas that are not inside brackets or quotes: "a:hover, b:is(c, d)" -> 2; "x:1; y:f(a;b)" -> 2.
function splitTop(text, sep) {
const out = [];
let depth = 0, quote = '', start = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quote) { if (c === '\\') i++; else if (c === quote) quote = ''; continue; }
if (c === '"' || c === "'") quote = c;
else if (c === '(' || c === '[') depth++;
else if (c === ')' || c === ']') depth--;
else if (c === sep && depth === 0) { out.push(text.slice(start, i).trim()); start = i + 1; }
}
out.push(text.slice(start).trim());
return out.filter(Boolean);
}
// Styled BECAUSE it has focus. ":not(:focus)" styles a field at rest, so it does not count.
function focusDriven(sel) {
return /:focus(?:-within|-visible)?(?![\w-])/.test(sel.replace(/:not\((?:[^()]|\([^()]*\))*\)/g, ''));
}
function isLook(decl) { return FOCUS_LOOK.test(decl.slice(0, decl.indexOf(':')).trim().toLowerCase()); }
function stripFocusLook(list, owner) {
for (let i = 0; i < list.length; i++) {
const r = list[i];
if (r.cssRules && r.cssRules.length) stripFocusLook(r.cssRules, r); // @media, @supports, @layer, nesting
if (!r.selectorText || !r.style || seenRules.has(r)) continue;
seenRules.add(r);
const sels = splitTop(r.selectorText, ',');
const focus = sels.filter(focusDriven);
if (!focus.length) continue;
// Declarations as written (cssText), so a shorthand built on var() survives intact.
const decls = splitTop(r.style.cssText, ';');
const keep = decls.filter((d) => !isLook(d));
if (keep.length === decls.length) continue;
if (focus.length === sels.length) { r.style.cssText = keep.join('; '); continue; }
// Shared with selectors that are not about focus ("a:hover, a:focus"): those keep the whole rule, and
// the focus ones move to a copy without the look.
r.selectorText = sels.filter((s) => !focusDriven(s)).join(', ');
if (!keep.length) continue;
try { owner.insertRule(focus.join(', ') + ' { ' + keep.join('; ') + ' }', i + 1); i++; seenRules.add(list[i]); } catch (_) {}
}
}
function hideFocusLook() {
const sheets = [...document.styleSheets, ...(document.adoptedStyleSheets || [])];
for (const sh of sheets) { try { stripFocusLook(sh.cssRules, sh); } catch (_) {} } // another origin's: unreadable
}
window.addEventListener('DOMContentLoaded', hideFocusLook);
window.addEventListener('load', hideFocusLook);
window.addEventListener('pointerdown', hideFocusLook, true);
document.addEventListener('focusin', hideFocusLook, true);
// ------------------------------------------------------------------ activity + five fingers // ------------------------------------------------------------------ activity + five fingers
let lastActivity = 0; let lastActivity = 0;
function activity() { function activity() {
+46 -14
View File
@@ -41,15 +41,18 @@
var RETRY_MS = 10000; var RETRY_MS = 10000;
/* /*
* The page itself is the only thing on screen: no scrollbars, no blue tap flash, no text selection from * The page itself is the only thing on screen: no scrollbars, no blue tap flash, no focus ring, no text
* a resting finger, and no rubber-band pull that would drag the whole page (or, sideways, swipe back a * selection from a resting finger, and no rubber-band pull that would drag the whole page (or, sideways,
* page). Inputs keep their selection so typing still behaves. Applied to the live page with insertCSS * swipe back a page). Inputs keep their selection so typing still behaves. Applied to the live page with
* and to the booth's copy as a <style>, so both screens look the same. * insertCSS and to the booth's copy as a <style>, so both screens look the same. (The ring matters
* twice: every key on the app's keyboard takes focus from the page for a moment, so a ring would flash
* with each letter. The site's other focus styles are taken out by browser-guest-preload.cjs.)
*/ */
var CLEAN_CSS = var CLEAN_CSS =
'html,body{scrollbar-width:none!important;overscroll-behavior:none!important;}' + 'html,body{scrollbar-width:none!important;overscroll-behavior:none!important;}' +
'::-webkit-scrollbar{display:none!important;width:0!important;height:0!important;}' + '::-webkit-scrollbar{display:none!important;width:0!important;height:0!important;}' +
'*{-webkit-tap-highlight-color:transparent!important;}' + '*{-webkit-tap-highlight-color:transparent!important;}' +
':focus,:focus-visible{outline:none!important;}' +
'body *{-webkit-user-select:none!important;user-select:none!important;-webkit-touch-callout:none!important;}' + 'body *{-webkit-user-select:none!important;user-select:none!important;-webkit-touch-callout:none!important;}' +
'input,textarea,[contenteditable],[contenteditable] *{-webkit-user-select:text!important;user-select:text!important;}'; 'input,textarea,[contenteditable],[contenteditable] *{-webkit-user-select:text!important;user-select:text!important;}';
@@ -360,7 +363,9 @@
// Only the page on screen may raise or drop the keyboard; a background page focusing its own // Only the page on screen may raise or drop the keyboard; a background page focusing its own
// search box as it loads must not. // search box as it loads must not.
if (!d || !isShown(v)) return; if (!d || !isShown(v)) return;
if (d.on && d.tap) showKeyboard(); if (d.on && d.tap) showKeyboard(v, d);
// Focus moved on to a DIFFERENT field while typing (Tab, the next memorize blank): follow it.
else if (d.on && kb && kb.el.style.display === 'block' && d.field && d.field !== kb.field) placeKeyboard(v, d);
else if (!d.on) hideKeyboard(); else if (!d.on) hideKeyboard();
return; return;
case 'bibleapp:activity': case 'bibleapp:activity':
@@ -600,6 +605,9 @@
if (name === 'Enter') v.el.sendInputEvent({ type: 'char', keyCode: '\r' }); if (name === 'Enter') v.el.sendInputEvent({ type: 'char', keyCode: '\r' });
v.el.sendInputEvent({ type: 'keyUp', keyCode: code }); v.el.sendInputEvent({ type: 'keyUp', keyCode: code });
} catch (_) {} } catch (_) {}
// Enter is "done": the search runs and the keyboard gets out of the way of the results. A tap on a
// field brings it back.
if (name === 'Enter') hideKeyboard();
}, },
focus: function () { focus: function () {
var v = activeView(); var v = activeView();
@@ -616,20 +624,44 @@
el.style.display = 'none'; el.style.display = 'none';
document.body.appendChild(el); document.body.appendChild(el);
a.createPrayerKeyboard(el, null, 'browser', sink); a.createPrayerKeyboard(el, null, 'browser', sink);
kb = { el: el, placed: false }; kb = { el: el, field: null };
return kb; return kb;
} }
function showKeyboard() { /*
* The keyboard opens directly under the field being typed into. The stage TV is portrait: anything at
* the bottom of the screen is a reach to the floor, and the page's search box is near the top. If the
* field is too low for the keyboard to fit under it, it goes just above instead. Re-tapping the same
* field while the keyboard is up leaves it wherever it was dragged.
*/
function placeKeyboard(v, d) {
var k = kb;
if (!k || !d || !d.rect || !v || !v.el) return;
var W = v.el.getBoundingClientRect(), z = v.zoom || 1;
var fieldTop = W.top + d.rect.y * z;
var fieldBottom = W.top + (d.rect.y + d.rect.h) * z;
var fieldMid = W.left + (d.rect.x + d.rect.w / 2) * z;
var w = k.el.offsetWidth || 760, h = k.el.offsetHeight || 340, gap = 14;
var y = fieldBottom + gap;
if (y + h > window.innerHeight - 8) {
var above = fieldTop - gap - h;
y = above >= 0 ? above : Math.max(0, window.innerHeight - h - 8);
}
var x = Math.max(0, Math.min(window.innerWidth - w, Math.round(fieldMid - w / 2)));
k.el.style.left = x + 'px';
k.el.style.top = Math.round(y) + 'px';
k.field = d.field || null;
}
function showKeyboard(v, d) {
if (ROLE !== 'stage') return; if (ROLE !== 'stage') return;
var k = keyboard(); var k = keyboard();
if (!k) return; if (!k) return;
k.el.style.display = 'block'; var wasHidden = k.el.style.display !== 'block';
// First time: bottom centre, clear of the page's search box at the top. After that, wherever it was dragged. k.el.style.display = 'block'; // shown before measuring: a hidden element has no size
if (!k.placed) { if (d && d.rect && (wasHidden || d.field !== k.field)) placeKeyboard(v, d);
k.placed = true; else if (wasHidden && !k.field) {
var w = k.el.offsetWidth || 760, hgt = k.el.offsetHeight || 340; // No position from the page (should not happen): centre it rather than leave it anywhere odd.
k.el.style.left = Math.max(0, Math.round((window.innerWidth - w) / 2)) + 'px'; k.el.style.left = Math.max(0, Math.round((window.innerWidth - (k.el.offsetWidth || 760)) / 2)) + 'px';
k.el.style.top = Math.max(0, Math.round(window.innerHeight - hgt - Math.max(24, window.innerHeight * 0.04))) + 'px'; k.el.style.top = Math.max(0, Math.round((window.innerHeight - (k.el.offsetHeight || 340)) / 3)) + 'px';
} }
} }
function hideKeyboard() { if (kb) kb.el.style.display = 'none'; } function hideKeyboard() { if (kb) kb.el.style.display = 'none'; }
+68
View File
@@ -4,3 +4,71 @@
IfFileExists "$INSTDIR\resources\app.asar.unpacked\vendor\redist\vc_redist.x64.exe" 0 +2 IfFileExists "$INSTDIR\resources\app.asar.unpacked\vendor\redist\vc_redist.x64.exe" 0 +2
ExecWait '"$INSTDIR\resources\app.asar.unpacked\vendor\redist\vc_redist.x64.exe" /install /quiet /norestart' $0 ExecWait '"$INSTDIR\resources\app.asar.unpacked\vendor\redist\vc_redist.x64.exe" /install /quiet /norestart' $0
!macroend !macroend
; ---------------------------------------------------------------------------------------------------
; Replaces electron-builder's "is the app running?" check (customCheckAppRunning). Runs at the start of
; installing, BEFORE the old version is removed and before any file is copied.
;
; The stock check only looks for "Bible App.exe". But the app also starts programs from its own folder
; - the local AI server (llama-server.exe), speech (whisper), the upscaler - and if the app is closed
; hard, those are left running. They hold their files open, so copying the new version fails and the
; installer says "Bible App cannot be closed. Please close it manually and click Retry" even though
; the app is plainly closed, and Retry can never succeed. So:
; 1. ask the app to close (it then stops the AI server itself), and give it a moment;
; 2. force-close anything named Bible App.exe that is still there;
; 3. force-close the app's own helper programs by name (plain taskkill, nothing to go wrong);
; 4. as a backstop, force-close EVERY program running from this install folder, whatever its name.
; Step 4 matches by folder, via WMI (works from this 32-bit installer on 64-bit Windows, where a
; 32-bit process cannot read a 64-bit process's path). The folder travels in an environment variable
; so a quote or space in it can never break the command, and it refuses to act on a bare drive.
; No question is asked first: installing this version replaces the app, which is what was asked for.
; ---------------------------------------------------------------------------------------------------
!macro customCheckAppRunning
DetailPrint `Closing "${PRODUCT_NAME}"...`
nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /im "${APP_EXECUTABLE_FILENAME}" /fi "USERNAME eq %USERNAME%"`
Pop $0
Sleep 2500
nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /t /im "${APP_EXECUTABLE_FILENAME}" /fi "USERNAME eq %USERNAME%"`
Pop $0
nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /f /t /im llama-server.exe /im whisper-cli.exe /im realesrgan-ncnn-vulkan.exe /fi "USERNAME eq %USERNAME%"`
Pop $0
System::Call 'Kernel32::SetEnvironmentVariable(t "BIBLEAPP_INSTDIR", t "$INSTDIR")'
StrCpy $1 "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe"
${If} ${FileExists} "$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe"
StrCpy $1 "$WINDIR\Sysnative\WindowsPowerShell\v1.0\powershell.exe"
${EndIf}
StrCpy $2 `"$1" -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command "$$r = $$env:BIBLEAPP_INSTDIR.TrimEnd('\'); if ($$r.Split('\').Count -lt 2) { exit }; $$d = $$r + '\'; Get-CimInstance Win32_Process | Where-Object { $$_.ExecutablePath -and $$_.ExecutablePath.StartsWith($$d, [StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { Stop-Process -Id $$_.ProcessId -Force -ErrorAction SilentlyContinue }"`
DetailPrint `Closing anything else still running from "${PRODUCT_NAME}"'s folder...`
nsExec::Exec $2
Pop $0
Sleep 1000
!insertmacro bibleAppSkipDamagedUninstaller SHELL_CONTEXT
!insertmacro bibleAppSkipDamagedUninstaller HKCU
!macroend
; ---------------------------------------------------------------------------------------------------
; Versions 1.4.35 - 1.4.37 were built on macOS before build/installerIcon.ico existed, and their
; uninstallers are damaged: they fail NSIS's own integrity check ("Installer integrity check has
; failed"). electron-builder rebuilds the uninstaller in JavaScript on macOS and leaves out NSIS's
; icon patch, so whenever the installer and uninstaller icons differ (NSIS's two defaults do), the
; checksum is wrong. An update runs the PREVIOUS version's uninstaller first, so those versions could
; never be updated: the damaged uninstaller fails, and the installer reports "Bible App cannot be
; closed" (it also cannot be uninstalled from Windows Settings).
;
; For exactly those versions, the old uninstaller is not run: its UninstallString is cleared, so
; electron-builder's uninstallOldVersion finds nothing to run, and the new files are copied over the
; old ones. This installer then writes a fresh, valid uninstaller and registry entry. Every other
; version still goes through the normal uninstall-then-install path.
; ---------------------------------------------------------------------------------------------------
!macro bibleAppSkipDamagedUninstaller ROOT
ReadRegStr $3 ${ROOT} "${UNINSTALL_REGISTRY_KEY}" DisplayVersion
${If} $3 == "1.4.35"
${OrIf} $3 == "1.4.36"
${OrIf} $3 == "1.4.37"
DetailPrint `Version $3's uninstaller is damaged; installing over it instead.`
DeleteRegValue ${ROOT} "${UNINSTALL_REGISTRY_KEY}" UninstallString
!ifdef UNINSTALL_REGISTRY_KEY_2
DeleteRegValue ${ROOT} "${UNINSTALL_REGISTRY_KEY_2}" UninstallString
!endif
${EndIf}
!macroend
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+3
View File
@@ -355,6 +355,9 @@ async function createWindow () {
quit: () => { try { app.quit(); } catch (_) {} }, quit: () => { try { app.quit(); } catch (_) {} },
runInstaller: (installerPath) => { runInstaller: (installerPath) => {
// Launch the installer detached, then quit so it can replace the running app. // Launch the installer detached, then quit so it can replace the running app.
// Stop the local AI server FIRST: it runs from the install folder, and one left running holds
// its files open, so the installer cannot copy the new version over them ("cannot be closed").
try { stopLlamaServer(); } catch (_) {}
try { try {
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const child = spawn(installerPath, [], { detached: true, stdio: 'ignore', windowsHide: false }); const child = spawn(installerPath, [], { detached: true, stdio: 'ignore', windowsHide: false });
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "bible-app", "name": "bible-app",
"version": "1.4.36", "version": "1.4.41",
"description": "Minimalist Bible App for Portrait-Oriented Touchscreen TV", "description": "Minimalist Bible App for Portrait-Oriented Touchscreen TV",
"type": "module", "type": "module",
"main": "main.cjs", "main": "main.cjs",
+3 -1
View File
@@ -1547,7 +1547,7 @@ body.drawing * {
} }
/* Removed explicit close button; using hold-to-close gesture on tab */ /* Removed explicit close button; using hold-to-close gesture on tab */
.bible-icon, .media-icon, .whiteboard-icon, .media-tab-icon, .graphics-tab-icon, .maps-tab-icon, .prayer-tab-icon, .alpha-icon, .notes-icon, .ask-tab-icon { .bible-icon, .media-icon, .whiteboard-icon, .media-tab-icon, .graphics-tab-icon, .maps-tab-icon, .browser-tab-icon, .prayer-tab-icon, .alpha-icon, .notes-icon, .ask-tab-icon {
opacity: 0.7; opacity: 0.7;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
@@ -1564,6 +1564,8 @@ body.drawing * {
.tab.active .graphics-tab-icon, .tab.active .graphics-tab-icon,
.tab:hover .maps-tab-icon, .tab:hover .maps-tab-icon,
.tab.active .maps-tab-icon, .tab.active .maps-tab-icon,
.tab:hover .browser-tab-icon,
.tab.active .browser-tab-icon,
.tab:hover .prayer-tab-icon, .tab:hover .prayer-tab-icon,
.tab.active .prayer-tab-icon, .tab.active .prayer-tab-icon,
.tab:hover .alpha-icon, .tab:hover .alpha-icon,
+13
View File
@@ -12,8 +12,14 @@
* - a clean signing environment. There is no code-signing certificate. electron-builder aborts on * - a clean signing environment. There is no code-signing certificate. electron-builder aborts on
* an EMPTY WIN_CSC_LINK ("Env WIN_CSC_LINK is not correct"), so those variables are removed * an EMPTY WIN_CSC_LINK ("Env WIN_CSC_LINK is not correct"), so those variables are removed
* rather than blanked, and identity auto-discovery is turned off. * rather than blanked, and identity auto-discovery is turned off.
* - a working uninstaller. On macOS electron-builder rebuilds the NSIS uninstaller in JavaScript and
* skips NSIS's icon patch, so unless the installer and uninstaller use the SAME icon the uninstaller
* fails NSIS's integrity check — and then that version can never be updated or uninstalled
* (1.4.35-1.4.37 shipped like that). build/installerIcon.ico makes electron-builder use one icon for
* both, so this refuses to build without it.
*/ */
const { spawnSync } = require('child_process'); const { spawnSync } = require('child_process');
const fs = require('fs');
const path = require('path'); const path = require('path');
const env = { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: 'false' }; const env = { ...process.env, CSC_IDENTITY_AUTO_DISCOVERY: 'false' };
@@ -21,6 +27,13 @@ for (const key of ['CSC_LINK', 'WIN_CSC_LINK', 'CSC_KEY_PASSWORD', 'WIN_CSC_KEY_
delete env[key]; delete env[key];
} }
const ICON = path.join(__dirname, '..', 'build', 'installerIcon.ico');
if (!fs.existsSync(ICON)) {
console.error('build/installerIcon.ico is missing. Without it the uninstaller built on macOS is damaged');
console.error('("Installer integrity check has failed") and the version could never be updated. See tools/build-win.cjs.');
process.exit(1);
}
const extra = process.argv.slice(2); const extra = process.argv.slice(2);
const args = ['electron-builder', '--win', '--x64', ...extra]; const args = ['electron-builder', '--win', '--x64', ...extra];