diff --git a/app/main.js b/app/main.js index 73f7b70..755d180 100644 --- a/app/main.js +++ b/app/main.js @@ -19,6 +19,19 @@ const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots'); const { readLock } = require('../core/locks'); const CaptureService = require('./capture'); +const { keepProcessesResponsive } = require('./win-power'); + +// Keep capture working on battery. In a power-saving plan on DC power, Windows +// applies Power Throttling (EcoQoS) to background work — and StepForge records +// with its window hidden, so the frame-capture worker renderer is exactly the +// kind of "background" process the OS slows down. A throttled worker can't +// sample the screen fast enough, so every click finds no fresh frame and the +// recording falls apart (the bug only ever reproduced on battery). These +// switches stop Chromium from de-prioritising and timer-throttling the hidden +// worker; win-power.js additionally opts the OS processes out of EcoQoS. +app.commandLine.appendSwitch('disable-background-timer-throttling'); +app.commandLine.appendSwitch('disable-renderer-backgrounding'); +app.commandLine.appendSwitch('disable-backgrounding-occluded-windows'); /** * StepForge main process. Zero network code: no telemetry, no updates, no @@ -510,16 +523,28 @@ function setupIpc() { } }; + // Opt every live Electron process (browser, GPU, the screen-capture utility, + // any renderers) out of EcoQoS for the duration of a recording. The hidden + // capture-worker renderer is created later, during warmup, so it opts itself + // out separately (see stream-backend.js); this covers the rest. + const keepCaptureProcessesResponsive = () => { + try { + keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid)); + } catch { /* metrics unavailable — best effort */ } + }; + h('capture:session', async ({ action, guideId, intervalSec }) => { if (action === 'start') { capture.startSession(guideId, { intervalSec: intervalSec ?? null }); startCapturePower(); + keepCaptureProcessesResponsive(); } else if (action === 'pause') { capture.togglePause(true); stopCapturePower(); } else if (action === 'resume') { capture.togglePause(false); startCapturePower(); + keepCaptureProcessesResponsive(); } else if (action === 'finish') { capture.finishSession(); stopCapturePower(); diff --git a/app/stream-backend.js b/app/stream-backend.js index 97f9032..dcf7369 100644 --- a/app/stream-backend.js +++ b/app/stream-backend.js @@ -340,6 +340,14 @@ async function createElectronHost(onEvent) { if (!win.isDestroyed()) win.destroy(); throw err; } + // The worker is a hidden renderer, so on battery Windows would EcoQoS-throttle + // it and starve frame sampling. Clear that on its own OS process before it + // starts streaming. Best-effort; no-op off Windows. + try { + // eslint-disable-next-line global-require + const { keepProcessesResponsive } = require('./win-power'); + if (!win.isDestroyed()) keepProcessesResponsive([win.webContents.getOSProcessId()]); + } catch { /* throttling tweak is optional */ } return { send(msg) { if (!win.isDestroyed()) win.webContents.send('capture-worker:command', msg); diff --git a/app/win-power.js b/app/win-power.js new file mode 100644 index 0000000..53f2c99 --- /dev/null +++ b/app/win-power.js @@ -0,0 +1,92 @@ +'use strict'; + +const { spawn } = require('node:child_process'); + +/** + * Opt a set of OS processes out of Windows Power Throttling (EcoQoS) and raise + * them to high priority, so the capture pipeline keeps running at full CPU + * speed while the laptop is on battery in a power-saving plan. + * + * Why this exists: during a recording StepForge hides its window, so Windows + * treats the frame-capture worker renderer (and the GPU / screen-capture + * utility processes feeding it) as background work and CPU-throttles them on + * DC power. Throttled, the worker can't sample frames fast enough — every + * click then finds no fresh pre-click frame and falls back to a slow + * post-click shot, which is what broke recordings on battery only. + * + * The Chromium command-line switches in main.js stop Chromium's own + * backgrounding; this goes one level lower and clears the OS EcoQoS flag via + * SetProcessInformation(ProcessPowerThrottling, EXECUTION_SPEED → off), which + * has no Node binding, so we drive it through a short-lived PowerShell. + * + * Best-effort and fire-and-forget: any failure (older Windows without the API, + * PowerShell blocked by policy, a process that already exited) is swallowed — + * the Chromium switches still apply and capture degrades gracefully. + * + * No-op on every non-Windows platform. + * + * @param {number[]} pids OS process ids to keep responsive. + */ +function keepProcessesResponsive(pids) { + if (process.platform !== 'win32') return; + // Only integers reach the script, so the interpolation below can't inject. + const list = [...new Set((pids || []).filter((p) => Number.isInteger(p) && p > 0))]; + if (!list.length) return; + + const ps = ` +$ErrorActionPreference = 'SilentlyContinue' +Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; + +public static class SFPower { + [StructLayout(LayoutKind.Sequential)] + struct PROCESS_POWER_THROTTLING_STATE { public uint Version; public uint ControlMask; public uint StateMask; } + + const int ProcessPowerThrottling = 4; + const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1; + const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1; + const uint HIGH_PRIORITY_CLASS = 0x00000080; + const uint PROCESS_SET_INFORMATION = 0x0200; + const uint PROCESS_QUERY_INFORMATION = 0x0400; + + [DllImport("kernel32.dll", SetLastError=true)] + static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll", SetLastError=true)] + static extern bool SetProcessInformation(IntPtr h, int cls, ref PROCESS_POWER_THROTTLING_STATE info, uint size); + [DllImport("kernel32.dll")] + static extern bool SetPriorityClass(IntPtr h, uint cls); + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr h); + + public static void Apply(uint pid) { + IntPtr h = OpenProcess(PROCESS_SET_INFORMATION | PROCESS_QUERY_INFORMATION, false, pid); + if (h == IntPtr.Zero) return; + try { + PROCESS_POWER_THROTTLING_STATE s = new PROCESS_POWER_THROTTLING_STATE(); + s.Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION; + s.ControlMask = PROCESS_POWER_THROTTLING_EXECUTION_SPEED; + s.StateMask = 0; // 0 => throttling off => opt out of EcoQoS + SetProcessInformation(h, ProcessPowerThrottling, ref s, (uint)Marshal.SizeOf(s)); + SetPriorityClass(h, HIGH_PRIORITY_CLASS); + } finally { CloseHandle(h); } + } +} +'@ +${list.map((p) => `[SFPower]::Apply(${p})`).join('\n')} +`; + + try { + const child = spawn( + 'powershell.exe', + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps], + { stdio: 'ignore', windowsHide: true }, + ); + child.on('error', () => { /* PowerShell missing/blocked — best effort */ }); + child.unref(); + } catch { + // spawn itself failed; the Chromium switches remain in effect. + } +} + +module.exports = { keepProcessesResponsive };