Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,42 @@ to be fixed:
list with a chip. A ~173px column has no room to both truncate and stay
readable, so dual-column names wrap as they did before the chips existed.

### Capture-card health and the log file

The wall is Windows 11 with two PCIe **Elgato 4K60 Pro MK.2** cards. Its startup
script (Mosaic, then Warp, then the cards, then the app) lives in
`LAB271/labs-videowall-config`, not here: this repo is the application only.
A card sometimes comes up showing black with a live source plugged in. The
**split-flap never shows for this**: the wall has no no-signal references on
purpose, and the stream opened fine.

`src/renderer/stream-health.js` is the policy (pure, unit tested) and
`renderer.js` samples it every 2s:

| Status | Evidence | Reopen schedule |
|---|---|---|
| `open-failed` / `ended` / `no-frames` / `stalled` | getUserMedia, track state, frame counter | 2s, 5s, 15s, 30s, then every 60s |
| `dark` | 32x18 luma thumbnail is one flat colour for 6s | 5s ... then every 10 min |

Three things worth not re-learning:

- **It counts frames and never measures motion.** A held slide still arrives at
60fps, so this does not reopen the #159 question. A stop only counts as a stall
if the feed was *flowing* first, which keeps a static virtual camera (which
really does send only one frame) from being reopened in a loop.
- **A reopen releases every side on the device first.** Chromium shares one
capture session per device, so reopening one side while the other still holds a
track never closes the device. On the wall both panels usually show the same card.
- **The driver exposes no signal-lock state** (registry and services checked).
The picture is the only evidence. Reopening a flat feed is safe because its
blank moment cannot be seen.

Everything either process prints goes to
`<userData>/logs/input-viewer-YYYY-MM-DD.log` (`src/main/file-log.js`): 7 days
kept, 20 MB/day cap, 600 lines/min. Renderer lines arrive through
`console-message`, so an object argument logs as `[object Object]`. Log a flat
string when the line matters. `__health()` in DevTools prints the current state.

### The test-mode launch flags (#248)

| Flag | Effect |
Expand Down
168 changes: 168 additions & 0 deletions input_viewer_electron/src/main/file-log.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2025-2026 Schuberg Philis / Lab271
/**
* Persistent app log: one file per day, kept for a week.
*
* Before this, nothing the app printed survived it. The wall came up with a dark
* capture card, someone restarted the app ten minutes later, and there was no way
* to tell afterwards what the renderer had seen. This is the record.
*
* Bounded three ways, because the wall runs for months:
* - files older than `retentionDays` are deleted (on start and at each day change)
* - a day's file stops growing at `maxBytesPerDay`, with one line saying so
* - more than `maxLinesPerMinute` lines in a minute are dropped and counted,
* so a log loop cannot fill the disk before the day cap even matters
* Worst case on disk is retentionDays x maxBytesPerDay; the default is ~140 MB and
* a normal day is a few hundred KB.
*
* Synchronous appends on purpose: a crash or a hard power-off is exactly when
* the last lines matter, and at the rate limit the cost is nothing.
*/
const fs = require('fs')
const path = require('path')

const FILE_RE = /^input-viewer-(\d{4})-(\d{2})-(\d{2})\.log$/

function pad(n) {
return String(n).padStart(2, '0')
}

/** Local-date stamp, so a file is one wall-clock day where the wall is. */
function dayStamp(d) {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}

function timeStamp(d) {
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.` +
String(d.getMilliseconds()).padStart(3, '0')
}

/**
* @param {{dir: string, retentionDays?: number, maxBytesPerDay?: number,
* maxLinesPerMinute?: number, maxLineLength?: number, now?: () => Date}} opts
*/
function createFileLog({
dir,
retentionDays = 7,
maxBytesPerDay = 20 * 1024 * 1024,
maxLinesPerMinute = 600,
maxLineLength = 2000,
now = () => new Date(),
}) {
let day = null
let file = null
let bytes = 0
let capped = false
let minute = null
let linesThisMinute = 0
let dropped = 0
let broken = false

function prune(today) {
let names
try {
names = fs.readdirSync(dir)
} catch {
return []
}
// Compare calendar days, not mtimes: a file touched late is still that day.
const cutoff = new Date(today.getFullYear(), today.getMonth(), today.getDate() - (retentionDays - 1))
const removed = []
for (const name of names) {
const m = FILE_RE.exec(name)
if (!m) continue
const fileDay = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
if (fileDay < cutoff) {
try {
fs.unlinkSync(path.join(dir, name))
removed.push(name)
} catch { /* in use or gone; try again tomorrow */ }
}
}
return removed
}

function rollIfNeeded(d) {
const stamp = dayStamp(d)
if (stamp === day) return
day = stamp
file = path.join(dir, `input-viewer-${stamp}.log`)
capped = false
try {
fs.mkdirSync(dir, { recursive: true })
bytes = fs.existsSync(file) ? fs.statSync(file).size : 0
} catch {
bytes = 0
}
prune(d)
}

function append(text) {
if (broken) return
try {
fs.appendFileSync(file, text)
bytes += Buffer.byteLength(text)
} catch {
// A log that cannot be written must never take the app down with it.
// Stop trying rather than throw on every console.log.
broken = true
}
}

return {
/**
* @param {string} level info | warn | error | debug
* @param {string} source main | renderer | ...
* @param {string} message
*/
write(level, source, message) {
const d = now()
rollIfNeeded(d)

const m = Math.floor(d.getTime() / 60_000)
if (m !== minute) {
if (dropped > 0 && !capped) {
append(`${timeStamp(d)} WARN [log] dropped ${dropped} line(s) over ` +
`${maxLinesPerMinute}/min in the previous minute\n`)
}
minute = m
linesThisMinute = 0
dropped = 0
}
if (linesThisMinute >= maxLinesPerMinute) {
dropped += 1
return
}
linesThisMinute += 1

if (capped) return
if (bytes >= maxBytesPerDay) {
capped = true
append(`${timeStamp(d)} WARN [log] ${maxBytesPerDay} byte daily cap reached; ` +
'nothing more is written until tomorrow\n')
return
}

let text = String(message)
if (text.length > maxLineLength) text = text.slice(0, maxLineLength) + '...(truncated)'
// One record per line, so a multi-line stack does not interleave with others.
text = text.replace(/\r?\n/g, '\n ')
append(`${timeStamp(d)} ${level.toUpperCase().padEnd(5)} [${source}] ${text}\n`)
},

/** Delete files past retention. Returns the names removed. */
prune() {
return prune(now())
},

/** Today's file. */
path() {
rollIfNeeded(now())
return file
},

dir,
}
}

module.exports = { createFileLog, dayStamp }
60 changes: 60 additions & 0 deletions input_viewer_electron/src/main/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
const { app, BrowserWindow, ipcMain, systemPreferences, dialog, shell, screen } = require('electron')
const path = require('path')
const fs = require('fs')
const os = require('os')
const { exec } = require('child_process')
const { createFileLog } = require('./file-log.js')

// Hardware acceleration for video decode/rendering
app.commandLine.appendSwitch('ignore-gpu-blocklist')
Expand Down Expand Up @@ -77,6 +79,40 @@ function getAppVersion() {
// Settings file path
const settingsPath = path.join(app.getPath('userData'), 'settings.json')

// Persistent log (see file-log.js): <userData>/logs/input-viewer-YYYY-MM-DD.log,
// seven days kept. Main's own console is teed into it here; the renderer's
// console arrives through 'console-message' in createWindow().
const fileLog = createFileLog({ dir: path.join(app.getPath('userData'), 'logs') })

for (const [method, level] of [['log', 'info'], ['info', 'info'], ['warn', 'warn'], ['error', 'error']]) {
const original = console[method].bind(console)
console[method] = (...args) => {
original(...args)
fileLog.write(level, 'main', args.map(formatLogArg).join(' '))
}
}

function formatLogArg(arg) {
if (typeof arg === 'string') return arg
if (arg instanceof Error) return arg.stack || `${arg.name}: ${arg.message}`
try {
return JSON.stringify(arg)
} catch {
return String(arg)
}
}

// Renderer console levels: Electron 43 passes a string on the event object;
// older versions passed a number as the second argument.
const RENDERER_LEVELS = { 0: 'debug', 1: 'info', 2: 'warn', 3: 'error',
verbose: 'debug', info: 'info', warning: 'warn', error: 'error', debug: 'debug' }

// Startup banner. OS uptime is here because the question after a bad boot is
// usually "how long after the machine came up did the app open the cards".
console.log(`[App] Input Viewer ${app.getVersion()} starting; ` +
`${process.platform} ${os.release()} ${process.arch}, electron ${process.versions.electron}, ` +
`OS up ${Math.round(os.uptime())}s, pid ${process.pid}, log ${fileLog.dir}`)

// Default settings
const defaultSettings = {
leftDeviceId: null,
Expand Down Expand Up @@ -226,6 +262,22 @@ function createWindow() {
callback(allowed)
})

// Everything the renderer prints goes to the log file too. Debug level is left
// out: it is DevTools-only chatter and would only eat the rate limit.
mainWindow.webContents.on('console-message', (event, legacyLevel, legacyMessage) => {
const level = RENDERER_LEVELS[event?.level ?? legacyLevel] ?? 'info'
if (level === 'debug') return
fileLog.write(level, 'renderer', event?.message ?? legacyMessage ?? '')
})

// The failures that leave a black wall with nothing in the renderer's own log,
// because the renderer is the thing that died.
mainWindow.webContents.on('render-process-gone', (_event, details) => {
console.error(`[App] renderer process gone: ${details.reason} (exit ${details.exitCode})`)
})
mainWindow.on('unresponsive', () => console.warn('[App] window unresponsive'))
mainWindow.on('responsive', () => console.log('[App] window responsive again'))

// Handle window closed
mainWindow.on('closed', () => {
mainWindow = null
Expand Down Expand Up @@ -271,6 +323,14 @@ app.whenReady().then(async () => {
})
})

// GPU and utility processes: the video decode and capture paths run in these.
app.on('child-process-gone', (_event, details) => {
console.error(`[App] ${details.type} process gone: ${details.reason} ` +
`(exit ${details.exitCode}${details.name ? `, ${details.name}` : ''})`)
})

app.on('before-quit', () => console.log('[App] quitting'))

// Quit when all windows are closed (except on macOS)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
Expand Down
Loading
Loading