commit 96ac2dec2d7ee178c424c5dc0d8ab2437b39a1f9 Author: Christian Date: Wed Sep 23 15:41:21 2026 -0400 Band Lineup Display, the backstage app itself The Electron app that runs on the backstage PC: stage lineup photos with mic/pack labels, packaged for Windows. It also carries the ProPresenter Now/Next feature in two forms that share one engine — propresenter-core, where every ProPresenter 7/20 quirk lives, and propresenter-app, the standalone display it serves. Until now this existed only on the production machine and as a read-only snapshot inside the ProdDash repo. Source only: the 231 MB dist/ is build output, and credentials, photos and processed images are written to the Electron userData directory, never here. tmp/pp-mock.js stays — it is the ProPresenter mock ProdDash's own CLAUDE.md tells people to run. Co-Authored-By: Claude Opus 5 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..b922d12 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,6 @@ +{ + "permissions": { + "defaultMode": "auto" + }, + "skipAutoPermissionPrompt": true +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..37b322e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,168 @@ +{ + "permissions": { + "allow": [ + "WebSearch", + "WebFetch(domain:openapi.propresenter.com)", + "WebFetch(domain:raw.githubusercontent.com)", + "Read(//private/tmp/**)", + "Bash(curl -sSL --max-time 60 \"https://raw.githubusercontent.com/jeffmikels/ProPresenter-API/master/pro7.9.openapi-spec.json\" -o pp.json)", + "Bash(python3 -)", + "Bash(node --check main.js)", + "Bash(node --check renderer.js)", + "Bash(node --check preload.js)", + "Bash(PP_STEP_MS=400 node tmp/pp-mock.js 1599)", + "Bash(node tmp/pp-verify.js 1599)", + "Bash(pkill -f \"tmp/pp-mock.js\")", + "Bash(PP_STEP_MS=600 node tmp/pp-mock.js 1599)", + "Bash(echo \"mock pid $!\")", + "Bash(curl -N --max-time 3 -s -X POST -H 'Content-Type: application/json' -d '[\"presentation/slide_index\"]' http://127.0.0.1:1599/v1/status/updates)", + "Bash(pkill -f \"tmp/pp-mock.js\" 2>/dev/null; sleep 0.3 *)", + "Bash(echo \"verify exit: $?\")", + "Bash(pkill -f \"electron .*pp-test-profile\")", + "Bash(npm run *)", + "Bash(tee /tmp/pp-build.log)", + "Bash(open -R \"/Users/Studio/Documents/BandLineupDisplay/dist/Band Lineup Setup 1.0.0.exe\")", + "Bash(tee /tmp/pp-build2.log)", + "Bash(tee /tmp/pp-build3.log)", + "Bash(tee /tmp/pp-build4.log)", + "Bash(curl -sSL --max-time 30 \"https://api.github.com/repos/jeffmikels/ProPresenter-API/contents/\")", + "Bash(curl -sSL --max-time 30 \"https://api.github.com/repos/bitfocus/companion-module-renewedvision-propresenter-api/contents/\")", + "Bash(curl -sSL --max-time 30 \"https://api.github.com/repos/bitfocus/companion-module-renewedvision-propresenter-api/contents/src\")", + "Bash(curl -sSL --max-time 30 https://raw.githubusercontent.com/bitfocus/companion-module-renewedvision-propresenter-api/master/src/__TRACKED_VAR__.ts -o comp-__TRACKED_VAR__.ts)", + "Bash(curl -sSL --max-time 30 \"https://raw.githubusercontent.com/bitfocus/companion-module-renewedvision-propresenter-api/master/package.json\")", + "Bash(curl -sSL --max-time 30 \"https://unpkg.com/renewedvision-propresenter@7.7.2/package.json\")", + "Bash(curl -sSL --max-time 30 \"https://unpkg.com/renewedvision-propresenter@7.7.2/dist/index.js\" -o rvpro.js)", + "Bash(grep -noE \"playlistPlaylistIdGet[^\\\\n]{0,120}\" rvpro.js)", + "Bash(grep -noE \"/v1/playlist[^\\\\\"\\\\\\\\\\\\`]{0,60}\" rvpro.js)", + "Bash(curl -sSL --max-time 30 \"https://unpkg.com/renewedvision-propresenter@7.7.2/?meta\")", + "Bash(curl -sSL --max-time 30 \"https://unpkg.com/renewedvision-propresenter@7.7.2/dist/propresenter.js\" -o rvpro2.js)", + "Bash(grep -noE \"playlistPlaylistIdGet[\\\\s\\\\S]{0,200}\" rvpro2.js)", + "Bash(grep -noE \"v1/playlist[^\\\\\"\\\\\\\\\\\\`]{0,80}\" rvpro2.js)", + "Bash(curl -sSL --max-time 30 \"https://registry.npmjs.org/renewedvision-propresenter\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('latest:', d['dist-tags']\\); print\\('all versions:', list\\(d['versions'].keys\\(\\)\\)[-12:]\\)\")", + "Bash(tee /tmp/pp-build5.log)", + "Bash(node --check tmp/pp-mock.js)", + "Bash(PP_STEP_MS=5000 node tmp/pp-mock.js 1599)", + "Bash(node tmp/pp-verify-slides.js 1599)", + "Bash(tee /tmp/pp-build6.log)", + "Skill(update-config)", + "Skill(update-config:*)", + "Bash(echo \"=== ~/.claude/settings.json ===\"; cat ~/.claude/settings.json 2>/dev/null || echo \"\\(none\\)\" *)", + "Bash(PORT=3000 node server.js)", + "Bash(curl -s http://localhost:3000/healthz)", + "Bash(curl -s -X POST http://localhost:3000/api/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"password\":\"?WaT3rs2016!\"}')", + "Bash(rm -f /Users/Studio/Documents/recurring-checklist-app/.claude/launch.json)", + "Bash(rmdir /Users/Studio/Documents/recurring-checklist-app/.claude)", + "Bash(mkdir -p /Users/Studio/Documents/BandLineupDisplay/.claude)", + "mcp__Claude_Preview__preview_start", + "Bash(tee /tmp/pp-build7.log)", + "Bash(tee /tmp/pp-build8.log)", + "Bash(tee /tmp/pp-build9.log)", + "Bash(tee /tmp/pp-build10.log)", + "Bash(node --check server.js)", + "Bash(node --check app.js)", + "WebFetch(domain:api.propresenter.com)", + "Bash(curl -s -o pp-spec.json \"https://raw.githubusercontent.com/jeffmikels/ProPresenter-API/master/pro7.9.openapi-spec.json\")", + "Bash(python3 -c ' *)", + "WebFetch(domain:api.github.com)", + "Bash(curl -s -L --max-time 20 \"https://api.github.com/search/code?q=group_identifiers+propresenter\" -H \"Accept: application/vnd.github+json\")", + "Bash(python3 -c \"import json,sys; d=json.load\\(sys.stdin\\); print\\(d.get\\('total_count'\\)\\); [print\\(i['repository']['full_name'], i['path']\\) for i in d.get\\('items',[]\\)[:15]]\")", + "Bash(curl -s -L --max-time 15 \"https://openapi.propresenter.com/\")", + "Bash(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://openapi.propresenter.com/__TRACKED_VAR__)", + "Bash(curl -s -L --max-time 30 \"https://openapi.propresenter.com/swagger.json\" -o pp20-spec.json)", + "Bash(node --check public/app.js)", + "Bash(pkill -f \"node server.js\")", + "Bash(curl -s -X POST http://localhost:3000/api/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"password\":\"changeme\"}')", + "Bash(node -pe \"JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\).users.map\\(u=>u.username+' ['+u.location+'/'+u.team+'/'+u.role+']'\\).join\\('\\\\n'\\)\")", + "Bash(node -pe \"JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\).lists.find\\(l=>l.id==='woonprod'\\).items.map\\(i=>i.title\\).join\\(', '\\)\")", + "Bash(node -pe ' *)", + "Bash(pkill -f \"pp-mock.js\")", + "Bash(node -e ' *)", + "Bash(node -pe \"JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\).lists.find\\(l=>l.id==='global1'\\).items.map\\(i=>i.title\\).join\\(', '\\)\")", + "Bash(pkill -f \"node server.js\" 2>/dev/null *)", + "Bash(node -pe \"const d=JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\); 'locations: '+d.locations.map\\(l=>l.name+'='+l.abbrev\\).join\\(', '\\)+'\\\\nteams: '+d.teams.map\\(t=>t.name+'='+t.abbrev\\).join\\(', '\\)\")", + "Bash(node -pe \"const d=JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\); 'locations: '+d.locations.map\\(l=>l.name\\).join\\(', '\\)+'\\\\nteams: '+d.teams.map\\(t=>t.name\\).join\\(', '\\)\")", + "Bash(node -pe \"JSON.parse\\(require\\('fs'\\).readFileSync\\(0\\)\\).teams.map\\(t=>t.name\\).join\\(', '\\)\")", + "Bash(curl -s -X POST http://localhost:3000/api/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"location\":\"executive\",\"password\":\"changeme\"}')", + "Bash(curl -sS -m 8 -o /dev/null -w \"HTTP %{http_code}\\\\n\" http://10.1.1.5:3000/healthz)", + "Bash(curl -sS -m 8 http://10.1.1.5:3000/app.js)", + "Bash(curl -sS -m 8 http://10.1.1.5:3000/styles.css)", + "Bash(sed -n '/function openListModal/,/^ }/p' app.js)", + "Bash(sed -n '/function handleModalSave/,/^ }/p' app.js)", + "Bash(sed -n '/\\\\.list-tabs-row {/,/^}/p' styles.css)", + "Bash(sed -n '/^\\\\.list-menu-handle {/,/^}/p' styles.css)", + "Bash(node -c server.js)", + "Bash(node -c public/app.js)", + "Bash(DATA_DIR=/tmp/cl-test-data PORT=3999 ADMIN_PASSWORD=changeme node server.js)", + "Bash(echo \"started pid $!\")", + "Bash(curl -sf http://127.0.0.1:3999/healthz)", + "Bash(break)", + "Bash(node /tmp/cl-test.mjs)", + "Bash(echo \"exit: $?\")", + "Bash(kill 92507)", + "Bash(awk '/^\\\\.list-tab \\\\{/,/^}/' styles.css)", + "Bash(awk '/^\\\\.list-tab-loc/,/^}/' styles.css)", + "Bash(awk '/^\\\\.list-tab\\\\.active \\\\{/,/^}/' styles.css)", + "Bash(rm -rf /tmp/cl-preview-data /tmp/cl-server.log /tmp/cl-test-data *)", + "Bash(./build-deploy.sh)", + "Bash(kill 93146 93148 93160 93162)", + "Bash(pkill -f \"ssh.*10.1.1.5.*checklist-deploy\")", + "Bash(echo \"EXIT=$?\")", + "Bash(expect /tmp/sshstdin.exp 10.1.1.5 Christian Waters2010! ' *)", + "Bash(expect /tmp/sshstdin.exp \"10.1.1.5\" \"Christian\" 'Waters2010!' \"REMOTE_PASS='Waters2010!' bash -s\" /tmp/verify.sh)", + "Bash(rm -f checklist-deploy.tgz /tmp/verify.sh /tmp/deploy2.log /tmp/sshstdin.exp /tmp/deploy.sh)", + "Bash(ls checklist-deploy.tgz)", + "Bash(awk '/^\\(html|body|\\\\.app-container|header|\\\\.main-content|\\\\.app-wrap\\)[ ,{]/{p=1} p{print} /^}/{if\\(p\\)print\"---\"; p=0}' styles.css)", + "Bash(awk '/^\\\\.modal-overlay[ ,{]/{p=1} p{print} /^}/{if\\(p\\)print\"---\"; p=0}' styles.css)", + "Bash(awk '/function openLogin/{p=1} p{print} p&&/^ }/{exit}' app.js)", + "Bash(grep -vE \"spawn|password:|chdir|^$\")", + "Bash(grep -vE \"spawn|chdir|^$\")", + "Bash(kill 96250 96254 96256)", + "Bash(pkill -f \"ssh.*10.1.1.5.*checklists.backup\")", + "Bash(pkill -f \"/tmp/sshbackup.exp\")", + "Bash(pkill -9 -f \"ssh.*10.1.1.5.*checklists.backup\")", + "Bash(awk '/^\\\\.list-tabs \\\\{/,/^}/' styles.css)", + "Bash(awk '/^\\\\.tab-view[ ,{.]/{p=1} p{print} /^}/{if\\(p\\)print\"---\"; p=0}' styles.css)", + "Bash(awk '/^\\\\.checklists-section \\\\{/,/^}/' styles.css)", + "Bash(awk '/^\\\\.checklist-column \\\\{/,/^}/' styles.css)", + "Bash(awk '/^\\\\.checklist-list \\\\{/,/^}/' styles.css)", + "Bash(rm -rf /tmp/cl-preview-data *)", + "Bash(grep -avE \"spawn|password:|chdir|^$\")", + "Bash(expect /tmp/sshstdin.exp \"10.1.1.5\" \"Christian\" 'Waters2010!' \"echo PROBE_OK\" /dev/null)", + "Bash(echo \"probe_exit=$?\")", + "Bash(pkill -9 -f \"ssh.*10.1.1.5\")", + "Bash(pkill -9 -f \"expect /tmp/sshstdin\")", + "Bash(pkill -9 -f sshprobe)", + "Bash(pkill -9 -f \"b5wgdco4c\")", + "Bash(ps -p 99173 -o pid,command)", + "Bash(kill -9 99173)", + "Bash(chmod +x /tmp/run-deploy.sh)", + "Bash(bash /tmp/run-deploy.sh)", + "Bash(echo \"launcher exit=$?\")", + "Bash(expect /tmp/sshstdin.exp \"10.1.1.5\" \"Christian\" 'Waters2010!' \"REMOTE_PASS='Waters2010!' sh -s\" /tmp/verify.sh)", + "Bash(curl -sS -m 10 -o /dev/null -w \"healthz -> HTTP %{http_code}\\\\n\" https://check.watersmedia.org/healthz)", + "Bash(curl -sS -m 10 https://check.watersmedia.org/)", + "Bash(echo \"hide-lists toggle \\(D4\\): $\\(curl -sS -m 10 https://check.watersmedia.org/)", + "Bash(rm -f checklist-deploy.tgz /tmp/deploy3.log /tmp/run-deploy.sh /tmp/sshhard.exp /tmp/verify.sh)", + "Bash(rm -f /tmp/sshstdin.exp)", + "Bash(sed -n '/function executiveSession/,/^}/p' server.js)", + "Bash(sed -n '/function findExecutive/,/^}/p' server.js)", + "Bash(curl -sS -m 10 -o /dev/null -w '%{http_code}' https://check.watersmedia.org/healthz)", + "Bash(echo \"healthz: $\\(curl -sS -m 10 -o /dev/null -w '%{http_code}' https://check.watersmedia.org/healthz\\)\")", + "Bash(curl -sS -m 10 https://check.watersmedia.org/app.js)", + "Bash(echo \"exec rename button \\(data-exec-action=rename\\): $\\(curl -sS -m 10 https://check.watersmedia.org/app.js)", + "Bash(echo \"team-login search box: $\\(curl -sS -m 10 https://check.watersmedia.org/)", + "Bash(rm -rf /tmp/cl-preview-data)", + "Bash(rm -f /Users/Studio/Documents/BandLineupDisplay/.claude/launch.json)", + "Bash(rmdir /Users/Studio/Documents/BandLineupDisplay/.claude)", + "Bash(node -c server/server.js)", + "Bash(node -c server/public/app.js)", + "Bash(echo \"exec scope badge \\(showScope in app.js\\): $\\(curl -sS -m 10 https://check.watersmedia.org/app.js)", + "Bash(grep -niE \"\\\\.\\(tile|card|cell|person|slot|name|label|photo|badge|pill\\)[ .{:,]\" styles.css)", + "Bash(cd \"/Users/Studio/Library/Application Support/Band Lineup\" 2>/dev/null && ls -la *.json 2>/dev/null; echo \"---\"; cat pco-credentials.json 2>/dev/null | head -20)", + "Read(//Users/Studio/Library/Application Support/**)", + "Read(//Users/Studio/Library/Application Support/Electron/**)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(list\\(d.keys\\(\\)\\)\\); print\\(json.dumps\\(d.get\\('proPresenter',{}\\),indent=1\\)[:200]\\); print\\('pco key present:', 'pco' in json.dumps\\(d\\).lower\\(\\)\\)\")" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0ce169 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Band Lineup — source only. Runtime state (photos, PCO and ProPresenter +# credentials, processed images) is written to the Electron userData +# directory, never here. + +.DS_Store +*.log + +node_modules/ +dist/ +logs/ + +# tmp/ is kept on purpose: pp-mock.js is the ProPresenter mock the ProdDash +# repo's CLAUDE.md tells people to run. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9879abf --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Band Lineup + +Stage lineup photos with mic/pack labels, for the backstage screen. Electron, packaged for Windows. + +It also carries the ProPresenter **Now / Next** feature, which exists in two forms that share +one engine: + +``` +BandLineupDisplay/ +├── main.js, preload.js, index.html, renderer.js, styles.css the Band Lineup app +├── propresenter-core/ the shared ProPresenter 7/20 client — no deps, no Electron +└── propresenter-app/ the standalone Now/Next display + the page the band app serves +``` + +| Want | Do this | +| --- | --- | +| Run the backstage app | `npm start` | +| Show Now/Next on screens around the building | In the app: **People** panel → **Studio Displays** → **Turn On**, then bookmark the address it gives you | +| Host the display without the Band Lineup app | `cd propresenter-app && node server.js` | +| Build the Windows installer | `npm run dist` | + +`propresenter-core` is the single home for every ProPresenter quirk — runtime route discovery on +PP20, arrangement-expanded slide totals, uuid-then-name matching for the live item. Fix a quirk +there and both programs get it. + +Details on the display, the bookmark that survives DHCP, and the Windows firewall gotcha: +[propresenter-app/README.md](propresenter-app/README.md). + +## Testing without ProPresenter + +`tmp/pp-mock.js` is a mock of the ProPresenter network API that advances slides on a timer, +including the tricky case where an arrangement repeats a section (43 cues over 41 master slides): + +```bash +node tmp/pp-mock.js 1599 +``` + +Then point either program at `127.0.0.1` port `1599`. diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 0000000..af3dc79 --- /dev/null +++ b/build/installer.nsh @@ -0,0 +1,112 @@ +; Make upgrades self-healing: +; 1. Kill any running Band Lineup process tree. +; 2. Delete the previous install folder directly. +; 3. Remove stale registry keys so electron-builder does not invoke the old uninstaller. + +!macro forceCloseBandLineup + DetailPrint "Force-closing ${PRODUCT_NAME}..." + nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"` + Pop $R9 + Sleep 500 + nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -WindowStyle Hidden -Command "Stop-Process -Name '${PRODUCT_NAME}' -Force -ErrorAction SilentlyContinue"` + Pop $R9 + Sleep 500 + nsExec::Exec `"$SYSDIR\cmd.exe" /c taskkill /F /T /IM "${APP_EXECUTABLE_FILENAME}"` + Pop $R9 + Sleep 1000 +!macroend + +!macro removePreviousInstall ROOT_KEY + ReadRegStr $R8 ${ROOT_KEY} "${INSTALL_REGISTRY_KEY}" "InstallLocation" + ${if} $R8 != "" + DetailPrint "Removing previous installation from $R8" + !insertmacro forceCloseBandLineup + RMDir /r "$R8" + nsExec::Exec `"$SYSDIR\cmd.exe" /c rmdir /S /Q "$R8"` + Pop $R9 + nsExec::Exec `"$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -WindowStyle Hidden -Command "if (Test-Path -LiteralPath '$R8') { Remove-Item -LiteralPath '$R8' -Force -Recurse -ErrorAction SilentlyContinue }"` + Pop $R9 + ${endif} + DeleteRegKey ${ROOT_KEY} "${UNINSTALL_REGISTRY_KEY}" + !ifdef UNINSTALL_REGISTRY_KEY_2 + DeleteRegKey ${ROOT_KEY} "${UNINSTALL_REGISTRY_KEY_2}" + !endif + DeleteRegKey ${ROOT_KEY} "${INSTALL_REGISTRY_KEY}" +!macroend + +!macro customCheckAppRunning + !insertmacro forceCloseBandLineup +!macroend + +!macro preInit + !insertmacro forceCloseBandLineup +!macroend + +!macro customInit + !insertmacro forceCloseBandLineup + !insertmacro removePreviousInstall HKCU + !insertmacro removePreviousInstall HKLM + Sleep 1000 +!macroend + +!macro customUnInstallCheck + DetailPrint "Skipping old uninstaller result and continuing with clean install." + ClearErrors + StrCpy $R0 0 +!macroend + +!macro customUnInstallCheckCurrentUser + DetailPrint "Skipping old per-user uninstaller result and continuing with clean install." + ClearErrors + StrCpy $R0 0 +!macroend + +; --------------------------------------------------------------------------- +; Studio displays: allow the app through Windows Defender Firewall. +; +; Why this belongs in the installer: the firewall prompt fires the moment the app +; starts listening, and Microsoft documents that if the user is NOT a local admin, +; block rules are created no matter which button they press — and a block rule is +; sticky and silent afterwards. The backstage machine then serves localhost perfectly +; while every studio screen times out. The installer is the one place we reliably +; have elevation, so create the allow rules here. +; +; Rules are program-scoped (not port-scoped) so they survive the operator changing +; the display port in the app. UDP covers mDNS on 5353, which publishes the +; nownext.local name that makes bookmarks survive a DHCP change. +; +; NOTE: this needs an elevated (per-machine) install. With oneClick:false and +; perMachine unset, the user may choose a per-user install, where netsh will fail — +; harmlessly, thanks to the swallowed exit codes below. If studio screens can't +; connect, the in-app "Studio Displays" status says so and names the fix. +; --------------------------------------------------------------------------- + +!macro addBandLineupFirewallRules + DetailPrint "Allowing ${PRODUCT_NAME} through Windows Firewall..." + nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall delete rule name="Band Lineup Display (TCP)"` + Pop $R9 + nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall delete rule name="Band Lineup Display (UDP)"` + Pop $R9 + nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall add rule name="Band Lineup Display (TCP)" dir=in action=allow program="$INSTDIR\${APP_EXECUTABLE_FILENAME}" enable=yes profile=domain,private protocol=TCP` + Pop $R9 + ; UDP is port-locked deliberately: mDNS is always 5353 (a hard-coded constant in mdns.js), + ; so unlike the TCP rule this can never go stale, and a program-wide UDP allow would open + ; every UDP port the app ever binds. + nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall add rule name="Band Lineup Display (UDP)" dir=in action=allow program="$INSTDIR\${APP_EXECUTABLE_FILENAME}" enable=yes profile=domain,private protocol=UDP localport=5353` + Pop $R9 +!macroend + +!macro removeBandLineupFirewallRules + nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall delete rule name="Band Lineup Display (TCP)"` + Pop $R9 + nsExec::Exec `"$SYSDIR\netsh.exe" advfirewall firewall delete rule name="Band Lineup Display (UDP)"` + Pop $R9 +!macroend + +!macro customInstall + !insertmacro addBandLineupFirewallRules +!macroend + +!macro customUnInstall + !insertmacro removeBandLineupFirewallRules +!macroend diff --git a/index.html b/index.html new file mode 100644 index 0000000..0d23c3e --- /dev/null +++ b/index.html @@ -0,0 +1,213 @@ + + + + + + + Band Lineup + + + + +
+ +
+
+ + + +
+ + + +
+
+
+
+

Drag people from the right into any stage slot.

+
+ + +
+ + + + + + + + + + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..4d27d92 --- /dev/null +++ b/main.js @@ -0,0 +1,1833 @@ +const { app, BrowserWindow, ipcMain, dialog, Menu, safeStorage, clipboard } = require('electron'); +const path = require('path'); +const fs = require('fs/promises'); +const fsSync = require('fs'); +const { pathToFileURL } = require('url'); +const crypto = require('crypto'); +const sharp = require('sharp'); +const { + createProPresenterClient, + clearedViewModel: ppClearedViewModel, +} = require('./propresenter-core'); +// Require the exact file, never the directory: propresenter-app/package.json declares the +// standalone kiosk wrapper as its main, so a bare directory require would start a second +// Electron app instead of the server. +const { + createDisplayServer, + DEFAULT_PORT: DEFAULT_DISPLAY_PORT, +} = require('./propresenter-app/server'); + +let mainWindow; +const DEFAULT_COLUMNS = 7; +const DEFAULT_ROWS = 2; +const PCO_API_BASE = 'https://api.planningcenteronline.com'; +const ALLOWED_PCO_TEAM_KEYWORDS = ['communication', 'band', 'production']; +/** Default ProPresenter network API port (user-configurable in ProPresenter ▸ Preferences ▸ Network). */ +const DEFAULT_PP_PORT = 1025; +/** How often to poll ProPresenter for playlist/slide changes while connected. */ +const PP_POLL_MS = 600; +/** Slower retry cadence when ProPresenter is unreachable. */ +const PP_RETRY_MS = 2500; +/** Long-edge cap when baking parallax layers (depth/segmentation). Higher = sharper on 4K; bump PARALLAX_VERSION when changing. */ +const PARALLAX_MAX_DIMENSION = 3072; +const PARALLAX_VERSION = 10; +let depthEstimatorPromise = null; +let backgroundRemoverPromise = null; +const parallaxTaskCache = new Map(); + +function normalizeLayout(layout) { + const columns = Number.parseInt(String(layout?.columns ?? DEFAULT_COLUMNS), 10); + const rows = Number.parseInt(String(layout?.rows ?? DEFAULT_ROWS), 10); + return { + columns: Number.isFinite(columns) && columns > 0 ? columns : DEFAULT_COLUMNS, + rows: Number.isFinite(rows) && rows > 0 ? rows : DEFAULT_ROWS, + }; +} + +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + app.quit(); +} else { + app.on('second-instance', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); +} + +function imagesDir() { + return path.join(app.getPath('userData'), 'images'); +} + +function processedDir() { + return path.join(app.getPath('userData'), 'processed'); +} + +function modelCacheDir() { + return path.join(app.getPath('userData'), 'model-cache'); +} + +function statePath() { + return path.join(app.getPath('userData'), 'state.json'); +} + +function pcoCredentialsPath() { + return path.join(app.getPath('userData'), 'pco-credentials.json'); +} + +async function ensureDirs() { + await fs.mkdir(imagesDir(), { recursive: true }); + await fs.mkdir(processedDir(), { recursive: true }); + await fs.mkdir(modelCacheDir(), { recursive: true }); +} + +function encodeStoredSecret(value) { + const text = String(value ?? ''); + if (!text) return ''; + if (safeStorage.isEncryptionAvailable()) { + return `enc:${safeStorage.encryptString(text).toString('base64')}`; + } + return `plain:${Buffer.from(text, 'utf8').toString('base64')}`; +} + +function decodeStoredSecret(value) { + const raw = String(value ?? ''); + if (!raw) return ''; + if (raw.startsWith('enc:')) { + return safeStorage.decryptString(Buffer.from(raw.slice(4), 'base64')); + } + if (raw.startsWith('plain:')) { + return Buffer.from(raw.slice(6), 'base64').toString('utf8'); + } + return raw; +} + +async function loadPcoCredentials() { + try { + const raw = JSON.parse(await fs.readFile(pcoCredentialsPath(), 'utf8')); + const clientId = decodeStoredSecret(raw.clientId); + const secret = decodeStoredSecret(raw.secret); + if (!clientId || !secret) return null; + return { clientId, secret }; + } catch { + return null; + } +} + +async function savePcoCredentials(credentials) { + await ensureDirs(); + await fs.writeFile( + pcoCredentialsPath(), + JSON.stringify( + { + clientId: encodeStoredSecret(credentials.clientId), + secret: encodeStoredSecret(credentials.secret), + }, + null, + 2, + ), + 'utf8', + ); +} + +async function clearPcoCredentials() { + try { + await fs.unlink(pcoCredentialsPath()); + } catch { + /* already gone */ + } +} + +function ppCredentialsPath() { + return path.join(app.getPath('userData'), 'propresenter-credentials.json'); +} + +async function loadPpPassword() { + try { + const raw = JSON.parse(await fs.readFile(ppCredentialsPath(), 'utf8')); + return decodeStoredSecret(raw.password) || ''; + } catch { + return ''; + } +} + +async function savePpPassword(password) { + if (!password) { + await clearPpPassword(); + return; + } + await ensureDirs(); + await fs.writeFile( + ppCredentialsPath(), + JSON.stringify({ password: encodeStoredSecret(password) }, null, 2), + 'utf8', + ); +} + +async function clearPpPassword() { + try { + await fs.unlink(ppCredentialsPath()); + } catch { + /* already gone */ + } +} + +function randomName(originalPath) { + const ext = path.extname(originalPath) || '.jpg'; + return `${crypto.randomBytes(16).toString('hex')}${ext}`; +} + +function originalDisplayName(sourcePath) { + return path.parse(sourcePath).name; +} + +async function copyImageToStore(sourcePath) { + await ensureDirs(); + const destName = randomName(sourcePath); + const dest = path.join(imagesDir(), destName); + await fs.copyFile(sourcePath, dest); + return destName; +} + +function processedBaseName(fileName) { + return path.join(processedDir(), path.parse(fileName).name); +} + +function pcoUserAgent() { + return `Band Lineup/${app.getVersion()} (desktop sync)`; +} + +function pcoAuthHeader(credentials) { + return `Basic ${Buffer.from(`${credentials.clientId}:${credentials.secret}`, 'utf8').toString('base64')}`; +} + +function buildPcoUrl(target, searchParams) { + const url = new URL(target.startsWith('http') ? target : `${PCO_API_BASE}${target}`); + for (const [key, value] of Object.entries(searchParams || {})) { + if (value === undefined || value === null || value === '') continue; + url.searchParams.set(key, String(value)); + } + return url; +} + +async function pcoRequestJson(target, credentials, searchParams) { + const url = buildPcoUrl(target, searchParams); + const response = await fetch(url, { + headers: { + Accept: 'application/json', + Authorization: pcoAuthHeader(credentials), + 'User-Agent': pcoUserAgent(), + }, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; + } + if (!response.ok) { + const detail = + payload?.errors?.map((entry) => entry.detail || entry.title).filter(Boolean).join(' | ') || + payload?.error || + `${response.status} ${response.statusText}`; + throw new Error(detail); + } + return payload || { data: [] }; +} + +/** + * Like pcoRequestJson but never throws on an HTTP error — returns the status so callers can tell + * "no access" (401/403) apart from "no such record" (404) apart from success. Used by the People + * birthdate probe/fetch, where the whole point is to report what the token is actually allowed to do. + */ +async function pcoFetchStatus(target, credentials, searchParams) { + const url = buildPcoUrl(target, searchParams); + const response = await fetch(url, { + headers: { + Accept: 'application/json', + Authorization: pcoAuthHeader(credentials), + 'User-Agent': pcoUserAgent(), + }, + }); + const text = await response.text(); + let payload = null; + try { + payload = text ? JSON.parse(text) : null; + } catch { + payload = null; + } + return { status: response.status, ok: response.ok, payload }; +} + +/** + * Fetch one person's birthday from the People API as "MM-DD" (year discarded). Services and People + * share the same person id, so the Services person id we already store doubles as the People id. + * Returns { status, birthday, fieldPresent } — birthday '' when the field is null/absent. + */ +async function pcoFetchBirthday(personId, credentials) { + if (!personId) return { status: 0, birthday: '', fieldPresent: false }; + const { status, ok, payload } = await pcoFetchStatus( + `/people/v2/people/${encodeURIComponent(personId)}`, + credentials, + { 'fields[Person]': 'birthdate' }, + ); + if (!ok) return { status, birthday: '', fieldPresent: false }; + const attrs = payload?.data?.attributes || {}; + return { status, birthday: normalizeBirthdayMmdd(attrs.birthdate), fieldPresent: 'birthdate' in attrs }; +} + +async function pcoFetchAll(target, credentials, searchParams) { + let nextUrl = buildPcoUrl(target, searchParams).toString(); + const data = []; + const included = []; + const seenIncluded = new Set(); + while (nextUrl) { + const payload = await pcoRequestJson(nextUrl, credentials); + if (Array.isArray(payload.data)) { + data.push(...payload.data); + } else if (payload.data) { + data.push(payload.data); + } + if (Array.isArray(payload.included)) { + for (const item of payload.included) { + const key = `${item?.type || ''}:${item?.id || ''}`; + if (!key || seenIncluded.has(key)) continue; + seenIncluded.add(key); + included.push(item); + } + } + nextUrl = typeof payload?.links?.next === 'string' && payload.links.next ? payload.links.next : ''; + } + return { data, included }; +} + +function emptyState() { + return { + library: [], + layout: { columns: DEFAULT_COLUMNS, rows: DEFAULT_ROWS }, + parallaxEnabled: true, + slotLabels: [], + slotNames: [], + lineup: [], + pcoServiceTypeId: '', + pcoDisplayPositions: [], + proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' }, + displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT }, + birthdayMode: { enabled: true, intensity: 'subtle' }, + }; +} + +/** Birthday Mode display settings (the per-person test override is NOT persisted — it lives only in the renderer). */ +function normalizeBirthdayMode(entry) { + return { + enabled: entry?.enabled === undefined ? true : Boolean(entry.enabled), + intensity: entry?.intensity === 'full' ? 'full' : 'subtle', + }; +} + +/** Whether we host the Now/Next display for other screens, and on which port. */ +function normalizeDisplayServerConfig(entry) { + const portNum = Number.parseInt(String(entry?.port ?? ''), 10); + const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : DEFAULT_DISPLAY_PORT; + return { enabled: Boolean(entry?.enabled), port }; +} + +function normalizeProPresenterConfig(entry) { + const host = String(entry?.host || '').trim(); + const portNum = Number.parseInt(String(entry?.port ?? ''), 10); + const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0; + return { + enabled: Boolean(entry?.enabled) && Boolean(host) && Boolean(port), + host, + port, + playlistUuid: String(entry?.playlistUuid || ''), + playlistName: String(entry?.playlistName || ''), + }; +} + +function normalizeSavedLibraryPerson(person) { + return { + id: String(person.id), + fileName: String(person.fileName), + originalName: typeof person.originalName === 'string' && person.originalName.trim() ? person.originalName.trim() : '', + lastLabel: typeof person.lastLabel === 'string' ? person.lastLabel : '', + lastName: typeof person.lastName === 'string' ? person.lastName : '', + source: person?.source === 'pco' ? 'pco' : 'local', + pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '', + pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '', + // "MM-DD" only — we deliberately never persist the birth year (month + day is all + // Birthday Mode needs, and it keeps a volunteer's age off this machine). '' = none on file. + birthday: normalizeBirthdayMmdd(person?.birthday), + }; +} + +/** Coerce a Planning Center birthdate ("YYYY-MM-DD", or an already-trimmed "MM-DD") to "MM-DD", or '' if absent/invalid. */ +function normalizeBirthdayMmdd(raw) { + const s = String(raw || '').trim(); + const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/); // PCO People birthdate shape + if (full) return `${full[1]}-${full[2]}`; + const md = s.match(/^(\d{2})-(\d{2})$/); + if (md) { + const mm = Number(md[1]); + const dd = Number(md[2]); + if (mm >= 1 && mm <= 12 && dd >= 1 && dd <= 31) return `${md[1]}-${md[2]}`; + } + return ''; +} + +function normalizeTeamName(value) { + return String(value || '').trim().toLowerCase(); +} + +function isAllowedPcoTeamName(value) { + const normalized = normalizeTeamName(value); + return ALLOWED_PCO_TEAM_KEYWORDS.some((keyword) => normalized === keyword || normalized.includes(keyword)); +} + +function normalizePcoLabel(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizePcoToken(token) { + const normalized = String(token || '').trim().toLowerCase(); + if (!normalized) return ''; + if (/^\d+$/.test(normalized)) return ''; + if (['mix', 'mic', 'slot', 'channel', 'ch', 'pack', 'wireless', 'input'].includes(normalized)) return ''; + if (['vox', 'vocal', 'vocalist', 'vocalists', 'vocals', 'singer', 'singers'].includes(normalized)) return 'vocals'; + if (['keys', 'keyboard', 'keyboards'].includes(normalized)) return 'keys'; + if (['drum', 'drums', 'drummer', 'drummers'].includes(normalized)) return 'drums'; + if (['perc', 'percussionist', 'percussionists'].includes(normalized)) return 'percussion'; + if (['gtr', 'guitar', 'guitars'].includes(normalized)) return 'guitar'; + if (['electric', 'electricguitar'].includes(normalized)) return 'electric'; + if (['acoustic', 'acousticguitar'].includes(normalized)) return 'acoustic'; + if (['b', 'bandleader', 'leader', 'lead'].includes(normalized)) return normalized === 'b' ? '' : 'leader'; + if (['worship', 'calltoworship'].includes(normalized)) return normalized === 'calltoworship' ? 'call' : normalized; + if (normalized.endsWith('s') && normalized.length > 4) return normalized.slice(0, -1); + return normalized; +} + +function normalizedPcoLabelTokens(value) { + const normalized = normalizePcoLabel(value); + if (!normalized) return []; + const tokens = normalized + .split(' ') + .map((token) => normalizePcoToken(token)) + .filter(Boolean); + if (!tokens.length && normalized) return [normalized]; + return [...new Set(tokens)]; +} + +function scorePcoLabelMatch(slotLabel, positionLabel) { + const slotNormalized = normalizePcoLabel(slotLabel); + const positionNormalized = normalizePcoLabel(positionLabel); + if (!slotNormalized || !positionNormalized) return 0; + if (slotNormalized === positionNormalized) return 120; + if (slotNormalized.includes(positionNormalized) || positionNormalized.includes(slotNormalized)) return 95; + + const slotTokens = normalizedPcoLabelTokens(slotLabel); + const positionTokens = normalizedPcoLabelTokens(positionLabel); + if (!slotTokens.length || !positionTokens.length) return 0; + + const positionSet = new Set(positionTokens); + const slotSet = new Set(slotTokens); + const overlap = slotTokens.filter((token) => positionSet.has(token)); + if (!overlap.length) return 0; + + const overlapCount = overlap.length; + const slotCoverage = overlapCount / slotSet.size; + const positionCoverage = overlapCount / positionSet.size; + const sharedPhrase = overlap.join(' '); + + let score = Math.round(slotCoverage * 70 + positionCoverage * 20 + overlapCount * 4); + if (slotCoverage === 1) score += 18; + if (positionCoverage === 1) score += 10; + if (sharedPhrase && slotNormalized.includes(sharedPhrase) && positionNormalized.includes(sharedPhrase)) score += 8; + return score; +} + +function findBestPcoMemberForSlot(slotLabel, availableMembers) { + let bestIndex = -1; + let bestScore = 0; + for (let i = 0; i < availableMembers.length; i += 1) { + const member = availableMembers[i]; + const score = scorePcoLabelMatch(slotLabel, member.label); + if (score > bestScore) { + bestScore = score; + bestIndex = i; + } + } + if (bestIndex < 0 || bestScore < 60) return null; + return { + member: availableMembers.splice(bestIndex, 1)[0], + score: bestScore, + }; +} + +function pcoPositionKey(teamName, positionName) { + return `${normalizeTeamName(teamName)}::${String(positionName || '').trim().toLowerCase()}`; +} + +function normalizePcoDisplayPosition(entry) { + const count = Number.parseInt(String(entry?.count ?? 1), 10); + return { + teamName: String(entry?.teamName || '').trim(), + positionName: String(entry?.positionName || '').trim(), + count: Number.isFinite(count) && count > 0 ? count : 1, + }; +} + +function normalizePcoDisplayPositions(entries) { + if (!Array.isArray(entries)) return []; + return entries + .map((entry) => normalizePcoDisplayPosition(entry)) + .filter((entry) => entry.teamName && entry.positionName); +} + +function initialsForName(name) { + const parts = String(name || '') + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2); + if (!parts.length) return '?'; + return parts.map((part) => part[0]).join('').toUpperCase(); +} + +function pcoImageExtension(contentType, imageUrl) { + const normalized = String(contentType || '').split(';')[0].trim().toLowerCase(); + if (normalized === 'image/jpeg') return '.jpg'; + if (normalized === 'image/png') return '.png'; + if (normalized === 'image/webp') return '.webp'; + if (normalized === 'image/gif') return '.gif'; + const ext = path.extname(new URL(imageUrl).pathname || '').toLowerCase(); + if (['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp'].includes(ext)) return ext; + return '.jpg'; +} + +async function writePcoPlaceholderImage(displayName, key) { + await ensureDirs(); + const fileName = `pco-${crypto.createHash('sha1').update(`${key}:placeholder`).digest('hex')}.svg`; + const filePath = path.join(imagesDir(), fileName); + const initials = initialsForName(displayName); + const safeInitials = initials.replace(/&/g, '&').replace(//g, '>'); + const svg = `${safeInitials}`; + await fs.writeFile(filePath, svg, 'utf8'); + return fileName; +} + +async function downloadPcoImage(imageUrl, key, displayName) { + if (!imageUrl) { + return writePcoPlaceholderImage(displayName, key); + } + try { + const response = await fetch(imageUrl, { + headers: { + 'User-Agent': pcoUserAgent(), + }, + }); + if (!response.ok) { + throw new Error(`image download failed with ${response.status}`); + } + const ext = pcoImageExtension(response.headers.get('content-type'), imageUrl); + const fileName = `pco-${crypto.createHash('sha1').update(key).digest('hex')}${ext}`; + const filePath = path.join(imagesDir(), fileName); + const bytes = Buffer.from(await response.arrayBuffer()); + await ensureDirs(); + await fs.writeFile(filePath, bytes); + return fileName; + } catch { + return writePcoPlaceholderImage(displayName, key); + } +} + +function makePcoLocalId(serviceTypeId, planId, planPersonId) { + return `pco-${serviceTypeId}-${planId}-${planPersonId}`; +} + +async function listPcoServiceTypes(credentials) { + const payload = await pcoFetchAll('/services/v2/service_types', credentials, { + per_page: 100, + order: 'sequence', + }); + return payload.data + .filter((entry) => !entry?.attributes?.archived_at && !entry?.attributes?.deleted_at) + .map((entry) => ({ + id: String(entry.id), + name: String(entry?.attributes?.name || `Service Type ${entry.id}`), + })); +} + +async function listPcoPositionOptions(serviceTypeId, credentials) { + const normalizedServiceTypeId = String(serviceTypeId || '').trim(); + if (!normalizedServiceTypeId) return []; + const payload = await pcoFetchAll(`/services/v2/service_types/${normalizedServiceTypeId}/team_positions`, credentials, { + include: 'team', + per_page: 100, + order: 'name', + }); + const includedByKey = new Map(payload.included.map((entry) => [`${entry?.type || ''}:${entry?.id || ''}`, entry])); + const options = []; + for (const entry of payload.data) { + if (!entry || typeof entry !== 'object') continue; + const teamId = String(entry?.relationships?.team?.data?.id || ''); + const team = teamId ? includedByKey.get(`Team:${teamId}`) : null; + const teamName = String(team?.attributes?.name || '').trim(); + if (!isAllowedPcoTeamName(teamName)) continue; + const positionName = String(entry?.attributes?.name || '').trim(); + if (!positionName) continue; + options.push({ + teamName, + positionName, + key: pcoPositionKey(teamName, positionName), + }); + } + options.sort((a, b) => { + const teamCompare = a.teamName.localeCompare(b.teamName, undefined, { sensitivity: 'base' }); + if (teamCompare) return teamCompare; + return a.positionName.localeCompare(b.positionName, undefined, { sensitivity: 'base' }); + }); + return options; +} + +async function syncUpcomingPcoPlan(payload) { + const credentials = await loadPcoCredentials(); + if (!credentials) { + throw new Error('Planning Center is not connected yet.'); + } + const normalizedServiceTypeId = String(payload?.serviceTypeId || '').trim(); + if (!normalizedServiceTypeId) { + throw new Error('Choose a service type first.'); + } + const currentState = await loadStateRaw(); + const baseState = { + library: Array.isArray(payload?.library) ? payload.library.map((entry) => normalizeSavedLibraryPerson(entry)) : currentState.library, + layout: payload?.layout ? normalizeLayout(payload.layout) : currentState.layout, + parallaxEnabled: typeof payload?.parallaxEnabled === 'boolean' ? payload.parallaxEnabled : currentState.parallaxEnabled, + slotLabels: Array.isArray(payload?.slotLabels) + ? payload.slotLabels.map((entry) => (typeof entry === 'string' ? entry : '')) + : currentState.slotLabels, + slotNames: Array.isArray(payload?.slotNames) + ? payload.slotNames.map((entry) => (typeof entry === 'string' ? entry : '')) + : currentState.slotNames, + lineup: Array.isArray(payload?.lineup) + ? payload.lineup.map((entry) => ({ + personId: typeof entry?.personId === 'string' ? entry.personId : '', + label: typeof entry?.label === 'string' ? entry.label : '', + })) + : currentState.lineup, + pcoDisplayPositions: normalizePcoDisplayPositions(payload?.pcoDisplayPositions ?? currentState.pcoDisplayPositions), + // Carried through deliberately: this state is rebuilt field-by-field and written whole, + // so anything omitted here is ERASED from disk by a sync. + proPresenter: normalizeProPresenterConfig(currentState.proPresenter), + displayServer: normalizeDisplayServerConfig(currentState.displayServer), + }; + const planPayload = await pcoRequestJson(`/services/v2/service_types/${normalizedServiceTypeId}/plans`, credentials, { + filter: 'future', + per_page: 1, + order: 'sort_date', + }); + const plan = Array.isArray(planPayload.data) ? planPayload.data[0] : null; + if (!plan) { + throw new Error('No upcoming plan was found for that service type.'); + } + + const teamMembersPayload = await pcoFetchAll( + `/services/v2/service_types/${normalizedServiceTypeId}/plans/${plan.id}/team_members`, + credentials, + { + include: 'person,team', + per_page: 100, + }, + ); + const includedByKey = new Map( + teamMembersPayload.included.map((entry) => [`${entry?.type || ''}:${entry?.id || ''}`, entry]), + ); + const scheduledMembers = []; + for (const member of teamMembersPayload.data) { + if (!member || typeof member !== 'object') continue; + const status = String(member?.attributes?.status || '').trim(); + if (status === 'D' || /^declined$/i.test(status)) continue; + const personId = String(member?.relationships?.person?.data?.id || ''); + const teamId = String(member?.relationships?.team?.data?.id || ''); + const person = personId ? includedByKey.get(`Person:${personId}`) : null; + const team = teamId ? includedByKey.get(`Team:${teamId}`) : null; + const displayName = + String( + person?.attributes?.full_name || + person?.attributes?.profile_name || + member?.attributes?.name || + 'Unknown Person', + ).trim() || 'Unknown Person'; + const label = String(member?.attributes?.team_position_name || '').trim(); + const teamName = String(team?.attributes?.name || '').trim(); + if (!isAllowedPcoTeamName(teamName)) continue; + const photoUrl = + String( + person?.attributes?.photo_url || + person?.attributes?.photo_thumbnail_url || + member?.attributes?.photo_thumbnail || + '', + ).trim(); + scheduledMembers.push({ + planPersonId: String(member.id), + personId, + displayName, + label, + teamName, + photoUrl, + }); + } + + scheduledMembers.sort((a, b) => { + const teamCompare = a.teamName.localeCompare(b.teamName, undefined, { sensitivity: 'base' }); + if (teamCompare) return teamCompare; + const labelCompare = a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); + if (labelCompare) return labelCompare; + return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' }); + }); + + // Pull each scheduled person's birthday from the People API (best-effort: a blocked token or a + // person with no date on file just yields '' and never fails the sync). Fetched once per unique + // person so a member in two positions isn't queried twice. + const birthdayByPerson = new Map(); + for (const member of scheduledMembers) { + if (!member.personId || birthdayByPerson.has(member.personId)) continue; + try { + const { birthday } = await pcoFetchBirthday(member.personId, credentials); + birthdayByPerson.set(member.personId, birthday || ''); + } catch { + birthdayByPerson.set(member.personId, ''); + } + } + + const oldPcoFiles = new Set( + (Array.isArray(baseState.library) ? baseState.library : []) + .filter((entry) => entry?.source === 'pco' && typeof entry?.fileName === 'string') + .map((entry) => entry.fileName), + ); + + const syncedLibrary = []; + const nextPcoFiles = new Set(); + for (const member of scheduledMembers) { + const localId = makePcoLocalId(normalizedServiceTypeId, String(plan.id), member.planPersonId); + const fileName = await downloadPcoImage( + member.photoUrl, + `${normalizedServiceTypeId}:${plan.id}:${member.planPersonId}:${member.photoUrl}`, + member.displayName, + ); + nextPcoFiles.add(fileName); + syncedLibrary.push({ + id: localId, + fileName, + originalName: member.displayName, + lastLabel: member.label, + lastName: member.displayName, + source: 'pco', + pcoPersonId: member.personId, + pcoPlanPersonId: member.planPersonId, + birthday: birthdayByPerson.get(member.personId) || '', + }); + } + + const layout = normalizeLayout(baseState.layout); + const slotCount = layout.columns * layout.rows; + const slotLabels = Array.from({ length: slotCount }, (_slot, index) => + typeof baseState.slotLabels[index] === 'string' + ? baseState.slotLabels[index] + : typeof baseState.lineup[index]?.label === 'string' + ? baseState.lineup[index].label + : '', + ); + const slotNames = Array.from({ length: slotCount }, (_slot, index) => + typeof baseState.slotNames[index] === 'string' ? baseState.slotNames[index] : '', + ); + const lineup = Array.from({ length: slotCount }, (_slot, index) => ({ + personId: typeof baseState.lineup[index]?.personId === 'string' ? baseState.lineup[index].personId : '', + label: slotLabels[index] || '', + })); + const libraryById = new Map(baseState.library.map((entry) => [entry.id, entry])); + let displayedCount = 0; + + const availableMembers = [...scheduledMembers].sort((a, b) => { + const labelCompare = a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }); + if (labelCompare) return labelCompare; + return a.displayName.localeCompare(b.displayName, undefined, { sensitivity: 'base' }); + }); + + for (let i = 0; i < slotCount; i += 1) { + const desiredLabel = String(slotLabels[i] || '').trim(); + if (!desiredLabel) continue; + const match = findBestPcoMemberForSlot(desiredLabel, availableMembers); + if (match?.member) { + const member = match.member; + lineup[i] = { + personId: makePcoLocalId(normalizedServiceTypeId, String(plan.id), member.planPersonId), + label: desiredLabel, + }; + slotNames[i] = member.displayName; + displayedCount += 1; + continue; + } + const currentPerson = libraryById.get(lineup[i].personId); + if (currentPerson?.source === 'local') { + lineup[i] = { + personId: currentPerson.id, + label: desiredLabel, + }; + continue; + } + lineup[i] = { + personId: '', + label: desiredLabel, + }; + slotNames[i] = ''; + } + + const localLibrary = (Array.isArray(baseState.library) ? baseState.library : []).filter( + (entry) => entry?.source !== 'pco', + ); + const nextState = { + library: [...localLibrary, ...syncedLibrary], + layout, + parallaxEnabled: typeof baseState.parallaxEnabled === 'boolean' ? baseState.parallaxEnabled : true, + slotLabels, + slotNames, + lineup, + pcoServiceTypeId: normalizedServiceTypeId, + pcoDisplayPositions: baseState.pcoDisplayPositions, + }; + // Re-read the keys this function does not own, immediately before writing. The snapshot + // above was taken before a plan fetch, paginated team-member fetches and one photo + // download per person — tens of seconds during which the operator can still connect + // ProPresenter or switch hosting on, and this whole-file write would stamp those back to + // their old values. (That is the bug the carry-forward was added to fix; reading the + // values early only moved the window instead of closing it.) + const atWriteTime = await loadStateRaw(); + nextState.proPresenter = normalizeProPresenterConfig(atWriteTime.proPresenter); + nextState.displayServer = normalizeDisplayServerConfig(atWriteTime.displayServer); + nextState.birthdayMode = normalizeBirthdayMode(atWriteTime.birthdayMode); + await saveStateRaw(nextState); + + for (const fileName of oldPcoFiles) { + if (nextPcoFiles.has(fileName)) continue; + try { + await fs.unlink(path.join(imagesDir(), path.basename(fileName))); + } catch { + /* ignore stale file cleanup */ + } + } + + return { + state: nextState, + plan: { + id: String(plan.id), + title: String(plan?.attributes?.title || ''), + shortDates: String(plan?.attributes?.short_dates || plan?.attributes?.dates || ''), + serviceTypeId: normalizedServiceTypeId, + peopleCount: scheduledMembers.length, + displayedCount, + }, + }; +} + +/* ------------------------------------------------------------------ * + * ProPresenter 7 integration * + * * + * The polling + HTTP logic lives in ./propresenter-core, shared with * + * the standalone display program in propresenter-app/ so both stay in * + * lockstep. It runs in the main process (Node fetch): no CORS and no * + * renderer CSP change. This file owns only the Electron side — * + * persistence in the app state file, and pushing the core's * + * view-model to the renderer over the 'pp-data' channel. * + * ------------------------------------------------------------------ */ + +/** Connection config as persisted in the app state file (password comes from safeStorage). */ +let ppConfig = { enabled: false, host: '', port: 0, password: '', playlistUuid: '', playlistName: '' }; + +const ppClient = createProPresenterClient({ + pollMs: PP_POLL_MS, + retryMs: PP_RETRY_MS, + onData: (view) => { + // Publish to the LAN FIRST, in its own guard. Two reasons: the window check below + // returns early when there is no window (so a fan-out after it would never run for + // the studios), and a throw from here escapes emit() into the poll loop — which is + // precisely how polling could stop for good. + try { + displayServer?.publish(view); + } catch (err) { + displayError = err instanceof Error ? err.message : String(err); + } + if (!mainWindow || mainWindow.isDestroyed()) return; + try { + mainWindow.webContents.send('pp-data', view); + } catch { + /* window went away between the check and the send */ + } + }, +}); + +/** Push ppConfig (our persisted source of truth) into the polling client. */ +function ppSyncClientConfig() { + ppClient.configure(ppConfig); +} + +function ppSendCleared() { + const cleared = ppClearedViewModel(); + try { + displayServer?.publish(cleared); + } catch (err) { + displayError = err instanceof Error ? err.message : String(err); + } + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send('pp-data', cleared); +} + +function ppStop() { + ppClient.stop(); +} + +function ppStart() { + ppSyncClientConfig(); + ppClient.start(); +} + +async function ppLoadConfigFromDisk() { + const state = await loadStateRaw(); + const config = normalizeProPresenterConfig(state.proPresenter); + const password = await loadPpPassword(); + ppConfig = { ...config, password }; + ppSyncClientConfig(); +} + +/* ------------------------------------------------------------------ * + * Hosting the Now / Next display for the rest of the building * + * * + * Serves the same display program that lives in propresenter-app/, so * + * studios and hallway screens need only a bookmark — nothing installed. * + * It is handed the ppClient above rather than making its own, so there * + * is exactly ONE poll loop behind this app's own column and every * + * screen on the network, and the ProPresenter connection is still * + * configured in one place: here. * + * ------------------------------------------------------------------ */ + +/** Persisted in state.json under `displayServer`. */ +let displayConfig = { enabled: false, port: DEFAULT_DISPLAY_PORT }; +/** The running server, or null. Also the flag the poll-loop fan-out keys off. */ +let displayServer = null; +let displayError = ''; +/** + * On Windows the firewall prompt fires at bind time, not on first connection, so this + * machine can look perfectly healthy on localhost while every studio times out. A viewer + * count that never leaves zero is the only signal we get, so the UI says so out loud — the + * latch itself lives in the display server, which is what actually sees connections. + */ +/** + * Serializes start/stop/port changes. Without this, two quick toggles interleave: the second + * call's stop runs while the first call is still awaiting listen(), finds displayServer still + * null, and does nothing — then the first call assigns a LISTENING server even though the + * saved state now says off. The port would stay open with the UI reporting "Off". + */ +let displayTransition = Promise.resolve(); + +function queueDisplayTransition(task) { + const run = () => task(); + const next = displayTransition.then(run, run); + displayTransition = next.then(() => {}, () => {}); + return next; +} + +function displayStatus() { + const listening = Boolean(displayServer && displayServer.isListening()); + const status = listening ? displayServer.status() : null; + const viewers = listening ? displayServer.viewers() : 0; + return { + enabled: displayConfig.enabled, + port: displayConfig.port, + listening, + viewers, + devices: status ? status.viewerDevices : 0, + // Read from the server, not sampled here: this UI's poll is suppressed for the whole of + // show mode, so sampling would report "nothing ever connected" after a service where + // three studios watched the entire time. + everHadViewer: Boolean(status && status.everHadViewer), + peakViewers: status ? status.peakViewers : 0, + addresses: status ? status.addresses : [], + mdnsName: status ? status.mdnsName : '', + mdnsConflict: status ? status.mdnsConflict : '', + lastError: displayError, + }; +} + +async function startDisplayServer() { + if (displayServer) return displayStatus(); + displayError = ''; + const candidate = createDisplayServer({ + client: ppClient, + port: displayConfig.port, + hostKind: 'band-lineup', + hostLabel: 'the Band Lineup computer backstage', + mdnsName: 'nownext', + appVersion: app.getVersion(), + }); + try { + await candidate.listen(); + // Re-check: hosting may have been switched off while listen() was in flight. + if (!displayConfig.enabled) { + await candidate.close().catch(() => {}); + return displayStatus(); + } + displayServer = candidate; + // Seed the screens with whatever is on stage right now. + candidate.publish(ppClient.getViewModel()); + } catch (error) { + await candidate.close().catch(() => {}); + // Deliberately NOT walking to the next free port: a moving port silently breaks every + // bookmark this feature exists to create. Say what happened and let the operator choose. + displayError = error?.code === 'EADDRINUSE' + ? `Port ${displayConfig.port} is already in use on this computer — another copy of Band Lineup, or another program, may already have it. Pick a different port.` + : `Could not start the display server: ${error instanceof Error ? error.message : String(error)}`; + } + return displayStatus(); +} + +async function stopDisplayServer() { + const current = displayServer; + displayServer = null; + // close() never stops the injected ppClient — this app's own column keeps running. + if (current) await current.close().catch(() => {}); + return displayStatus(); +} + +/** Tell every connected screen the ProPresenter connection changed. Never throws. */ +function displayNotifyStatus() { + try { + displayServer?.publishStatus(); + } catch (err) { + displayError = err instanceof Error ? err.message : String(err); + } +} + +async function persistDisplayConfig() { + const state = await loadStateRaw(); + state.displayServer = { enabled: displayConfig.enabled, port: displayConfig.port }; + await saveStateRaw(state); +} + +async function loadDisplayConfigFromDisk() { + const state = await loadStateRaw(); + displayConfig = normalizeDisplayServerConfig(state.displayServer); +} + +function parallaxMetaPath(fileName) { + return `${processedBaseName(fileName)}.json`; +} + +function parallaxLayerPath(fileName, layerName) { + return `${processedBaseName(fileName)}-${layerName}.png`; +} + +async function getDepthEstimator() { + if (!depthEstimatorPromise) { + depthEstimatorPromise = (async () => { + const { pipeline, env } = await import('@huggingface/transformers'); + env.cacheDir = modelCacheDir(); + env.allowLocalModels = true; + env.useFSCache = true; + return pipeline('depth-estimation', 'Xenova/dpt-hybrid-midas', { dtype: 'q8' }); + })(); + } + return depthEstimatorPromise; +} + +async function getBackgroundRemover() { + if (!backgroundRemoverPromise) { + backgroundRemoverPromise = (async () => { + const { pipeline, env } = await import('@huggingface/transformers'); + env.cacheDir = modelCacheDir(); + env.allowLocalModels = true; + env.useFSCache = true; + return pipeline('background-removal', 'Xenova/modnet', { dtype: 'q8' }); + })(); + } + return backgroundRemoverPromise; +} + +function smoothstep(edge0, edge1, x) { + const t = Math.max(0, Math.min(1, (x - edge0) / Math.max(1, edge1 - edge0))); + return t * t * (3 - 2 * t); +} + +function percentileFromHistogram(histogram, total, percentile) { + const target = total * percentile; + let running = 0; + for (let i = 0; i < histogram.length; i += 1) { + running += histogram[i]; + if (running >= target) return i; + } + return histogram.length - 1; +} + +function applyMaskToRgba(sourceData, width, height, alphaForPixel) { + const out = new Uint8ClampedArray(width * height * 4); + for (let i = 0; i < width * height; i += 1) { + const srcOffset = i * 4; + out[srcOffset] = sourceData[srcOffset]; + out[srcOffset + 1] = sourceData[srcOffset + 1]; + out[srcOffset + 2] = sourceData[srcOffset + 2]; + out[srcOffset + 3] = alphaForPixel(i); + } + return out; +} + +async function writeRawRgbaPng(filePath, width, height, data) { + await sharp(Buffer.from(data), { + raw: { + width, + height, + channels: 4, + }, + }) + .png() + .toFile(filePath); +} + +async function buildExpandedSubjectMask(width, height, foregroundData) { + const alpha = new Uint8ClampedArray(width * height); + for (let i = 0; i < width * height; i += 1) { + alpha[i] = foregroundData[i * 4 + 3]; + } + const edgePx = Math.max(8, Math.min(40, Math.round(10 * (Math.max(width, height) / 768)))); + return sharp(Buffer.from(alpha), { + raw: { + width, + height, + channels: 1, + }, + }) + .dilate(edgePx) + .blur(edgePx) + .raw() + .toBuffer(); +} + +function averageBackgroundColor(imageData, maskData) { + let r = 0; + let g = 0; + let b = 0; + let count = 0; + for (let i = 0; i < maskData.length; i += 1) { + const subject = maskData[i] / 255; + if (subject > 0.12) continue; + const offset = i * 4; + r += imageData[offset]; + g += imageData[offset + 1]; + b += imageData[offset + 2]; + count += 1; + } + if (!count) return { r: 24, g: 24, b: 24 }; + return { + r: Math.round(r / count), + g: Math.round(g / count), + b: Math.round(b / count), + }; +} + +async function existingParallaxAssets(fileName) { + const metaPath = parallaxMetaPath(fileName); + const bgPath = parallaxLayerPath(fileName, 'bg'); + const midPath = parallaxLayerPath(fileName, 'mid'); + const fgPath = parallaxLayerPath(fileName, 'fg'); + const depthPath = parallaxLayerPath(fileName, 'depth'); + if (!fsSync.existsSync(metaPath) || !fsSync.existsSync(bgPath) || !fsSync.existsSync(midPath) || !fsSync.existsSync(fgPath) || !fsSync.existsSync(depthPath)) { + return null; + } + try { + const meta = JSON.parse(await fs.readFile(metaPath, 'utf8')); + if (meta.version !== PARALLAX_VERSION) return null; + return { + bgUrl: pathToFileURL(bgPath).href, + midUrl: pathToFileURL(midPath).href, + fgUrl: pathToFileURL(fgPath).href, + depthUrl: pathToFileURL(depthPath).href, + width: meta.width, + height: meta.height, + }; + } catch { + return null; + } +} + +async function generateParallaxAssets(fileName) { + const existing = await existingParallaxAssets(fileName); + if (existing) return existing; + + const safeName = path.basename(fileName); + const sourcePath = path.join(imagesDir(), safeName); + if (!fsSync.existsSync(sourcePath)) return null; + + const image = sharp(sourcePath).rotate(); + const metadata = await image.metadata(); + const sourceWidth = metadata.width || 0; + const sourceHeight = metadata.height || 0; + if (!sourceWidth || !sourceHeight) return null; + + const scale = Math.min(1, PARALLAX_MAX_DIMENSION / Math.max(sourceWidth, sourceHeight)); + const width = Math.max(1, Math.round(sourceWidth * scale)); + const height = Math.max(1, Math.round(sourceHeight * scale)); + + const encoded = await image + .resize(width, height, { fit: 'contain', kernel: sharp.kernel.lanczos3 }) + .png() + .toBuffer(); + const baseRaw = await sharp(encoded).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + const bgRaw = await sharp(encoded).modulate({ brightness: 0.94, saturation: 1.0 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true }); + + const inputBlob = new Blob([encoded], { type: 'image/png' }); + const [depthEstimator, backgroundRemover] = await Promise.all([getDepthEstimator(), getBackgroundRemover()]); + const [depthOutput, foregroundOutput] = await Promise.all([ + depthEstimator(inputBlob), + backgroundRemover(inputBlob), + ]); + const depthImage = depthOutput.depth; + const depthData = depthImage.data; + const foregroundImage = Array.isArray(foregroundOutput) ? foregroundOutput[0] : foregroundOutput; + const foregroundRgba = foregroundImage.clone().rgba(); + const foregroundData = foregroundRgba.data; + const histogram = new Array(256).fill(0); + for (let i = 0; i < depthData.length; i += 1) { + histogram[depthData[i]] += 1; + } + const total = depthData.length; + const midThreshold = percentileFromHistogram(histogram, total, 0.56); + const frontThreshold = percentileFromHistogram(histogram, total, 0.82); + const subjectMaskData = await buildExpandedSubjectMask(width, height, foregroundData); + const avgBg = averageBackgroundColor(bgRaw.data, subjectMaskData); + const bgData = applyMaskToRgba(bgRaw.data, width, height, () => 255); + for (let i = 0; i < width * height; i += 1) { + const expandedAlpha = subjectMaskData[i] / 255; + if (expandedAlpha <= 0.02) continue; + const offset = i * 4; + const keep = 1 - expandedAlpha; + bgData[offset] = Math.round(bgData[offset] * keep + avgBg.r * expandedAlpha); + bgData[offset + 1] = Math.round(bgData[offset + 1] * keep + avgBg.g * expandedAlpha); + bgData[offset + 2] = Math.round(bgData[offset + 2] * keep + avgBg.b * expandedAlpha); + bgData[offset + 3] = 255; + } + const midData = applyMaskToRgba(baseRaw.data, width, height, (index) => { + const depth = depthData[index]; + const fgAlpha = foregroundData[index * 4 + 3] / 255; + const frontMask = smoothstep(frontThreshold - 16, frontThreshold + 8, depth); + const midMask = smoothstep(midThreshold - 18, midThreshold + 10, depth) * (1 - frontMask); + return Math.round(255 * fgAlpha * midMask); + }); + const fgData = applyMaskToRgba(baseRaw.data, width, height, (index) => { + const depth = depthData[index]; + const fgAlpha = foregroundData[index * 4 + 3] / 255; + const frontMask = smoothstep(frontThreshold - 16, frontThreshold + 8, depth); + return Math.round(255 * fgAlpha * frontMask); + }); + + const bgPath = parallaxLayerPath(fileName, 'bg'); + const midPath = parallaxLayerPath(fileName, 'mid'); + const fgPath = parallaxLayerPath(fileName, 'fg'); + const depthPath = parallaxLayerPath(fileName, 'depth'); + + await Promise.all([ + writeRawRgbaPng(bgPath, width, height, bgData), + writeRawRgbaPng(midPath, width, height, midData), + writeRawRgbaPng(fgPath, width, height, fgData), + depthImage.save(depthPath), + fs.writeFile( + parallaxMetaPath(fileName), + JSON.stringify({ version: PARALLAX_VERSION, width, height }, null, 2), + 'utf8', + ), + ]); + + return { + bgUrl: pathToFileURL(bgPath).href, + midUrl: pathToFileURL(midPath).href, + fgUrl: pathToFileURL(fgPath).href, + depthUrl: pathToFileURL(depthPath).href, + width, + height, + }; +} + +async function getOrCreateParallaxAssets(fileName) { + const safeName = path.basename(fileName); + if (!parallaxTaskCache.has(safeName)) { + parallaxTaskCache.set( + safeName, + generateParallaxAssets(safeName).finally(() => { + parallaxTaskCache.delete(safeName); + }), + ); + } + return parallaxTaskCache.get(safeName); +} + +function migrateState(data) { + if (data && Array.isArray(data.library) && Array.isArray(data.lineup)) { + const library = data.library.map((p) => normalizeSavedLibraryPerson(p)); + + for (const entry of data.lineup) { + if (!entry || typeof entry !== 'object') continue; + if (!entry.personId || typeof entry.nameLabel !== 'string' || !entry.nameLabel) continue; + const person = library.find((item) => item.id === String(entry.personId)); + if (person && !person.lastName) { + person.lastName = entry.nameLabel; + } + } + + const slotLabels = Array.isArray(data.slotLabels) + ? data.slotLabels.map((label) => (typeof label === 'string' ? label : '')) + : data.lineup.map((entry) => (entry && typeof entry.label === 'string' ? entry.label : '')); + const slotNames = Array.isArray(data.slotNames) + ? data.slotNames.map((label) => (typeof label === 'string' ? label : '')) + : data.lineup.map((entry) => (entry && typeof entry.nameLabel === 'string' ? entry.nameLabel : '')); + + return { + library, + layout: normalizeLayout(data.layout), + parallaxEnabled: typeof data.parallaxEnabled === 'boolean' ? data.parallaxEnabled : true, + slotLabels, + slotNames, + pcoServiceTypeId: typeof data.pcoServiceTypeId === 'string' ? data.pcoServiceTypeId : '', + pcoDisplayPositions: normalizePcoDisplayPositions(data.pcoDisplayPositions), + proPresenter: normalizeProPresenterConfig(data.proPresenter), + displayServer: normalizeDisplayServerConfig(data.displayServer), + birthdayMode: normalizeBirthdayMode(data.birthdayMode), + lineup: data.lineup.map((e) => { + if (!e || typeof e !== 'object') return { personId: '', label: '' }; + return { + personId: typeof e.personId === 'string' ? e.personId : '', + label: typeof e.label === 'string' ? e.label : '', + }; + }), + }; + } + if (data && Array.isArray(data.tiles)) { + const library = data.tiles.map((t) => ({ + id: String(t.id), + fileName: String(t.fileName), + originalName: '', + lastLabel: typeof t.label === 'string' ? t.label : '', + lastName: '', + source: 'local', + pcoPersonId: '', + pcoPlanPersonId: '', + })); + const lineup = data.tiles.map((t) => ({ + personId: String(t.id), + label: typeof t.label === 'string' ? t.label : '', + })); + return { + library, + layout: { columns: DEFAULT_COLUMNS, rows: DEFAULT_ROWS }, + parallaxEnabled: true, + slotLabels: lineup.map((slot) => slot.label || ''), + slotNames: [], + lineup, + pcoServiceTypeId: '', + pcoDisplayPositions: [], + proPresenter: { enabled: false, host: '', port: 0, playlistUuid: '', playlistName: '' }, + displayServer: { enabled: false, port: DEFAULT_DISPLAY_PORT }, + }; + } + return emptyState(); +} + +async function loadStateRaw() { + try { + const raw = await fs.readFile(statePath(), 'utf8'); + return migrateState(JSON.parse(raw)); + } catch { + return emptyState(); + } +} + +async function saveStateRaw(data) { + await ensureDirs(); + await fs.writeFile(statePath(), JSON.stringify(data, null, 2), 'utf8'); +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1280, + height: 720, + minWidth: 800, + minHeight: 480, + backgroundColor: '#0f0f12', + autoHideMenuBar: true, + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + }, + }); + + mainWindow.setAutoHideMenuBar(true); + mainWindow.loadFile('index.html'); + + mainWindow.on('closed', () => { + ppStop(); + mainWindow = null; + }); + + const template = [ + { + label: 'File', + submenu: [ + { + label: 'Add photos…', + accelerator: 'CmdOrCtrl+O', + click: () => mainWindow.webContents.send('menu-add-photos'), + }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + { + label: 'View', + submenu: [ + { role: 'togglefullscreen' }, + { role: 'reload' }, + { type: 'separator' }, + { role: 'toggleDevTools' }, + ], + }, + { + label: 'Help', + submenu: [ + { + label: 'About', + click: async () => { + const { readFileSync } = require('fs'); + let buildNumber = '1'; + try { + const pkg = JSON.parse(readFileSync(path.join(__dirname, 'package.json'), 'utf8')); + buildNumber = String(pkg.buildNumber ?? pkg.version); + } catch { + /* ignore */ + } + await dialog.showMessageBox(mainWindow, { + type: 'info', + title: 'About Band Lineup', + message: 'Band Lineup', + detail: `Version ${app.getVersion()}\nBuild ${buildNumber}\n\nPhotos and labels are saved automatically on this computer.`, + }); + }, + }, + ], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +if (gotTheLock) { + app.whenReady().then(async () => { + await ppLoadConfigFromDisk().catch(() => {}); + await loadDisplayConfigFromDisk().catch(() => {}); + createWindow(); + if (displayConfig.enabled) { + await queueDisplayTransition(() => startDisplayServer()); + } + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); + }); +} + +app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); +}); + +app.on('before-quit', () => { + // Best effort only: Electron does not wait for async work here. This gets the mDNS + // goodbye packet out and closes the listener politely; if we lose the race, the OS + // reclaims the port and viewers see "display offline" on their next poll anyway. + const current = displayServer; + displayServer = null; + if (current) void current.close().catch(() => {}); +}); + +ipcMain.handle('add-images-dialog', async () => { + const { canceled, filePaths } = await dialog.showOpenDialog(mainWindow, { + title: 'Add photos', + properties: ['openFile', 'multiSelections'], + filters: [ + { name: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'] }, + { name: 'All', extensions: ['*'] }, + ], + }); + if (canceled || !filePaths.length) return []; + const added = []; + for (const p of filePaths) { + const fileName = await copyImageToStore(p); + const id = crypto.randomBytes(12).toString('hex'); + added.push({ id, fileName, originalName: originalDisplayName(p) }); + } + return added; +}); + +ipcMain.handle('add-images-paths', async (_e, filePaths) => { + if (!Array.isArray(filePaths) || !filePaths.length) return []; + const added = []; + for (const p of filePaths) { + try { + if (!fsSync.existsSync(p)) continue; + const fileName = await copyImageToStore(p); + const id = crypto.randomBytes(12).toString('hex'); + added.push({ id, fileName, originalName: originalDisplayName(p) }); + } catch { + /* skip bad file */ + } + } + return added; +}); + +ipcMain.handle('load-state', async () => { + await ensureDirs(); + return loadStateRaw(); +}); + +ipcMain.handle('save-state', async (_e, payload) => { + const library = Array.isArray(payload?.library) ? payload.library : []; + const lineup = Array.isArray(payload?.lineup) ? payload.lineup : []; + const slotLabels = Array.isArray(payload?.slotLabels) ? payload.slotLabels : []; + const slotNames = Array.isArray(payload?.slotNames) ? payload.slotNames : []; + const layout = normalizeLayout(payload?.layout); + const parallaxEnabled = typeof payload?.parallaxEnabled === 'boolean' ? payload.parallaxEnabled : true; + const pcoServiceTypeId = typeof payload?.pcoServiceTypeId === 'string' ? payload.pcoServiceTypeId : ''; + const pcoDisplayPositions = normalizePcoDisplayPositions(payload?.pcoDisplayPositions); + const existing = await loadStateRaw(); + const proPresenter = payload?.proPresenter + ? normalizeProPresenterConfig(payload.proPresenter) + : normalizeProPresenterConfig(existing.proPresenter); + // The renderer never edits this (it is changed through the display-* IPC handlers), so + // always carry the stored value forward rather than letting a save blank it out. + const displayServer = normalizeDisplayServerConfig(existing.displayServer); + const birthdayMode = payload?.birthdayMode + ? normalizeBirthdayMode(payload.birthdayMode) + : normalizeBirthdayMode(existing.birthdayMode); + await saveStateRaw({ library, layout, parallaxEnabled, slotLabels, slotNames, lineup, pcoServiceTypeId, pcoDisplayPositions, proPresenter, displayServer, birthdayMode }); + return true; +}); + +ipcMain.handle('delete-library-image', async (_e, fileName) => { + if (!fileName || typeof fileName !== 'string' || fileName.includes('..')) return false; + const full = path.join(imagesDir(), path.basename(fileName)); + try { + await fs.unlink(full); + } catch { + /* already gone */ + } + return true; +}); + +ipcMain.handle('image-url', async (_e, fileName) => { + if (!fileName || typeof fileName !== 'string') return ''; + const safe = path.basename(fileName); + const full = path.join(imagesDir(), safe); + if (!fsSync.existsSync(full)) return ''; + return pathToFileURL(full).href; +}); + +ipcMain.handle('parallax-assets', async (_e, fileName) => { + if (!fileName || typeof fileName !== 'string') return null; + try { + return await getOrCreateParallaxAssets(fileName); + } catch { + return null; + } +}); + +ipcMain.handle('pco-status', async () => { + const credentials = await loadPcoCredentials(); + const data = await loadStateRaw(); + return { + connected: Boolean(credentials?.clientId && credentials?.secret), + pcoServiceTypeId: typeof data?.pcoServiceTypeId === 'string' ? data.pcoServiceTypeId : '', + pcoDisplayPositions: normalizePcoDisplayPositions(data?.pcoDisplayPositions), + }; +}); + +ipcMain.handle('pco-connect', async (_e, payload) => { + const clientId = String(payload?.clientId || '').trim(); + const secret = String(payload?.secret || '').trim(); + if (!clientId || !secret) { + throw new Error('Enter both the Planning Center client ID and secret.'); + } + const credentials = { clientId, secret }; + const serviceTypes = await listPcoServiceTypes(credentials); + await savePcoCredentials(credentials); + return { connected: true, serviceTypes }; +}); + +ipcMain.handle('pco-disconnect', async () => { + await clearPcoCredentials(); + return true; +}); + +ipcMain.handle('pco-service-types', async () => { + const credentials = await loadPcoCredentials(); + if (!credentials) { + throw new Error('Planning Center is not connected yet.'); + } + return listPcoServiceTypes(credentials); +}); + +ipcMain.handle('pco-position-options', async (_e, serviceTypeId) => { + const credentials = await loadPcoCredentials(); + if (!credentials) { + throw new Error('Planning Center is not connected yet.'); + } + return listPcoPositionOptions(serviceTypeId, credentials); +}); + +ipcMain.handle('pco-sync-upcoming-plan', async (_e, payload) => { + return syncUpcomingPcoPlan(payload); +}); + +/** + * Diagnostic behind the "Test Birthdays" button: does this Personal Access Token actually reach the + * People API and read birthdates, and how many synced band members have one on file. Read-only. + */ +ipcMain.handle('pco-test-birthdays', async () => { + const credentials = await loadPcoCredentials(); + if (!credentials) return { connected: false }; + + // 1. Can the token read the People API at all? + let probe; + try { + probe = await pcoFetchStatus('/people/v2/people', credentials, { per_page: 1, 'fields[Person]': 'birthdate' }); + } catch (err) { + return { connected: true, canRead: false, reason: 'network', detail: err instanceof Error ? err.message : String(err) }; + } + if (!probe.ok) { + const detail = + probe.payload?.errors?.map((e) => e.detail || e.title).filter(Boolean).join(' | ') || + `${probe.status}`; + return { connected: true, canRead: false, status: probe.status, detail }; + } + const sampleAttrs = probe.payload?.data?.[0]?.attributes || null; + const fieldExposed = sampleAttrs ? 'birthdate' in sampleAttrs : true; + + // 2. Coverage across the people we've actually synced (unique by person id). + const state = await loadStateRaw(); + const seen = new Set(); + const targets = []; + for (const p of Array.isArray(state.library) ? state.library : []) { + if (p?.source !== 'pco' || !p?.pcoPersonId || seen.has(p.pcoPersonId)) continue; + seen.add(p.pcoPersonId); + targets.push(p); + } + + let withBirthday = 0; + let notFound = 0; + let errors = 0; + const found = []; + for (const p of targets) { + let r; + try { + r = await pcoFetchBirthday(p.pcoPersonId, credentials); + } catch { + errors += 1; + continue; + } + if (r.status === 404) { notFound += 1; continue; } + if (r.status && r.status >= 400) { errors += 1; continue; } + if (r.birthday) { + withBirthday += 1; + found.push({ name: String(p.lastName || p.originalName || 'Unknown').trim() || 'Unknown', mmdd: r.birthday }); + } + } + + // Soonest upcoming birthday, for a concrete "it works" line. + const today = new Date(); + const daysUntil = (mmdd) => { + const [mm, dd] = mmdd.split('-').map(Number); + let next = new Date(today.getFullYear(), mm - 1, dd); + const midnight = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + if (next < midnight) next = new Date(today.getFullYear() + 1, mm - 1, dd); + return Math.round((next - midnight) / 86400000); + }; + const sortedFound = [...found].sort((a, b) => daysUntil(a.mmdd) - daysUntil(b.mmdd)); + const nextUp = sortedFound[0] ? { ...sortedFound[0], inDays: daysUntil(sortedFound[0].mmdd) } : null; + + return { + connected: true, + canRead: true, + fieldExposed, + total: targets.length, + withBirthday, + notFound, + errors, + found: sortedFound.slice(0, 8), + nextUp, + }; +}); + +ipcMain.handle('pp-status', async () => { + return { + enabled: ppConfig.enabled, + host: ppConfig.host, + port: ppConfig.port || '', + reachable: ppClient.getViewModel().reachable, + hasPassword: Boolean(ppConfig.password), + playlistUuid: ppConfig.playlistUuid || '', + playlistName: ppConfig.playlistName || '', + }; +}); + +ipcMain.handle('pp-playlists', async () => { + ppSyncClientConfig(); + return ppClient.listPlaylists(); +}); + +ipcMain.handle('pp-select-playlist', async (_e, payload) => { + const uuid = String(payload?.uuid || ''); + const name = String(payload?.name || ''); + ppConfig.playlistUuid = uuid; + ppConfig.playlistName = name; + // Resets the core's cached playlist so the next poll refetches the newly chosen one, + // and restarts the poll loop if it was already running. + ppClient.selectPlaylist({ uuid, name }); + + const state = await loadStateRaw(); + state.proPresenter = { + enabled: ppConfig.enabled, + host: ppConfig.host, + port: ppConfig.port, + playlistUuid: uuid, + playlistName: name, + }; + await saveStateRaw(state); + displayNotifyStatus(); + return true; +}); + +ipcMain.handle('pp-connect', async (_e, payload) => { + const host = String(payload?.host || '').trim(); + const portNum = Number.parseInt(String(payload?.port ?? ''), 10); + const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0; + const password = String(payload?.password || ''); + if (!host || !port) { + throw new Error('Enter the ProPresenter host (IP) and port.'); + } + + const prevPlaylistUuid = ppConfig.playlistUuid || ''; + const prevPlaylistName = ppConfig.playlistName || ''; + ppConfig = { enabled: true, host, port, password, playlistUuid: prevPlaylistUuid, playlistName: prevPlaylistName }; + ppSyncClientConfig(); + try { + await ppClient.testConnection(); + } catch (error) { + ppConfig.enabled = false; + ppSyncClientConfig(); + throw error; + } + + const state = await loadStateRaw(); + state.proPresenter = { enabled: true, host, port, playlistUuid: prevPlaylistUuid, playlistName: prevPlaylistName }; + await saveStateRaw(state); + await savePpPassword(password); + + ppClient.reset(); + ppStop(); + ppStart(); + displayNotifyStatus(); + return { connected: true, host, port }; +}); + +ipcMain.handle('pp-disconnect', async () => { + ppStop(); + ppConfig = { ...ppConfig, enabled: false, password: '' }; // keep host/port for easy reconnect + ppClient.reset(); + ppSyncClientConfig(); + + const state = await loadStateRaw(); + state.proPresenter = { + enabled: false, + host: ppConfig.host, + port: ppConfig.port, + playlistUuid: ppConfig.playlistUuid, + playlistName: ppConfig.playlistName, + }; + await saveStateRaw(state); + await clearPpPassword(); + + ppSendCleared(); + displayNotifyStatus(); + return true; +}); + +/** Version + build, for the toolbar label and for studio screens to confirm what they see. */ +ipcMain.handle('app-version', async () => { + let build = ''; + try { + // Present in the packaged asar as well as in a dev run. + build = String(require('./package.json').buildNumber || ''); + } catch { + build = ''; + } + return { version: app.getVersion(), build }; +}); + +ipcMain.handle('display-status', async () => displayStatus()); + +ipcMain.handle('display-set-enabled', async (_e, enabled) => queueDisplayTransition(async () => { + displayConfig.enabled = Boolean(enabled); + await persistDisplayConfig(); + if (displayConfig.enabled) return startDisplayServer(); + return stopDisplayServer(); +})); + +ipcMain.handle('display-set-port', async (_e, value) => { + const portNum = Number.parseInt(String(value ?? ''), 10); + // Floor at 1024: below that needs elevation on macOS/Linux, and it is where a mistyped or + // coerced value (an empty keypad entry used to become "1") would silently land and take + // every bookmark down with it. + const port = Number.isFinite(portNum) && portNum >= 1024 && portNum <= 65535 ? portNum : 0; + if (!port) { + throw new Error('Enter a port number between 1024 and 65535.'); + } + return queueDisplayTransition(async () => { + if (port === displayConfig.port) return displayStatus(); + displayConfig.port = port; + await persistDisplayConfig(); + if (displayServer) await stopDisplayServer(); + // Key the restart off the SETTING, not off whether a server object exists. The usual + // reason to change the port is that the previous one was taken — in which case there is + // no server to stop, and the old code fell straight through without trying again, + // leaving the stale "port N is in use" error on screen forever. + if (displayConfig.enabled) return startDisplayServer(); + return displayStatus(); + }); +}); + +// Goes through the main process on purpose: Electron's clipboard always works, while the +// renderer's navigator.clipboard depends on secure-context rules under a file:// origin. +ipcMain.handle('display-copy-url', async (_e, url) => { + clipboard.writeText(String(url || '')); + return true; +}); + +// Renderer calls this after attaching its 'pp-data' listener, so the first +// emit isn't missed when auto-reconnecting a saved connection on launch. +ipcMain.handle('pp-start', async () => { + if (!ppConfig.enabled || !ppConfig.host || !ppConfig.port) return false; + ppStop(); + ppStart(); + displayNotifyStatus(); + return true; +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..51182c8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6056 @@ +{ + "name": "band-lineup-display", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "band-lineup-display", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@huggingface/transformers": "^3.8.1", + "@img/sharp-libvips-win32-x64": "1.2.4", + "@img/sharp-win32-x64": "0.34.5", + "sharp": "^0.34.5" + }, + "devDependencies": { + "electron": "^33.2.0", + "electron-builder": "^25.1.8" + }, + "optionalDependencies": { + "@img/sharp-libvips-win32-x64": "^1.2.4", + "@img/sharp-win32-x64": "^0.34.5" + } + }, + "node_modules/@develar/schema-utils": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.0", + "ajv-keywords": "^3.4.1" + }, + "engines": { + "node": ">= 8.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" + }, + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@electron/get": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "env-paths": "^2.2.0", + "fs-extra": "^8.1.0", + "got": "^11.8.5", + "progress": "^2.0.3", + "semver": "^6.2.0", + "sumchecker": "^3.0.1" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "global-agent": "^3.0.0" + } + }, + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/notarize/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/notarize/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/osx-sign": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz", + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/@electron/osx-sign/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/@electron/osx-sign/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/osx-sign/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/rebuild": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz", + "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", + "debug": "^4.1.1", + "detect-libc": "^2.0.1", + "fs-extra": "^10.0.0", + "got": "^11.7.0", + "node-abi": "^3.45.0", + "node-api-version": "^0.2.0", + "node-gyp": "^9.0.0", + "ora": "^5.1.0", + "read-binary-file-arch": "^1.0.6", + "semver": "^7.3.5", + "tar": "^6.0.5", + "yargs": "^17.0.1" + }, + "bin": { + "electron-rebuild": "lib/cli.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/@electron/rebuild/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@electron/rebuild/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/rebuild/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@electron/universal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz", + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "^3.2.7", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" + }, + "engines": { + "node": ">=16.4" + } + }, + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.3.4", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", + "integrity": "sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/@electron/universal/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@electron/universal/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@gar/promisify": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.6.tgz", + "integrity": "sha512-MyMWyLnjqo+KRJYSH7oWNbsOn5onuIvfXYPcc0WOGxU0eHUV7oAYUoQTl2BMdu7ml+ea/bu11UM+EshbeHwtIA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-win32-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-win32-x64/-/sharp-libvips-win32-x64-1.2.4.tgz", + "integrity": "sha512-fpCMPMNm6z5dA0fEFTXn93JJJql0ya7Z5rSC80pd5IJE0/TBzRSGI1zx4CHYOSTBls26TxRGUxnfNgEJk4DNeA==", + "license": "LGPL-3.0-or-later", + "optional": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/fs-minipass/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@malept/cross-spawn-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/malept" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund" + } + ], + "license": "Apache-2.0", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/@malept/flatpak-bundler": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz", + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^9.0.0", + "lodash": "^4.17.15", + "tmp-promise": "^3.0.2" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@malept/flatpak-bundler/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promisify": "^1.1.3", + "semver": "^7.3.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@npmcli/fs/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@npmcli/move-file": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", + "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", + "deprecated": "This functionality has been moved to @npmcli/fs", + "dev": true, + "license": "MIT", + "dependencies": { + "mkdirp": "^1.0.4", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/fs-extra": { + "version": "9.0.13", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", + "integrity": "sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.37", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", + "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/plist": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/plist/-/plist-3.0.5.tgz", + "integrity": "sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*", + "xmlbuilder": ">=11.0.1" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/verror": { + "version": "1.10.11", + "resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.11.tgz", + "integrity": "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/7zip-bin": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", + "integrity": "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-bin": { + "version": "5.0.0-alpha.10", + "resolved": "https://registry.npmjs.org/app-builder-bin/-/app-builder-bin-5.0.0-alpha.10.tgz", + "integrity": "sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==", + "dev": true, + "license": "MIT" + }, + "node_modules/app-builder-lib": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-25.1.8.tgz", + "integrity": "sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@develar/schema-utils": "~2.6.5", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.1", + "@electron/rebuild": "3.6.1", + "@electron/universal": "2.0.1", + "@malept/flatpak-bundler": "^0.4.0", + "@types/fs-extra": "9.0.13", + "async-exit-hook": "^2.0.1", + "bluebird-lst": "^1.0.9", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chromium-pickle-js": "^0.2.0", + "config-file-ts": "0.2.8-rc1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "25.1.7", + "form-data": "^4.0.0", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "is-ci": "^3.0.0", + "isbinaryfile": "^5.0.0", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.0.0", + "resedit": "^1.7.0", + "sanitize-filename": "^1.6.3", + "semver": "^7.3.8", + "tar": "^6.1.12", + "temp-file": "^3.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "25.1.8", + "electron-builder-squirrel-windows": "25.1.8" + } + }, + "node_modules/app-builder-lib/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/are-we-there-yet": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", + "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/bluebird-lst": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/bluebird-lst/-/bluebird-lst-1.0.9.tgz", + "integrity": "sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bluebird": "^3.5.5" + } + }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-25.1.7.tgz", + "integrity": "sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "7zip-bin": "~5.2.0", + "app-builder-bin": "5.0.0-alpha.10", + "bluebird-lst": "^1.0.9", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.3", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "is-ci": "^3.0.0", + "js-yaml": "^4.1.0", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.2.10", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.10.tgz", + "integrity": "sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/builder-util/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/builder-util/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/builder-util/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/cacache": { + "version": "16.1.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", + "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^2.1.0", + "@npmcli/move-file": "^2.0.0", + "chownr": "^2.0.0", + "fs-minipass": "^2.1.0", + "glob": "^8.0.1", + "infer-owner": "^1.0.4", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "mkdirp": "^1.0.4", + "p-map": "^4.0.0", + "promise-inflight": "^1.0.1", + "rimraf": "^3.0.2", + "ssri": "^9.0.0", + "tar": "^6.1.11", + "unique-filename": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/cacache/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cacache/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cacache/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/cacache/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/cacache/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts": { + "version": "0.2.8-rc1", + "resolved": "https://registry.npmjs.org/config-file-ts/-/config-file-ts-0.2.8-rc1.tgz", + "integrity": "sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^10.3.12", + "typescript": "^5.4.3" + } + }, + "node_modules/config-file-ts/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-file-ts/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/config-file-ts/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/config-file-ts/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/crc": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz", + "integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.1.0" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " + } + }, + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/dmg-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-25.1.8.tgz", + "integrity": "sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "fs-extra": "^10.1.0", + "iconv-lite": "^0.6.2", + "js-yaml": "^4.1.0" + }, + "optionalDependencies": { + "dmg-license": "^1.0.11" + } + }, + "node_modules/dmg-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dmg-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/dmg-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/dmg-license": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/dmg-license/-/dmg-license-1.0.11.tgz", + "integrity": "sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "@types/plist": "^3.0.1", + "@types/verror": "^1.10.3", + "ajv": "^6.10.0", + "crc": "^3.8.0", + "iconv-corefoundation": "^1.1.7", + "plist": "^3.0.4", + "smart-buffer": "^4.0.2", + "verror": "^1.10.0" + }, + "bin": { + "dmg-license": "bin/dmg-license.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron": { + "version": "33.4.11", + "resolved": "https://registry.npmjs.org/electron/-/electron-33.4.11.tgz", + "integrity": "sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@electron/get": "^2.0.0", + "@types/node": "^20.9.0", + "extract-zip": "^2.0.1" + }, + "bin": { + "electron": "cli.js" + }, + "engines": { + "node": ">= 12.20.55" + } + }, + "node_modules/electron-builder": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-25.1.8.tgz", + "integrity": "sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==", + "dev": true, + "license": "MIT", + "dependencies": { + "app-builder-lib": "25.1.8", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "dmg-builder": "25.1.8", + "fs-extra": "^10.1.0", + "is-ci": "^3.0.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" + }, + "bin": { + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/electron-builder-squirrel-windows": { + "version": "25.1.8", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-25.1.8.tgz", + "integrity": "sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "25.1.8", + "archiver": "^5.3.1", + "builder-util": "25.1.7", + "fs-extra": "^10.1.0" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder-squirrel-windows/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-builder/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-builder/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-builder/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/electron-publish": { + "version": "25.1.7", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-25.1.7.tgz", + "integrity": "sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "builder-util": "25.1.7", + "builder-util-runtime": "9.2.10", + "chalk": "^4.1.2", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-publish/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-publish/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-publish/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz", + "integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", + "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.3", + "console-control-strings": "^1.1.0", + "has-unicode": "^2.0.1", + "signal-exit": "^3.0.7", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.5" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/global-agent/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/iconv-corefoundation": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz", + "integrity": "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "dependencies": { + "cli-truncate": "^2.1.0", + "node-addon-api": "^1.6.3" + }, + "engines": { + "node": "^8.11.2 || >=10" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infer-owner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", + "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", + "dev": true, + "license": "ISC" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-lambda": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", + "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/make-fetch-happen": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", + "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "agentkeepalive": "^4.2.1", + "cacache": "^16.1.0", + "http-cache-semantics": "^4.1.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "is-lambda": "^1.0.1", + "lru-cache": "^7.7.1", + "minipass": "^3.1.6", + "minipass-collect": "^1.0.2", + "minipass-fetch": "^2.0.3", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^0.6.3", + "promise-retry": "^2.0.1", + "socks-proxy-agent": "^7.0.0", + "ssri": "^9.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/make-fetch-happen/node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/make-fetch-happen/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-collect": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", + "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-fetch": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", + "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.1.6", + "minipass-sized": "^1.0.3", + "minizlib": "^2.1.2" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.6.tgz", + "integrity": "sha512-7Uf5gMJZ2kTkFisE3toGxT991s+cg+vMh42nbZGM2bNxfYVpkpqRudf1QrcOy72a3PwcL4JYqL+4NY7t0Hdd0A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^7.1.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-1.7.2.tgz", + "integrity": "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-api-version": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz", + "integrity": "sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + } + }, + "node_modules/node-api-version/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "9.4.1", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", + "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "glob": "^7.1.4", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^10.0.3", + "nopt": "^6.0.0", + "npmlog": "^6.0.0", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.2", + "which": "^2.0.2" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^12.13 || ^14.13 || >=16" + } + }, + "node_modules/node-gyp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/nopt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", + "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^1.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npmlog": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", + "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", + "deprecated": "This package is no longer supported.", + "dev": true, + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^3.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^4.0.3", + "set-blocking": "^2.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, + "node_modules/onnxruntime-node/node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/onnxruntime-node/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/onnxruntime-node/node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/onnxruntime-node/node_modules/tar": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", + "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/onnxruntime-node/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "license": "MIT" + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/path-scurry/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/pe-library": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz", + "integrity": "sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, + "node_modules/plist": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", + "integrity": "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise-inflight": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", + "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-binary-file-arch": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", + "integrity": "sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "bin": { + "read-binary-file-arch": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resedit": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", + "integrity": "sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pe-library": "^0.4.1" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jet2jet" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sanitize-filename": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", + "integrity": "sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==", + "dev": true, + "license": "WTFPL OR ISC", + "dependencies": { + "truncate-utf8-bytes": "^1.0.0" + } + }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", + "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^6.0.2", + "debug": "^4.3.3", + "socks": "^2.6.2" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/socks-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, + "node_modules/ssri": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", + "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.1.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/stat-mode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/stat-mode/-/stat-mode-1.0.0.tgz", + "integrity": "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/sumchecker": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", + "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.1.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tar/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/temp-file": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz", + "integrity": "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-exit-hook": "^2.0.1", + "fs-extra": "^10.0.0" + } + }, + "node_modules/temp-file/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/temp-file/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/temp-file/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tmp": "^0.2.0" + } + }, + "node_modules/truncate-utf8-bytes": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", + "integrity": "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==", + "dev": true, + "license": "WTFPL", + "dependencies": { + "utf8-byte-length": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unique-filename": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", + "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "unique-slug": "^3.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/unique-slug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", + "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utf8-byte-length": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz", + "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/verror": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz", + "integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5bffa80 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "band-lineup-display", + "productName": "Band Lineup", + "version": "1.3.10", + "buildNumber": "96", + "description": "Stage lineup photos with mic/pack labels", + "main": "main.js", + "scripts": { + "start": "electron .", + "pack": "electron-builder --dir", + "dist": "electron-builder --win --x64", + "postdist": "node scripts/archive-builds.js", + "archive": "node scripts/archive-builds.js" + }, + "author": "", + "license": "MIT", + "devDependencies": { + "electron": "^33.2.0", + "electron-builder": "^25.1.8" + }, + "build": { + "appId": "com.band.lineup.display", + "productName": "Band Lineup", + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "preload.js", + "index.html", + "renderer.js", + "styles.css", + "propresenter-core/**/*", + "propresenter-app/server.js", + "propresenter-app/config.js", + "propresenter-app/mdns.js", + "propresenter-app/public/**/*" + ], + "win": { + "target": [ + "nsis", + "portable" + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true, + "include": "build/installer.nsh", + "differentialPackage": false, + "useZip": true + } + }, + "dependencies": { + "@huggingface/transformers": "^3.8.1", + "sharp": "^0.34.5" + }, + "optionalDependencies": { + "@img/sharp-libvips-win32-x64": "^1.2.4", + "@img/sharp-win32-x64": "^0.34.5" + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..f999761 --- /dev/null +++ b/preload.js @@ -0,0 +1,39 @@ +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('lineup', { + addImagesDialog: () => ipcRenderer.invoke('add-images-dialog'), + addImagesPaths: (paths) => ipcRenderer.invoke('add-images-paths', paths), + loadState: () => ipcRenderer.invoke('load-state'), + saveState: (payload) => ipcRenderer.invoke('save-state', payload), + deleteLibraryImage: (fileName) => ipcRenderer.invoke('delete-library-image', fileName), + imageUrl: (fileName) => ipcRenderer.invoke('image-url', fileName), + parallaxAssets: (fileName) => ipcRenderer.invoke('parallax-assets', fileName), + pcoStatus: () => ipcRenderer.invoke('pco-status'), + pcoConnect: (payload) => ipcRenderer.invoke('pco-connect', payload), + pcoDisconnect: () => ipcRenderer.invoke('pco-disconnect'), + pcoServiceTypes: () => ipcRenderer.invoke('pco-service-types'), + pcoPositionOptions: (serviceTypeId) => ipcRenderer.invoke('pco-position-options', serviceTypeId), + pcoSyncUpcomingPlan: (payload) => ipcRenderer.invoke('pco-sync-upcoming-plan', payload), + pcoTestBirthdays: () => ipcRenderer.invoke('pco-test-birthdays'), + onMenuAddPhotos: (cb) => { + ipcRenderer.on('menu-add-photos', () => cb()); + }, + appVersion: () => ipcRenderer.invoke('app-version'), + display: { + status: () => ipcRenderer.invoke('display-status'), + setEnabled: (enabled) => ipcRenderer.invoke('display-set-enabled', enabled), + setPort: (port) => ipcRenderer.invoke('display-set-port', port), + copyUrl: (url) => ipcRenderer.invoke('display-copy-url', url), + }, + proPresenter: { + status: () => ipcRenderer.invoke('pp-status'), + connect: (payload) => ipcRenderer.invoke('pp-connect', payload), + disconnect: () => ipcRenderer.invoke('pp-disconnect'), + start: () => ipcRenderer.invoke('pp-start'), + listPlaylists: () => ipcRenderer.invoke('pp-playlists'), + selectPlaylist: (payload) => ipcRenderer.invoke('pp-select-playlist', payload), + onData: (cb) => { + ipcRenderer.on('pp-data', (_event, data) => cb(data)); + }, + }, +}); diff --git a/propresenter-app/README.md b/propresenter-app/README.md new file mode 100644 index 0000000..d2d5528 --- /dev/null +++ b/propresenter-app/README.md @@ -0,0 +1,233 @@ +# Stage Now / Next + +The ProPresenter **Now / Next** display, as its own program — the same feature that +lives in the Band Lineup app's right-hand column, but on its own so it can run on +studio machines, hallway TVs, green-room tablets, anywhere in the building. + +Both programs share one engine: [`../propresenter-core`](../propresenter-core/index.js). +Fix a ProPresenter quirk there and both get the fix. + +--- + +## The easiest way: let the Band Lineup app host it + +If the backstage machine already runs **Band Lineup**, you don't need to start anything here. +Open the app, and in the **People** panel find **Studio Displays** → **Turn On**. + +It then lists the addresses to use, best first, each with a **Copy** button. Open one on any +screen in the building and bookmark it. Nothing to install on those screens. + +Because the app hands the display its own ProPresenter connection, there is exactly one +connection to ProPresenter no matter how many screens are watching, and the studios always +show precisely what the backstage screen shows. The connection is still set up in one place — the +app's ProPresenter section — and the studio screens' own settings are locked, with a note +saying so. + +The displays are live whenever Band Lineup is open. Close the app and every screen says +**Display offline** and names the backstage computer, rather than quietly showing a frozen +lineup. They come back on their own when the app reopens — no reloading needed. + +### The bookmark that keeps working + +The first address in the list is `http://nownext.local:7654`. The app publishes that name +itself over mDNS, so the bookmark survives the backstage machine getting a new IP from DHCP. +It resolves on Macs, iPads and Windows 10/11. + +Do **not** bookmark the computer's own name (`http://backstage-pc.local:7654`) — Windows can +*resolve* mDNS names but never *publishes* its own, so Macs and iPads get nothing back. + +If `nownext.local` doesn't work on a particular screen (some managed networks and separate +VLANs block mDNS), use one of the numeric addresses below it. Once any screen has loaded +the page from an address, that address is relabelled **Confirmed working** and moves to the +top — so after the first studio connects, the app knows the right answer for your building. + +For a permanently fixed address, ask IT for a DHCP reservation on the backstage machine, and +optionally a DNS name pointing at it. One caveat if you go the DNS route: the zone must not +end in `.local`, or Apple devices will route the lookup to mDNS and it will fail. + +### If a studio screen can't load the page (Windows) + +Almost always Windows Defender Firewall. Two things make this confusing: + +- The prompt appears when the app **starts serving**, not when a studio first connects — so + the backstage machine looks perfectly fine on `localhost` while every other screen times out. +- If whoever answered the prompt wasn't a local admin, Windows creates **block** rules no + matter which button they pressed, and it never asks again. + +The installer adds the allow rules for you, so a normal install is fine. If you're running +an unpacked build or the rules got lost, an admin can restore them: + +```bash +netsh advfirewall firewall add rule name="Band Lineup Display (TCP)" dir=in action=allow program="C:\Program Files\Band Lineup\Band Lineup.exe" enable=yes profile=domain,private protocol=TCP +``` + +The app helps you spot this: if hosting is on and no screen has ever connected, the Studio +Displays status line says so and names the port to allow. + +Other things that break a working setup: the backstage PC sleeping (turn off sleep on a machine +that hosts displays), and Windows flipping the network profile to *Public* — the allow rules +above only cover Domain and Private. + +### Keep the studio screens awake + +The page asks the browser for a screen wake lock, but browsers only grant that on a secure +(HTTPS) origin, and this is served over plain HTTP on the LAN — so the request is always +refused. Turn off screen sleep in the OS settings on any machine or tablet you leave running +as a dedicated display; the web page cannot do it for you. + +--- + +## Security: what's protected, and what isn't + +Be clear-eyed about this one, because the honest answer is unusual. + +**Viewing is deliberately open.** Anyone who can reach the port can watch the page. There is no +password, and adding one was considered and rejected: a wall TV has to come back by itself +after a power cut with nobody standing there, and a login prompt, a key in the URL, or a +certificate warning all break exactly that. A key in the URL would also live forever in every +bookmark and in the address list volunteers copy into group chats. + +**More importantly, gating this page would not raise the bar much.** ProPresenter's own network +API on the same LAN has no authentication at all, and unlike this page it is not read-only. +Anyone who can reach this display can already read the same service order straight from +ProPresenter — and put whatever they like on the main screen. **Network placement is the control +that actually covers both.** + +### What is protected + +- **Nothing can be changed over the network** while the Band Lineup app is hosting. The + connection routes return `409`, so a browser on the LAN cannot retarget or disconnect the + display. Standalone additionally requires `application/json`, a same-origin request, and a + recognised `Host` — which blocks the JavaScript-free form CSRF and DNS-rebinding writes that + are otherwise possible against a local HTTP server. +- **Hidden items stay hidden.** Anything flagged hidden in ProPresenter — cut songs, contingency + cues, staff-only slides — has its name stripped before it leaves the machine, not merely + skipped when drawing the page. +- **The playlist library is not exposed.** `/api/playlists` is refused while the app is hosting. +- **Connection limits.** Concurrent viewers are capped (32 total, 6 per device) so nobody can pile + up connections inside the app, and a screen that stops reading is dropped rather than buffered + forever. Established screens are never dropped to make room for a new one. +- **The mDNS name can't be stolen with one packet.** A claim on `nownext.local` is verified by + probing before this host stands aside, and it reclaims the name when the other host leaves. + +### What is exposed + +Song and sermon titles, slide numbers, and the upcoming service order — in the clear, over plain +HTTP, to anything that can reach the port. Also the ProPresenter machine's address and port, and +the host's LAN addresses. Decide whether an unannounced sermon title or a baptism in the order +matters to you before a service; if it does, the answer is the VLAN below, not a password. + +### Where this should live on the network + +- Backstage PC, ProPresenter, and the display screens on a **trusted A/V or management VLAN**. +- **Guest Wi-Fi kept off that VLAN.** This is the single highest-value control here. +- A **DHCP reservation** for the backstage machine — which you want anyway so bookmarks survive. +- If you ever set a ProPresenter network password, treat it as a LAN-visible string: it travels + in the query string of ProPresenter's own API on every poll, because that is the interface + ProPresenter offers. + +--- + +## Run it standalone + +For a machine that doesn't run Band Lineup — or to host the display without it — run the +server on its own. Every other screen just opens a web page. + +```bash +cd propresenter-app +node server.js +``` + +Then open `http://localhost:7654` on that machine, press the **gear** (bottom-right, +appears when you move the mouse or tap), and enter the IP and port of the computer +running ProPresenter. That's it — the connection is stored on the server, so every +other screen that opens the page is already following along. + +The startup log prints the addresses other screens should use, e.g. +`http://10.1.1.42:7654`. They're also listed in the settings panel under +**Other screens**. + +Requirements: Node 18 or newer. No `npm install`, no dependencies. + +### ProPresenter side + +Turn the network API on in **ProPresenter ▸ Preferences ▸ Network**. Note the port +(1025 by default) and set a password there if you want one. + +## Run it as an app window + +For the machine that should *host* the display, there's an Electron wrapper that +starts the same server and shows the page in a window: + +```bash +npm run kiosk +``` + +```bash +npm run kiosk -- --kiosk +``` + +The second form is true kiosk: no window chrome, fullscreen, for a dedicated screen. +Either way the server still serves the rest of the building. + +## Options + +| What | How | +| --- | --- | +| Different port | `PORT=9000 node server.js` | +| Lock the settings so viewers can't change the connection | `PP_DISPLAY_READONLY=1 node server.js` | +| Move where `config.json` lives (a NAS, a shared path) | `PP_DISPLAY_DATA_DIR=/path/to/dir node server.js` | + +Per-screen looks — text size (Small → Huge), whether to show the playlist name, the +progress bar, and whether to tidy titles — are set in the gear panel and saved on +**that device only**, so a 65" TV and an iPad can each be sized for their own room. + +Keyboard: `f` toggles fullscreen, `Esc` closes the settings panel. + +## Running it as a service + +Any process manager works since it's a plain Node script. For example, with `pm2`: + +```bash +pm2 start server.js --name stage-now-next -- --port 7654 +``` + +## What's in here + +| File | Job | +| --- | --- | +| `server.js` | Serves the UI and pushes the live view-model to every browser over SSE. Standalone it polls ProPresenter itself; embedded it mirrors the client the Band Lineup app is already polling with | +| `config.js` | Reads/writes `config.json` (password obfuscated at rest — treat the file as a secret), and ranks which LAN address to bookmark | +| `mdns.js` | Publishes `nownext.local` so a bookmark survives a DHCP change. Zero dependencies | +| `public/index.html`, `public/app.js`, `public/styles.css` | The display itself and the settings panel | +| `main.js` | Optional Electron kiosk wrapper | +| `../propresenter-core/index.js` | The shared ProPresenter client — also used by the Band Lineup app | + +## API + +Anything on the LAN can read the live state, which makes it easy to hang other +displays or automations off the same feed: + +| Endpoint | Returns | +| --- | --- | +| `GET /api/state` | `{ state, status }` — one JSON snapshot | +| `GET /api/stream` | Server-Sent Events: `state` on every poll, `status` on connection changes | +| `GET /api/status` | Connection info only | +| `GET /api/playlists` | Every playlist ProPresenter knows about | +| `GET /api/health` | `{ ok, viewers, reachable }` | +| `POST /api/connect` | `{ host, port, password? }` — tests, saves, and starts following | +| `POST /api/disconnect` | Stops following; keeps host/port for next time | + +The two `POST` routes return `403` when `PP_DISPLAY_READONLY=1`, and `409` when the Band +Lineup app is hosting — a browser on the LAN must never be able to retarget or disconnect +the backstage app's own ProPresenter connection. + +## Notes + +- **The display auto-follows whatever ProPresenter is presenting** — the live playlist + and item come from ProPresenter itself, so there's nothing to pick each week. +- **Slide totals respect arrangements.** If an arrangement repeats a chorus, the total + counts the repeat, so "Slide 41 / 43" always matches what the operator sees. +- **Building a Windows installer** (`npm run dist`) needs `npm install` in this folder + first, and packages `../propresenter-core` alongside — keep the two folders siblings. + The everyday `node server.js` path needs neither step. diff --git a/propresenter-app/config.js b/propresenter-app/config.js new file mode 100644 index 0000000..eeb4741 --- /dev/null +++ b/propresenter-app/config.js @@ -0,0 +1,279 @@ +'use strict'; + +/** + * Config storage for the standalone Now/Next display. + * + * The file format matches the Band Lineup app's credential files (an `enc:` prefix + * for OS-encrypted secrets, `plain:` for base64) so the two programs stay readable + * to each other. Under Electron we use safeStorage; under plain `node server.js` + * there is no OS keychain hook, so the password is base64-obfuscated at rest — + * treat the config file as a secret either way. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const dgram = require('dgram'); + +const DEFAULT_CONFIG = { + enabled: false, + host: '', + port: 0, + password: '', +}; + +/** Electron's safeStorage when available (kiosk mode); null under plain Node. */ +function safeStorage() { + try { + // eslint-disable-next-line global-require + const electron = require('electron'); + if (electron?.safeStorage?.isEncryptionAvailable?.()) return electron.safeStorage; + } catch { + /* not running under Electron */ + } + return null; +} + +function encodeSecret(value) { + const text = String(value ?? ''); + if (!text) return ''; + const store = safeStorage(); + if (store) return `enc:${store.encryptString(text).toString('base64')}`; + return `plain:${Buffer.from(text, 'utf8').toString('base64')}`; +} + +function decodeSecret(value) { + const raw = String(value ?? ''); + if (!raw) return ''; + if (raw.startsWith('enc:')) { + const store = safeStorage(); + if (!store) return ''; // written under Electron, now read by plain Node — can't decrypt + try { + return store.decryptString(Buffer.from(raw.slice(4), 'base64')); + } catch { + return ''; + } + } + if (raw.startsWith('plain:')) return Buffer.from(raw.slice(6), 'base64').toString('utf8'); + return raw; +} + +/** + * Where config.json lives. Override with PP_DISPLAY_DATA_DIR (handy on a NAS or + * when several displays share one host). Electron callers pass app.getPath('userData'). + */ +function resolveDataDir(preferred) { + const fromEnv = process.env.PP_DISPLAY_DATA_DIR; + if (fromEnv) return path.resolve(fromEnv); + if (preferred) return path.resolve(preferred); + return path.join(__dirname, 'data'); +} + +function configPath(dataDir) { + return path.join(dataDir, 'config.json'); +} + +function normalize(entry) { + const host = String(entry?.host || '').trim(); + const portNum = Number.parseInt(String(entry?.port ?? ''), 10); + const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0; + return { + enabled: Boolean(entry?.enabled) && Boolean(host) && Boolean(port), + host, + port, + password: String(entry?.password || ''), + }; +} + +function loadConfig(dataDir) { + try { + const raw = JSON.parse(fs.readFileSync(configPath(dataDir), 'utf8')); + return normalize({ + enabled: raw.enabled, + host: raw.host, + port: raw.port, + password: decodeSecret(raw.password), + }); + } catch { + return { ...DEFAULT_CONFIG }; + } +} + +function saveConfig(dataDir, config) { + const clean = normalize(config); + fs.mkdirSync(dataDir, { recursive: true }); + fs.writeFileSync( + configPath(dataDir), + JSON.stringify( + { + enabled: clean.enabled, + host: clean.host, + port: clean.port, + password: encodeSecret(clean.password), + }, + null, + 2, + ), + 'utf8', + ); + return clean; +} + +/* ------------------------------------------------------------------ * + * Which address should someone actually bookmark? * + * * + * An A/V machine backstage is the worst case: alongside the house LAN * + * LAN it often has a camera/Dante network, a VPN tunnel, and on Windows * + * Hyper-V/WSL switches that hand out plausible-looking 192.168.x * + * addresses. Listing every non-internal IPv4 unranked (what we used to * + * do) gives a volunteer five equal-looking URLs and no way to choose. * + * So: hard-filter the impossible ones, score the rest, and let two * + * stronger signals override the score — the OS's own default route, * + * and (best of all) the local address a real viewer actually reached * + * us on. * + * ------------------------------------------------------------------ */ + +/** Adapter names that are never the church LAN. Windows keys are friendly names. */ +const VIRTUAL_IFACE = [ + /vethernet/i, /hyper-?v/i, /\bwsl\b/i, /virtualbox/i, /vmware/i, /vmnet/i, /docker/i, + /tailscale/i, /zerotier/i, /wintun/i, /wireguard/i, /nordlynx/i, /openvpn/i, /^tap-/i, + /radmin/i, /hamachi/i, /npcap/i, /bluetooth/i, /teredo/i, /isatap/i, /wan miniport/i, + /loopback pseudo/i, /^utun/i, /^awdl/i, /^llw/i, /^bridge/i, /^ipsec/i, /^gif/i, /^stf/i, +]; + +/** Addresses that can never carry a bookmark, whatever adapter they came from. */ +function isImpossibleAddress(address) { + if (/^169\.254\./.test(address)) return true; // DHCP failed on that NIC + if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(address)) return true; // CGNAT / Tailscale + if (/^25\./.test(address)) return true; // Hamachi + if (/^192\.168\.56\./.test(address)) return true; // VirtualBox host-only default + return false; +} + +function isPrivate(address) { + return /^10\./.test(address) + || /^192\.168\./.test(address) + || /^172\.(1[6-9]|2\d|3[01])\./.test(address); +} + +/** Locally-administered MAC bit — set on virtual/bridged adapters. */ +function isLocallyAdministeredMac(mac) { + const firstOctet = Number.parseInt(String(mac || '').split(':')[0], 16); + return Number.isFinite(firstOctet) && (firstOctet & 0x02) !== 0; +} + +function scoreAddress(entry, ifaceName) { + let score = 0; + if (/^192\.168\./.test(entry.address)) score += 40; // overwhelmingly the church-LAN shape + else if (/^10\./.test(entry.address)) score += 30; + else if (/^172\./.test(entry.address)) score += 20; + else score -= 60; // public or unrecognised + if (entry.netmask === '255.255.255.0') score += 10; + if (/^ethernet/i.test(ifaceName)) score += 8; // prefer wired on an A/V machine + else if (/wi-?fi|wlan|^en0$/i.test(ifaceName)) score += 4; + if (isLocallyAdministeredMac(entry.mac)) score -= 25; + return score; +} + +/** + * Ranked candidate addresses for viewers, best first. + * @param {object} [hints] + * @param {string} [hints.defaultRoute] Address of the interface holding the default route. + * @param {string[]} [hints.observed] Local addresses real viewers have already connected to. + */ +function viewerAddresses(hints = {}) { + const defaultRoute = String(hints.defaultRoute || ''); + const observed = new Set((hints.observed || []).filter(Boolean)); + const out = []; + const interfaces = os.networkInterfaces(); + + for (const [ifaceName, entries] of Object.entries(interfaces)) { + if (VIRTUAL_IFACE.some((pattern) => pattern.test(ifaceName))) continue; + for (const entry of entries || []) { + // Node <18 reported family as a string, newer as the number 4 — accept both. + const isIPv4 = entry.family === 'IPv4' || entry.family === 4; + if (!isIPv4 || entry.internal) continue; + if (isImpossibleAddress(entry.address)) continue; + if (entry.mac === '00:00:00:00:00:00') continue; // tunnel / pseudo adapter + + let score = scoreAddress(entry, ifaceName); + if (entry.address === defaultRoute) score += 100; // the OS's own answer beats any heuristic + const confirmed = observed.has(entry.address); + if (confirmed) score += 1000; // a real viewer reached us here + + out.push({ + address: entry.address, + iface: ifaceName, + netmask: entry.netmask, + private: isPrivate(entry.address), + confirmed, + score, + }); + } + } + + out.sort((a, b) => b.score - a.score || a.address.localeCompare(b.address)); + return out; +} + +/** + * The address on the interface that holds the default route, per the OS routing table. + * A UDP "connect" sends no packets — it only asks the kernel which source address it + * would use — so this is free and side-effect-less. + */ +function defaultRouteAddress() { + return new Promise((resolve) => { + let settled = false; + const done = (value) => { + if (settled) return; + settled = true; + resolve(value); + }; + let socket; + try { + socket = dgram.createSocket('udp4'); + } catch { + done(''); + return; + } + socket.once('error', () => { + try { socket.close(); } catch { /* already closed */ } + done(''); + }); + const timer = setTimeout(() => { + try { socket.close(); } catch { /* already closed */ } + done(''); + }, 400); + timer.unref?.(); + try { + socket.connect(53, '203.0.113.1', () => { // RFC 5737 documentation address; nothing is sent + let address = ''; + try { address = socket.address().address || ''; } catch { /* ignore */ } + clearTimeout(timer); + try { socket.close(); } catch { /* already closed */ } + done(address); + }); + } catch { + clearTimeout(timer); + try { socket.close(); } catch { /* already closed */ } + done(''); + } + }); +} + +/** Legacy helper: flat list of plausible addresses, best first. */ +function lanAddresses() { + return viewerAddresses().map((entry) => entry.address); +} + +module.exports = { + DEFAULT_CONFIG, + resolveDataDir, + configPath, + loadConfig, + saveConfig, + normalize, + lanAddresses, + viewerAddresses, + defaultRouteAddress, +}; diff --git a/propresenter-app/main.js b/propresenter-app/main.js new file mode 100644 index 0000000..b71f3a1 --- /dev/null +++ b/propresenter-app/main.js @@ -0,0 +1,143 @@ +'use strict'; + +/** + * Stage Now / Next — Electron kiosk wrapper. + * + * Optional. The display itself is just a web page served by server.js, so a studio + * can simply open the URL in a browser. This wrapper exists for the machine that + * should *host* the display: it starts the same server in-process and puts the page + * in a borderless window, while still serving every other screen on the network. + * + * npm run kiosk → window on the host machine + * npm run kiosk -- --kiosk → true kiosk (no window chrome, always fullscreen) + */ + +const { app, BrowserWindow, Menu, shell, dialog } = require('electron'); +const { createDisplayServer, DEFAULT_PORT } = require('./server'); + +/** How many ports to try past the preferred one before giving up. */ +const PORT_ATTEMPTS = 5; + +let mainWindow = null; +let display = null; + +const gotTheLock = app.requestSingleInstanceLock(); +if (!gotTheLock) { + app.quit(); +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); + + app.whenReady().then(async () => { + try { + display = await startServer(); + } catch (error) { + dialog.showErrorBox( + 'Stage Now / Next', + `Could not start the display server.\n\n${error instanceof Error ? error.message : String(error)}`, + ); + app.quit(); + return; + } + createWindow(); + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); + }); + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); + }); + + app.on('before-quit', async () => { + if (display) await display.close().catch(() => {}); + }); +} + +async function startServer() { + const preferred = Number.parseInt(process.env.PORT || '', 10) || DEFAULT_PORT; + let lastError = null; + for (let attempt = 0; attempt < PORT_ATTEMPTS; attempt += 1) { + const candidate = createDisplayServer({ + port: preferred + attempt, + dataDir: app.getPath('userData'), + }); + try { + await candidate.listen(); + return candidate; + } catch (error) { + await candidate.close().catch(() => {}); + lastError = error; + // Another display (or anything else) already holds that port — step to the next one. + if (error?.code !== 'EADDRINUSE') throw error; + } + } + throw lastError || new Error('No free port found.'); +} + +function createWindow() { + const kiosk = process.argv.includes('--kiosk'); + mainWindow = new BrowserWindow({ + width: 1280, + height: 800, + backgroundColor: '#0f0f12', + autoHideMenuBar: true, + kiosk, + fullscreen: kiosk, + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + }, + }); + + buildMenu(); + mainWindow.loadURL(`http://127.0.0.1:${display.port}/`); + + // Anything that tries to open a new window (there is nothing today) goes to the real browser. + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + shell.openExternal(url); + return { action: 'deny' }; + }); + + mainWindow.on('closed', () => { + mainWindow = null; + }); +} + +function buildMenu() { + const urls = display.status().viewerUrls; + const template = [ + ...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []), + { + label: 'Display', + submenu: [ + { + label: 'Toggle Fullscreen', + accelerator: process.platform === 'darwin' ? 'Ctrl+Cmd+F' : 'F11', + click: () => mainWindow?.setFullScreen(!mainWindow.isFullScreen()), + }, + { role: 'reload' }, + { type: 'separator' }, + { + label: 'Copy Address For Other Screens', + enabled: urls.length > 0, + click: () => { + const { clipboard } = require('electron'); + clipboard.writeText(urls[0]); + }, + }, + { + label: urls.length ? `Serving ${urls.join(' · ')}` : 'No network address detected', + enabled: false, + }, + { type: 'separator' }, + { role: 'quit' }, + ], + }, + ]; + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} diff --git a/propresenter-app/mdns.js b/propresenter-app/mdns.js new file mode 100644 index 0000000..0c37dfd --- /dev/null +++ b/propresenter-app/mdns.js @@ -0,0 +1,570 @@ +'use strict'; + +/** + * A minimal multicast-DNS responder — just enough to make one name resolve. + * + * WHY THIS EXISTS: a bookmark has to survive DHCP handing the host a new address. + * The obvious answer, "bookmark the computer's name", does not work here: Windows + * ships an mDNS *resolver* but no *responder*, so it never publishes its own + * .local, and an iPad or Mac asking for it gets silence. (Windows-to-Windows + * appears to work only via LLMNR/NetBIOS, which Microsoft is ramping down.) + * So the app publishes its OWN name — nownext.local by default — which macOS, iOS + * and Windows 10 1703+/11 all resolve natively. + * + * Scope on purpose: one A record for one name. No service discovery (_http._tcp), + * no PTR/SRV/TXT, no full RFC 6762 probing state machine. Zero dependencies. + * + * Limits worth knowing: mDNS is link-local (TTL 1) and does not cross VLANs or + * subnets, and it needs inbound UDP 5353 through the firewall. The ranked IP + * addresses remain the fallback for anywhere this can't reach. + */ + +const dgram = require('dgram'); +const os = require('os'); + +const MDNS_ADDRESS = '224.0.0.251'; +const MDNS_PORT = 5353; +const TYPE_A = 1; +const TYPE_ANY = 255; +const CLASS_IN = 1; +/** Set on unique records so resolvers replace rather than accumulate. */ +const CACHE_FLUSH = 0x8000; +const DEFAULT_TTL = 120; + +function ipToInt(address) { + const parts = String(address).split('.'); + if (parts.length !== 4) return null; + let value = 0; + for (const part of parts) { + const octet = Number.parseInt(part, 10); + if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null; + value = (value * 256) + octet; + } + return value; +} + +function sameSubnet(addressA, addressB, netmask) { + const a = ipToInt(addressA); + const b = ipToInt(addressB); + const mask = ipToInt(netmask); + if (a === null || b === null || mask === null) return false; + // >>> 0 keeps the result unsigned; a /0 mask would match everything, so guard it. + return mask !== 0 && ((a & mask) >>> 0) === ((b & mask) >>> 0); +} + +/** Encode a dotted name as length-prefixed DNS labels. */ +function encodeName(name) { + const labels = String(name).replace(/\.$/, '').split('.'); + const parts = []; + for (const label of labels) { + const bytes = Buffer.from(label, 'utf8'); + if (bytes.length > 63) throw new Error(`mDNS label too long: ${label}`); + parts.push(Buffer.from([bytes.length]), bytes); + } + parts.push(Buffer.from([0])); + return Buffer.concat(parts); +} + +/** + * Read a DNS name at `offset`, following compression pointers. + * Returns the name plus the offset just past the name in the ORIGINAL position. + */ +function decodeName(buffer, offset) { + const labels = []; + let cursor = offset; + let end = -1; + let hops = 0; + while (cursor < buffer.length) { + const length = buffer[cursor]; + if (length === 0) { + cursor += 1; + break; + } + if ((length & 0xc0) === 0xc0) { + // Compression pointer: follow it, but the caller continues after the pointer. + if (cursor + 1 >= buffer.length) return null; + if (end === -1) end = cursor + 2; + cursor = ((length & 0x3f) << 8) | buffer[cursor + 1]; + hops += 1; + if (hops > 16) return null; // malformed / pointer loop + continue; + } + if (cursor + 1 + length > buffer.length) return null; + labels.push(buffer.toString('utf8', cursor + 1, cursor + 1 + length)); + cursor += 1 + length; + } + return { name: labels.join('.'), offset: end === -1 ? cursor : end }; +} + +/** Parse the question section of a query. Returns null for anything malformed. */ +function decodeQuestions(buffer) { + if (buffer.length < 12) return null; + const flags = buffer.readUInt16BE(2); + if ((flags & 0x8000) !== 0) return { isResponse: true, questions: [] }; // a response, not a query + const count = buffer.readUInt16BE(4); + if (count === 0 || count > 32) return { isResponse: false, questions: [] }; + const questions = []; + let offset = 12; + for (let i = 0; i < count; i += 1) { + const decoded = decodeName(buffer, offset); + if (!decoded) return null; + offset = decoded.offset; + if (offset + 4 > buffer.length) return null; + const type = buffer.readUInt16BE(offset); + const rawClass = buffer.readUInt16BE(offset + 2); + offset += 4; + questions.push({ + name: decoded.name, + type, + class: rawClass & 0x7fff, + // The top bit of QCLASS is the "unicast response wanted" flag (RFC 6762 §5.4). + wantsUnicast: (rawClass & 0x8000) !== 0, + }); + } + return { isResponse: false, questions }; +} + +/** + * Parse the answer section of a response, returning the A records only. + * Needed for conflict detection: enterprise APs and mesh routers commonly REFLECT mDNS, + * so our own announcement comes back from the reflector's address. Matching on raw bytes + * flags that as a name clash; comparing the actual advertised addresses does not. + */ +function decodeAnswers(buffer) { + if (buffer.length < 12) return []; + const answerCount = buffer.readUInt16BE(6); + if (answerCount === 0 || answerCount > 64) return []; + const questionCount = buffer.readUInt16BE(4); + let offset = 12; + // Skip the question section, if the responder echoed it. + for (let i = 0; i < questionCount; i += 1) { + const decoded = decodeName(buffer, offset); + if (!decoded) return []; + offset = decoded.offset + 4; + } + const answers = []; + for (let i = 0; i < answerCount; i += 1) { + const decoded = decodeName(buffer, offset); + if (!decoded) break; + offset = decoded.offset; + if (offset + 10 > buffer.length) break; + const type = buffer.readUInt16BE(offset); + const rdLength = buffer.readUInt16BE(offset + 8); + offset += 10; + if (offset + rdLength > buffer.length) break; + if (type === TYPE_A && rdLength === 4) { + answers.push({ + name: decoded.name, + address: `${buffer[offset]}.${buffer[offset + 1]}.${buffer[offset + 2]}.${buffer[offset + 3]}`, + }); + } + offset += rdLength; + } + return answers; +} + +/** Build a standard mDNS query for one name (type A, class IN). */ +function buildQuery(name) { + const header = Buffer.alloc(12); + header.writeUInt16BE(0, 0); // mDNS queries use id 0 + header.writeUInt16BE(0, 2); // standard query + header.writeUInt16BE(1, 4); // one question + const question = Buffer.alloc(4); + question.writeUInt16BE(TYPE_A, 0); + question.writeUInt16BE(CLASS_IN, 2); + return Buffer.concat([header, encodeName(name), question]); +} + +/** Build a response packet carrying one A record per address. */ +function buildResponse(name, addresses, ttl, queryId) { + const encodedName = encodeName(name); + const answers = addresses.map((address) => { + const rdata = Buffer.from(String(address).split('.').map((octet) => Number.parseInt(octet, 10))); + const record = Buffer.alloc(10); + record.writeUInt16BE(TYPE_A, 0); + record.writeUInt16BE(CLASS_IN | CACHE_FLUSH, 2); + record.writeUInt32BE(ttl, 4); + record.writeUInt16BE(4, 8); + return Buffer.concat([encodedName, record, rdata]); + }); + + const header = Buffer.alloc(12); + header.writeUInt16BE(queryId || 0, 0); + header.writeUInt16BE(0x8400, 2); // QR=1 (response), AA=1 (authoritative) + header.writeUInt16BE(0, 4); // no question echoed back + header.writeUInt16BE(answers.length, 6); + header.writeUInt16BE(0, 8); + header.writeUInt16BE(0, 10); + return Buffer.concat([header, ...answers]); +} + +/** + * @param {object} [options] + * @param {string} [options.name] Bare label to publish; ".local" is appended. Default "nownext". + * @param {() => string[]} [options.getAddresses] Current IPv4 addresses to advertise, best first. + * @param {(message: string) => void} [options.log] + */ +function createMdnsResponder(options = {}) { + const label = String(options.name || 'nownext').replace(/[^a-zA-Z0-9-]/g, '').toLowerCase() || 'nownext'; + const fqdn = `${label}.local`; + const ttl = Number.isFinite(options.ttl) && options.ttl > 0 ? options.ttl : DEFAULT_TTL; + const getAddresses = typeof options.getAddresses === 'function' ? options.getAddresses : () => []; + const log = typeof options.log === 'function' ? options.log : () => {}; + + let socket = null; + let started = false; + let announceTimer = null; + let lastError = ''; + let conflictHost = ''; + /** True while we have stood down from the name because another host really holds it. */ + let relinquished = false; + /** In-flight conflict verification: { foreign: string } while probing, else null. */ + let probe = null; + let recoveryTimer = null; + + /** Interfaces we can advertise on, as {address, netmask}. */ + function localInterfaces() { + const out = []; + for (const entries of Object.values(os.networkInterfaces())) { + for (const entry of entries || []) { + const isIPv4 = entry.family === 'IPv4' || entry.family === 4; + if (isIPv4 && !entry.internal) out.push({ address: entry.address, netmask: entry.netmask }); + } + } + return out; + } + + /** + * Which of our addresses to hand a particular asker. On a multi-homed A/V machine + * (house LAN + camera network) answering with everything would send a studio PC an + * address it cannot route to, so prefer addresses on the asker's own subnet. + */ + function addressesForPeer(peerAddress) { + const advertised = getAddresses().filter(Boolean); + if (!advertised.length) return []; + const interfaces = localInterfaces(); + const onPeerSubnet = advertised.filter((address) => { + const match = interfaces.find((entry) => entry.address === address); + return match && sameSubnet(peerAddress, address, match.netmask); + }); + return onPeerSubnet.length ? onPeerSubnet : advertised; + } + + function send(message, port, address) { + if (!socket) return; + try { + socket.send(message, port, address, (err) => { + if (err) lastError = err.message; + }); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + } + + /** Aim the next multicast send out of a specific interface. Best effort. */ + function setOutgoingInterface(address) { + try { + socket.setMulticastInterface(address); + return true; + } catch { + return false; + } + } + + /** + * Announce (or, with ttlValue 0, withdraw) the name. + * + * One packet PER INTERFACE, each advertising only that interface's own address. Sending + * every address to every interface is the tempting shortcut and it is wrong: a studio PC + * would cache all of them, and browsers try cached addresses in turn — so a machine that + * can only route to the house LAN might sit waiting on the camera-network address first. + * A listener should only ever learn the address it can actually reach us on. + */ + function announce(ttlValue = ttl) { + const advertised = getAddresses().filter(Boolean); + if (!advertised.length) return; + const interfaces = localInterfaces(); + let sent = 0; + for (const address of advertised) { + if (!interfaces.some((entry) => entry.address === address)) continue; + if (!setOutgoingInterface(address)) continue; + send(buildResponse(fqdn, [address], ttlValue, 0), MDNS_PORT, MDNS_ADDRESS); + sent += 1; + } + if (!sent) { + // No interface would take the hint — fall back to one packet on the default route. + setOutgoingInterface('0.0.0.0'); + send(buildResponse(fqdn, advertised, ttlValue, 0), MDNS_PORT, MDNS_ADDRESS); + } + setOutgoingInterface('0.0.0.0'); + } + + function handleMessage(buffer, rinfo) { + let parsed; + try { + parsed = decodeQuestions(buffer); + } catch { + return; // never let a malformed packet from the network throw + } + if (!parsed) return; + + if (parsed.isResponse) { + // A genuine clash is another host advertising OUR name with an address that is not + // ours. A reflected copy of our own announcement carries only our own addresses, so + // it no longer trips this. + const mine = new Set(localInterfaces().map((entry) => entry.address)); + for (const answer of decodeAnswers(buffer)) { + if (answer.name.toLowerCase() !== fqdn) continue; + if (mine.has(answer.address)) continue; + // Only believe a claim from something on one of our own subnets. An off-link + // packet naming our record is a spoof, not a neighbour. + if (!onOurLink(rinfo.address)) continue; + if (probe) { + probe.foreign = answer.address; // a probe is running: this answers it + } else if (!relinquished) { + void verifyThenConcede(answer.address); + } + break; + } + return; + } + + // Once the name is contested we must not answer for it (RFC 6762 §9). Two responders + // both answering is worse than neither: the name resolves to a different machine each + // time, so half the screens land on the wrong host and show nothing. + if (relinquished) return; + + for (const question of parsed.questions) { + if (question.name.toLowerCase() !== fqdn) continue; + if (question.type !== TYPE_A && question.type !== TYPE_ANY) continue; + if (question.class !== CLASS_IN && question.class !== TYPE_ANY) continue; + const addresses = addressesForPeer(rinfo.address); + if (!addresses.length) continue; + const queryId = buffer.readUInt16BE(0); + // Legacy resolvers (source port != 5353) and QU queries want a direct reply. + if (question.wantsUnicast || rinfo.port !== MDNS_PORT) { + send(buildResponse(fqdn, addresses, ttl, queryId), rinfo.port, rinfo.address); + } else { + // Reply out of the interface the answer is valid for, so nobody else on another + // network caches an address they cannot reach. + setOutgoingInterface(addresses[0]); + send(buildResponse(fqdn, addresses, ttl, 0), MDNS_PORT, MDNS_ADDRESS); + setOutgoingInterface('0.0.0.0'); + } + } + } + + /** Is this address on one of our own subnets? */ + function onOurLink(address) { + return localInterfaces().some((entry) => sameSubnet(address, entry.address, entry.netmask)); + } + + /** + * Ask whether anyone else is holding our name (RFC 6762 §8.1-style probing: three + * queries, 250ms apart). Resolves with the foreign address, or '' if the link is quiet. + * + * This is the difference between a robust responder and a one-packet kill switch. A + * genuine second host answers a direct question; a spoofer who fires one unsolicited + * packet and leaves does not. + */ + function probeForConflict() { + return new Promise((resolve) => { + if (!socket) { + resolve(''); + return; + } + probe = { foreign: '' }; + const query = buildQuery(fqdn); + let sent = 0; + const tick = () => { + if (!socket || !probe) return; + if (probe.foreign || sent >= 3) { + const found = probe ? probe.foreign : ''; + probe = null; + resolve(found); + return; + } + sent += 1; + send(query, MDNS_PORT, MDNS_ADDRESS); + const next = setTimeout(tick, 250); + next.unref?.(); + }; + tick(); + // Give the third query time to be answered before declaring the link quiet. + const done = setTimeout(() => { + const found = probe ? probe.foreign : ''; + probe = null; + resolve(found); + }, 1200); + done.unref?.(); + }); + } + + /** + * A neighbour appears to hold our name. Verify it before standing down, then keep + * checking so the name comes back when they go away. + * + * Deliberately NOT the RFC's rename-on-conflict: the URL is the whole point of this + * feature, and renaming would break every bookmark in the building — and would hand a + * spoofer the good name. Standing aside temporarily keeps resolution unambiguous while + * the numeric addresses (which the host app lists) keep working throughout. + */ + async function verifyThenConcede(claimedBy) { + if (relinquished || probe) return; + const confirmed = await probeForConflict(); + if (!confirmed) { + log(`ignored an unverified claim on ${fqdn} (nobody answered a probe) — keeping the name`); + return; + } + conflictHost = confirmed; + relinquished = true; + log(`another host at ${confirmed} really holds ${fqdn} — standing down until it leaves`); + if (announceTimer) { + clearInterval(announceTimer); + announceTimer = null; + } + try { + announce(0); // TTL 0: resolvers drop OUR records so the name stops being ambiguous + } catch { + /* nothing more we can do */ + } + startRecoveryWatch(); + } + + /** While stood down, re-probe periodically and reclaim the name once the link is quiet. */ + function startRecoveryWatch() { + if (recoveryTimer) return; + recoveryTimer = setInterval(async () => { + if (!socket || !relinquished) return; + const stillThere = await probeForConflict(); + if (stillThere) return; + log(`${fqdn} is free again — reclaiming it`); + relinquished = false; + conflictHost = ''; + clearInterval(recoveryTimer); + recoveryTimer = null; + announce(); + announceTimer = setInterval(announce, Math.max(30, ttl / 2) * 1000); + announceTimer.unref?.(); + }, 60000); + recoveryTimer.unref?.(); + } + + function start() { + if (started) return Promise.resolve({ ok: true, name: fqdn }); + started = true; + return new Promise((resolve) => { + let settled = false; + const finish = (result) => { + if (settled) return; + settled = true; + resolve(result); + }; + try { + // reuseAddr is essential: macOS already runs mDNSResponder on 5353 and we must + // coexist with it (it will not answer for our name, so there is no conflict). + socket = dgram.createSocket({ type: 'udp4', reuseAddr: true }); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + started = false; + finish({ ok: false, name: fqdn, error: lastError }); + return; + } + + socket.on('error', (err) => { + lastError = err instanceof Error ? err.message : String(err); + log(`socket error: ${lastError}`); + try { socket.close(); } catch { /* already closed */ } + socket = null; + started = false; + finish({ ok: false, name: fqdn, error: lastError }); + }); + + socket.on('message', (buffer, rinfo) => { + try { + handleMessage(buffer, rinfo); + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + }); + + socket.bind({ port: MDNS_PORT, exclusive: false }, () => { + try { + socket.setMulticastTTL(255); // RFC 6762 §11 + } catch { /* not fatal */ } + let joined = 0; + for (const entry of localInterfaces()) { + try { + socket.addMembership(MDNS_ADDRESS, entry.address); + joined += 1; + } catch { + /* some adapters refuse multicast; others still work */ + } + } + if (!joined) { + try { + socket.addMembership(MDNS_ADDRESS); + joined = 1; + } catch (err) { + lastError = err instanceof Error ? err.message : String(err); + } + } + // Announce twice a second apart (RFC 6762 §8.3 asks for repeats), then hourly-ish + // so a resolver that missed both still learns the name. + announce(); + const second = setTimeout(announce, 1000); + second.unref?.(); + announceTimer = setInterval(announce, Math.max(30, ttl / 2) * 1000); + announceTimer.unref?.(); + finish({ ok: joined > 0, name: fqdn, interfaces: joined, error: joined ? '' : lastError }); + }); + }); + } + + function stop() { + if (announceTimer) { + clearInterval(announceTimer); + announceTimer = null; + } + if (recoveryTimer) { + clearInterval(recoveryTimer); + recoveryTimer = null; + } + probe = null; + if (socket) { + // Goodbye: TTL 0 tells resolvers to forget the name immediately, so a screen opened + // right after we stop fails fast instead of hanging on a stale cache entry. + try { + announce(0); + } catch { /* going away anyway */ } + try { socket.close(); } catch { /* already closed */ } + socket = null; + } + started = false; + } + + return { + start, + stop, + get name() { return fqdn; }, + get url() { return fqdn; }, + isRunning: () => Boolean(socket), + /** True only while we still own the name and are answering for it. */ + isPublishing: () => Boolean(socket) && !relinquished, + getLastError: () => lastError, + getConflictHost: () => conflictHost, + }; +} + +module.exports = { + createMdnsResponder, + MDNS_PORT, + // exported for tests + buildQuery, + encodeName, + decodeName, + decodeQuestions, + decodeAnswers, + buildResponse, + sameSubnet, +}; diff --git a/propresenter-app/package.json b/propresenter-app/package.json new file mode 100644 index 0000000..0a60e60 --- /dev/null +++ b/propresenter-app/package.json @@ -0,0 +1,48 @@ +{ + "name": "propresenter-now-next", + "productName": "Stage Now / Next", + "version": "1.3.6", + "buildNumber": "14", + "description": "Standalone ProPresenter Now / Next display for studios and hallway screens", + "main": "main.js", + "scripts": { + "start": "node server.js", + "kiosk": "../node_modules/.bin/electron .", + "pack": "electron-builder --dir", + "dist": "electron-builder --win --x64" + }, + "license": "MIT", + "devDependencies": { + "electron": "^33.2.0", + "electron-builder": "^25.1.8" + }, + "build": { + "appId": "com.band.propresenter.nownext", + "productName": "Stage Now Next", + "directories": { + "output": "dist" + }, + "files": [ + "main.js", + "server.js", + "config.js", + "public/**/*", + "../propresenter-core/**/*" + ], + "win": { + "target": [ + "nsis", + "portable" + ] + }, + "mac": { + "target": [ + "dmg" + ] + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + } + } +} diff --git a/propresenter-app/public/app.js b/propresenter-app/public/app.js new file mode 100644 index 0000000..5df01c6 --- /dev/null +++ b/propresenter-app/public/app.js @@ -0,0 +1,830 @@ +'use strict'; + +/* Stage Now / Next — display client. + Receives the live view-model over SSE from server.js (which does all the + ProPresenter talking) and renders it. Also owns the settings panel: the + connection lives on the server and is shared by every screen, while the + look-and-feel options are per-device in localStorage. */ + +const el = (id) => document.getElementById(id); + +const screenEl = el('screen'); +const topRow = el('top'); +const playlistNameEl = el('playlist-name'); +const liveEl = el('live'); +const liveTextEl = el('live-text'); +const nowTitleBox = el('now-title-box'); +let nowTitleEl = el('now-title'); +const nowMetaEl = el('now-meta'); +const progressEl = el('progress'); +const progressFillEl = el('progress-fill'); +const nextLabelEl = el('next-label'); +const nextListEl = el('next-list'); +const nextMoreEl = el('next-more'); + +const gearBtn = el('gear'); +const panel = el('panel'); +const panelBackdrop = el('panel-backdrop'); +const panelClose = el('panel-close'); +const panelStatus = el('panel-status'); +const hostInput = el('host'); +const portInput = el('port'); +const passwordInput = el('password'); +const btnConnect = el('btn-connect'); +const btnDisconnect = el('btn-disconnect'); +const btnFullscreen = el('btn-fullscreen'); +const scaleRow = el('scale-row'); +const optPlaylist = el('opt-playlist'); +const optProgress = el('opt-progress'); +const optClean = el('opt-clean'); +const urlList = el('url-list'); +const aboutLine = el('about-line'); +const connectionSection = el('connection-section'); +const managedSection = el('managed-section'); +const managedNote = el('managed-note'); + +const PREFS_KEY = 'ppDisplay:prefs'; +/** + * How long the settings gear stays visible after the last pointer/key activity. + * On a wall tablet the first tap wakes the gear and the second opens it, so this + * needs to be long enough for someone to reach across and press it. + */ +const CHROME_IDLE_MS = 6000; + +let state = null; +let status = null; +let chromeTimer = null; +let lastErrorShown = ''; +/** The live SSE connection, retained so it can be torn down and re-opened on demand. */ +let stream = null; +/** True while we have no feed from the host at all (as opposed to ProPresenter being down). */ +let feedOffline = false; +/** Host build this page was loaded from. When it changes, the host was updated → reload. */ +let loadedHostVersion = null; + +/* ---------------- per-device preferences ---------------- */ + +const defaultPrefs = { scale: 1, showPlaylist: true, showProgress: true, cleanTitles: true }; +let prefs = { ...defaultPrefs }; + +function loadPrefs() { + try { + const saved = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); + prefs = { ...defaultPrefs, ...saved }; + } catch { + prefs = { ...defaultPrefs }; + } +} + +function savePrefs() { + try { + localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); + } catch { + /* private mode / storage full — the display still works, just won't remember */ + } +} + +function applyPrefs() { + document.documentElement.style.setProperty('--scale', String(prefs.scale)); + for (const chip of scaleRow.querySelectorAll('.chip')) { + chip.classList.toggle('is-on', Number(chip.dataset.scale) === Number(prefs.scale)); + } + optPlaylist.checked = prefs.showPlaylist; + optProgress.checked = prefs.showProgress; + optClean.checked = prefs.cleanTitles; + render(); +} + +/* ---------------- title tidying ---------------- + Same rule as the Band Lineup app's Now/Next column. Duplicated here on purpose: + this page loads as a plain script and can't require ../propresenter-core, and the + raw name is still what the server matches ProPresenter items on. */ + +function cleanItemName(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code + s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space + s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace + return s || original; // never blank out a title (e.g. a name that was only a tag) +} + +function displayName(raw) { + return prefs.cleanTitles ? cleanItemName(raw) : String(raw || '').trim(); +} + +function itemTypeTag(type) { + switch (type) { + case 'media': + return 'Media'; + case 'audio': + return 'Audio'; + case 'livevideo': + return 'Live'; + case 'placeholder': + return 'PCO'; + default: + return ''; + } +} + +/* ---------------- rendering ---------------- */ + +/** The items after the live one, hidden entries dropped. Shared by NOW's note and NEXT. */ +function upcomingItems() { + const items = Array.isArray(state?.items) ? state.items : []; + // Presenting from the playlist → everything after the live item. Presenting from the + // Library (activeIndex < 0) → the whole list is still ahead. + const from = state && state.activeIndex >= 0 ? state.activeIndex + 1 : 0; + const out = []; + for (let i = from; i < items.length; i += 1) { + if (!items[i].isHidden) out.push(items[i]); + } + return out; +} + +/** + * Size the NOW title as large as it can be while (a) staying inside the box and (b) NEVER + * breaking a word across lines. Wrapping is allowed only at spaces. + * + * The trick that makes it robust: with word-breaking forbidden (overflow-wrap/word-break + * normal), a word wider than the box OVERFLOWS rather than splitting, so scrollWidth + * exceeds the box — and the same fit test that guards height also rejects that size. Binary + * search then lands on the biggest size where every whole word fits the width and the whole + * title fits the height. No pixel-rounding fallback can sneak a mid-word break in, which was + * the "Promis / ed Land" bug. + */ +/** + * Hand the browser a brand-new title element whenever the text changes. On some GPUs (macOS + * Chrome on an external display) a large text element keeps a stale compositor backing store + * when its contents change, so the previous song stays painted under the new one — "AMAZING!" + * ghosting behind "Alleluia". A screenshot looks clean (the DOM is right); only the physical + * pixels are wrong, the signature of a layer that was never invalidated. A fresh node has no + * old backing store, so the ghost cannot survive the swap. Runs only when the title changes. + */ +let lastTitleText = null; +function setNowTitle(text) { + if (text === lastTitleText && nowTitleEl.isConnected) return; + lastTitleText = text; + const fresh = document.createElement('div'); + fresh.className = 'now-title'; + fresh.id = 'now-title'; + fresh.textContent = text; + nowTitleEl.replaceWith(fresh); + nowTitleEl = fresh; +} + +function fitNowTitle() { + // Status messages ("Nothing on screen") keep their CSS size. The resize observer calls + // straight into here, so the guard has to live here too. + if (nowTitleEl.classList.contains('is-waiting')) return; + const boxHeight = nowTitleBox.clientHeight; + const boxWidth = nowTitleBox.clientWidth; + const MIN = 14; + + // Wrap only at spaces; never split a word. + nowTitleEl.style.whiteSpace = 'normal'; + nowTitleEl.style.overflowWrap = 'normal'; + nowTitleEl.style.wordBreak = 'normal'; + nowTitleEl.style.hyphens = 'none'; + + if (boxHeight < MIN || !boxWidth) { + nowTitleEl.style.fontSize = `${MIN}px`; + return; + } + + const fits = (size) => { + nowTitleEl.style.fontSize = `${size}px`; + // +1 absorbs sub-pixel rounding; because word-breaking is off, an over-wide word shows + // up here as horizontal overflow and is rejected rather than being split. + return nowTitleEl.scrollWidth <= nowTitleBox.clientWidth + 1 + && nowTitleEl.scrollHeight <= nowTitleBox.clientHeight + 1; + }; + + const max = Math.max(MIN, Math.floor(boxHeight)); + let best = MIN; + if (fits(max)) { + best = max; + } else { + let lo = MIN; + let hi = max; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; } + } + } + // Hysteresis: keep the current size unless the new best differs by more than a pixel. + // Sub-pixel box jitter (Windows fractional-DPI, a re-measured meta line) would otherwise + // flap the size ±1px every render — the "bigger and smaller text" the operator sees. + const current = parseFloat(nowTitleEl.style.fontSize) || 0; + // Only HOLD the current size if it still genuinely fits. A plain 1px band can straddle the + // line-wrap threshold (137px = one line fits, 138px = two lines overflow) and stick on the + // overflowing side — which is exactly how the title spilled out of the box. + if (current && Math.abs(current - best) <= 1 && fits(current)) { + nowTitleEl.style.fontSize = `${current}px`; + return; + } + nowTitleEl.style.fontSize = `${best}px`; + + // Last resort: a single word so long it cannot fit even at MIN (unlikely for a song + // title). Rather than overflow the card, allow this one to break. + if (best === MIN && nowTitleEl.scrollWidth > nowTitleBox.clientWidth + 1) { + nowTitleEl.style.overflowWrap = 'anywhere'; + nowTitleEl.style.wordBreak = 'break-word'; + } +} + +/** + * Hide the NEXT rows that fall past the bottom of the list and report how many. + * + * Rows are hidden, never removed: NOW is a greedy flex item, so deleting rows would + * shrink the NEXT block, let NOW grow into the freed space, and ratchet the list down + * to nothing over successive renders. Keeping every row in the flow fixes the NEXT + * block's height, and `visibility: hidden` keeps a half-cut row off the screen. + */ +function markNextOverflow() { + const rows = Array.from(nextListEl.children); + for (const row of rows) row.style.visibility = ''; + let hidden = 0; + // Revealing the "+ N more" line takes height away from the list, which can push one + // more row past the fold — so re-measure until it settles (bounded, only a few rows). + for (let pass = 0; pass < 3; pass += 1) { + const listRect = nextListEl.getBoundingClientRect(); + if (listRect.height <= 0) return; + let changed = 0; + for (const row of rows) { + if (row.style.visibility === 'hidden') continue; + if (row.getBoundingClientRect().bottom > listRect.bottom + 1) { + row.style.visibility = 'hidden'; + changed += 1; + } + } + hidden += changed; + if (hidden > 0) { + nextMoreEl.hidden = false; + nextMoreEl.textContent = `+ ${hidden} more`; + } + if (changed === 0) break; + } + if (hidden === 0) { + nextMoreEl.hidden = true; + nextMoreEl.textContent = ''; + } +} + +function renderLive() { + liveEl.classList.remove('is-live', 'is-down'); + if (feedOffline) { + liveEl.classList.add('is-down'); + liveTextEl.textContent = 'Display offline'; + return; + } + if (!status) { + liveTextEl.textContent = 'Connecting…'; + return; + } + if (!status.enabled) { + liveTextEl.textContent = 'Not set up'; + return; + } + if (state?.reachable) { + liveEl.classList.add('is-live'); + liveTextEl.textContent = 'Live'; + return; + } + liveEl.classList.add('is-down'); + liveTextEl.textContent = 'Reconnecting…'; +} + +function renderNow() { + const activeItem = state && state.activeIndex >= 0 ? state.items[state.activeIndex] : null; + const rawName = activeItem?.name || state?.activePresentationName || ''; + + // Clear anything a previous render appended below the progress bar. + for (const stale of screenEl.querySelectorAll('.now-error, .now-hint, .stale-note')) stale.remove(); + + if (feedOffline) { + // We are showing whatever was last on screen, and it is not moving. Say so. + setNowTitle(rawName ? `Last shown: ${displayName(rawName)}` : 'Display offline'); + nowTitleEl.classList.add('is-waiting'); + nowTitleEl.style.fontSize = ''; + nowTitleEl.style.whiteSpace = ''; + nowMetaEl.textContent = ''; + progressEl.hidden = true; + const staleEl = document.createElement('div'); + staleEl.className = 'stale-note'; + staleEl.textContent = status?.hostLabel + ? `Not receiving updates — ${status.hostLabel} may be closed or asleep.` + : 'Not receiving updates from the display host.'; + el('now').appendChild(staleEl); + return; + } + + if (!rawName) { + // A status message is not a song title — leave it at its CSS size instead of + // running the fill-the-box fit, which would blow "No ProPresenter connection" + // up across the whole screen. + let hint = ''; + if (!status?.enabled) { + setNowTitle('No ProPresenter connection'); + // On a locked studio screen, "press the gear and type an IP" is advice nobody + // standing there can act on — the host supplies the right wording instead. + hint = status?.setupHint || 'Waiting for the display host to connect to ProPresenter.'; + } else { + // "Waiting for ProPresenter" reads as a fault. When we are connected and ProPresenter + // simply has nothing live (it returns presentation_index: null with the output + // cleared), say that plainly so nobody goes looking for a broken connection. + setNowTitle(state?.reachable ? 'Nothing on screen' : 'Reconnecting…'); + hint = state?.reachable + ? 'Connected to ProPresenter — waiting for a slide to go live.' + : 'Trying to reach ProPresenter.'; + } + nowTitleEl.classList.add('is-waiting'); + nowTitleEl.style.fontSize = ''; + nowTitleEl.style.whiteSpace = ''; + nowMetaEl.textContent = ''; + progressEl.hidden = true; + if (hint) { + const hintEl = document.createElement('div'); + hintEl.className = 'now-hint'; + hintEl.textContent = hint; + el('now').appendChild(hintEl); + } + // Raw fetch/socket error text is for whoever administers the connection, not for a + // wall in a studio. It stays in the host app's own status line. + return; + } + + setNowTitle(displayName(rawName)); + nowTitleEl.classList.remove('is-waiting'); + fitNowTitle(); + // Re-fit once more after layout settles: on the first render after a navigation/resize the + // title box may not have its final height yet, and fitting against a too-tall box would let + // the title overflow for a frame. The ResizeObserver also covers this; this makes it instant. + window.requestAnimationFrame(() => { + if (!nowTitleEl.classList.contains('is-waiting')) fitNowTitle(); + }); + + // An empty NEXT list looks broken unless the screen says why it is empty. + if (!upcomingItems().length) { + const kind = state?.playlistKind; + let why = ''; + if (kind === 'group' || kind === 'none') { + why = 'Presenting from the library — no playlist open, so there is nothing listed after this.'; + } else if (kind === 'unknown') { + why = 'ProPresenter did not return this playlist\u2019s items, so the next list is unavailable.'; + } else if (Array.isArray(state?.items) && state.items.length) { + why = 'Last item in the playlist.'; + } + if (why) { + const note = document.createElement('div'); + note.className = 'now-hint'; + note.textContent = why; + el('now').appendChild(note); + } + } + + const total = state.currentSlideTotal || activeItem?.slideCount || 0; + const current = state.currentSlide || 0; + if (total > 0 && current > 0) { + const remaining = Math.max(0, total - current); + nowMetaEl.textContent = `Slide ${current} / ${total} · ${remaining} left`; + } else if (total > 0) { + nowMetaEl.textContent = `${total} slides`; + } else if (current > 0) { + nowMetaEl.textContent = `Slide ${current}`; + } else { + nowMetaEl.textContent = ''; + } + + if (prefs.showProgress && total > 0) { + progressEl.hidden = false; + const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; + progressFillEl.style.width = `${pct}%`; + } else { + progressEl.hidden = true; + } +} + +function renderNext() { + nextListEl.innerHTML = ''; + const upcoming = upcomingItems(); + // With nothing to list, NOW would otherwise grow into the entire screen and read as a + // broken page; the class caps it so the card still looks deliberate. + document.body.classList.toggle('no-upcoming', upcoming.length === 0); + + for (const item of upcoming) { + const row = document.createElement('div'); + const nameEl = document.createElement('div'); + nameEl.className = 'next-name'; + + if (item.type === 'header') { + row.className = 'next-item is-header'; + nameEl.textContent = displayName(item.name || ''); + row.appendChild(nameEl); + nextListEl.appendChild(row); + continue; + } + + row.className = 'next-item'; + nameEl.textContent = displayName(item.name) || '(untitled)'; + row.appendChild(nameEl); + + if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { + const chip = document.createElement('div'); + chip.className = 'count-chip'; + chip.textContent = String(item.slideCount); + row.appendChild(chip); + } else { + const tag = itemTypeTag(item.type); + if (tag) { + const tagEl = document.createElement('div'); + tagEl.className = 'type-tag'; + tagEl.textContent = tag; + row.appendChild(tagEl); + } + } + nextListEl.appendChild(row); + } + + nextLabelEl.hidden = upcoming.length === 0; + nextMoreEl.hidden = true; + // Measure synchronously so a half-visible row never reaches the screen; the extra frame + // is a safety net for the first render, when layout height isn't known yet. + markNextOverflow(); + window.requestAnimationFrame(markNextOverflow); +} + +function render() { + document.body.classList.toggle('is-offline', feedOffline); + const showTop = prefs.showPlaylist || feedOffline || !state?.reachable || !status?.enabled; + topRow.classList.toggle('hidden-row', !showTop); + playlistNameEl.textContent = prefs.showPlaylist ? state?.playlistName || '' : ''; + renderLive(); + renderNow(); + renderNext(); +} + +/* ---------------- live feed ---------------- */ + +/** + * Reload the page when the host app has been updated to a new build. + * + * These screens are bookmarks nobody tends — a tab left open keeps running the JavaScript it + * first loaded, so a fixed display page would never reach the wall until someone walked over + * and refreshed it. The host stamps its version into every status frame; when it changes from + * what this tab loaded with, the host was reinstalled, so pick up its new page automatically. + */ +function checkForHostUpdate() { + const version = status && status.hostVersion; + if (!version) return; + if (loadedHostVersion === null) { + loadedHostVersion = version; + return; + } + if (version !== loadedHostVersion) { + // location.reload() refetches the page and its assets (served no-store), so the tab + // comes back running the new build. loadedHostVersion resets on reload, so no loop. + window.location.reload(); + } +} + +function connectStream() { + if (stream) { + try { stream.close(); } catch { /* already closed */ } + stream = null; + } + // Relative on purpose so the page works from any host, port, or mount path. + const source = new EventSource('api/stream'); + stream = source; + + source.addEventListener('open', () => { + feedOffline = false; + render(); + }); + + source.addEventListener('state', (event) => { + let incoming; + try { + incoming = JSON.parse(event.data); + } catch { + return; + } + state = incoming; + feedOffline = false; + render(); + }); + + source.addEventListener('status', (event) => { + let incoming; + try { + incoming = JSON.parse(event.data); + } catch { + return; + } + status = incoming; + feedOffline = false; + checkForHostUpdate(); + syncPanelFromStatus(); + render(); + }); + + source.addEventListener('error', () => { + // EventSource retries by itself, but the screen must admit it is frozen meanwhile — + // otherwise a stale lineup is indistinguishable from a live one. + feedOffline = true; + render(); + }); +} + +/** + * Force a fresh connection. iPadOS suspends timers and sockets on a sleeping tab and does + * not always resume an EventSource, which would leave a wall display frozen indefinitely. + */ +function reconnectStream() { + // OPEN (1) or still CONNECTING (0) both mean a connection is already in flight. Opening + // another would leave this page holding two streams, which the host then counts twice. + if (stream && (stream.readyState === 0 || stream.readyState === 1)) return; + connectStream(); +} + +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') reconnectStream(); +}); +window.addEventListener('online', reconnectStream); +window.addEventListener('pageshow', reconnectStream); + +/* ---------------- settings panel ---------------- */ + +function setPanelStatus(message, isError = false) { + panelStatus.textContent = message; + panelStatus.classList.toggle('is-error', Boolean(isError)); +} + +function syncPanelFromStatus() { + if (!status) return; + if (document.activeElement !== hostInput) hostInput.value = status.host || ''; + if (document.activeElement !== portInput) { + portInput.value = status.port ? String(status.port) : String(status.defaultProPresenterPort || ''); + } + passwordInput.placeholder = status.hasPassword ? 'Saved — leave blank to keep' : 'Leave blank if none'; + + const locked = Boolean(status.readOnly); + for (const control of [hostInput, portInput, passwordInput, btnConnect, btnDisconnect]) { + control.disabled = locked; + } + // A row of dead grey fields invites people to keep poking at them. Replace the whole + // section with a sentence saying where the connection actually lives. + if (connectionSection) connectionSection.hidden = locked; + if (managedSection) managedSection.hidden = !locked; + if (managedNote) { + managedNote.textContent = status.enabled + ? `This screen follows ${status.hostLabel || 'the display host'}, which is connected to ProPresenter. Nothing to set up here.` + : `This screen follows ${status.hostLabel || 'the display host'}. ${status.setupHint || ''}`.trim(); + } + + if (aboutLine) { + const version = status.hostVersion ? `v${status.hostVersion}` : ''; + aboutLine.textContent = [ + 'Stage Now / Next', + version, + status.hostLabel ? `served by ${status.hostLabel}` : '', + ].filter(Boolean).join(' · '); + } + + urlList.innerHTML = ''; + const addresses = Array.isArray(status.addresses) && status.addresses.length + ? status.addresses + : (Array.isArray(status.viewerUrls) ? status.viewerUrls : []).map((url) => ({ url, detail: '' })); + if (!addresses.length) { + const none = document.createElement('span'); + none.className = 'muted'; + none.textContent = 'No network address detected on this machine.'; + urlList.appendChild(none); + } + for (const entry of addresses) { + const row = document.createElement('div'); + row.textContent = entry.url; + urlList.appendChild(row); + if (entry.detail) { + const note = document.createElement('span'); + note.className = 'muted'; + note.textContent = entry.detail; + urlList.appendChild(note); + } + } + + if (locked) { + setPanelStatus(''); + } else if (status.enabled) { + setPanelStatus( + status.reachable + ? `Following ProPresenter at ${status.host}:${status.port}.` + : `Saved ${status.host}:${status.port} — trying to reach ProPresenter…`, + ); + } else if (!lastErrorShown) { + setPanelStatus('Not connected yet. Enter the ProPresenter IP and port.'); + } +} + +function openPanel() { + panel.classList.remove('hidden'); + panelBackdrop.classList.remove('hidden'); + syncPanelFromStatus(); + hostInput.focus(); +} + +function closePanel() { + panel.classList.add('hidden'); + panelBackdrop.classList.add('hidden'); + passwordInput.value = ''; + lastErrorShown = ''; +} + +async function postJson(url, body) { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body || {}), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `Request failed (${response.status}).`); + return data; +} + +async function connectProPresenter() { + const host = hostInput.value.trim(); + const port = portInput.value.trim(); + if (!host || !port) { + lastErrorShown = 'missing'; + setPanelStatus('Enter the ProPresenter host and port.', true); + return; + } + btnConnect.disabled = true; + setPanelStatus('Connecting to ProPresenter…'); + try { + // An untouched password field means "keep whatever is saved". + const payload = { host, port }; + if (passwordInput.value !== '') payload.password = passwordInput.value; + const data = await postJson('api/connect', payload); + status = data.status; + lastErrorShown = ''; + passwordInput.value = ''; + syncPanelFromStatus(); + setPanelStatus(`Connected. Every screen on this server is now following ${host}:${port}.`); + } catch (error) { + lastErrorShown = 'connect'; + setPanelStatus(error instanceof Error ? error.message : 'Could not connect to ProPresenter.', true); + } finally { + btnConnect.disabled = false; + } +} + +async function disconnectProPresenter() { + btnDisconnect.disabled = true; + try { + const data = await postJson('api/disconnect'); + status = data.status; + state = null; + lastErrorShown = ''; + syncPanelFromStatus(); + setPanelStatus('Disconnected. Host and port are remembered for next time.'); + render(); + } catch (error) { + setPanelStatus(error instanceof Error ? error.message : 'Could not disconnect.', true); + } finally { + btnDisconnect.disabled = false; + } +} + +/* ---------------- chrome auto-hide (the display stays clean) ---------------- */ + +function revealChrome() { + document.body.classList.add('chrome-visible'); + if (chromeTimer) window.clearTimeout(chromeTimer); + chromeTimer = window.setTimeout(() => { + if (panel.classList.contains('hidden')) document.body.classList.remove('chrome-visible'); + }, CHROME_IDLE_MS); +} + +/* ---------------- wiring ---------------- */ + +gearBtn.addEventListener('click', openPanel); +panelClose.addEventListener('click', closePanel); +panelBackdrop.addEventListener('click', closePanel); +btnConnect.addEventListener('click', () => void connectProPresenter()); +btnDisconnect.addEventListener('click', () => void disconnectProPresenter()); + +btnFullscreen.addEventListener('click', () => { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen?.().catch(() => {}); + } else { + document.exitFullscreen?.().catch(() => {}); + } +}); + +for (const input of [hostInput, portInput, passwordInput]) { + input.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void connectProPresenter(); + } + if (event.key === 'Escape') { + event.preventDefault(); + closePanel(); + } + }); +} + +scaleRow.addEventListener('click', (event) => { + const chip = event.target.closest('.chip'); + if (!chip) return; + prefs.scale = Number(chip.dataset.scale) || 1; + savePrefs(); + applyPrefs(); +}); + +optPlaylist.addEventListener('change', () => { + prefs.showPlaylist = optPlaylist.checked; + savePrefs(); + render(); +}); + +optProgress.addEventListener('change', () => { + prefs.showProgress = optProgress.checked; + savePrefs(); + render(); +}); + +optClean.addEventListener('change', () => { + prefs.cleanTitles = optClean.checked; + savePrefs(); + render(); +}); + +for (const event of ['pointermove', 'pointerdown', 'keydown', 'touchstart']) { + window.addEventListener(event, revealChrome, { passive: true }); +} + +document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !panel.classList.contains('hidden')) closePanel(); + if (event.key === 'f' && !event.metaKey && !event.ctrlKey && panel.classList.contains('hidden')) { + btnFullscreen.click(); + } +}); + +// Re-fit on any size change: window resize, fullscreen, rotation, or scale change. +if (typeof ResizeObserver !== 'undefined') { + // Re-fit only when a watched box ACTUALLY changed size (window resize, rotation), never + // just because the fitter or the overflow-marker wrote to the DOM. Without this guard the + // observer reacts to its own effects and the title size oscillates every render. + let lastTitleW = 0; + let lastTitleH = 0; + let lastListH = 0; + let pending = false; + const observer = new ResizeObserver(() => { + const tw = Math.round(nowTitleBox.clientWidth); + const th = Math.round(nowTitleBox.clientHeight); + const lh = Math.round(nextListEl.clientHeight); + if (tw === lastTitleW && th === lastTitleH && lh === lastListH) return; + lastTitleW = tw; lastTitleH = th; lastListH = lh; + if (pending) return; + pending = true; + window.requestAnimationFrame(() => { + pending = false; + fitNowTitle(); + // markNextOverflow re-evaluates row visibility for the new size WITHOUT rebuilding the + // list (a rebuild would flash and could re-enter this observer). + markNextOverflow(); + }); + }); + observer.observe(nowTitleBox); + observer.observe(nextListEl); +} + +/** Best-effort: keep a dedicated display awake. Only granted in a secure context. */ +async function keepAwake() { + try { + if (!('wakeLock' in navigator)) return; + let lock = await navigator.wakeLock.request('screen'); + document.addEventListener('visibilitychange', async () => { + if (document.visibilityState === 'visible' && lock?.released !== false) { + try { + lock = await navigator.wakeLock.request('screen'); + } catch { + /* denied — nothing to do */ + } + } + }); + } catch { + /* not available over plain http, or refused — the page still works */ + } +} + +loadPrefs(); +applyPrefs(); +revealChrome(); +connectStream(); +void keepAwake(); diff --git a/propresenter-app/public/index.html b/propresenter-app/public/index.html new file mode 100644 index 0000000..f2ad569 --- /dev/null +++ b/propresenter-app/public/index.html @@ -0,0 +1,126 @@ + + + + + + + + + Stage Now / Next + + + +
+
+
+
+ + Connecting… +
+
+ +
+
Now
+
+
Waiting for ProPresenter…
+
+
+ +
+ +
+ +
+ +
+
+ + + + + + + + + diff --git a/propresenter-app/public/styles.css b/propresenter-app/public/styles.css new file mode 100644 index 0000000..78bdcde --- /dev/null +++ b/propresenter-app/public/styles.css @@ -0,0 +1,638 @@ +/* Stage Now / Next — standalone ProPresenter display. + Visual language matches the Band Lineup app's Now/Next column, scaled up from a + sidebar to a whole screen. Sizes are viewport-relative and multiplied by --scale + (set per device in the settings panel) so one page suits a 15" studio monitor, + a 65" hallway TV, and an iPad. */ + +:root { + --scale: 1; + --bg: #0f0f12; + --panel: #16161a; + --line: #2a2a32; + --line-soft: #2f2f3a; + --text: #f4f6fb; + --text-dim: #9ea7bc; + --text-faint: #7a7a8a; + --accent: #7fa8e0; + --accent-deep: #4a6fa5; + --danger: #d98c8c; + --unit: clamp(11px, 1.35vh, 22px); + --pad: calc(var(--unit) * 1.4); + --gap: calc(var(--unit) * 0.9); +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + /* dvh so iPad Safari's collapsing toolbars don't leave the layout taller than the glass; + the 100% first line keeps older browsers working. */ + height: 100%; + height: 100dvh; + background: var(--bg); + color: var(--text); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + -webkit-font-smoothing: antialiased; + /* Stop iOS inflating text on a rotated wall display, and stop a stray pinch or + double-tap from zooming a screen nobody is standing at. */ + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; + touch-action: pan-x pan-y; + user-select: none; + -webkit-tap-highlight-color: transparent; + overflow: hidden; +} + +/* ---------- the display ---------- */ + +.screen { + height: 100%; + display: flex; + flex-direction: column; + gap: var(--gap); + padding: calc(var(--pad) + env(safe-area-inset-top)) calc(var(--pad) + env(safe-area-inset-right)) + calc(var(--pad) + env(safe-area-inset-bottom)) calc(var(--pad) + env(safe-area-inset-left)); +} + +.top { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--gap); + flex-shrink: 0; + min-height: 0; +} + +.top.hidden-row { + display: none; +} + +.playlist-name { + flex: 1; + min-width: 0; + font-size: calc(var(--unit) * 1.05 * var(--scale)); + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #8a8a99; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.live { + display: flex; + align-items: center; + gap: calc(var(--unit) * 0.5); + flex-shrink: 0; + font-size: calc(var(--unit) * 0.95 * var(--scale)); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-faint); +} + +.live-dot { + width: calc(var(--unit) * 0.62); + height: calc(var(--unit) * 0.62); + border-radius: 999px; + background: #55555f; + transition: background 0.3s ease, box-shadow 0.3s ease; +} + +.live.is-live .live-dot { + background: #6fd08c; + box-shadow: 0 0 0 calc(var(--unit) * 0.22) rgba(111, 208, 140, 0.18); +} + +.live.is-down .live-dot { + background: var(--danger); + box-shadow: 0 0 0 calc(var(--unit) * 0.22) rgba(217, 140, 140, 0.16); +} + +/* Idle screens don't need a status chip staring at them — it fades once we're live. */ +.live.is-live .live-text { + opacity: 0; + transition: opacity 0.6s ease 1.4s; +} + +/* Losing the feed is the one thing a wall display MUST admit to, so this overrides the + "hide the top row" preference — otherwise a frozen lineup looks like a live one. */ +body.is-offline .top { + display: flex !important; +} + +body.is-offline .live-text { + opacity: 1 !important; + color: var(--danger); +} + +body.is-offline .now { + border-color: rgba(217, 140, 140, 0.5); +} + +.stale-note { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.4); + font-size: calc(var(--unit) * 1 * var(--scale)); + font-weight: 600; + color: var(--danger); +} + +/* With no NEXT list there is nothing below the card, so centre it rather than leaving a + tall band of empty black under a top-aligned card. */ +body.no-upcoming .screen { + justify-content: center; +} + +/* FIXED height. When NOW was flexible it shared vertical space with the NEXT block and the + two fed back every frame — the fitter sized the title to the leftover space, which changed + how many NEXT rows fit, which changed the leftover space… a two-layout strobe. A fixed NOW + makes NEXT depend on NOW and never the reverse, so the title box is deterministic and the + layout cannot oscillate. */ +.now { + flex: 0 0 auto; + height: 40vh; + min-height: 180px; + display: flex; + flex-direction: column; + border: 1px solid #30303c; + border-radius: calc(var(--unit) * 1.1); + background: linear-gradient(160deg, rgba(61, 90, 128, 0.22), rgba(26, 26, 34, 0.6)); + padding: calc(var(--pad) * 1.05) calc(var(--pad) * 1.2); +} + +.now-label { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.95 * var(--scale)); + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--accent); +} + +.now-title-box { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + overflow: hidden; + margin-top: calc(var(--unit) * 0.4); +} + +.now-title { + width: 100%; + /* Font size is set by fitNowTitle() in app.js so the title fills the box. Keep the + line-height at or above the font's natural ~1.2: a tighter line box clips the ink of + ascenders and descenders (the "y" in "This Is The Day") once the size gets large. */ + font-size: calc(var(--unit) * 3 * var(--scale)); + font-weight: 700; + line-height: 1.2; + color: var(--text); + /* Wrapping is controlled by fitNowTitle() in app.js, which forbids mid-word breaks and + sizes down until whole words fit. Defaulting to break-word here would let the browser + split a word ("Promis / ed Land") between fitter runs. */ + overflow-wrap: normal; + word-break: normal; +} + +/* Status text, not a title: a readable size, never the fill-the-box treatment. */ +.now-title.is-waiting { + color: var(--text-faint); + font-weight: 600; + font-size: calc(var(--unit) * 1.9 * var(--scale)); + white-space: normal; +} + +.now-hint { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.5); + font-size: calc(var(--unit) * 1.05 * var(--scale)); + line-height: 1.45; + color: var(--text-dim); +} + +.now-meta { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.55); + font-size: calc(var(--unit) * 1.35 * var(--scale)); + font-weight: 600; + color: #c6cada; + font-variant-numeric: tabular-nums; + /* Never wrap: a wrap to 2 lines changes the title box height, which refits the title — + the source of the every-other-slide size flap as the slide-number width changes. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.now-meta:empty { + display: none; +} + +.now-error { + margin-top: calc(var(--unit) * 0.4); + font-size: calc(var(--unit) * 0.9 * var(--scale)); + color: var(--danger); + word-break: break-word; +} + +.progress { + flex-shrink: 0; + margin-top: calc(var(--unit) * 0.7); + height: calc(var(--unit) * 0.45); + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: 999px; + background: var(--accent-deep); + transition: width 0.25s ease; +} + +.next-wrap { + /* Takes whatever is left below the fixed NOW card; markNextOverflow() hides rows past the + fold into a "+ N more" line. */ + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: calc(var(--unit) * 0.6); +} + +.section-label { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.95 * var(--scale)); + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--text-faint); +} + +.next-list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: calc(var(--unit) * 0.55); + overflow: hidden; +} + +.next-item { + display: flex; + align-items: center; + gap: var(--gap); + padding: calc(var(--unit) * 0.75) calc(var(--unit) * 1); + border: 1px solid var(--line); + border-radius: calc(var(--unit) * 0.75); + background: rgba(26, 26, 32, 0.7); +} + +.next-name { + flex: 1; + min-width: 0; + font-size: calc(var(--unit) * 1.45 * var(--scale)); + font-weight: 600; + color: #e2e4ec; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.count-chip { + flex-shrink: 0; + min-width: calc(var(--unit) * 2.2); + padding: calc(var(--unit) * 0.2) calc(var(--unit) * 0.6); + text-align: center; + border-radius: 999px; + background: rgba(74, 111, 165, 0.28); + color: #cfe0f7; + font-size: calc(var(--unit) * 1.05 * var(--scale)); + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.type-tag { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.85 * var(--scale)); + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #8a8a99; +} + +.next-item.is-header { + background: none; + border: none; + border-bottom: 1px solid var(--line-soft); + border-radius: 0; + padding: calc(var(--unit) * 0.7) calc(var(--unit) * 0.15) calc(var(--unit) * 0.25); +} + +.next-item.is-header .next-name { + font-size: calc(var(--unit) * 0.95 * var(--scale)); + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #8a8a99; +} + +.next-more { + flex-shrink: 0; + font-size: calc(var(--unit) * 0.95 * var(--scale)); + color: var(--text-faint); + font-weight: 600; +} + +.empty-next { + font-size: calc(var(--unit) * 1.1 * var(--scale)); + color: var(--text-faint); +} + +/* ---------- settings (hidden until someone reaches for it) ---------- */ + +.gear { + position: fixed; + right: calc(var(--pad) + env(safe-area-inset-right)); + bottom: calc(var(--pad) + env(safe-area-inset-bottom)); + width: 44px; + height: 44px; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: 999px; + /* A floating control: opaque with a shadow so it reads as sitting above the NEXT list + during the few seconds it is visible after someone touches the screen. */ + background: #1c1c22; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); + color: #9a9aa8; + cursor: pointer; + opacity: 0; + pointer-events: none; + transition: opacity 0.25s ease, color 0.2s ease, border-color 0.2s ease; +} + +body.chrome-visible .gear { + opacity: 1; + pointer-events: auto; +} + +.gear:hover { + color: var(--text); + border-color: #3d3d4a; +} + +.gear svg { + width: 22px; + height: 22px; +} + +.panel-backdrop { + position: fixed; + inset: 0; + background: rgba(6, 6, 8, 0.62); +} + +.panel { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: min(440px, 100vw); + display: flex; + flex-direction: column; + gap: 18px; + padding: calc(18px + env(safe-area-inset-top)) 20px calc(24px + env(safe-area-inset-bottom)); + background: var(--panel); + border-left: 1px solid var(--line); + overflow-y: auto; + user-select: text; + font-size: 14px; +} + +.panel.hidden, +.panel-backdrop.hidden { + display: none; +} + +.panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.panel-title { + margin: 0; + font-size: 18px; + font-weight: 700; +} + +.panel-section { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 16px; + border-top: 1px solid var(--line); +} + +.panel-section-title { + margin: 0; + font-size: 12px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + color: #8a8a99; +} + +.panel-copy { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: var(--text-dim); +} + +.panel-copy code { + font-size: 12px; + color: #cfe0f7; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: #a0a0ae; +} + +.field em { + font-style: normal; + color: var(--text-faint); +} + +.field input { + padding: 10px 12px; + font-size: 15px; + color: var(--text); + background: #0f0f13; + border: 1px solid #33333d; + border-radius: 9px; +} + +.field input:focus { + outline: none; + border-color: var(--accent-deep); +} + +.field-narrow input { + max-width: 140px; +} + +.panel-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.btn { + padding: 9px 14px; + font-size: 13px; + font-weight: 600; + color: var(--text); + background: #24242c; + border: 1px solid #35353f; + border-radius: 9px; + cursor: pointer; +} + +.btn:hover { + background: #2b2b34; +} + +.btn:disabled { + opacity: 0.5; + cursor: default; +} + +.btn.primary { + background: var(--accent-deep); + border-color: #5b83bd; +} + +.btn.primary:hover { + background: #547cb6; +} + +.btn.ghost { + background: none; + border-color: transparent; + color: var(--text-dim); +} + +.panel-status { + min-height: 18px; + font-size: 13px; + line-height: 1.4; + color: var(--text-dim); +} + +.panel-status.is-error { + color: #ffb1b1; +} + +.chip-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chip { + padding: 8px 13px; + font-size: 13px; + font-weight: 600; + color: var(--text-dim); + background: #1e1e25; + border: 1px solid #33333d; + border-radius: 999px; + cursor: pointer; +} + +.chip.is-on { + color: #cfe0f7; + background: rgba(74, 111, 165, 0.3); + border-color: #4a6fa5; +} + +.toggle { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: #c6cada; + cursor: pointer; +} + +.toggle input { + width: 17px; + height: 17px; + accent-color: var(--accent-deep); +} + +.url-list { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 14px; + font-weight: 600; + color: #cfe0f7; + word-break: break-all; +} + +.url-list span.muted { + font-weight: 500; + color: var(--text-faint); +} + +/* Short windows (a strip display above a door, or a small browser window): shed the NOW + card's chrome rather than squeezing the title down to nothing. */ +@media (max-height: 460px) { + .now { + height: 62vh; + min-height: 0; + padding: calc(var(--pad) * 0.55) calc(var(--pad) * 0.9); + } + + .now-label { + font-size: calc(var(--unit) * 0.8 * var(--scale)); + } + + .now-meta { + margin-top: calc(var(--unit) * 0.25); + font-size: calc(var(--unit) * 1 * var(--scale)); + } + + /* The meta line already reads "Slide 12 / 43", so the bar is the first thing to go. */ + .progress { + display: none; + } +} + +/* Phones and small tablets in portrait: give NOW less height so NEXT still shows. */ +@media (max-aspect-ratio: 3 / 4) { + .now { + height: 38vh; + } +} + +@media (max-width: 520px) { + .panel { + width: 100vw; + border-left: none; + } +} diff --git a/propresenter-app/server.js b/propresenter-app/server.js new file mode 100644 index 0000000..1aade05 --- /dev/null +++ b/propresenter-app/server.js @@ -0,0 +1,790 @@ +'use strict'; + +/** + * The ProPresenter Now / Next display server. + * + * Runs in two modes: + * + * STANDALONE — `node server.js`. Owns its own ProPresenter connection, stored in + * config.json, configurable from any browser that opens the page. + * + * EMBEDDED — the Band Lineup app passes in the client it is already polling with + * (`createDisplayServer({ client })`). One poll loop feeds Band Lineup's own column and + * every screen in the building, the band app's UI is the only place the connection is + * configured, and this server touches no config file at all. In this mode it must never + * stop or reconfigure the client it was handed — doing so would kill the backstage display + * mid-service. + * + * Either way, browsers get the live view-model over Server-Sent Events, so one install + * can drive every screen: studios, hallway TVs, green-room tablets. + * + * Zero npm dependencies: Node built-ins only (needs Node 18+ for global fetch). + */ + +const http = require('http'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { createProPresenterClient, clearedViewModel, DEFAULT_PP_PORT } = require('../propresenter-core'); +const { + resolveDataDir, + configPath, + loadConfig, + saveConfig, + viewerAddresses, + defaultRouteAddress, +} = require('./config'); +const { createMdnsResponder } = require('./mdns'); + +const DEFAULT_PORT = 7654; +const PUBLIC_DIR = path.join(__dirname, 'public'); +/** SSE comment ping cadence — keeps idle connections alive through proxies and sleepy Wi-Fi. */ +const HEARTBEAT_MS = 15000; +/** Cap on remembered viewer-facing local addresses, so a hostile client can't grow it forever. */ +const MAX_OBSERVED = 16; +/** + * Per-viewer write buffer we tolerate before dropping them. A tablet that locks or a PC that + * enters modern standby keeps the TCP connection open but stops reading, and res.write() + * returns false rather than throwing — so without this every frame queues in the main + * process forever (measured: ~78 MB RSS after two hours of polling for one dead viewer). + * A healthy screen is brought straight back by the page's own reconnect logic. + */ +const MAX_VIEWER_BACKLOG = 256 * 1024; +/** + * Caps on live viewers. A building has a dozen screens at most; without a limit anyone who + * can open sockets can pile up connections inside the Electron main process (300 were + * accepted before this). Excess connections are REFUSED — an established screen is never + * dropped to make room for a new one, because the established one is the one someone is + * watching. + */ +const MAX_STREAMS = 32; +const MAX_STREAMS_PER_IP = 6; + +const MIME = { + '.html': 'text/html; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.ico': 'image/x-icon', + '.webmanifest': 'application/manifest+json; charset=utf-8', + '.woff2': 'font/woff2', +}; + +function sendJson(res, status, body) { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }); + res.end(payload); +} + +function readBody(req, limitBytes = 64 * 1024) { + return new Promise((resolve, reject) => { + let data = ''; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > limitBytes) { + reject(new Error('Request body too large.')); + req.destroy(); + return; + } + data += chunk; + }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); +} + +/** + * Build the display server. + * @param {object} [options] + * @param {number} [options.port] TCP port to listen on. + * @param {object} [options.client] An existing ProPresenter client to mirror (EMBEDDED mode). + * @param {string} [options.hostKind] Machine-readable host id, e.g. 'band-lineup'. + * @param {string} [options.hostLabel] Human phrase for the host, shown to viewers. + * @param {string} [options.mdnsName] Bare label to publish over mDNS; '' disables it. + * @param {string} [options.dataDir] Where config.json is read/written (standalone only). + * @param {boolean} [options.readOnly] Refuse settings changes over HTTP. + */ +function createDisplayServer(options = {}) { + const port = Number.parseInt(String(options.port ?? process.env.PORT ?? DEFAULT_PORT), 10) || DEFAULT_PORT; + /** Presence of an injected client IS the mode switch. */ + const embedded = Boolean(options.client); + const hostKind = String(options.hostKind || (embedded ? 'embedded' : 'standalone')); + const hostLabel = String(options.hostLabel || (embedded ? 'the computer hosting this display' : 'the display server')); + const mdnsName = options.mdnsName === undefined ? 'nownext' : String(options.mdnsName || ''); + // Embedded callers pass the host app's version; standalone reads its own package.json. + const appVersion = String(options.appVersion || (() => { + try { + return require('./package.json').version || ''; + } catch { + return ''; + } + })()); + + // Standalone owns a config file; embedded owns nothing on disk (the band app persists the + // connection itself, so writing a second copy here would only invite the two to disagree). + const dataDir = embedded ? null : resolveDataDir(options.dataDir); + let config = embedded ? null : loadConfig(dataDir); + + const readOnly = embedded ? true : (options.readOnly ?? (process.env.PP_DISPLAY_READONLY === '1')); + const readOnlyReason = embedded ? 'managed-by-host' : (readOnly ? 'locked' : ''); + + /** Latest view-model, served to new viewers immediately so screens never start blank. */ + let latest = clearedViewModel(); + /** Serialized copy of the last frame actually broadcast, for change detection. */ + let lastStateFrame = ''; + /** @type {Set} */ + const streams = new Set(); + /** Local addresses that real (non-loopback) viewers actually reached us on. */ + const observed = new Set(); + /** + * Whether any screen has connected since this server started. Latched HERE, where the + * fact is known, not sampled by a UI poll — that poll is suppressed for the whole of + * show mode, so a service where three studios watched for 90 minutes would otherwise + * look like one where nothing ever connected. + */ + let everHadViewer = false; + let peakViewers = 0; + let defaultRoute = ''; + let heartbeat = null; + let listening = false; + let lastServerError = ''; + let mdns = null; + + const client = embedded + ? options.client + : createProPresenterClient({ + onData: (view) => { + publish(view); + }, + }); + + if (!embedded) client.configure(config); + + function broadcast(event, payload) { + if (!streams.size) return; + let frame; + try { + // Stringify OUTSIDE the per-client loop, and inside the guard: a serialization + // failure must not escape into the caller (in embedded mode the caller is the poll + // loop, and a throw there would end polling). + frame = `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; + } catch (err) { + lastServerError = err instanceof Error ? err.message : String(err); + return; + } + for (const res of streams) { + try { + const backlog = res.writableLength || res.socket?.writableLength || 0; + if (backlog > MAX_VIEWER_BACKLOG) { + streams.delete(res); + res.destroy(); + continue; + } + res.write(frame); + } catch { + streams.delete(res); + } + } + } + + /** + * Push a fresh view-model to every viewer. In embedded mode this is the ONLY writer of + * `latest` — the band app calls it from the poll loop's onData. Never throws. + */ + function publish(view) { + try { + latest = view; + // The poll loop emits every tick whether anything changed or not (~100 frames a + // minute). Sending only real changes saves each screen a steady few KB/s and keeps + // idle viewers genuinely idle. New viewers still get `latest` on connect. + let frame; + try { + frame = JSON.stringify(view); + } catch (err) { + lastServerError = err instanceof Error ? err.message : String(err); + return; + } + if (frame === lastStateFrame) return; + lastStateFrame = frame; + broadcast('state', view); + } catch (err) { + lastServerError = err instanceof Error ? err.message : String(err); + } + } + + /** Push a fresh connection status to every viewer. Call after anything changes it. */ + function publishStatus() { + try { + broadcast('status', publicStatus()); + } catch (err) { + lastServerError = err instanceof Error ? err.message : String(err); + } + } + + /** Ranked list of ways to reach this display, best first. */ + function addressList() { + const out = []; + if (mdns && mdns.isPublishing()) { + out.push({ + url: `http://${mdns.name}:${port}`, + kind: 'name', + confirmed: false, + detail: 'Best bookmark — survives an IP change.', + }); + } + for (const entry of viewerAddresses({ defaultRoute, observed: [...observed] })) { + out.push({ + url: `http://${entry.address}:${port}`, + kind: 'ip', + confirmed: entry.confirmed, + detail: entry.confirmed + ? 'Confirmed working.' + : `Network adapter: ${entry.iface}`, + }); + } + return out; + } + + function publicStatus() { + const info = client.getConfig(); + const addresses = addressList(); + return { + host: info.host, + port: info.port || '', + enabled: info.enabled, + hasPassword: info.hasPassword, + reachable: latest.reachable, + readOnly, + readOnlyReason, + hostKind, + hostLabel, + hostVersion: appVersion, + // What to tell a viewer when ProPresenter is not connected. On a locked studio screen + // "press the gear and type an IP" is advice nobody there can act on. + setupHint: readOnly + ? `Nobody needs to do anything here — the connection is set up on ${hostLabel}.` + : 'Press the gear, then enter the IP address and port of the ProPresenter computer.', + defaultProPresenterPort: DEFAULT_PP_PORT, + // Deliberately not published to the network: the absolute config path and the host's + // adapter names. The host app reads those in-process; a viewer has no use for them. + configPath: null, + viewers: streams.size, + // Screens, not sockets. One machine can hold more than one connection — a reloaded + // tab whose old stream has not been reaped yet, or two tabs of the same page — and + // reporting sockets makes the app claim more screens than exist in the building. + viewerDevices: new Set([...streams].map((res) => res.__viewerIp).filter(Boolean)).size, + everHadViewer, + peakViewers, + displayPort: port, + mdnsName: mdns && mdns.isPublishing() ? mdns.name : '', + mdnsConflict: mdns ? mdns.getConflictHost() : '', + addresses, + // Kept for older callers/scripts that read a flat list. + viewerUrls: addresses.filter((entry) => entry.kind === 'ip').map((entry) => entry.url), + }; + } + + function startPolling() { + if (embedded) throw new Error('startPolling() is not available in embedded mode.'); + client.configure(config); + if (!client.start()) { + publish(clearedViewModel()); + return false; + } + return true; + } + + function stopPolling() { + if (embedded) throw new Error('stopPolling() is not available in embedded mode.'); + client.stop(); + publish(clearedViewModel()); + } + + function serveStatic(req, res, urlPath) { + let rel; + try { + rel = urlPath === '/' ? 'index.html' : decodeURIComponent(urlPath).replace(/^\/+/, ''); + } catch { + res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Bad request'); + return; + } + // Resolve inside PUBLIC_DIR only — never let a crafted path escape the web root. + const target = path.resolve(PUBLIC_DIR, rel); + if (target !== PUBLIC_DIR && !target.startsWith(PUBLIC_DIR + path.sep)) { + res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Forbidden'); + return; + } + fs.readFile(target, (err, data) => { + if (err) { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Not found'); + return; + } + res.writeHead(200, { + 'Content-Type': MIME[path.extname(target).toLowerCase()] || 'application/octet-stream', + // no-store, not no-cache: a wall screen must never run a stale page after an update. + // Paired with the page's own version-triggered reload, a new build reaches every + // screen within a poll or two without anyone touching them. + 'Cache-Control': 'no-store, must-revalidate', + }); + res.end(data); + }); + } + + /** True if an address belongs to this machine. */ + function isOwnAddress(address) { + if (!address) return false; + for (const entries of Object.values(os.networkInterfaces())) { + for (const entry of entries || []) { + if (entry.address === address) return true; + } + } + return false; + } + + /** + * Remember the local address a real viewer reached us on — that beats every ranking + * heuristic, because it is evidence rather than inference. + * + * Requests from this machine to its own LAN address do NOT count: they prove the socket + * is bound, not that anything else on the network can get through the firewall to it. + * Labelling those "confirmed working" would be exactly the false reassurance this is + * meant to replace. + */ + function noteViewerRoute(req) { + try { + const remote = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, ''); + if (!remote || remote.startsWith('127.') || remote === '::1') return; + if (isOwnAddress(remote)) return; + const local = String(req.socket?.localAddress || '').replace(/^::ffff:/, ''); + if (!local || local.startsWith('127.') || local === '::1') return; + if (!observed.has(local) && observed.size < MAX_OBSERVED) observed.add(local); + } catch { + /* socket already gone */ + } + } + + /** Hosts we will accept a state-changing request for (blocks DNS-rebinding writes). */ + function hostAllowedForWrite(hostHeader) { + const raw = String(hostHeader || '').trim().toLowerCase(); + if (!raw) return false; + // Strip the port; reject anything that isn't a plain host[:port]. + const host = raw.replace(/:\d+$/, ''); + if (host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || host === '::1') return true; + if (mdns && mdns.name && host === mdns.name.toLowerCase()) return true; + return viewerAddresses({ defaultRoute, observed: [...observed] }).some((entry) => entry.address === host); + } + + /** + * Guard the two state-changing routes. Reads stay wide open — an unattended wall display + * must never be gated — but a write has to look like it came from this app's own page. + * + * Without this, a plain cross-site form works with no JavaScript and no preflight: a + * `
` can post a body that JSON.parse accepts, which is enough + * to retarget or disconnect a standalone display from any page a volunteer happens to open. + * Requiring application/json forces a CORS preflight that a hostile origin cannot pass. + */ + function refuseUnsafeWrite(req, res) { + const contentType = String(req.headers['content-type'] || '').toLowerCase(); + if (!contentType.startsWith('application/json')) { + sendJson(res, 415, { error: 'Send application/json.' }); + return true; + } + const site = String(req.headers['sec-fetch-site'] || '').toLowerCase(); + if (site && site !== 'same-origin' && site !== 'none') { + sendJson(res, 403, { error: 'Cross-site requests are not allowed.' }); + return true; + } + const origin = req.headers.origin; + if (origin) { + let originHost = ''; + try { + originHost = new URL(origin).host; + } catch { + originHost = 'invalid'; + } + if (!hostAllowedForWrite(originHost)) { + sendJson(res, 403, { error: 'Cross-site requests are not allowed.' }); + return true; + } + } + if (!hostAllowedForWrite(req.headers.host)) { + sendJson(res, 403, { error: 'Unrecognised host name for a settings change.' }); + return true; + } + return false; + } + + async function handleApi(req, res, urlPath) { + if (urlPath === '/api/stream' && req.method === 'GET') { + const remote = String(req.socket?.remoteAddress || '').replace(/^::ffff:/, ''); + if (streams.size >= MAX_STREAMS) { + res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '30' }); + res.end(JSON.stringify({ error: `This display is already serving ${streams.size} screens.` })); + return true; + } + let sameIp = 0; + for (const existing of streams) { + if (existing.__viewerIp === remote) sameIp += 1; + } + if (sameIp >= MAX_STREAMS_PER_IP) { + res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8', 'Retry-After': '30' }); + res.end(JSON.stringify({ error: 'Too many connections from this device.' })); + return true; + } + res.__viewerIp = remote; + // A browser that vanishes without closing the TCP connection (tab discarded, laptop + // lid shut, Wi-Fi dropped) leaves a socket the kernel will happily hold for many + // minutes, which inflates the count the operator reads. Keepalive probes make the + // OS notice and tear it down. + try { + req.socket.setKeepAlive(true, 15000); + } catch { + /* socket already gone */ + } + // Build both payloads BEFORE writing headers: a throw after writeHead becomes an + // ERR_HTTP_HEADERS_SENT unhandled rejection, which in Electron takes the app down. + const stateFrame = `event: state\ndata: ${JSON.stringify(latest)}\n\n`; + const statusFrame = `event: status\ndata: ${JSON.stringify(publicStatus())}\n\n`; + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write('retry: 3000\n\n'); + res.write(stateFrame); + res.write(statusFrame); + streams.add(res); + everHadViewer = true; + peakViewers = Math.max(peakViewers, streams.size); + req.on('close', () => { + streams.delete(res); + // The viewer count changed; tell the remaining screens (and the host UI). + publishStatus(); + }); + publishStatus(); + return true; + } + + if (urlPath === '/api/state' && req.method === 'GET') { + sendJson(res, 200, { state: latest, status: publicStatus() }); + return true; + } + + if (urlPath === '/api/status' && req.method === 'GET') { + sendJson(res, 200, publicStatus()); + return true; + } + + if (urlPath === '/api/playlists' && req.method === 'GET') { + // The one route that had no guard. Nothing over HTTP needs it: the display page never + // calls it, and the host app reads playlists in-process over IPC. Left open it hands + // any unauthenticated client the entire ProPresenter library — not just today's order, + // but every playlist name, including anything held back or staff-only. + if (embedded || readOnly) { + sendJson(res, 409, { + error: `Playlists are managed on ${hostLabel}.`, + readOnlyReason, + }); + return true; + } + try { + sendJson(res, 200, { playlists: await client.listPlaylists() }); + } catch (error) { + sendJson(res, 502, { error: error instanceof Error ? error.message : 'Could not list playlists.' }); + } + return true; + } + + if (urlPath === '/api/connect' && req.method === 'POST') { + if (embedded) { + // Hard refuse. Half-completing this would retarget — or silently stop — the poll + // loop the backstage display itself depends on. + sendJson(res, 409, { + error: `This display follows ${hostLabel}. Change the ProPresenter connection there.`, + readOnlyReason, + }); + return true; + } + if (readOnly) { + sendJson(res, 403, { error: 'This display is locked. Change the connection on the host machine.' }); + return true; + } + if (refuseUnsafeWrite(req, res)) return true; + let payload = {}; + try { + payload = JSON.parse((await readBody(req)) || '{}'); + } catch { + sendJson(res, 400, { error: 'Malformed request.' }); + return true; + } + const host = String(payload.host || '').trim(); + const portNum = Number.parseInt(String(payload.port ?? ''), 10); + const ppPort = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0; + // An omitted password field keeps the stored one; an empty string clears it. + const password = payload.password === undefined ? config.password : String(payload.password); + if (!host || !ppPort) { + sendJson(res, 400, { error: 'Enter the ProPresenter host (IP) and port.' }); + return true; + } + + // Test on a throwaway client: pointing the live one at an unverified address would + // make every screen in the building flash "Reconnecting…" while an operator types. + const probe = createProPresenterClient(); + probe.configure({ enabled: true, host, port: ppPort, password }); + try { + await probe.testConnection(); + } catch (error) { + sendJson(res, 502, { error: error instanceof Error ? error.message : 'Could not reach ProPresenter.' }); + return true; + } + + config = saveConfig(dataDir, { enabled: true, host, port: ppPort, password }); + client.reset(); + client.stop(); + startPolling(); + publishStatus(); + sendJson(res, 200, { ok: true, status: publicStatus() }); + return true; + } + + if (urlPath === '/api/disconnect' && req.method === 'POST') { + if (embedded) { + sendJson(res, 409, { + error: `This display follows ${hostLabel}. Disconnect there instead.`, + readOnlyReason, + }); + return true; + } + if (readOnly) { + sendJson(res, 403, { error: 'This display is locked. Change the connection on the host machine.' }); + return true; + } + if (refuseUnsafeWrite(req, res)) return true; + stopPolling(); + client.reset(); + // Keep host/port on disk for an easy reconnect; drop the password. + config = saveConfig(dataDir, { enabled: false, host: config.host, port: config.port, password: '' }); + client.configure({ ...config, password: '' }); + publishStatus(); + sendJson(res, 200, { ok: true, status: publicStatus() }); + return true; + } + + if (urlPath === '/api/health' && req.method === 'GET') { + sendJson(res, 200, { ok: true, viewers: streams.size, reachable: latest.reachable }); + return true; + } + + return false; + } + + const server = http.createServer((req, res) => { + // Everything in here runs inside the Electron main process when embedded, so a single + // unhandled throw would take the whole band app down with it. + try { + noteViewerRoute(req); + + let urlPath; + try { + // A malformed Host header makes the URL constructor throw. Don't trust it. + urlPath = new URL(req.url, 'http://display.invalid').pathname; + } catch { + sendJson(res, 400, { error: 'Bad request.' }); + return; + } + + if (urlPath.startsWith('/api/')) { + handleApi(req, res, urlPath) + .then((handled) => { + if (!handled) sendJson(res, 404, { error: 'Unknown endpoint.' }); + }) + .catch((error) => { + lastServerError = error instanceof Error ? error.message : String(error); + if (!res.headersSent) { + sendJson(res, 500, { error: 'Server error.' }); + } else { + try { res.end(); } catch { /* already gone */ } + } + }); + return; + } + + if (req.method !== 'GET' && req.method !== 'HEAD') { + res.writeHead(405, { Allow: 'GET, HEAD' }).end('Method not allowed'); + return; + } + serveStatic(req, res, urlPath); + } catch (error) { + lastServerError = error instanceof Error ? error.message : String(error); + try { + if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' }); + else res.end(); + } catch { /* already gone */ } + } + }); + + // Post-listen errors (a dropped interface, an ECONNRESET storm) must be handled or Node + // treats them as uncaught. This has to be a persistent handler, not the one-shot reject + // used by listen(), which is already settled by then. + server.on('error', (error) => { + lastServerError = error instanceof Error ? error.message : String(error); + }); + server.on('clientError', (error, socket) => { + lastServerError = error instanceof Error ? error.message : String(error); + try { socket.destroy(); } catch { /* already gone */ } + }); + + async function listen() { + defaultRoute = await defaultRouteAddress(); + + await new Promise((resolve, reject) => { + const onError = (error) => { + server.removeListener('listening', onListening); + reject(error); + }; + const onListening = () => { + server.removeListener('error', onError); + resolve(); + }; + server.once('error', onError); + server.once('listening', onListening); + // 0.0.0.0 on purpose: the whole point is other screens in the building can open this. + // NOTE on Windows: this bind is what triggers the Defender Firewall prompt — not the + // first remote connection. localhost keeps working either way, so the host machine + // can look perfectly healthy while every studio times out. + server.listen(port, '0.0.0.0'); + }); + + listening = true; + heartbeat = setInterval(() => { + for (const res of streams) { + try { + if ((res.writableLength || res.socket?.writableLength || 0) > MAX_VIEWER_BACKLOG) { + streams.delete(res); + res.destroy(); + continue; + } + res.write(': ping\n\n'); + } catch { + streams.delete(res); + } + } + }, HEARTBEAT_MS); + heartbeat.unref?.(); + + if (mdnsName) { + mdns = createMdnsResponder({ + name: mdnsName, + getAddresses: () => viewerAddresses({ defaultRoute, observed: [...observed] }).map((entry) => entry.address), + }); + await mdns.start().catch(() => ({ ok: false })); + } + + if (embedded) { + // Seed from the client we were handed so the first viewer sees the live service + // immediately instead of a blank card. + latest = client.getViewModel(); + } else if (config.enabled) { + startPolling(); + } + + return { port, addresses: addressList(), mdnsName: mdns && mdns.isPublishing() ? mdns.name : '' }; + } + + async function close() { + if (heartbeat) { + clearInterval(heartbeat); + heartbeat = null; + } + if (mdns) { + mdns.stop(); + mdns = null; + } + // EMBEDDED: never touch the injected client. Stopping it here would silently kill the + // backstage Now/Next column every time hosting is switched off or the app quits. + if (!embedded) client.stop(); + for (const res of streams) { + try { res.end(); } catch { /* already gone */ } + } + streams.clear(); + if (!listening) return; + listening = false; + await new Promise((resolve) => { + // server.close() waits for every connection that isn't *idle* to finish, and a socket + // that connected without ever completing a request never becomes idle — which is + // exactly what browser speculative preconnect leaves behind (Safari on iPad, Chrome + // and Edge on the studio PCs all do it). close() would then never call back, wedging + // the caller: a port change would stop the old server and never start the new one. + // Node also cancels its own header/request timeouts during close, so nothing else + // reaps those sockets. Tear them down ourselves, with a hard bound as a backstop. + const bail = setTimeout(resolve, 2000); + bail.unref?.(); + server.close(() => { + clearTimeout(bail); + resolve(); + }); + // Safe here: every SSE response was already ended above, so viewers see a clean + // stream end and reconnect on their own. + server.closeAllConnections?.(); + }); + } + + return { + server, + listen, + close, + port, + dataDir, + readOnly, + embedded, + status: publicStatus, + addresses: addressList, + publish, + publishStatus, + viewers: () => streams.size, + isListening: () => listening, + getLastError: () => lastServerError, + client, + }; +} + +module.exports = { createDisplayServer, DEFAULT_PORT }; + +// Run directly: node server.js +if (require.main === module) { + const display = createDisplayServer(); + display + .listen() + .then(({ port, addresses, mdnsName }) => { + const status = display.status(); + console.log(`Stage Now / Next display on http://localhost:${port}`); + if (mdnsName) console.log(` bookmark this: http://${mdnsName}:${port}`); + for (const entry of addresses) console.log(` ${entry.url}${entry.confirmed ? ' (confirmed working)' : ''}`); + console.log(` config: ${display.dataDir ? configPath(display.dataDir) : '(none)'}`); + console.log( + status.enabled + ? ` following ProPresenter at ${status.host}:${status.port}` + : ' no ProPresenter connection saved yet — open the page and press the gear to set one up', + ); + if (status.readOnly) console.log(' settings are LOCKED (PP_DISPLAY_READONLY=1)'); + }) + .catch((error) => { + console.error(`Could not start the display server: ${error.message}`); + process.exit(1); + }); + + for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => { + display.close().then(() => process.exit(0)); + }); + } +} diff --git a/propresenter-core/index.js b/propresenter-core/index.js new file mode 100644 index 0000000..fb72fb4 --- /dev/null +++ b/propresenter-core/index.js @@ -0,0 +1,1029 @@ +'use strict'; + +/* ------------------------------------------------------------------ * + * ProPresenter 7 / 20 integration core * + * * + * Shared by two programs: * + * 1. the Band Lineup Electron app (main.js, via IPC to its renderer) * + * 2. propresenter-app — the standalone Now/Next display for studios * + * * + * Everything here is plain Node: global fetch only, no Electron, no * + * npm dependencies. All ProPresenter HTTP lives in this module so no * + * browser ever has to deal with CORS or a CSP exception. We poll the * + * basic REST endpoints (works on every ProPresenter 7+ version) and * + * hand callers a small, stable view-model. * + * ------------------------------------------------------------------ */ + +/** Default ProPresenter network API port (user-configurable in ProPresenter ▸ Preferences ▸ Network). */ +const DEFAULT_PP_PORT = 1025; +/** How often to poll ProPresenter for playlist/slide changes while connected. */ +const DEFAULT_POLL_MS = 600; +/** Slower retry cadence when ProPresenter is unreachable. */ +const DEFAULT_RETRY_MS = 2500; + +/** Normalize a stored/entered connection config into the shape the client expects. */ +function normalizeConfig(entry) { + const host = String(entry?.host || '').trim(); + const portNum = Number.parseInt(String(entry?.port ?? ''), 10); + const port = Number.isFinite(portNum) && portNum > 0 && portNum <= 65535 ? portNum : 0; + return { + enabled: Boolean(entry?.enabled) && Boolean(host) && Boolean(port), + host, + port, + playlistUuid: String(entry?.playlistUuid || ''), + playlistName: String(entry?.playlistName || ''), + }; +} + +/** The internal polling state, before it is projected into a view-model. */ +function emptyPollState() { + return { + reachable: false, + lastError: '', + playlistUuid: '', + playlistName: '', + items: [], + activeUuid: '', + activeName: '', + activeIndex: -1, + currentSlide: 0, + livePresentationUuid: '', + livePresentationSlideTotal: 0, + /** 'playlist' | 'group' | 'unknown' | 'none' — what ProPresenter pointed us at. */ + playlistKind: 'none', + itemsRetryAt: 0, + /** From /v1/status/layers: is the slide layer actually on screen right now? */ + slideLayerLive: false, + }; +} + +/** + * The "nothing connected" view-model. Consumers render this after a disconnect so + * the display clears instead of freezing on the last live slide. + */ +function clearedViewModel() { + return { + enabled: false, + reachable: false, + lastError: '', + needsPlaylist: false, + playlistName: '', + items: [], + activeIndex: -1, + activePresentationName: '', + currentSlide: 0, + currentSlideTotal: 0, + }; +} + +/** Flatten the /v1/playlists tree (groups → playlists) into a flat list a picker can show. */ +function flattenPlaylists(nodes, groupName, out) { + for (const node of Array.isArray(nodes) ? nodes : []) { + const id = node?.id || {}; + const uuid = String(id.uuid || node?.uuid || ''); + const name = String(id.name || node?.name || ''); + const fieldType = String(node?.field_type || node?.type || ''); + if (fieldType === 'playlist' || (!fieldType && uuid && !Array.isArray(node?.children))) { + if (uuid) out.push({ uuid, name, group: groupName }); + } + if (Array.isArray(node?.children) && node.children.length) { + flattenPlaylists(node.children, fieldType === 'group' ? name : groupName, out); + } + } + return out; +} + +/** Parse a /v1/playlist/{uuid} response into our item shape, tolerating ProPresenter version differences. */ +function parsePlaylistItems(data) { + const list = Array.isArray(data?.items) + ? data.items + : Array.isArray(data?.children) + ? data.children + : Array.isArray(data) + ? data + : []; + return list.map((item) => { + const id = item?.id || {}; + return { + uuid: String(id.uuid || item?.uuid || ''), + name: String(id.name || item?.name || ''), + type: String(item?.type || item?.field_type || 'presentation'), + isHidden: Boolean(item?.is_hidden ?? item?.isHidden), + isPco: Boolean(item?.is_pco ?? item?.isPco), + slideCount: null, + }; + }); +} + +/** Depth-first search the /v1/playlists tree for a playlist by uuid; returns its index path + name. */ +function findPlaylistPath(nodes, targetUuid, prefix) { + if (!Array.isArray(nodes)) return null; + for (let i = 0; i < nodes.length; i += 1) { + const node = nodes[i]; + const id = node?.id || {}; + const uuid = String(id.uuid || node?.uuid || ''); + const idx = Number.isInteger(id.index) ? id.index : i; + const here = [...prefix, idx]; + if (uuid && uuid === targetUuid) { + return { + path: here, + name: String(id.name || node?.name || ''), + fieldType: String(node?.field_type || node?.type || ''), + }; + } + const deeper = findPlaylistPath(node?.children, targetUuid, here); + if (deeper) return deeper; + } + return null; +} + +function countSlides(presentationData) { + // PP20 wraps details under `presentation`; older shapes are flat. + const presentation = presentationData?.presentation || presentationData; + const groups = Array.isArray(presentation?.groups) ? presentation.groups : []; + let count = 0; + for (const group of groups) { + const slides = Array.isArray(group?.slides) ? group.slides : []; + // Count ALL slides including disabled — slide_index.index counts disabled slides too, + // so the total must match that same sequence or "Slide X / Y" will be inconsistent. + count += slides.length; + } + return count; +} + +/** + * Tidy an item name for display only (the raw name is kept for ProPresenter matching). + * Strips arrangement/section tags in brackets ([ Full ], ( TAG ), { ... }), a leading date + * code (e.g. "260607 "), separator dashes, and any leftover edge punctuation/whitespace so the + * card reads as cleanly as possible — "260607 Taking Thoughts Captive - [ Full ]" → "Taking Thoughts Captive". + * + * Exported for Node-side consumers; the browser UIs keep their own copy of this pure + * function because they load as plain scripts with no module loader. + */ +function cleanItemName(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code + s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space + s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace + return s || original; // never blank out a title (e.g. a name that was only a tag) +} + +/** + * Create a ProPresenter polling client. + * + * @param {object} [options] + * @param {(view: object) => void} [options.onData] Called with a view-model on every poll tick. + * @param {number} [options.pollMs] Poll cadence while reachable. + * @param {number} [options.retryMs] Poll cadence while unreachable. + */ +function createProPresenterClient(options = {}) { + const pollMs = Number.isFinite(options.pollMs) && options.pollMs > 0 ? options.pollMs : DEFAULT_POLL_MS; + const retryMs = Number.isFinite(options.retryMs) && options.retryMs > 0 ? options.retryMs : DEFAULT_RETRY_MS; + let onData = typeof options.onData === 'function' ? options.onData : null; + + /** @type {{ enabled: boolean, host: string, port: number, password: string, playlistUuid: string, playlistName: string }} */ + let config = { enabled: false, host: '', port: 0, password: '', playlistUuid: '', playlistName: '' }; + let state = emptyPollState(); + let pollTimer = null; + let stopped = true; + /** Monotonic token so a superseded poll loop can detect it should bail. */ + let runToken = 0; + /** uuid -> master (document) slide count. Counts are stable during a service, so fetch once and reuse. */ + const slideCountCache = new Map(); + /** + * uuid -> arrangement-expanded cue count (the total that matches slide_index.index). + * slide_index counts the SELECTED arrangement's expanded cue sequence (repeated groups count + * multiple times), while /v1/presentation/{uuid} only returns the unique master groups — so the + * two disagree whenever an arrangement repeats a section. We recover the true total by probing the + * thumbnail endpoint, whose index "respects the selected arrangement" (same space as slide_index). + */ + const arrangementCountCache = new Map(); + /** Cached URL that successfully returned the current playlist's item list (PP20 renamed this route). */ + let itemsSourceUrl = ''; + let itemsForUuid = ''; + /** Index path of the playlist we last proved contains the live presentation. */ + let provenPlaylistPath = ''; + /** Name of that proven playlist, so the header doesn't blank when the hint disagrees. */ + let provenPlaylistName = ''; + /** Last playlist uuid ProPresenter HINTED at, for change detection. */ + let lastHintUuid = ''; + /** Poll ticks since the last tree-wide search, so it cannot run every 600ms. */ + let searchCooldown = 0; + /** Monotonic tick count, so the heavy calls run on a slow secondary cadence. */ + let tick = 0; + /** True when the last slide_index came back null. */ + let lastIndexWasNull = false; + + function url(target) { + const built = new URL(`http://${config.host}:${config.port}${target}`); + if (config.password) built.searchParams.set('password', config.password); + return built; + } + + async function requestJson(target, init) { + const response = await fetch(url(target), { + headers: { Accept: 'application/json' }, + ...init, + }); + if (!response.ok) { + throw new Error(`ProPresenter ${response.status} ${response.statusText}`); + } + const text = await response.text(); + return text ? JSON.parse(text) : null; + } + + async function slideCount(uuid) { + if (!uuid) return null; + if (slideCountCache.has(uuid)) return slideCountCache.get(uuid); + try { + const data = await requestJson(`/v1/presentation/${encodeURIComponent(uuid)}`); + const count = countSlides(data); + slideCountCache.set(uuid, count); + return count; + } catch { + return null; + } + } + + /** + * Probe the thumbnail endpoint to find how many cues the SELECTED arrangement actually plays. + * The thumbnail index "respects the selected arrangement of cues" — the exact same index space + * slide_index reports — and returns 404 once the index runs past the end. So the count of cues is + * the smallest index that 404s. We only read the HTTP status (the JPEG body is cancelled), and do + * an exponential-then-binary search so the whole probe is ~2·log2(N) tiny requests. + * Returns the cue count, or null if the presentation can't be probed (auth error / unexpected status). + */ + async function probeArrangementLength(uuid) { + const CAP = 4000; // guard against a build that clamps instead of 404-ing — fall back rather than loop + const exists = async (index) => { + try { + const res = await fetch(url(`/v1/presentation/${encodeURIComponent(uuid)}/thumbnail/${index}`), { + headers: { Accept: 'image/jpeg' }, + }); + try { await res.body?.cancel?.(); } catch { /* body already consumed/closed */ } + if (res.status === 200) return true; + if (res.status === 404) return false; + return null; // 403 or anything unexpected → can't trust the probe + } catch { + return null; + } + }; + + const first = await exists(0); + if (first === null) return null; + if (first === false) return 0; + + // Exponential search for an index that is past the end (404). + let lo = 0; // known valid + let hi = 1; // candidate; grows until invalid + while (true) { + const r = await exists(hi); + if (r === null) return null; + if (r === false) break; + lo = hi; + hi *= 2; + if (hi > CAP) return null; + } + // Binary search the boundary: lo valid, hi invalid. + while (hi - lo > 1) { + const mid = Math.floor((lo + hi) / 2); + const r = await exists(mid); + if (r === null) return null; + if (r) lo = mid; else hi = mid; + } + return lo + 1; // cue indices are 0-based, so the count is the last valid index + 1 + } + + /** + * The slide TOTAL for the live presentation, in the same index space as slide_index.currentSlide. + * Prefers the arrangement-expanded count (cached per uuid); falls back to the master slide count if + * the thumbnail probe is unavailable. Always returns a value >= the live slide so "Slide X / Y" can + * never show X > Y (a stale arrangement is re-probed when the live index outruns the cached total). + */ + /** Pull the arrangement tag out of a playlist item name: "AMAZING! - [ Full ]" -> "full". */ + function arrangementTagFromName(name) { + const m = String(name || '').match(/[[({]\s*([^\])}]+?)\s*[\])}]/); + return m ? m[1].trim().toLowerCase() : ''; + } + + /** + * The true slide total, in the SAME index space as slide_index — computed from the + * presentation's arrangement structure rather than guessed. + * + * ProPresenter reports \`groups\` (each with slides), \`arrangements\` (each a flat ordered + * list of group UUIDs, which REPEAT sections like choruses), and \`current_arrangement\`. + * slide_index counts the selected arrangement's expanded sequence, so the correct total is + * the sum of slide counts over that arrangement's group list. The old thumbnail probe + * returned the MASTER count on this build (49 vs the real 64 for a [Full] arrangement), + * which made the live slide overshoot the total and the counter read "63/63, 64/64…". + * + * @param arrangementHint arrangement name from the playlist item, used when + * current_arrangement is blank (this PP build often leaves it blank). + */ + async function computePresentationTotal(uuid, arrangementHint) { + let data; + try { + data = await requestJson(`/v1/presentation/${encodeURIComponent(uuid)}`); + } catch { + return null; + } + const pres = data?.presentation || data; + const groups = Array.isArray(pres?.groups) ? pres.groups : []; + const groupSlides = new Map(); + let master = 0; + for (const g of groups) { + const n = Array.isArray(g?.slides) ? g.slides.length : 0; + master += n; + const gu = String(g?.uuid || g?.id?.uuid || ''); + if (gu) groupSlides.set(gu, n); + } + const arrangements = Array.isArray(pres?.arrangements) ? pres.arrangements : []; + if (!arrangements.length) return master || null; + + const currentUuid = String(pres?.current_arrangement || ''); + let chosen = currentUuid + ? arrangements.find((a) => String(a?.id?.uuid || '') === currentUuid) + : null; + // current_arrangement is frequently blank on PP20 — fall back to the item's "[ tag ]". + if (!chosen && arrangementHint) { + chosen = arrangements.find((a) => String(a?.id?.name || '').trim().toLowerCase() === arrangementHint); + } + if (!chosen && arrangements.length === 1) chosen = arrangements[0]; + if (!chosen) return master || null; + + const refs = Array.isArray(chosen.groups) ? chosen.groups : []; + let total = 0; + for (const ref of refs) { + const gu = typeof ref === 'string' ? ref : String(ref?.uuid || ref?.id?.uuid || ''); + total += groupSlides.get(gu) || 0; + } + return total > 0 ? total : (master || null); + } + + async function liveSlideTotal(uuid, currentSlide, arrangementHint) { + if (!uuid) return 0; + if (!arrangementCountCache.has(uuid)) { + const computed = await computePresentationTotal(uuid, arrangementHint); + if (computed && computed > 0) { + arrangementCountCache.set(uuid, computed); + } else { + // Last resort only if the structure was unreadable: the old probe, then master. + const probed = await probeArrangementLength(uuid); + const master = await slideCount(uuid); + arrangementCountCache.set(uuid, (probed && probed > 0) ? probed : (master || 0)); + } + } + const total = arrangementCountCache.get(uuid); + // If the live slide somehow exceeds the computed total, re-derive ONCE (an arrangement + // switch mid-song). Never poison the cache by setting total = currentSlide — that is + // exactly what produced the ever-climbing "X / X". + if (currentSlide > total) { + const recomputed = await computePresentationTotal(uuid, arrangementHint); + if (recomputed && recomputed >= currentSlide) { + arrangementCountCache.set(uuid, recomputed); + return recomputed; + } + // Can't resolve a bigger real total — show the honest live index rather than X/(smaller). + return currentSlide; + } + return total; + } + + /** + * How confident are we that this playlist item is the presentation that is live? + * 3 = same uuid, 2 = same title, 1 = one title is a prefix of the other, 0 = no. + * + * Compared on TIDIED names, because ProPresenter reports the presentation as + * "Covered By The Blood" while the playlist item carries the arrangement, + * "Covered By The Blood - [ Full ]". + * + * Deliberately NOT a substring test. A loose "does either contain the other" matched a + * pre-service playlist containing a song called "The Blood" and pointed the whole display + * at the wrong running order — a plain substring is far too eager on a song library. + */ + function matchStrength(item, liveName, liveUuid) { + if (liveUuid && item.uuid && item.uuid === liveUuid) return 3; + const itemName = cleanItemName(item.name || '').toLowerCase(); + const live = cleanItemName(liveName || '').toLowerCase(); + if (!itemName || !live) return 0; + if (itemName === live) return 2; + // A prefix relationship covers "… TAG" and "… (Reprise)" style siblings. Require a + // meaningful stem so short titles can't latch onto anything. + const stem = Math.min(itemName.length, live.length); + if (stem >= 6 && (itemName.startsWith(live) || live.startsWith(itemName))) return 1; + return 0; + } + + function itemMatchesLive(item, liveName, liveUuid) { + return matchStrength(item, liveName, liveUuid) > 0; + } + + /** Every playlist in the tree, with the index path that /v1/playlist/{a}/{b} wants. */ + async function listAllPlaylists() { + const out = []; + let tree; + try { + tree = await requestJson('/v1/playlists'); + } catch { + return out; + } + const nodes = Array.isArray(tree) ? tree : tree?.children || tree?.playlists || []; + const walk = (ns, prefix) => { + for (let i = 0; i < (Array.isArray(ns) ? ns.length : 0); i += 1) { + const node = ns[i]; + const id = node?.id || {}; + const idx = Number.isInteger(id.index) ? id.index : i; + const here = [...prefix, idx]; + const fieldType = String(node?.field_type || node?.type || ''); + if (fieldType === 'playlist') { + out.push({ path: here.join('/'), name: String(id.name || ''), uuid: String(id.uuid || '') }); + } + walk(node?.children, here); + } + }; + walk(nodes, []); + return out; + } + + /** + * Find the playlist that actually contains what is on screen. + * + * Necessary because /v1/playlist/active reports null and /v1/playlist/focused reports + * whatever the operator last clicked — which is frequently a GROUP, not the running order. + * Trusting it meant the display showed no upcoming items all through a service that was + * being run from a playlist the whole time. Searching by the live presentation is the only + * signal that reflects what is genuinely being presented. + */ + async function findPlaylistContainingLive(liveName, liveUuid) { + const playlists = await listAllPlaylists(); + if (!playlists.length) return null; + // Check the one we already proved first: while a service runs, that is a single request. + playlists.sort((a, b) => (b.path === provenPlaylistPath ? 1 : 0) - (a.path === provenPlaylistPath ? 1 : 0)); + + let best = null; + for (const entry of playlists) { + const url = `/v1/playlist/${entry.path}`; + let items = []; + try { + items = parsePlaylistItems(await requestJson(url)); + } catch { + continue; + } + if (!items.length) continue; + let strength = 0; + for (const item of items) { + strength = Math.max(strength, matchStrength(item, liveName, liveUuid)); + } + if (!strength) continue; + if (!best || strength > best.strength) { + best = { items, name: entry.name, uuid: entry.uuid, path: entry.path, strength }; + } + // A uuid or exact-title hit is as good as it gets — stop walking the tree. + if (strength >= 2) break; + } + if (!best) return null; + provenPlaylistPath = best.path; + itemsSourceUrl = `/v1/playlist/${best.path}`; + itemsForUuid = best.uuid; + return best; + } + + function recomputeActiveIndex() { + // 1. Try UUID match (works when presentation UUID === playlist item UUID). + if (state.activeUuid) { + const byUuid = state.items.findIndex((item) => item.uuid && item.uuid === state.activeUuid); + if (byUuid >= 0) { state.activeIndex = byUuid; return; } + } + // 2. Fall back to name match. On PP20, slide_index gives the presentation document UUID + // which differs from the playlist item UUID, so UUID matching silently fails. + // Names always match because both the playlist item and the presentation share the same title. + if (state.activeName) { + const normalised = state.activeName.trim().toLowerCase(); + const byName = state.items.findIndex( + (item) => item.name && item.name.trim().toLowerCase() === normalised, + ); + if (byName >= 0) { state.activeIndex = byName; return; } + // Partial name match as a last resort (handles slight label differences). + // Strongest match wins, rather than the first loose one — inside a long song list + // several titles can look similar. + let bestIndex = -1; + let bestStrength = 0; + for (let i = 0; i < state.items.length; i += 1) { + const strength = matchStrength(state.items[i], state.activeName, state.livePresentationUuid); + if (strength > bestStrength) { + bestStrength = strength; + bestIndex = i; + } + } + state.activeIndex = bestIndex; + return; + } + state.activeIndex = -1; + } + + /** + * ProPresenter 20 renamed the "list a playlist's items" route and didn't document it publicly. + * Since this app runs on the same LAN as ProPresenter, we probe the plausible address formats + * at runtime and use whichever returns a real item list, caching the winner. + */ + async function discoverItems(playlistUuid) { + if (!playlistUuid) return { items: [], kind: 'none' }; + const enc = encodeURIComponent; + + // Reuse a previously-working URL for this playlist. + if (itemsSourceUrl && itemsForUuid === playlistUuid) { + try { + const items = parsePlaylistItems(await requestJson(itemsSourceUrl)); + if (items.length) return { items, kind: 'playlist' }; + } catch { + /* re-discover below */ + } + } + + let pathStr = []; + let name = ''; + let fieldType = ''; + try { + const tree = await requestJson('/v1/playlists'); + const nodes = Array.isArray(tree) ? tree : tree?.children || tree?.playlists || []; + const found = findPlaylistPath(nodes, playlistUuid, []); + if (found) { + pathStr = found.path; + name = found.name; + fieldType = found.fieldType; + } + } catch { + /* ignore */ + } + + // A GROUP holds playlists, not items, and ProPresenter's /v1/playlist/focused will + // happily name one — which is what happens when the operator is presenting from the + // Library rather than from a service playlist. Listing its items correctly returns + // nothing, so say so instead of leaving the caller to guess from an empty array. + if (fieldType === 'group') { + return { items: [], kind: 'group' }; + } + + const candidates = [`/v1/playlist/${enc(playlistUuid)}`]; + if (name) candidates.push(`/v1/playlist/${enc(name)}`); + if (pathStr.length) { + candidates.push( + `/v1/playlist/${pathStr.join('.')}`, + `/v1/playlist/${pathStr.join(':')}`, + `/v1/playlist/${pathStr.join('-')}`, + `/v1/playlist/${pathStr.join('_')}`, + `/v1/playlist/${pathStr.join(',')}`, + `/v1/playlist/${enc(pathStr.join('/'))}`, + `/v1/playlist/${pathStr[pathStr.length - 1]}`, + ); + } + + for (const candidate of candidates) { + try { + const items = parsePlaylistItems(await requestJson(candidate)); + if (items.length) { + itemsSourceUrl = candidate; + itemsForUuid = playlistUuid; + return { items, kind: 'playlist' }; + } + } catch { + /* try next candidate */ + } + } + return { items: [], kind: 'unknown' }; + } + + /** + * Refresh which playlist we're in (for the item list). + * Uses /v1/playlist/active then /v1/playlist/focused as fallback. + * Does NOT set the active item — that comes from slide_index (see applySlideIndex). + */ + async function refreshPlaylistContext() { + let playlistUuid = ''; + let playlistName = ''; + + try { + const active = await requestJson('/v1/playlist/active'); + const pl = active?.presentation?.playlist; + if (pl?.uuid) { + playlistUuid = String(pl.uuid); + playlistName = String(pl.name || ''); + } + } catch { /* ignore */ } + + if (!playlistUuid) { + try { + const focused = await requestJson('/v1/playlist/focused'); + const pl = focused?.playlist; + if (pl?.uuid) { + playlistUuid = String(pl.uuid); + playlistName = String(pl.name || ''); + } + } catch { /* ignore */ } + } + + // Compare against the previous HINT, not against the playlist we settled on. Otherwise + // every poll looks like a change (the hint keeps naming a group while we hold the real + // playlist) and re-probes ProPresenter twice a second for nothing. + const changed = playlistUuid !== lastHintUuid; + lastHintUuid = playlistUuid; + + if (playlistUuid && changed) { + state.playlistUuid = playlistUuid; + const found = await discoverItems(playlistUuid); + state.playlistKind = found.kind; + state.itemsRetryAt = 0; + // Only REPLACE the list when we actually resolved one. During a rehearsal the operator + // clicks around groups and library items, and ProPresenter reports each of those as the + // focused "playlist"; overwriting with an empty list each time is what made the running + // order keep vanishing off the screens. + if (found.items.length) { + state.items = found.items; + await fillSlideCounts(); + } + } else if (playlistUuid && !state.items.length && state.playlistKind !== 'group') { + // Retry a discovery that came back empty. Item lookup depends on probing undocumented + // routes, so a single failure used to be sticky for the whole service — the list would + // stay blank until the operator happened to switch playlists. + state.itemsRetryAt = (state.itemsRetryAt || 0) + 1; + if (state.itemsRetryAt >= 20) { + state.itemsRetryAt = 0; + const found = await discoverItems(playlistUuid); + state.items = found.items; + state.playlistKind = found.kind; + await fillSlideCounts(); + } + } else if (!playlistUuid) { + state.playlistUuid = ''; + state.items = []; + state.playlistKind = 'none'; + } + + // Don't present a group's name as if it were a service playlist — "EVENTS" reads like a + // running order when it is really just the folder the operator has open. + state.playlistName = state.playlistKind === 'group' ? '' : playlistName; + + // The hint above is unreliable. What matters is whether the list we are holding actually + // contains what is on screen; if it doesn't, go and find the playlist that does. + const liveName = state.activeName; + if (!liveName) return; + const holdsLive = state.items.some((item) => itemMatchesLive(item, liveName, state.livePresentationUuid)); + if (holdsLive) { + searchCooldown = 0; + // Keep showing the playlist we proved, not the group the hint keeps naming. + if (provenPlaylistName) state.playlistName = provenPlaylistName; + return; + } + if (searchCooldown > 0) { + searchCooldown -= 1; + return; + } + const found = await findPlaylistContainingLive(liveName, state.livePresentationUuid); + if (found) { + state.items = found.items; + state.playlistName = found.name; + provenPlaylistName = found.name; + state.playlistUuid = found.uuid || state.playlistUuid; + state.playlistKind = 'playlist'; + await fillSlideCounts(); + recomputeActiveIndex(); + } else { + // Nothing in the tree holds it — genuinely presenting outside a playlist. Back off so + // a full tree walk cannot run on every poll. + searchCooldown = 25; + } + } + + /** + * Who is on screen when slide_index won't say. /v1/presentation/focused reports the + * presentation ProPresenter has focused — including one opened straight from the Library, + * which slide_index attributes to nothing. No slide number is available this way, so the + * card shows the title and the total without a running count. + */ + async function applyFocusedPresentation() { + try { + const focused = await requestJson('/v1/presentation/focused'); + const uuid = String(focused?.uuid || focused?.id?.uuid || ''); + const name = String(focused?.name || focused?.id?.name || ''); + if (!name && !uuid) return; + if (uuid && uuid !== state.activeUuid) { + state.activeUuid = uuid; + state.livePresentationUuid = uuid; + state.livePresentationSlideTotal = 0; + } + state.activeName = name || state.activeName; + recomputeActiveIndex(); + } catch { + /* leave the last known state alone */ + } + } + + async function fillSlideCounts() { + for (const item of state.items) { + // Try a slide count for anything that isn't an explicit header; non-presentations + // simply return null (their UUID has no presentation), which we render as "no count". + if (item.uuid && item.type !== 'header' && item.slideCount === null) { + item.slideCount = await slideCount(item.uuid); + } + } + } + + function applySlideIndex(data) { + // PP20 uses `presentation_index`; older versions used `presentation`. + const node = data?.presentation_index || data?.presentation || null; + if (!node) { + // A null here does NOT mean the screen is empty. On ProPresenter 20 this endpoint + // reports null whenever the live slide isn't attributed to a playlist presentation — + // presenting from the Library is the common case — and it also blips to null between + // triggers. Blanking on it is what made the display keep clearing itself during a + // rehearsal. /v1/status/layers is the authority on whether anything is on screen: + // while the slide layer is live, hold the last known item. + if (state.slideLayerLive) return; + state.currentSlide = 0; + state.livePresentationUuid = ''; + state.activeUuid = ''; + state.activeName = ''; + recomputeActiveIndex(); + return; + } + const idx = Number.isInteger(node.index) ? node.index : null; + state.currentSlide = idx !== null ? idx + 1 : 0; + + const presId = node?.presentation_id || node?.id || null; + const uuid = presId ? String(presId.uuid || '') : ''; + state.livePresentationUuid = uuid; + + // Use this uuid as the active item — it's always accurate regardless of how slides advanced. + if (uuid && uuid !== state.activeUuid) { + state.activeUuid = uuid; + state.livePresentationSlideTotal = 0; // reset so a fresh count is fetched this poll + // Try to find the name from the item list; fall back to presentation_id.name. + const match = state.items.find((item) => item.uuid === uuid); + state.activeName = match ? match.name : String(presId?.name || ''); + recomputeActiveIndex(); + } + } + + /** Project the internal poll state into the view-model both UIs render. */ + function getViewModel() { + // Items the operator marked hidden in ProPresenter — cut songs, contingency cues, + // staff-only slides — are REDACTED here, not removed. Both UIs already skip hidden + // rows when rendering, but their names were still being published to every screen on + // the network. Keeping the entries (minus their names) preserves the positions that + // activeIndex and the "what's next" slice depend on, which dropping them would shift. + const items = state.items.map((item) => (item.isHidden + ? { uuid: '', name: '', type: item.type, isHidden: true, isPco: false, slideCount: null } + : item)); + + // Use the live count for the presentation that's actually on screen — it reflects the + // arrangement playing right now, so it can never be stale or mismatched. + return { + enabled: config.enabled, + reachable: state.reachable, + lastError: state.lastError || '', + needsPlaylist: false, + playlistName: state.playlistName, + playlistKind: state.playlistKind || 'none', + items, + activeIndex: state.activeIndex, + activePresentationName: state.activeName, + currentSlide: state.currentSlide, + currentSlideTotal: state.livePresentationSlideTotal || 0, + }; + } + + /** Last error thrown by a consumer's onData, for callers that want to surface it. */ + let lastEmitError = ''; + + function emit() { + if (!onData) return; + // A consumer must never be able to stop the engine. emit() runs OUTSIDE pollTick's + // try/catch and BEFORE the reschedule below it, so an unhandled throw here would end + // polling for good — and with no unhandledRejection handler installed (Electron adds + // none) it would take the whole process down. Swallow, record, keep polling. + try { + onData(getViewModel()); + lastEmitError = ''; + } catch (err) { + lastEmitError = err instanceof Error ? err.message : String(err); + console.error('[propresenter-core] onData consumer threw:', err); + } + } + + async function pollTick(token) { + if (stopped || token !== runToken) return; + + try { + tick += 1; + + // THE HOT PATH — one request, every tick, exactly like version 1. This is the live + // slide counter, and it must not be starved by anything heavier sharing the loop: + // ProPresenter's little HTTP server will start returning null for slide_index if it + // is hammered, which is what stopped the counter from updating. + const raw = await requestJson('/v1/presentation/slide_index'); + lastIndexWasNull = !(raw?.presentation_index || raw?.presentation); + applySlideIndex(raw); + state.reachable = true; + state.lastError = ''; + + // Slide TOTAL for the live presentation (cached per uuid — after the first probe this + // costs nothing, so it can stay on the hot path). + if (state.livePresentationUuid) { + try { + const activeItem = state.activeIndex >= 0 ? state.items[state.activeIndex] : null; + const hint = arrangementTagFromName(activeItem?.name || state.activeName); + state.livePresentationSlideTotal = await liveSlideTotal( + state.livePresentationUuid, + state.currentSlide, + hint, + ); + } catch { + /* keep the last known total */ + } + } + + // EVERYTHING BELOW is the slow lane — the calls I added that ProPresenter does not + // need to answer on every slide. Run them roughly every 2.5s, or immediately when we + // still have no idea what is on screen. Keeps steady-state load below version 1's. + const slowTick = tick % 4 === 0; + const needBootstrap = !state.items.length || (!state.activeName && lastIndexWasNull); + + if (slowTick || needBootstrap) { + // Only ask about layers/focused when slide_index isn't naming the slide itself. + if (lastIndexWasNull) { + try { + const layers = await requestJson('/v1/status/layers'); + if (layers && typeof layers.slide === 'boolean') state.slideLayerLive = layers.slide; + } catch { + /* older builds have no layer status; leave the last value */ + } + if (state.slideLayerLive && !state.activeName) { + await applyFocusedPresentation(); + } + } + await refreshPlaylistContext(); + } + + if (state.activeUuid) { + const match = state.items.find((item) => item.uuid === state.activeUuid); + if (match) state.activeName = match.name; + } + recomputeActiveIndex(); + } catch (err) { + state.reachable = false; + state.lastError = err instanceof Error ? err.message : String(err); + } + + if (stopped || token !== runToken) return; + emit(); + pollTimer = setTimeout(() => pollTick(token), state.reachable ? pollMs : retryMs); + } + + function stop() { + stopped = true; + runToken += 1; + if (pollTimer) { + clearTimeout(pollTimer); + pollTimer = null; + } + state.reachable = false; + } + + function start() { + if (!config.enabled || !config.host || !config.port) return false; + stopped = false; + const token = (runToken += 1); + pollTick(token); + return true; + } + + /** Restart the poll loop only if it was already running (used after a config tweak). */ + function restartIfRunning() { + if (!stopped) { + stop(); + start(); + } + } + + /** Drop every cache + poll result. Call when the connection target changes. */ + function reset() { + slideCountCache.clear(); + arrangementCountCache.clear(); + itemsSourceUrl = ''; + itemsForUuid = ''; + state = emptyPollState(); + } + + /** + * Point the client at a ProPresenter instance. Does not start polling. + * Accepts a partial config; omitted fields keep their current value. + */ + function configure(next = {}) { + const merged = { + enabled: next.enabled ?? config.enabled, + host: next.host ?? config.host, + port: next.port ?? config.port, + playlistUuid: next.playlistUuid ?? config.playlistUuid, + playlistName: next.playlistName ?? config.playlistName, + }; + const normalized = normalizeConfig(merged); + config = { + ...normalized, + // enabled is only meaningful with a host+port, but callers may enable optimistically + // before the reachability check; normalizeConfig already guards that. + enabled: Boolean(merged.enabled) && Boolean(normalized.host) && Boolean(normalized.port), + password: next.password ?? config.password, + }; + return getConfig(); + } + + /** The current config, minus the password (callers get `hasPassword` instead). */ + function getConfig() { + return { + enabled: config.enabled, + host: config.host, + port: config.port, + hasPassword: Boolean(config.password), + playlistUuid: config.playlistUuid, + playlistName: config.playlistName, + }; + } + + /** + * Hit /version to confirm the API is reachable and the password (if any) is accepted. + * Resolves with ProPresenter's version payload, or throws a message worth showing a human. + */ + async function testConnection() { + if (!config.host || !config.port) { + throw new Error('Enter the ProPresenter host (IP) and port.'); + } + try { + return await requestJson('/version'); + } catch { + throw new Error( + `Could not reach ProPresenter at ${config.host}:${config.port}. Check the IP and port, and that the API is enabled in ProPresenter ▸ Preferences ▸ Network.`, + ); + } + } + + /** Every playlist ProPresenter knows about, flattened for a picker. */ + async function listPlaylists() { + if (!config.host || !config.port) return []; + const data = await requestJson('/v1/playlists'); + const nodes = Array.isArray(data) + ? data + : Array.isArray(data?.playlists) + ? data.playlists + : Array.isArray(data?.children) + ? data.children + : []; + return flattenPlaylists(nodes, '', []); + } + + /** Remember a manually chosen playlist and force the next poll to refetch its items. */ + function selectPlaylist({ uuid = '', name = '' } = {}) { + config.playlistUuid = String(uuid); + config.playlistName = String(name); + state.playlistUuid = ''; + state.items = []; + state.activeIndex = -1; + restartIfRunning(); + return getConfig(); + } + + return { + configure, + getConfig, + setOnData(cb) { onData = typeof cb === 'function' ? cb : null; }, + start, + stop, + restartIfRunning, + // NOTE: isRunning() is just !stopped — it says the loop was started, NOT that + // ProPresenter is reachable or that data is flowing. Use getViewModel().reachable for health. + isRunning: () => !stopped, + getLastEmitError: () => lastEmitError, + reset, + getViewModel, + testConnection, + listPlaylists, + selectPlaylist, + requestJson, + }; +} + +module.exports = { + DEFAULT_PP_PORT, + DEFAULT_POLL_MS, + DEFAULT_RETRY_MS, + createProPresenterClient, + clearedViewModel, + normalizeConfig, + flattenPlaylists, + parsePlaylistItems, + findPlaylistPath, + countSlides, + cleanItemName, +}; diff --git a/propresenter-core/package.json b/propresenter-core/package.json new file mode 100644 index 0000000..be1048a --- /dev/null +++ b/propresenter-core/package.json @@ -0,0 +1,8 @@ +{ + "name": "propresenter-core", + "version": "1.0.0", + "private": true, + "description": "Shared ProPresenter 7/20 network-API polling client (no dependencies, Node built-ins only)", + "main": "index.js", + "license": "MIT" +} diff --git a/renderer.js b/renderer.js new file mode 100644 index 0000000..634d634 --- /dev/null +++ b/renderer.js @@ -0,0 +1,2639 @@ +if (window.__bandLineupRendererLoaded) { + console.warn('renderer.js already loaded; skipping duplicate initialization'); +} else { + window.__bandLineupRendererLoaded = true; +/** + * @typedef {{ id: string, fileName: string, originalName: string, lastLabel: string, lastName: string, source?: string, pcoPersonId?: string, pcoPlanPersonId?: string, birthday?: string }} LibraryPerson + * @typedef {{ personId: string, label: string }} StageSlot + * @typedef {{ teamName: string, positionName: string, count: number }} PcoDisplayPosition + */ + +const DEFAULT_COLUMNS = 7; +const DEFAULT_ROWS = 2; +const DRAG_THRESHOLD = 14; +const TOUCH_HOLD_MS = 420; +const DOUBLE_TAP_MS = 325; +const DOUBLE_TAP_DISTANCE = 24; +const CHROME_HIDE_MS = 12000; + +/** @type {LibraryPerson[]} */ +let library = []; +let stageColumns = DEFAULT_COLUMNS; +let stageRows = DEFAULT_ROWS; +let parallaxEnabled = true; +let pcoServiceTypeId = ''; +/** @type {PcoDisplayPosition[]} */ +let pcoDisplayPositions = []; +let pcoPositionOptions = []; +let slotLabelMemory = []; +let slotNameMemory = []; +/** Birthday Mode display settings (persisted). */ +let birthdayMode = { enabled: true, intensity: 'subtle' }; +/** Person id forced into Birthday Mode for testing — in-memory only, never saved, clears on restart. */ +let birthdayTestPersonId = ''; +/** @type {StageSlot[]} */ +let stageSlots = createEmptySlots(); +const imageUrlCache = new Map(); +/** @type {null | { enabled: boolean, reachable: boolean, lastError: string, needsPlaylist: boolean, playlistName: string, items: Array<{uuid:string,name:string,type:string,isHidden:boolean,isPco:boolean,slideCount:number|null}>, activeIndex: number, activePresentationName: string, currentSlide: number, currentSlideTotal: number }} */ +let ppData = null; +let saveTimer = null; +let chromeHideTimer = 0; +/** When true, chrome was shown via double-tap edit mode: stay visible until double-tap dismisses (no idle hide). */ +let chromeEditLocked = false; + +/** @type {null | { type: 'library' | 'stage', personId: string, slotIndex: number, startX: number, startY: number, pointerId: number, pointerType: string, el: HTMLElement, active: boolean, cancelled: boolean, holdTimer: number | null }} */ +let pointerDrag = null; + +/** @type {{ type: 'name' | 'slotName' | 'slotLabel', personId: string | null, slotIndex: number }} */ +let labelEdit = { type: 'slotLabel', personId: null, slotIndex: -1 }; +/** @type {HTMLInputElement | null} */ +let numericEditTarget = null; +let numericEditCommit = null; +let numericEditMin = 1; +let numericEditMax = Number.POSITIVE_INFINITY; +let numericEditOnInvalid = null; + +/** @type {{ time: number, x: number, y: number, pointerType: string, source: string } | null} */ +let lastTap = null; +let lastPointerType = 'mouse'; + +const row = document.getElementById('row'); +const empty = document.getElementById('empty'); +const dropZone = document.getElementById('drop-zone'); +const toolbar = document.getElementById('toolbar'); +const libraryList = document.getElementById('library-list'); +const libraryPanel = document.getElementById('library-panel'); +const btnAdd = document.getElementById('btn-add'); +const btnFullscreen = document.getElementById('btn-fullscreen'); +const btnApplyLayout = document.getElementById('btn-apply-layout'); +const btnToggleParallax = document.getElementById('btn-toggle-parallax'); +const btnPcoConnect = document.getElementById('btn-pco-connect'); +const btnPcoSync = document.getElementById('btn-pco-sync'); +const btnPcoTestBirthdays = document.getElementById('btn-pco-test-birthdays'); +const btnPcoDisconnect = document.getElementById('btn-pco-disconnect'); +const pcoBirthdayResult = document.getElementById('pco-birthday-result'); +const bdayEnabled = document.getElementById('bday-enabled'); +const bdayIntensity = document.getElementById('bday-intensity'); +const bdayControls = document.getElementById('bday-controls'); +const bdayTestSelect = document.getElementById('bday-test'); +const btnPcoAddPosition = document.getElementById('btn-pco-add-position'); +const inputLayoutCols = document.getElementById('layout-cols'); +const inputLayoutRows = document.getElementById('layout-rows'); +const slotSizeReadout = document.getElementById('slot-size-readout'); +const pcoServiceTypeSelect = document.getElementById('pco-service-type'); +const pcoPositionOptionSelect = document.getElementById('pco-position-option'); +const pcoPositionList = document.getElementById('pco-position-list'); +const pcoStatus = document.getElementById('pco-status'); +const dragGhost = document.getElementById('drag-ghost'); +const labelModal = document.getElementById('label-modal'); +const labelTitle = document.getElementById('label-modal-title'); +const labelInput = document.getElementById('label-input'); +const labelDone = document.getElementById('label-done'); +const labelCancel = document.getElementById('label-cancel'); +const labelBackdrop = document.getElementById('label-modal-backdrop'); +const numberModal = document.getElementById('number-modal'); +const numberModalTitle = document.getElementById('number-modal-title'); +const numberInput = document.getElementById('number-input'); +const numberDone = document.getElementById('number-done'); +const numberCancel = document.getElementById('number-cancel'); +const numberBackdrop = document.getElementById('number-modal-backdrop'); +const numberKeyboard = document.getElementById('number-keyboard'); +const pcoModal = document.getElementById('pco-modal'); +const pcoModalBackdrop = document.getElementById('pco-modal-backdrop'); +const pcoClientIdInput = document.getElementById('pco-client-id'); +const pcoSecretInput = document.getElementById('pco-secret'); +const pcoSaveButton = document.getElementById('pco-save'); +const pcoCancelButton = document.getElementById('pco-cancel'); +const ppColumn = document.getElementById('pp-column'); +const ppPlaylistName = document.getElementById('pp-playlist-name'); +const ppNow = document.getElementById('pp-now'); +const ppNextLabel = document.getElementById('pp-next-label'); +const ppNextList = document.getElementById('pp-next-list'); +const ppStatus = document.getElementById('pp-status'); +const ppPlaylistSelect = document.getElementById('pp-playlist-select'); +const btnPpConnect = document.getElementById('btn-pp-connect'); +const btnPpDisconnect = document.getElementById('btn-pp-disconnect'); +const ppModal = document.getElementById('pp-modal'); +const ppModalBackdrop = document.getElementById('pp-modal-backdrop'); +const ppHostInput = document.getElementById('pp-host'); +const ppPortInput = document.getElementById('pp-port'); +const ppPasswordInput = document.getElementById('pp-password'); +const ppSaveButton = document.getElementById('pp-save'); +const ppCancelButton = document.getElementById('pp-cancel'); +const libraryTabs = document.getElementById('library-tabs'); +const appVersionEl = document.getElementById('app-version'); +const btnDisplayToggle = document.getElementById('btn-display-toggle'); +const displayStatusEl = document.getElementById('display-status'); +const displayUrlsEl = document.getElementById('display-urls'); +const displayPortInput = document.getElementById('display-port'); + +const VK_ROWS = [ + ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'], + ['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P'], + ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L'], + ['Z', 'X', 'C', 'V', 'B', 'N', 'M'], +]; +const parallaxAssetCache = new Map(); +const parallaxViewStates = new Set(); +let parallaxAnimationFrame = 0; + +function getStageSlotCount() { + return stageColumns * stageRows; +} + +function createEmptySlots(count = getStageSlotCount()) { + return Array.from({ length: count }, (_slot, index) => ({ + personId: '', + label: slotLabelMemory[index] || '', + })); +} + +function ensureSlotLabelMemorySize(count) { + while (slotLabelMemory.length < count) { + slotLabelMemory.push(''); + } +} + +function ensureSlotNameMemorySize(count) { + while (slotNameMemory.length < count) { + slotNameMemory.push(''); + } +} + +/** + * @param {unknown} raw + */ +function normalizeLineup(raw, slotCount = getStageSlotCount()) { + ensureSlotLabelMemorySize(slotCount); + ensureSlotNameMemorySize(slotCount); + const normalized = createEmptySlots(slotCount); + if (!Array.isArray(raw)) return normalized; + + for (let i = 0; i < Math.min(raw.length, slotCount); i += 1) { + const entry = raw[i]; + if (!entry || typeof entry !== 'object') continue; + normalized[i] = { + personId: typeof entry.personId === 'string' ? entry.personId : '', + label: slotLabelMemory[i] || (typeof entry.label === 'string' ? entry.label : ''), + }; + } + + return normalized; +} + +function isModalOpen() { + return ( + !labelModal.classList.contains('hidden') || + !numberModal.classList.contains('hidden') || + !pcoModal.classList.contains('hidden') || + !ppModal.classList.contains('hidden') + ); +} + +function isToolbarActive() { + return toolbar.contains(document.activeElement) || toolbar.matches(':hover'); +} + +function isLibraryActive() { + return libraryPanel.contains(document.activeElement) || libraryPanel.matches(':hover'); +} + +function clearChromeHideTimer() { + if (!chromeHideTimer) return; + clearTimeout(chromeHideTimer); + chromeHideTimer = 0; +} + +function enterChromeEditMode() { + chromeEditLocked = true; + clearChromeHideTimer(); + document.body.classList.remove('chrome-hidden'); +} + +function exitChromeEditMode() { + chromeEditLocked = false; + clearChromeHideTimer(); + document.body.classList.add('chrome-hidden'); +} + +function toggleChromeEditMode() { + if (chromeEditLocked || !document.body.classList.contains('chrome-hidden')) { + exitChromeEditMode(); + return; + } + enterChromeEditMode(); +} + +function scheduleChromeHide() { + clearChromeHideTimer(); +} + +function revealChrome() { + enterChromeEditMode(); +} + +function noteActivity() { + void CHROME_HIDE_MS; +} + +function syncLayoutInputs() { + inputLayoutCols.value = String(stageColumns); + inputLayoutRows.value = String(stageRows); +} + +function syncParallaxToggle() { + btnToggleParallax.textContent = parallaxEnabled ? 'Parallax On' : 'Parallax Off'; + btnToggleParallax.classList.toggle('primary', parallaxEnabled); +} + +function setPcoStatus(message, isError = false) { + pcoStatus.textContent = message; + pcoStatus.style.color = isError ? '#ffb1b1' : '#9ea7bc'; +} + +function setPcoControlsEnabled(enabled) { + btnPcoConnect.disabled = !enabled; + btnPcoSync.disabled = !enabled; + btnPcoTestBirthdays.disabled = !enabled; + btnPcoDisconnect.disabled = !enabled; + btnPcoAddPosition.disabled = !enabled; + pcoServiceTypeSelect.disabled = !enabled; + pcoPositionOptionSelect.disabled = !enabled; +} + +function normalizePcoDisplayPosition(entry) { + const count = Number.parseInt(String(entry?.count ?? 1), 10); + return { + teamName: String(entry?.teamName || '').trim(), + positionName: String(entry?.positionName || '').trim(), + count: Number.isFinite(count) && count > 0 ? count : 1, + }; +} + +function pcoPositionKey(teamName, positionName) { + return `${String(teamName || '').trim().toLowerCase()}::${String(positionName || '').trim().toLowerCase()}`; +} + +function renderPcoServiceTypes(serviceTypes) { + const previousValue = pcoServiceTypeId; + pcoServiceTypeSelect.innerHTML = ''; + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = 'Choose service type'; + pcoServiceTypeSelect.appendChild(placeholder); + for (const serviceType of serviceTypes) { + const option = document.createElement('option'); + option.value = serviceType.id; + option.textContent = serviceType.name; + pcoServiceTypeSelect.appendChild(option); + } + pcoServiceTypeSelect.value = previousValue || ''; +} + +async function refreshPcoServiceTypes() { + const serviceTypes = await window.lineup.pcoServiceTypes(); + renderPcoServiceTypes(Array.isArray(serviceTypes) ? serviceTypes : []); +} + +function renderPcoPositionOptions() { + const previousValue = pcoPositionOptionSelect.value; + pcoPositionOptionSelect.innerHTML = ''; + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = pcoServiceTypeId ? 'Choose position' : 'Choose service type first'; + pcoPositionOptionSelect.appendChild(placeholder); + for (const optionData of pcoPositionOptions) { + const option = document.createElement('option'); + option.value = optionData.key; + option.textContent = `${optionData.teamName} | ${optionData.positionName}`; + pcoPositionOptionSelect.appendChild(option); + } + pcoPositionOptionSelect.value = previousValue || ''; +} + +function movePcoDisplayPosition(index, direction) { + const targetIndex = index + direction; + if (targetIndex < 0 || targetIndex >= pcoDisplayPositions.length) return; + const next = [...pcoDisplayPositions]; + const [entry] = next.splice(index, 1); + next.splice(targetIndex, 0, entry); + pcoDisplayPositions = next; + renderPcoDisplayPositions(); + scheduleSave(); +} + +function updatePcoDisplayPositionCount(index, delta) { + const entry = pcoDisplayPositions[index]; + if (!entry) return; + entry.count = Math.max(1, entry.count + delta); + renderPcoDisplayPositions(); + scheduleSave(); +} + +function removePcoDisplayPosition(index) { + pcoDisplayPositions = pcoDisplayPositions.filter((_entry, entryIndex) => entryIndex !== index); + renderPcoDisplayPositions(); + scheduleSave(); +} + +function renderPcoDisplayPositions() { + pcoPositionList.innerHTML = ''; + if (!pcoDisplayPositions.length) { + const emptyItem = document.createElement('div'); + emptyItem.className = 'pco-position-empty'; + emptyItem.textContent = 'Add the positions you want to display. Increase the count if you need multiple slots for a position like Vocalists.'; + pcoPositionList.appendChild(emptyItem); + return; + } + for (const [index, entry] of pcoDisplayPositions.entries()) { + const item = document.createElement('div'); + item.className = 'pco-position-item'; + + const copy = document.createElement('div'); + copy.className = 'pco-position-copy'; + const name = document.createElement('div'); + name.className = 'pco-position-name'; + name.textContent = entry.positionName; + const team = document.createElement('div'); + team.className = 'pco-position-team'; + team.textContent = entry.teamName; + copy.appendChild(name); + copy.appendChild(team); + + const downCount = document.createElement('button'); + downCount.type = 'button'; + downCount.className = 'btn btn-small'; + downCount.textContent = '-'; + downCount.addEventListener('click', () => updatePcoDisplayPositionCount(index, -1)); + + const countChip = document.createElement('div'); + countChip.className = 'pco-count-chip'; + countChip.textContent = `x${entry.count}`; + + const upCount = document.createElement('button'); + upCount.type = 'button'; + upCount.className = 'btn btn-small'; + upCount.textContent = '+'; + upCount.addEventListener('click', () => updatePcoDisplayPositionCount(index, 1)); + + const up = document.createElement('button'); + up.type = 'button'; + up.className = 'btn btn-small'; + up.textContent = 'Up'; + up.addEventListener('click', () => movePcoDisplayPosition(index, -1)); + + const down = document.createElement('button'); + down.type = 'button'; + down.className = 'btn btn-small'; + down.textContent = 'Down'; + down.addEventListener('click', () => movePcoDisplayPosition(index, 1)); + + const remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'btn btn-small'; + remove.textContent = 'Remove'; + remove.addEventListener('click', () => removePcoDisplayPosition(index)); + + item.appendChild(copy); + item.appendChild(downCount); + item.appendChild(countChip); + item.appendChild(upCount); + item.appendChild(up); + item.appendChild(down); + item.appendChild(remove); + pcoPositionList.appendChild(item); + } +} + +async function refreshPcoPositionOptions() { + if (!pcoServiceTypeId) { + pcoPositionOptions = []; + renderPcoPositionOptions(); + return; + } + const options = await window.lineup.pcoPositionOptions(pcoServiceTypeId); + pcoPositionOptions = Array.isArray(options) ? options : []; + renderPcoPositionOptions(); +} + +function openPcoModal() { + pcoModal.classList.remove('hidden'); + pcoClientIdInput.focus(); + pcoClientIdInput.select(); +} + +function closePcoModal() { + pcoModal.classList.add('hidden'); + pcoSecretInput.value = ''; +} + +function applyLoadedState(data) { + library = Array.isArray(data.library) + ? data.library.map((person) => ({ + id: String(person.id), + fileName: String(person.fileName), + originalName: typeof person.originalName === 'string' ? person.originalName : '', + lastLabel: typeof person.lastLabel === 'string' ? person.lastLabel : '', + lastName: typeof person.lastName === 'string' ? person.lastName : '', + source: person?.source === 'pco' ? 'pco' : 'local', + pcoPersonId: typeof person?.pcoPersonId === 'string' ? person.pcoPersonId : '', + pcoPlanPersonId: typeof person?.pcoPlanPersonId === 'string' ? person.pcoPlanPersonId : '', + // Carried through so a routine save doesn't wipe birthdays a PCO sync stored (main.js + // re-validates the "MM-DD" shape on write); Birthday Mode reads this. + birthday: typeof person?.birthday === 'string' ? person.birthday : '', + })) + : []; + const savedColumns = Number.parseInt(String(data?.layout?.columns ?? DEFAULT_COLUMNS), 10); + const savedRows = Number.parseInt(String(data?.layout?.rows ?? DEFAULT_ROWS), 10); + stageColumns = Number.isFinite(savedColumns) && savedColumns > 0 ? savedColumns : DEFAULT_COLUMNS; + stageRows = Number.isFinite(savedRows) && savedRows > 0 ? savedRows : DEFAULT_ROWS; + parallaxEnabled = typeof data?.parallaxEnabled === 'boolean' ? data.parallaxEnabled : true; + pcoServiceTypeId = typeof data?.pcoServiceTypeId === 'string' ? data.pcoServiceTypeId : ''; + pcoDisplayPositions = Array.isArray(data?.pcoDisplayPositions) + ? data.pcoDisplayPositions.map((entry) => normalizePcoDisplayPosition(entry)).filter((entry) => entry.teamName && entry.positionName) + : []; + slotLabelMemory = Array.isArray(data.slotLabels) + ? data.slotLabels.map((label) => (typeof label === 'string' ? label : '')) + : []; + slotNameMemory = Array.isArray(data.slotNames) + ? data.slotNames.map((label) => (typeof label === 'string' ? label : '')) + : []; + ensureSlotLabelMemorySize(getStageSlotCount()); + ensureSlotNameMemorySize(getStageSlotCount()); + syncLayoutInputs(); + syncParallaxToggle(); + pcoServiceTypeSelect.value = pcoServiceTypeId; + renderPcoDisplayPositions(); + stageSlots = normalizeLineup(data.lineup, getStageSlotCount()); + birthdayMode = { + enabled: data?.birthdayMode?.enabled === undefined ? true : Boolean(data.birthdayMode.enabled), + intensity: data?.birthdayMode?.intensity === 'full' ? 'full' : 'subtle', + }; + syncBirthdayControls(); +} + +function updateSlotSizeReadout() { + // The readout is no longer in the toolbar. Kept null-safe because several render paths + // still call it; the source-resolution numbers move into the Pictures tab. + if (!slotSizeReadout) return; + const firstSlot = row.querySelector('.stage-slot'); + const visual = firstSlot ? firstSlot.querySelector('.slot-visual') : null; + if (!firstSlot || !visual) { + slotSizeReadout.textContent = 'Slot: -- x -- px | Visible image: -- x -- px | Minimum source: -- x -- px | Ideal source: -- x -- px'; + return; + } + + const slotRect = firstSlot.getBoundingClientRect(); + const visualRect = visual.getBoundingClientRect(); + const slotWidth = Math.max(1, Math.round(slotRect.width)); + const slotHeight = Math.max(1, Math.round(slotRect.height)); + const imageWidth = Math.max(1, Math.round(visualRect.width)); + const imageHeight = Math.max(1, Math.round(visualRect.height)); + const minimumWidth = imageWidth * 2; + const minimumHeight = imageHeight * 2; + const idealWidth = imageWidth * 3; + const idealHeight = imageHeight * 3; + + slotSizeReadout.textContent = `Slot: ${slotWidth} x ${slotHeight} px | Visible image: ${imageWidth} x ${imageHeight} px | Minimum source: ${minimumWidth} x ${minimumHeight} px | Ideal source: ${idealWidth} x ${idealHeight} px`; +} + +function resizeStageSlots(nextColumns, nextRows) { + const nextCount = nextColumns * nextRows; + for (let i = 0; i < stageSlots.length; i += 1) { + slotLabelMemory[i] = stageSlots[i].label || ''; + } + ensureSlotLabelMemorySize(nextCount); + ensureSlotNameMemorySize(nextCount); + const nextSlots = createEmptySlots(nextCount); + for (let i = 0; i < Math.min(stageSlots.length, nextCount); i += 1) { + nextSlots[i] = { + personId: stageSlots[i].personId || '', + label: slotLabelMemory[i] || '', + }; + } + stageColumns = nextColumns; + stageRows = nextRows; + stageSlots = nextSlots; +} + +async function applyLayoutChange() { + const nextColumns = Number.parseInt(inputLayoutCols.value, 10); + const nextRows = Number.parseInt(inputLayoutRows.value, 10); + const safeColumns = Number.isFinite(nextColumns) && nextColumns > 0 ? nextColumns : stageColumns; + const safeRows = Number.isFinite(nextRows) && nextRows > 0 ? nextRows : stageRows; + + if (safeColumns === stageColumns && safeRows === stageRows) { + syncLayoutInputs(); + return; + } + + resizeStageSlots(safeColumns, safeRows); + syncLayoutInputs(); + await renderStage(); + await renderLibrary(); + scheduleSave(); +} + +function scheduleSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(async () => { + saveTimer = null; + await window.lineup.saveState({ + library, + layout: { columns: stageColumns, rows: stageRows }, + parallaxEnabled, + pcoServiceTypeId, + pcoDisplayPositions, + slotLabels: slotLabelMemory, + slotNames: slotNameMemory, + lineup: stageSlots, + birthdayMode, + }); + }, 250); +} + +function pointInRect(x, y, rect) { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +function getPerson(personId) { + return library.find((person) => person.id === personId) || null; +} + +/** + * @param {LibraryPerson} person + */ +function getLibraryDisplayName(person) { + if (typeof person.originalName === 'string' && person.originalName.trim()) { + return person.originalName.trim(); + } + if (typeof person.lastName === 'string' && person.lastName.trim()) { + return person.lastName.trim(); + } + return String(person.fileName || '').replace(/\.[^.]+$/, ''); +} + +function getStageName(slotIndex, person) { + if (person && typeof person.lastName === 'string' && person.lastName.trim()) { + return person.lastName.trim(); + } + return slotNameMemory[slotIndex] || ''; +} + +async function getImageHref(fileName) { + if (!fileName) return ''; + if (imageUrlCache.has(fileName)) { + return imageUrlCache.get(fileName) || ''; + } + const href = await window.lineup.imageUrl(fileName); + imageUrlCache.set(fileName, href || ''); + return href || ''; +} + +async function getParallaxAssets(fileName) { + if (!fileName) return null; + if (parallaxAssetCache.has(fileName)) { + return parallaxAssetCache.get(fileName); + } + const result = await window.lineup.parallaxAssets(fileName); + parallaxAssetCache.set(fileName, result); + return result; +} + +function ensureParallaxLoop() { + if (parallaxAnimationFrame) return; + const step = (time) => { + parallaxAnimationFrame = 0; + if (!parallaxViewStates.size) return; + for (const state of [...parallaxViewStates]) { + if (!state.root.isConnected) { + parallaxViewStates.delete(state); + continue; + } + const camX = Math.sin(time / 3200 + state.phase) * state.xRange; + const camY = Math.cos(time / 4100 + state.phase) * state.yRange; + const camRot = Math.sin(time / 3600 + state.phase) * state.rotRange; + state.root.style.setProperty('--cam-x', `${camX}px`); + state.root.style.setProperty('--cam-y', `${camY}px`); + state.root.style.setProperty('--cam-rot', `${camRot}deg`); + } + parallaxAnimationFrame = window.requestAnimationFrame(step); + }; + parallaxAnimationFrame = window.requestAnimationFrame(step); +} + +function setupParallaxViewport(root, seed) { + let hash = 0; + for (let i = 0; i < seed.length; i += 1) { + hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; + } + parallaxViewStates.add({ + root, + phase: (hash % 628) / 100, + xRange: 8 + (hash % 5), + yRange: 5 + ((hash >> 5) % 4), + rotRange: 1.2 + ((hash >> 9) % 8) / 10, + }); + ensureParallaxLoop(); +} + +/** + * @param {PointerEvent | MouseEvent} e + */ +function interactionPointerType(e) { + if ('pointerType' in e && typeof e.pointerType === 'string' && e.pointerType) { + return e.pointerType; + } + return lastPointerType || 'mouse'; +} + +/** + * @param {HTMLElement | null} target + */ +function shouldIgnoreChromeToggleTarget(target) { + if (!target) return false; + return Boolean( + target.closest( + 'button, input, select, textarea, a, .btn, .vk-key, .label-input, .caption, .name-badge, .remove, .lib-delete', + ), + ); +} + +function findStageIndexByPersonId(personId) { + return stageSlots.findIndex((slot) => slot.personId === personId); +} + +function getStageSlotIndexFromPoint(clientX, clientY) { + const el = document.elementFromPoint(clientX, clientY); + const slot = el ? el.closest('.stage-slot') : null; + if (!slot) return null; + const idx = Number(slot.dataset.slotIndex); + return Number.isInteger(idx) ? idx : null; +} + +function removeFromStageIndex(slotIndex) { + if (slotIndex < 0 || slotIndex >= getStageSlotCount()) return; + stageSlots[slotIndex].personId = ''; +} + +/** + * Put a person into a slot. + * @param sourceIndex the slot being dragged FROM for a stage move, or -1 for a library drag. + * + * A library drag places into the target and leaves any other slots holding that person + * ALONE — the same photo can fill several slots (e.g. one player covering two mix positions). + * A stage drag still swaps, using the exact slot dragged from rather than searching by + * person, so a duplicated person moves the tile you actually grabbed. + */ +function placePersonIntoSlot(personId, targetIndex, sourceIndex = -1) { + if (targetIndex < 0 || targetIndex >= getStageSlotCount()) return []; + if (sourceIndex === targetIndex) return []; + + if (sourceIndex >= 0) { + const targetPersonId = stageSlots[targetIndex].personId; + stageSlots[targetIndex].personId = personId; + stageSlots[sourceIndex].personId = targetPersonId || ''; + return [sourceIndex, targetIndex]; + } + stageSlots[targetIndex].personId = personId; + return [targetIndex]; +} + +function showGhostFromImg(imgEl) { + const ghostImage = dragGhost.querySelector('img') || document.createElement('img'); + if (!ghostImage.parentElement) dragGhost.appendChild(ghostImage); + ghostImage.src = imgEl.currentSrc || imgEl.src; + dragGhost.classList.remove('hidden'); +} + +function moveGhost(x, y) { + dragGhost.style.left = `${x - 44}px`; + dragGhost.style.top = `${y - 59}px`; +} + +function hideGhost() { + dragGhost.classList.add('hidden'); +} + +function clearHoldTimer() { + if (pointerDrag && pointerDrag.holdTimer) { + window.clearTimeout(pointerDrag.holdTimer); + pointerDrag.holdTimer = null; + } +} + +function startActiveDrag() { + if (!pointerDrag || pointerDrag.active || pointerDrag.cancelled) return; + pointerDrag.active = true; + pointerDrag.el.classList.add('dragging-source'); + const image = pointerDrag.el.querySelector('img'); + if (image) showGhostFromImg(image); + moveGhost(pointerDrag.startX, pointerDrag.startY); + try { + pointerDrag.el.setPointerCapture(pointerDrag.pointerId); + } catch { + /* ignore */ + } +} + +function startDragSession(el, state) { + pointerDrag = { + ...state, + active: false, + cancelled: false, + holdTimer: null, + }; + + if (state.pointerType === 'touch') { + pointerDrag.holdTimer = window.setTimeout(() => { + startActiveDrag(); + }, TOUCH_HOLD_MS); + } +} + +/** + * @param {PointerEvent | MouseEvent} e + * @param {string} source + */ +function maybeToggleChromeOnDoubleActivate(e, source) { + const pointerType = interactionPointerType(e); + if (!pointerType) return; + const now = Date.now(); + if ( + lastTap && + lastTap.pointerType === pointerType && + lastTap.source === source && + now - lastTap.time <= DOUBLE_TAP_MS && + Math.hypot(e.clientX - lastTap.x, e.clientY - lastTap.y) <= DOUBLE_TAP_DISTANCE + ) { + lastTap = null; + toggleChromeEditMode(); + return; + } + + lastTap = { + time: now, + x: e.clientX, + y: e.clientY, + pointerType, + source, + }; +} + +/** + * @param {MouseEvent} e + * @param {string} source + */ +function handleChromeToggleSurfaceClick(e, source) { + if (isModalOpen()) return; + const target = e.target instanceof HTMLElement ? e.target : null; + if (shouldIgnoreChromeToggleTarget(target)) return; + noteActivity(); + maybeToggleChromeOnDoubleActivate(e, source); +} + +async function handlePointerDrop(clientX, clientY, ctx) { + const targetIndex = getStageSlotIndexFromPoint(clientX, clientY); + const libraryRect = libraryPanel.getBoundingClientRect(); + // The whole panel counts as "drop here to remove from the stage" — except the tab strip, + // where a drop would both remove the person and switch tabs. Measured against the panel + // rather than the photo grid on purpose: a hidden grid reports an all-zero rect, which + // would silently disable drag-to-remove whenever another tab is open. + const tabsRect = libraryTabs ? libraryTabs.getBoundingClientRect() : null; + const onTabs = tabsRect ? pointInRect(clientX, clientY, tabsRect) : false; + const onLibrary = !onTabs && pointInRect(clientX, clientY, libraryRect); + + if (targetIndex !== null) { + // A stage drag carries its own slot index; a library drag is -1, which lets the same + // photo land in more than one slot instead of being moved out of its current one. + const sourceIndex = ctx.type === 'stage' ? ctx.slotIndex : -1; + const changedSlots = placePersonIntoSlot(ctx.personId, targetIndex, sourceIndex); + if (!changedSlots.length) return; + await renderStage(changedSlots); + if (ctx.type === 'library') { + await renderLibrary(); + } + scheduleSave(); + return; + } + + if (ctx.type === 'stage' && onLibrary) { + removeFromStageIndex(ctx.slotIndex); + await renderStage([ctx.slotIndex]); + await renderLibrary(); + scheduleSave(); + } +} + +function onPointerDown(e) { + lastPointerType = e.pointerType || lastPointerType; + if (isModalOpen()) return; + if (e.pointerType === 'mouse' && e.button !== 0) return; + + const target = /** @type {HTMLElement} */ (e.target); + if ( + target.closest('.lib-delete') || + target.closest('.remove') || + target.closest('.caption') || + target.closest('.name-badge') || + target.closest('.btn') || + target.closest('.lib-tab') || + target.closest('.vk-key') || + target.closest('.label-input') + ) { + noteActivity(); + return; + } + + const libItem = target.closest('.lib-item'); + const photoArea = target.closest('.photo-area'); + + if (libItem) { + const personId = libItem.dataset.personId; + if (!personId) return; + startDragSession(libItem, { + type: 'library', + personId, + slotIndex: -1, + startX: e.clientX, + startY: e.clientY, + pointerId: e.pointerId, + pointerType: e.pointerType, + el: libItem, + }); + return; + } + + if (photoArea) { + const tile = photoArea.closest('.tile'); + if (!tile) return; + const personId = tile.dataset.personId; + const slotIndex = Number(tile.dataset.slotIndex); + if (!personId || !Number.isInteger(slotIndex)) return; + startDragSession(tile, { + type: 'stage', + personId, + slotIndex, + startX: e.clientX, + startY: e.clientY, + pointerId: e.pointerId, + pointerType: e.pointerType, + el: tile, + }); + } +} + +function onPointerMove(e) { + if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return; + noteActivity(); + const dx = e.clientX - pointerDrag.startX; + const dy = e.clientY - pointerDrag.startY; + const distance = Math.hypot(dx, dy); + + if (!pointerDrag.active) { + if (pointerDrag.pointerType === 'touch') { + if (distance > DRAG_THRESHOLD) { + if (pointerDrag.holdTimer) { + clearHoldTimer(); + pointerDrag.cancelled = true; + } + } + return; + } + + if (distance < DRAG_THRESHOLD) return; + startActiveDrag(); + } + + moveGhost(e.clientX, e.clientY); +} + +function onPointerUp(e) { + if (!pointerDrag || e.pointerId !== pointerDrag.pointerId) return; + + const currentDrag = pointerDrag; + const wasActive = currentDrag.active; + clearHoldTimer(); + + try { + currentDrag.el.releasePointerCapture(e.pointerId); + } catch { + /* ignore */ + } + + currentDrag.el.classList.remove('dragging-source'); + pointerDrag = null; + hideGhost(); + noteActivity(); + + if (wasActive) { + void handlePointerDrop(e.clientX, e.clientY, { + type: currentDrag.type, + personId: currentDrag.personId, + slotIndex: currentDrag.slotIndex, + }); + return; + } + + if (currentDrag.cancelled) return; +} + +document.addEventListener('pointerdown', onPointerDown, true); +document.addEventListener('pointermove', onPointerMove, true); +document.addEventListener('pointerup', onPointerUp, true); +document.addEventListener('pointercancel', onPointerUp, true); + +function updateEmpty() { + empty.classList.toggle('visible', stageSlots.every((slot) => !slot.personId)); +} + +function parallaxMotionVars(seedText) { + let hash = 0; + for (let i = 0; i < seedText.length; i += 1) { + hash = (hash * 31 + seedText.charCodeAt(i)) >>> 0; + } + const directionX = hash % 2 === 0 ? 1 : -1; + const directionY = (hash >> 1) % 2 === 0 ? 1 : -1; + const bgX = 28 + (hash % 18); + const bgY = 18 + ((hash >> 3) % 12); + const midX = 0; + const midY = 0; + const fgX = 14 + ((hash >> 11) % 10); + const fgY = 9 + ((hash >> 14) % 7); + return { + '--p-bg-x': `${directionX * bgX}px`, + '--p-bg-y': `${directionY * bgY}px`, + '--p-mid-x': `${-directionX * midX}px`, + '--p-mid-y': `${directionY * midY}px`, + '--p-fg-x': `${directionX * fgX}px`, + '--p-fg-y': `${-directionY * fgY}px`, + }; +} + +/* ---------------- Birthday Mode ---------------- */ + +const BIRTHDAY_COLORS = ['#f5c451', '#ff6b81', '#4fd1c5', '#a78bfa', '#ffffff']; +/** Live confetti canvases; a single rAF loop drives them and prunes any whose tile was rebuilt. */ +const birthdayCanvases = new Set(); +let birthdayRaf = null; + +/** + * The 7 "MM-DD" dates of the current celebration week, which runs Wednesday→Tuesday. A birthday + * anywhere in this window celebrates all week (so it's already up for the weekend services). + * Cached per week; birthdayTick() re-renders the stage when the window rolls over to a new Wednesday. + */ +let birthdayWeekCache = { key: '', set: null }; +function birthdayWeekSet() { + const now = new Date(); + const daysSinceWed = (now.getDay() - 3 + 7) % 7; // getDay: Sun=0…Wed=3…Sat=6 + const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed); + const key = `${start.getFullYear()}-${start.getMonth()}-${start.getDate()}`; + if (birthdayWeekCache.key === key && birthdayWeekCache.set) return birthdayWeekCache.set; + const set = new Set(); + for (let i = 0; i < 7; i += 1) { + const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i); + set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`); + } + birthdayWeekCache = { key, set }; + return set; +} + +/** Is this person celebrating this week — their birthday falls in the current Wed→Tue window, or they're the test override. */ +function isBirthdayActive(person) { + if (!birthdayMode.enabled || !person) return false; + if (birthdayTestPersonId && person.id === birthdayTestPersonId) return true; + return typeof person.birthday === 'string' && person.birthday.length === 5 && birthdayWeekSet().has(person.birthday); +} + +/** Re-render the stage when the celebration week rolls over (the app can stay open for days). */ +function birthdayTick() { + const before = birthdayWeekCache.key; + birthdayWeekSet(); + if (before && birthdayWeekCache.key !== before) void renderStage(); +} +setInterval(birthdayTick, 15 * 60 * 1000); + +function birthdayMakeParticle(w, h, intensity, seedY) { + const ribbon = Math.random() < 0.55; + return { + x: Math.random() * w, + y: seedY != null ? seedY : Math.random() * h, + w: ribbon ? 4 + Math.random() * 4 : 5 + Math.random() * 5, + h: ribbon ? 9 + Math.random() * 8 : 0, + ribbon, + color: BIRTHDAY_COLORS[(Math.random() * BIRTHDAY_COLORS.length) | 0], + rot: Math.random() * Math.PI * 2, + vr: -0.14 + Math.random() * 0.28, + sway: 0.6 + Math.random() * 1.1, + phase: Math.random() * Math.PI * 2, + vy: (intensity === 'subtle' ? 0.6 : 1.0) * (1.1 + Math.random() * 1.5), + }; +} + +function birthdaySeedCanvas(c, w, h) { + const base = c._intensity === 'subtle' ? 26 : 64; + const count = Math.max(10, Math.round((base * (w * h)) / (300 * 500))); + c._parts = Array.from({ length: count }, () => birthdayMakeParticle(w, h, c._intensity)); +} + +// The confetti ALWAYS animates — this is a celebration effect, so it intentionally ignores the +// OS "reduced motion" setting (which was freezing the confetti mid-fall on the backstage machine). +function birthdayStepCanvas(c) { + const w = c.clientWidth; + const h = c.clientHeight; + if (!w || !h) return; // not laid out yet — try again next frame + const dpr = Math.min(window.devicePixelRatio || 1, 2); + if (c._w !== w || c._h !== h) { + c.width = w * dpr; + c.height = h * dpr; + c._ctx = c.getContext('2d'); + c._ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + c._w = w; + c._h = h; + birthdaySeedCanvas(c, w, h); + } + const ctx = c._ctx; + ctx.clearRect(0, 0, w, h); + for (const p of c._parts) { + p.phase += 0.03; + p.y += p.vy; + p.x += Math.sin(p.phase) * p.sway; + p.rot += p.vr; + if (p.y > h + 16) Object.assign(p, birthdayMakeParticle(w, h, c._intensity, -16)); + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.rot); + ctx.fillStyle = p.color; + if (p.ribbon) ctx.fillRect(-p.w / 2, -p.h / 2, p.w, p.h); + else { ctx.beginPath(); ctx.arc(0, 0, p.w / 2, 0, Math.PI * 2); ctx.fill(); } + ctx.restore(); + } +} + +function birthdayLoop() { + for (const c of [...birthdayCanvases]) { + if (!c.isConnected) { birthdayCanvases.delete(c); continue; } + birthdayStepCanvas(c); + } + birthdayRaf = birthdayCanvases.size ? window.requestAnimationFrame(birthdayLoop) : null; +} + +function birthdayRegisterCanvas(canvas, intensity) { + canvas._intensity = intensity; + birthdayCanvases.add(canvas); + if (!birthdayRaf) birthdayRaf = window.requestAnimationFrame(birthdayLoop); +} + +/** Rain confetti (plus a soft gold glow) onto a tile whose person is celebrating. */ +function applyBirthdayEffects(tile, photoArea) { + const intensity = birthdayMode.intensity === 'full' ? 'full' : 'subtle'; + tile.classList.add('birthday', `birthday-${intensity}`); + + const confetti = document.createElement('canvas'); + confetti.className = 'birthday-confetti'; + confetti.setAttribute('aria-hidden', 'true'); + photoArea.appendChild(confetti); + birthdayRegisterCanvas(confetti, intensity); +} + +function createPhotoTile(person, href, slotIndex, parallaxAssets) { + void parallaxAssets; + const tile = document.createElement('article'); + tile.className = 'tile'; + tile.dataset.personId = person.id; + tile.dataset.slotIndex = String(slotIndex); + + const photoArea = document.createElement('div'); + photoArea.className = 'photo-area'; + + if (!parallaxEnabled) { + const staticImage = document.createElement('img'); + staticImage.alt = ''; + staticImage.src = href; + staticImage.className = 'static-stage-image'; + photoArea.appendChild(staticImage); + } else { + const parallaxViewport = document.createElement('div'); + parallaxViewport.className = 'parallax-viewport'; + const movingImage = document.createElement('img'); + movingImage.alt = ''; + movingImage.src = href; + movingImage.className = 'moving-stage-image'; + parallaxViewport.appendChild(movingImage); + + photoArea.appendChild(parallaxViewport); + setupParallaxViewport(parallaxViewport, `${person.id}:${slotIndex}`); + } + + const nameBadge = document.createElement('div'); + nameBadge.className = 'name-badge'; + nameBadge.textContent = getStageName(slotIndex, person); + nameBadge.addEventListener('click', (e) => { + e.stopPropagation(); + openLabelModal({ type: 'name', personId: person.id, slotIndex }); + }); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'remove'; + removeBtn.setAttribute('aria-label', 'Remove from stage'); + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + removeFromStageIndex(slotIndex); + void renderStage([slotIndex]); + void renderLibrary(); + scheduleSave(); + }); + + photoArea.appendChild(nameBadge); + photoArea.appendChild(removeBtn); + if (isBirthdayActive(person)) applyBirthdayEffects(tile, photoArea); + tile.appendChild(photoArea); + return tile; +} + +function createStageSlot(slotIndex, slot, href, parallaxAssets) { + const slotEl = document.createElement('div'); + const hasPerson = Boolean(slot.personId && href); + slotEl.className = 'stage-slot' + (hasPerson ? '' : ' empty-slot'); + slotEl.dataset.slotIndex = String(slotIndex); + slotEl.dataset.slotLabel = `Slot ${slotIndex + 1}`; + + const visual = document.createElement('div'); + visual.className = 'slot-visual'; + + if (hasPerson) { + const person = getPerson(slot.personId); + if (person) { + visual.appendChild(createPhotoTile(person, href, slotIndex, parallaxAssets)); + } + } else { + const nameHotspot = document.createElement('button'); + nameHotspot.type = 'button'; + nameHotspot.className = 'empty-name-hotspot'; + nameHotspot.setAttribute('aria-label', 'Add stage name'); + nameHotspot.title = 'Tap top area to add a name'; + nameHotspot.textContent = slotNameMemory[slotIndex] || ''; + nameHotspot.addEventListener('click', (e) => { + e.stopPropagation(); + openLabelModal({ type: 'slotName', personId: null, slotIndex }); + }); + + const placeholder = document.createElement('div'); + placeholder.className = 'slot-placeholder'; + placeholder.textContent = ''; + visual.appendChild(nameHotspot); + visual.appendChild(placeholder); + } + + const caption = document.createElement('div'); + caption.className = 'caption'; + caption.textContent = slot.label || ''; + caption.addEventListener('click', (e) => { + e.stopPropagation(); + openLabelModal({ type: 'slotLabel', personId: null, slotIndex }); + }); + + slotEl.appendChild(visual); + slotEl.appendChild(caption); + return slotEl; +} + +function createLibraryItem(person, href, onStage) { + const item = document.createElement('div'); + item.className = 'lib-item' + (onStage ? ' on-lineup' : ''); + item.dataset.personId = person.id; + + const deleteBtn = document.createElement('button'); + deleteBtn.type = 'button'; + deleteBtn.className = 'lib-delete'; + deleteBtn.setAttribute('aria-label', 'Delete photo from library'); + deleteBtn.textContent = '×'; + deleteBtn.addEventListener('pointerdown', (e) => e.stopPropagation()); + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + void deleteLibraryPerson(person.id); + }); + + const image = document.createElement('img'); + image.alt = getLibraryDisplayName(person); + image.src = href; + + const nameOverlay = document.createElement('div'); + nameOverlay.className = 'lib-name-overlay'; + nameOverlay.textContent = getLibraryDisplayName(person); + + item.appendChild(deleteBtn); + item.appendChild(image); + item.appendChild(nameOverlay); + return item; +} + +async function deleteLibraryPerson(personId) { + const person = getPerson(personId); + if (!person) return; + const ok = window.confirm('Delete this photo from the library? It will be removed from disk and cannot be undone.'); + if (!ok) return; + + stageSlots = stageSlots.map((slot) => (slot.personId === personId ? { ...slot, personId: '' } : slot)); + library = library.filter((item) => item.id !== personId); + + await window.lineup.deleteLibraryImage(person.fileName); + imageUrlCache.delete(person.fileName); + await renderStage(); + await renderLibrary(); + scheduleSave(); +} + +async function renderStage(slotIndexes) { + row.style.gridTemplateColumns = `repeat(${stageColumns}, minmax(0, 1fr))`; + row.style.gridTemplateRows = `repeat(${stageRows}, minmax(0, 1fr))`; + const slotCount = getStageSlotCount(); + const indexes = + Array.isArray(slotIndexes) && slotIndexes.length + ? [...new Set(slotIndexes.filter((idx) => idx >= 0 && idx < slotCount))] + : Array.from({ length: slotCount }, (_v, idx) => idx); + + if (row.children.length !== slotCount || !Array.isArray(slotIndexes)) { + const fragment = document.createDocumentFragment(); + for (let i = 0; i < slotCount; i += 1) { + const slot = stageSlots[i]; + const person = slot.personId ? getPerson(slot.personId) : null; + const href = person ? await getImageHref(person.fileName) : ''; + fragment.appendChild(createStageSlot(i, slot, href, null)); + } + row.replaceChildren(fragment); + updateEmpty(); + window.requestAnimationFrame(updateSlotSizeReadout); + return; + } + + for (const i of indexes) { + const slot = stageSlots[i]; + const person = slot.personId ? getPerson(slot.personId) : null; + const href = person ? await getImageHref(person.fileName) : ''; + const nextSlot = createStageSlot(i, slot, href, null); + const existingSlot = row.children[i]; + if (existingSlot) { + existingSlot.replaceWith(nextSlot); + } else { + row.appendChild(nextSlot); + } + } + updateEmpty(); + window.requestAnimationFrame(updateSlotSizeReadout); +} + +async function renderLibrary() { + // The grid is rebuilt after every drop. Now that it is full-height and genuinely + // scrollable, losing the scroll position would throw the operator back to the top on + // every single drag onto the stage. + const previousScroll = libraryList.scrollTop; + libraryList.innerHTML = ''; + const onStage = new Set(stageSlots.filter((slot) => slot.personId).map((slot) => slot.personId)); + for (const person of library) { + const href = await getImageHref(person.fileName); + if (!href) continue; + libraryList.appendChild(createLibraryItem(person, href, onStage.has(person.id))); + } + libraryList.scrollTop = previousScroll; +} + +async function renderAll() { + await renderStage(); + await renderLibrary(); + renderBirthdayTestOptions(); +} + +async function importNewPeople(added) { + if (!added.length) return; + for (const person of added) { + library.push({ + id: person.id, + fileName: person.fileName, + originalName: typeof person.originalName === 'string' ? person.originalName : '', + lastLabel: '', + lastName: '', + source: 'local', + pcoPersonId: '', + pcoPlanPersonId: '', + birthday: '', + }); + } + await renderLibrary(); + scheduleSave(); +} + +async function connectPlanningCenter() { + const clientId = pcoClientIdInput.value.trim(); + const secret = pcoSecretInput.value.trim(); + if (!clientId || !secret) { + setPcoStatus('Enter both the Client ID and Secret.', true); + return; + } + setPcoControlsEnabled(false); + setPcoStatus('Connecting to Planning Center...'); + try { + const result = await window.lineup.pcoConnect({ clientId, secret }); + renderPcoServiceTypes(Array.isArray(result?.serviceTypes) ? result.serviceTypes : []); + pcoServiceTypeSelect.value = pcoServiceTypeId || ''; + await refreshPcoPositionOptions(); + closePcoModal(); + setPcoStatus('Planning Center connected.'); + } catch (error) { + setPcoStatus(error instanceof Error ? error.message : 'Could not connect to Planning Center.', true); + } finally { + setPcoControlsEnabled(true); + } +} + +async function syncUpcomingPlanFromPco() { + if (!pcoServiceTypeId) { + setPcoStatus('Choose a service type before syncing.', true); + return; + } + setPcoControlsEnabled(false); + setPcoStatus('Syncing the upcoming plan from Planning Center...'); + try { + const result = await window.lineup.pcoSyncUpcomingPlan({ + serviceTypeId: pcoServiceTypeId, + library, + layout: { columns: stageColumns, rows: stageRows }, + parallaxEnabled, + slotLabels: slotLabelMemory, + slotNames: slotNameMemory, + lineup: stageSlots, + pcoDisplayPositions, + }); + applyLoadedState(result.state); + await renderAll(); + const planLabel = [result?.plan?.title, result?.plan?.shortDates].filter(Boolean).join(' | '); + setPcoStatus( + planLabel + ? `Synced ${result?.plan?.displayedCount || 0} labeled boxes from ${planLabel}.` + : `Synced ${result?.plan?.displayedCount || 0} labeled boxes.`, + ); + } catch (error) { + setPcoStatus(error instanceof Error ? error.message : 'Planning Center sync failed.', true); + } finally { + setPcoControlsEnabled(true); + } +} + +const BIRTHDAY_MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; +function formatBirthdayMmdd(mmdd) { + const parts = String(mmdd || '').split('-'); + const mm = Number(parts[0]); + const dd = Number(parts[1]); + if (!(mm >= 1 && mm <= 12) || !(dd >= 1 && dd <= 31)) return ''; + return `${BIRTHDAY_MONTHS[mm - 1]} ${dd}`; +} + +function setPcoBirthdayResult(message, tone) { + if (!pcoBirthdayResult) return; + pcoBirthdayResult.textContent = message || ''; + pcoBirthdayResult.style.display = message ? '' : 'none'; + pcoBirthdayResult.style.color = tone === 'error' ? '#ffb1b1' : tone === 'good' ? '#8fe0b0' : '#9ea7bc'; +} + +async function testPcoBirthdays() { + setPcoControlsEnabled(false); + setPcoBirthdayResult('Checking Planning Center for birthday access…', 'info'); + try { + const r = await window.lineup.pcoTestBirthdays(); + if (!r || r.connected === false) { + setPcoBirthdayResult('Connect Planning Center first, then test birthday access.', 'error'); + return; + } + if (!r.canRead) { + const why = r.reason === 'network' + ? `Couldn't reach Planning Center (${r.detail || 'network error'}).` + : `This token can't read People birthdays${r.status ? ` (HTTP ${r.status})` : ''}${r.detail ? ` — ${r.detail}` : ''}.`; + setPcoBirthdayResult(`✕ ${why} Birthday Mode needs People access on the token.`, 'error'); + return; + } + // Access works. Report coverage across synced band members. + if (r.total === 0) { + setPcoBirthdayResult('✓ People access works. Sync an upcoming plan, then test again to see how many musicians have a birthday on file.', 'good'); + return; + } + const bits = [`✓ People access works — ${r.withBirthday} of ${r.total} synced ${r.total === 1 ? 'person has' : 'people have'} a birthday on file.`]; + if (r.nextUp) { + const when = r.nextUp.inDays === 0 ? 'today 🎂' : r.nextUp.inDays === 1 ? 'tomorrow' : `in ${r.nextUp.inDays} days`; + bits.push(`Next up: ${r.nextUp.name} (${formatBirthdayMmdd(r.nextUp.mmdd)}, ${when}).`); + } + if (r.notFound) bits.push(`${r.notFound} not matched in People.`); + setPcoBirthdayResult(bits.join(' '), r.withBirthday > 0 ? 'good' : 'info'); + } catch (error) { + setPcoBirthdayResult(error instanceof Error ? error.message : 'Birthday test failed.', 'error'); + } finally { + setPcoControlsEnabled(true); + } +} + +/* ---------------- Birthday Mode controls ---------------- */ + +/** Reflect current birthdayMode settings onto the controls (called after load). */ +function syncBirthdayControls() { + if (bdayEnabled) bdayEnabled.checked = birthdayMode.enabled; + if (bdayIntensity) { + for (const b of bdayIntensity.querySelectorAll('button')) { + b.setAttribute('aria-pressed', b.dataset.int === birthdayMode.intensity ? 'true' : 'false'); + } + } + if (bdayControls) bdayControls.style.opacity = birthdayMode.enabled ? '' : '0.45'; +} + +/** Rebuild the "Force on (test)" dropdown from whoever is currently on the stage. */ +function renderBirthdayTestOptions() { + if (!bdayTestSelect) return; + const previous = birthdayTestPersonId; + bdayTestSelect.innerHTML = ''; + const seen = new Set(); + stageSlots.forEach((slot, index) => { + if (!slot.personId || seen.has(slot.personId)) return; + const person = getPerson(slot.personId); + if (!person) return; + seen.add(slot.personId); + const option = document.createElement('option'); + option.value = person.id; + const name = String(getStageName(index, person) || person.lastName || 'Unknown').trim(); + const label = String(slotLabelMemory[index] || '').trim(); + option.textContent = label ? `${name} — ${label}` : name; + bdayTestSelect.appendChild(option); + }); + // Keep the current override selected if that person is still on stage; otherwise drop it. + if (previous && seen.has(previous)) { + bdayTestSelect.value = previous; + } else if (previous) { + birthdayTestPersonId = ''; + bdayTestSelect.value = ''; + } +} + +/* ---------------- Setup panel tabs ---------------- */ + +/** Set while a tab switch blurs the port field, so the blur handler doesn't commit. */ +let suppressPortCommit = false; + +function activatePanelTab(name) { + if (!libraryTabs) return; + // Leaving the ProPresenter tab must not commit a port the operator was still thinking + // about: hiding the pane blurs the field, and that blur would otherwise restart the LAN + // display server and drop every bookmarked screen in the building. + if (displayPortInput && document.activeElement === displayPortInput) { + suppressPortCommit = true; + displayPortInput.blur(); + suppressPortCommit = false; + } + for (const tab of libraryTabs.querySelectorAll('.lib-tab')) { + const isOn = tab.dataset.tab === name; + tab.classList.toggle('is-active', isOn); + tab.setAttribute('aria-selected', isOn ? 'true' : 'false'); + } + for (const pane of libraryPanel.querySelectorAll('.library-pane')) { + pane.classList.toggle('is-active', pane.dataset.pane === name); + } + // The readout measures a stage slot, so it can only be filled in once its pane is laid out. + if (name === 'pictures') window.requestAnimationFrame(updateSlotSizeReadout); + if (name === 'pco') renderBirthdayTestOptions(); +} + +/* ---------------- Studio displays (hosting the Now/Next page on the LAN) ---------------- */ + +let displayState = null; +/** When hosting was switched on, so the firewall warning waits for a grace period. */ +let displayOnSince = 0; +let displayRefreshTimer = null; +const DISPLAY_FIREWALL_GRACE_MS = 60000; + +function setDisplayStatus(message, isError = false) { + if (!displayStatusEl) return; + displayStatusEl.textContent = message; + displayStatusEl.style.color = isError ? '#ffb1b1' : '#9ea7bc'; +} + +/** Signature of the rendered rows, so an unchanged list is left alone. */ +let displayUrlsSignature = null; + +function renderDisplayUrls() { + if (!displayUrlsEl) return; + const addresses = displayState?.listening ? displayState.addresses || [] : []; + // The status poll runs every 4s. Rebuilding identical rows each time would wipe a + // "Copied" confirmation out from under the operator mid-read, and drop the row they are + // reaching for. Only touch the DOM when the list actually changed. + const signature = JSON.stringify(addresses.map((a) => [a.url, a.confirmed, a.detail])); + if (signature === displayUrlsSignature) return; + displayUrlsSignature = signature; + displayUrlsEl.innerHTML = ''; + addresses.forEach((entry, index) => { + const row = document.createElement('div'); + row.className = index === 0 ? 'display-url-row is-best' : 'display-url-row'; + + const main = document.createElement('div'); + main.className = 'display-url-main'; + const text = document.createElement('div'); + text.className = 'display-url-text'; + text.textContent = entry.url; + main.appendChild(text); + const note = document.createElement('div'); + note.className = 'display-url-note'; + note.textContent = entry.detail || ''; + main.appendChild(note); + row.appendChild(main); + + const copy = document.createElement('button'); + copy.type = 'button'; + copy.className = 'btn btn-small'; + copy.textContent = 'Copy'; + copy.addEventListener('click', async () => { + revealChrome(); + try { + await window.lineup.display.copyUrl(entry.url); + copy.textContent = 'Copied'; + window.setTimeout(() => { copy.textContent = 'Copy'; }, 1400); + } catch { + copy.textContent = 'Failed'; + } + }); + row.appendChild(copy); + displayUrlsEl.appendChild(row); + }); +} + +function renderDisplay() { + if (!btnDisplayToggle) return; + const state = displayState; + btnDisplayToggle.textContent = state?.enabled ? 'Turn Off' : 'Turn On'; + if (displayPortInput && document.activeElement !== displayPortInput) { + displayPortInput.value = state?.port ? String(state.port) : ''; + } + + if (!state?.enabled) { + setDisplayStatus('Off \u2014 screens around the building can\u2019t see this yet.'); + } else if (state.lastError) { + setDisplayStatus(state.lastError, true); + } else if (!state.listening) { + setDisplayStatus('Starting\u2026'); + } else if (state.mdnsConflict) { + setDisplayStatus( + `Another computer is already publishing that name (${state.mdnsConflict}). Use one of the numeric addresses below.`, + true, + ); + } else if (state.viewers > 0) { + // Report devices rather than connections: one machine can briefly hold two streams + // while a reloaded tab's old connection is still being reaped, and "4 screens watching" + // when two machines are open is just wrong. + const screens = state.devices || state.viewers; + const extra = state.viewers > screens ? ` (${state.viewers} connections)` : ''; + setDisplayStatus(`On \u2014 ${screens} screen${screens === 1 ? '' : 's'} watching${extra}.`); + } else if (state.everHadViewer) { + setDisplayStatus('On \u2014 no screens open right now.'); + } else if (displayOnSince && Date.now() - displayOnSince > DISPLAY_FIREWALL_GRACE_MS) { + // On Windows the firewall prompt fires when the server starts listening, not when a + // studio first connects — so localhost looks fine here while every other screen times + // out. A viewer count stuck at zero is the only hint we get; say it plainly. + setDisplayStatus( + `On, but no screen has connected yet. If a studio can\u2019t load the page, Windows Firewall on this computer is probably blocking Band Lineup \u2014 it needs to allow TCP port ${state.port} for Private networks.`, + true, + ); + } else { + setDisplayStatus('On \u2014 open one of these on any screen in the building.'); + } + + renderDisplayUrls(); +} + +async function refreshDisplayStatus() { + try { + displayState = await window.lineup.display.status(); + } catch { + return; + } + renderDisplay(); +} + +function scheduleDisplayRefresh() { + if (displayRefreshTimer) return; + displayRefreshTimer = window.setInterval(() => { + // Only while the edit chrome is up: show mode must stay completely idle. + if (document.body.classList.contains('chrome-hidden')) return; + void refreshDisplayStatus(); + }, 4000); +} + +async function toggleDisplayHosting() { + const next = !displayState?.enabled; + btnDisplayToggle.disabled = true; + setDisplayStatus(next ? 'Starting\u2026' : 'Stopping\u2026'); + try { + displayState = await window.lineup.display.setEnabled(next); + displayOnSince = next ? Date.now() : 0; + } catch (error) { + setDisplayStatus(error instanceof Error ? error.message : 'Could not change the display server.', true); + } finally { + btnDisplayToggle.disabled = false; + } + renderDisplay(); +} + +async function applyDisplayPort() { + if (!displayPortInput) return; + const value = displayPortInput.value.trim(); + if (!value || (displayState && String(displayState.port) === value)) return; + try { + displayState = await window.lineup.display.setPort(value); + renderDisplay(); + } catch (error) { + setDisplayStatus(error instanceof Error ? error.message : 'Could not change the port.', true); + } +} + +/* ---------------- ProPresenter Now / Next column ---------------- */ + +function setPpStatus(message, isError = false) { + if (!ppStatus) return; + ppStatus.textContent = message; + ppStatus.style.color = isError ? '#ffb1b1' : '#9ea7bc'; +} + +function ppItemTypeTag(type) { + switch (type) { + case 'media': + return 'Media'; + case 'audio': + return 'Audio'; + case 'livevideo': + return 'Live'; + case 'placeholder': + return 'PCO'; + default: + return ''; + } +} + +/** Remove any NEXT rows that fall below the visible bottom of the list, so it fills with as many whole items as fit. */ +function ppTrimOverflow() { + if (!ppNextList) return; + const listRect = ppNextList.getBoundingClientRect(); + if (listRect.height <= 0) return; + for (const child of Array.from(ppNextList.children)) { + if (child.getBoundingClientRect().bottom > listRect.bottom + 1) { + child.remove(); + } + } +} + +function updatePpActiveClass() { + // Show the column the whole time ProPresenter is connected (enabled), even when idle — + // the NOW area then shows a "Waiting…"/"Reconnecting…" card. Hidden only after Disconnect. + document.body.classList.toggle('pp-active', Boolean(ppData?.enabled)); +} + +/** + * Scale the NOW title to fill its box: short names stay large, long names shrink to fit. + * Picks the biggest font size (binary search) at which the text fits within `maxLines`, + * so titles like "I Know A Name - [ Full ]" and "This Is The Day - [ Full ]" each look right. + * Falls back to the smallest size and just wraps if a name is too long even at the minimum. + */ +function fitNowTitle(el, { min = 17, max = 40, maxLines = 2 } = {}) { + const lineHeight = 1.1; // keep in sync with .pp-now-name line-height in styles.css + // Wrap only at spaces; never split a word (the "Promis / ed Land" bug). An over-wide word + // then overflows horizontally, which the width check below rejects, forcing a smaller size. + el.style.whiteSpace = 'normal'; + el.style.overflowWrap = 'normal'; + el.style.wordBreak = 'normal'; + el.style.hyphens = 'none'; + const fits = (size) => { + el.style.fontSize = `${size}px`; + const allowedHeight = Math.ceil(size * lineHeight * maxLines) + 1; + return el.scrollHeight <= allowedHeight && el.scrollWidth <= el.clientWidth + 1; + }; + if (fits(max)) { el.style.fontSize = `${max}px`; return; } + let lo = min; + let hi = max; + let best = min; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; } + } + el.style.fontSize = `${best}px`; + // Last resort for a single word too wide even at min: allow it to break rather than spill. + if (best === min && el.scrollWidth > el.clientWidth + 1) { + el.style.overflowWrap = 'anywhere'; + el.style.wordBreak = 'break-word'; + } +} + +/** + * Tidy an item name for display only (the raw name is kept for ProPresenter matching). + * Strips arrangement/section tags in brackets ([ Full ], ( TAG ), { ... }), a leading date + * code (e.g. "260607 "), separator dashes, and any leftover edge punctuation/whitespace so the + * card reads as cleanly as possible — "260607 Taking Thoughts Captive - [ Full ]" → "Taking Thoughts Captive". + */ +function cleanItemName(raw) { + const original = String(raw || '').trim(); + let s = original; + s = s.replace(/[[({][^\])}]*[\])}]/g, ' '); // drop [..], (..), {..} tags + s = s.replace(/^\s*\d{6,8}\b[\s\-–—:.]*/, ''); // drop a leading 6–8 digit date code + s = s.replace(/\s[-–—]+\s/g, ' '); // drop " - " style separators + s = s.replace(/^[\s\-–—:.]+|[\s\-–—:.]+$/g, ''); // trim edge dashes/colons/dots/space + s = s.replace(/\s{2,}/g, ' ').trim(); // collapse runs of whitespace + return s || original; // never blank out a title (e.g. a name that was only a tag) +} + +function renderPpNow() { + ppNow.innerHTML = ''; + const data = ppData; + + if (data?.needsPlaylist) { + const prompt = document.createElement('div'); + prompt.className = 'pp-waiting'; + prompt.textContent = 'Pick your playlist in settings →'; + ppNow.appendChild(prompt); + return; + } + + const activeItem = data && data.activeIndex >= 0 ? data.items[data.activeIndex] : null; + const name = activeItem?.name || data?.activePresentationName || ''; + + if (!name) { + const waiting = document.createElement('div'); + waiting.className = 'pp-waiting'; + waiting.textContent = data?.reachable ? 'Nothing on screen' : 'Reconnecting…'; + ppNow.appendChild(waiting); + if (data?.lastError) { + const errEl = document.createElement('div'); + errEl.className = 'pp-error'; + errEl.textContent = data.lastError; + ppNow.appendChild(errEl); + } + return; + } + + const label = document.createElement('div'); + label.className = 'pp-now-label'; + label.textContent = 'Now'; + ppNow.appendChild(label); + + const nameEl = document.createElement('div'); + nameEl.className = 'pp-now-name'; + nameEl.textContent = cleanItemName(name); + ppNow.appendChild(nameEl); + fitNowTitle(nameEl); + + const total = data.currentSlideTotal || activeItem?.slideCount || 0; + const current = data.currentSlide || 0; + if (total > 0 || current > 0) { + const meta = document.createElement('div'); + meta.className = 'pp-now-meta'; + if (total > 0 && current > 0) { + const remaining = Math.max(0, total - current); + meta.textContent = `Slide ${current} / ${total} · ${remaining} left`; + } else if (total > 0) { + meta.textContent = `${total} slides`; + } else { + meta.textContent = `Slide ${current}`; + } + ppNow.appendChild(meta); + + if (total > 0) { + const progress = document.createElement('div'); + progress.className = 'pp-progress'; + const fill = document.createElement('div'); + fill.className = 'pp-progress-fill'; + const pct = current > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : 0; + fill.style.width = `${pct}%`; + progress.appendChild(fill); + ppNow.appendChild(progress); + } + } +} + +function renderPpNext() { + ppNextList.innerHTML = ''; + const data = ppData; + const items = Array.isArray(data?.items) ? data.items : []; + // When presenting from the playlist we list everything after the active item; when + // presenting from the Library (activeIndex < 0) we show the whole upcoming playlist. + const from = data && data.activeIndex >= 0 ? data.activeIndex + 1 : 0; + const upcoming = []; + for (let i = from; i < items.length; i += 1) { + if (!items[i].isHidden) upcoming.push(items[i]); + } + + for (const item of upcoming) { + if (item.type === 'header') { + const headerEl = document.createElement('div'); + headerEl.className = 'pp-next-item is-header'; + const headerName = document.createElement('div'); + headerName.className = 'pp-next-name'; + headerName.textContent = cleanItemName(item.name || ''); + headerEl.appendChild(headerName); + ppNextList.appendChild(headerEl); + continue; + } + + const itemEl = document.createElement('div'); + itemEl.className = 'pp-next-item'; + const nameEl = document.createElement('div'); + nameEl.className = 'pp-next-name'; + nameEl.textContent = cleanItemName(item.name) || '(untitled)'; + itemEl.appendChild(nameEl); + + if (item.type === 'presentation' && Number.isInteger(item.slideCount)) { + const chip = document.createElement('div'); + chip.className = 'pp-count-chip'; + chip.textContent = String(item.slideCount); + itemEl.appendChild(chip); + } else { + const tag = ppItemTypeTag(item.type); + if (tag) { + const tagEl = document.createElement('div'); + tagEl.className = 'pp-type-tag'; + tagEl.textContent = tag; + itemEl.appendChild(tagEl); + } + } + ppNextList.appendChild(itemEl); + } + + ppNextLabel.style.display = upcoming.length ? '' : 'none'; + window.requestAnimationFrame(ppTrimOverflow); +} + +function renderProPresenterColumn() { + if (!ppColumn || !ppNow || !ppNextList) return; + ppPlaylistName.textContent = ppData?.playlistName || ''; + renderPpNow(); + renderPpNext(); +} + +function openPpModal() { + ppModal.classList.remove('hidden'); + ppHostInput.focus(); + ppHostInput.select(); +} + +function closePpModal() { + ppModal.classList.add('hidden'); + ppPasswordInput.value = ''; +} + +async function connectProPresenter() { + const host = ppHostInput.value.trim(); + const port = ppPortInput.value.trim(); + const password = ppPasswordInput.value; + if (!host || !port) { + setPpStatus('Enter the ProPresenter host and port.', true); + return; + } + ppSaveButton.disabled = true; + setPpStatus('Connecting to ProPresenter…'); + try { + await window.lineup.proPresenter.connect({ host, port, password }); + closePpModal(); + if (ppPlaylistSelect) ppPlaylistSelect.style.display = 'none'; + setPpStatus(`Connected to ProPresenter at ${host}:${port}. Auto-following the live playlist.`); + } catch (error) { + setPpStatus(error instanceof Error ? error.message : 'Could not connect to ProPresenter.', true); + } finally { + ppSaveButton.disabled = false; + } +} + +async function disconnectProPresenter() { + try { + await window.lineup.proPresenter.disconnect(); + if (ppPlaylistSelect) ppPlaylistSelect.value = ''; + setPpStatus('ProPresenter disconnected.'); + } catch (error) { + setPpStatus(error instanceof Error ? error.message : 'Could not disconnect ProPresenter.', true); + } +} + +async function refreshPpPlaylists() { + if (!ppPlaylistSelect) return; + let playlists = []; + let selected = ''; + try { + playlists = await window.lineup.proPresenter.listPlaylists(); + const info = await window.lineup.proPresenter.status(); + selected = info?.playlistUuid || ''; + } catch { + playlists = []; + } + ppPlaylistSelect.innerHTML = ''; + const placeholder = document.createElement('option'); + placeholder.value = ''; + placeholder.textContent = 'Choose playlist'; + ppPlaylistSelect.appendChild(placeholder); + for (const playlist of Array.isArray(playlists) ? playlists : []) { + const option = document.createElement('option'); + option.value = playlist.uuid; + option.textContent = playlist.group ? `${playlist.group} / ${playlist.name}` : playlist.name; + option.dataset.name = playlist.name || ''; + ppPlaylistSelect.appendChild(option); + } + ppPlaylistSelect.value = selected; +} + +btnAdd.addEventListener('click', async () => { + revealChrome(); + const added = await window.lineup.addImagesDialog(); + await importNewPeople(added); +}); + +btnPcoConnect.addEventListener('click', () => { + revealChrome(); + openPcoModal(); +}); + +btnPcoAddPosition.addEventListener('click', () => { + revealChrome(); + const selectedKey = pcoPositionOptionSelect.value; + if (!selectedKey) { + setPcoStatus('Choose a position to add.', true); + return; + } + const option = pcoPositionOptions.find((entry) => entry.key === selectedKey); + if (!option) return; + pcoDisplayPositions.push({ + teamName: option.teamName, + positionName: option.positionName, + count: 1, + }); + renderPcoDisplayPositions(); + scheduleSave(); + setPcoStatus(`Added ${option.positionName} from ${option.teamName}.`); +}); + +btnPcoSync.addEventListener('click', () => { + revealChrome(); + void syncUpcomingPlanFromPco(); +}); + +btnPcoTestBirthdays.addEventListener('click', () => { + revealChrome(); + void testPcoBirthdays(); +}); + +if (bdayEnabled) { + bdayEnabled.addEventListener('change', () => { + birthdayMode.enabled = bdayEnabled.checked; + syncBirthdayControls(); + scheduleSave(); + void renderStage(); + }); +} +if (bdayIntensity) { + bdayIntensity.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-int]'); + if (!btn) return; + birthdayMode.intensity = btn.dataset.int === 'full' ? 'full' : 'subtle'; + syncBirthdayControls(); + scheduleSave(); + void renderStage(); + }); +} +if (bdayTestSelect) { + bdayTestSelect.addEventListener('change', () => { + birthdayTestPersonId = bdayTestSelect.value || ''; + void renderStage(); + }); +} + +btnPcoDisconnect.addEventListener('click', async () => { + revealChrome(); + setPcoControlsEnabled(false); + try { + await window.lineup.pcoDisconnect(); + pcoServiceTypeId = ''; + pcoPositionOptions = []; + pcoServiceTypeSelect.innerHTML = ''; + renderPcoPositionOptions(); + scheduleSave(); + setPcoStatus('Planning Center disconnected.'); + setPcoBirthdayResult(''); + } catch (error) { + setPcoStatus(error instanceof Error ? error.message : 'Could not disconnect Planning Center.', true); + } finally { + setPcoControlsEnabled(true); + } +}); + +pcoServiceTypeSelect.addEventListener('change', () => { + pcoServiceTypeId = pcoServiceTypeSelect.value; + void refreshPcoPositionOptions(); + scheduleSave(); +}); + +btnApplyLayout.addEventListener('click', () => { + revealChrome(); + void applyLayoutChange(); +}); + +btnToggleParallax.addEventListener('click', () => { + revealChrome(); + parallaxEnabled = !parallaxEnabled; + syncParallaxToggle(); + void renderStage(); + scheduleSave(); +}); + +for (const input of [inputLayoutCols, inputLayoutRows]) { + input.addEventListener('click', (e) => { + e.stopPropagation(); + if (lastPointerType === 'touch') { + e.preventDefault(); + openNumberModal(input); + } + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void applyLayoutChange(); + } + }); +} + +toolbar.addEventListener('pointerenter', () => { + revealChrome(); +}); + +toolbar.addEventListener('pointerleave', () => { + noteActivity(); +}); + +toolbar.addEventListener('focusin', () => { + revealChrome(); +}); + +toolbar.addEventListener('focusout', () => { + window.setTimeout(() => { + noteActivity(); + }, 0); +}); + +libraryPanel.addEventListener('pointerenter', () => { + revealChrome(); +}); + +libraryPanel.addEventListener('pointerleave', () => { + noteActivity(); +}); + +libraryPanel.addEventListener('focusin', () => { + revealChrome(); +}); + +libraryPanel.addEventListener('focusout', () => { + window.setTimeout(() => { + noteActivity(); + }, 0); +}); + +window.lineup.onMenuAddPhotos(() => { + btnAdd.click(); +}); + +btnFullscreen.addEventListener('click', () => { + revealChrome(); + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch(() => {}); + } else { + document.exitFullscreen().catch(() => {}); + } +}); + +document.addEventListener('fullscreenchange', () => { + noteActivity(); + window.requestAnimationFrame(updateSlotSizeReadout); +}); + +dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + noteActivity(); + e.dataTransfer.dropEffect = e.dataTransfer.types.includes('Files') ? 'copy' : 'move'; + if (e.dataTransfer.types.includes('Files')) dropZone.classList.add('drag-over'); +}); + +dropZone.addEventListener('dragleave', (e) => { + if (!dropZone.contains(/** @type {Node} */ (e.relatedTarget))) { + dropZone.classList.remove('drag-over'); + } +}); + +dropZone.addEventListener('drop', async (e) => { + e.preventDefault(); + noteActivity(); + dropZone.classList.remove('drag-over'); + const files = Array.from(e.dataTransfer.files || []).filter((file) => file.type.startsWith('image/')); + if (!files.length) return; + const paths = files.map((file) => file.path).filter(Boolean); + if (!paths.length) return; + const added = await window.lineup.addImagesPaths(paths); + await importNewPeople(added); +}); + +function buildVirtualKeyboard() { + const root = document.getElementById('vkeyboard'); + if (!root) return; + root.innerHTML = ''; + + for (const keys of VK_ROWS) { + const rowEl = document.createElement('div'); + rowEl.className = 'vk-row'; + for (const key of keys) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'vk-key'; + button.textContent = key; + button.dataset.ch = key; + rowEl.appendChild(button); + } + root.appendChild(rowEl); + } + + const actionsRow = document.createElement('div'); + actionsRow.className = 'vk-row'; + + const space = document.createElement('button'); + space.type = 'button'; + space.className = 'vk-key space'; + space.textContent = 'Space'; + space.dataset.action = 'space'; + + const erase = document.createElement('button'); + erase.type = 'button'; + erase.className = 'vk-key wide'; + erase.textContent = 'Erase'; + erase.dataset.action = 'erase'; + + actionsRow.appendChild(space); + actionsRow.appendChild(erase); + root.appendChild(actionsRow); + + root.addEventListener('click', (e) => { + const button = e.target.closest('.vk-key'); + if (!button) return; + noteActivity(); + e.preventDefault(); + const action = button.dataset.action; + if (action === 'space') { + labelInput.value += ' '; + return; + } + if (action === 'erase') { + labelInput.value = labelInput.value.slice(0, -1); + return; + } + const ch = button.dataset.ch; + if (ch) labelInput.value += ch; + }); +} + +function buildNumberKeyboard() { + if (!numberKeyboard) return; + numberKeyboard.innerHTML = ''; + + const rows = [['1', '2', '3'], ['4', '5', '6'], ['7', '8', '9']]; + for (const keys of rows) { + const rowEl = document.createElement('div'); + rowEl.className = 'vk-row'; + for (const key of keys) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'vk-key wide'; + button.textContent = key; + button.dataset.ch = key; + rowEl.appendChild(button); + } + numberKeyboard.appendChild(rowEl); + } + + const lastRow = document.createElement('div'); + lastRow.className = 'vk-row'; + + const clear = document.createElement('button'); + clear.type = 'button'; + clear.className = 'vk-key wide'; + clear.textContent = 'Clear'; + clear.dataset.action = 'clear'; + + const zero = document.createElement('button'); + zero.type = 'button'; + zero.className = 'vk-key wide'; + zero.textContent = '0'; + zero.dataset.ch = '0'; + + const erase = document.createElement('button'); + erase.type = 'button'; + erase.className = 'vk-key wide'; + erase.textContent = 'Erase'; + erase.dataset.action = 'erase'; + + lastRow.appendChild(clear); + lastRow.appendChild(zero); + lastRow.appendChild(erase); + numberKeyboard.appendChild(lastRow); + + numberKeyboard.addEventListener('click', (e) => { + const button = e.target.closest('.vk-key'); + if (!button) return; + noteActivity(); + e.preventDefault(); + const action = button.dataset.action; + if (action === 'clear') { + numberInput.value = ''; + return; + } + if (action === 'erase') { + numberInput.value = numberInput.value.slice(0, -1); + return; + } + const ch = button.dataset.ch; + if (ch) { + const nextValue = `${numberInput.value}${ch}`.replace(/^0+(?=\d)/, ''); + numberInput.value = nextValue; + } + }); +} + +function openNumberModal(input, options = {}) { + numericEditTarget = input; + numericEditCommit = typeof options.onCommit === 'function' ? options.onCommit : null; + numericEditMin = Number.isFinite(options.min) ? options.min : 1; + numericEditMax = Number.isFinite(options.max) ? options.max : Number.POSITIVE_INFINITY; + numericEditOnInvalid = typeof options.onInvalid === 'function' ? options.onInvalid : null; + numberModalTitle.textContent = options.title || (input === inputLayoutCols ? 'Columns' : 'Rows'); + numberInput.value = input.value || ''; + numberModal.classList.remove('hidden'); + revealChrome(); +} + +function closeNumberModal() { + numberModal.classList.add('hidden'); + numericEditTarget = null; + numericEditCommit = null; + numericEditMin = 1; + numericEditMax = Number.POSITIVE_INFINITY; + numericEditOnInvalid = null; + scheduleChromeHide(); +} + +async function commitNumberModal() { + if (!numericEditTarget) { + closeNumberModal(); + return; + } + const parsed = Number.parseInt(numberInput.value, 10); + const commit = numericEditCommit; + + if (commit) { + // A bounded field REFUSES a bad entry rather than coercing it. The grid's historic + // floor-of-1 is harmless for Cols/Rows, but silently turning a cleared keypad into + // port 1 would restart the live display server on a port nobody typed and kill every + // bookmark in the building. + const min = numericEditMin; + const max = numericEditMax; + const onInvalid = numericEditOnInvalid; + if (!Number.isFinite(parsed) || parsed < min || parsed > max) { + closeNumberModal(); + if (onInvalid) onInvalid(`Enter a number between ${min} and ${max}.`); + return; + } + numericEditTarget.value = String(parsed); + closeNumberModal(); + await commit(parsed); + return; + } + + const safeValue = Number.isFinite(parsed) && parsed > 0 ? parsed : 1; + numericEditTarget.value = String(safeValue); + closeNumberModal(); + await applyLayoutChange(); +} + +function openLabelModal(edit) { + labelEdit = edit; + if (edit.type === 'name') { + const person = edit.personId ? getPerson(edit.personId) : null; + if (!person) return; + labelTitle.textContent = 'Band member name'; + labelInput.placeholder = 'Enter band member name'; + labelInput.value = person.lastName || ''; + } else if (edit.type === 'slotName') { + labelTitle.textContent = 'Stage position name'; + labelInput.placeholder = 'Enter stage name'; + labelInput.value = slotNameMemory[edit.slotIndex] || ''; + } else { + const slot = stageSlots[edit.slotIndex]; + if (!slot) return; + labelTitle.textContent = 'Mic / pack label'; + labelInput.placeholder = 'Enter mic / pack'; + labelInput.value = slot.label || ''; + } + + labelModal.classList.remove('hidden'); + labelInput.focus(); + labelInput.select(); +} + +function closeLabelModal() { + labelModal.classList.add('hidden'); + labelEdit = { type: 'slotLabel', personId: null, slotIndex: -1 }; + labelInput.blur(); + scheduleChromeHide(); +} + +async function commitLabelModal() { + const currentEdit = { ...labelEdit }; + const value = labelInput.value.trim(); + if (currentEdit.type === 'name') { + const person = currentEdit.personId ? getPerson(currentEdit.personId) : null; + if (person) { + person.lastName = value; + } + } else if (currentEdit.type === 'slotName') { + if (currentEdit.slotIndex >= 0 && currentEdit.slotIndex < getStageSlotCount()) { + slotNameMemory[currentEdit.slotIndex] = value; + } + } else if (currentEdit.slotIndex >= 0 && currentEdit.slotIndex < getStageSlotCount()) { + stageSlots[currentEdit.slotIndex].label = value; + slotLabelMemory[currentEdit.slotIndex] = value; + } + + closeLabelModal(); + if (currentEdit.type === 'name') { + const slotIndex = currentEdit.personId ? findStageIndexByPersonId(currentEdit.personId) : -1; + if (slotIndex >= 0) { + await renderStage([slotIndex]); + } + } else if (currentEdit.type === 'slotName' && currentEdit.slotIndex >= 0) { + await renderStage([currentEdit.slotIndex]); + } else if (currentEdit.slotIndex >= 0) { + await renderStage([currentEdit.slotIndex]); + } else { + await renderStage(); + } + scheduleSave(); +} + +labelDone.addEventListener('click', () => void commitLabelModal()); +labelCancel.addEventListener('click', () => closeLabelModal()); +labelBackdrop.addEventListener('click', () => closeLabelModal()); +numberDone.addEventListener('click', () => void commitNumberModal()); +numberCancel.addEventListener('click', () => closeNumberModal()); +numberBackdrop.addEventListener('click', () => closeNumberModal()); +pcoSaveButton.addEventListener('click', () => void connectPlanningCenter()); +pcoCancelButton.addEventListener('click', () => closePcoModal()); +pcoModalBackdrop.addEventListener('click', () => closePcoModal()); + +btnPpConnect.addEventListener('click', () => { + revealChrome(); + openPpModal(); +}); +btnPpDisconnect.addEventListener('click', () => { + revealChrome(); + void disconnectProPresenter(); +}); +if (btnDisplayToggle) { + btnDisplayToggle.addEventListener('click', () => { + revealChrome(); + void toggleDisplayHosting(); + }); +} + +if (displayPortInput) { + displayPortInput.addEventListener('click', (e) => { + e.stopPropagation(); + if (lastPointerType === 'touch') { + e.preventDefault(); + openNumberModal(displayPortInput, { + title: 'Display port', + min: 1024, + max: 65535, + onCommit: () => applyDisplayPort(), + onInvalid: (message) => setDisplayStatus(message, true), + }); + } + }); + displayPortInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void applyDisplayPort(); + } + }); + displayPortInput.addEventListener('blur', () => { + if (suppressPortCommit) { + // A tab switch took the focus, not the operator. Put the running value back. + displayPortInput.value = displayState?.port ? String(displayState.port) : ''; + return; + } + // Committing whatever is in the box on blur would restart the server on a half-typed + // port ("76" on the way to "7654"). Apply only a value that is actually usable; put + // anything else back so the field never disagrees with the running server. + const parsed = Number.parseInt(displayPortInput.value.trim(), 10); + if (Number.isFinite(parsed) && parsed >= 1024 && parsed <= 65535) { + void applyDisplayPort(); + return; + } + displayPortInput.value = displayState?.port ? String(displayState.port) : ''; + }); +} + +ppSaveButton.addEventListener('click', () => void connectProPresenter()); +ppCancelButton.addEventListener('click', () => closePpModal()); +ppModalBackdrop.addEventListener('click', () => closePpModal()); + +if (ppPlaylistSelect) { + ppPlaylistSelect.addEventListener('change', () => { + revealChrome(); + const uuid = ppPlaylistSelect.value; + const option = ppPlaylistSelect.selectedOptions[0]; + const name = option ? option.dataset.name || option.textContent || '' : ''; + void window.lineup.proPresenter.selectPlaylist({ uuid, name }); + setPpStatus(uuid ? `Showing playlist: ${name}` : 'Choose a playlist to display.'); + }); +} + +for (const input of [ppHostInput, ppPortInput, ppPasswordInput]) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void connectProPresenter(); + } + if (e.key === 'Escape') { + e.preventDefault(); + closePpModal(); + } + }); +} + +// Re-fit the Now/Next list whenever the column's size changes: window resize, +// fullscreen toggle, or the column becoming visible when entering show mode. +if (ppColumn && typeof ResizeObserver !== 'undefined') { + // Re-render only when the column actually changes size (window/show-mode), not in reaction + // to our own content writes — otherwise the fitted title can oscillate. + let lastW = 0; + let lastH = 0; + let pending = false; + const ppResizeObserver = new ResizeObserver(() => { + const w = Math.round(ppColumn.clientWidth); + const h = Math.round(ppColumn.clientHeight); + if (w === lastW && h === lastH) return; + lastW = w; lastH = h; + if (pending) return; + pending = true; + window.requestAnimationFrame(() => { + pending = false; + renderProPresenterColumn(); + }); + }); + ppResizeObserver.observe(ppColumn); +} + +labelInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void commitLabelModal(); + } + if (e.key === 'Escape') { + e.preventDefault(); + closeLabelModal(); + } +}); + +numberInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void commitNumberModal(); + } + if (e.key === 'Escape') { + e.preventDefault(); + closeNumberModal(); + } +}); + +for (const input of [pcoClientIdInput, pcoSecretInput]) { + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void connectPlanningCenter(); + } + if (e.key === 'Escape') { + e.preventDefault(); + closePcoModal(); + } + }); +} + +for (const eventName of ['keydown', 'wheel']) { + window.addEventListener( + eventName, + () => { + noteActivity(); + }, + { passive: true }, + ); +} + +window.addEventListener( + 'resize', + () => { + window.requestAnimationFrame(updateSlotSizeReadout); + }, + { passive: true }, +); + +dropZone.addEventListener('click', (e) => { + handleChromeToggleSurfaceClick(e, 'stage-surface'); +}); + +if (libraryTabs) { + libraryTabs.addEventListener('click', (e) => { + const tab = e.target instanceof HTMLElement ? e.target.closest('.lib-tab') : null; + if (!tab) return; + // Keep this away from the panel's double-tap show-mode toggle below. + e.stopPropagation(); + revealChrome(); + activatePanelTab(tab.dataset.tab); + }); +} + +libraryPanel.addEventListener('click', (e) => { + handleChromeToggleSurfaceClick(e, 'library-surface'); +}); + +buildVirtualKeyboard(); +buildNumberKeyboard(); + +(async function init() { + const data = await window.lineup.loadState(); + applyLoadedState(data); + await renderAll(); + try { + const status = await window.lineup.pcoStatus(); + if (status?.connected) { + if (!pcoServiceTypeId && typeof status.pcoServiceTypeId === 'string') { + pcoServiceTypeId = status.pcoServiceTypeId; + } + if (!pcoDisplayPositions.length && Array.isArray(status.pcoDisplayPositions)) { + pcoDisplayPositions = status.pcoDisplayPositions + .map((entry) => normalizePcoDisplayPosition(entry)) + .filter((entry) => entry.teamName && entry.positionName); + renderPcoDisplayPositions(); + } + await refreshPcoServiceTypes(); + pcoServiceTypeSelect.value = pcoServiceTypeId || ''; + await refreshPcoPositionOptions(); + setPcoStatus( + pcoServiceTypeId + ? 'Planning Center connected.' + : 'Planning Center connected. Choose a service type.', + ); + } else { + setPcoStatus('Planning Center is not connected.'); + } + } catch (error) { + setPcoStatus(error instanceof Error ? error.message : 'Planning Center is unavailable.', true); + } + + window.lineup.proPresenter.onData((incoming) => { + ppData = incoming; + updatePpActiveClass(); + renderProPresenterColumn(); + }); + try { + const ppInfo = await window.lineup.proPresenter.status(); + if (ppInfo?.host) ppHostInput.value = ppInfo.host; + if (ppInfo?.port) ppPortInput.value = String(ppInfo.port); + if (ppPlaylistSelect) ppPlaylistSelect.style.display = 'none'; // auto-follow: no manual picker + if (ppInfo?.enabled) { + setPpStatus(`Connected to ProPresenter at ${ppInfo.host}:${ppInfo.port}. Auto-following the live playlist.`); + await window.lineup.proPresenter.start(); + } else { + setPpStatus('ProPresenter is not connected.'); + } + } catch (error) { + setPpStatus(error instanceof Error ? error.message : 'ProPresenter is unavailable.', true); + } + + try { + const info = await window.lineup.appVersion(); + if (appVersionEl && info?.version) { + appVersionEl.textContent = info.build ? `v${info.version} · build ${info.build}` : `v${info.version}`; + } + } catch { + /* label just stays empty */ + } + + await refreshDisplayStatus(); + if (displayState?.enabled) displayOnSince = Date.now(); + scheduleDisplayRefresh(); + + exitChromeEditMode(); +})(); +} diff --git a/scripts/archive-builds.js b/scripts/archive-builds.js new file mode 100644 index 0000000..c0cd26e --- /dev/null +++ b/scripts/archive-builds.js @@ -0,0 +1,105 @@ +'use strict'; + +/** + * Keep old installers instead of overwriting them. + * + * Runs automatically after `npm run dist` (see the "postdist" script). The freshly built + * version stays at the top level of dist/ so there is never a question about which file to + * install; every older version is moved into dist/archive/ and the archive is pruned to the + * most recent KEEP_VERSIONS so the folder cannot grow without bound. + * + * Zero dependencies. Set DIST_DIR to point it somewhere else (used by the tests). + */ + +const fs = require('fs'); +const path = require('path'); + +/** How many previous versions to keep. Each version is ~460 MB (installer + portable). */ +const KEEP_VERSIONS = 5; + +const ROOT = path.join(__dirname, '..'); +const DIST = process.env.DIST_DIR || path.join(ROOT, 'dist'); +const ARCHIVE = path.join(DIST, 'archive'); + +/** Pull "1.2.1" out of "Band Lineup Setup 1.2.1.exe". */ +function versionOf(fileName) { + const match = fileName.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : ''; +} + +function compareVersions(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i += 1) { + if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); + } + return 0; +} + +function currentVersion() { + if (process.env.CURRENT_VERSION) return process.env.CURRENT_VERSION; + return JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; +} + +/** Installer-ish artifacts worth keeping. Build scratch (win-unpacked, logs) is left alone. */ +function isArtifact(fileName) { + return /\.(exe|blockmap|dmg|zip|appx|msi)$/i.test(fileName); +} + +function humanSize(bytes) { + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; +} + +function run() { + if (!fs.existsSync(DIST)) { + console.log('[archive-builds] no dist/ yet — nothing to do'); + return; + } + const version = currentVersion(); + fs.mkdirSync(ARCHIVE, { recursive: true }); + + // 1. Move anything that isn't the current version out of the way. + let moved = 0; + for (const name of fs.readdirSync(DIST)) { + const from = path.join(DIST, name); + if (!fs.statSync(from).isFile() || !isArtifact(name)) continue; + const fileVersion = versionOf(name); + if (!fileVersion || fileVersion === version) continue; + const to = path.join(ARCHIVE, name); + fs.renameSync(from, to); + moved += 1; + console.log(`[archive-builds] archived ${name}`); + } + + // 2. Prune the archive to the newest KEEP_VERSIONS, by version number. + const byVersion = new Map(); + for (const name of fs.readdirSync(ARCHIVE)) { + const full = path.join(ARCHIVE, name); + if (!fs.statSync(full).isFile() || !isArtifact(name)) continue; + const fileVersion = versionOf(name); + if (!fileVersion) continue; + if (!byVersion.has(fileVersion)) byVersion.set(fileVersion, []); + byVersion.get(fileVersion).push(full); + } + const versions = [...byVersion.keys()].sort(compareVersions).reverse(); + const drop = versions.slice(KEEP_VERSIONS); + for (const old of drop) { + for (const file of byVersion.get(old)) { + fs.unlinkSync(file); + } + console.log(`[archive-builds] pruned ${old} (keeping the newest ${KEEP_VERSIONS})`); + } + + const kept = versions.slice(0, KEEP_VERSIONS); + let bytes = 0; + for (const v of kept) { + for (const file of byVersion.get(v)) bytes += fs.statSync(file).size; + } + console.log( + `[archive-builds] current: ${version} in dist/ | archived: ${kept.join(', ') || 'none yet'}` + + (bytes ? ` (${humanSize(bytes)})` : ''), + ); + if (!moved && !kept.length) console.log('[archive-builds] first run — nothing to archive yet'); +} + +run(); diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..a53169f --- /dev/null +++ b/styles.css @@ -0,0 +1,1332 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: #0f0f12; + color: #e8e8ec; + user-select: none; +} + +body { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.top-edge-hit { + display: none; +} + +.toolbar { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: #1a1a20; + border-bottom: 1px solid #2a2a32; + flex-shrink: 0; + overflow: hidden; + max-height: 72px; + opacity: 1; + transition: max-height 0.22s ease, padding 0.22s ease, opacity 0.22s ease, transform 0.22s ease, border-color 0.22s ease; +} + +.toolbar-spacer { + flex: 1; +} + +.app-version { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: #6f6f7d; + white-space: nowrap; + user-select: text; +} + +.layout-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.layout-field { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #a0a0ae; +} + +.layout-field input { + width: 64px; + padding: 7px 8px; + border-radius: 8px; + border: 1px solid #3a3a44; + background: #25252e; + color: #e8e8ec; + font-size: 14px; +} + +.layout-field input:focus { + outline: none; + border-color: #4a6fa5; +} + + + +.btn { + padding: 8px 14px; + border-radius: 8px; + border: 1px solid #3a3a44; + background: #25252e; + color: #e8e8ec; + font-size: 14px; + cursor: pointer; + touch-action: manipulation; +} + +.btn:hover { + background: #32323c; +} + +.btn.primary { + background: #3d5a80; + border-color: #4a6fa5; +} + +.btn.primary:hover { + background: #4a6fa5; +} + +.btn.btn-small { + padding: 6px 10px; + font-size: 12px; +} + +.app-main { + flex: 1; + min-height: 0; + display: flex; + align-items: stretch; +} + +.stage { + flex: 1; + min-width: 0; + position: relative; + display: flex; + align-items: stretch; + justify-content: stretch; + padding: 12px 14px 12px 16px; + overflow: hidden; +} + +.stage.drag-over { + outline: 2px dashed #4a6fa5; + outline-offset: -6px; + background: #15151a; +} + +.row { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + grid-template-rows: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.stage-slot { + min-width: 0; + min-height: 0; + position: relative; + border-radius: 12px; + overflow: hidden; + background: #15151b; + border: 2px solid #282832; +} + +.stage-slot.empty-slot { + border-style: dashed; + border-color: #343442; + background: rgba(30, 30, 38, 0.65); +} + +.stage-slot.empty-slot::before { + content: attr(data-slot-label); + position: absolute; + top: 8px; + left: 10px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #646474; +} + +.slot-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 12px; + color: #555563; + font-size: 14px; +} + +.slot-visual { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.library-panel { + width: clamp(320px, 30vw, 420px); + min-width: 320px; + flex-shrink: 0; + display: flex; + flex-direction: column; + /* Show mode collapses this panel to width 0 rather than hiding it; without clipping, the + tab strip and pane contents spill across the stage. */ + overflow: hidden; + background: #141418; + border-left: 1px solid #2a2a32; + transition: width 0.22s ease, min-width 0.22s ease, border-color 0.22s ease; +} + +/* ---------- panel tabs ---------- */ + +.library-tabs { + display: flex; + flex-shrink: 0; + gap: 4px; + padding: 8px 8px 0; + border-bottom: 1px solid #2a2a32; + transition: opacity 0.18s ease; +} + +.lib-tab { + flex: 1; + min-width: 0; + padding: 9px 6px; + font-family: inherit; + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #8a8a99; + background: none; + border: none; + border-bottom: 2px solid transparent; + border-radius: 6px 6px 0 0; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lib-tab:hover { + color: #c6cada; + background: rgba(255, 255, 255, 0.03); +} + +.lib-tab.is-active { + color: #cfe0f7; + border-bottom-color: #4a6fa5; + background: rgba(74, 111, 165, 0.12); +} + +/* One pane at a time. display:none rather than opacity, so a hidden pane cannot be + clicked, tabbed into, or measured. */ +.library-pane { + display: none; + min-height: 0; + transition: opacity 0.18s ease; +} + +.library-pane.is-active { + /* This chain is load-bearing: .library-scroll's flex:1 only expands because every + ancestor up to .library-panel is a column flex box with min-height:0. */ + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; +} + +.pane-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 10px; + touch-action: pan-y; +} + +.pane-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.pane-head-spaced { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #2a2a32; +} + +.pane-title { + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9a9aa8; +} + +.pane-foot { + flex-shrink: 0; + padding: 6px 10px 8px; + border-top: 1px solid #2a2a32; +} + +.slot-size-readout { + font-size: 11px; + line-height: 1.4; + color: #7d8492; + user-select: text; +} + +.pco-controls { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + margin-top: 8px; +} + +.pco-select { + min-width: 0; + width: 100%; + padding: 7px 10px; + border-radius: 8px; + border: 1px solid #3a3a44; + background: #25252e; + color: #e8e8ec; + font-size: 13px; +} + +.pco-status { + margin-top: 8px; + min-height: 18px; + font-size: 12px; + color: #9ea7bc; +} + +.pco-birthday-result { + min-height: 0; + line-height: 1.45; +} + +.pco-birthday-result:empty { + display: none; +} + +/* ---- Birthday Mode settings ---- */ +.bday-block { + margin-top: 14px; + border-top: 1px solid #23232c; + padding-top: 12px; +} +.bday-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.bday-title { + font-size: 13px; + font-weight: 700; + color: #e7e7ef; +} +.bday-switch { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 12px; + color: #9ea7bc; +} +.bday-controls { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 10px; +} +.bday-line { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.bday-line-label { + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #8a8a99; + min-width: 74px; +} +.bday-line-label em { + font-style: normal; + text-transform: none; + letter-spacing: 0; + color: #6d6d7a; +} +.seg-inline { + display: inline-flex; + gap: 4px; + background: #0e0e14; + border: 1px solid #23232c; + border-radius: 9px; + padding: 3px; +} +.seg-inline button { + appearance: none; + border: 0; + background: transparent; + color: #9ea7bc; + font: inherit; + font-size: 12px; + font-weight: 600; + padding: 5px 12px; + border-radius: 6px; + cursor: pointer; +} +.seg-inline button[aria-pressed="true"] { + background: #f5c451; + color: #241a02; +} +.bday-check { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #c6cada; + cursor: pointer; +} +.bday-test-select { + flex: 1; + min-width: 150px; +} +.bday-hint { + font-size: 11px; + color: #6d6d7a; +} + +.pco-position-builder { + display: none; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + margin-top: 8px; +} + +.pco-position-list { + display: none; + flex-direction: column; + gap: 6px; + margin-top: 8px; + max-height: 170px; + overflow-y: auto; +} + +.pco-position-empty { + padding: 8px 10px; + border: 1px dashed #353542; + border-radius: 8px; + color: #808092; + font-size: 12px; +} + +.pco-position-item { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto auto auto auto; + gap: 6px; + align-items: center; + padding: 8px; + border: 1px solid #30303a; + border-radius: 8px; + background: #1a1a20; +} + +.pco-position-copy { + min-width: 0; +} + +.pco-position-name { + font-size: 12px; + font-weight: 600; + color: #ececf1; +} + +.pco-position-team { + margin-top: 2px; + font-size: 11px; + color: #9da5b6; +} + +.pco-count-chip { + min-width: 34px; + text-align: center; + font-size: 12px; + color: #d8deea; +} + +.library-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 8px; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 8px; + align-content: start; + touch-action: pan-y; + transition: opacity 0.18s ease; +} + +.lib-item { + position: relative; + width: 100%; + aspect-ratio: 3 / 4; + border-radius: 10px; + overflow: hidden; + border: 2px solid #33333e; + background: #0a0a0c; + flex-shrink: 0; + touch-action: pan-y; + cursor: grab; +} + +.lib-item:active { + cursor: grabbing; +} + +.lib-item.dragging-source { + opacity: 0.35; +} + +.lib-item.on-lineup { + border-color: #3d5a80; + box-shadow: 0 0 0 1px rgba(61, 90, 128, 0.5); +} + +.lib-item img { + width: 100%; + height: 100%; + aspect-ratio: 3 / 4; + object-fit: cover; + object-position: center top; + display: block; + pointer-events: none; +} + +.lib-name-overlay { + position: absolute; + top: 8px; + left: 8px; + right: 8px; + z-index: 1; + padding: 7px 8px; + border-radius: 8px; + background: rgba(8, 8, 12, 0.74); + color: #f4f4f6; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + text-align: center; + word-break: break-word; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.65); + pointer-events: none; +} + +.lib-delete { + position: absolute; + top: 4px; + right: 4px; + width: 24px; + height: 24px; + border: none; + border-radius: 8px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 14px; + line-height: 1; + cursor: pointer; + z-index: 2; + touch-action: manipulation; + padding: 0; +} + +.lib-delete:hover { + background: #8b2942; +} + +.tile { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + border-radius: 12px; + overflow: hidden; + background: #1a1a22; + position: relative; + touch-action: none; + cursor: grab; +} + +.tile:active { + cursor: grabbing; +} + +.tile.dragging-source { + opacity: 0.4; +} + +.tile .photo-area { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: #0a0a0c; +} + +.tile img { + width: 100%; + height: 100%; + object-fit: contain; + object-position: center center; + pointer-events: none; + display: block; +} + +.tile .parallax-viewport { + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; + pointer-events: none; +} + +.tile .moving-stage-image { + position: absolute; + inset: -5%; + width: 110%; + height: 110%; + object-fit: cover; + object-position: center center; + will-change: transform; + backface-visibility: hidden; + transform-origin: center center; + image-rendering: -webkit-optimize-contrast; + transform: translate3d(calc(var(--cam-x, 0px) * -0.3), calc(var(--cam-y, 0px) * -0.3), 0) scale(1.01); +} + +.tile .static-stage-image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center center; +} + +.tile .name-badge { + position: absolute; + top: 8px; + left: 8px; + max-width: calc(100% - 52px); + padding: 6px 10px; + border-radius: 8px; + background: rgba(8, 8, 12, 0.84); + color: #f4f4f6; + font-size: clamp(11px, 1vw, 15px); + font-weight: 700; + line-height: 1.2; + z-index: 3; + cursor: pointer; + touch-action: manipulation; + word-break: break-word; +} + +.empty-name-hotspot { + position: absolute; + top: 8px; + left: 8px; + right: 40px; + min-height: 28px; + border: 0; + border-radius: 8px; + padding: 6px 10px; + background: transparent; + color: #f4f4f6; + font-size: clamp(11px, 1vw, 15px); + font-weight: 700; + line-height: 1.2; + text-align: left; + z-index: 2; + cursor: pointer; + touch-action: manipulation; +} + +.empty-name-hotspot:not(:empty) { + background: rgba(8, 8, 12, 0.68); +} + +.empty-name-hotspot:focus-visible { + outline: 2px solid #6b9ee0; + outline-offset: 1px; +} + +.tile .name-badge:empty::before { + content: "Tap for name"; + color: #b2b2c0; + font-weight: 600; +} + +.tile .caption { + position: relative; + padding: 10px 8px; + font-size: clamp(11px, 1vw, 15px); + font-weight: 600; + text-align: center; + background: rgba(12, 12, 16, 0.88); + border-top: 1px solid #2e2e38; + min-height: 42px; + display: flex; + align-items: center; + justify-content: center; + word-break: break-word; + touch-action: manipulation; + cursor: pointer; +} + +.stage-slot > .caption:empty::before { + content: "Tap for mic / pack"; + color: #6b6b79; + font-weight: 500; +} + +/* ---- Birthday Mode tile effects ---- */ +.tile.birthday-subtle { + animation: bdayGlowSubtle 3.4s ease-in-out infinite; +} +.tile.birthday-full { + animation: bdayGlowFull 2.2s ease-in-out infinite; +} +@keyframes bdayGlowSubtle { + 0%, 100% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.55), 0 0 14px rgba(245, 196, 81, 0.18); } + 50% { box-shadow: inset 0 0 0 2px rgba(245, 196, 81, 0.85), 0 0 20px rgba(245, 196, 81, 0.32); } +} +@keyframes bdayGlowFull { + 0%, 100% { box-shadow: inset 0 0 0 3px rgba(245, 196, 81, 0.8), 0 0 20px rgba(245, 196, 81, 0.42); } + 50% { box-shadow: inset 0 0 0 3px #ffd873, 0 0 34px rgba(245, 196, 81, 0.66); } +} + +/* Birthday Mode intentionally keeps animating even under OS "reduced motion" — it's a celebration + the operator opts into, and freezing it leaves confetti stuck mid-fall. */ + +.birthday-confetti { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; +} + + +.tile .remove { + position: absolute; + top: 6px; + right: 6px; + width: 30px; + height: 30px; + border: none; + border-radius: 8px; + background: rgba(0, 0, 0, 0.6); + color: #fff; + font-size: 18px; + line-height: 1; + cursor: pointer; + z-index: 4; + touch-action: manipulation; + padding: 0; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +body:not(.chrome-hidden) .tile .remove { + opacity: 1; + pointer-events: auto; +} + +.tile .remove:hover { + background: #8b2942; +} + +.stage-slot { + display: flex; + flex-direction: column; +} + +.stage-slot > .caption { + flex-shrink: 0; + padding: 10px 8px; + font-size: clamp(11px, 1vw, 15px); + font-weight: 600; + text-align: center; + background: rgba(12, 12, 16, 0.88); + border-top: 1px solid #2e2e38; + min-height: 42px; + display: flex; + align-items: center; + justify-content: center; + word-break: break-word; + touch-action: manipulation; + cursor: pointer; +} + +.empty { + display: none; + position: absolute; + inset: 0; + align-items: center; + justify-content: center; + text-align: center; + color: #666670; + font-size: 16px; + margin: 0; + pointer-events: none; +} + +.empty.visible { + display: flex; +} + +body.chrome-hidden .toolbar { + max-height: 0; + padding-top: 0; + padding-bottom: 0; + opacity: 0; + transform: translateY(-10px); + border-bottom-color: transparent; + pointer-events: none; +} + +body.chrome-hidden .library-panel { + width: 0; + min-width: 0; + border-left-color: transparent; +} + +body.chrome-hidden .library-tabs, +body.chrome-hidden .library-pane { + opacity: 0; + pointer-events: none; +} + +body.chrome-hidden .tile .remove { + opacity: 0; + pointer-events: none; +} + +body.chrome-hidden .stage-slot.empty-slot::before, +body.chrome-hidden .slot-placeholder, +body.chrome-hidden .stage-slot > .caption:empty::before, +body.chrome-hidden .tile .name-badge:empty::before { + opacity: 0; +} + +.drag-ghost { + position: fixed; + width: 88px; + height: 118px; + border-radius: 10px; + overflow: hidden; + border: 2px solid #4a6fa5; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.55); + pointer-events: none; + z-index: 1000; + opacity: 0.92; +} + +.drag-ghost img { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center top; + display: block; +} + +.drag-ghost.hidden { + display: none; +} + +.modal { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + +.modal.hidden { + display: none; +} + +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.72); +} + +.modal-card { + position: relative; + width: min(720px, 100%); + max-height: min(92vh, 900px); + overflow: auto; + background: #1e1e26; + border: 1px solid #3a3a48; + border-radius: 16px; + padding: 20px; + box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5); +} + +.modal-card-small { + width: min(420px, 100%); +} + +.modal-title { + margin: 0 0 12px; + font-size: 18px; + font-weight: 600; +} + +.modal-copy { + margin: 0 0 12px; + color: #aeb6c8; + font-size: 13px; + line-height: 1.4; +} + +.modal-field { + display: block; + margin-bottom: 12px; + color: #c6cada; + font-size: 13px; +} + +.modal-field span { + display: block; + margin-bottom: 6px; +} + +.label-input { + width: 100%; + padding: 14px 12px; + font-size: 22px; + border-radius: 10px; + border: 2px solid #4a6fa5; + background: #121218; + color: #e8e8ec; + margin-bottom: 14px; +} + +.label-input:focus { + outline: none; + border-color: #6b9ee0; +} + +.modal-input { + margin-bottom: 0; + font-size: 16px; +} + +.number-input { + text-align: center; + font-size: 32px; + font-weight: 700; + letter-spacing: 0.04em; +} + +.vkeyboard { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.vk-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: center; +} + +.vk-key { + min-width: 44px; + height: 48px; + padding: 0 12px; + border-radius: 8px; + border: 1px solid #3a3a48; + background: #2a2a34; + color: #e8e8ec; + font-size: 18px; + font-weight: 600; + cursor: pointer; + touch-action: manipulation; +} + +.vk-key:hover { + background: #363642; +} + +.vk-key.wide { + min-width: 120px; +} + +.vk-key.space { + flex: 1; + min-width: 160px; + max-width: 320px; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} + +/* ---------- ProPresenter config (in library head) ---------- */ + +.pp-controls { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid #2a2a32; +} + +.pp-controls-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +.pp-controls-title { + font-size: 13px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9a9aa8; +} + +.pp-controls-buttons { + display: flex; + gap: 8px; +} + +.pp-status { + margin-top: 8px; + min-height: 18px; + font-size: 12px; + color: #9ea7bc; +} + +.pp-playlist-select { + margin-top: 8px; +} + +/* ---------- Studio displays (hosting the Now/Next page on the LAN) ---------- */ + +.display-urls { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + /* A multi-homed machine can offer five or six addresses; keep the list from taking over + the panel on its own. */ + max-height: 168px; + overflow-y: auto; + touch-action: pan-y; +} + +.display-urls:empty { + display: none; +} + +.display-url-row { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 9px; + border: 1px solid #2a2a34; + border-radius: 8px; + background: rgba(26, 26, 32, 0.7); +} + +.display-url-row.is-best { + border-color: #4a6fa5; + background: rgba(74, 111, 165, 0.16); +} + +.display-url-main { + flex: 1; + min-width: 0; +} + +.display-url-text { + font-size: 13px; + font-weight: 700; + color: #cfe0f7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: text; +} + +.display-url-note { + margin-top: 2px; + font-size: 11px; + line-height: 1.35; + color: #8a8a99; +} + +.display-url-row.is-best .display-url-note { + color: #9fb6d6; +} + +.display-port-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 8px; +} + +.display-port-field { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #a0a0ae; +} + +.display-port-field input { + width: 74px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid #33333d; + background: #0f0f13; + color: #e8e8ec; + font-size: 13px; +} + +.display-port-field input:focus { + outline: none; + border-color: #4a6fa5; +} + + +/* ---------- ProPresenter Now / Next column (show mode) ---------- */ + +.pp-column { + display: none; + width: clamp(280px, 26vw, 380px); + min-width: 280px; + flex-shrink: 0; + flex-direction: column; + gap: 14px; + padding: 18px 16px; + background: rgba(16, 16, 20, 0.96); + border-left: 1px solid #2a2a32; + overflow: hidden; +} + +/* Only present in minimal/show mode, and only when ProPresenter has live data. */ +body.chrome-hidden.pp-active .pp-column { + display: flex; +} + +.pp-playlist-name { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #8a8a99; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pp-playlist-name:empty { + display: none; +} + +.pp-now { + border: 1px solid #30303c; + border-radius: 14px; + background: linear-gradient(160deg, rgba(61, 90, 128, 0.22), rgba(26, 26, 34, 0.6)); + padding: 14px 16px; +} + +.pp-now-label { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #7fa8e0; +} + +.pp-now-name { + margin-top: 6px; + /* Size is set dynamically by fitNowTitle() in renderer.js to fill the box; + this is just the pre-fit default. Keep line-height in sync with that helper. */ + font-size: 36px; + font-weight: 700; + line-height: 1.1; + color: #f4f6fb; + /* Wrapping is controlled by fitNowTitle() in renderer.js — it forbids mid-word breaks + and sizes down until whole words fit, so the base must not force break-word. */ + overflow-wrap: normal; + word-break: normal; +} + +.pp-now-meta { + margin-top: 10px; + font-size: clamp(13px, 1.2vw, 17px); + font-weight: 600; + color: #c6cada; + font-variant-numeric: tabular-nums; + /* Never wrap — a 2nd line would resize the title box and flap the fitted title size. */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pp-progress { + margin-top: 10px; + height: 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + overflow: hidden; +} + +.pp-progress-fill { + height: 100%; + border-radius: 999px; + background: #4a6fa5; + transition: width 0.25s ease; +} + +.pp-next-wrap { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.pp-section-label { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #7a7a8a; +} + +.pp-next-list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; +} + +.pp-next-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1px solid #2a2a34; + border-radius: 10px; + background: rgba(26, 26, 32, 0.7); +} + +.pp-next-name { + flex: 1; + min-width: 0; + font-size: clamp(14px, 1.3vw, 19px); + font-weight: 600; + color: #e2e4ec; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.pp-count-chip { + flex-shrink: 0; + min-width: 30px; + padding: 3px 9px; + text-align: center; + border-radius: 999px; + background: rgba(74, 111, 165, 0.28); + color: #cfe0f7; + font-size: 13px; + font-weight: 700; +} + +.pp-type-tag { + flex-shrink: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #8a8a99; +} + +.pp-next-item.is-header { + background: none; + border: none; + border-bottom: 1px solid #2f2f3a; + border-radius: 0; + padding: 10px 2px 4px; +} + +.pp-next-item.is-header .pp-next-name { + font-size: 11px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #8a8a99; + white-space: normal; +} + +.pp-waiting { + font-size: 14px; + color: #7a7a8a; +} + +.pp-error { + margin-top: 6px; + font-size: 11px; + color: #d98c8c; + word-break: break-word; +} diff --git a/tmp/pp-mock.js b/tmp/pp-mock.js new file mode 100644 index 0000000..1a23e2e --- /dev/null +++ b/tmp/pp-mock.js @@ -0,0 +1,198 @@ +/** + * Mock ProPresenter 7 network API for verifying the Band Lineup integration + * without a live ProPresenter. Serves the documented endpoint shapes and a + * chunked /v1/status/updates stream that advances the active slide on a timer. + * + * node tmp/pp-mock.js [port] (default 1599) + * + * Every incoming request is logged so we can confirm what the app fetches. + */ +const http = require('http'); + +const PORT = Number.parseInt(process.argv[2] || '1599', 10); +const STEP_MS = Number.parseInt(process.env.PP_STEP_MS || '1500', 10); + +// Playlist: header + presentations + a media item + a hidden item. +// `slides` = unique master slides returned by /v1/presentation/{uuid} (what ppCountSlides sees). +// `arr` = cues the SELECTED arrangement actually plays (what slide_index + thumbnails respect). +// When arr > slides the arrangement repeats a section (e.g. a chorus) — this is the 43/41 case. +const ITEMS = [ + { uuid: 'I0', name: 'Pre-Service Loop', type: 'header', is_hidden: false, is_pco: false }, + { uuid: 'I1', name: 'Welcome', type: 'presentation', is_hidden: false, is_pco: false, slides: 3 }, + { uuid: 'I2', name: 'This Is The Day - [ Full ]', type: 'presentation', is_hidden: false, is_pco: false, slides: 41, arr: 43 }, + { uuid: 'I3', name: 'Sermon: Living Hope', type: 'presentation', is_hidden: false, is_pco: true, slides: 5 }, + { uuid: 'I4', name: 'Bumper Video', type: 'media', is_hidden: false, is_pco: false }, + { uuid: 'I5', name: 'Closing Song', type: 'presentation', is_hidden: false, is_pco: false, slides: 8 }, + { uuid: 'IH', name: 'Hidden Cue', type: 'presentation', is_hidden: true, is_pco: false, slides: 4 }, + { uuid: 'I6', name: 'Benediction', type: 'presentation', is_hidden: false, is_pco: false, slides: 2 }, +]; +/** Arrangement-expanded cue count for an item (defaults to its master slide count). */ +function arrLen(item) { + return item && item.arr ? item.arr : (item && item.slides ? item.slides : 1); +} +const PLAYLIST = { uuid: 'PL1', name: 'Sunday AM — June 1', index: 0 }; + +// Only presentation items can be "active". Start on Amazing Grace. +const PRESENTATIONS = ITEMS.filter((it) => it.type === 'presentation'); +let activePresIdx = PRESENTATIONS.findIndex((it) => it.uuid === 'I2'); +let slide = 0; + +function activeItem() { + return PRESENTATIONS[activePresIdx]; +} +function playlistIndexOfActive() { + return ITEMS.findIndex((it) => it.uuid === activeItem().uuid); +} +function slideIndexPayload() { + // PP20 shape: top-level `presentation_index`. + const item = activeItem(); + return { + presentation_index: { + index: slide, + presentation_id: { uuid: item.uuid, name: item.name, index: playlistIndexOfActive() }, + }, + }; +} +function presentationPayload(uuid) { + // PP20 shape: details wrapped under `presentation`. + const item = ITEMS.find((it) => it.uuid === uuid); + const n = item && item.slides ? item.slides : 0; + const slides = Array.from({ length: n }, (_s, i) => ({ + enabled: true, + notes: '', + text: `${item ? item.name : ''} — slide ${i + 1}`, + image: '', + label: '', + color: { red: 0, green: 0, blue: 0, alpha: 0 }, + })); + return { + presentation: { + id: { uuid, name: item ? item.name : '', index: 0 }, + name: item ? item.name : '', + index: 0, + groups: [{ name: 'Group', color: {}, slides }], + has_timeline: false, + }, + }; +} +function playlistItemsPayload() { + // ProPresenter 20 style: items carry id{uuid,name,index} + field_type. + return { + id: PLAYLIST, + items: ITEMS.map((it, index) => ({ + id: { uuid: it.uuid, name: it.name, index }, + field_type: it.type, + is_hidden: it.is_hidden, + is_pco: it.is_pco, + })), + }; +} + +function currentItemId() { + const it = activeItem(); + return { uuid: it.uuid, name: it.name, index: ITEMS.findIndex((x) => x.uuid === it.uuid) }; +} +// ProPresenter 20: what's live and what's focused (auto-follow sources). +function activePlaylistPayload() { + return { presentation: { playlist: PLAYLIST, item: currentItemId() }, announcements: { playlist: null, item: null } }; +} +function focusedPlaylistPayload() { + return { playlist: PLAYLIST, item: currentItemId() }; +} + +// ProPresenter 20 /v1/playlists returns a tree of groups → playlists. +function playlistsTreePayload() { + return [ + { + id: { uuid: 'GRP-SERVICES', name: 'Services', index: 0 }, + field_type: 'group', + children: [ + { id: { uuid: PLAYLIST.uuid, name: PLAYLIST.name, index: 0 }, field_type: 'playlist', children: [] }, + { id: { uuid: 'PL-OTHER', name: 'Speaker Pics', index: 1 }, field_type: 'playlist', children: [] }, + ], + }, + ]; +} + +const subscribers = new Set(); +let lastPollAt = 0; // updated when a client polls slide_index, so we also advance under polling clients +function broadcastSlideIndex() { + const line = JSON.stringify({ url: 'presentation/slide_index', data: slideIndexPayload() }) + '\n'; + for (const res of subscribers) res.write(line); +} + +// Advance: walk slides of the active presentation, then jump to the next presentation item. +// Only advance once something is subscribed, so REST snapshots before subscribing are deterministic. +setInterval(() => { + if (subscribers.size === 0 && Date.now() - lastPollAt > 3000) return; + const item = activeItem(); + slide += 1; + if (slide >= arrLen(item)) { + slide = 0; + activePresIdx = (activePresIdx + 1) % PRESENTATIONS.length; + console.log(`[mock] → now presenting "${activeItem().name}"`); + } + broadcastSlideIndex(); +}, STEP_MS); + +function sendJson(res, obj) { + const body = JSON.stringify(obj); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(body); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const p = url.pathname; + console.log(`[mock] ${req.method} ${p}`); + + if (p === '/version') return sendJson(res, { name: 'NA-ProP (mock)', platform: 'mac', host_description: 'ProPresenter 20.0.1', api_version: 'v1' }); + if (p === '/v1/playlists') return sendJson(res, playlistsTreePayload()); + if (p === '/v1/playlist/active') return sendJson(res, activePlaylistPayload()); + if (p === '/v1/playlist/focused') return sendJson(res, focusedPlaylistPayload()); + // Simulate PP20: the items list lives at a renamed route (uuid/dot/colon all 404). + // Services group index 0, First Tuesday index 0 → dash-joined path "0-0". + if (p === '/v1/playlist/0-0') return sendJson(res, playlistItemsPayload()); + if (p === '/v1/presentation/slide_index') { lastPollAt = Date.now(); return sendJson(res, slideIndexPayload()); } + // Thumbnail probe: index "respects the selected arrangement" → valid for 0..arrLen-1, else 404. + const thumb = p.match(/^\/v1\/presentation\/([^/]+)\/thumbnail\/(\d+)$/); + if (thumb) { + const item = ITEMS.find((it) => it.uuid === decodeURIComponent(thumb[1])); + const index = Number.parseInt(thumb[2], 10); + if (item && index >= 0 && index < arrLen(item)) { + res.writeHead(200, { 'Content-Type': 'image/jpeg' }); + return res.end(Buffer.from([0xff, 0xd8, 0xff, 0xd9])); // tiny stub JPEG + } + res.writeHead(404, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: 'index out of range' })); + } + if (p === '/v1/presentation/current' || p === '/v1/presentation/active') return sendJson(res, presentationPayload(activeItem().uuid)); + if (p.startsWith('/v1/presentation/')) { + const uuid = decodeURIComponent(p.split('/').pop()); + return sendJson(res, presentationPayload(uuid)); + } + + if (p === '/v1/status/updates' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + let urls = []; + try { urls = JSON.parse(body); } catch { /* ignore */ } + console.log(`[mock] status/updates subscribe: ${JSON.stringify(urls)}`); + res.writeHead(200, { 'Content-Type': 'application/json', 'Transfer-Encoding': 'chunked' }); + // initial snapshot for each subscribed url + if (urls.includes('playlist/current')) res.write(JSON.stringify({ url: 'playlist/current', data: PLAYLIST }) + '\n'); + if (urls.includes('presentation/slide_index')) res.write(JSON.stringify({ url: 'presentation/slide_index', data: slideIndexPayload() }) + '\n'); + subscribers.add(res); + res.on('close', () => subscribers.delete(res)); + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`[mock] ProPresenter mock API on http://127.0.0.1:${PORT} (slide step ${STEP_MS}ms)`); +}); diff --git a/tmp/pp-verify-slides.js b/tmp/pp-verify-slides.js new file mode 100644 index 0000000..664c60c --- /dev/null +++ b/tmp/pp-verify-slides.js @@ -0,0 +1,47 @@ +// Verifies the slide-count + "remaining" logic (mirrors main.js) against tmp/pp-mock.js. +// The total comes from probing the thumbnail boundary (arrangement-expanded cue count), +// which lives in the SAME index space as slide_index — so "Slide X / Y" can never have X > Y, +// even when the selected arrangement repeats a section (the 43/41 bug this fixes). +const HOST = '127.0.0.1'; +const PORT = Number.parseInt(process.argv[2] || '1599', 10); +async function get(p) { + const r = await fetch(`http://${HOST}:${PORT}${p}`, { headers: { Accept: 'application/json' } }); + if (!r.ok) throw new Error(`${p} -> ${r.status}`); + return r.json(); +} +function countSlides(d) { + const p = d?.presentation || d; + const groups = Array.isArray(p?.groups) ? p.groups : []; + let c = 0; + for (const g of groups) c += Array.isArray(g?.slides) ? g.slides.length : 0; + return c; +} +// Mirror of ppProbeArrangementLength: smallest thumbnail index that 404s = cue count. +async function status(p) { + const r = await fetch(`http://${HOST}:${PORT}${p}`, { headers: { Accept: 'image/jpeg' } }); + return r.status; +} +async function probeArrangementLength(uuid) { + const exists = async (i) => (await status(`/v1/presentation/${encodeURIComponent(uuid)}/thumbnail/${i}`)) === 200; + if (!(await exists(0))) return 0; + let lo = 0; let hi = 1; + while (await exists(hi)) { lo = hi; hi *= 2; if (hi > 4000) return null; } + while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (await exists(mid)) lo = mid; else hi = mid; } + return lo + 1; +} +(async () => { + const si = await get('/v1/presentation/slide_index'); + const node = si?.presentation_index || si?.presentation; + const current = node && Number.isInteger(node.index) ? node.index + 1 : 0; + const uuid = node?.presentation_id?.uuid || ''; + const det = await get(`/v1/presentation/${encodeURIComponent(uuid)}`); + const master = countSlides(det); + const total = await probeArrangementLength(uuid); + const remaining = Math.max(0, total - current); + console.log(`live presentation uuid: ${uuid}`); + console.log(`master slides (/presentation/{uuid}): ${master} arrangement cues (thumbnail probe): ${total}`); + console.log(`NOW card → "Slide ${current} / ${total} · ${remaining} left"`); + const ok = current > 0 && total > 0 && total >= current && remaining === total - current; + console.log(ok ? '\n✅ total matches the slide_index space (X never exceeds Y)' : '\n❌ FAILED'); + process.exit(ok ? 0 : 1); +})().catch((e) => { console.error('error:', e.message); process.exit(2); }); diff --git a/tmp/pp-verify.js b/tmp/pp-verify.js new file mode 100644 index 0000000..75b4a57 --- /dev/null +++ b/tmp/pp-verify.js @@ -0,0 +1,139 @@ +/** + * Verifies the ProPresenter data pipeline used by main.js against tmp/pp-mock.js. + * Mirrors the main-process client logic (stream parsing, slide-count summing, + * active-item matching, NOW/NEXT assembly) and asserts the resulting view-model. + * + * node tmp/pp-verify.js [port] + */ +const HOST = '127.0.0.1'; +const PORT = Number.parseInt(process.argv[2] || '1599', 10); + +let failures = 0; +function check(label, cond) { + console.log(`${cond ? 'PASS' : 'FAIL'} ${label}`); + if (!cond) failures += 1; +} + +// ---- client logic mirrored from main.js ---- +const slideCountCache = new Map(); +let state = { reachable: false, playlistUuid: '', playlistName: '', items: [], activeUuid: '', activeName: '', activeIndex: -1, currentSlide: 0 }; + +function ppUrl(target) { return `http://${HOST}:${PORT}${target}`; } +async function ppRequestJson(target, init) { + const r = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, ...init }); + if (!r.ok) throw new Error(`${r.status}`); + const t = await r.text(); + return t ? JSON.parse(t) : null; +} +async function ppSlideCount(uuid) { + if (!uuid) return null; + if (slideCountCache.has(uuid)) return slideCountCache.get(uuid); + try { + const data = await ppRequestJson(`/v1/presentation/${encodeURIComponent(uuid)}`); + let count = 0; + for (const g of data?.groups || []) for (const s of g?.slides || []) if (s?.enabled !== false) count += 1; + slideCountCache.set(uuid, count); + return count; + } catch { return null; } +} +function recomputeActiveIndex() { + state.activeIndex = state.activeUuid ? state.items.findIndex((it) => it.uuid && it.uuid === state.activeUuid) : -1; +} +async function refreshCurrentPlaylist() { + const cur = await ppRequestJson('/v1/playlist/current'); + state.playlistUuid = String(cur?.uuid || ''); + state.playlistName = String(cur?.name || ''); + if (!state.playlistUuid) { state.items = []; recomputeActiveIndex(); return; } + const data = await ppRequestJson(`/v1/playlist/${encodeURIComponent(state.playlistUuid)}`); + state.items = (data?.items || []).map((it) => ({ + uuid: String(it?.id?.uuid || ''), name: String(it?.id?.name || ''), type: String(it?.type || ''), + isHidden: Boolean(it?.is_hidden), isPco: Boolean(it?.is_pco), slideCount: null, + })); + recomputeActiveIndex(); +} +async function fillSlideCounts() { + for (const it of state.items) if (it.type === 'presentation' && it.uuid && it.slideCount === null) it.slideCount = await ppSlideCount(it.uuid); +} +function applySlideIndex(data) { + const pres = data?.presentation; + if (!pres || !pres.presentation_id) { state.activeUuid = ''; state.activeName = ''; state.currentSlide = 0; recomputeActiveIndex(); return; } + state.currentSlide = Number.isInteger(pres.index) ? pres.index + 1 : 0; + state.activeUuid = String(pres.presentation_id.uuid || ''); + state.activeName = String(pres.presentation_id.name || ''); + recomputeActiveIndex(); +} +async function viewModel() { + let total = 0; + if (state.activeUuid) { const c = slideCountCache.get(state.activeUuid); total = Number.isInteger(c) ? c : (await ppSlideCount(state.activeUuid)) || 0; } + return { reachable: state.reachable, playlistName: state.playlistName, items: state.items, activeIndex: state.activeIndex, activePresentationName: state.activeName, currentSlide: state.currentSlide, currentSlideTotal: total }; +} +function describe(vm) { + const now = vm.activeIndex >= 0 ? vm.items[vm.activeIndex].name : vm.activePresentationName; + const from = vm.activeIndex >= 0 ? vm.activeIndex + 1 : 0; + const next = vm.items.slice(from).filter((it) => !it.isHidden) + .map((it) => it.type === 'presentation' ? `${it.name} (${it.slideCount})` : `${it.name} [${it.type}]`); + return `NOW: ${now} — slide ${vm.currentSlide}/${vm.currentSlideTotal}\n NEXT: ${next.join(' · ')}`; +} + +async function main() { + await refreshCurrentPlaylist(); + applySlideIndex(await ppRequestJson('/v1/presentation/slide_index')); + await fillSlideCounts(); + state.reachable = true; + const vm = await viewModel(); + console.log('\n--- initial view-model ---\n' + describe(vm) + '\n'); + + const byName = (n) => vm.items.find((it) => it.name === n); + check('playlist name', vm.playlistName === 'Sunday AM — June 1'); + check('item count = 8', vm.items.length === 8); + check('Welcome slides = 3', byName('Welcome').slideCount === 3); + check('Amazing Grace slides = 12', byName('Amazing Grace').slideCount === 12); + check('Sermon slides = 5', byName('Sermon: Living Hope').slideCount === 5); + check('Closing slides = 8', byName('Closing Song').slideCount === 8); + check('Benediction slides = 2', byName('Benediction').slideCount === 2); + check('media item has no count', byName('Bumper Video').slideCount === null); + check('header item has no count', byName('Pre-Service Loop').slideCount === null); + check('active is Amazing Grace', vm.activeIndex === vm.items.findIndex((it) => it.name === 'Amazing Grace')); + check('current slide >= 1', vm.currentSlide >= 1); + check('current total = 12', vm.currentSlideTotal === 12); + const from = vm.activeIndex + 1; + const nextNames = vm.items.slice(from).filter((it) => !it.isHidden).map((it) => it.name); + check('NEXT excludes hidden cue', !nextNames.includes('Hidden Cue')); + check('NEXT starts with Sermon', nextNames[0] === 'Sermon: Living Hope'); + + // Now exercise the live chunked stream for a few updates. + console.log('\n--- streaming live updates ---'); + setTimeout(() => { console.error('TIMEOUT waiting for stream updates'); process.exit(3); }, 8000); + const res = await fetch(ppUrl('/v1/status/updates'), { + method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify(['presentation/slide_index', 'playlist/current']), + }); + check('stream opened (chunked)', res.ok && !!res.body); + let updates = 0; + const seenSlides = []; + let buffer = ''; + const decoder = new TextDecoder(); + for await (const chunk of res.body) { + buffer += decoder.decode(chunk, { stream: true }); + let nl; + while ((nl = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, nl).trim(); + buffer = buffer.slice(nl + 1); + if (!line) continue; + const msg = JSON.parse(line); + if (String(msg.url).includes('presentation/slide_index')) { + applySlideIndex(msg.data); + seenSlides.push(state.currentSlide); + console.log(describe(await viewModel()).split('\n')[0]); + updates += 1; + } + } + if (updates >= 4) break; + } + check('received >= 4 live slide updates', updates >= 4); + check('slide value changed across updates', new Set(seenSlides).size >= 2); + + console.log(`\n${failures === 0 ? '✅ ALL CHECKS PASSED' : `❌ ${failures} CHECK(S) FAILED`}`); + process.exit(failures === 0 ? 0 : 1); +} +main().catch((e) => { console.error('verify error:', e); process.exit(2); }); diff --git a/x b/x new file mode 100644 index 0000000..81253ff --- /dev/null +++ b/x @@ -0,0 +1 @@ +[{"id":"s00","sym":"","text":"Dios es santo hoy."},{"id":"s01","sym":"*","text":"Dios es * santo hoy."},{"id":"s02","sym":"^","text":"Dios es ^ santo hoy."},{"id":"s03","sym":"~","text":"Dios es ~ santo hoy."},{"id":"s04","sym":"#","text":"Dios es # santo hoy."},{"id":"s05","sym":"&","text":"Dios es & santo hoy."},{"id":"s06","sym":"%","text":"Dios es % santo hoy."},{"id":"s07","sym":"©","text":"Dios es © santo hoy."},{"id":"s08","sym":"@","text":"Dios es @ santo hoy."},{"id":"s09","sym":"$","text":"Dios es $ santo hoy."},{"id":"s10","sym":"/","text":"Dios es / santo hoy."},{"id":"s11","sym":"°","text":"Dios es ° santo hoy."},{"id":"s12","sym":"€","text":"Dios es € santo hoy."},{"id":"s13","sym":"£","text":"Dios es £ santo hoy."},{"id":"s14","sym":"§","text":"Dios es § santo hoy."},{"id":"s15","sym":"¶","text":"Dios es ¶ santo hoy."},{"id":"s16","sym":"+","text":"Dios es + santo hoy."},{"id":"s17","sym":"=","text":"Dios es = santo hoy."},{"id":"s18","sym":"_","text":"Dios es _ santo hoy."},{"id":"s19","sym":"[","text":"Dios es [ santo hoy."},{"id":"s20","sym":"]","text":"Dios es ] santo hoy."},{"id":"s21","sym":"(","text":"Dios es ( santo hoy."},{"id":"s22","sym":")","text":"Dios es ) santo hoy."},{"id":"s23","sym":"<","text":"Dios es < santo hoy."},{"id":"s24","sym":">","text":"Dios es > santo hoy."},{"id":"s25","sym":"\\","text":"Dios es \\ santo hoy."},{"id":"s26","sym":"{","text":"Dios es { santo hoy."},{"id":"s27","sym":"}","text":"Dios es } santo hoy."},{"id":"s28","sym":"\"","text":"Dios es \" santo hoy."},{"id":"s29","sym":"«","text":"Dios es « santo hoy."},{"id":"s30","sym":"•","text":"Dios es • santo hoy."},{"id":"s31","sym":"×","text":"Dios es × santo hoy."},{"id":"s32","sym":"÷","text":"Dios es ÷ santo hoy."},{"id":"s33","sym":"¥","text":"Dios es ¥ santo hoy."},{"id":"s34","sym":"№","text":"Dios es № santo hoy."},{"id":"s35","sym":"…","text":"Dios es … santo hoy."},{"id":"s36","sym":"¡","text":"Dios es ¡ santo hoy."},{"id":"s37","sym":"¿","text":"Dios es ¿ santo hoy."},{"id":"s38","sym":"|","text":"Dios es | santo hoy."},{"id":"s39","sym":"`","text":"Dios es ` santo hoy."},{"id":"s40","sym":"'","text":"Dios es ' santo hoy."},{"id":"s41","sym":"’","text":"Dios es ’ santo hoy."},{"id":"s42","sym":"-","text":"Dios es - santo hoy."},{"id":"s43","sym":"—","text":"Dios es — santo hoy."}] \ No newline at end of file