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; });